From 18b2d0efe631b8ccf512d82467280f30e1d0b913 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Fri, 7 Aug 2026 21:12:03 +0200 Subject: [PATCH] [feat](trx-rs): keep a station log, and a layout to work the bands from The logbook of issue #54, in the shape the proposal settled on. A new crate, trx-logbook, holds the contact, the ADIF reader and writer, the file, and the rules for telling one contact from two. ADIF because it is the only thing the ecosystem reads: LoTW, eQSL, Club Log, QRZ and every other logger take it and nothing else, so a log that cannot write .adi cannot be uploaded, confirmed or moved. The reader is forgiving in the ways real files are irregular -- lowercase tags, CRLF, missing header, unknown fields, a declared length that is the only thing ending a value -- and carries what it does not model through to the export, so a round trip does not strip what another program wrote. The file is JSON Lines, appended one line per contact. A log is the one thing here that cannot be regenerated, and the bookmark store's whole-file dump would rewrite megabytes to log one contact and lose all of them if the power went halfway; an append costs the record being written and no more, which a test tears a line in half to prove. Edits append revisions, deletes append tombstones, and the file compacts when the superseded outnumber the live. The panel is its own tab and stands in every layout. An entry opens with six fields and no more -- frequency, mode, rig name, time, and the callsign and locator of whatever decode it was started from. A report stays empty: an FT8 SNR is not what was sent. Times come from the server, because the browser may be a phone in another timezone, and the panel says so when the two disagree by more than a second. Worked-before answers as a callsign is typed. A decode is not a contact, so the Log button on an FT8 or APRS row opens an entry and logs nothing by itself. The ham layout is the fifth operator layout, opening on the logbook with the radio controls around it, offered only where the rig can transmit. Two bugs found on the way, both in code written here: a frequency of a whole number of megahertz ending in a zero rendered as a tenth of itself, in Rust and in TypeScript alike, because trimming trailing zeros from "20.000000" walks back through the point. The API also sits under /api/logbook rather than /logbook, so it cannot shadow its own page the way /bookmarks does. Closes #54 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams --- CLAUDE.md | 1 + Cargo.lock | 16 + Cargo.toml | 1 + docs/User-Manual.md | 51 +++ src/trx-client/src/main.rs | 4 + src/trx-client/trx-frontend/src/lib.rs | 6 + .../trx-frontend/trx-frontend-http/Cargo.toml | 5 + .../assets/web/generated/app.js | 17 +- .../assets/web/generated/aprs.js | 5 +- .../{chunk-OPEIVJGD.js => chunk-REYSUJQ4.js} | 21 +- .../{chunk-PJ5Q7CWJ.js => chunk-WU5EWJX4.js} | 18 + .../assets/web/generated/ft2.js | 2 +- .../assets/web/generated/ft4.js | 2 +- .../assets/web/generated/ft8.js | 2 +- .../assets/web/generated/hf-aprs.js | 2 +- .../assets/web/generated/logbook.js | 331 ++++++++++++++ .../assets/web/generated/map-core.js | 2 +- .../trx-frontend-http/assets/web/index.html | 110 +++++ .../trx-frontend-http/assets/web/style.css | 245 ++++++++++ .../trx-frontend-http/frontend/build.mjs | 1 + .../trx-frontend-http/frontend/package.json | 2 +- .../trx-frontend-http/frontend/src/app.ts | 5 +- .../src/features/navigation/routes.ts | 3 +- .../frontend/src/plugin-loader.ts | 5 +- .../frontend/src/plugins/aprs-shared.ts | 27 ++ .../frontend/src/plugins/aprs.ts | 4 + .../frontend/src/plugins/ftx-family.ts | 22 + .../frontend/src/plugins/host.ts | 3 + .../frontend/src/plugins/logbook.ts | 430 ++++++++++++++++++ .../trx-frontend-http/frontend/src/ui-core.ts | 9 +- .../frontend/tests/logbook.mjs | 162 +++++++ .../frontend/tests/web-fixture.mjs | 78 ++++ .../trx-frontend-http/src/api/assets.rs | 11 + .../trx-frontend-http/src/api/logbook.rs | 372 +++++++++++++++ .../trx-frontend-http/src/api/mod.rs | 181 +++++++- .../trx-frontend-http/src/server.rs | 37 ++ src/trx-client/trx-logbook/Cargo.toml | 19 + src/trx-client/trx-logbook/src/adif.rs | 414 +++++++++++++++++ src/trx-client/trx-logbook/src/dedupe.rs | 191 ++++++++ src/trx-client/trx-logbook/src/lib.rs | 310 +++++++++++++ src/trx-client/trx-logbook/src/qso.rs | 380 ++++++++++++++++ src/trx-client/trx-logbook/src/store.rs | 330 ++++++++++++++ src/trx-config/src/client.rs | 18 + src/trx-config/src/example.rs | 4 + trx-rs.toml.example | 4 + 45 files changed, 3843 insertions(+), 20 deletions(-) rename src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/{chunk-OPEIVJGD.js => chunk-REYSUJQ4.js} (91%) rename src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/{chunk-PJ5Q7CWJ.js => chunk-WU5EWJX4.js} (94%) create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/logbook.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/logbook.ts create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/logbook.mjs create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/src/api/logbook.rs create mode 100644 src/trx-client/trx-logbook/Cargo.toml create mode 100644 src/trx-client/trx-logbook/src/adif.rs create mode 100644 src/trx-client/trx-logbook/src/dedupe.rs create mode 100644 src/trx-client/trx-logbook/src/lib.rs create mode 100644 src/trx-client/trx-logbook/src/qso.rs create mode 100644 src/trx-client/trx-logbook/src/store.rs diff --git a/CLAUDE.md b/CLAUDE.md index 5981235c..9ea82d98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,7 @@ src/ trx-backend-ft450d/ # Yaesu FT-450D ASCII CAT trx-backend-soapysdr/ # SoapySDR RX with full DSP pipeline (~5,000+ LOC) trx-client/ # Client binary: remote connection, frontend spawning (~1,500 LOC) + trx-logbook/ # Station log: QSO record, ADIF reader/writer, append-only store trx-frontend/ # Frontend trait (FrontendSpawner), runtime context trx-frontend-http/ # Web UI: REST API, SSE, WebSocket audio, session auth trx-frontend-http-json/ # JSON-over-TCP control frontend diff --git a/Cargo.lock b/Cargo.lock index 7b8488a8..a8466f2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3218,6 +3218,7 @@ dependencies = [ "base64", "brotli 7.0.0", "bytes", + "chrono", "dirs", "flate2", "futures-util", @@ -3226,11 +3227,13 @@ dependencies = [ "rand 0.8.6", "serde", "serde_json", + "tempfile", "tokio", "tokio-stream", "tracing", "trx-core", "trx-frontend", + "trx-logbook", "trx-protocol", "ts-rs", "uuid", @@ -3269,6 +3272,19 @@ dependencies = [ "rustfft", ] +[[package]] +name = "trx-logbook" +version = "0.1.0" +dependencies = [ + "chrono", + "dirs", + "serde", + "serde_json", + "tempfile", + "tracing", + "uuid", +] + [[package]] name = "trx-protocol" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 18246b51..51221f81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ members = [ "src/trx-server/trx-backend/trx-backend-ft450d", "src/trx-server/trx-backend/trx-backend-soapysdr", "src/trx-client", + "src/trx-client/trx-logbook", "src/trx-client/trx-frontend", "src/trx-client/trx-frontend/trx-frontend-http", "src/trx-client/trx-frontend/trx-frontend-http-json", diff --git a/docs/User-Manual.md b/docs/User-Manual.md index 19dc9736..e2498eaf 100644 --- a/docs/User-Manual.md +++ b/docs/User-Manual.md @@ -477,6 +477,57 @@ Each browser tab keeps its own selection, so two tabs can watch two rigs. --- +## Logbook + +The **Logbook** tab keeps the station's contacts and speaks ADIF, so a log can +be uploaded to LoTW, eQSL, Club Log or QRZ, or moved to another logger. + +An entry opens pre-filled with six fields and no more: the frequency, mode and +name of the rig you are on, the time from the server's clock, and — when the +entry was started from a decode or a map station — that station's callsign and +locator. Signal reports, name, comment and the rest are yours to fill in; a +report in particular is never guessed, because an FT8 SNR is not what was sent. + +Your own callsign and locator are not per-contact fields. They are station +identity: the callsign comes from `[trx-client.general].callsign`, the locator +from the rig's position, and both are shown once at the top of the panel. The +operator defaults to the station callsign and can be changed for the session, +which is what a multi-operator station needs. + +**Times are the server's**, in UTC — the server is the machine at the radio. If +the browser's clock disagrees by more than a second the entry says so rather +than logging a time you did not expect. + +**A decode is not a contact.** The decoders are receive-only, so a **Log** +button on an FT8 or APRS row opens an entry with what was heard in it and logs +nothing by itself. Digital QSOs made in WSJT-X come in through Import ADIF, the +way every other logger takes them. + +| Action | What it does | +|--------|--------------| +| Log contact | Writes the entry and opens a fresh one | +| Export ADIF | Downloads the log — filtered, if a filter is set | +| Import ADIF | Reads a file, skipping contacts already held and reporting what could not be read | +| Worked before | Shows, as you type a callsign, which bands and modes it has been worked on | + +Two contacts are treated as the same when the callsign, band and mode match and +the times are within two minutes: two loggers rarely stamp a QSO to the same +minute, one recording when it started and the other when it was typed. + +The log is a JSON Lines file, appended one contact at a time so a crash costs at +most the contact being written. It lives in your data directory by default; +`[trx-client.logbook].path` moves it, for a station that keeps its log on a +backed-up volume. + +### Ham radio layout + +The **Ham radio** operator layout opens on the logbook with the transceiver +controls around it — the arrangement for working the bands, where logging the +contact is the task and the radio is the instrument. It is offered only where +the selected rig can transmit; a receiver has no contacts to log. + +--- + ## Tune Links Every page of the web UI carries what the radio is doing in its address, so the diff --git a/src/trx-client/src/main.rs b/src/trx-client/src/main.rs index 9146cd82..cde1f992 100644 --- a/src/trx-client/src/main.rs +++ b/src/trx-client/src/main.rs @@ -279,6 +279,10 @@ async fn async_init() -> DynResult { .http .decode_history_retention_min_by_rig .clone(); + frontend_runtime.http_ui.logbook_path = cfg.logbook.path.clone(); + // The callsign the station is on the air with, which every contact is + // logged under and which the operator defaults to. + frontend_runtime.http_ui.station_callsign = cfg.general.callsign.clone(); // Resolve remote entries: CLI --url > [[remotes]] > legacy [remote] > error let resolved_remotes: Vec = if let Some(ref url) = cli.url { diff --git a/src/trx-client/trx-frontend/src/lib.rs b/src/trx-client/trx-frontend/src/lib.rs index 689acf63..d57ba240 100644 --- a/src/trx-client/trx-frontend/src/lib.rs +++ b/src/trx-client/trx-frontend/src/lib.rs @@ -292,6 +292,10 @@ pub struct HttpUiConfig { pub bandplan_region: String, pub decode_history_retention_min: u64, pub decode_history_retention_min_by_rig: HashMap, + /// Where the station log is kept; `None` takes the default. + pub logbook_path: Option, + /// The callsign contacts are logged under, and the operator by default. + pub station_callsign: Option, } impl Default for HttpUiConfig { @@ -305,6 +309,8 @@ impl Default for HttpUiConfig { bandplan_region: "iaru_r1".to_string(), decode_history_retention_min: 24 * 60, decode_history_retention_min_by_rig: HashMap::new(), + logbook_path: None, + station_callsign: None, } } } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml b/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml index 18a130d8..25cfb039 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml +++ b/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml @@ -11,6 +11,8 @@ build = "build.rs" [dependencies] trx-core = { path = "../../../trx-core" } trx-frontend = { path = ".." } +trx-logbook = { path = "../../trx-logbook" } +chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } trx-protocol = { path = "../../../../src/trx-protocol" } tokio = { workspace = true, features = ["full"] } serde = { workspace = true, features = ["derive"] } @@ -30,3 +32,6 @@ pickledb = "0.5" dirs = "6" uuid = { workspace = true } ts-rs = "12.0.1" + +[dev-dependencies] +tempfile = "3" diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js index 4e315f52..3e8a529a 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js @@ -633,9 +633,12 @@ function elementById(id) { compact: { label: "Compact", advanced: false, audio: false, scheduler: false, preferredTab: "main" }, broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" }, digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, audio: false, scheduler: false, preferredTab: "digital-modes", capability: "digital" }, - full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" } + full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" }, + // Working the bands: the log is the task and the radio is the instrument, + // so this one opens on the logbook with the controls a keystroke away. + ham: { label: "Ham radio", unavailable: "Ham radio needs a rig that can transmit", advanced: true, audio: true, scheduler: false, preferredTab: "logbook", capability: "ham" } }; - const layoutCapabilities = { broadcast: false, digital: false }; + const layoutCapabilities = { broadcast: false, digital: false, ham: false }; let activeRigId = null; const layoutSections = [ { id: "advanced-radio-controls", key: "advanced" }, @@ -1530,6 +1533,7 @@ function decodeCbor(buffer) { var TAB_ORDER = [ "main", "bookmarks", + "logbook", "digital-modes", "map", "satellites", @@ -1541,6 +1545,7 @@ var TAB_ORDER = [ var TAB_PATHS = { main: "/", bookmarks: "/bookmarks", + logbook: "/logbook", "digital-modes": "/digital-modes", map: "/map", satellites: "/satellites", @@ -1764,6 +1769,7 @@ var pluginGroups = { satellites: ["/satellite-predictions.js"], statistics: ["/map-core.js"], bookmarks: ["/bookmarks.js"], + logbook: ["/logbook.js"], recorder: [], settings: ["/vchan.js", "/scheduler.js"] }; @@ -1788,7 +1794,7 @@ async function loadPlugins(group) { for (const path of pluginGroups[group]) await loadPlugin(path); } async function loadEagerPlugins() { - await Promise.all(["digital-modes", "bookmarks", "settings"].map(loadPlugins)); + await Promise.all(["digital-modes", "bookmarks", "logbook", "settings"].map(loadPlugins)); } async function loadPluginsForTab(tab) { await loadPlugins(tab); @@ -2950,7 +2956,10 @@ function refreshOperatorLayoutCapabilities() { )); window.trxUi?.setLayoutCapabilities({ broadcast: rigModes.some((modes) => modes.includes("WFM")), - digital: decoderModes.size > 0 && rigModes.some((modes) => modes.some((mode) => decoderModes.has(mode))) + digital: decoderModes.size > 0 && rigModes.some((modes) => modes.some((mode) => decoderModes.has(mode))), + // A logbook is for contacts made, so the layout built around it is offered + // where a rig can transmit. + ham: serverRigs.some((rig) => rig?.tx === true) }); } function showHint(msg, duration) { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js index a64edc72..ae7f12a0 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js @@ -4,7 +4,7 @@ import { collapseAprsDuplicates, normalizeAprsPacket, renderAprsPacketRow -} from "./chunk-OPEIVJGD.js"; +} from "./chunk-REYSUJQ4.js"; import { forActiveRig, isActiveRigDecode @@ -139,6 +139,9 @@ function renderAprsRow(pkt, isFresh) { }, onCopy: (text) => { void copyAprsCoords(text); + }, + onLog: (call, gridsquare) => { + aprsWindow.logContact?.({ call, gridsquare: gridsquare ?? void 0, decoder: "aprs" }); } }); } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-OPEIVJGD.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-REYSUJQ4.js similarity index 91% rename from src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-OPEIVJGD.js rename to src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-REYSUJQ4.js index c13bbc3c..48aec162 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-OPEIVJGD.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-REYSUJQ4.js @@ -214,6 +214,16 @@ function summarizeAprsPayload(packet) { const text = info.replace(/^[>;${escapeAprsHtml(time)}` + (options.badge ? `${escapeAprsHtml(options.badge)}` : "") + renderAprsSymbolSlot(packet, escapeAprsHtml) + `${escapeAprsHtml(packet.srcCall ?? "")}${escapeAprsHtml(aprsCategoryLabel(category))}${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}` + (packet.crcOk ? "" : 'CRC') + (options.distance ? `${escapeAprsHtml(options.distance)}` : "") + `
>${escapeAprsHtml(packet.destCall || "--")}${escapeAprsHtml(packet.path || "no path")}${escapeAprsHtml(aprsAgeText(packet._tsMs))}CRC ${packet.crcOk ? "ok" : "failed"}` + (hasPosition ? `${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}` : "") + `
${renderAprsInfo(packet)}
` + (packet.info_bytes?.length ? `
${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}
` : "") + `
` + (hasPosition ? `` : "") + (hasPosition ? `` : "") + `QRZ
`; + row.innerHTML = `${escapeAprsHtml(time)}` + (options.badge ? `${escapeAprsHtml(options.badge)}` : "") + renderAprsSymbolSlot(packet, escapeAprsHtml) + `${escapeAprsHtml(packet.srcCall ?? "")}${escapeAprsHtml(aprsCategoryLabel(category))}${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}` + (packet.crcOk ? "" : 'CRC') + (options.distance ? `${escapeAprsHtml(options.distance)}` : "") + `
>${escapeAprsHtml(packet.destCall || "--")}${escapeAprsHtml(packet.path || "no path")}${escapeAprsHtml(aprsAgeText(packet._tsMs))}CRC ${packet.crcOk ? "ok" : "failed"}` + (hasPosition ? `${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}` : "") + `
${renderAprsInfo(packet)}
` + (packet.info_bytes?.length ? `
${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}
` : "") + `
` + (hasPosition ? `` : "") + (hasPosition ? `` : "") + `QRZ` + (options.onLog && packet.srcCall ? '' : "") + `
`; row.querySelectorAll("[data-aprs-map]").forEach((element) => { element.addEventListener("click", (event) => { event.preventDefault(); @@ -233,6 +243,15 @@ function renderAprsPacketRow(packet, options = {}) { if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat, lon); }); }); + const logButton = row.querySelector("[data-aprs-log]"); + if (logButton) { + logButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + const grid = packet.lat != null && packet.lon != null ? maidenheadForLatLon(packet.lat, packet.lon) : null; + options.onLog?.(packet.srcCall ?? "", grid); + }); + } const copyButton = row.querySelector("[data-aprs-copy]"); if (copyButton) { copyButton.addEventListener("click", (event) => { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-PJ5Q7CWJ.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-WU5EWJX4.js similarity index 94% rename from src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-PJ5Q7CWJ.js rename to src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-WU5EWJX4.js index e2dd2fc8..cfa7e943 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-PJ5Q7CWJ.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-WU5EWJX4.js @@ -152,6 +152,24 @@ function initializeFtxDecoder(config) { second: "2-digit" }); row.innerHTML = `${time}${snr?.toFixed(1) ?? "--"}${delta?.toFixed(2) ?? "--"}${frequency?.toFixed(0) ?? "--"}${renderMessage(raw)}`; + const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw); + if (station) { + const log = document.createElement("button"); + log.type = "button"; + log.className = "ft8-log-btn"; + log.textContent = "Log"; + log.title = `Start a log entry for ${station}`; + log.addEventListener("click", (event) => { + event.stopPropagation(); + const details = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw); + bridge.logContact?.({ + call: station, + gridsquare: details.find((detail) => detail.station === station)?.grid, + decoder: id + }); + }); + row.appendChild(log); + } return row; }; const render = () => { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js index ad69d39d..dfe8aafd 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js @@ -1,6 +1,6 @@ import { initializeFtxDecoder -} from "./chunk-PJ5Q7CWJ.js"; +} from "./chunk-WU5EWJX4.js"; import "./chunk-S57W63QN.js"; import "./chunk-KL66PICH.js"; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js index 30180ef0..e72ec5d2 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js @@ -1,6 +1,6 @@ import { initializeFtxDecoder -} from "./chunk-PJ5Q7CWJ.js"; +} from "./chunk-WU5EWJX4.js"; import "./chunk-S57W63QN.js"; import "./chunk-KL66PICH.js"; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js index ff94b312..e04a3d55 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js @@ -2,7 +2,7 @@ import { initializeFt8FamilyBar, initializeFtxDecoder, installFtxCompatibilityHelpers -} from "./chunk-PJ5Q7CWJ.js"; +} from "./chunk-WU5EWJX4.js"; import "./chunk-S57W63QN.js"; import "./chunk-KL66PICH.js"; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js index 2bdbcb9b..94c1d89e 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js @@ -4,7 +4,7 @@ import { collapseAprsDuplicates, normalizeAprsPacket, renderAprsPacketRow -} from "./chunk-OPEIVJGD.js"; +} from "./chunk-REYSUJQ4.js"; import { forActiveRig, isActiveRigDecode 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 new file mode 100644 index 00000000..581bae1f --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/logbook.js @@ -0,0 +1,331 @@ +import { + hostCore, + hostState +} from "./chunk-KL66PICH.js"; + +// src/plugins/logbook.ts +var bridge = window; +var el = (id) => document.getElementById(id); +var form = el("log-entry-form"); +var callInput = el("log-call"); +var freqInput = el("log-freq"); +var modeInput = el("log-mode"); +var rstSentInput = el("log-rst-sent"); +var rstRcvdInput = el("log-rst-rcvd"); +var gridInput = el("log-grid"); +var nameInput = el("log-name"); +var commentInput = el("log-comment"); +var operatorInput = el("log-operator"); +var rowsBody = el("log-rows"); +var summaryEl = el("log-summary"); +var workedEl = el("log-worked-before"); +var clockEl = el("log-clock"); +var stationCallEl = el("log-station-callsign"); +var stationRigEl = el("log-station-rig-name"); +var stationGridEl = el("log-station-grid"); +var filterCall = el("log-filter-call"); +var filterBand = el("log-filter-band"); +var filterMode = el("log-filter-mode"); +var importBtn = el("log-import-btn"); +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 entryStartedAt = null; +var entryRigId = null; +var entryRigName = null; +var entryGrid = null; +var qsos = []; +var workedRequest = 0; +function notify(message, kind) { + if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : void 0); + else hostCore.showHint(message, 2e3); +} +async function getJson(path) { + const response = await fetch(path); + if (!response.ok) throw new Error(`HTTP ${String(response.status)}`); + return await response.json(); +} +function formatFreq(hz) { + if (!Number.isFinite(hz) || hz <= 0) return ""; + return String(Number((hz / 1e6).toFixed(6))); +} +function parseFreq(text) { + const value = Number(text.trim().replace(/\s+/g, "").replace(",", ".")); + if (!Number.isFinite(value) || value <= 0) return null; + return value < 1e5 ? Math.round(value * 1e6) : Math.round(value); +} +function utcDate(iso) { + const date = new Date(iso); + return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10); +} +function utcTime(iso) { + const date = new Date(iso); + return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(11, 19); +} +async function openEntry(seed = {}) { + const params = new URLSearchParams(); + if (hostState.lastActiveRigId) params.set("remote", hostState.lastActiveRigId); + if (seed.decoder) params.set("decoder", seed.decoder); + if (seed.call) params.set("call", seed.call); + if (seed.gridsquare) params.set("gridsquare", seed.gridsquare); + try { + const prefill = await getJson(`/api/logbook/prefill?${params.toString()}`); + applyPrefill(prefill); + } catch (error) { + console.error("logbook prefill failed", error); + } +} +function applyPrefill(prefill) { + entryStartedAt = prefill.started_at; + entryRigId = prefill.rig_id; + entryRigName = prefill.my_rig; + if (freqInput) freqInput.value = formatFreq(prefill.freq_hz); + if (modeInput) modeInput.value = prefill.submode ?? prefill.mode; + if (callInput && prefill.call) callInput.value = prefill.call; + if (gridInput && prefill.gridsquare) gridInput.value = prefill.gridsquare; + if (stationRigEl) stationRigEl.textContent = prefill.my_rig ?? "no rig"; + showClock(prefill); + updateWorkedBefore(); +} +function showClock(prefill) { + if (!clockEl) return; + const time = utcTime(prefill.started_at); + const drift = Math.abs(Date.now() - prefill.epoch_ms); + clockEl.textContent = drift > 1e3 ? `${time}Z · your clock is ${(drift / 1e3).toFixed(0)}s out` : `${time}Z`; + clockEl.classList.toggle("is-adrift", drift > 1e3); +} +function resetEntry() { + for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput]) { + if (input) input.value = ""; + } + if (workedEl) workedEl.textContent = ""; + void openEntry(); +} +async function submitEntry(event) { + event.preventDefault(); + const call = callInput?.value.trim() ?? ""; + if (!call) { + notify("A contact needs a callsign", "error"); + callInput?.focus(); + return; + } + const freqHz = parseFreq(freqInput?.value ?? ""); + if (freqHz == null) { + notify("A contact needs a frequency", "error"); + freqInput?.focus(); + return; + } + const typedMode = (modeInput?.value ?? "").trim().toUpperCase(); + const isSideband = typedMode === "USB" || typedMode === "LSB"; + const body = { + // The server stamps the time; this is the one it gave when the entry + // opened, so a contact logged five minutes later keeps the time it started. + started_at: entryStartedAt, + call, + freq_hz: freqHz, + mode: isSideband ? "SSB" : typedMode, + submode: isSideband ? typedMode : null, + rst_sent: rstSentInput?.value ?? null, + rst_rcvd: rstRcvdInput?.value ?? null, + gridsquare: gridInput?.value ?? null, + name: nameInput?.value ?? null, + comment: commentInput?.value ?? null, + station_callsign: stationCallEl?.textContent?.trim() ?? null, + operator: operatorInput?.value ?? null, + my_gridsquare: entryGrid, + my_rig: entryRigName, + rig_id: entryRigId + }; + if (saveBtn) saveBtn.disabled = true; + try { + const response = await fetch("/api/logbook", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + if (!response.ok) { + const detail = await response.json().catch(() => ({})); + throw new Error(detail.error ?? `HTTP ${String(response.status)}`); + } + notify(`${call} logged`); + resetEntry(); + await refreshLog(); + } catch (error) { + notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error"); + } finally { + if (saveBtn) saveBtn.disabled = false; + } +} +function updateWorkedBefore() { + if (!workedEl) return; + const call = callInput?.value.trim() ?? ""; + if (call.length < 3) { + workedEl.textContent = ""; + return; + } + const request = ++workedRequest; + void getJson( + `/api/logbook/worked/${encodeURIComponent(call)}` + ).then((answer) => { + if (request !== workedRequest || !workedEl) return; + if (answer.worked.length === 0) { + workedEl.textContent = "Not worked before"; + workedEl.classList.remove("is-worked"); + return; + } + const where = answer.worked.map((entry) => `${entry.band} ${entry.mode}`).join(", "); + workedEl.textContent = `Worked before: ${where}`; + workedEl.classList.add("is-worked"); + }).catch(() => { + }); +} +function currentQuery() { + const params = new URLSearchParams(); + const call = filterCall?.value.trim(); + if (call) params.set("call", call); + if (filterBand?.value) params.set("band", filterBand.value); + if (filterMode?.value) params.set("mode", filterMode.value); + return params.toString(); +} +async function refreshLog() { + try { + const query = currentQuery(); + const answer = await getJson( + `/api/logbook${query ? `?${query}` : ""}` + ); + qsos = answer.qsos; + renderRows(); + renderFilterOptions(); + if (summaryEl) { + 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}` : ""}`; + } catch (error) { + console.error("logbook read failed", error); + } +} +function renderRows() { + if (!rowsBody) return; + if (qsos.length === 0) { + rowsBody.innerHTML = 'No contacts yet. Work someone and log them here, or bring a log in with Import ADIF.'; + return; + } + const fragment = document.createDocumentFragment(); + for (const qso of qsos) { + const row = document.createElement("tr"); + row.dataset.qsoId = qso.id; + const cells = [ + utcDate(qso.started_at), + utcTime(qso.started_at), + qso.call, + qso.band ?? "", + qso.submode ?? qso.mode, + qso.rst_sent ?? "", + qso.rst_rcvd ?? "", + qso.gridsquare ?? "", + qso.my_rig ?? "" + ]; + for (const [index, value] of cells.entries()) { + const cell = document.createElement("td"); + cell.textContent = value ?? ""; + if (index === 2) cell.className = "log-cell-call"; + row.appendChild(cell); + } + const actions = document.createElement("td"); + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "log-row-btn"; + remove.textContent = "Delete"; + remove.setAttribute("aria-label", `Delete the contact with ${qso.call}`); + remove.addEventListener("click", () => { + void deleteQso(qso); + }); + actions.appendChild(remove); + row.appendChild(actions); + fragment.appendChild(row); + } + rowsBody.replaceChildren(fragment); +} +function renderFilterOptions() { + for (const [select, values] of [ + [filterBand, [...new Set(qsos.map((q) => q.band).filter((b) => !!b))]], + [filterMode, [...new Set(qsos.map((q) => q.submode ?? q.mode).filter(Boolean))]] + ]) { + if (!select) continue; + const chosen = select.value; + const known = new Set([...select.options].map((option) => option.value)); + for (const value of [...values].sort()) { + if (known.has(value)) continue; + select.add(new Option(value, value)); + } + select.value = chosen; + } +} +async function deleteQso(qso) { + const confirmed = await bridge.trxUi.confirm({ + title: "Delete this contact?", + message: `${qso.call} on ${qso.band ?? formatFreq(qso.freq_hz)} will be removed from the log.`, + confirmLabel: "Delete" + }); + if (!confirmed) return; + try { + const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, { method: "DELETE" }); + if (!response.ok) throw new Error(`HTTP ${String(response.status)}`); + await refreshLog(); + } catch (error) { + notify(`Could not delete: ${error instanceof Error ? error.message : String(error)}`, "error"); + } +} +async function importAdif(file) { + try { + const response = await fetch("/api/logbook/import", { method: "POST", body: await file.arrayBuffer() }); + if (!response.ok) throw new Error(`HTTP ${String(response.status)}`); + const outcome = await response.json(); + const parts = [`${String(outcome.added)} added`]; + if (outcome.duplicate > 0) parts.push(`${String(outcome.duplicate)} already held`); + if (outcome.rejected.length > 0) parts.push(`${String(outcome.rejected.length)} not readable`); + notify(parts.join(", ")); + await refreshLog(); + } catch (error) { + notify(`Import failed: ${error instanceof Error ? error.message : String(error)}`, "error"); + } +} +function renderStation() { + const callsign = hostState.ownerCallsign ?? ""; + if (stationCallEl) stationCallEl.textContent = callsign || "no callsign set"; + if (operatorInput && !operatorInput.value) operatorInput.value = callsign; + if (stationGridEl) { + const grid = hostState.serverLat != null && hostState.serverLon != null ? hostCore.latLonToMaidenhead(hostState.serverLat, hostState.serverLon) : ""; + entryGrid = grid || null; + stationGridEl.textContent = grid; + } +} +form?.addEventListener("submit", (event) => { + void submitEntry(event); +}); +clearBtn?.addEventListener("click", resetEntry); +callInput?.addEventListener("input", updateWorkedBefore); +for (const control of [filterCall, filterBand, filterMode]) { + control?.addEventListener("input", () => { + void refreshLog(); + }); + control?.addEventListener("change", () => { + void refreshLog(); + }); +} +importBtn?.addEventListener("click", () => { + importFile?.click(); +}); +importFile?.addEventListener("change", () => { + const file = importFile.files?.[0]; + if (file) void importAdif(file); + importFile.value = ""; +}); +bridge.logContact = (seed) => { + bridge.navigateToTab?.("logbook"); + void openEntry(seed).then(() => callInput?.focus()); +}; +renderStation(); +void openEntry(); +void refreshLog(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js index f493d173..42e9c298 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js @@ -1,6 +1,6 @@ import { aprsSymbolSprite -} from "./chunk-OPEIVJGD.js"; +} from "./chunk-REYSUJQ4.js"; // src/map-core.ts function mapEl(id) { 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 1e1b49dd..6d5ec71a 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 @@ -22,6 +22,7 @@ SPDX-License-Identifier: GPL-2.0-or-later + @@ -53,6 +54,10 @@ SPDX-License-Identifier: GPL-2.0-or-later Bookmarks + + + + + + + +
+ + + + + Export ADIF + + +
+
+ + + + + + + + + + + + + + + + +
DateTimeCallsignBandModeSentRcvdLocatorRigActions
+
+
+ +