[feat](trx-logbook): work a contest, and record what came back #60
@@ -31,6 +31,16 @@ var importFile = el("log-import-file");
|
||||
var exportLink = el("log-export-btn");
|
||||
var 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() {
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Cabrillo 3.0, the format contest logs are submitted in.
|
||||
//!
|
||||
//! ADIF cannot do this job: sponsors take Cabrillo and reject everything else.
|
||||
//! It is export-only and narrower on purpose — a header naming the entrant and
|
||||
//! the category, then one fixed-shape `QSO:` line per contact carrying the
|
||||
//! frequency, the mode, the time, and both stations' calls, reports and
|
||||
//! exchanges. Everything a log holds beyond that is left behind, which is why
|
||||
//! this complements ADIF rather than replacing it.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::qso::Qso;
|
||||
|
||||
/// The header of a contest entry.
|
||||
///
|
||||
/// Sponsors want the entrant's own answers here — how many operators, how much
|
||||
/// power, which bands — and no log can work them out for itself, so they come
|
||||
/// from the operator with sane defaults behind them.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CabrilloHeader {
|
||||
/// The contest's Cabrillo name, e.g. `CQ-WW-SSB`.
|
||||
#[serde(default)]
|
||||
pub contest: Option<String>,
|
||||
/// The callsign the entry is submitted under.
|
||||
#[serde(default)]
|
||||
pub callsign: Option<String>,
|
||||
#[serde(default)]
|
||||
pub category_operator: Option<String>,
|
||||
#[serde(default)]
|
||||
pub category_power: Option<String>,
|
||||
#[serde(default)]
|
||||
pub category_band: Option<String>,
|
||||
#[serde(default)]
|
||||
pub category_mode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub claimed_score: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub operators: Option<String>,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub soapbox: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for CabrilloHeader {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
contest: None,
|
||||
callsign: None,
|
||||
category_operator: Some("SINGLE-OP".to_string()),
|
||||
category_power: Some("LOW".to_string()),
|
||||
category_band: Some("ALL".to_string()),
|
||||
category_mode: Some("MIXED".to_string()),
|
||||
claimed_score: None,
|
||||
operators: None,
|
||||
name: None,
|
||||
email: None,
|
||||
soapbox: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mode letters Cabrillo uses, which are not ADIF's names.
|
||||
fn cabrillo_mode(qso: &Qso) -> &'static str {
|
||||
match qso.mode.trim().to_uppercase().as_str() {
|
||||
"CW" | "CWR" => "CW",
|
||||
"SSB" | "USB" | "LSB" | "AM" | "FM" | "SAM" | "WFM" | "PHONE" => {
|
||||
// FM has a letter of its own; the rest of phone is PH.
|
||||
if matches!(qso.mode.trim().to_uppercase().as_str(), "FM" | "WFM") {
|
||||
"FM"
|
||||
} else {
|
||||
"PH"
|
||||
}
|
||||
}
|
||||
"RTTY" => "RY",
|
||||
_ => "DG",
|
||||
}
|
||||
}
|
||||
|
||||
/// Band designators above 30 MHz, where Cabrillo names the band rather than
|
||||
/// the frequency.
|
||||
const VHF_DESIGNATORS: &[(u64, u64, &str)] = &[
|
||||
(50_000_000, 54_000_000, "50"),
|
||||
(70_000_000, 71_000_000, "70"),
|
||||
(144_000_000, 148_000_000, "144"),
|
||||
(222_000_000, 225_000_000, "222"),
|
||||
(420_000_000, 450_000_000, "432"),
|
||||
(902_000_000, 928_000_000, "902"),
|
||||
(1_240_000_000, 1_300_000_000, "1.2G"),
|
||||
(2_300_000_000, 2_450_000_000, "2.3G"),
|
||||
(3_300_000_000, 3_500_000_000, "3.4G"),
|
||||
(5_650_000_000, 5_925_000_000, "5.7G"),
|
||||
(10_000_000_000, 10_500_000_000, "10G"),
|
||||
];
|
||||
|
||||
/// The frequency field: kilohertz below 30 MHz, a band designator above it.
|
||||
fn cabrillo_frequency(hz: u64) -> String {
|
||||
if hz < 30_000_000 {
|
||||
return (hz / 1000).to_string();
|
||||
}
|
||||
VHF_DESIGNATORS
|
||||
.iter()
|
||||
.find(|(low, high, _)| hz >= *low && hz <= *high)
|
||||
.map_or_else(
|
||||
|| (hz / 1000).to_string(),
|
||||
|(_, _, name)| (*name).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn header_line(out: &mut String, key: &str, value: Option<&str>) {
|
||||
if let Some(value) = value {
|
||||
let value = value.trim();
|
||||
if !value.is_empty() {
|
||||
out.push_str(&format!("{key}: {value}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a Cabrillo 3.0 entry for `qsos`.
|
||||
///
|
||||
/// The caller decides which contacts belong to the entry; this writes what it
|
||||
/// is given, in the order it is given, oldest first as sponsors expect.
|
||||
pub fn write_cabrillo(qsos: &[Qso], header: &CabrilloHeader) -> String {
|
||||
let mut out = String::with_capacity(qsos.len() * 96 + 512);
|
||||
out.push_str("START-OF-LOG: 3.0\n");
|
||||
header_line(&mut out, "CREATED-BY", Some("trx-rs"));
|
||||
header_line(&mut out, "CONTEST", header.contest.as_deref());
|
||||
header_line(&mut out, "CALLSIGN", header.callsign.as_deref());
|
||||
header_line(
|
||||
&mut out,
|
||||
"CATEGORY-OPERATOR",
|
||||
header.category_operator.as_deref(),
|
||||
);
|
||||
header_line(&mut out, "CATEGORY-BAND", header.category_band.as_deref());
|
||||
header_line(&mut out, "CATEGORY-MODE", header.category_mode.as_deref());
|
||||
header_line(&mut out, "CATEGORY-POWER", header.category_power.as_deref());
|
||||
if let Some(score) = header.claimed_score {
|
||||
header_line(&mut out, "CLAIMED-SCORE", Some(&score.to_string()));
|
||||
}
|
||||
header_line(&mut out, "OPERATORS", header.operators.as_deref());
|
||||
header_line(&mut out, "NAME", header.name.as_deref());
|
||||
header_line(&mut out, "EMAIL", header.email.as_deref());
|
||||
header_line(&mut out, "SOAPBOX", header.soapbox.as_deref());
|
||||
|
||||
// Oldest first: a contest log is read as it was worked.
|
||||
let mut ordered: Vec<&Qso> = qsos.iter().collect();
|
||||
ordered.sort_by_key(|qso| qso.started_at);
|
||||
|
||||
let station = header
|
||||
.callsign
|
||||
.clone()
|
||||
.or_else(|| qsos.first().and_then(|qso| qso.station_callsign.clone()))
|
||||
.unwrap_or_else(|| "UNKNOWN".to_string());
|
||||
|
||||
for qso in ordered {
|
||||
let sent = qso.exchange_sent().unwrap_or_default();
|
||||
let received = qso.exchange_received().unwrap_or_default();
|
||||
out.push_str(&format!(
|
||||
"QSO: {freq:>5} {mode:2} {date} {time} {mine:<13} {rst_sent:>3} {exch_sent:<6} {theirs:<13} {rst_rcvd:>3} {exch_rcvd:<6}\n",
|
||||
freq = cabrillo_frequency(qso.freq_hz),
|
||||
mode = cabrillo_mode(qso),
|
||||
date = qso.started_at.format("%Y-%m-%d"),
|
||||
time = qso.started_at.format("%H%M"),
|
||||
mine = qso.station_callsign.as_deref().unwrap_or(&station),
|
||||
rst_sent = qso.rst_sent.as_deref().unwrap_or(""),
|
||||
exch_sent = sent,
|
||||
theirs = qso.call,
|
||||
rst_rcvd = qso.rst_rcvd.as_deref().unwrap_or(""),
|
||||
exch_rcvd = received,
|
||||
));
|
||||
}
|
||||
out.push_str("END-OF-LOG:\n");
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::qso::parse_adif_datetime;
|
||||
|
||||
fn contest_qso(call: &str, hz: u64, mode: &str, time: &str, sent: u32, rcvd: u32) -> Qso {
|
||||
let mut qso = Qso::new(
|
||||
format!("{call}-{time}"),
|
||||
parse_adif_datetime("20260807", Some(time)).expect("time"),
|
||||
call,
|
||||
hz,
|
||||
mode,
|
||||
);
|
||||
qso.station_callsign = Some("SP0TRX".into());
|
||||
qso.rst_sent = Some("59".into());
|
||||
qso.rst_rcvd = Some("59".into());
|
||||
qso.stx = Some(sent);
|
||||
qso.srx = Some(rcvd);
|
||||
qso.contest_id = Some("CQ-WW-SSB".into());
|
||||
qso
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entry_has_the_header_a_sponsor_expects() {
|
||||
let header = CabrilloHeader {
|
||||
contest: Some("CQ-WW-SSB".into()),
|
||||
callsign: Some("SP0TRX".into()),
|
||||
claimed_score: Some(1234),
|
||||
..CabrilloHeader::default()
|
||||
};
|
||||
let text = write_cabrillo(
|
||||
&[contest_qso("DL1AB", 14_200_000, "SSB", "120000", 1, 42)],
|
||||
&header,
|
||||
);
|
||||
assert!(text.starts_with("START-OF-LOG: 3.0\n"), "{text}");
|
||||
assert!(text.contains("CONTEST: CQ-WW-SSB\n"), "{text}");
|
||||
assert!(text.contains("CALLSIGN: SP0TRX\n"), "{text}");
|
||||
assert!(text.contains("CATEGORY-OPERATOR: SINGLE-OP\n"), "{text}");
|
||||
assert!(text.contains("CLAIMED-SCORE: 1234\n"), "{text}");
|
||||
assert!(text.trim_end().ends_with("END-OF-LOG:"), "{text}");
|
||||
// A header field nobody filled in is left out, not written empty.
|
||||
assert!(!text.contains("SOAPBOX:"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_qso_line_carries_both_sides_of_the_exchange() {
|
||||
let text = write_cabrillo(
|
||||
&[contest_qso("DL1AB", 14_200_000, "SSB", "120000", 1, 42)],
|
||||
&CabrilloHeader::default(),
|
||||
);
|
||||
let line = text
|
||||
.lines()
|
||||
.find(|line| line.starts_with("QSO:"))
|
||||
.expect("a QSO line");
|
||||
// Frequency in kHz on HF, the phone mode letter, the date and UTC time,
|
||||
// then mine, my report and my serial, then theirs.
|
||||
assert!(line.contains("14200"), "{line}");
|
||||
assert!(line.contains(" PH "), "{line}");
|
||||
assert!(line.contains("2026-08-07 1200"), "{line}");
|
||||
assert!(line.contains("SP0TRX"), "{line}");
|
||||
assert!(line.contains("DL1AB"), "{line}");
|
||||
assert!(line.contains("001"), "{line}");
|
||||
assert!(line.contains("042"), "{line}");
|
||||
}
|
||||
|
||||
/// Above 30 MHz Cabrillo names the band rather than the frequency.
|
||||
#[test]
|
||||
fn vhf_and_up_are_written_as_band_designators() {
|
||||
assert_eq!(cabrillo_frequency(14_200_000), "14200");
|
||||
assert_eq!(cabrillo_frequency(7_030_000), "7030");
|
||||
assert_eq!(cabrillo_frequency(144_300_000), "144");
|
||||
assert_eq!(cabrillo_frequency(432_100_000), "432");
|
||||
assert_eq!(cabrillo_frequency(1_296_000_000), "1.2G");
|
||||
assert_eq!(cabrillo_frequency(50_313_000), "50");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mode_letters_are_cabrillos_not_adifs() {
|
||||
let cw = contest_qso("DL1AB", 7_030_000, "CW", "120000", 1, 1);
|
||||
let phone = contest_qso("DL1AB", 14_200_000, "SSB", "120000", 1, 1);
|
||||
let fm = contest_qso("DL1AB", 145_500_000, "FM", "120000", 1, 1);
|
||||
let digital = contest_qso("DL1AB", 14_074_000, "FT8", "120000", 1, 1);
|
||||
assert_eq!(cabrillo_mode(&cw), "CW");
|
||||
assert_eq!(cabrillo_mode(&phone), "PH");
|
||||
assert_eq!(cabrillo_mode(&fm), "FM");
|
||||
assert_eq!(cabrillo_mode(&digital), "DG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contacts_are_written_oldest_first_however_they_arrive() {
|
||||
let text = write_cabrillo(
|
||||
&[
|
||||
contest_qso("LATER", 14_200_000, "SSB", "130000", 2, 2),
|
||||
contest_qso("EARLIER", 14_200_000, "SSB", "120000", 1, 1),
|
||||
],
|
||||
&CabrilloHeader::default(),
|
||||
);
|
||||
let calls: Vec<&str> = text
|
||||
.lines()
|
||||
.filter(|line| line.starts_with("QSO:"))
|
||||
.map(|line| {
|
||||
if line.contains("EARLIER") {
|
||||
"EARLIER"
|
||||
} else {
|
||||
"LATER"
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(calls, vec!["EARLIER", "LATER"]);
|
||||
}
|
||||
|
||||
/// A word exchange -- a zone, a section, a name -- rather than a serial.
|
||||
#[test]
|
||||
fn an_exchange_that_is_not_a_number_is_written_as_it_was() {
|
||||
let mut qso = contest_qso("DL1AB", 14_200_000, "SSB", "120000", 1, 1);
|
||||
qso.stx_string = Some("14".into());
|
||||
qso.srx_string = Some("BAVARIA".into());
|
||||
let text = write_cabrillo(&[qso], &CabrilloHeader::default());
|
||||
let line = text.lines().find(|l| l.starts_with("QSO:")).expect("line");
|
||||
assert!(line.contains("BAVARIA"), "{line}");
|
||||
assert!(!line.contains("001"), "{line}");
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
//! from the rig that made it, because rigs can be in different places.
|
||||
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user