[feat](trx-logbook): work a contest, and record what came back #60

Merged
sjg merged 6 commits from feat/logbook-contest-and-qsl into main 2026-08-07 21:50:02 +02:00
12 changed files with 1296 additions and 3 deletions
Showing only changes of commit ee185b98bc - Show all commits
@@ -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 = '<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() {
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();
@@ -543,6 +543,24 @@ SPDX-License-Identifier: GPL-2.0-or-later
<input type="text" id="log-comment" class="status-input" />
</label>
</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">
<span id="log-worked-before" class="log-worked" aria-live="polite"></span>
<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">Locator</th>
<th scope="col">Rig</th>
<th scope="col">QSL</th>
<th scope="col"><span class="visually-hidden">Actions</span></th>
</tr>
</thead>
@@ -588,6 +607,68 @@ SPDX-License-Identifier: GPL-2.0-or-later
</table>
</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 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;
}
}
/* 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_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<HTMLInputElement>("log-import-file");
const exportLink = el<HTMLAnchorElement>("log-export-btn");
const clearBtn = el<HTMLButtonElement>("log-clear-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. */
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<void> {
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<void> {
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<void> {
: `${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<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 {
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<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> {
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();
@@ -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();
@@ -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) => `<CALL:${qso.call.length}>${qso.call}<EOR>\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" });
@@ -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<Qso> 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<String>,
#[serde(default)]
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> {
@@ -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<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
/// worked on.
#[get("/api/logbook/worked/{call}")]
@@ -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("<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.
#[actix_web::test]
async fn a_read_only_session_cannot_write_to_the_log() {
+52
View File
@@ -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<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.
#[derive(Debug, Default, Clone)]
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_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}"));
}
+304
View File
@@ -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}");
}
}
+200
View File
@@ -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<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)]
pub band: Option<String>,
#[serde(default)]
@@ -62,6 +71,16 @@ pub struct LogQuery {
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.
#[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<Qso> = 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<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.
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<_>>(),
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]
fn a_contact_can_be_changed_and_forgotten() {
let (_dir, book) = new_book();
+85
View File
@@ -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<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.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, String>,
@@ -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<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.
///
/// 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)
}
/// 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