[feat](trx-logbook): work a contest, and record what came back #60
@@ -31,6 +31,16 @@ var importFile = el("log-import-file");
|
|||||||
var exportLink = el("log-export-btn");
|
var exportLink = el("log-export-btn");
|
||||||
var clearBtn = el("log-clear-btn");
|
var clearBtn = el("log-clear-btn");
|
||||||
var saveBtn = el("log-save-btn");
|
var saveBtn = el("log-save-btn");
|
||||||
|
var contestIdInput = el("log-contest-id");
|
||||||
|
var stxInput = el("log-stx");
|
||||||
|
var srxInput = el("log-srx");
|
||||||
|
var statisticsRows = el("log-statistics-rows");
|
||||||
|
var cabrilloContest = el("log-cbr-contest");
|
||||||
|
var cabrilloCallsign = el("log-cbr-callsign");
|
||||||
|
var cabrilloOperator = el("log-cbr-operator");
|
||||||
|
var cabrilloPower = el("log-cbr-power");
|
||||||
|
var cabrilloScore = el("log-cbr-score");
|
||||||
|
var cabrilloExport = el("log-cbr-export");
|
||||||
var entryStartedAt = null;
|
var entryStartedAt = null;
|
||||||
var entryRigId = null;
|
var entryRigId = null;
|
||||||
var entryRigName = null;
|
var entryRigName = null;
|
||||||
@@ -55,6 +65,12 @@ function parseFreq(text) {
|
|||||||
if (!Number.isFinite(value) || value <= 0) return null;
|
if (!Number.isFinite(value) || value <= 0) return null;
|
||||||
return value < 1e5 ? Math.round(value * 1e6) : Math.round(value);
|
return value < 1e5 ? Math.round(value * 1e6) : Math.round(value);
|
||||||
}
|
}
|
||||||
|
function numberOrNull(text) {
|
||||||
|
const trimmed = (text ?? "").trim();
|
||||||
|
if (!trimmed || !/^\d+$/.test(trimmed)) return null;
|
||||||
|
const value = Number(trimmed);
|
||||||
|
return Number.isSafeInteger(value) ? value : null;
|
||||||
|
}
|
||||||
function utcDate(iso) {
|
function utcDate(iso) {
|
||||||
const date = new Date(iso);
|
const date = new Date(iso);
|
||||||
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
|
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
|
||||||
@@ -96,7 +112,7 @@ function showClock(prefill) {
|
|||||||
clockEl.classList.toggle("is-adrift", drift > 1e3);
|
clockEl.classList.toggle("is-adrift", drift > 1e3);
|
||||||
}
|
}
|
||||||
function resetEntry() {
|
function resetEntry() {
|
||||||
for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput]) {
|
for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput, srxInput]) {
|
||||||
if (input) input.value = "";
|
if (input) input.value = "";
|
||||||
}
|
}
|
||||||
if (workedEl) workedEl.textContent = "";
|
if (workedEl) workedEl.textContent = "";
|
||||||
@@ -131,6 +147,13 @@ async function submitEntry(event) {
|
|||||||
gridsquare: gridInput?.value ?? null,
|
gridsquare: gridInput?.value ?? null,
|
||||||
name: nameInput?.value ?? null,
|
name: nameInput?.value ?? null,
|
||||||
comment: commentInput?.value ?? null,
|
comment: commentInput?.value ?? null,
|
||||||
|
contest_id: contestIdInput?.value ?? null,
|
||||||
|
// The exchange is a number when it is a serial and a word when it is a
|
||||||
|
// zone or a section; both are kept, and the log writes whichever it has.
|
||||||
|
stx: numberOrNull(stxInput?.value),
|
||||||
|
stx_string: numberOrNull(stxInput?.value) == null ? stxInput?.value ?? null : null,
|
||||||
|
srx: numberOrNull(srxInput?.value),
|
||||||
|
srx_string: numberOrNull(srxInput?.value) == null ? srxInput?.value ?? null : null,
|
||||||
station_callsign: stationCallEl?.textContent?.trim() ?? null,
|
station_callsign: stationCallEl?.textContent?.trim() ?? null,
|
||||||
operator: operatorInput?.value ?? null,
|
operator: operatorInput?.value ?? null,
|
||||||
my_gridsquare: entryGrid,
|
my_gridsquare: entryGrid,
|
||||||
@@ -149,7 +172,9 @@ async function submitEntry(event) {
|
|||||||
throw new Error(detail.error ?? `HTTP ${String(response.status)}`);
|
throw new Error(detail.error ?? `HTTP ${String(response.status)}`);
|
||||||
}
|
}
|
||||||
notify(`${call} logged`);
|
notify(`${call} logged`);
|
||||||
|
const sent = numberOrNull(stxInput?.value);
|
||||||
resetEntry();
|
resetEntry();
|
||||||
|
if (stxInput && sent != null) stxInput.value = String(sent + 1);
|
||||||
await refreshLog();
|
await refreshLog();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error");
|
notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
@@ -201,10 +226,51 @@ async function refreshLog() {
|
|||||||
summaryEl.textContent = query ? `${String(qsos.length)} of ${String(answer.total)} contacts` : `${String(answer.total)} contact${answer.total === 1 ? "" : "s"}`;
|
summaryEl.textContent = query ? `${String(qsos.length)} of ${String(answer.total)} contacts` : `${String(answer.total)} contact${answer.total === 1 ? "" : "s"}`;
|
||||||
}
|
}
|
||||||
if (exportLink) exportLink.href = `/api/logbook/export.adi${query ? `?${query}` : ""}`;
|
if (exportLink) exportLink.href = `/api/logbook/export.adi${query ? `?${query}` : ""}`;
|
||||||
|
await refreshStatistics();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("logbook read failed", error);
|
console.error("logbook read failed", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function refreshStatistics() {
|
||||||
|
if (!statisticsRows) return;
|
||||||
|
try {
|
||||||
|
const answer = await getJson("/api/logbook/statistics");
|
||||||
|
if (answer.bands.length === 0) {
|
||||||
|
statisticsRows.innerHTML = '<tr><td colspan="4" class="log-empty">Nothing worked yet.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const band of answer.bands) {
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
for (const value of [band.band, band.contacts, band.stations, band.confirmed]) {
|
||||||
|
const cell = document.createElement("td");
|
||||||
|
cell.textContent = String(value);
|
||||||
|
row.appendChild(cell);
|
||||||
|
}
|
||||||
|
fragment.appendChild(row);
|
||||||
|
}
|
||||||
|
statisticsRows.replaceChildren(fragment);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("logbook statistics failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function syncCabrilloLink() {
|
||||||
|
if (!cabrilloExport) return;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const contest = cabrilloContest?.value.trim();
|
||||||
|
if (contest) {
|
||||||
|
params.set("contest", contest);
|
||||||
|
}
|
||||||
|
const callsign = cabrilloCallsign?.value.trim() || (stationCallEl?.textContent?.trim() ?? "");
|
||||||
|
if (callsign) params.set("callsign", callsign);
|
||||||
|
if (cabrilloOperator?.value) params.set("category_operator", cabrilloOperator.value);
|
||||||
|
if (cabrilloPower?.value) params.set("category_power", cabrilloPower.value);
|
||||||
|
const score = numberOrNull(cabrilloScore?.value);
|
||||||
|
if (score != null) params.set("claimed_score", String(score));
|
||||||
|
const operator = operatorInput?.value.trim();
|
||||||
|
if (operator) params.set("operators", operator);
|
||||||
|
cabrilloExport.href = `/api/logbook/export.cbr?${params.toString()}`;
|
||||||
|
}
|
||||||
function renderRows() {
|
function renderRows() {
|
||||||
if (!rowsBody) return;
|
if (!rowsBody) return;
|
||||||
if (qsos.length === 0) {
|
if (qsos.length === 0) {
|
||||||
@@ -224,7 +290,8 @@ function renderRows() {
|
|||||||
qso.rst_sent ?? "",
|
qso.rst_sent ?? "",
|
||||||
qso.rst_rcvd ?? "",
|
qso.rst_rcvd ?? "",
|
||||||
qso.gridsquare ?? "",
|
qso.gridsquare ?? "",
|
||||||
qso.my_rig ?? ""
|
qso.my_rig ?? "",
|
||||||
|
qso.confirmed ? "✓" : ""
|
||||||
];
|
];
|
||||||
for (const [index, value] of cells.entries()) {
|
for (const [index, value] of cells.entries()) {
|
||||||
const cell = document.createElement("td");
|
const cell = document.createElement("td");
|
||||||
@@ -233,6 +300,15 @@ function renderRows() {
|
|||||||
row.appendChild(cell);
|
row.appendChild(cell);
|
||||||
}
|
}
|
||||||
const actions = document.createElement("td");
|
const actions = document.createElement("td");
|
||||||
|
const confirm = document.createElement("button");
|
||||||
|
confirm.type = "button";
|
||||||
|
confirm.className = "log-row-btn";
|
||||||
|
confirm.textContent = qso.confirmed ? "Unconfirm" : "Confirm";
|
||||||
|
confirm.title = qso.confirmed ? "Mark this contact as not confirmed" : "Mark this contact confirmed by QSL";
|
||||||
|
confirm.addEventListener("click", () => {
|
||||||
|
void setConfirmed(qso, !qso.confirmed);
|
||||||
|
});
|
||||||
|
actions.appendChild(confirm);
|
||||||
const remove = document.createElement("button");
|
const remove = document.createElement("button");
|
||||||
remove.type = "button";
|
remove.type = "button";
|
||||||
remove.className = "log-row-btn";
|
remove.className = "log-row-btn";
|
||||||
@@ -262,6 +338,24 @@ function renderFilterOptions() {
|
|||||||
select.value = chosen;
|
select.value = chosen;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function setConfirmed(qso, confirmed) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...qso,
|
||||||
|
// A card is a card: this is the paper one, and an electronic
|
||||||
|
// confirmation the log already holds is left where it is.
|
||||||
|
qsl_rcvd: confirmed ? "Y" : "N"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||||
|
await refreshLog();
|
||||||
|
} catch (error) {
|
||||||
|
notify(`Could not update: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
async function deleteQso(qso) {
|
async function deleteQso(qso) {
|
||||||
const confirmed = await bridge.trxUi.confirm({
|
const confirmed = await bridge.trxUi.confirm({
|
||||||
title: "Delete this contact?",
|
title: "Delete this contact?",
|
||||||
@@ -314,6 +408,10 @@ for (const control of [filterCall, filterBand, filterMode]) {
|
|||||||
void refreshLog();
|
void refreshLog();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
for (const control of [cabrilloContest, cabrilloCallsign, cabrilloOperator, cabrilloPower, cabrilloScore]) {
|
||||||
|
control?.addEventListener("input", syncCabrilloLink);
|
||||||
|
control?.addEventListener("change", syncCabrilloLink);
|
||||||
|
}
|
||||||
importBtn?.addEventListener("click", () => {
|
importBtn?.addEventListener("click", () => {
|
||||||
importFile?.click();
|
importFile?.click();
|
||||||
});
|
});
|
||||||
@@ -327,5 +425,9 @@ bridge.logContact = (seed) => {
|
|||||||
void openEntry(seed).then(() => callInput?.focus());
|
void openEntry(seed).then(() => callInput?.focus());
|
||||||
};
|
};
|
||||||
renderStation();
|
renderStation();
|
||||||
|
if (cabrilloCallsign && !cabrilloCallsign.value) {
|
||||||
|
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
|
||||||
|
}
|
||||||
|
syncCabrilloLink();
|
||||||
void openEntry();
|
void openEntry();
|
||||||
void refreshLog();
|
void refreshLog();
|
||||||
|
|||||||
@@ -543,6 +543,24 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<input type="text" id="log-comment" class="status-input" />
|
<input type="text" id="log-comment" class="status-input" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<details class="log-contest" id="log-contest-block">
|
||||||
|
<summary>Contest exchange</summary>
|
||||||
|
<div class="log-entry-grid">
|
||||||
|
<label class="log-field">
|
||||||
|
<span>Contest</span>
|
||||||
|
<input type="text" id="log-contest-id" class="status-input" spellcheck="false"
|
||||||
|
placeholder="CQ-WW-SSB" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field log-field-narrow">
|
||||||
|
<span>Serial sent</span>
|
||||||
|
<input type="text" id="log-stx" class="status-input" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field log-field-narrow">
|
||||||
|
<span>Received</span>
|
||||||
|
<input type="text" id="log-srx" class="status-input" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
<div class="log-entry-actions">
|
<div class="log-entry-actions">
|
||||||
<span id="log-worked-before" class="log-worked" aria-live="polite"></span>
|
<span id="log-worked-before" class="log-worked" aria-live="polite"></span>
|
||||||
<span class="log-entry-buttons">
|
<span class="log-entry-buttons">
|
||||||
@@ -581,6 +599,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<th scope="col">Rcvd</th>
|
<th scope="col">Rcvd</th>
|
||||||
<th scope="col">Locator</th>
|
<th scope="col">Locator</th>
|
||||||
<th scope="col">Rig</th>
|
<th scope="col">Rig</th>
|
||||||
|
<th scope="col">QSL</th>
|
||||||
<th scope="col"><span class="visually-hidden">Actions</span></th>
|
<th scope="col"><span class="visually-hidden">Actions</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -588,6 +607,68 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div id="log-summary" class="log-summary" aria-live="polite"></div>
|
<div id="log-summary" class="log-summary" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<details class="log-report" id="log-statistics-block">
|
||||||
|
<summary>Bands worked</summary>
|
||||||
|
<div class="log-table-wrap">
|
||||||
|
<table class="log-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Band</th>
|
||||||
|
<th scope="col">Contacts</th>
|
||||||
|
<th scope="col">Stations</th>
|
||||||
|
<th scope="col">Confirmed</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="log-statistics-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="log-report" id="log-contest-export-block">
|
||||||
|
<summary>Contest entry (Cabrillo)</summary>
|
||||||
|
<p class="log-report-note">
|
||||||
|
Contest logs are submitted in Cabrillo and rejected in anything else.
|
||||||
|
Only the contacts of the contest named here go into the entry.
|
||||||
|
</p>
|
||||||
|
<div class="log-entry-grid">
|
||||||
|
<label class="log-field">
|
||||||
|
<span>Contest</span>
|
||||||
|
<input type="text" id="log-cbr-contest" class="status-input" spellcheck="false"
|
||||||
|
placeholder="CQ-WW-SSB" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field">
|
||||||
|
<span>Callsign</span>
|
||||||
|
<input type="text" id="log-cbr-callsign" class="status-input" spellcheck="false" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field">
|
||||||
|
<span>Operators</span>
|
||||||
|
<select id="log-cbr-operator" class="status-input">
|
||||||
|
<option value="SINGLE-OP">Single operator</option>
|
||||||
|
<option value="MULTI-OP">Multi operator</option>
|
||||||
|
<option value="CHECKLOG">Checklog</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="log-field">
|
||||||
|
<span>Power</span>
|
||||||
|
<select id="log-cbr-power" class="status-input">
|
||||||
|
<option value="LOW">Low</option>
|
||||||
|
<option value="HIGH">High</option>
|
||||||
|
<option value="QRP">QRP</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="log-field log-field-narrow">
|
||||||
|
<span>Claimed score</span>
|
||||||
|
<input type="text" id="log-cbr-score" class="status-input" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="log-entry-actions">
|
||||||
|
<span class="log-entry-buttons">
|
||||||
|
<a id="log-cbr-export" class="log-secondary-btn" href="/api/logbook/export.cbr"
|
||||||
|
download>Export Cabrillo</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="tab-bookmarks" class="tab-panel" style="display:none;">
|
<div id="tab-bookmarks" class="tab-panel" style="display:none;">
|
||||||
|
|||||||
@@ -6633,3 +6633,35 @@ body[data-operator-layout="ham"] #rds-panel { display: none !important; }
|
|||||||
flex: 1 1 8rem;
|
flex: 1 1 8rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* Contest exchange and the reports below the log: folded away, because most
|
||||||
|
operating is not a contest and most sessions do not export one. */
|
||||||
|
.log-contest,
|
||||||
|
.log-report {
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 0.5rem 0.85rem;
|
||||||
|
}
|
||||||
|
.log-contest {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.log-contest > summary,
|
||||||
|
.log-report > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.log-contest[open] > summary,
|
||||||
|
.log-report[open] > summary {
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
}
|
||||||
|
.log-report-note {
|
||||||
|
margin: 0 0 0.65rem;
|
||||||
|
max-width: 62ch;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,20 @@ interface Qso {
|
|||||||
my_gridsquare?: string | null;
|
my_gridsquare?: string | null;
|
||||||
my_rig?: string | null;
|
my_rig?: string | null;
|
||||||
rig_id?: string | null;
|
rig_id?: string | null;
|
||||||
|
contest_id?: string | null;
|
||||||
|
stx?: number | null;
|
||||||
|
srx?: number | null;
|
||||||
|
srx_string?: string | null;
|
||||||
|
qsl_rcvd?: string | null;
|
||||||
|
lotw_qsl_rcvd?: string | null;
|
||||||
|
confirmed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BandStatistics {
|
||||||
|
band: string;
|
||||||
|
contacts: number;
|
||||||
|
stations: number;
|
||||||
|
confirmed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Prefill {
|
interface Prefill {
|
||||||
@@ -86,6 +100,16 @@ const importFile = el<HTMLInputElement>("log-import-file");
|
|||||||
const exportLink = el<HTMLAnchorElement>("log-export-btn");
|
const exportLink = el<HTMLAnchorElement>("log-export-btn");
|
||||||
const clearBtn = el<HTMLButtonElement>("log-clear-btn");
|
const clearBtn = el<HTMLButtonElement>("log-clear-btn");
|
||||||
const saveBtn = el<HTMLButtonElement>("log-save-btn");
|
const saveBtn = el<HTMLButtonElement>("log-save-btn");
|
||||||
|
const contestIdInput = el<HTMLInputElement>("log-contest-id");
|
||||||
|
const stxInput = el<HTMLInputElement>("log-stx");
|
||||||
|
const srxInput = el<HTMLInputElement>("log-srx");
|
||||||
|
const statisticsRows = el<HTMLTableSectionElement>("log-statistics-rows");
|
||||||
|
const cabrilloContest = el<HTMLInputElement>("log-cbr-contest");
|
||||||
|
const cabrilloCallsign = el<HTMLInputElement>("log-cbr-callsign");
|
||||||
|
const cabrilloOperator = el<HTMLSelectElement>("log-cbr-operator");
|
||||||
|
const cabrilloPower = el<HTMLSelectElement>("log-cbr-power");
|
||||||
|
const cabrilloScore = el<HTMLInputElement>("log-cbr-score");
|
||||||
|
const cabrilloExport = el<HTMLAnchorElement>("log-cbr-export");
|
||||||
|
|
||||||
/** The time the open entry was started, as the server gave it. */
|
/** The time the open entry was started, as the server gave it. */
|
||||||
let entryStartedAt: string | null = null;
|
let entryStartedAt: string | null = null;
|
||||||
@@ -121,6 +145,14 @@ function parseFreq(text: string): number | null {
|
|||||||
return value < 100_000 ? Math.round(value * 1e6) : Math.round(value);
|
return value < 100_000 ? Math.round(value * 1e6) : Math.round(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A serial, or null when the exchange is a word rather than a number. */
|
||||||
|
function numberOrNull(text: string | undefined): number | null {
|
||||||
|
const trimmed = (text ?? "").trim();
|
||||||
|
if (!trimmed || !/^\d+$/.test(trimmed)) return null;
|
||||||
|
const value = Number(trimmed);
|
||||||
|
return Number.isSafeInteger(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
function utcDate(iso: string): string {
|
function utcDate(iso: string): string {
|
||||||
const date = new Date(iso);
|
const date = new Date(iso);
|
||||||
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
|
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
|
||||||
@@ -176,7 +208,9 @@ function showClock(prefill: Prefill): void {
|
|||||||
|
|
||||||
/** Clear the entry and open a fresh one. */
|
/** Clear the entry and open a fresh one. */
|
||||||
function resetEntry(): void {
|
function resetEntry(): void {
|
||||||
for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput]) {
|
// The contest and the serial sent stay: they belong to the session, not to
|
||||||
|
// the contact just logged.
|
||||||
|
for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput, srxInput]) {
|
||||||
if (input) input.value = "";
|
if (input) input.value = "";
|
||||||
}
|
}
|
||||||
if (workedEl) workedEl.textContent = "";
|
if (workedEl) workedEl.textContent = "";
|
||||||
@@ -214,6 +248,13 @@ async function submitEntry(event: Event): Promise<void> {
|
|||||||
gridsquare: gridInput?.value ?? null,
|
gridsquare: gridInput?.value ?? null,
|
||||||
name: nameInput?.value ?? null,
|
name: nameInput?.value ?? null,
|
||||||
comment: commentInput?.value ?? null,
|
comment: commentInput?.value ?? null,
|
||||||
|
contest_id: contestIdInput?.value ?? null,
|
||||||
|
// The exchange is a number when it is a serial and a word when it is a
|
||||||
|
// zone or a section; both are kept, and the log writes whichever it has.
|
||||||
|
stx: numberOrNull(stxInput?.value),
|
||||||
|
stx_string: numberOrNull(stxInput?.value) == null ? stxInput?.value ?? null : null,
|
||||||
|
srx: numberOrNull(srxInput?.value),
|
||||||
|
srx_string: numberOrNull(srxInput?.value) == null ? srxInput?.value ?? null : null,
|
||||||
station_callsign: stationCallEl?.textContent?.trim() ?? null,
|
station_callsign: stationCallEl?.textContent?.trim() ?? null,
|
||||||
operator: operatorInput?.value ?? null,
|
operator: operatorInput?.value ?? null,
|
||||||
my_gridsquare: entryGrid,
|
my_gridsquare: entryGrid,
|
||||||
@@ -232,7 +273,11 @@ async function submitEntry(event: Event): Promise<void> {
|
|||||||
throw new Error(detail.error ?? `HTTP ${String(response.status)}`);
|
throw new Error(detail.error ?? `HTTP ${String(response.status)}`);
|
||||||
}
|
}
|
||||||
notify(`${call} logged`);
|
notify(`${call} logged`);
|
||||||
|
// A contest runs on serials: the next one is this one plus one, so it is
|
||||||
|
// not retyped forty times an hour.
|
||||||
|
const sent = numberOrNull(stxInput?.value);
|
||||||
resetEntry();
|
resetEntry();
|
||||||
|
if (stxInput && sent != null) stxInput.value = String(sent + 1);
|
||||||
await refreshLog();
|
await refreshLog();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error");
|
notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
@@ -292,11 +337,57 @@ async function refreshLog(): Promise<void> {
|
|||||||
: `${String(answer.total)} contact${answer.total === 1 ? "" : "s"}`;
|
: `${String(answer.total)} contact${answer.total === 1 ? "" : "s"}`;
|
||||||
}
|
}
|
||||||
if (exportLink) exportLink.href = `/api/logbook/export.adi${query ? `?${query}` : ""}`;
|
if (exportLink) exportLink.href = `/api/logbook/export.adi${query ? `?${query}` : ""}`;
|
||||||
|
await refreshStatistics();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error("logbook read failed", error);
|
console.error("logbook read failed", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What has been worked and confirmed, band by band. */
|
||||||
|
async function refreshStatistics(): Promise<void> {
|
||||||
|
if (!statisticsRows) return;
|
||||||
|
try {
|
||||||
|
const answer = await getJson<{ bands: BandStatistics[] }>("/api/logbook/statistics");
|
||||||
|
if (answer.bands.length === 0) {
|
||||||
|
statisticsRows.innerHTML = '<tr><td colspan="4" class="log-empty">Nothing worked yet.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const band of answer.bands) {
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
for (const value of [band.band, band.contacts, band.stations, band.confirmed]) {
|
||||||
|
const cell = document.createElement("td");
|
||||||
|
cell.textContent = String(value);
|
||||||
|
row.appendChild(cell);
|
||||||
|
}
|
||||||
|
fragment.appendChild(row);
|
||||||
|
}
|
||||||
|
statisticsRows.replaceChildren(fragment);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error("logbook statistics failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Cabrillo link, carrying the header the operator filled in. */
|
||||||
|
function syncCabrilloLink(): void {
|
||||||
|
if (!cabrilloExport) return;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const contest = cabrilloContest?.value.trim();
|
||||||
|
if (contest) {
|
||||||
|
// Only that contest's contacts belong in the entry.
|
||||||
|
params.set("contest", contest);
|
||||||
|
}
|
||||||
|
const callsign = cabrilloCallsign?.value.trim() || (stationCallEl?.textContent?.trim() ?? "");
|
||||||
|
if (callsign) params.set("callsign", callsign);
|
||||||
|
if (cabrilloOperator?.value) params.set("category_operator", cabrilloOperator.value);
|
||||||
|
if (cabrilloPower?.value) params.set("category_power", cabrilloPower.value);
|
||||||
|
const score = numberOrNull(cabrilloScore?.value);
|
||||||
|
if (score != null) params.set("claimed_score", String(score));
|
||||||
|
const operator = operatorInput?.value.trim();
|
||||||
|
if (operator) params.set("operators", operator);
|
||||||
|
cabrilloExport.href = `/api/logbook/export.cbr?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderRows(): void {
|
function renderRows(): void {
|
||||||
if (!rowsBody) return;
|
if (!rowsBody) return;
|
||||||
if (qsos.length === 0) {
|
if (qsos.length === 0) {
|
||||||
@@ -319,6 +410,7 @@ function renderRows(): void {
|
|||||||
qso.rst_rcvd ?? "",
|
qso.rst_rcvd ?? "",
|
||||||
qso.gridsquare ?? "",
|
qso.gridsquare ?? "",
|
||||||
qso.my_rig ?? "",
|
qso.my_rig ?? "",
|
||||||
|
qso.confirmed ? "✓" : "",
|
||||||
];
|
];
|
||||||
for (const [index, value] of cells.entries()) {
|
for (const [index, value] of cells.entries()) {
|
||||||
const cell = document.createElement("td");
|
const cell = document.createElement("td");
|
||||||
@@ -327,6 +419,17 @@ function renderRows(): void {
|
|||||||
row.appendChild(cell);
|
row.appendChild(cell);
|
||||||
}
|
}
|
||||||
const actions = document.createElement("td");
|
const actions = document.createElement("td");
|
||||||
|
// Confirming is the commonest edit a log gets, so it is a button rather
|
||||||
|
// than a form: a card arrives, and the contact counts towards an award.
|
||||||
|
const confirm = document.createElement("button");
|
||||||
|
confirm.type = "button";
|
||||||
|
confirm.className = "log-row-btn";
|
||||||
|
confirm.textContent = qso.confirmed ? "Unconfirm" : "Confirm";
|
||||||
|
confirm.title = qso.confirmed
|
||||||
|
? "Mark this contact as not confirmed"
|
||||||
|
: "Mark this contact confirmed by QSL";
|
||||||
|
confirm.addEventListener("click", () => { void setConfirmed(qso, !qso.confirmed); });
|
||||||
|
actions.appendChild(confirm);
|
||||||
const remove = document.createElement("button");
|
const remove = document.createElement("button");
|
||||||
remove.type = "button";
|
remove.type = "button";
|
||||||
remove.className = "log-row-btn";
|
remove.className = "log-row-btn";
|
||||||
@@ -357,6 +460,26 @@ function renderFilterOptions(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Record, or withdraw, the other station's confirmation. */
|
||||||
|
async function setConfirmed(qso: Qso, confirmed: boolean): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...qso,
|
||||||
|
// A card is a card: this is the paper one, and an electronic
|
||||||
|
// confirmation the log already holds is left where it is.
|
||||||
|
qsl_rcvd: confirmed ? "Y" : "N",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||||
|
await refreshLog();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
notify(`Could not update: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteQso(qso: Qso): Promise<void> {
|
async function deleteQso(qso: Qso): Promise<void> {
|
||||||
const confirmed = await bridge.trxUi.confirm({
|
const confirmed = await bridge.trxUi.confirm({
|
||||||
title: "Delete this contact?",
|
title: "Delete this contact?",
|
||||||
@@ -412,6 +535,10 @@ for (const control of [filterCall, filterBand, filterMode]) {
|
|||||||
control?.addEventListener("input", () => { void refreshLog(); });
|
control?.addEventListener("input", () => { void refreshLog(); });
|
||||||
control?.addEventListener("change", () => { void refreshLog(); });
|
control?.addEventListener("change", () => { void refreshLog(); });
|
||||||
}
|
}
|
||||||
|
for (const control of [cabrilloContest, cabrilloCallsign, cabrilloOperator, cabrilloPower, cabrilloScore]) {
|
||||||
|
control?.addEventListener("input", syncCabrilloLink);
|
||||||
|
control?.addEventListener("change", syncCabrilloLink);
|
||||||
|
}
|
||||||
importBtn?.addEventListener("click", () => { importFile?.click(); });
|
importBtn?.addEventListener("click", () => { importFile?.click(); });
|
||||||
importFile?.addEventListener("change", () => {
|
importFile?.addEventListener("change", () => {
|
||||||
const file = importFile.files?.[0];
|
const file = importFile.files?.[0];
|
||||||
@@ -426,5 +553,9 @@ bridge.logContact = (seed) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
renderStation();
|
renderStation();
|
||||||
|
if (cabrilloCallsign && !cabrilloCallsign.value) {
|
||||||
|
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
|
||||||
|
}
|
||||||
|
syncCabrilloLink();
|
||||||
void openEntry();
|
void openEntry();
|
||||||
void refreshLog();
|
void refreshLog();
|
||||||
|
|||||||
@@ -137,6 +137,69 @@ try {
|
|||||||
assert.equal(inHamLayout.layout, "ham");
|
assert.equal(inHamLayout.layout, "ham");
|
||||||
assert.equal(inHamLayout.path, "/logbook", "the ham layout opened somewhere else");
|
assert.equal(inHamLayout.path, "/logbook", "the ham layout opened somewhere else");
|
||||||
|
|
||||||
|
// ── Contest working ─────────────────────────────────────────────────────
|
||||||
|
// The exchange belongs to the session: the contest and the serial sent stay
|
||||||
|
// between contacts, and the serial counts on by itself.
|
||||||
|
await page.evaluate(() => { window.navigateToTab("logbook"); });
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.locator("#log-contest-block > summary").click();
|
||||||
|
await page.locator("#log-contest-id").fill("CQ-WW-SSB");
|
||||||
|
await page.locator("#log-stx").fill("1");
|
||||||
|
await page.locator("#log-call").fill("DL9CON");
|
||||||
|
await page.locator("#log-srx").fill("014");
|
||||||
|
await page.locator("#log-rst-sent").fill("59");
|
||||||
|
await page.locator("#log-rst-rcvd").fill("59");
|
||||||
|
await page.locator("#log-save-btn").click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
const afterContest = await page.evaluate(() => ({
|
||||||
|
contest: document.getElementById("log-contest-id")?.value ?? "",
|
||||||
|
stx: document.getElementById("log-stx")?.value ?? "",
|
||||||
|
srx: document.getElementById("log-srx")?.value ?? "",
|
||||||
|
call: document.getElementById("log-call")?.value ?? "",
|
||||||
|
}));
|
||||||
|
assert.equal(afterContest.contest, "CQ-WW-SSB", "the contest was cleared with the contact");
|
||||||
|
assert.equal(afterContest.stx, "2", `the serial sent went to "${afterContest.stx}"`);
|
||||||
|
assert.equal(afterContest.srx, "", "the received exchange was kept for the next station");
|
||||||
|
assert.equal(afterContest.call, "");
|
||||||
|
|
||||||
|
// The Cabrillo link carries the header, and names the contest so that only
|
||||||
|
// its contacts are entered.
|
||||||
|
await page.locator("#log-contest-export-block > summary").click();
|
||||||
|
await page.locator("#log-cbr-contest").fill("CQ-WW-SSB");
|
||||||
|
await page.locator("#log-cbr-score").fill("4242");
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const cabrillo = await page.evaluate(() =>
|
||||||
|
document.getElementById("log-cbr-export")?.getAttribute("href") ?? "");
|
||||||
|
assert.match(cabrillo, /contest=CQ-WW-SSB/, `the Cabrillo link reads "${cabrillo}"`);
|
||||||
|
assert.match(cabrillo, /claimed_score=4242/, `the Cabrillo link reads "${cabrillo}"`);
|
||||||
|
const entry = await page.evaluate(async (href) => {
|
||||||
|
const response = await fetch(href);
|
||||||
|
return await response.text();
|
||||||
|
}, cabrillo);
|
||||||
|
assert.match(entry, /^START-OF-LOG: 3\.0/, entry);
|
||||||
|
assert.match(entry, /DL9CON/, "the contest contact is not in the entry");
|
||||||
|
assert.ok(!entry.includes("OZ1NEW"), "a contact outside the contest was entered");
|
||||||
|
|
||||||
|
// ── Confirmations ───────────────────────────────────────────────────────
|
||||||
|
// A card arrives: the contact is marked confirmed, and the per-band report
|
||||||
|
// counts it.
|
||||||
|
const confirmRow = page.locator("#log-rows tr", { hasText: "DL9CON" }).first();
|
||||||
|
await confirmRow.getByRole("button", { name: "Confirm" }).click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await page.locator("#log-statistics-block > summary").click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
const confirmed = await page.evaluate(() => ({
|
||||||
|
marks: [...document.querySelectorAll("#log-rows tr")]
|
||||||
|
.map((row) => [...row.querySelectorAll("td")].at(-2)?.textContent ?? ""),
|
||||||
|
bands: [...document.querySelectorAll("#log-statistics-rows tr")]
|
||||||
|
.map((row) => [...row.querySelectorAll("td")].map((cell) => cell.textContent)),
|
||||||
|
}));
|
||||||
|
assert.equal(confirmed.marks.filter((mark) => mark === "✓").length, 1,
|
||||||
|
`the QSL column reads ${JSON.stringify(confirmed.marks)}`);
|
||||||
|
const twenty = confirmed.bands.find((band) => band[0] === "20m");
|
||||||
|
assert.ok(twenty, `the bands are ${JSON.stringify(confirmed.bands)}`);
|
||||||
|
assert.equal(twenty[3], "1", `20m shows ${twenty[3]} confirmed`);
|
||||||
|
|
||||||
assert.deepEqual(runtimeErrors, []);
|
assert.deepEqual(runtimeErrors, []);
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|||||||
@@ -309,6 +309,49 @@ export async function startWebFixture({
|
|||||||
response.end(JSON.stringify({ call, worked }));
|
response.end(JSON.stringify({ call, worked }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (tail === "/statistics") {
|
||||||
|
const bands = new Map();
|
||||||
|
for (const qso of logbook) {
|
||||||
|
const entry = bands.get(qso.band) ?? { band: qso.band, contacts: 0, stations: 0, confirmed: 0, calls: new Set() };
|
||||||
|
entry.contacts += 1;
|
||||||
|
entry.calls.add(qso.call);
|
||||||
|
if (qso.confirmed || qso.qsl_rcvd === "Y" || qso.lotw_qsl_rcvd === "Y") entry.confirmed += 1;
|
||||||
|
bands.set(qso.band, entry);
|
||||||
|
}
|
||||||
|
const list = [...bands.values()].map(({ calls, ...rest }) => ({ ...rest, stations: calls.size }));
|
||||||
|
response.writeHead(200, { "content-type": "application/json" });
|
||||||
|
response.end(JSON.stringify({
|
||||||
|
contacts: logbook.length,
|
||||||
|
confirmed: list.reduce((total, band) => total + band.confirmed, 0),
|
||||||
|
bands: list,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tail === "/export.cbr") {
|
||||||
|
const contest = url.searchParams.get("contest");
|
||||||
|
const entered = contest ? logbook.filter((qso) => qso.contest_id === contest) : logbook;
|
||||||
|
response.writeHead(200, { "content-type": "text/plain" });
|
||||||
|
response.end(`START-OF-LOG: 3.0\nCONTEST: ${contest ?? ""}\n`
|
||||||
|
+ entered.map((qso) => `QSO: 14200 PH ${qso.call}\n`).join("")
|
||||||
|
+ "END-OF-LOG:\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === "PUT") {
|
||||||
|
const raw = await new Promise((resolve) => {
|
||||||
|
let text = "";
|
||||||
|
request.on("data", (chunk) => { text += chunk; });
|
||||||
|
request.on("end", () => resolve(text));
|
||||||
|
});
|
||||||
|
const input = JSON.parse(raw);
|
||||||
|
const id = tail.replace("/", "");
|
||||||
|
const held = logbook.find((qso) => qso.id === id);
|
||||||
|
if (held) {
|
||||||
|
Object.assign(held, input, { id, confirmed: input.qsl_rcvd === "Y" });
|
||||||
|
}
|
||||||
|
response.writeHead(200, { "content-type": "application/json" });
|
||||||
|
response.end(JSON.stringify(held ?? {}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (tail === "/export.adi") {
|
if (tail === "/export.adi") {
|
||||||
const body = logbook
|
const body = logbook
|
||||||
.map((qso) => `<CALL:${qso.call.length}>${qso.call}<EOR>\n`)
|
.map((qso) => `<CALL:${qso.call.length}>${qso.call}<EOR>\n`)
|
||||||
@@ -337,6 +380,10 @@ export async function startWebFixture({
|
|||||||
gridsquare: input.gridsquare ? String(input.gridsquare).toUpperCase() : null,
|
gridsquare: input.gridsquare ? String(input.gridsquare).toUpperCase() : null,
|
||||||
my_rig: input.my_rig,
|
my_rig: input.my_rig,
|
||||||
operator: input.operator,
|
operator: input.operator,
|
||||||
|
contest_id: input.contest_id ?? null,
|
||||||
|
stx: input.stx ?? null,
|
||||||
|
srx: input.srx ?? null,
|
||||||
|
confirmed: input.qsl_rcvd === "Y" || input.lotw_qsl_rcvd === "Y",
|
||||||
};
|
};
|
||||||
logbook.unshift(qso);
|
logbook.unshift(qso);
|
||||||
response.writeHead(200, { "content-type": "application/json" });
|
response.writeHead(200, { "content-type": "application/json" });
|
||||||
|
|||||||
@@ -27,12 +27,15 @@ struct QsoView {
|
|||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
qso: Qso,
|
qso: Qso,
|
||||||
band: Option<&'static str>,
|
band: Option<&'static str>,
|
||||||
|
/// Whether the other station has confirmed, from whichever bureau answered.
|
||||||
|
confirmed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Qso> for QsoView {
|
impl From<Qso> for QsoView {
|
||||||
fn from(qso: Qso) -> Self {
|
fn from(qso: Qso) -> Self {
|
||||||
Self {
|
Self {
|
||||||
band: qso.band(),
|
band: qso.band(),
|
||||||
|
confirmed: qso.is_confirmed(),
|
||||||
qso,
|
qso,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,6 +82,39 @@ struct QsoInput {
|
|||||||
my_rig: Option<String>,
|
my_rig: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
rig_id: Option<String>,
|
rig_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
contest_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
stx: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
stx_string: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
srx: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
srx_string: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
cqz: Option<u8>,
|
||||||
|
#[serde(default)]
|
||||||
|
ituz: Option<u8>,
|
||||||
|
#[serde(default)]
|
||||||
|
qsl_sent: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
qsl_rcvd: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
lotw_qsl_sent: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
lotw_qsl_rcvd: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
eqsl_qsl_sent: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
eqsl_qsl_rcvd: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADIF's QSL states are single letters. Anything else is refused rather than
|
||||||
|
/// written, or a log would grow states no other program can read.
|
||||||
|
fn qsl_state(value: Option<String>) -> Option<String> {
|
||||||
|
let state = blank_to_none(value)?.to_uppercase();
|
||||||
|
matches!(state.as_str(), "Y" | "N" | "R" | "I" | "Q" | "V").then_some(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn blank_to_none(value: Option<String>) -> Option<String> {
|
fn blank_to_none(value: Option<String>) -> Option<String> {
|
||||||
@@ -123,6 +159,19 @@ impl QsoInput {
|
|||||||
qso.my_gridsquare = blank_to_none(self.my_gridsquare).map(|g| g.to_uppercase());
|
qso.my_gridsquare = blank_to_none(self.my_gridsquare).map(|g| g.to_uppercase());
|
||||||
qso.my_rig = blank_to_none(self.my_rig);
|
qso.my_rig = blank_to_none(self.my_rig);
|
||||||
qso.rig_id = blank_to_none(self.rig_id);
|
qso.rig_id = blank_to_none(self.rig_id);
|
||||||
|
qso.contest_id = blank_to_none(self.contest_id).map(|c| c.to_uppercase());
|
||||||
|
qso.stx = self.stx;
|
||||||
|
qso.stx_string = blank_to_none(self.stx_string);
|
||||||
|
qso.srx = self.srx;
|
||||||
|
qso.srx_string = blank_to_none(self.srx_string);
|
||||||
|
qso.cqz = self.cqz;
|
||||||
|
qso.ituz = self.ituz;
|
||||||
|
qso.qsl_sent = qsl_state(self.qsl_sent);
|
||||||
|
qso.qsl_rcvd = qsl_state(self.qsl_rcvd);
|
||||||
|
qso.lotw_qsl_sent = qsl_state(self.lotw_qsl_sent);
|
||||||
|
qso.lotw_qsl_rcvd = qsl_state(self.lotw_qsl_rcvd);
|
||||||
|
qso.eqsl_qsl_sent = qsl_state(self.eqsl_qsl_sent);
|
||||||
|
qso.eqsl_qsl_rcvd = qsl_state(self.eqsl_qsl_rcvd);
|
||||||
// Whatever an imported contact carried that this application does not
|
// Whatever an imported contact carried that this application does not
|
||||||
// model stays with it through an edit.
|
// model stays with it through an edit.
|
||||||
if let Some(existing) = existing {
|
if let Some(existing) = existing {
|
||||||
@@ -271,6 +320,41 @@ pub async fn import_adi(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `GET /api/logbook/export.cbr` — a contest entry, in the only format sponsors
|
||||||
|
/// take.
|
||||||
|
///
|
||||||
|
/// The header cannot be worked out from the log — how many operators, how much
|
||||||
|
/// power — so it comes as query parameters from the operator.
|
||||||
|
#[get("/api/logbook/export.cbr")]
|
||||||
|
pub async fn export_cabrillo(
|
||||||
|
query: web::Query<LogQuery>,
|
||||||
|
header: web::Query<trx_logbook::CabrilloHeader>,
|
||||||
|
logbook: web::Data<Arc<Logbook>>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let text = book(&logbook).export_cabrillo(&query.into_inner(), &header.into_inner());
|
||||||
|
let filename = format!("trx-rs-contest-{}.cbr", Utc::now().format("%Y%m%d"));
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header((header::CONTENT_TYPE, "text/plain; charset=utf-8"))
|
||||||
|
.insert_header((
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
format!("attachment; filename=\"{filename}\""),
|
||||||
|
))
|
||||||
|
.body(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/logbook/statistics` — what has been worked and confirmed, per band.
|
||||||
|
#[get("/api/logbook/statistics")]
|
||||||
|
pub async fn statistics(logbook: web::Data<Arc<Logbook>>) -> impl Responder {
|
||||||
|
let bands = book(&logbook).band_statistics();
|
||||||
|
let contacts: usize = bands.iter().map(|band| band.contacts).sum();
|
||||||
|
let confirmed: usize = bands.iter().map(|band| band.confirmed).sum();
|
||||||
|
HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"contacts": contacts,
|
||||||
|
"confirmed": confirmed,
|
||||||
|
"bands": bands,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// `GET /api/logbook/worked/{call}` — which bands and modes this station has been
|
/// `GET /api/logbook/worked/{call}` — which bands and modes this station has been
|
||||||
/// worked on.
|
/// worked on.
|
||||||
#[get("/api/logbook/worked/{call}")]
|
#[get("/api/logbook/worked/{call}")]
|
||||||
|
|||||||
@@ -715,6 +715,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(logbook::edit_qso)
|
.service(logbook::edit_qso)
|
||||||
.service(logbook::delete_qso)
|
.service(logbook::delete_qso)
|
||||||
.service(logbook::export_adi)
|
.service(logbook::export_adi)
|
||||||
|
.service(logbook::export_cabrillo)
|
||||||
|
.service(logbook::statistics)
|
||||||
.service(logbook::import_adi)
|
.service(logbook::import_adi)
|
||||||
.service(logbook::worked_before)
|
.service(logbook::worked_before)
|
||||||
.service(logbook::server_now)
|
.service(logbook::server_now)
|
||||||
@@ -1045,6 +1047,116 @@ mod tests {
|
|||||||
assert!(text.contains("<EOR>"), "{text}");
|
assert!(text.contains("<EOR>"), "{text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A contest entry comes out in Cabrillo, holding that contest's contacts
|
||||||
|
/// and no others, with the header the operator gave.
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn a_contest_entry_is_exported_as_cabrillo() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let logbook = std::sync::Arc::new(
|
||||||
|
trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"),
|
||||||
|
);
|
||||||
|
let app = actix_test::init_service(
|
||||||
|
App::new()
|
||||||
|
.app_data(web::Data::new(logbook.clone()))
|
||||||
|
.app_data(web::Data::new(auth_state_disabled()))
|
||||||
|
.service(logbook::add_qso)
|
||||||
|
.service(logbook::export_cabrillo)
|
||||||
|
.service(logbook::statistics),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
for (call, contest, serial, confirmed) in [
|
||||||
|
("DL1AB", Some("CQ-WW-SSB"), 1, true),
|
||||||
|
("OZ2CD", Some("CQ-WW-SSB"), 2, false),
|
||||||
|
("SP3EF", None, 0, false),
|
||||||
|
] {
|
||||||
|
let response = actix_test::call_service(
|
||||||
|
&app,
|
||||||
|
actix_test::TestRequest::post()
|
||||||
|
.uri("/api/logbook")
|
||||||
|
.set_json(serde_json::json!({
|
||||||
|
"call": call,
|
||||||
|
"freq_hz": 14_200_000_u64,
|
||||||
|
"mode": "SSB",
|
||||||
|
"rst_sent": "59",
|
||||||
|
"rst_rcvd": "59",
|
||||||
|
"station_callsign": "SP0TRX",
|
||||||
|
"contest_id": contest,
|
||||||
|
"stx": serial,
|
||||||
|
"srx": serial,
|
||||||
|
"lotw_qsl_rcvd": if confirmed { "Y" } else { "N" },
|
||||||
|
}))
|
||||||
|
.to_request(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = actix_test::call_and_read_body(
|
||||||
|
&app,
|
||||||
|
actix_test::TestRequest::get()
|
||||||
|
.uri("/api/logbook/export.cbr?contest=CQ-WW-SSB&callsign=SP0TRX&claimed_score=42")
|
||||||
|
.to_request(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let text = String::from_utf8_lossy(&entry);
|
||||||
|
assert!(text.starts_with("START-OF-LOG: 3.0"), "{text}");
|
||||||
|
assert!(text.contains("CONTEST: CQ-WW-SSB"), "{text}");
|
||||||
|
assert!(text.contains("CLAIMED-SCORE: 42"), "{text}");
|
||||||
|
assert_eq!(
|
||||||
|
text.lines().filter(|line| line.starts_with("QSO:")).count(),
|
||||||
|
2,
|
||||||
|
"the entry holds contacts from outside the contest: {text}"
|
||||||
|
);
|
||||||
|
assert!(!text.contains("SP3EF"), "{text}");
|
||||||
|
|
||||||
|
// And the statistics count the station once per band, with the
|
||||||
|
// confirmation counted wherever it came from.
|
||||||
|
let stats: serde_json::Value = actix_test::call_and_read_body_json(
|
||||||
|
&app,
|
||||||
|
actix_test::TestRequest::get()
|
||||||
|
.uri("/api/logbook/statistics")
|
||||||
|
.to_request(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(stats["contacts"], 3);
|
||||||
|
assert_eq!(stats["confirmed"], 1);
|
||||||
|
assert_eq!(stats["bands"][0]["band"], "20m");
|
||||||
|
assert_eq!(stats["bands"][0]["stations"], 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A QSL state that is not one of ADIF's letters is dropped rather than
|
||||||
|
/// written, or the log grows states nothing else can read.
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn a_qsl_state_outside_the_enumeration_is_not_written() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let logbook = std::sync::Arc::new(
|
||||||
|
trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"),
|
||||||
|
);
|
||||||
|
let app = actix_test::init_service(
|
||||||
|
App::new()
|
||||||
|
.app_data(web::Data::new(logbook))
|
||||||
|
.app_data(web::Data::new(auth_state_disabled()))
|
||||||
|
.service(logbook::add_qso),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let written: serde_json::Value = actix_test::call_and_read_body_json(
|
||||||
|
&app,
|
||||||
|
actix_test::TestRequest::post()
|
||||||
|
.uri("/api/logbook")
|
||||||
|
.set_json(serde_json::json!({
|
||||||
|
"call": "SP1AA", "freq_hz": 14_074_000_u64, "mode": "FT8",
|
||||||
|
"qsl_rcvd": "maybe", "lotw_qsl_rcvd": "y",
|
||||||
|
}))
|
||||||
|
.to_request(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(written.get("qsl_rcvd").is_none(), "{written}");
|
||||||
|
// ...but a lowercase letter that is in the enumeration is taken.
|
||||||
|
assert_eq!(written["lotw_qsl_rcvd"], "Y");
|
||||||
|
assert_eq!(written["confirmed"], true);
|
||||||
|
}
|
||||||
|
|
||||||
/// A read-only session may read the log and may not write to it.
|
/// A read-only session may read the log and may not write to it.
|
||||||
#[actix_web::test]
|
#[actix_web::test]
|
||||||
async fn a_read_only_session_cannot_write_to_the_log() {
|
async fn a_read_only_session_cannot_write_to_the_log() {
|
||||||
|
|||||||
@@ -47,8 +47,28 @@ const MODELLED: &[&str] = &[
|
|||||||
"OPERATOR",
|
"OPERATOR",
|
||||||
"MY_GRIDSQUARE",
|
"MY_GRIDSQUARE",
|
||||||
"MY_RIG",
|
"MY_RIG",
|
||||||
|
"CONTEST_ID",
|
||||||
|
"STX",
|
||||||
|
"STX_STRING",
|
||||||
|
"SRX",
|
||||||
|
"SRX_STRING",
|
||||||
|
"CQZ",
|
||||||
|
"ITUZ",
|
||||||
|
"QSL_SENT",
|
||||||
|
"QSL_RCVD",
|
||||||
|
"LOTW_QSL_SENT",
|
||||||
|
"LOTW_QSL_RCVD",
|
||||||
|
"EQSL_QSL_SENT",
|
||||||
|
"EQSL_QSL_RCVD",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// ADIF's QSL states are single letters; anything else in that field is a
|
||||||
|
/// misunderstanding on the writer's part and is not carried into ours.
|
||||||
|
fn qsl_state(value: Option<&str>) -> Option<String> {
|
||||||
|
let state = value?.trim().to_uppercase();
|
||||||
|
matches!(state.as_str(), "Y" | "N" | "R" | "I" | "Q" | "V").then_some(state)
|
||||||
|
}
|
||||||
|
|
||||||
/// What a file gave up, and what it could not.
|
/// What a file gave up, and what it could not.
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct ParseReport {
|
pub struct ParseReport {
|
||||||
@@ -215,6 +235,19 @@ fn qso_from_fields(fields: &[Field]) -> Result<Qso, String> {
|
|||||||
my_gridsquare: find(fields, "MY_GRIDSQUARE").map(|g| g.to_uppercase()),
|
my_gridsquare: find(fields, "MY_GRIDSQUARE").map(|g| g.to_uppercase()),
|
||||||
my_rig: find(fields, "MY_RIG").map(str::to_string),
|
my_rig: find(fields, "MY_RIG").map(str::to_string),
|
||||||
rig_id: None,
|
rig_id: None,
|
||||||
|
contest_id: find(fields, "CONTEST_ID").map(|c| c.to_uppercase()),
|
||||||
|
stx: find(fields, "STX").and_then(|value| value.parse().ok()),
|
||||||
|
stx_string: find(fields, "STX_STRING").map(str::to_string),
|
||||||
|
srx: find(fields, "SRX").and_then(|value| value.parse().ok()),
|
||||||
|
srx_string: find(fields, "SRX_STRING").map(str::to_string),
|
||||||
|
cqz: find(fields, "CQZ").and_then(|value| value.parse().ok()),
|
||||||
|
ituz: find(fields, "ITUZ").and_then(|value| value.parse().ok()),
|
||||||
|
qsl_sent: qsl_state(find(fields, "QSL_SENT")),
|
||||||
|
qsl_rcvd: qsl_state(find(fields, "QSL_RCVD")),
|
||||||
|
lotw_qsl_sent: qsl_state(find(fields, "LOTW_QSL_SENT")),
|
||||||
|
lotw_qsl_rcvd: qsl_state(find(fields, "LOTW_QSL_RCVD")),
|
||||||
|
eqsl_qsl_sent: qsl_state(find(fields, "EQSL_QSL_SENT")),
|
||||||
|
eqsl_qsl_rcvd: qsl_state(find(fields, "EQSL_QSL_RCVD")),
|
||||||
extra,
|
extra,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -273,11 +306,30 @@ pub fn write_adi(qsos: &[Qso], program_version: &str) -> String {
|
|||||||
("OPERATOR", &qso.operator),
|
("OPERATOR", &qso.operator),
|
||||||
("MY_GRIDSQUARE", &qso.my_gridsquare),
|
("MY_GRIDSQUARE", &qso.my_gridsquare),
|
||||||
("MY_RIG", &qso.my_rig),
|
("MY_RIG", &qso.my_rig),
|
||||||
|
("CONTEST_ID", &qso.contest_id),
|
||||||
|
("STX_STRING", &qso.stx_string),
|
||||||
|
("SRX_STRING", &qso.srx_string),
|
||||||
|
("QSL_SENT", &qso.qsl_sent),
|
||||||
|
("QSL_RCVD", &qso.qsl_rcvd),
|
||||||
|
("LOTW_QSL_SENT", &qso.lotw_qsl_sent),
|
||||||
|
("LOTW_QSL_RCVD", &qso.lotw_qsl_rcvd),
|
||||||
|
("EQSL_QSL_SENT", &qso.eqsl_qsl_sent),
|
||||||
|
("EQSL_QSL_RCVD", &qso.eqsl_qsl_rcvd),
|
||||||
] {
|
] {
|
||||||
if let Some(value) = value {
|
if let Some(value) = value {
|
||||||
write_field(&mut out, name, value);
|
write_field(&mut out, name, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (name, value) in [
|
||||||
|
("STX", qso.stx),
|
||||||
|
("SRX", qso.srx),
|
||||||
|
("CQZ", qso.cqz.map(u32::from)),
|
||||||
|
("ITUZ", qso.ituz.map(u32::from)),
|
||||||
|
] {
|
||||||
|
if let Some(value) = value {
|
||||||
|
write_field(&mut out, name, &value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(power) = qso.tx_pwr_w {
|
if let Some(power) = qso.tx_pwr_w {
|
||||||
write_field(&mut out, "TX_PWR", &format!("{power}"));
|
write_field(&mut out, "TX_PWR", &format!("{power}"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! Cabrillo 3.0, the format contest logs are submitted in.
|
||||||
|
//!
|
||||||
|
//! ADIF cannot do this job: sponsors take Cabrillo and reject everything else.
|
||||||
|
//! It is export-only and narrower on purpose — a header naming the entrant and
|
||||||
|
//! the category, then one fixed-shape `QSO:` line per contact carrying the
|
||||||
|
//! frequency, the mode, the time, and both stations' calls, reports and
|
||||||
|
//! exchanges. Everything a log holds beyond that is left behind, which is why
|
||||||
|
//! this complements ADIF rather than replacing it.
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::qso::Qso;
|
||||||
|
|
||||||
|
/// The header of a contest entry.
|
||||||
|
///
|
||||||
|
/// Sponsors want the entrant's own answers here — how many operators, how much
|
||||||
|
/// power, which bands — and no log can work them out for itself, so they come
|
||||||
|
/// from the operator with sane defaults behind them.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct CabrilloHeader {
|
||||||
|
/// The contest's Cabrillo name, e.g. `CQ-WW-SSB`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub contest: Option<String>,
|
||||||
|
/// The callsign the entry is submitted under.
|
||||||
|
#[serde(default)]
|
||||||
|
pub callsign: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub category_operator: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub category_power: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub category_band: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub category_mode: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub claimed_score: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub operators: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub email: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub soapbox: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CabrilloHeader {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
contest: None,
|
||||||
|
callsign: None,
|
||||||
|
category_operator: Some("SINGLE-OP".to_string()),
|
||||||
|
category_power: Some("LOW".to_string()),
|
||||||
|
category_band: Some("ALL".to_string()),
|
||||||
|
category_mode: Some("MIXED".to_string()),
|
||||||
|
claimed_score: None,
|
||||||
|
operators: None,
|
||||||
|
name: None,
|
||||||
|
email: None,
|
||||||
|
soapbox: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mode letters Cabrillo uses, which are not ADIF's names.
|
||||||
|
fn cabrillo_mode(qso: &Qso) -> &'static str {
|
||||||
|
match qso.mode.trim().to_uppercase().as_str() {
|
||||||
|
"CW" | "CWR" => "CW",
|
||||||
|
"SSB" | "USB" | "LSB" | "AM" | "FM" | "SAM" | "WFM" | "PHONE" => {
|
||||||
|
// FM has a letter of its own; the rest of phone is PH.
|
||||||
|
if matches!(qso.mode.trim().to_uppercase().as_str(), "FM" | "WFM") {
|
||||||
|
"FM"
|
||||||
|
} else {
|
||||||
|
"PH"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"RTTY" => "RY",
|
||||||
|
_ => "DG",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Band designators above 30 MHz, where Cabrillo names the band rather than
|
||||||
|
/// the frequency.
|
||||||
|
const VHF_DESIGNATORS: &[(u64, u64, &str)] = &[
|
||||||
|
(50_000_000, 54_000_000, "50"),
|
||||||
|
(70_000_000, 71_000_000, "70"),
|
||||||
|
(144_000_000, 148_000_000, "144"),
|
||||||
|
(222_000_000, 225_000_000, "222"),
|
||||||
|
(420_000_000, 450_000_000, "432"),
|
||||||
|
(902_000_000, 928_000_000, "902"),
|
||||||
|
(1_240_000_000, 1_300_000_000, "1.2G"),
|
||||||
|
(2_300_000_000, 2_450_000_000, "2.3G"),
|
||||||
|
(3_300_000_000, 3_500_000_000, "3.4G"),
|
||||||
|
(5_650_000_000, 5_925_000_000, "5.7G"),
|
||||||
|
(10_000_000_000, 10_500_000_000, "10G"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The frequency field: kilohertz below 30 MHz, a band designator above it.
|
||||||
|
fn cabrillo_frequency(hz: u64) -> String {
|
||||||
|
if hz < 30_000_000 {
|
||||||
|
return (hz / 1000).to_string();
|
||||||
|
}
|
||||||
|
VHF_DESIGNATORS
|
||||||
|
.iter()
|
||||||
|
.find(|(low, high, _)| hz >= *low && hz <= *high)
|
||||||
|
.map_or_else(
|
||||||
|
|| (hz / 1000).to_string(),
|
||||||
|
|(_, _, name)| (*name).to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_line(out: &mut String, key: &str, value: Option<&str>) {
|
||||||
|
if let Some(value) = value {
|
||||||
|
let value = value.trim();
|
||||||
|
if !value.is_empty() {
|
||||||
|
out.push_str(&format!("{key}: {value}\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a Cabrillo 3.0 entry for `qsos`.
|
||||||
|
///
|
||||||
|
/// The caller decides which contacts belong to the entry; this writes what it
|
||||||
|
/// is given, in the order it is given, oldest first as sponsors expect.
|
||||||
|
pub fn write_cabrillo(qsos: &[Qso], header: &CabrilloHeader) -> String {
|
||||||
|
let mut out = String::with_capacity(qsos.len() * 96 + 512);
|
||||||
|
out.push_str("START-OF-LOG: 3.0\n");
|
||||||
|
header_line(&mut out, "CREATED-BY", Some("trx-rs"));
|
||||||
|
header_line(&mut out, "CONTEST", header.contest.as_deref());
|
||||||
|
header_line(&mut out, "CALLSIGN", header.callsign.as_deref());
|
||||||
|
header_line(
|
||||||
|
&mut out,
|
||||||
|
"CATEGORY-OPERATOR",
|
||||||
|
header.category_operator.as_deref(),
|
||||||
|
);
|
||||||
|
header_line(&mut out, "CATEGORY-BAND", header.category_band.as_deref());
|
||||||
|
header_line(&mut out, "CATEGORY-MODE", header.category_mode.as_deref());
|
||||||
|
header_line(&mut out, "CATEGORY-POWER", header.category_power.as_deref());
|
||||||
|
if let Some(score) = header.claimed_score {
|
||||||
|
header_line(&mut out, "CLAIMED-SCORE", Some(&score.to_string()));
|
||||||
|
}
|
||||||
|
header_line(&mut out, "OPERATORS", header.operators.as_deref());
|
||||||
|
header_line(&mut out, "NAME", header.name.as_deref());
|
||||||
|
header_line(&mut out, "EMAIL", header.email.as_deref());
|
||||||
|
header_line(&mut out, "SOAPBOX", header.soapbox.as_deref());
|
||||||
|
|
||||||
|
// Oldest first: a contest log is read as it was worked.
|
||||||
|
let mut ordered: Vec<&Qso> = qsos.iter().collect();
|
||||||
|
ordered.sort_by_key(|qso| qso.started_at);
|
||||||
|
|
||||||
|
let station = header
|
||||||
|
.callsign
|
||||||
|
.clone()
|
||||||
|
.or_else(|| qsos.first().and_then(|qso| qso.station_callsign.clone()))
|
||||||
|
.unwrap_or_else(|| "UNKNOWN".to_string());
|
||||||
|
|
||||||
|
for qso in ordered {
|
||||||
|
let sent = qso.exchange_sent().unwrap_or_default();
|
||||||
|
let received = qso.exchange_received().unwrap_or_default();
|
||||||
|
out.push_str(&format!(
|
||||||
|
"QSO: {freq:>5} {mode:2} {date} {time} {mine:<13} {rst_sent:>3} {exch_sent:<6} {theirs:<13} {rst_rcvd:>3} {exch_rcvd:<6}\n",
|
||||||
|
freq = cabrillo_frequency(qso.freq_hz),
|
||||||
|
mode = cabrillo_mode(qso),
|
||||||
|
date = qso.started_at.format("%Y-%m-%d"),
|
||||||
|
time = qso.started_at.format("%H%M"),
|
||||||
|
mine = qso.station_callsign.as_deref().unwrap_or(&station),
|
||||||
|
rst_sent = qso.rst_sent.as_deref().unwrap_or(""),
|
||||||
|
exch_sent = sent,
|
||||||
|
theirs = qso.call,
|
||||||
|
rst_rcvd = qso.rst_rcvd.as_deref().unwrap_or(""),
|
||||||
|
exch_rcvd = received,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push_str("END-OF-LOG:\n");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::qso::parse_adif_datetime;
|
||||||
|
|
||||||
|
fn contest_qso(call: &str, hz: u64, mode: &str, time: &str, sent: u32, rcvd: u32) -> Qso {
|
||||||
|
let mut qso = Qso::new(
|
||||||
|
format!("{call}-{time}"),
|
||||||
|
parse_adif_datetime("20260807", Some(time)).expect("time"),
|
||||||
|
call,
|
||||||
|
hz,
|
||||||
|
mode,
|
||||||
|
);
|
||||||
|
qso.station_callsign = Some("SP0TRX".into());
|
||||||
|
qso.rst_sent = Some("59".into());
|
||||||
|
qso.rst_rcvd = Some("59".into());
|
||||||
|
qso.stx = Some(sent);
|
||||||
|
qso.srx = Some(rcvd);
|
||||||
|
qso.contest_id = Some("CQ-WW-SSB".into());
|
||||||
|
qso
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_entry_has_the_header_a_sponsor_expects() {
|
||||||
|
let header = CabrilloHeader {
|
||||||
|
contest: Some("CQ-WW-SSB".into()),
|
||||||
|
callsign: Some("SP0TRX".into()),
|
||||||
|
claimed_score: Some(1234),
|
||||||
|
..CabrilloHeader::default()
|
||||||
|
};
|
||||||
|
let text = write_cabrillo(
|
||||||
|
&[contest_qso("DL1AB", 14_200_000, "SSB", "120000", 1, 42)],
|
||||||
|
&header,
|
||||||
|
);
|
||||||
|
assert!(text.starts_with("START-OF-LOG: 3.0\n"), "{text}");
|
||||||
|
assert!(text.contains("CONTEST: CQ-WW-SSB\n"), "{text}");
|
||||||
|
assert!(text.contains("CALLSIGN: SP0TRX\n"), "{text}");
|
||||||
|
assert!(text.contains("CATEGORY-OPERATOR: SINGLE-OP\n"), "{text}");
|
||||||
|
assert!(text.contains("CLAIMED-SCORE: 1234\n"), "{text}");
|
||||||
|
assert!(text.trim_end().ends_with("END-OF-LOG:"), "{text}");
|
||||||
|
// A header field nobody filled in is left out, not written empty.
|
||||||
|
assert!(!text.contains("SOAPBOX:"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_qso_line_carries_both_sides_of_the_exchange() {
|
||||||
|
let text = write_cabrillo(
|
||||||
|
&[contest_qso("DL1AB", 14_200_000, "SSB", "120000", 1, 42)],
|
||||||
|
&CabrilloHeader::default(),
|
||||||
|
);
|
||||||
|
let line = text
|
||||||
|
.lines()
|
||||||
|
.find(|line| line.starts_with("QSO:"))
|
||||||
|
.expect("a QSO line");
|
||||||
|
// Frequency in kHz on HF, the phone mode letter, the date and UTC time,
|
||||||
|
// then mine, my report and my serial, then theirs.
|
||||||
|
assert!(line.contains("14200"), "{line}");
|
||||||
|
assert!(line.contains(" PH "), "{line}");
|
||||||
|
assert!(line.contains("2026-08-07 1200"), "{line}");
|
||||||
|
assert!(line.contains("SP0TRX"), "{line}");
|
||||||
|
assert!(line.contains("DL1AB"), "{line}");
|
||||||
|
assert!(line.contains("001"), "{line}");
|
||||||
|
assert!(line.contains("042"), "{line}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Above 30 MHz Cabrillo names the band rather than the frequency.
|
||||||
|
#[test]
|
||||||
|
fn vhf_and_up_are_written_as_band_designators() {
|
||||||
|
assert_eq!(cabrillo_frequency(14_200_000), "14200");
|
||||||
|
assert_eq!(cabrillo_frequency(7_030_000), "7030");
|
||||||
|
assert_eq!(cabrillo_frequency(144_300_000), "144");
|
||||||
|
assert_eq!(cabrillo_frequency(432_100_000), "432");
|
||||||
|
assert_eq!(cabrillo_frequency(1_296_000_000), "1.2G");
|
||||||
|
assert_eq!(cabrillo_frequency(50_313_000), "50");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_mode_letters_are_cabrillos_not_adifs() {
|
||||||
|
let cw = contest_qso("DL1AB", 7_030_000, "CW", "120000", 1, 1);
|
||||||
|
let phone = contest_qso("DL1AB", 14_200_000, "SSB", "120000", 1, 1);
|
||||||
|
let fm = contest_qso("DL1AB", 145_500_000, "FM", "120000", 1, 1);
|
||||||
|
let digital = contest_qso("DL1AB", 14_074_000, "FT8", "120000", 1, 1);
|
||||||
|
assert_eq!(cabrillo_mode(&cw), "CW");
|
||||||
|
assert_eq!(cabrillo_mode(&phone), "PH");
|
||||||
|
assert_eq!(cabrillo_mode(&fm), "FM");
|
||||||
|
assert_eq!(cabrillo_mode(&digital), "DG");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contacts_are_written_oldest_first_however_they_arrive() {
|
||||||
|
let text = write_cabrillo(
|
||||||
|
&[
|
||||||
|
contest_qso("LATER", 14_200_000, "SSB", "130000", 2, 2),
|
||||||
|
contest_qso("EARLIER", 14_200_000, "SSB", "120000", 1, 1),
|
||||||
|
],
|
||||||
|
&CabrilloHeader::default(),
|
||||||
|
);
|
||||||
|
let calls: Vec<&str> = text
|
||||||
|
.lines()
|
||||||
|
.filter(|line| line.starts_with("QSO:"))
|
||||||
|
.map(|line| {
|
||||||
|
if line.contains("EARLIER") {
|
||||||
|
"EARLIER"
|
||||||
|
} else {
|
||||||
|
"LATER"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(calls, vec!["EARLIER", "LATER"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A word exchange -- a zone, a section, a name -- rather than a serial.
|
||||||
|
#[test]
|
||||||
|
fn an_exchange_that_is_not_a_number_is_written_as_it_was() {
|
||||||
|
let mut qso = contest_qso("DL1AB", 14_200_000, "SSB", "120000", 1, 1);
|
||||||
|
qso.stx_string = Some("14".into());
|
||||||
|
qso.srx_string = Some("BAVARIA".into());
|
||||||
|
let text = write_cabrillo(&[qso], &CabrilloHeader::default());
|
||||||
|
let line = text.lines().find(|l| l.starts_with("QSO:")).expect("line");
|
||||||
|
assert!(line.contains("BAVARIA"), "{line}");
|
||||||
|
assert!(!line.contains("001"), "{line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
//! from the rig that made it, because rigs can be in different places.
|
//! from the rig that made it, because rigs can be in different places.
|
||||||
|
|
||||||
pub mod adif;
|
pub mod adif;
|
||||||
|
pub mod cabrillo;
|
||||||
pub mod dedupe;
|
pub mod dedupe;
|
||||||
pub mod qso;
|
pub mod qso;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
@@ -21,6 +22,7 @@ use std::sync::{Arc, Mutex};
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub use cabrillo::{write_cabrillo, CabrilloHeader};
|
||||||
pub use dedupe::{worked_before, DuplicateIndex, MATCH_WINDOW_MINUTES};
|
pub use dedupe::{worked_before, DuplicateIndex, MATCH_WINDOW_MINUTES};
|
||||||
pub use qso::Qso;
|
pub use qso::Qso;
|
||||||
pub use store::LogStore;
|
pub use store::LogStore;
|
||||||
@@ -47,6 +49,13 @@ pub struct LogQuery {
|
|||||||
/// Substring of the callsign, case-insensitive.
|
/// Substring of the callsign, case-insensitive.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub call: Option<String>,
|
pub call: Option<String>,
|
||||||
|
/// Only contacts in this contest.
|
||||||
|
#[serde(default)]
|
||||||
|
pub contest: Option<String>,
|
||||||
|
/// Only contacts the other station has confirmed, or only those they have
|
||||||
|
/// not.
|
||||||
|
#[serde(default)]
|
||||||
|
pub confirmed: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub band: Option<String>,
|
pub band: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -62,6 +71,16 @@ pub struct LogQuery {
|
|||||||
pub offset: Option<usize>,
|
pub offset: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One band's worth of the log.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct BandStatistics {
|
||||||
|
pub band: String,
|
||||||
|
pub contacts: usize,
|
||||||
|
/// Distinct callsigns, which is the number an award counts.
|
||||||
|
pub stations: usize,
|
||||||
|
pub confirmed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
/// What an import did.
|
/// What an import did.
|
||||||
#[derive(Debug, Default, Clone, Serialize)]
|
#[derive(Debug, Default, Clone, Serialize)]
|
||||||
pub struct ImportOutcome {
|
pub struct ImportOutcome {
|
||||||
@@ -106,6 +125,7 @@ impl Logbook {
|
|||||||
let call = query.call.as_deref().map(str::to_uppercase);
|
let call = query.call.as_deref().map(str::to_uppercase);
|
||||||
let band = query.band.as_deref().map(str::to_lowercase);
|
let band = query.band.as_deref().map(str::to_lowercase);
|
||||||
let mode = query.mode.as_deref().map(str::to_uppercase);
|
let mode = query.mode.as_deref().map(str::to_uppercase);
|
||||||
|
let contest = query.contest.clone();
|
||||||
let matching: Vec<Qso> = store
|
let matching: Vec<Qso> = store
|
||||||
.all()
|
.all()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -117,6 +137,14 @@ impl Logbook {
|
|||||||
&& mode.as_ref().is_none_or(|m| qso.mode == *m)
|
&& mode.as_ref().is_none_or(|m| qso.mode == *m)
|
||||||
&& query.from.is_none_or(|from| qso.started_at >= from)
|
&& query.from.is_none_or(|from| qso.started_at >= from)
|
||||||
&& query.to.is_none_or(|to| qso.started_at <= to)
|
&& query.to.is_none_or(|to| qso.started_at <= to)
|
||||||
|
&& contest.as_ref().is_none_or(|wanted| {
|
||||||
|
qso.contest_id
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|held| held.eq_ignore_ascii_case(wanted))
|
||||||
|
})
|
||||||
|
&& query
|
||||||
|
.confirmed
|
||||||
|
.is_none_or(|wanted| qso.is_confirmed() == wanted)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let offset = query.offset.unwrap_or(0).min(matching.len());
|
let offset = query.offset.unwrap_or(0).min(matching.len());
|
||||||
@@ -146,6 +174,52 @@ impl Logbook {
|
|||||||
self.with_store(|store| dedupe::worked_before(&store.all(), call))
|
self.with_store(|store| dedupe::worked_before(&store.all(), call))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Export as Cabrillo, for submitting a contest entry.
|
||||||
|
pub fn export_cabrillo(&self, query: &LogQuery, header: &CabrilloHeader) -> String {
|
||||||
|
cabrillo::write_cabrillo(&self.query(query), header)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What has been worked, and what has been confirmed, band by band.
|
||||||
|
///
|
||||||
|
/// One confirmation counts wherever it came from: an award wants a card or
|
||||||
|
/// an electronic match, not one of each.
|
||||||
|
pub fn band_statistics(&self) -> Vec<BandStatistics> {
|
||||||
|
self.with_store(|store| {
|
||||||
|
let mut by_band: std::collections::HashMap<String, BandStatistics> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
let mut stations: std::collections::HashMap<String, std::collections::HashSet<String>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for qso in store.all() {
|
||||||
|
let band = qso.band().unwrap_or("other").to_string();
|
||||||
|
let entry = by_band
|
||||||
|
.entry(band.clone())
|
||||||
|
.or_insert_with(|| BandStatistics {
|
||||||
|
band: band.clone(),
|
||||||
|
contacts: 0,
|
||||||
|
stations: 0,
|
||||||
|
confirmed: 0,
|
||||||
|
});
|
||||||
|
entry.contacts += 1;
|
||||||
|
if qso.is_confirmed() {
|
||||||
|
entry.confirmed += 1;
|
||||||
|
}
|
||||||
|
stations.entry(band).or_default().insert(qso.call.clone());
|
||||||
|
}
|
||||||
|
let mut all: Vec<BandStatistics> = by_band
|
||||||
|
.into_values()
|
||||||
|
.map(|mut entry| {
|
||||||
|
entry.stations = stations
|
||||||
|
.get(&entry.band)
|
||||||
|
.map_or(0, std::collections::HashSet::len);
|
||||||
|
entry
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// Ordered by wavelength, longest first, as a band plan reads.
|
||||||
|
all.sort_by_key(|entry| qso::band_order(&entry.band));
|
||||||
|
all
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Export as ADIF, honouring the same filters as a read.
|
/// Export as ADIF, honouring the same filters as a read.
|
||||||
pub fn export_adi(&self, query: &LogQuery, program_version: &str) -> String {
|
pub fn export_adi(&self, query: &LogQuery, program_version: &str) -> String {
|
||||||
adif::write_adi(&self.query(query), program_version)
|
adif::write_adi(&self.query(query), program_version)
|
||||||
@@ -294,6 +368,132 @@ mod tests {
|
|||||||
assert!(book.worked_before("SP9ZZ").is_empty());
|
assert!(book.worked_before("SP9ZZ").is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn statistics_count_stations_and_confirmations_band_by_band() {
|
||||||
|
let (_dir, book) = new_book();
|
||||||
|
let mut first = qso("SP1AA", 14_074_000, "FT8", "120000");
|
||||||
|
first.lotw_qsl_rcvd = Some("Y".into());
|
||||||
|
book.put(first).expect("put");
|
||||||
|
// The same station again on the same band: one station, two contacts.
|
||||||
|
book.put(qso("SP1AA", 14_100_000, "SSB", "121000"))
|
||||||
|
.expect("put");
|
||||||
|
let mut third = qso("DL2BB", 7_030_000, "CW", "130000");
|
||||||
|
third.qsl_rcvd = Some("Y".into());
|
||||||
|
book.put(third).expect("put");
|
||||||
|
book.put(qso("OZ3CC", 7_040_000, "CW", "131000"))
|
||||||
|
.expect("put");
|
||||||
|
|
||||||
|
let stats = book.band_statistics();
|
||||||
|
// Longest wavelength first, as a band plan reads.
|
||||||
|
assert_eq!(
|
||||||
|
stats.iter().map(|s| s.band.as_str()).collect::<Vec<_>>(),
|
||||||
|
vec!["40m", "20m"]
|
||||||
|
);
|
||||||
|
let forty = &stats[0];
|
||||||
|
assert_eq!((forty.contacts, forty.stations, forty.confirmed), (2, 2, 1));
|
||||||
|
let twenty = &stats[1];
|
||||||
|
assert_eq!(
|
||||||
|
(twenty.contacts, twenty.stations, twenty.confirmed),
|
||||||
|
(2, 1, 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_contest_entry_holds_only_that_contest() {
|
||||||
|
let (_dir, book) = new_book();
|
||||||
|
for (call, contest, serial) in [
|
||||||
|
("SP1AA", Some("CQ-WW-SSB"), 1),
|
||||||
|
("DL2BB", Some("CQ-WW-SSB"), 2),
|
||||||
|
("OZ3CC", None, 0),
|
||||||
|
] {
|
||||||
|
let mut entry = qso(call, 14_200_000, "SSB", "120000");
|
||||||
|
entry.contest_id = contest.map(str::to_string);
|
||||||
|
entry.stx = Some(serial);
|
||||||
|
entry.station_callsign = Some("SP0TRX".into());
|
||||||
|
entry.rst_sent = Some("59".into());
|
||||||
|
entry.rst_rcvd = Some("59".into());
|
||||||
|
book.put(entry).expect("put");
|
||||||
|
}
|
||||||
|
let query = LogQuery {
|
||||||
|
contest: Some("cq-ww-ssb".into()),
|
||||||
|
..LogQuery::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
book.query(&query).len(),
|
||||||
|
2,
|
||||||
|
"the contest filter is case-sensitive"
|
||||||
|
);
|
||||||
|
|
||||||
|
let text = book.export_cabrillo(
|
||||||
|
&query,
|
||||||
|
&CabrilloHeader {
|
||||||
|
contest: Some("CQ-WW-SSB".into()),
|
||||||
|
callsign: Some("SP0TRX".into()),
|
||||||
|
..CabrilloHeader::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let lines: Vec<&str> = text
|
||||||
|
.lines()
|
||||||
|
.filter(|line| line.starts_with("QSO:"))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(lines.len(), 2, "{text}");
|
||||||
|
assert!(
|
||||||
|
!text.contains("OZ3CC"),
|
||||||
|
"a contact outside the contest was submitted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confirmations_come_from_whichever_bureau_answered() {
|
||||||
|
let (_dir, book) = new_book();
|
||||||
|
let mut card = qso("SP1AA", 14_074_000, "FT8", "120000");
|
||||||
|
card.qsl_rcvd = Some("Y".into());
|
||||||
|
let mut lotw = qso("DL2BB", 14_074_000, "FT8", "121000");
|
||||||
|
lotw.lotw_qsl_rcvd = Some("Y".into());
|
||||||
|
let mut requested = qso("OZ3CC", 14_074_000, "FT8", "122000");
|
||||||
|
requested.qsl_rcvd = Some("R".into());
|
||||||
|
for entry in [card, lotw, requested] {
|
||||||
|
book.put(entry).expect("put");
|
||||||
|
}
|
||||||
|
let confirmed = LogQuery {
|
||||||
|
confirmed: Some(true),
|
||||||
|
..LogQuery::default()
|
||||||
|
};
|
||||||
|
let calls: Vec<String> = book.query(&confirmed).into_iter().map(|q| q.call).collect();
|
||||||
|
assert_eq!(calls.len(), 2, "{calls:?}");
|
||||||
|
assert!(calls.contains(&"SP1AA".to_string()) && calls.contains(&"DL2BB".to_string()));
|
||||||
|
// Requested is not confirmed.
|
||||||
|
let outstanding = LogQuery {
|
||||||
|
confirmed: Some(false),
|
||||||
|
..LogQuery::default()
|
||||||
|
};
|
||||||
|
assert_eq!(book.query(&outstanding)[0].call, "OZ3CC");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_contest_and_qsl_fields_survive_a_trip_through_adif() {
|
||||||
|
let (_dir, book) = new_book();
|
||||||
|
let mut entry = qso("SP1AA", 14_200_000, "SSB", "120000");
|
||||||
|
entry.contest_id = Some("CQ-WW-SSB".into());
|
||||||
|
entry.stx = Some(7);
|
||||||
|
entry.srx_string = Some("BAVARIA".into());
|
||||||
|
entry.cqz = Some(15);
|
||||||
|
entry.qsl_sent = Some("Y".into());
|
||||||
|
entry.lotw_qsl_rcvd = Some("Y".into());
|
||||||
|
book.put(entry).expect("put");
|
||||||
|
|
||||||
|
let exported = book.export_adi(&LogQuery::default(), "0.1.0");
|
||||||
|
let (_other_dir, other) = new_book();
|
||||||
|
other.import_adi(exported.as_bytes()).expect("import");
|
||||||
|
let back = &other.query(&LogQuery::default())[0];
|
||||||
|
assert_eq!(back.contest_id.as_deref(), Some("CQ-WW-SSB"));
|
||||||
|
assert_eq!(back.stx, Some(7));
|
||||||
|
assert_eq!(back.srx_string.as_deref(), Some("BAVARIA"));
|
||||||
|
assert_eq!(back.cqz, Some(15));
|
||||||
|
assert_eq!(back.qsl_sent.as_deref(), Some("Y"));
|
||||||
|
assert!(back.is_confirmed());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_contact_can_be_changed_and_forgotten() {
|
fn a_contact_can_be_changed_and_forgotten() {
|
||||||
let (_dir, book) = new_book();
|
let (_dir, book) = new_book();
|
||||||
|
|||||||
@@ -64,6 +64,39 @@ pub struct Qso {
|
|||||||
/// Which rig of this client, by id — ours, not ADIF's.
|
/// Which rig of this client, by id — ours, not ADIF's.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub rig_id: Option<String>,
|
pub rig_id: Option<String>,
|
||||||
|
/// The contest this contact counts towards, by its Cabrillo name.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub contest_id: Option<String>,
|
||||||
|
/// Serial sent. Kept as text as well as a number, because plenty of
|
||||||
|
/// exchanges are neither — a zone, a section, a name, a power.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub stx: Option<u32>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub stx_string: Option<String>,
|
||||||
|
/// Serial received, likewise.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub srx: Option<u32>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub srx_string: Option<String>,
|
||||||
|
/// CQ and ITU zones of the station worked.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cqz: Option<u8>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ituz: Option<u8>,
|
||||||
|
/// Paper QSL, and the two card-less bureaux, as ADIF's single letters:
|
||||||
|
/// `Y` yes, `N` no, `R` requested, `I` ignore, `Q` queued, `V` verified.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub qsl_sent: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub qsl_rcvd: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub lotw_qsl_sent: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub lotw_qsl_rcvd: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub eqsl_qsl_sent: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub eqsl_qsl_rcvd: Option<String>,
|
||||||
/// ADIF fields this application does not model, kept verbatim.
|
/// ADIF fields this application does not model, kept verbatim.
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
pub extra: BTreeMap<String, String>,
|
pub extra: BTreeMap<String, String>,
|
||||||
@@ -98,6 +131,19 @@ impl Qso {
|
|||||||
my_gridsquare: None,
|
my_gridsquare: None,
|
||||||
my_rig: None,
|
my_rig: None,
|
||||||
rig_id: None,
|
rig_id: None,
|
||||||
|
contest_id: None,
|
||||||
|
stx: None,
|
||||||
|
stx_string: None,
|
||||||
|
srx: None,
|
||||||
|
srx_string: None,
|
||||||
|
cqz: None,
|
||||||
|
ituz: None,
|
||||||
|
qsl_sent: None,
|
||||||
|
qsl_rcvd: None,
|
||||||
|
lotw_qsl_sent: None,
|
||||||
|
lotw_qsl_rcvd: None,
|
||||||
|
eqsl_qsl_sent: None,
|
||||||
|
eqsl_qsl_rcvd: None,
|
||||||
extra: BTreeMap::new(),
|
extra: BTreeMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,6 +153,34 @@ impl Qso {
|
|||||||
band_for_hz(self.freq_hz)
|
band_for_hz(self.freq_hz)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the other station has confirmed this contact, anywhere.
|
||||||
|
///
|
||||||
|
/// One confirmation is a confirmation: an award needs one card or one
|
||||||
|
/// electronic match, not all three, and a log that counted them separately
|
||||||
|
/// would tell the operator they were three contacts short of what they have.
|
||||||
|
pub fn is_confirmed(&self) -> bool {
|
||||||
|
[&self.qsl_rcvd, &self.lotw_qsl_rcvd, &self.eqsl_qsl_rcvd]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.any(|state| {
|
||||||
|
let state = state.trim().to_uppercase();
|
||||||
|
state == "Y" || state == "V"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The exchange as it goes into a contest log: what was sent, or received.
|
||||||
|
pub fn exchange_sent(&self) -> Option<String> {
|
||||||
|
self.stx_string
|
||||||
|
.clone()
|
||||||
|
.or_else(|| self.stx.map(|serial| format!("{serial:03}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn exchange_received(&self) -> Option<String> {
|
||||||
|
self.srx_string
|
||||||
|
.clone()
|
||||||
|
.or_else(|| self.srx.map(|serial| format!("{serial:03}")))
|
||||||
|
}
|
||||||
|
|
||||||
/// What two logs have to agree on to be describing the same contact.
|
/// What two logs have to agree on to be describing the same contact.
|
||||||
///
|
///
|
||||||
/// Not the time: that is compared as a window by [`crate::dedupe`], because
|
/// Not the time: that is compared as a window by [`crate::dedupe`], because
|
||||||
@@ -183,6 +257,17 @@ pub fn band_midpoint_for_name(band: &str) -> Option<u64> {
|
|||||||
.map(|(_, low, high)| low + (high - low) / 2)
|
.map(|(_, low, high)| low + (high - low) / 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where a band sits in the table, for ordering a report by wavelength.
|
||||||
|
///
|
||||||
|
/// Anything not in the table sorts last, which is where "other" belongs.
|
||||||
|
pub fn band_order(band: &str) -> usize {
|
||||||
|
let wanted = band.trim().to_lowercase();
|
||||||
|
BANDS
|
||||||
|
.iter()
|
||||||
|
.position(|(name, _, _)| *name == wanted)
|
||||||
|
.unwrap_or(BANDS.len())
|
||||||
|
}
|
||||||
|
|
||||||
/// The frequency in MHz, as ADIF writes it.
|
/// The frequency in MHz, as ADIF writes it.
|
||||||
pub fn freq_mhz_string(hz: u64) -> String {
|
pub fn freq_mhz_string(hz: u64) -> String {
|
||||||
// Six decimals is 1 Hz, which is finer than any dial reports and is what
|
// Six decimals is 1 Hz, which is finer than any dial reports and is what
|
||||||
|
|||||||
Reference in New Issue
Block a user