diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/logbook.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/logbook.js index 581bae1f..089af5f6 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/logbook.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/logbook.js @@ -31,6 +31,16 @@ var importFile = el("log-import-file"); var exportLink = el("log-export-btn"); var clearBtn = el("log-clear-btn"); var saveBtn = el("log-save-btn"); +var contestIdInput = el("log-contest-id"); +var stxInput = el("log-stx"); +var srxInput = el("log-srx"); +var statisticsRows = el("log-statistics-rows"); +var cabrilloContest = el("log-cbr-contest"); +var cabrilloCallsign = el("log-cbr-callsign"); +var cabrilloOperator = el("log-cbr-operator"); +var cabrilloPower = el("log-cbr-power"); +var cabrilloScore = el("log-cbr-score"); +var cabrilloExport = el("log-cbr-export"); var entryStartedAt = null; var entryRigId = null; var entryRigName = null; @@ -55,6 +65,12 @@ function parseFreq(text) { if (!Number.isFinite(value) || value <= 0) return null; return value < 1e5 ? Math.round(value * 1e6) : Math.round(value); } +function numberOrNull(text) { + const trimmed = (text ?? "").trim(); + if (!trimmed || !/^\d+$/.test(trimmed)) return null; + const value = Number(trimmed); + return Number.isSafeInteger(value) ? value : null; +} function utcDate(iso) { const date = new Date(iso); return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10); @@ -96,7 +112,7 @@ function showClock(prefill) { clockEl.classList.toggle("is-adrift", drift > 1e3); } function resetEntry() { - for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput]) { + for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput, srxInput]) { if (input) input.value = ""; } if (workedEl) workedEl.textContent = ""; @@ -131,6 +147,13 @@ async function submitEntry(event) { gridsquare: gridInput?.value ?? null, name: nameInput?.value ?? null, comment: commentInput?.value ?? null, + contest_id: contestIdInput?.value ?? null, + // The exchange is a number when it is a serial and a word when it is a + // zone or a section; both are kept, and the log writes whichever it has. + stx: numberOrNull(stxInput?.value), + stx_string: numberOrNull(stxInput?.value) == null ? stxInput?.value ?? null : null, + srx: numberOrNull(srxInput?.value), + srx_string: numberOrNull(srxInput?.value) == null ? srxInput?.value ?? null : null, station_callsign: stationCallEl?.textContent?.trim() ?? null, operator: operatorInput?.value ?? null, my_gridsquare: entryGrid, @@ -149,7 +172,9 @@ async function submitEntry(event) { throw new Error(detail.error ?? `HTTP ${String(response.status)}`); } notify(`${call} logged`); + const sent = numberOrNull(stxInput?.value); resetEntry(); + if (stxInput && sent != null) stxInput.value = String(sent + 1); await refreshLog(); } catch (error) { notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error"); @@ -201,10 +226,51 @@ async function refreshLog() { summaryEl.textContent = query ? `${String(qsos.length)} of ${String(answer.total)} contacts` : `${String(answer.total)} contact${answer.total === 1 ? "" : "s"}`; } if (exportLink) exportLink.href = `/api/logbook/export.adi${query ? `?${query}` : ""}`; + await refreshStatistics(); } catch (error) { console.error("logbook read failed", error); } } +async function refreshStatistics() { + if (!statisticsRows) return; + try { + const answer = await getJson("/api/logbook/statistics"); + if (answer.bands.length === 0) { + statisticsRows.innerHTML = 'Nothing worked yet.'; + return; + } + const fragment = document.createDocumentFragment(); + for (const band of answer.bands) { + const row = document.createElement("tr"); + for (const value of [band.band, band.contacts, band.stations, band.confirmed]) { + const cell = document.createElement("td"); + cell.textContent = String(value); + row.appendChild(cell); + } + fragment.appendChild(row); + } + statisticsRows.replaceChildren(fragment); + } catch (error) { + console.error("logbook statistics failed", error); + } +} +function syncCabrilloLink() { + if (!cabrilloExport) return; + const params = new URLSearchParams(); + const contest = cabrilloContest?.value.trim(); + if (contest) { + params.set("contest", contest); + } + const callsign = cabrilloCallsign?.value.trim() || (stationCallEl?.textContent?.trim() ?? ""); + if (callsign) params.set("callsign", callsign); + if (cabrilloOperator?.value) params.set("category_operator", cabrilloOperator.value); + if (cabrilloPower?.value) params.set("category_power", cabrilloPower.value); + const score = numberOrNull(cabrilloScore?.value); + if (score != null) params.set("claimed_score", String(score)); + const operator = operatorInput?.value.trim(); + if (operator) params.set("operators", operator); + cabrilloExport.href = `/api/logbook/export.cbr?${params.toString()}`; +} function renderRows() { if (!rowsBody) return; if (qsos.length === 0) { @@ -224,7 +290,8 @@ function renderRows() { qso.rst_sent ?? "", qso.rst_rcvd ?? "", qso.gridsquare ?? "", - qso.my_rig ?? "" + qso.my_rig ?? "", + qso.confirmed ? "✓" : "" ]; for (const [index, value] of cells.entries()) { const cell = document.createElement("td"); @@ -233,6 +300,15 @@ function renderRows() { row.appendChild(cell); } const actions = document.createElement("td"); + const confirm = document.createElement("button"); + confirm.type = "button"; + confirm.className = "log-row-btn"; + confirm.textContent = qso.confirmed ? "Unconfirm" : "Confirm"; + confirm.title = qso.confirmed ? "Mark this contact as not confirmed" : "Mark this contact confirmed by QSL"; + confirm.addEventListener("click", () => { + void setConfirmed(qso, !qso.confirmed); + }); + actions.appendChild(confirm); const remove = document.createElement("button"); remove.type = "button"; remove.className = "log-row-btn"; @@ -262,6 +338,24 @@ function renderFilterOptions() { select.value = chosen; } } +async function setConfirmed(qso, confirmed) { + try { + const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...qso, + // A card is a card: this is the paper one, and an electronic + // confirmation the log already holds is left where it is. + qsl_rcvd: confirmed ? "Y" : "N" + }) + }); + if (!response.ok) throw new Error(`HTTP ${String(response.status)}`); + await refreshLog(); + } catch (error) { + notify(`Could not update: ${error instanceof Error ? error.message : String(error)}`, "error"); + } +} async function deleteQso(qso) { const confirmed = await bridge.trxUi.confirm({ title: "Delete this contact?", @@ -314,6 +408,10 @@ for (const control of [filterCall, filterBand, filterMode]) { void refreshLog(); }); } +for (const control of [cabrilloContest, cabrilloCallsign, cabrilloOperator, cabrilloPower, cabrilloScore]) { + control?.addEventListener("input", syncCabrilloLink); + control?.addEventListener("change", syncCabrilloLink); +} importBtn?.addEventListener("click", () => { importFile?.click(); }); @@ -327,5 +425,9 @@ bridge.logContact = (seed) => { void openEntry(seed).then(() => callInput?.focus()); }; renderStation(); +if (cabrilloCallsign && !cabrilloCallsign.value) { + cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? ""; +} +syncCabrilloLink(); void openEntry(); void refreshLog(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index 6d5ec71a..fa524ee5 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -543,6 +543,24 @@ SPDX-License-Identifier: GPL-2.0-or-later +
+ Contest exchange +
+ + + +
+
@@ -581,6 +599,7 @@ SPDX-License-Identifier: GPL-2.0-or-later Rcvd Locator Rig + QSL Actions @@ -588,6 +607,68 @@ SPDX-License-Identifier: GPL-2.0-or-later
+ +
+ Bands worked +
+ + + + + + + + + + +
BandContactsStationsConfirmed
+
+
+ +
+ Contest entry (Cabrillo) +

+ Contest logs are submitted in Cabrillo and rejected in anything else. + Only the contacts of the contest named here go into the entry. +

+
+ + + + + +
+
+ + Export Cabrillo + +
+