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
+
+
+ +