diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/logbook.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/logbook.js
index 581bae1f..089af5f6 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/logbook.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/logbook.js
@@ -31,6 +31,16 @@ var importFile = el("log-import-file");
var exportLink = el("log-export-btn");
var clearBtn = el("log-clear-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 entryRigId = null;
var entryRigName = null;
@@ -55,6 +65,12 @@ function parseFreq(text) {
if (!Number.isFinite(value) || value <= 0) return null;
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) {
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
@@ -96,7 +112,7 @@ function showClock(prefill) {
clockEl.classList.toggle("is-adrift", drift > 1e3);
}
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 (workedEl) workedEl.textContent = "";
@@ -131,6 +147,13 @@ async function submitEntry(event) {
gridsquare: gridInput?.value ?? null,
name: nameInput?.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,
operator: operatorInput?.value ?? null,
my_gridsquare: entryGrid,
@@ -149,7 +172,9 @@ async function submitEntry(event) {
throw new Error(detail.error ?? `HTTP ${String(response.status)}`);
}
notify(`${call} logged`);
+ const sent = numberOrNull(stxInput?.value);
resetEntry();
+ if (stxInput && sent != null) stxInput.value = String(sent + 1);
await refreshLog();
} catch (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"}`;
}
if (exportLink) exportLink.href = `/api/logbook/export.adi${query ? `?${query}` : ""}`;
+ await refreshStatistics();
} catch (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 = '
| Nothing worked yet. |
';
+ 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() {
if (!rowsBody) return;
if (qsos.length === 0) {
@@ -224,7 +290,8 @@ function renderRows() {
qso.rst_sent ?? "",
qso.rst_rcvd ?? "",
qso.gridsquare ?? "",
- qso.my_rig ?? ""
+ qso.my_rig ?? "",
+ qso.confirmed ? "✓" : ""
];
for (const [index, value] of cells.entries()) {
const cell = document.createElement("td");
@@ -233,6 +300,15 @@ function renderRows() {
row.appendChild(cell);
}
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");
remove.type = "button";
remove.className = "log-row-btn";
@@ -262,6 +338,24 @@ function renderFilterOptions() {
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) {
const confirmed = await bridge.trxUi.confirm({
title: "Delete this contact?",
@@ -314,6 +408,10 @@ for (const control of [filterCall, filterBand, filterMode]) {
void refreshLog();
});
}
+for (const control of [cabrilloContest, cabrilloCallsign, cabrilloOperator, cabrilloPower, cabrilloScore]) {
+ control?.addEventListener("input", syncCabrilloLink);
+ control?.addEventListener("change", syncCabrilloLink);
+}
importBtn?.addEventListener("click", () => {
importFile?.click();
});
@@ -327,5 +425,9 @@ bridge.logContact = (seed) => {
void openEntry(seed).then(() => callInput?.focus());
};
renderStation();
+if (cabrilloCallsign && !cabrilloCallsign.value) {
+ cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
+}
+syncCabrilloLink();
void openEntry();
void refreshLog();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
index 6d5ec71a..fa524ee5 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
@@ -543,6 +543,24 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
+ Contest exchange
+
+
+
+
+
+
@@ -581,6 +599,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
Rcvd |
Locator |
Rig |
+ QSL |
Actions |
@@ -588,6 +607,68 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
+
+ Bands worked
+
+
+
+
+ | Band |
+ Contacts |
+ Stations |
+ Confirmed |
+
+
+
+
+
+
+
+
+ Contest entry (Cabrillo)
+
+ Contest logs are submitted in Cabrillo and rejected in anything else.
+ Only the contacts of the contest named here go into the entry.
+
+
+
+
+
+
+
+
+
+
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
index fbd1b51d..7d136827 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
@@ -6633,3 +6633,35 @@ body[data-operator-layout="ham"] #rds-panel { display: none !important; }
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;
+}
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/logbook.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/logbook.ts
index e1e7f9a1..3df96e12 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/logbook.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/logbook.ts
@@ -32,6 +32,20 @@ interface Qso {
my_gridsquare?: string | null;
my_rig?: 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 {
@@ -86,6 +100,16 @@ const importFile = el
("log-import-file");
const exportLink = el("log-export-btn");
const clearBtn = el("log-clear-btn");
const saveBtn = el("log-save-btn");
+const contestIdInput = el("log-contest-id");
+const stxInput = el("log-stx");
+const srxInput = el("log-srx");
+const statisticsRows = el("log-statistics-rows");
+const cabrilloContest = el("log-cbr-contest");
+const cabrilloCallsign = el("log-cbr-callsign");
+const cabrilloOperator = el("log-cbr-operator");
+const cabrilloPower = el("log-cbr-power");
+const cabrilloScore = el("log-cbr-score");
+const cabrilloExport = el("log-cbr-export");
/** The time the open entry was started, as the server gave it. */
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);
}
+/** 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 {
const date = new Date(iso);
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. */
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 (workedEl) workedEl.textContent = "";
@@ -214,6 +248,13 @@ async function submitEntry(event: Event): Promise {
gridsquare: gridInput?.value ?? null,
name: nameInput?.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,
operator: operatorInput?.value ?? null,
my_gridsquare: entryGrid,
@@ -232,7 +273,11 @@ async function submitEntry(event: Event): Promise {
throw new Error(detail.error ?? `HTTP ${String(response.status)}`);
}
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();
+ if (stxInput && sent != null) stxInput.value = String(sent + 1);
await refreshLog();
} catch (error: unknown) {
notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error");
@@ -292,11 +337,57 @@ async function refreshLog(): Promise {
: `${String(answer.total)} contact${answer.total === 1 ? "" : "s"}`;
}
if (exportLink) exportLink.href = `/api/logbook/export.adi${query ? `?${query}` : ""}`;
+ await refreshStatistics();
} catch (error: unknown) {
console.error("logbook read failed", error);
}
}
+/** What has been worked and confirmed, band by band. */
+async function refreshStatistics(): Promise {
+ if (!statisticsRows) return;
+ try {
+ const answer = await getJson<{ bands: BandStatistics[] }>("/api/logbook/statistics");
+ if (answer.bands.length === 0) {
+ statisticsRows.innerHTML = '| Nothing worked yet. |
';
+ 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 {
if (!rowsBody) return;
if (qsos.length === 0) {
@@ -319,6 +410,7 @@ function renderRows(): void {
qso.rst_rcvd ?? "",
qso.gridsquare ?? "",
qso.my_rig ?? "",
+ qso.confirmed ? "✓" : "",
];
for (const [index, value] of cells.entries()) {
const cell = document.createElement("td");
@@ -327,6 +419,17 @@ function renderRows(): void {
row.appendChild(cell);
}
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");
remove.type = "button";
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 {
+ 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 {
const confirmed = await bridge.trxUi.confirm({
title: "Delete this contact?",
@@ -412,6 +535,10 @@ for (const control of [filterCall, filterBand, filterMode]) {
control?.addEventListener("input", () => { 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(); });
importFile?.addEventListener("change", () => {
const file = importFile.files?.[0];
@@ -426,5 +553,9 @@ bridge.logContact = (seed) => {
};
renderStation();
+if (cabrilloCallsign && !cabrilloCallsign.value) {
+ cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
+}
+syncCabrilloLink();
void openEntry();
void refreshLog();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/logbook.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/logbook.mjs
index c441607e..093a9848 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/logbook.mjs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/logbook.mjs
@@ -137,6 +137,69 @@ try {
assert.equal(inHamLayout.layout, "ham");
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, []);
} finally {
await browser.close();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs
index 9463082b..b732692b 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs
@@ -309,6 +309,49 @@ export async function startWebFixture({
response.end(JSON.stringify({ call, worked }));
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") {
const body = logbook
.map((qso) => `${qso.call}\n`)
@@ -337,6 +380,10 @@ export async function startWebFixture({
gridsquare: input.gridsquare ? String(input.gridsquare).toUpperCase() : null,
my_rig: input.my_rig,
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);
response.writeHead(200, { "content-type": "application/json" });
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/logbook.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/logbook.rs
index 98e05945..732494fb 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/logbook.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/logbook.rs
@@ -27,12 +27,15 @@ struct QsoView {
#[serde(flatten)]
qso: Qso,
band: Option<&'static str>,
+ /// Whether the other station has confirmed, from whichever bureau answered.
+ confirmed: bool,
}
impl From for QsoView {
fn from(qso: Qso) -> Self {
Self {
band: qso.band(),
+ confirmed: qso.is_confirmed(),
qso,
}
}
@@ -79,6 +82,39 @@ struct QsoInput {
my_rig: Option,
#[serde(default)]
rig_id: Option,
+ #[serde(default)]
+ contest_id: Option,
+ #[serde(default)]
+ stx: Option,
+ #[serde(default)]
+ stx_string: Option,
+ #[serde(default)]
+ srx: Option,
+ #[serde(default)]
+ srx_string: Option,
+ #[serde(default)]
+ cqz: Option,
+ #[serde(default)]
+ ituz: Option,
+ #[serde(default)]
+ qsl_sent: Option,
+ #[serde(default)]
+ qsl_rcvd: Option,
+ #[serde(default)]
+ lotw_qsl_sent: Option,
+ #[serde(default)]
+ lotw_qsl_rcvd: Option,
+ #[serde(default)]
+ eqsl_qsl_sent: Option,
+ #[serde(default)]
+ eqsl_qsl_rcvd: Option,
+}
+
+/// 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) -> Option {
+ 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) -> Option {
@@ -123,6 +159,19 @@ impl QsoInput {
qso.my_gridsquare = blank_to_none(self.my_gridsquare).map(|g| g.to_uppercase());
qso.my_rig = blank_to_none(self.my_rig);
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
// model stays with it through an edit.
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,
+ header: web::Query,
+ logbook: web::Data>,
+) -> 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>) -> 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
/// worked on.
#[get("/api/logbook/worked/{call}")]
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs
index 21c8554a..72dd9ddb 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs
@@ -715,6 +715,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(logbook::edit_qso)
.service(logbook::delete_qso)
.service(logbook::export_adi)
+ .service(logbook::export_cabrillo)
+ .service(logbook::statistics)
.service(logbook::import_adi)
.service(logbook::worked_before)
.service(logbook::server_now)
@@ -1045,6 +1047,116 @@ mod tests {
assert!(text.contains(""), "{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.
#[actix_web::test]
async fn a_read_only_session_cannot_write_to_the_log() {
diff --git a/src/trx-client/trx-logbook/src/adif.rs b/src/trx-client/trx-logbook/src/adif.rs
index bb705e0f..da789876 100644
--- a/src/trx-client/trx-logbook/src/adif.rs
+++ b/src/trx-client/trx-logbook/src/adif.rs
@@ -47,8 +47,28 @@ const MODELLED: &[&str] = &[
"OPERATOR",
"MY_GRIDSQUARE",
"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 {
+ 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.
#[derive(Debug, Default, Clone)]
pub struct ParseReport {
@@ -215,6 +235,19 @@ fn qso_from_fields(fields: &[Field]) -> Result {
my_gridsquare: find(fields, "MY_GRIDSQUARE").map(|g| g.to_uppercase()),
my_rig: find(fields, "MY_RIG").map(str::to_string),
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,
})
}
@@ -273,11 +306,30 @@ pub fn write_adi(qsos: &[Qso], program_version: &str) -> String {
("OPERATOR", &qso.operator),
("MY_GRIDSQUARE", &qso.my_gridsquare),
("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 {
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 {
write_field(&mut out, "TX_PWR", &format!("{power}"));
}
diff --git a/src/trx-client/trx-logbook/src/cabrillo.rs b/src/trx-client/trx-logbook/src/cabrillo.rs
new file mode 100644
index 00000000..73fe0893
--- /dev/null
+++ b/src/trx-client/trx-logbook/src/cabrillo.rs
@@ -0,0 +1,304 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// 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,
+ /// The callsign the entry is submitted under.
+ #[serde(default)]
+ pub callsign: Option,
+ #[serde(default)]
+ pub category_operator: Option,
+ #[serde(default)]
+ pub category_power: Option,
+ #[serde(default)]
+ pub category_band: Option,
+ #[serde(default)]
+ pub category_mode: Option,
+ #[serde(default)]
+ pub claimed_score: Option,
+ #[serde(default)]
+ pub operators: Option,
+ #[serde(default)]
+ pub name: Option,
+ #[serde(default)]
+ pub email: Option,
+ #[serde(default)]
+ pub soapbox: Option,
+}
+
+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}");
+ }
+}
diff --git a/src/trx-client/trx-logbook/src/lib.rs b/src/trx-client/trx-logbook/src/lib.rs
index 5b61b5cf..2c462f00 100644
--- a/src/trx-client/trx-logbook/src/lib.rs
+++ b/src/trx-client/trx-logbook/src/lib.rs
@@ -11,6 +11,7 @@
//! from the rig that made it, because rigs can be in different places.
pub mod adif;
+pub mod cabrillo;
pub mod dedupe;
pub mod qso;
pub mod store;
@@ -21,6 +22,7 @@ use std::sync::{Arc, Mutex};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
+pub use cabrillo::{write_cabrillo, CabrilloHeader};
pub use dedupe::{worked_before, DuplicateIndex, MATCH_WINDOW_MINUTES};
pub use qso::Qso;
pub use store::LogStore;
@@ -47,6 +49,13 @@ pub struct LogQuery {
/// Substring of the callsign, case-insensitive.
#[serde(default)]
pub call: Option,
+ /// Only contacts in this contest.
+ #[serde(default)]
+ pub contest: Option,
+ /// Only contacts the other station has confirmed, or only those they have
+ /// not.
+ #[serde(default)]
+ pub confirmed: Option,
#[serde(default)]
pub band: Option,
#[serde(default)]
@@ -62,6 +71,16 @@ pub struct LogQuery {
pub offset: Option,
}
+/// 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.
#[derive(Debug, Default, Clone, Serialize)]
pub struct ImportOutcome {
@@ -106,6 +125,7 @@ impl Logbook {
let call = query.call.as_deref().map(str::to_uppercase);
let band = query.band.as_deref().map(str::to_lowercase);
let mode = query.mode.as_deref().map(str::to_uppercase);
+ let contest = query.contest.clone();
let matching: Vec = store
.all()
.into_iter()
@@ -117,6 +137,14 @@ impl Logbook {
&& mode.as_ref().is_none_or(|m| qso.mode == *m)
&& query.from.is_none_or(|from| qso.started_at >= from)
&& 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();
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))
}
+ /// 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 {
+ self.with_store(|store| {
+ let mut by_band: std::collections::HashMap =
+ std::collections::HashMap::new();
+ let mut stations: std::collections::HashMap> =
+ 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 = 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.
pub fn export_adi(&self, query: &LogQuery, program_version: &str) -> String {
adif::write_adi(&self.query(query), program_version)
@@ -294,6 +368,132 @@ mod tests {
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!["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 = 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]
fn a_contact_can_be_changed_and_forgotten() {
let (_dir, book) = new_book();
diff --git a/src/trx-client/trx-logbook/src/qso.rs b/src/trx-client/trx-logbook/src/qso.rs
index f927b5aa..0c24fbf7 100644
--- a/src/trx-client/trx-logbook/src/qso.rs
+++ b/src/trx-client/trx-logbook/src/qso.rs
@@ -64,6 +64,39 @@ pub struct Qso {
/// Which rig of this client, by id — ours, not ADIF's.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rig_id: Option,
+ /// The contest this contact counts towards, by its Cabrillo name.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub contest_id: Option,
+ /// 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,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub stx_string: Option,
+ /// Serial received, likewise.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub srx: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub srx_string: Option,
+ /// CQ and ITU zones of the station worked.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cqz: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub ituz: Option,
+ /// 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,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub qsl_rcvd: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub lotw_qsl_sent: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub lotw_qsl_rcvd: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub eqsl_qsl_sent: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub eqsl_qsl_rcvd: Option,
/// ADIF fields this application does not model, kept verbatim.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap,
@@ -98,6 +131,19 @@ impl Qso {
my_gridsquare: None,
my_rig: 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(),
}
}
@@ -107,6 +153,34 @@ impl Qso {
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 {
+ self.stx_string
+ .clone()
+ .or_else(|| self.stx.map(|serial| format!("{serial:03}")))
+ }
+
+ pub fn exchange_received(&self) -> Option {
+ 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.
///
/// 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 {
.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.
pub fn freq_mhz_string(hz: u64) -> String {
// Six decimals is 1 Hz, which is finer than any dial reports and is what