Compare commits

...
Author SHA1 Message Date
sjgandClaude Opus 5 18b2d0efe6 [feat](trx-rs): keep a station log, and a layout to work the bands from
CI / lint (pull_request) Successful in 2m23s
CI / test (pull_request) Successful in 9m12s
CI / frontend (pull_request) Successful in 5m52s
CI / reuse (pull_request) Successful in 6s
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 21:12:03 +02:00
45 changed files with 3843 additions and 20 deletions
+1
View File
@@ -56,6 +56,7 @@ src/
trx-backend-ft450d/ # Yaesu FT-450D ASCII CAT trx-backend-ft450d/ # Yaesu FT-450D ASCII CAT
trx-backend-soapysdr/ # SoapySDR RX with full DSP pipeline (~5,000+ LOC) trx-backend-soapysdr/ # SoapySDR RX with full DSP pipeline (~5,000+ LOC)
trx-client/ # Client binary: remote connection, frontend spawning (~1,500 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/ # Frontend trait (FrontendSpawner), runtime context
trx-frontend-http/ # Web UI: REST API, SSE, WebSocket audio, session auth trx-frontend-http/ # Web UI: REST API, SSE, WebSocket audio, session auth
trx-frontend-http-json/ # JSON-over-TCP control frontend trx-frontend-http-json/ # JSON-over-TCP control frontend
Generated
+16
View File
@@ -3218,6 +3218,7 @@ dependencies = [
"base64", "base64",
"brotli 7.0.0", "brotli 7.0.0",
"bytes", "bytes",
"chrono",
"dirs", "dirs",
"flate2", "flate2",
"futures-util", "futures-util",
@@ -3226,11 +3227,13 @@ dependencies = [
"rand 0.8.6", "rand 0.8.6",
"serde", "serde",
"serde_json", "serde_json",
"tempfile",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tracing", "tracing",
"trx-core", "trx-core",
"trx-frontend", "trx-frontend",
"trx-logbook",
"trx-protocol", "trx-protocol",
"ts-rs", "ts-rs",
"uuid", "uuid",
@@ -3269,6 +3272,19 @@ dependencies = [
"rustfft", "rustfft",
] ]
[[package]]
name = "trx-logbook"
version = "0.1.0"
dependencies = [
"chrono",
"dirs",
"serde",
"serde_json",
"tempfile",
"tracing",
"uuid",
]
[[package]] [[package]]
name = "trx-protocol" name = "trx-protocol"
version = "0.1.0" version = "0.1.0"
+1
View File
@@ -26,6 +26,7 @@ members = [
"src/trx-server/trx-backend/trx-backend-ft450d", "src/trx-server/trx-backend/trx-backend-ft450d",
"src/trx-server/trx-backend/trx-backend-soapysdr", "src/trx-server/trx-backend/trx-backend-soapysdr",
"src/trx-client", "src/trx-client",
"src/trx-client/trx-logbook",
"src/trx-client/trx-frontend", "src/trx-client/trx-frontend",
"src/trx-client/trx-frontend/trx-frontend-http", "src/trx-client/trx-frontend/trx-frontend-http",
"src/trx-client/trx-frontend/trx-frontend-http-json", "src/trx-client/trx-frontend/trx-frontend-http-json",
+51
View File
@@ -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 ## Tune Links
Every page of the web UI carries what the radio is doing in its address, so the Every page of the web UI carries what the radio is doing in its address, so the
+4
View File
@@ -279,6 +279,10 @@ async fn async_init() -> DynResult<AppState> {
.http .http
.decode_history_retention_min_by_rig .decode_history_retention_min_by_rig
.clone(); .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 // Resolve remote entries: CLI --url > [[remotes]] > legacy [remote] > error
let resolved_remotes: Vec<RemoteEntry> = if let Some(ref url) = cli.url { let resolved_remotes: Vec<RemoteEntry> = if let Some(ref url) = cli.url {
+6
View File
@@ -292,6 +292,10 @@ pub struct HttpUiConfig {
pub bandplan_region: String, pub bandplan_region: String,
pub decode_history_retention_min: u64, pub decode_history_retention_min: u64,
pub decode_history_retention_min_by_rig: HashMap<String, u64>, pub decode_history_retention_min_by_rig: HashMap<String, u64>,
/// Where the station log is kept; `None` takes the default.
pub logbook_path: Option<String>,
/// The callsign contacts are logged under, and the operator by default.
pub station_callsign: Option<String>,
} }
impl Default for HttpUiConfig { impl Default for HttpUiConfig {
@@ -305,6 +309,8 @@ impl Default for HttpUiConfig {
bandplan_region: "iaru_r1".to_string(), bandplan_region: "iaru_r1".to_string(),
decode_history_retention_min: 24 * 60, decode_history_retention_min: 24 * 60,
decode_history_retention_min_by_rig: HashMap::new(), decode_history_retention_min_by_rig: HashMap::new(),
logbook_path: None,
station_callsign: None,
} }
} }
} }
@@ -11,6 +11,8 @@ build = "build.rs"
[dependencies] [dependencies]
trx-core = { path = "../../../trx-core" } trx-core = { path = "../../../trx-core" }
trx-frontend = { path = ".." } trx-frontend = { path = ".." }
trx-logbook = { path = "../../trx-logbook" }
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
trx-protocol = { path = "../../../../src/trx-protocol" } trx-protocol = { path = "../../../../src/trx-protocol" }
tokio = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
@@ -30,3 +32,6 @@ pickledb = "0.5"
dirs = "6" dirs = "6"
uuid = { workspace = true } uuid = { workspace = true }
ts-rs = "12.0.1" ts-rs = "12.0.1"
[dev-dependencies]
tempfile = "3"
@@ -633,9 +633,12 @@ function elementById(id) {
compact: { label: "Compact", advanced: false, audio: false, scheduler: false, preferredTab: "main" }, 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" }, 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" }, 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; let activeRigId = null;
const layoutSections = [ const layoutSections = [
{ id: "advanced-radio-controls", key: "advanced" }, { id: "advanced-radio-controls", key: "advanced" },
@@ -1530,6 +1533,7 @@ function decodeCbor(buffer) {
var TAB_ORDER = [ var TAB_ORDER = [
"main", "main",
"bookmarks", "bookmarks",
"logbook",
"digital-modes", "digital-modes",
"map", "map",
"satellites", "satellites",
@@ -1541,6 +1545,7 @@ var TAB_ORDER = [
var TAB_PATHS = { var TAB_PATHS = {
main: "/", main: "/",
bookmarks: "/bookmarks", bookmarks: "/bookmarks",
logbook: "/logbook",
"digital-modes": "/digital-modes", "digital-modes": "/digital-modes",
map: "/map", map: "/map",
satellites: "/satellites", satellites: "/satellites",
@@ -1764,6 +1769,7 @@ var pluginGroups = {
satellites: ["/satellite-predictions.js"], satellites: ["/satellite-predictions.js"],
statistics: ["/map-core.js"], statistics: ["/map-core.js"],
bookmarks: ["/bookmarks.js"], bookmarks: ["/bookmarks.js"],
logbook: ["/logbook.js"],
recorder: [], recorder: [],
settings: ["/vchan.js", "/scheduler.js"] settings: ["/vchan.js", "/scheduler.js"]
}; };
@@ -1788,7 +1794,7 @@ async function loadPlugins(group) {
for (const path of pluginGroups[group]) await loadPlugin(path); for (const path of pluginGroups[group]) await loadPlugin(path);
} }
async function loadEagerPlugins() { 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) { async function loadPluginsForTab(tab) {
await loadPlugins(tab); await loadPlugins(tab);
@@ -2950,7 +2956,10 @@ function refreshOperatorLayoutCapabilities() {
)); ));
window.trxUi?.setLayoutCapabilities({ window.trxUi?.setLayoutCapabilities({
broadcast: rigModes.some((modes) => modes.includes("WFM")), 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) { function showHint(msg, duration) {
@@ -4,7 +4,7 @@ import {
collapseAprsDuplicates, collapseAprsDuplicates,
normalizeAprsPacket, normalizeAprsPacket,
renderAprsPacketRow renderAprsPacketRow
} from "./chunk-OPEIVJGD.js"; } from "./chunk-REYSUJQ4.js";
import { import {
forActiveRig, forActiveRig,
isActiveRigDecode isActiveRigDecode
@@ -139,6 +139,9 @@ function renderAprsRow(pkt, isFresh) {
}, },
onCopy: (text) => { onCopy: (text) => {
void copyAprsCoords(text); void copyAprsCoords(text);
},
onLog: (call, gridsquare) => {
aprsWindow.logContact?.({ call, gridsquare: gridsquare ?? void 0, decoder: "aprs" });
} }
}); });
} }
@@ -214,6 +214,16 @@ function summarizeAprsPayload(packet) {
const text = info.replace(/^[>;<?]/, "").trim(); const text = info.replace(/^[>;<?]/, "").trim();
return text.length ? text : null; return text.length ? text : null;
} }
function maidenheadForLatLon(lat, lon) {
const adjustedLon = lon + 180;
const adjustedLat = lat + 90;
const field = String.fromCharCode(65 + Math.floor(adjustedLon / 20)) + String.fromCharCode(65 + Math.floor(adjustedLat / 10));
const square = String(Math.floor(adjustedLon % 20 / 2)) + String(Math.floor(adjustedLat % 10));
const subLon = adjustedLon % 2 * 60 / 5;
const subLat = adjustedLat % 1 * 60 / 2.5;
const sub = String.fromCharCode(97 + Math.floor(subLon)) + String.fromCharCode(97 + Math.floor(subLat));
return `${field}${square}${sub}`.toUpperCase();
}
function renderAprsPacketRow(packet, options = {}) { function renderAprsPacketRow(packet, options = {}) {
const row = document.createElement("details"); const row = document.createElement("details");
row.className = "aprs-packet"; row.className = "aprs-packet";
@@ -224,7 +234,7 @@ function renderAprsPacketRow(packet, options = {}) {
const summary = summarizeAprsPayload(packet); const summary = summarizeAprsPayload(packet);
const hasPosition = packet.lat != null && packet.lon != null; const hasPosition = packet.lat != null && packet.lon != null;
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`; const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`;
row.innerHTML = `<summary class="decode-line"><span class="aprs-time">${escapeAprsHtml(time)}</span>` + (options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") + renderAprsSymbolSlot(packet, escapeAprsHtml) + `<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span><span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">${escapeAprsHtml(aprsCategoryLabel(category))}</span><span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` + (packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') + (options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>&gt;${escapeAprsHtml(packet.destCall || "--")}</span><span>${escapeAprsHtml(packet.path || "no path")}</span><span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span><span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` + (hasPosition ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>` : "") + `</div><div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` + (packet.info_bytes?.length ? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>` : "") + `<div class="aprs-row-actions">` + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div></div>`; row.innerHTML = `<summary class="decode-line"><span class="aprs-time">${escapeAprsHtml(time)}</span>` + (options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") + renderAprsSymbolSlot(packet, escapeAprsHtml) + `<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span><span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">${escapeAprsHtml(aprsCategoryLabel(category))}</span><span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` + (packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') + (options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>&gt;${escapeAprsHtml(packet.destCall || "--")}</span><span>${escapeAprsHtml(packet.path || "no path")}</span><span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span><span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` + (hasPosition ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>` : "") + `</div><div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` + (packet.info_bytes?.length ? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>` : "") + `<div class="aprs-row-actions">` + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` + (options.onLog && packet.srcCall ? '<button class="aprs-inline-btn" type="button" data-aprs-log="1">Log</button>' : "") + `</div></div>`;
row.querySelectorAll("[data-aprs-map]").forEach((element) => { row.querySelectorAll("[data-aprs-map]").forEach((element) => {
element.addEventListener("click", (event) => { element.addEventListener("click", (event) => {
event.preventDefault(); event.preventDefault();
@@ -233,6 +243,15 @@ function renderAprsPacketRow(packet, options = {}) {
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat, lon); 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]"); const copyButton = row.querySelector("[data-aprs-copy]");
if (copyButton) { if (copyButton) {
copyButton.addEventListener("click", (event) => { copyButton.addEventListener("click", (event) => {
@@ -152,6 +152,24 @@ function initializeFtxDecoder(config) {
second: "2-digit" second: "2-digit"
}); });
row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`; row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
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; return row;
}; };
const render = () => { const render = () => {
@@ -1,6 +1,6 @@
import { import {
initializeFtxDecoder initializeFtxDecoder
} from "./chunk-PJ5Q7CWJ.js"; } from "./chunk-WU5EWJX4.js";
import "./chunk-S57W63QN.js"; import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js"; import "./chunk-KL66PICH.js";
@@ -1,6 +1,6 @@
import { import {
initializeFtxDecoder initializeFtxDecoder
} from "./chunk-PJ5Q7CWJ.js"; } from "./chunk-WU5EWJX4.js";
import "./chunk-S57W63QN.js"; import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js"; import "./chunk-KL66PICH.js";
@@ -2,7 +2,7 @@ import {
initializeFt8FamilyBar, initializeFt8FamilyBar,
initializeFtxDecoder, initializeFtxDecoder,
installFtxCompatibilityHelpers installFtxCompatibilityHelpers
} from "./chunk-PJ5Q7CWJ.js"; } from "./chunk-WU5EWJX4.js";
import "./chunk-S57W63QN.js"; import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js"; import "./chunk-KL66PICH.js";
@@ -4,7 +4,7 @@ import {
collapseAprsDuplicates, collapseAprsDuplicates,
normalizeAprsPacket, normalizeAprsPacket,
renderAprsPacketRow renderAprsPacketRow
} from "./chunk-OPEIVJGD.js"; } from "./chunk-REYSUJQ4.js";
import { import {
forActiveRig, forActiveRig,
isActiveRigDecode isActiveRigDecode
@@ -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 = '<tr><td colspan="10" class="log-empty">No contacts yet. Work someone and log them here, or bring a log in with Import ADIF.</td></tr>';
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();
@@ -1,6 +1,6 @@
import { import {
aprsSymbolSprite aprsSymbolSprite
} from "./chunk-OPEIVJGD.js"; } from "./chunk-REYSUJQ4.js";
// src/map-core.ts // src/map-core.ts
function mapEl(id) { function mapEl(id) {
@@ -22,6 +22,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<svg xmlns="http://www.w3.org/2000/svg" style="display:none"> <svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<symbol id="icon-home" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.8 7 8 2.9 13.2 7"/><path d="M4.3 5.9V13h7.4V5.9"/><path d="M6.8 13V9.3h2.4V13"/></symbol> <symbol id="icon-home" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.8 7 8 2.9 13.2 7"/><path d="M4.3 5.9V13h7.4V5.9"/><path d="M6.8 13V9.3h2.4V13"/></symbol>
<symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol> <symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol>
<symbol id="icon-logbook" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 2h8a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M5.5 2v12"/><path d="M7.5 5.5h3M7.5 8h3M7.5 10.5h2"/></symbol>
<symbol id="icon-digital" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 11.5h2.6V4.5h3.2v7h3.2v-7h3.2v7H15"/></symbol> <symbol id="icon-digital" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 11.5h2.6V4.5h3.2v7h3.2v-7h3.2v7H15"/></symbol>
<symbol id="icon-map" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></symbol> <symbol id="icon-map" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></symbol>
<symbol id="icon-satellite" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 13.2 5.6 9.6"/><path d="M4.2 8.2a3.4 3.4 0 0 1 4.8 4.8z"/><path d="M9.2 6.6a3.2 3.2 0 0 1 2.6 2.6"/><path d="M9.8 3.6a6.2 6.2 0 0 1 5.2 5.2"/></symbol> <symbol id="icon-satellite" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 13.2 5.6 9.6"/><path d="M4.2 8.2a3.4 3.4 0 0 1 4.8 4.8z"/><path d="M9.2 6.6a3.2 3.2 0 0 1 2.6 2.6"/><path d="M9.8 3.6a6.2 6.2 0 0 1 5.2 5.2"/></symbol>
@@ -53,6 +54,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg> <svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
<span class="tab-label">Bookmarks</span> <span class="tab-label">Bookmarks</span>
</button> </button>
<button class="tab" data-tab="logbook">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-logbook"/></svg>
<span class="tab-label">Logbook</span>
</button>
<button class="tab" data-tab="digital-modes" aria-label="Digital modes"> <button class="tab" data-tab="digital-modes" aria-label="Digital modes">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-digital"/></svg> <svg class="tab-icon" aria-hidden="true"><use href="#icon-digital"/></svg>
<span class="tab-label">Digital modes</span> <span class="tab-label">Digital modes</span>
@@ -480,6 +485,111 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div> </div>
</div> </div>
</div> </div>
<div id="tab-logbook" class="tab-panel" style="display:none;">
<div id="logbook-panel" class="log-panel">
<!-- Station: who the contacts are logged under, and from where. Shown
once rather than typed into every entry. -->
<div class="log-station">
<div class="log-station-identity">
<span class="log-station-call" id="log-station-callsign">--</span>
<label class="log-station-operator">
<span>Operator</span>
<input type="text" id="log-operator" class="status-input" autocomplete="off"
spellcheck="false" aria-label="Operator callsign" />
</label>
</div>
<div class="log-station-rig">
<span id="log-station-rig-name">--</span>
<span id="log-station-grid" class="log-station-grid"></span>
<span id="log-clock" class="log-clock" aria-live="polite"></span>
</div>
</div>
<!-- Entry: opens pre-filled with the six fields the radio and the clock
can answer for. Everything else is the operator's. -->
<form id="log-entry-form" class="log-entry" autocomplete="off">
<div class="log-entry-grid">
<label class="log-field log-field-call">
<span>Callsign</span>
<input type="text" id="log-call" class="status-input" required
spellcheck="false" aria-describedby="log-worked-before" />
</label>
<label class="log-field">
<span>Frequency</span>
<input type="text" id="log-freq" class="status-input" inputmode="decimal" />
</label>
<label class="log-field log-field-narrow">
<span>Mode</span>
<input type="text" id="log-mode" class="status-input" spellcheck="false" />
</label>
<label class="log-field log-field-narrow">
<span>RST sent</span>
<input type="text" id="log-rst-sent" class="status-input" spellcheck="false" />
</label>
<label class="log-field log-field-narrow">
<span>RST rcvd</span>
<input type="text" id="log-rst-rcvd" class="status-input" spellcheck="false" />
</label>
<label class="log-field log-field-narrow">
<span>Locator</span>
<input type="text" id="log-grid" class="status-input" spellcheck="false" />
</label>
<label class="log-field">
<span>Name</span>
<input type="text" id="log-name" class="status-input" />
</label>
<label class="log-field">
<span>Comment</span>
<input type="text" id="log-comment" class="status-input" />
</label>
</div>
<div class="log-entry-actions">
<span id="log-worked-before" class="log-worked" aria-live="polite"></span>
<span class="log-entry-buttons">
<button type="button" id="log-clear-btn" class="log-secondary-btn">Clear</button>
<button type="submit" id="log-save-btn" class="log-primary-btn">Log contact</button>
</span>
</div>
</form>
<!-- The log itself. -->
<div class="log-list-toolbar">
<input type="text" id="log-filter-call" class="status-input" placeholder="Callsign&hellip;"
aria-label="Filter by callsign" />
<select id="log-filter-band" class="status-input" aria-label="Filter by band">
<option value="">All bands</option>
</select>
<select id="log-filter-mode" class="status-input" aria-label="Filter by mode">
<option value="">All modes</option>
</select>
<span class="log-toolbar-spacer"></span>
<a id="log-export-btn" class="log-secondary-btn" href="/api/logbook/export.adi"
download>Export ADIF</a>
<button type="button" id="log-import-btn" class="log-secondary-btn">Import ADIF</button>
<input type="file" id="log-import-file" accept=".adi,.adif,text/plain" hidden />
</div>
<div class="log-table-wrap">
<table class="log-table">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Time</th>
<th scope="col">Callsign</th>
<th scope="col">Band</th>
<th scope="col">Mode</th>
<th scope="col">Sent</th>
<th scope="col">Rcvd</th>
<th scope="col">Locator</th>
<th scope="col">Rig</th>
<th scope="col"><span class="visually-hidden">Actions</span></th>
</tr>
</thead>
<tbody id="log-rows"></tbody>
</table>
</div>
<div id="log-summary" class="log-summary" aria-live="polite"></div>
</div>
</div>
<div id="tab-bookmarks" class="tab-panel" style="display:none;"> <div id="tab-bookmarks" class="tab-panel" style="display:none;">
<div class="bm-toolbar"> <div class="bm-toolbar">
<select id="bm-scope-picker" class="status-input" aria-label="Bookmark scope"> <select id="bm-scope-picker" class="status-input" aria-label="Bookmark scope">
@@ -6388,3 +6388,248 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
} }
#tab-statistics > .section-heading { margin-bottom: 0; } #tab-statistics > .section-heading { margin-bottom: 0; }
/* =========================================================================
Logbook
========================================================================= */
.log-panel {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
/* Who the contacts are logged under, and from where: shown once, not typed
into every entry. */
.log-station {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.6rem 0.85rem;
border: 1px solid var(--border-light);
border-radius: 0.5rem;
background: var(--bg-secondary);
}
.log-station-identity {
display: flex;
align-items: center;
gap: 0.85rem;
min-width: 0;
}
.log-station-call {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text);
}
.log-station-operator {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
color: var(--text-muted);
}
.log-station-operator input {
width: 7rem;
text-transform: uppercase;
}
.log-station-rig {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.82rem;
color: var(--text-muted);
min-width: 0;
}
.log-station-grid {
font-variant-numeric: tabular-nums;
}
.log-clock {
font-variant-numeric: tabular-nums;
padding: 0.1rem 0.45rem;
border-radius: 0.3rem;
background: color-mix(in srgb, var(--card-bg) 70%, transparent);
}
/* A browser clock that disagrees with the radio's is worth saying out loud:
the times in a log are the one thing that cannot be corrected later. */
.log-clock.is-adrift {
color: var(--accent-yellow, #b7791f);
font-weight: 600;
}
.log-entry {
border: 1px solid var(--border-light);
border-radius: 0.5rem;
padding: 0.85rem;
background: var(--bg-secondary);
}
.log-entry-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
gap: 0.6rem 0.75rem;
}
.log-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
font-size: 0.75rem;
color: var(--text-muted);
}
.log-field input {
min-width: 0;
}
.log-field-call {
grid-column: span 2;
}
.log-field-call input {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.log-field-narrow {
max-width: 10rem;
}
.log-entry-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-top: 0.75rem;
}
.log-entry-buttons {
display: inline-flex;
gap: 0.5rem;
margin-left: auto;
}
.log-worked {
font-size: 0.8rem;
color: var(--text-muted);
}
.log-worked.is-worked {
color: var(--accent-green);
font-weight: 600;
}
.log-primary-btn {
background: var(--accent-green);
color: #fff;
border: none;
border-radius: 0.35rem;
padding: 0.4rem 1.1rem;
font-weight: 600;
cursor: pointer;
}
.log-secondary-btn {
display: inline-flex;
align-items: center;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: 0.35rem;
color: var(--text-muted);
padding: 0.35rem 0.75rem;
font-size: 0.82rem;
text-decoration: none;
cursor: pointer;
}
.log-secondary-btn:hover {
background: var(--card-bg);
color: var(--text);
}
.log-list-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.log-list-toolbar .status-input {
max-width: 11rem;
}
.log-toolbar-spacer {
flex: 1 1 auto;
}
.log-table-wrap {
overflow-x: auto;
border: 1px solid var(--border-light);
border-radius: 0.5rem;
}
.log-table {
width: 100%;
border-collapse: collapse;
font-size: 0.83rem;
}
.log-table th {
position: sticky;
top: 0;
z-index: 1;
text-align: left;
padding: 0.5rem 0.65rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-light);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
white-space: nowrap;
}
.log-table td {
padding: 0.4rem 0.65rem;
border-bottom: 1px solid color-mix(in srgb, var(--border-light) 60%, transparent);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.log-table tr:last-child td {
border-bottom: none;
}
.log-table tbody tr:hover {
background: color-mix(in srgb, var(--card-bg) 60%, transparent);
}
.log-cell-call {
font-weight: 700;
letter-spacing: 0.04em;
font-variant-numeric: normal;
}
.log-row-btn {
background: transparent;
border: 1px solid var(--border-light);
border-radius: 0.3rem;
color: var(--text-muted);
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
cursor: pointer;
}
.log-row-btn:hover {
color: var(--text);
background: var(--card-bg);
}
.log-empty {
padding: 1.5rem 0.75rem;
text-align: center;
color: var(--text-muted);
white-space: normal;
}
.log-summary {
color: var(--text-muted);
font-size: 0.8rem;
}
/* The ham layout works the bands: the broadcast furniture goes, and the entry
form is the thing the panel opens on. */
body[data-operator-layout="ham"] .controls-col-wfm { display: none !important; }
body[data-operator-layout="ham"] #rds-panel { display: none !important; }
@media (max-width: 700px) {
.log-station,
.log-entry-actions {
flex-direction: column;
align-items: stretch;
}
.log-entry-buttons {
margin-left: 0;
}
.log-field-call {
grid-column: span 2;
}
.log-list-toolbar .status-input {
max-width: none;
flex: 1 1 8rem;
}
}
@@ -33,6 +33,7 @@ await build({
"satellite-predictions": path.join(sourceDir, "plugins", "satellite-predictions.ts"), "satellite-predictions": path.join(sourceDir, "plugins", "satellite-predictions.ts"),
vchan: path.join(sourceDir, "plugins", "vchan.ts"), vchan: path.join(sourceDir, "plugins", "vchan.ts"),
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"), bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
logbook: path.join(sourceDir, "plugins", "logbook.ts"),
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"), scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
"map-core": path.join(sourceDir, "map-core.ts"), "map-core": path.join(sourceDir, "map-core.ts"),
screenshot: path.join(sourceDir, "screenshot.ts"), screenshot: path.join(sourceDir, "screenshot.ts"),
@@ -12,7 +12,7 @@
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json", "typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern", "lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
"test": "node --test tests/*.test.mjs", "test": "node --test tests/*.test.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs", "test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs && node tests/logbook.mjs",
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts" "verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
}, },
"devDependencies": { "devDependencies": {
@@ -265,7 +265,7 @@ interface TrxUi {
confirm(options?: { title?: string; message?: string; confirmLabel?: string; danger?: boolean }): Promise<boolean>; confirm(options?: { title?: string; message?: string; confirmLabel?: string; danger?: boolean }): Promise<boolean>;
notify(message: string, options?: { kind?: "info" | "error" | "success" | "warning"; duration?: number; action?: { label?: string; run(): void } | null }): HTMLDivElement; notify(message: string, options?: { kind?: "info" | "error" | "success" | "warning"; duration?: number; action?: { label?: string; run(): void } | null }): HTMLDivElement;
setActiveRig(rigId: string | null): void; setActiveRig(rigId: string | null): void;
setLayoutCapabilities(capabilities?: Partial<Record<"broadcast" | "digital", boolean>>): void; setLayoutCapabilities(capabilities?: Partial<Record<"broadcast" | "digital" | "ham", boolean>>): void;
setButtonState(element: HTMLButtonElement | null, options?: { active?: boolean; activeLabel?: string; inactiveLabel?: string; busy?: boolean; disabled?: boolean }): void; setButtonState(element: HTMLButtonElement | null, options?: { active?: boolean; activeLabel?: string; inactiveLabel?: string; busy?: boolean; disabled?: boolean }): void;
closeMobileOverlays?(restoreFocus?: boolean): void; closeMobileOverlays?(restoreFocus?: boolean): void;
syncSelectedTab(container: HTMLElement | null, selected: Element): void; syncSelectedTab(container: HTMLElement | null, selected: Element): void;
@@ -1522,6 +1522,9 @@ function refreshOperatorLayoutCapabilities() {
window.trxUi?.setLayoutCapabilities({ window.trxUi?.setLayoutCapabilities({
broadcast: rigModes.some((modes) => modes.includes("WFM")), 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),
}); });
} }
@@ -3,7 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
export const TAB_ORDER = [ export const TAB_ORDER = [
"main", "bookmarks", "digital-modes", "map", "satellites", "statistics", "recorder", "settings", "about", "main", "bookmarks", "logbook", "digital-modes", "map", "satellites", "statistics", "recorder", "settings", "about",
] as const; ] as const;
export type TabName = typeof TAB_ORDER[number]; export type TabName = typeof TAB_ORDER[number];
@@ -11,6 +11,7 @@ export type TabName = typeof TAB_ORDER[number];
export const TAB_PATHS: Readonly<Record<TabName, string>> = { export const TAB_PATHS: Readonly<Record<TabName, string>> = {
main: "/", main: "/",
bookmarks: "/bookmarks", bookmarks: "/bookmarks",
logbook: "/logbook",
"digital-modes": "/digital-modes", "digital-modes": "/digital-modes",
map: "/map", map: "/map",
satellites: "/satellites", satellites: "/satellites",
@@ -4,7 +4,7 @@
type PluginGroup = type PluginGroup =
| "digital-modes" | "map-data" | "map" | "satellites" | "statistics" | "digital-modes" | "map-data" | "map" | "satellites" | "statistics"
| "bookmarks" | "recorder" | "settings"; | "bookmarks" | "logbook" | "recorder" | "settings";
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = { const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
// AIS, VDES and the two APRS decoders have panels on this tab, so they load // AIS, VDES and the two APRS decoders have panels on this tab, so they load
@@ -20,6 +20,7 @@ const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
satellites: ["/satellite-predictions.js"], satellites: ["/satellite-predictions.js"],
statistics: ["/map-core.js"], statistics: ["/map-core.js"],
bookmarks: ["/bookmarks.js"], bookmarks: ["/bookmarks.js"],
logbook: ["/logbook.js"],
recorder: [], recorder: [],
settings: ["/vchan.js", "/scheduler.js"], settings: ["/vchan.js", "/scheduler.js"],
}; };
@@ -47,7 +48,7 @@ async function loadPlugins(group: string): Promise<void> {
} }
export async function loadEagerPlugins(): Promise<void> { export async function loadEagerPlugins(): Promise<void> {
await Promise.all(["digital-modes", "bookmarks", "settings"].map(loadPlugins)); await Promise.all(["digital-modes", "bookmarks", "logbook", "settings"].map(loadPlugins));
} }
export async function loadPluginsForTab(tab: string): Promise<void> { export async function loadPluginsForTab(tab: string): Promise<void> {
@@ -324,6 +324,21 @@ export interface AprsRowOptions {
onMap?: (lat: number, lon: number) => void; onMap?: (lat: number, lon: number) => void;
/** Puts a frame's coordinates on the clipboard. */ /** Puts a frame's coordinates on the clipboard. */
onCopy?: (text: string, button: HTMLElement) => void; onCopy?: (text: string, button: HTMLElement) => void;
/** Opens a log entry for the station that sent the frame. */
onLog?: (call: string, gridsquare: string | null) => void;
}
/** A position as a six-character locator, which is what a log wants. */
function maidenheadForLatLon(lat: number, lon: number): string {
const adjustedLon = lon + 180;
const adjustedLat = lat + 90;
const field = String.fromCharCode(65 + Math.floor(adjustedLon / 20))
+ String.fromCharCode(65 + Math.floor(adjustedLat / 10));
const square = String(Math.floor((adjustedLon % 20) / 2)) + String(Math.floor(adjustedLat % 10));
const subLon = ((adjustedLon % 2) * 60) / 5;
const subLat = ((adjustedLat % 1) * 60) / 2.5;
const sub = String.fromCharCode(97 + Math.floor(subLon)) + String.fromCharCode(97 + Math.floor(subLat));
return `${field}${square}${sub}`.toUpperCase();
} }
export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions = {}): HTMLElement { export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions = {}): HTMLElement {
@@ -370,6 +385,7 @@ export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions
(hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") +
(hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") +
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
(options.onLog && packet.srcCall ? '<button class="aprs-inline-btn" type="button" data-aprs-log="1">Log</button>' : "") +
`</div>` + `</div>` +
`</div>`; `</div>`;
@@ -381,6 +397,17 @@ export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat as number, lon as number); if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat as number, lon as number);
}); });
}); });
const logButton = row.querySelector<HTMLElement>("[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<HTMLElement>("[data-aprs-copy]"); const copyButton = row.querySelector<HTMLElement>("[data-aprs-copy]");
if (copyButton) { if (copyButton) {
copyButton.addEventListener("click", (event) => { copyButton.addEventListener("click", (event) => {
@@ -23,6 +23,7 @@ interface AprsBridge {
aprsMapAddStation?: (call: string, lat: number, lon: number, info: string, symbolTable: string | null | undefined, symbolCode: string | null | undefined, packet: AprsPacket) => void; aprsMapAddStation?: (call: string, lat: number, lon: number, info: string, symbolTable: string | null | undefined, symbolCode: string | null | undefined, packet: AprsPacket) => void;
getDecodeRigMeta?: () => unknown; getDecodeRigMeta?: () => unknown;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> }; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
logContact?: (seed: { call?: string; gridsquare?: string | undefined; decoder?: string }) => void;
updateAprsBar?: () => void; updateAprsBar?: () => void;
clearAprsBar?: () => void; clearAprsBar?: () => void;
closeAprsBar?: () => void; closeAprsBar?: () => void;
@@ -164,6 +165,9 @@ function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
distance: aprsDistanceText(pkt), distance: aprsDistanceText(pkt),
onMap: (lat, lon) => { aprsWindow.navigateToAprsMap?.(lat, lon); }, onMap: (lat, lon) => { aprsWindow.navigateToAprsMap?.(lat, lon); },
onCopy: (text) => { void copyAprsCoords(text); }, onCopy: (text) => { void copyAprsCoords(text); },
onLog: (call, gridsquare) => {
aprsWindow.logContact?.({ call, gridsquare: gridsquare ?? undefined, decoder: "aprs" });
},
}); });
} }
@@ -49,6 +49,8 @@ interface FtxBridge {
updateFt8Bar?: () => void; updateFt8Bar?: () => void;
registerFt8FamilyBarRenderer?: (decoder: FtxDecoderId, renderer: () => BarFrames) => void; registerFt8FamilyBarRenderer?: (decoder: FtxDecoderId, renderer: () => BarFrames) => void;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<void>; takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<void>;
/** Opens a log entry from a decode. Pre-fills it; never logs by itself. */
logContact?: (seed: { call?: string; gridsquare?: string | undefined; decoder?: string }) => void;
trxUi: ConfirmApi; trxUi: ConfirmApi;
clearFt8Bar?: () => void; clearFt8Bar?: () => void;
closeFt8Bar?: () => void; closeFt8Bar?: () => void;
@@ -224,6 +226,26 @@ export function initializeFtxDecoder(config: FtxConfig): void {
hour: "2-digit", minute: "2-digit", second: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit",
}); });
row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`; row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
// A decode is not a contact: this opens an entry with what was heard in it
// and leaves the logging to the operator.
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; return row;
}; };
const render = (): void => { const render = (): void => {
@@ -22,6 +22,8 @@ export interface HostDecoderDescriptor {
export interface HostState { export interface HostState {
readonly serverLat: number | null; readonly serverLat: number | null;
readonly serverLon: number | null; readonly serverLon: number | null;
/** The callsign this station is on the air with, from the client config. */
readonly ownerCallsign: string | null;
readonly authEnabled: boolean; readonly authEnabled: boolean;
readonly authRole: string | null; readonly authRole: string | null;
readonly lastActiveRigId: string | null; readonly lastActiveRigId: string | null;
@@ -43,6 +45,7 @@ export interface HostCore {
armOptimisticFrequency(frequencyHz: number): void; armOptimisticFrequency(frequencyHz: number): void;
escapeMapHtml(value: string): string; escapeMapHtml(value: string): string;
haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number; haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number;
latLonToMaidenhead(lat: number, lon: number): string;
showHint(message: string, durationMs?: number): void; showHint(message: string, durationMs?: number): void;
formatFreqForStep(frequencyHz: number, stepHz: number): string; formatFreqForStep(frequencyHz: number, stepHz: number): string;
refreshFreqDisplay(): void; refreshFreqDisplay(): void;
@@ -0,0 +1,430 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// The station log: one panel that opens an entry, keeps the log, and takes
// ADIF in and out.
//
// Times come from the server, never from here. The browser may be a phone in
// another timezone with a clock nobody has checked, and a log is only worth
// keeping if the times in it are the radio's.
import { hostCore, hostState } from "./host.js";
export {};
interface Qso {
id: string;
started_at: string;
call: string;
freq_hz: number;
band?: string | null;
mode: string;
submode?: string | null;
rst_sent?: string | null;
rst_rcvd?: string | null;
gridsquare?: string | null;
name?: string | null;
qth?: string | null;
comment?: string | null;
station_callsign?: string | null;
operator?: string | null;
my_gridsquare?: string | null;
my_rig?: string | null;
rig_id?: string | null;
}
interface Prefill {
started_at: string;
epoch_ms: number;
freq_hz: number;
band: string | null;
mode: string;
submode: string | null;
rig_id: string | null;
my_rig: string | null;
call: string | null;
gridsquare: string | null;
}
interface LogbookBridge {
trxUi: {
confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean>;
notify?(message: string, options?: { kind?: string }): void;
};
navigateToTab?: (name: string) => void;
/** Opens the panel with an entry started from a decode. */
logContact?: (seed: { call?: string; gridsquare?: string | undefined; decoder?: string }) => void;
}
const bridge = window as unknown as LogbookBridge;
const el = <T extends HTMLElement>(id: string): T | null => document.getElementById(id) as T | null;
const form = el<HTMLFormElement>("log-entry-form");
const callInput = el<HTMLInputElement>("log-call");
const freqInput = el<HTMLInputElement>("log-freq");
const modeInput = el<HTMLInputElement>("log-mode");
const rstSentInput = el<HTMLInputElement>("log-rst-sent");
const rstRcvdInput = el<HTMLInputElement>("log-rst-rcvd");
const gridInput = el<HTMLInputElement>("log-grid");
const nameInput = el<HTMLInputElement>("log-name");
const commentInput = el<HTMLInputElement>("log-comment");
const operatorInput = el<HTMLInputElement>("log-operator");
const rowsBody = el<HTMLTableSectionElement>("log-rows");
const summaryEl = el("log-summary");
const workedEl = el("log-worked-before");
const clockEl = el("log-clock");
const stationCallEl = el("log-station-callsign");
const stationRigEl = el("log-station-rig-name");
const stationGridEl = el("log-station-grid");
const filterCall = el<HTMLInputElement>("log-filter-call");
const filterBand = el<HTMLSelectElement>("log-filter-band");
const filterMode = el<HTMLSelectElement>("log-filter-mode");
const importBtn = el<HTMLButtonElement>("log-import-btn");
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");
/** The time the open entry was started, as the server gave it. */
let entryStartedAt: string | null = null;
/** The rig the open entry belongs to, likewise. */
let entryRigId: string | null = null;
let entryRigName: string | null = null;
let entryGrid: string | null = null;
let qsos: Qso[] = [];
let workedRequest = 0;
function notify(message: string, kind?: string): void {
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : undefined);
else hostCore.showHint(message, 2000);
}
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(path);
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
return await response.json() as T;
}
function formatFreq(hz: number): string {
if (!Number.isFinite(hz) || hz <= 0) return "";
// Through Number, not a trailing-zero trim: trimming "20.000000" as a string
// walks back through the point and leaves "2".
return String(Number((hz / 1e6).toFixed(6)));
}
/** What the operator typed, in Hz: MHz unless it is plainly already Hz. */
function parseFreq(text: string): number | null {
const value = Number(text.trim().replace(/\s+/g, "").replace(",", "."));
if (!Number.isFinite(value) || value <= 0) return null;
return value < 100_000 ? Math.round(value * 1e6) : Math.round(value);
}
function utcDate(iso: string): string {
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
}
function utcTime(iso: string): string {
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(11, 19);
}
// ── The entry ───────────────────────────────────────────────────────────────
/** Open an entry, pre-filled with the six fields the radio and clock answer. */
async function openEntry(
seed: { call?: string; gridsquare?: string | undefined; decoder?: string } = {},
): Promise<void> {
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<Prefill>(`/api/logbook/prefill?${params.toString()}`);
applyPrefill(prefill);
} catch (error: unknown) {
console.error("logbook prefill failed", error);
}
}
function applyPrefill(prefill: Prefill): void {
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();
}
/** The entry's time, and whether this browser agrees with it. */
function showClock(prefill: Prefill): void {
if (!clockEl) return;
const time = utcTime(prefill.started_at);
const drift = Math.abs(Date.now() - prefill.epoch_ms);
clockEl.textContent = drift > 1000
? `${time}Z · your clock is ${(drift / 1000).toFixed(0)}s out`
: `${time}Z`;
clockEl.classList.toggle("is-adrift", drift > 1000);
}
/** Clear the entry and open a fresh one. */
function resetEntry(): void {
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): Promise<void> {
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();
// USB and LSB are how a rig says SSB; the log wants the mode with the
// sideband beside it, which is what other loggers read.
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(() => ({})) as { error?: string };
throw new Error(detail.error ?? `HTTP ${String(response.status)}`);
}
notify(`${call} logged`);
resetEntry();
await refreshLog();
} catch (error: unknown) {
notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error");
} finally {
if (saveBtn) saveBtn.disabled = false;
}
}
/** Whether this station has been worked, and on what. */
function updateWorkedBefore(): void {
if (!workedEl) return;
const call = callInput?.value.trim() ?? "";
if (call.length < 3) {
workedEl.textContent = "";
return;
}
const request = ++workedRequest;
void getJson<{ worked: { band: string; mode: string }[] }>(
`/api/logbook/worked/${encodeURIComponent(call)}`,
).then((answer) => {
// A slower answer to an earlier callsign must not overwrite a later one.
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(() => { /* the log will still take the contact */ });
}
// ── The log ─────────────────────────────────────────────────────────────────
function currentQuery(): string {
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(): Promise<void> {
try {
const query = currentQuery();
const answer = await getJson<{ total: number; qsos: Qso[] }>(
`/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: unknown) {
console.error("logbook read failed", error);
}
}
function renderRows(): void {
if (!rowsBody) return;
if (qsos.length === 0) {
rowsBody.innerHTML =
'<tr><td colspan="10" class="log-empty">No contacts yet. Work someone and log them here,'
+ " or bring a log in with Import ADIF.</td></tr>";
return;
}
const fragment = document.createDocumentFragment();
for (const qso of qsos) {
const row = document.createElement("tr");
row.dataset.qsoId = qso.id;
const cells: (string | null | undefined)[] = [
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);
}
/** Bands and modes the log actually holds, so the filters offer only those. */
function renderFilterOptions(): void {
for (const [select, values] of [
[filterBand, [...new Set(qsos.map((q) => q.band).filter((b): b is string => !!b))]],
[filterMode, [...new Set(qsos.map((q) => q.submode ?? q.mode).filter(Boolean))]],
] as const) {
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: Qso): Promise<void> {
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: unknown) {
notify(`Could not delete: ${error instanceof Error ? error.message : String(error)}`, "error");
}
}
async function importAdif(file: File): Promise<void> {
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() as { added: number; duplicate: number; rejected: string[] };
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: unknown) {
notify(`Import failed: ${error instanceof Error ? error.message : String(error)}`, "error");
}
}
// ── Station line ────────────────────────────────────────────────────────────
function renderStation(): void {
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;
}
}
// ── Wiring ──────────────────────────────────────────────────────────────────
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 = "";
});
/** Start an entry from a decode, and show the operator where it went. */
bridge.logContact = (seed) => {
bridge.navigateToTab?.("logbook");
void openEntry(seed).then(() => callInput?.focus());
};
renderStation();
void openEntry();
void refreshLog();
@@ -24,8 +24,8 @@ interface ButtonStateOptions {
disabled?: boolean; disabled?: boolean;
} }
type TabKind = "primary" | "secondary"; type TabKind = "primary" | "secondary";
type LayoutCapability = "broadcast" | "digital"; type LayoutCapability = "broadcast" | "digital" | "ham";
type LayoutName = "compact" | "broadcast" | "digital" | "full"; type LayoutName = "compact" | "broadcast" | "digital" | "full" | "ham";
interface OperatorLayout { interface OperatorLayout {
label: string; label: string;
unavailable?: string; unavailable?: string;
@@ -191,8 +191,11 @@ function elementById<T extends HTMLElement>(id: string): T {
broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" }, 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" }, 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: Record<LayoutCapability, boolean> = { broadcast: false, digital: false }; const layoutCapabilities: Record<LayoutCapability, boolean> = { broadcast: false, digital: false, ham: false };
let activeRigId: string | null = null; let activeRigId: string | null = null;
// A layout seeds the collapsible sections; it does not hold them there. // A layout seeds the collapsible sections; it does not hold them there.
@@ -0,0 +1,162 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// The station log: the panel opens an entry pre-filled from the radio and the
// server's clock, writes a contact, keeps it, and says whether the station has
// been worked before. And the ham layout, which is offered only where a rig can
// transmit -- a log is of contacts made.
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document, window, location */
const HELD = [
{
id: "q1", started_at: "2026-08-06T14:22:00Z", call: "DL3ABC", freq_hz: 14_074_000,
band: "20m", mode: "FT8", rst_sent: "-07", rst_rcvd: "-12", gridsquare: "JO31",
my_rig: "Primary fixture",
},
{
id: "q2", started_at: "2026-08-06T13:05:00Z", call: "SP9XYZ", freq_hz: 7_120_000,
band: "40m", mode: "SSB", submode: "LSB", rst_sent: "59", rst_rcvd: "57",
gridsquare: "JO90", my_rig: "Primary fixture",
},
];
const fixture = await startWebFixture({ spectrum: true, tx: true, logbook: [...HELD] });
const { browser, page, runtimeErrors } = await startBrowser(chromium);
const readPanel = () => page.evaluate(() => ({
rows: [...(document.getElementById("log-rows")?.children ?? [])].map((row) => ({
call: row.querySelector(".log-cell-call")?.textContent ?? "",
cells: [...row.querySelectorAll("td")].map((cell) => cell.textContent),
})),
summary: document.getElementById("log-summary")?.textContent ?? "",
freq: document.getElementById("log-freq")?.value ?? "",
mode: document.getElementById("log-mode")?.value ?? "",
call: document.getElementById("log-call")?.value ?? "",
grid: document.getElementById("log-grid")?.value ?? "",
rig: document.getElementById("log-station-rig-name")?.textContent ?? "",
clock: document.getElementById("log-clock")?.textContent ?? "",
worked: document.getElementById("log-worked-before")?.textContent ?? "",
}));
try {
await page.setViewportSize({ width: 1400, height: 950 });
await page.goto(`${fixture.origin}/logbook`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-logbook").waitFor({ state: "visible" });
await page.waitForTimeout(2500);
// The log is there, newest first, with the band and mode of each contact.
const opened = await readPanel();
assert.deepEqual(opened.rows.map((row) => row.call), ["DL3ABC", "SP9XYZ"]);
assert.equal(opened.summary, "2 contacts");
// A phone contact shows the sideband it was worked on, not just "SSB".
assert.ok(opened.rows[1].cells.includes("LSB"), `the row reads ${JSON.stringify(opened.rows[1].cells)}`);
// The entry opened pre-filled: frequency and mode from the rig, the rig's
// name, and the time from the server.
assert.equal(opened.freq, "100", `the frequency pre-filled as "${opened.freq}"`);
assert.equal(opened.mode, "USB", `the mode pre-filled as "${opened.mode}"`);
assert.equal(opened.rig, "Primary fixture");
assert.match(opened.clock, /^\d{2}:\d{2}:\d{2}Z$/, `the clock reads "${opened.clock}"`);
// ...and nothing else was: a report is the operator's to give.
assert.equal(opened.call, "");
assert.equal(await page.evaluate(() => document.getElementById("log-rst-sent")?.value), "");
// Worked-before answers as the callsign is typed.
await page.locator("#log-call").fill("DL3ABC");
await page.waitForTimeout(600);
assert.match((await readPanel()).worked, /Worked before: 20m FT8/);
await page.locator("#log-call").fill("OZ1NEW");
await page.waitForTimeout(600);
assert.match((await readPanel()).worked, /Not worked before/);
// Logging writes the contact and clears the entry for the next one.
await page.locator("#log-rst-sent").fill("59");
await page.locator("#log-rst-rcvd").fill("57");
await page.locator("#log-save-btn").click();
await page.waitForTimeout(900);
const afterLogging = await readPanel();
assert.deepEqual(afterLogging.rows.map((row) => row.call), ["OZ1NEW", "DL3ABC", "SP9XYZ"]);
assert.equal(afterLogging.summary, "3 contacts");
assert.equal(afterLogging.call, "", "the entry kept the callsign after logging it");
assert.equal(afterLogging.freq, "100", "the entry did not re-open pre-filled");
// A decode row starts an entry rather than logging one: what was heard goes
// into the form, and the operator finishes it.
await page.evaluate(() => { window.navigateToTab("digital-modes"); });
await page.waitForTimeout(800);
await page.locator('#tab-digital-modes .sub-tab[data-subtab="ft8"]').click();
await page.waitForTimeout(400);
await page.evaluate(() => {
window.trxPluginRuntime.dispatch("ft8", {
message: "CQ SP2SJG JO94", snr_db: -7, dt_s: 0.2, freq_hz: 1200,
ts_ms: Date.now(), rig_id: "rig-a",
});
});
await page.waitForTimeout(600);
await page.locator("#ft8-messages .ft8-log-btn").first().click();
await page.waitForTimeout(900);
const fromDecode = await page.evaluate(() => ({
path: location.pathname,
call: document.getElementById("log-call")?.value ?? "",
grid: document.getElementById("log-grid")?.value ?? "",
mode: document.getElementById("log-mode")?.value ?? "",
rows: document.getElementById("log-rows")?.children.length ?? 0,
}));
assert.equal(fromDecode.path, "/logbook", `the Log button landed on ${fromDecode.path}`);
assert.equal(fromDecode.call, "SP2SJG", "the heard callsign did not reach the entry");
assert.equal(fromDecode.grid, "JO94", "the heard locator did not reach the entry");
// The decoder answers for the mode, because a rig in DIG cannot.
assert.equal(fromDecode.mode, "FT8", `the mode came through as "${fromDecode.mode}"`);
assert.equal(fromDecode.rows, 3, "a decode logged itself instead of opening an entry");
// The ham layout is on offer, because this rig can transmit.
const layouts = await page.evaluate(() =>
[...document.querySelectorAll("#operator-layout-select option")].map((option) => option.value));
assert.ok(layouts.includes("ham"), `the layouts are ${JSON.stringify(layouts)}`);
// Choosing it the way the picker does. The <select> itself measures zero
// wide in a headless window, so Playwright will not click it; the change
// event is what the picker is wired to either way.
await page.evaluate(() => { window.navigateToTab("main"); });
await page.waitForTimeout(600);
await page.evaluate(() => {
const select = document.getElementById("operator-layout-select");
select.value = "ham";
select.dispatchEvent(new Event("change", { bubbles: true }));
});
await page.waitForTimeout(900);
const inHamLayout = await page.evaluate(() => ({
layout: document.body.dataset.operatorLayout,
path: location.pathname,
}));
assert.equal(inHamLayout.layout, "ham");
assert.equal(inHamLayout.path, "/logbook", "the ham layout opened somewhere else");
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await fixture.close();
}
// Without a transmitter there is nothing to log, so the layout is not offered.
const listenerFixture = await startWebFixture({ spectrum: true, tx: false });
const listener = await startBrowser(chromium);
try {
await listener.page.setViewportSize({ width: 1400, height: 950 });
await listener.page.goto(`${listenerFixture.origin}/`, { waitUntil: "domcontentloaded" });
await listener.page.locator("#content").waitFor({ state: "visible" });
await listener.page.waitForTimeout(2000);
const layouts = await listener.page.evaluate(() =>
[...document.querySelectorAll("#operator-layout-select option")].map((option) => option.value));
assert.ok(!layouts.includes("ham"), `a receiver was offered the ham layout: ${JSON.stringify(layouts)}`);
} finally {
await listener.browser.close();
await listenerFixture.close();
}
console.log("logbook tests passed");
@@ -140,6 +140,7 @@ export async function startWebFixture({
history = {}, history = {},
bookmarks = [], bookmarks = [],
backgroundDecode = null, backgroundDecode = null,
logbook = [],
bandplan = {}, bandplan = {},
bandplanEnabled = false, bandplanEnabled = false,
bandplanUnauthorizedFirst = false, bandplanUnauthorizedFirst = false,
@@ -279,6 +280,83 @@ export async function startWebFixture({
response.writeHead(200).end(); response.writeHead(200).end();
return; return;
} }
// The logbook: an in-memory station log, enough for the panel to open an
// entry, write it, read it back and export it.
if (url.pathname.startsWith("/api/logbook")) {
const tail = url.pathname.slice("/api/logbook".length);
if (tail === "/prefill") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({
started_at: new Date().toISOString(),
epoch_ms: Date.now(),
freq_hz: status.status.freq.hz,
band: "20m",
mode: url.searchParams.get("decoder") === "ft8" ? "FT8" : "SSB",
submode: url.searchParams.get("decoder") === "ft8" ? null : "USB",
rig_id: url.searchParams.get("remote") ?? "rig-a",
my_rig: "Primary fixture",
call: url.searchParams.get("call"),
gridsquare: url.searchParams.get("gridsquare"),
}));
return;
}
if (tail.startsWith("/worked/")) {
const call = decodeURIComponent(tail.slice("/worked/".length)).toUpperCase();
const worked = logbook
.filter((qso) => qso.call === call)
.map((qso) => ({ band: qso.band, mode: qso.mode }));
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ call, worked }));
return;
}
if (tail === "/export.adi") {
const body = logbook
.map((qso) => `<CALL:${qso.call.length}>${qso.call}<EOR>\n`)
.join("");
response.writeHead(200, { "content-type": "text/plain" });
response.end(body);
return;
}
if (request.method === "POST" && tail === "") {
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 qso = {
id: `qso-${String(logbook.length + 1)}`,
started_at: input.started_at ?? new Date().toISOString(),
call: String(input.call).toUpperCase(),
freq_hz: input.freq_hz,
band: "20m",
mode: input.mode,
submode: input.submode,
rst_sent: input.rst_sent,
rst_rcvd: input.rst_rcvd,
gridsquare: input.gridsquare ? String(input.gridsquare).toUpperCase() : null,
my_rig: input.my_rig,
operator: input.operator,
};
logbook.unshift(qso);
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(qso));
return;
}
if (request.method === "DELETE") {
const id = tail.replace("/", "");
const index = logbook.findIndex((qso) => qso.id === id);
if (index >= 0) logbook.splice(index, 1);
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ deleted: index >= 0 }));
return;
}
const wanted = (url.searchParams.get("call") ?? "").toUpperCase();
const matching = wanted ? logbook.filter((qso) => qso.call.includes(wanted)) : logbook;
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ total: logbook.length, qsos: matching }));
return;
}
// Background decode: the panel reads its config, its status, and the // Background decode: the panel reads its config, its status, and the
// bookmark list, and writes the config back. // bookmark list, and writes the config back.
if (url.pathname.startsWith("/background-decode/")) { if (url.pathname.startsWith("/background-decode/")) {
@@ -138,6 +138,17 @@ pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder {
) )
} }
#[get("/logbook")]
pub(crate) async fn logbook_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
}
#[get("/satellites")] #[get("/satellites")]
pub(crate) async fn satellites_index(req: HttpRequest) -> impl Responder { pub(crate) async fn satellites_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html(); let c = gz_index_html();
@@ -0,0 +1,372 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! The logbook over HTTP: read, write, import, export, and the clock a contact
//! is stamped from.
use std::sync::Arc;
use actix_web::http::header;
use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse, Responder};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use trx_logbook::qso::{adif_mode_for_rig_mode, band_for_hz, mode_for_decoder};
use trx_logbook::{LogQuery, Logbook, Qso};
use super::{active_rig_id_from_context, require_control};
use crate::server::auth::AuthState;
/// What a contact looks like on the wire.
///
/// The band is sent although it is derived, because every reader of the log
/// wants it and none of them should have to carry the band plan to get it.
#[derive(Debug, Serialize)]
struct QsoView {
#[serde(flatten)]
qso: Qso,
band: Option<&'static str>,
}
impl From<Qso> for QsoView {
fn from(qso: Qso) -> Self {
Self {
band: qso.band(),
qso,
}
}
}
/// A contact as the panel sends it.
///
/// Times come from the server (`started_at` omitted means "now"), so a browser
/// with a wrong clock cannot write a wrong time into the log.
#[derive(Debug, Deserialize)]
struct QsoInput {
#[serde(default)]
id: Option<String>,
#[serde(default)]
started_at: Option<chrono::DateTime<Utc>>,
#[serde(default)]
ended_at: Option<chrono::DateTime<Utc>>,
call: String,
freq_hz: u64,
mode: String,
#[serde(default)]
submode: Option<String>,
#[serde(default)]
rst_sent: Option<String>,
#[serde(default)]
rst_rcvd: Option<String>,
#[serde(default)]
gridsquare: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
qth: Option<String>,
#[serde(default)]
comment: Option<String>,
#[serde(default)]
tx_pwr_w: Option<f64>,
#[serde(default)]
station_callsign: Option<String>,
#[serde(default)]
operator: Option<String>,
#[serde(default)]
my_gridsquare: Option<String>,
#[serde(default)]
my_rig: Option<String>,
#[serde(default)]
rig_id: Option<String>,
}
fn blank_to_none(value: Option<String>) -> Option<String> {
value
.map(|text| text.trim().to_string())
.filter(|text| !text.is_empty())
}
impl QsoInput {
fn into_qso(self, existing: Option<Qso>) -> Result<Qso, String> {
let call = self.call.trim();
if call.is_empty() {
return Err("a contact needs a callsign".to_string());
}
if self.mode.trim().is_empty() {
return Err("a contact needs a mode".to_string());
}
let started_at = self
.started_at
.or(existing.as_ref().map(|q| q.started_at))
.unwrap_or_else(Utc::now);
let mut qso = Qso::new(
self.id
.or(existing.as_ref().map(|q| q.id.clone()))
.unwrap_or_else(trx_logbook::new_id),
started_at,
call,
self.freq_hz,
&self.mode,
);
qso.ended_at = self.ended_at;
qso.submode = blank_to_none(self.submode).map(|s| s.to_uppercase());
qso.rst_sent = blank_to_none(self.rst_sent);
qso.rst_rcvd = blank_to_none(self.rst_rcvd);
qso.gridsquare = blank_to_none(self.gridsquare).map(|g| g.to_uppercase());
qso.name = blank_to_none(self.name);
qso.qth = blank_to_none(self.qth);
qso.comment = blank_to_none(self.comment);
qso.tx_pwr_w = self.tx_pwr_w;
qso.station_callsign = blank_to_none(self.station_callsign).map(|c| c.to_uppercase());
qso.operator = blank_to_none(self.operator).map(|c| c.to_uppercase());
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);
// Whatever an imported contact carried that this application does not
// model stays with it through an edit.
if let Some(existing) = existing {
qso.extra = existing.extra;
}
Ok(qso)
}
}
fn book(logbook: &web::Data<Arc<Logbook>>) -> Arc<Logbook> {
logbook.get_ref().clone()
}
/// The rig's name as the operator knows it, which is what belongs in the log —
/// falling back to its id, since a log entry naming nothing is worse.
fn rig_display_name(context: &trx_frontend::FrontendRuntimeContext, rig_id: &str) -> String {
context
.routing
.remote_rigs
.lock()
.ok()
.and_then(|rigs| {
rigs.iter()
.find(|rig| rig.rig_id == rig_id)
.and_then(|rig| rig.display_name.clone())
})
.unwrap_or_else(|| rig_id.to_string())
}
/// `GET /api/logbook` — contacts, newest first, filtered.
#[get("/api/logbook")]
pub async fn list_qsos(
query: web::Query<LogQuery>,
logbook: web::Data<Arc<Logbook>>,
) -> impl Responder {
let query = query.into_inner();
let total = book(&logbook).count();
let qsos: Vec<QsoView> = book(&logbook)
.query(&query)
.into_iter()
.map(QsoView::from)
.collect();
HttpResponse::Ok().json(serde_json::json!({ "total": total, "qsos": qsos }))
}
/// `POST /api/logbook` — write a contact.
#[post("/api/logbook")]
pub async fn add_qso(
req: HttpRequest,
input: web::Json<QsoInput>,
logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?;
let qso = match input.into_inner().into_qso(None) {
Ok(qso) => qso,
Err(reason) => {
return Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": reason })))
}
};
match book(&logbook).put(qso) {
Ok(saved) => Ok(HttpResponse::Ok().json(QsoView::from(saved))),
Err(err) => Ok(HttpResponse::InternalServerError()
.json(serde_json::json!({ "error": err.to_string() }))),
}
}
/// `PUT /api/logbook/{id}` — change a contact.
#[put("/api/logbook/{id}")]
pub async fn edit_qso(
req: HttpRequest,
path: web::Path<String>,
input: web::Json<QsoInput>,
logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?;
let id = path.into_inner();
let Some(existing) = book(&logbook).get(&id) else {
return Ok(HttpResponse::NotFound().json(serde_json::json!({ "error": "no such contact" })));
};
let mut input = input.into_inner();
input.id = Some(id);
let qso = match input.into_qso(Some(existing)) {
Ok(qso) => qso,
Err(reason) => {
return Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": reason })))
}
};
match book(&logbook).put(qso) {
Ok(saved) => Ok(HttpResponse::Ok().json(QsoView::from(saved))),
Err(err) => Ok(HttpResponse::InternalServerError()
.json(serde_json::json!({ "error": err.to_string() }))),
}
}
/// `DELETE /api/logbook/{id}` — forget a contact.
#[delete("/api/logbook/{id}")]
pub async fn delete_qso(
req: HttpRequest,
path: web::Path<String>,
logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?;
match book(&logbook).delete(&path.into_inner()) {
Ok(true) => Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": true }))),
Ok(false) => {
Ok(HttpResponse::NotFound().json(serde_json::json!({ "error": "no such contact" })))
}
Err(err) => Ok(HttpResponse::InternalServerError()
.json(serde_json::json!({ "error": err.to_string() }))),
}
}
/// `GET /api/logbook/export.adi` — ADIF, honouring the same filters as a read.
#[get("/api/logbook/export.adi")]
pub async fn export_adi(
query: web::Query<LogQuery>,
logbook: web::Data<Arc<Logbook>>,
) -> impl Responder {
let text = book(&logbook).export_adi(&query.into_inner(), env!("CARGO_PKG_VERSION"));
let filename = format!("trx-rs-log-{}.adi", 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)
}
/// `POST /api/logbook/import` — take an ADIF file, skipping what is already held.
#[post("/api/logbook/import")]
pub async fn import_adi(
req: HttpRequest,
body: web::Bytes,
logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?;
match book(&logbook).import_adi(&body) {
Ok(outcome) => Ok(HttpResponse::Ok().json(outcome)),
Err(err) => Ok(HttpResponse::InternalServerError()
.json(serde_json::json!({ "error": err.to_string() }))),
}
}
/// `GET /api/logbook/worked/{call}` — which bands and modes this station has been
/// worked on.
#[get("/api/logbook/worked/{call}")]
pub async fn worked_before(
path: web::Path<String>,
logbook: web::Data<Arc<Logbook>>,
) -> impl Responder {
let call = path.into_inner();
let worked: Vec<serde_json::Value> = book(&logbook)
.worked_before(&call)
.into_iter()
.map(|(band, mode)| serde_json::json!({ "band": band, "mode": mode }))
.collect();
HttpResponse::Ok().json(serde_json::json!({
"call": trx_logbook::qso::normalize_call(&call),
"worked": worked,
}))
}
/// `GET /api/logbook/now` — the clock a contact is stamped from.
///
/// The panel shows the operator when their browser disagrees with it, rather
/// than logging a time nobody expected.
#[get("/api/logbook/now")]
pub async fn server_now() -> impl Responder {
let now = Utc::now();
HttpResponse::Ok().json(serde_json::json!({
"utc": now.to_rfc3339(),
"epoch_ms": now.timestamp_millis(),
}))
}
/// `GET /api/logbook/prefill` — the six fields an entry opens with.
///
/// Worked out on the server because that is where the rig state, the station
/// identity and the clock all are; the panel would otherwise have to assemble
/// them from three places and would still not have the clock.
#[derive(Debug, Deserialize)]
pub struct PrefillQuery {
/// The rig the entry is for.
#[serde(default)]
pub remote: Option<String>,
/// The decoder a decode row came from, which answers for the mode when the
/// rig only says `DIG`.
#[serde(default)]
pub decoder: Option<String>,
/// A callsign and locator carried over from a decode row.
#[serde(default)]
pub call: Option<String>,
#[serde(default)]
pub gridsquare: Option<String>,
}
#[get("/api/logbook/prefill")]
pub async fn prefill(
query: web::Query<PrefillQuery>,
context: web::Data<Arc<trx_frontend::FrontendRuntimeContext>>,
state: web::Data<tokio::sync::watch::Receiver<trx_core::RigState>>,
) -> impl Responder {
let query = query.into_inner();
let rig_id = query
.remote
.clone()
.filter(|id| !id.is_empty())
.or_else(|| active_rig_id_from_context(context.get_ref()));
let rig_state = rig_id
.as_deref()
.and_then(|id| context.rig_state_rx(id))
.unwrap_or_else(|| state.get_ref().clone())
.borrow()
.clone();
let freq_hz = rig_state.status.freq.hz;
let rig_mode = format!("{:?}", rig_state.status.mode);
// The rig cannot answer for DIG: a rig in DIG is in FT8 or FT4 depending on
// which decoder is running, so the decoder is asked first when there is one.
let (mode, submode) = query
.decoder
.as_deref()
.and_then(mode_for_decoder)
.or_else(|| adif_mode_for_rig_mode(&rig_mode))
.map_or((String::new(), None), |(mode, submode)| {
(mode.to_string(), submode.map(str::to_string))
});
let now = Utc::now();
HttpResponse::Ok().json(serde_json::json!({
"started_at": now.to_rfc3339(),
"epoch_ms": now.timestamp_millis(),
"freq_hz": freq_hz,
"band": band_for_hz(freq_hz),
"mode": mode,
"submode": submode,
"rig_id": rig_id,
"my_rig": rig_id.as_deref().map(|id| rig_display_name(context.get_ref(), id)),
"call": query.call.map(|c| trx_logbook::qso::normalize_call(&c)),
"gridsquare": query.gridsquare.map(|g| g.trim().to_uppercase()),
}))
}
@@ -7,6 +7,7 @@
mod assets; mod assets;
mod bookmarks; mod bookmarks;
mod decoder; mod decoder;
mod logbook;
pub mod recorder; pub mod recorder;
pub mod rig; pub mod rig;
mod sse; mod sse;
@@ -664,6 +665,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::recorder_index) .service(assets::recorder_index)
.service(assets::settings_index) .service(assets::settings_index)
.service(assets::about_index) .service(assets::about_index)
.service(assets::logbook_index)
.service(assets::satellites_index) .service(assets::satellites_index)
.service(assets::statistics_index) .service(assets::statistics_index)
.service(assets::bookmarks_index) .service(assets::bookmarks_index)
@@ -706,7 +708,17 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
// Auth endpoints // Auth endpoints
.service(crate::server::auth::login) .service(crate::server::auth::login)
.service(crate::server::auth::logout) .service(crate::server::auth::logout)
.service(crate::server::auth::session_status); .service(crate::server::auth::session_status)
// Logbook
.service(logbook::list_qsos)
.service(logbook::add_qso)
.service(logbook::edit_qso)
.service(logbook::delete_qso)
.service(logbook::export_adi)
.service(logbook::import_adi)
.service(logbook::worked_before)
.service(logbook::server_now)
.service(logbook::prefill);
} }
#[cfg(test)] #[cfg(test)]
@@ -935,6 +947,173 @@ mod tests {
); );
} }
/// Auth off: every session may write, as a station with no passphrase set.
fn auth_state_disabled() -> crate::server::auth::AuthState {
crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new(
false,
None,
None,
false,
std::time::Duration::from_secs(3600),
false,
crate::server::auth::SameSite::Lax,
))
}
/// Auth on with no session presented, which is what a listener is.
fn auth_state_locked() -> crate::server::auth::AuthState {
crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new(
true,
Some("listen".to_string()),
Some("control".to_string()),
false,
std::time::Duration::from_secs(3600),
false,
crate::server::auth::SameSite::Lax,
))
}
/// A contact written over HTTP comes back out of the log, and out of an
/// ADIF export that another logger could read.
#[actix_web::test]
async fn a_contact_written_over_http_is_kept_and_exported() {
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::list_qsos)
.service(logbook::export_adi)
.service(logbook::worked_before),
)
.await;
let response = actix_test::call_service(
&app,
actix_test::TestRequest::post()
.uri("/api/logbook")
.set_json(serde_json::json!({
"call": "sp2sjg",
"freq_hz": 14_074_000_u64,
"mode": "FT8",
"rst_sent": "-07",
"gridsquare": "jo94",
}))
.to_request(),
)
.await;
assert_eq!(response.status(), 200);
let written: serde_json::Value = actix_test::read_body_json(response).await;
// Normalised on the way in, and the band worked out from the dial.
assert_eq!(written["call"], "SP2SJG");
assert_eq!(written["gridsquare"], "JO94");
assert_eq!(written["band"], "20m");
let listed: serde_json::Value = actix_test::call_and_read_body_json(
&app,
actix_test::TestRequest::get()
.uri("/api/logbook")
.to_request(),
)
.await;
assert_eq!(listed["total"], 1);
assert_eq!(listed["qsos"][0]["call"], "SP2SJG");
let worked: serde_json::Value = actix_test::call_and_read_body_json(
&app,
actix_test::TestRequest::get()
.uri("/api/logbook/worked/SP2SJG")
.to_request(),
)
.await;
assert_eq!(worked["worked"][0]["band"], "20m");
let exported = actix_test::call_and_read_body(
&app,
actix_test::TestRequest::get()
.uri("/api/logbook/export.adi")
.to_request(),
)
.await;
let text = String::from_utf8_lossy(&exported);
assert!(text.contains("<CALL:6>SP2SJG"), "{text}");
assert!(text.contains("<BAND:3>20m"), "{text}");
assert!(text.contains("<EOR>"), "{text}");
}
/// 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() {
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_locked()))
.service(logbook::add_qso)
.service(logbook::list_qsos),
)
.await;
let response = actix_test::call_service(
&app,
actix_test::TestRequest::post()
.uri("/api/logbook")
.set_json(serde_json::json!({
"call": "SP2SJG", "freq_hz": 14_074_000_u64, "mode": "FT8",
}))
.to_request(),
)
.await;
assert!(
response.status().is_client_error(),
"a listener wrote to the log: {}",
response.status()
);
let listed: serde_json::Value = actix_test::call_and_read_body_json(
&app,
actix_test::TestRequest::get()
.uri("/api/logbook")
.to_request(),
)
.await;
assert_eq!(listed["total"], 0);
}
/// A contact needs a callsign; the panel is not the only thing that has to
/// insist on it.
#[actix_web::test]
async fn a_contact_without_a_callsign_is_refused() {
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 response = actix_test::call_service(
&app,
actix_test::TestRequest::post()
.uri("/api/logbook")
.set_json(serde_json::json!({
"call": " ", "freq_hz": 14_074_000_u64, "mode": "FT8",
}))
.to_request(),
)
.await;
assert_eq!(response.status(), 400);
}
/// GET /status returns 200 with valid JSON containing rig snapshot fields. /// GET /status returns 200 with valid JSON containing rig snapshot fields.
#[actix_web::test] #[actix_web::test]
async fn test_status_endpoint_returns_json() { async fn test_status_endpoint_returns_json() {
@@ -106,6 +106,39 @@ async fn serve(
activity_log_map.clone(), activity_log_map.clone(),
); );
// The log is opened once and shared: it is one station's log, not one per
// rig or per session.
let logbook_path = context
.http_ui
.logbook_path
.clone()
.map(std::path::PathBuf::from)
.unwrap_or_else(trx_logbook::default_log_path);
let logbook = match trx_logbook::Logbook::open(&logbook_path) {
Ok(logbook) => {
tracing::info!(
"Logbook: {} contact(s) in {}",
logbook.count(),
logbook_path.display()
);
Arc::new(logbook)
}
Err(err) => {
// A log that cannot be opened must not stop the radio working, but
// it must be said loudly: contacts made now will not be kept.
tracing::error!(
"Logbook at {} could not be opened ({err}); logging is unavailable",
logbook_path.display()
);
Arc::new(
trx_logbook::Logbook::open(std::path::Path::new(
&std::env::temp_dir().join("trx-rs-logbook.jsonl"),
))
.unwrap_or_else(|e| panic!("logbook fallback in the temp directory failed: {e}")),
)
}
};
let background_decode_path = BackgroundDecodeStore::default_path(); let background_decode_path = BackgroundDecodeStore::default_path();
let background_decode_store = Arc::new(BackgroundDecodeStore::open(&background_decode_path)); let background_decode_store = Arc::new(BackgroundDecodeStore::open(&background_decode_path));
let vchan_mgr = Arc::new(ClientChannelManager::new( let vchan_mgr = Arc::new(ClientChannelManager::new(
@@ -164,6 +197,7 @@ async fn serve(
session_rig_mgr, session_rig_mgr,
background_decode_mgr, background_decode_mgr,
recorder_mgr, recorder_mgr,
logbook,
)?; )?;
let handle = server.handle(); let handle = server.handle();
tokio::spawn(async move { tokio::spawn(async move {
@@ -192,6 +226,7 @@ fn build_server(
session_rig_mgr: Arc<api::SessionRigManager>, session_rig_mgr: Arc<api::SessionRigManager>,
background_decode_mgr: Arc<BackgroundDecodeManager>, background_decode_mgr: Arc<BackgroundDecodeManager>,
recorder_mgr: Arc<RecorderManager>, recorder_mgr: Arc<RecorderManager>,
logbook: Arc<trx_logbook::Logbook>,
) -> Result<Server, actix_web::Error> { ) -> Result<Server, actix_web::Error> {
let state_data = web::Data::new(state_rx); let state_data = web::Data::new(state_rx);
let rig_tx = web::Data::new(rig_tx); let rig_tx = web::Data::new(rig_tx);
@@ -209,6 +244,7 @@ fn build_server(
let session_rig_mgr = web::Data::new(session_rig_mgr); let session_rig_mgr = web::Data::new(session_rig_mgr);
let background_decode_mgr = web::Data::new(background_decode_mgr); let background_decode_mgr = web::Data::new(background_decode_mgr);
let recorder_mgr = web::Data::new(recorder_mgr); let recorder_mgr = web::Data::new(recorder_mgr);
let logbook = web::Data::new(logbook);
// Extract auth config values before moving context // Extract auth config values before moving context
let same_site = match context.http_auth.cookie_same_site.as_str() { let same_site = match context.http_auth.cookie_same_site.as_str() {
@@ -267,6 +303,7 @@ fn build_server(
.app_data(session_rig_mgr.clone()) .app_data(session_rig_mgr.clone())
.app_data(background_decode_mgr.clone()) .app_data(background_decode_mgr.clone())
.app_data(recorder_mgr.clone()) .app_data(recorder_mgr.clone())
.app_data(logbook.clone())
.wrap(Compress::default()) .wrap(Compress::default())
.wrap( .wrap(
DefaultHeaders::new() DefaultHeaders::new()
+19
View File
@@ -0,0 +1,19 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
[package]
name = "trx-logbook"
version.workspace = true
edition = "2021"
[dependencies]
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
dirs = "6"
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
tempfile = "3"
+414
View File
@@ -0,0 +1,414 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! ADIF, the format every other logger reads.
//!
//! ADI is a tagged text format: `<FIELD:length>value`, where the length counts
//! the bytes of the value that follows, records end at `<EOR>`, an optional
//! header ends at `<EOH>`, and anything outside a tag is ignored — which is how
//! files carry human notes between records.
//!
//! The reader is deliberately forgiving. Files in the wild have lowercase tags,
//! CRLF line endings, no header at all, type indicators on some fields and not
//! others, and fields this application has never heard of. None of that is a
//! reason to refuse someone's log. The writer, by contrast, is strict: what
//! leaves here has to be read by software that is not.
use std::collections::BTreeMap;
use chrono::Utc;
use crate::qso::{
adif_date, adif_time, freq_mhz_string, hz_from_mhz_string, normalize_call, parse_adif_datetime,
Qso,
};
/// Fields the [`Qso`] models itself. Everything else an import carries is kept
/// in `extra` under its own name, so a round trip does not strip it.
const MODELLED: &[&str] = &[
"CALL",
"QSO_DATE",
"TIME_ON",
"QSO_DATE_OFF",
"TIME_OFF",
"FREQ",
"BAND",
"MODE",
"SUBMODE",
"RST_SENT",
"RST_RCVD",
"GRIDSQUARE",
"NAME",
"QTH",
"COMMENT",
"TX_PWR",
"STATION_CALLSIGN",
"OPERATOR",
"MY_GRIDSQUARE",
"MY_RIG",
];
/// What a file gave up, and what it could not.
#[derive(Debug, Default, Clone)]
pub struct ParseReport {
/// Records that became a QSO.
pub qsos: Vec<Qso>,
/// Records that did not, and why — one line each, for showing the operator.
pub rejected: Vec<String>,
}
/// One `<NAME:LEN[:TYPE]>value` field.
struct Field {
name: String,
value: String,
}
/// Reads the next tag starting at `from`, returning it and where it ended.
///
/// Returns `None` at the end of input or on a tag that never closes, which is
/// how a truncated file stops rather than looping.
fn next_field(bytes: &[u8], from: usize) -> Option<(Field, usize)> {
let open = bytes[from..].iter().position(|b| *b == b'<')? + from;
let close = bytes[open..].iter().position(|b| *b == b'>')? + open;
let spec = String::from_utf8_lossy(&bytes[open + 1..close]).to_string();
let mut parts = spec.split(':');
let name = parts.next().unwrap_or_default().trim().to_uppercase();
// <EOR> and <EOH> carry no length and no value.
let Some(length_text) = parts.next() else {
return Some((
Field {
name,
value: String::new(),
},
close + 1,
));
};
let length: usize = length_text.trim().parse().unwrap_or(0);
let value_start = close + 1;
// A length that runs off the end is a truncated file, not a reason to lose
// everything before it: take what is there.
let value_end = value_start.saturating_add(length).min(bytes.len());
let value = String::from_utf8_lossy(&bytes[value_start..value_end]).to_string();
Some((Field { name, value }, value_end))
}
/// Read an ADI file.
///
/// Never fails as a whole: a record that cannot be made into a QSO is reported
/// on [`ParseReport::rejected`] and the rest of the file is still read.
pub fn parse_adi(input: &[u8]) -> ParseReport {
let mut report = ParseReport::default();
let mut position = 0usize;
let mut fields: Vec<Field> = Vec::new();
let mut in_header = looks_like_header(input);
let mut record_number = 0usize;
while let Some((field, next)) = next_field(input, position) {
position = next;
match field.name.as_str() {
"EOH" => {
in_header = false;
fields.clear();
}
"EOR" => {
record_number += 1;
if in_header {
// A file with no <EOH> but an <EOR>: the header guess was
// wrong, so treat what we have as a record.
in_header = false;
}
match qso_from_fields(&fields) {
Ok(qso) => report.qsos.push(qso),
Err(reason) => report
.rejected
.push(format!("record {record_number}: {reason}")),
}
fields.clear();
}
_ => fields.push(field),
}
}
// A last record without its <EOR> is still a record someone wrote.
if !fields.is_empty() && !in_header {
record_number += 1;
match qso_from_fields(&fields) {
Ok(qso) => report.qsos.push(qso),
Err(reason) => report
.rejected
.push(format!("record {record_number}: {reason}")),
}
}
report
}
/// Whether the file opens with a header, i.e. anything before the first tag.
///
/// ADIF says a file whose first character is not `<` has a header. Plenty of
/// exporters write no header at all, so the first `<EOR>` un-guesses this.
fn looks_like_header(input: &[u8]) -> bool {
match input.iter().position(|b| !b.is_ascii_whitespace()) {
Some(first) => input[first] != b'<',
None => false,
}
}
fn find<'a>(fields: &'a [Field], name: &str) -> Option<&'a str> {
fields
.iter()
.find(|f| f.name == name)
.map(|f| f.value.trim())
.filter(|value| !value.is_empty())
}
fn qso_from_fields(fields: &[Field]) -> Result<Qso, String> {
let call = find(fields, "CALL").ok_or("no CALL")?;
let date = find(fields, "QSO_DATE").ok_or("no QSO_DATE")?;
let started_at = parse_adif_datetime(date, find(fields, "TIME_ON"))
.ok_or("QSO_DATE or TIME_ON unreadable")?;
// FREQ is authoritative; BAND is the fallback for logs that record only the
// band, and its midpoint is the honest answer for "where in the band".
let freq_hz = match find(fields, "FREQ").and_then(hz_from_mhz_string) {
Some(hz) => hz,
None => find(fields, "BAND")
.and_then(band_midpoint_hz)
.ok_or("neither FREQ nor a known BAND")?,
};
let mode = find(fields, "MODE").unwrap_or("SSB").to_uppercase();
let ended_at = find(fields, "TIME_OFF").and_then(|time| {
let off_date = find(fields, "QSO_DATE_OFF").unwrap_or(date);
parse_adif_datetime(off_date, Some(time))
});
let mut extra = BTreeMap::new();
for field in fields {
if MODELLED.contains(&field.name.as_str()) {
continue;
}
if field.value.trim().is_empty() {
continue;
}
extra.insert(field.name.clone(), field.value.trim().to_string());
}
Ok(Qso {
id: crate::new_id(),
started_at,
ended_at,
call: normalize_call(call),
freq_hz,
mode,
submode: find(fields, "SUBMODE").map(|s| s.to_uppercase()),
rst_sent: find(fields, "RST_SENT").map(str::to_string),
rst_rcvd: find(fields, "RST_RCVD").map(str::to_string),
gridsquare: find(fields, "GRIDSQUARE").map(|g| g.to_uppercase()),
name: find(fields, "NAME").map(str::to_string),
qth: find(fields, "QTH").map(str::to_string),
comment: find(fields, "COMMENT").map(str::to_string),
tx_pwr_w: find(fields, "TX_PWR").and_then(|p| p.parse().ok()),
station_callsign: find(fields, "STATION_CALLSIGN").map(normalize_call),
operator: find(fields, "OPERATOR").map(normalize_call),
my_gridsquare: find(fields, "MY_GRIDSQUARE").map(|g| g.to_uppercase()),
my_rig: find(fields, "MY_RIG").map(str::to_string),
rig_id: None,
extra,
})
}
/// The middle of a named band, for logs that recorded a band and no frequency.
fn band_midpoint_hz(band: &str) -> Option<u64> {
crate::qso::band_midpoint_for_name(band)
}
fn write_field(out: &mut String, name: &str, value: &str) {
if value.is_empty() {
return;
}
out.push_str(&format!("<{}:{}>{}", name, value.len(), value));
}
/// Write QSOs as an ADI file, header and all.
pub fn write_adi(qsos: &[Qso], program_version: &str) -> String {
let mut out = String::with_capacity(qsos.len() * 256 + 256);
let now = Utc::now();
out.push_str("ADIF export from trx-rs\n");
write_field(&mut out, "ADIF_VER", "3.1.4");
write_field(&mut out, "PROGRAMID", "trx-rs");
write_field(&mut out, "PROGRAMVERSION", program_version);
write_field(
&mut out,
"CREATED_TIMESTAMP",
&format!("{} {}", adif_date(&now), adif_time(&now)),
);
out.push_str("<EOH>\n");
for qso in qsos {
write_field(&mut out, "CALL", &qso.call);
write_field(&mut out, "QSO_DATE", &adif_date(&qso.started_at));
write_field(&mut out, "TIME_ON", &adif_time(&qso.started_at));
if let Some(ended) = qso.ended_at {
write_field(&mut out, "QSO_DATE_OFF", &adif_date(&ended));
write_field(&mut out, "TIME_OFF", &adif_time(&ended));
}
write_field(&mut out, "FREQ", &freq_mhz_string(qso.freq_hz));
if let Some(band) = qso.band() {
write_field(&mut out, "BAND", band);
}
write_field(&mut out, "MODE", &qso.mode);
if let Some(submode) = &qso.submode {
write_field(&mut out, "SUBMODE", submode);
}
for (name, value) in [
("RST_SENT", &qso.rst_sent),
("RST_RCVD", &qso.rst_rcvd),
("GRIDSQUARE", &qso.gridsquare),
("NAME", &qso.name),
("QTH", &qso.qth),
("COMMENT", &qso.comment),
("STATION_CALLSIGN", &qso.station_callsign),
("OPERATOR", &qso.operator),
("MY_GRIDSQUARE", &qso.my_gridsquare),
("MY_RIG", &qso.my_rig),
] {
if let Some(value) = value {
write_field(&mut out, name, value);
}
}
if let Some(power) = qso.tx_pwr_w {
write_field(&mut out, "TX_PWR", &format!("{power}"));
}
// Whatever another logger wrote and this one does not model leaves as
// it arrived.
for (name, value) in &qso.extra {
write_field(&mut out, name, value);
}
out.push_str("<EOR>\n");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_written_log_reads_back_the_same() {
let mut qso = Qso::new(
"id-1".into(),
parse_adif_datetime("20260807", Some("121530")).expect("time"),
"sp2sjg",
14_074_000,
"FT8",
);
qso.rst_sent = Some("-07".into());
qso.rst_rcvd = Some("-12".into());
qso.gridsquare = Some("JO94".into());
qso.station_callsign = Some("SP0TRX".into());
qso.operator = Some("SP0TRX".into());
qso.my_gridsquare = Some("JO91".into());
qso.my_rig = Some("Shack SDR".into());
let text = write_adi(&[qso.clone()], "0.1.0");
let report = parse_adi(text.as_bytes());
assert!(report.rejected.is_empty(), "{:?}", report.rejected);
assert_eq!(report.qsos.len(), 1);
let back = &report.qsos[0];
assert_eq!(back.call, "SP2SJG");
assert_eq!(back.freq_hz, qso.freq_hz);
assert_eq!(back.mode, "FT8");
assert_eq!(back.started_at, qso.started_at);
assert_eq!(back.rst_sent, qso.rst_sent);
assert_eq!(back.gridsquare, qso.gridsquare);
assert_eq!(back.my_rig, qso.my_rig);
}
/// Files from other loggers are irregular in ways that are not errors.
#[test]
fn a_file_from_another_logger_is_read_as_it_comes() {
let text = concat!(
"Generated by SomeLogger 4.2\r\n",
"<adif_ver:5>3.1.0<programid:10>SomeLogger<eoh>\r\n",
// lowercase tags, CRLF, a type indicator, no seconds on the time
"<call:6>W1AW/4<qso_date:8>20260101<time_on:4>1200<band:3>20m",
"<mode:3>SSB<rst_sent:2>59<rst_rcvd:2>59<my_antenna:6>Dipole<eor>\r\n",
// a record with the fields in another order and a full frequency
"<qso_date:8>20260102<time_on:6>235959<call:5>DL1AB<freq:6>7.0301",
"<mode:2>CW<eor>\r\n",
);
let report = parse_adi(text.as_bytes());
assert!(report.rejected.is_empty(), "{:?}", report.rejected);
assert_eq!(report.qsos.len(), 2);
let first = &report.qsos[0];
assert_eq!(first.call, "W1AW/4");
assert_eq!(first.mode, "SSB");
// No FREQ, so the band gives the frequency, and the band comes back.
assert_eq!(first.band(), Some("20m"));
// A field we do not model is kept, and comes back out on export.
assert_eq!(
first.extra.get("MY_ANTENNA").map(String::as_str),
Some("Dipole")
);
let exported = write_adi(&report.qsos, "0.1.0");
assert!(exported.contains("<MY_ANTENNA:6>Dipole"), "{exported}");
let second = &report.qsos[1];
assert_eq!(second.call, "DL1AB");
assert_eq!(second.freq_hz, 7_030_100);
assert_eq!(adif_time(&second.started_at), "235959");
}
#[test]
fn a_record_that_cannot_be_a_qso_is_reported_and_the_rest_still_read() {
let text = concat!(
"<call:5>NOCAL<eor>", // no date
"<qso_date:8>20260101<time_on:4>1200<mode:3>SSB<eor>", // no call
"<call:5>SP1AB<qso_date:8>20260101<time_on:4>1200<freq:6>14.074<mode:3>FT8<eor>",
);
let report = parse_adi(text.as_bytes());
assert_eq!(report.qsos.len(), 1);
assert_eq!(report.qsos[0].call, "SP1AB");
assert_eq!(report.rejected.len(), 2);
assert!(
report.rejected[0].contains("QSO_DATE"),
"{:?}",
report.rejected
);
assert!(report.rejected[1].contains("CALL"), "{:?}", report.rejected);
}
/// The declared length is the length, even when the value looks longer:
/// ADIF values may contain `<`, so the count is the only thing that can be
/// trusted to end them.
#[test]
fn the_declared_length_wins_over_what_the_value_looks_like() {
let text = "<call:5>SP1AB<qso_date:8>20260101<time_on:4>1200<freq:6>14.074<mode:3>FT8\
<comment:11>see <notes><eor>";
let report = parse_adi(text.as_bytes());
assert_eq!(report.qsos.len(), 1);
assert_eq!(report.qsos[0].comment.as_deref(), Some("see <notes>"));
}
#[test]
fn a_truncated_file_gives_up_what_it_has() {
// The length says 20 bytes of callsign; the file ends after five.
let text = "<call:20>SP1AB";
let report = parse_adi(text.as_bytes());
// No date, so it is rejected -- but the parser returned rather than
// running off the end or looping.
assert_eq!(report.qsos.len(), 0);
assert_eq!(report.rejected.len(), 1);
}
#[test]
fn a_file_with_no_header_is_still_a_file() {
let text = "<call:5>SP1AB<qso_date:8>20260101<time_on:4>1200<freq:6>14.074<mode:3>FT8<eor>";
let report = parse_adi(text.as_bytes());
assert_eq!(report.qsos.len(), 1);
}
}
+191
View File
@@ -0,0 +1,191 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Telling one contact from two.
//!
//! Two programs rarely stamp the same QSO to the same minute — one records when
//! the contact started, another when the operator finished typing — so matching
//! on an exact time duplicates half of what an import is asked to merge. The
//! key is therefore the callsign, the band and the mode, with the time compared
//! as a window.
//!
//! Two minutes is the window. It is wide enough for the disagreement between
//! loggers and narrow enough to keep a legitimate re-work: contest rules forbid
//! a second contact with the same station on the same band and mode, so a
//! repeat inside two minutes is the same contact written twice, while the same
//! station on another band an hour later is a different QSO and keys differently
//! anyway.
use std::collections::HashMap;
use chrono::Duration;
use crate::qso::Qso;
/// How far apart two records of the same contact may be stamped.
pub const MATCH_WINDOW_MINUTES: i64 = 2;
/// Modes as they are compared, not as they are written.
///
/// A log that stored `USB` and one that stored `SSB` describe the same contact;
/// without this an import from either would duplicate every phone QSO in the
/// other.
fn normalized_mode(qso: &Qso) -> String {
match qso.mode.trim().to_uppercase().as_str() {
"USB" | "LSB" | "SSB" => "SSB".to_string(),
"CWR" => "CW".to_string(),
"SAM" => "AM".to_string(),
"WFM" => "FM".to_string(),
other => other.to_string(),
}
}
fn key(qso: &Qso) -> (String, String, String) {
(
qso.call.clone(),
qso.band().unwrap_or("").to_string(),
normalized_mode(qso),
)
}
/// An index over an existing log, for asking whether an incoming record is
/// already held.
pub struct DuplicateIndex {
by_key: HashMap<(String, String, String), Vec<chrono::DateTime<chrono::Utc>>>,
}
impl DuplicateIndex {
pub fn build(existing: &[Qso]) -> Self {
let mut by_key: HashMap<_, Vec<_>> = HashMap::new();
for qso in existing {
by_key.entry(key(qso)).or_default().push(qso.started_at);
}
Self { by_key }
}
/// Whether this contact is already in the log.
pub fn contains(&self, candidate: &Qso) -> bool {
let window = Duration::minutes(MATCH_WINDOW_MINUTES);
self.by_key.get(&key(candidate)).is_some_and(|times| {
times
.iter()
// Compared as instants, so a contact either side of
// midnight matches rather than falling into two days.
.any(|held| (*held - candidate.started_at).abs() <= window)
})
}
/// Remember a contact, so a file that repeats itself does not import twice.
pub fn insert(&mut self, qso: &Qso) {
self.by_key
.entry(key(qso))
.or_default()
.push(qso.started_at);
}
}
/// Which bands and modes a callsign has been worked on.
pub fn worked_before(existing: &[Qso], call: &str) -> Vec<(String, String)> {
let wanted = crate::qso::normalize_call(call);
let mut seen: Vec<(String, String)> = existing
.iter()
.filter(|qso| qso.call == wanted)
.map(|qso| (qso.band().unwrap_or("").to_string(), normalized_mode(qso)))
.collect();
seen.sort();
seen.dedup();
seen
}
#[cfg(test)]
mod tests {
use super::*;
use crate::qso::parse_adif_datetime;
fn qso(call: &str, hz: u64, mode: &str, time: &str) -> Qso {
Qso::new(
format!("{call}-{time}"),
parse_adif_datetime("20260807", Some(time)).expect("time"),
call,
hz,
mode,
)
}
#[test]
fn the_same_contact_stamped_a_minute_apart_is_one_contact() {
let held = vec![qso("SP1AA", 14_074_000, "FT8", "120000")];
let index = DuplicateIndex::build(&held);
assert!(index.contains(&qso("SP1AA", 14_074_000, "FT8", "120100")));
assert!(index.contains(&qso("SP1AA", 14_074_000, "FT8", "115900")));
// Beyond the window it is another contact.
assert!(!index.contains(&qso("SP1AA", 14_074_000, "FT8", "120300")));
}
#[test]
fn phone_matches_however_the_other_logger_spelled_it() {
let held = vec![qso("SP1AA", 14_200_000, "SSB", "120000")];
let index = DuplicateIndex::build(&held);
assert!(index.contains(&qso("SP1AA", 14_200_000, "USB", "120000")));
assert!(index.contains(&qso("SP1AA", 14_200_000, "LSB", "120030")));
}
#[test]
fn another_band_or_another_mode_is_another_contact() {
let held = vec![qso("SP1AA", 14_074_000, "FT8", "120000")];
let index = DuplicateIndex::build(&held);
// Same station, same minute, 40 m: a band change during a QSO happens.
assert!(!index.contains(&qso("SP1AA", 7_074_000, "FT8", "120000")));
assert!(!index.contains(&qso("SP1AA", 14_074_000, "CW", "120000")));
assert!(!index.contains(&qso("SP2BB", 14_074_000, "FT8", "120000")));
}
#[test]
fn a_contact_either_side_of_midnight_still_matches() {
let before = Qso::new(
"1".into(),
parse_adif_datetime("20260807", Some("235930")).expect("time"),
"SP1AA",
14_074_000,
"FT8",
);
let after = Qso::new(
"2".into(),
parse_adif_datetime("20260808", Some("000030")).expect("time"),
"SP1AA",
14_074_000,
"FT8",
);
let index = DuplicateIndex::build(&[before]);
assert!(index.contains(&after));
}
#[test]
fn a_file_that_repeats_itself_imports_once() {
let mut index = DuplicateIndex::build(&[]);
let first = qso("SP1AA", 14_074_000, "FT8", "120000");
assert!(!index.contains(&first));
index.insert(&first);
assert!(index.contains(&qso("SP1AA", 14_074_000, "FT8", "120030")));
}
#[test]
fn worked_before_answers_by_band_and_mode() {
let held = vec![
qso("SP1AA", 14_074_000, "FT8", "120000"),
qso("SP1AA", 7_030_000, "CW", "130000"),
qso("SP1AA", 14_074_000, "FT8", "140000"),
qso("SP2BB", 14_200_000, "USB", "150000"),
];
let worked = worked_before(&held, "sp1aa");
assert_eq!(
worked,
vec![
("20m".to_string(), "FT8".to_string()),
("40m".to_string(), "CW".to_string()),
]
);
assert!(worked_before(&held, "SP9ZZ").is_empty());
}
}
+310
View File
@@ -0,0 +1,310 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! The station logbook: contacts, the file they live in, and ADIF in and out.
//!
//! One log per station rather than one per rig. Awards and uploads count the
//! callsign, not the radio — a station worked on the second rig is still worked
//! — so the rig is recorded on the contact instead of dividing the log by it.
//! Where a rig stands does follow the contact, though: `my_gridsquare` comes
//! from the rig that made it, because rigs can be in different places.
pub mod adif;
pub mod dedupe;
pub mod qso;
pub mod store;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub use dedupe::{worked_before, DuplicateIndex, MATCH_WINDOW_MINUTES};
pub use qso::Qso;
pub use store::LogStore;
/// A fresh contact identity.
pub fn new_id() -> String {
uuid::Uuid::new_v4().to_string()
}
/// Where the log lives when the configuration does not say.
///
/// The data directory, not the config directory the bookmarks use and not the
/// cache directory the decode logs use: a log is neither a setting nor
/// disposable, and cache directories get swept by cleaners.
pub fn default_log_path() -> PathBuf {
dirs::data_dir()
.map(|dir| dir.join("trx-rs").join("logbook.jsonl"))
.unwrap_or_else(|| PathBuf::from("logbook.jsonl"))
}
/// What a filtered read asks for.
#[derive(Debug, Default, Clone, Deserialize)]
pub struct LogQuery {
/// Substring of the callsign, case-insensitive.
#[serde(default)]
pub call: Option<String>,
#[serde(default)]
pub band: Option<String>,
#[serde(default)]
pub mode: Option<String>,
/// Inclusive bounds on the contact's time.
#[serde(default)]
pub from: Option<DateTime<Utc>>,
#[serde(default)]
pub to: Option<DateTime<Utc>>,
#[serde(default)]
pub limit: Option<usize>,
#[serde(default)]
pub offset: Option<usize>,
}
/// What an import did.
#[derive(Debug, Default, Clone, Serialize)]
pub struct ImportOutcome {
pub added: usize,
/// Already held, by callsign, band, mode and the two-minute window.
pub duplicate: usize,
/// Records that could not be read as contacts, with the reason.
pub rejected: Vec<String>,
}
/// The logbook, shared by whoever serves it.
#[derive(Clone)]
pub struct Logbook {
store: Arc<Mutex<LogStore>>,
}
impl Logbook {
pub fn open(path: &std::path::Path) -> std::io::Result<Self> {
Ok(Self {
store: Arc::new(Mutex::new(LogStore::open(path)?)),
})
}
fn with_store<T>(&self, f: impl FnOnce(&mut LogStore) -> T) -> T {
// A poisoned lock means a panic while holding it, not a corrupt log --
// the file is on disk either way -- so carry on with what is there.
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
f(&mut store)
}
pub fn path(&self) -> PathBuf {
self.with_store(|store| store.path().to_path_buf())
}
pub fn count(&self) -> usize {
self.with_store(|store| store.len())
}
/// Contacts matching `query`, newest first.
pub fn query(&self, query: &LogQuery) -> Vec<Qso> {
self.with_store(|store| {
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 matching: Vec<Qso> = store
.all()
.into_iter()
.filter(|qso| {
call.as_ref().is_none_or(|c| qso.call.contains(c.as_str()))
&& band
.as_ref()
.is_none_or(|b| qso.band().is_some_and(|found| found == b))
&& 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)
})
.collect();
let offset = query.offset.unwrap_or(0).min(matching.len());
let end = query
.limit
.map_or(matching.len(), |limit| (offset + limit).min(matching.len()));
matching[offset..end].to_vec()
})
}
pub fn get(&self, id: &str) -> Option<Qso> {
self.with_store(|store| store.get(id).cloned())
}
/// Add a contact, or replace one with the same id.
pub fn put(&self, qso: Qso) -> std::io::Result<Qso> {
self.with_store(|store| store.put(qso.clone()))?;
Ok(qso)
}
pub fn delete(&self, id: &str) -> std::io::Result<bool> {
self.with_store(|store| store.delete(id))
}
/// Which bands and modes a callsign has been worked on.
pub fn worked_before(&self, call: &str) -> Vec<(String, String)> {
self.with_store(|store| dedupe::worked_before(&store.all(), call))
}
/// 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)
}
/// Import an ADIF file, skipping contacts already held.
pub fn import_adi(&self, input: &[u8]) -> std::io::Result<ImportOutcome> {
let report = adif::parse_adi(input);
let mut outcome = ImportOutcome {
rejected: report.rejected,
..ImportOutcome::default()
};
self.with_store(|store| {
let mut index = DuplicateIndex::build(&store.all());
let mut fresh = Vec::new();
for qso in report.qsos {
if index.contains(&qso) {
outcome.duplicate += 1;
continue;
}
index.insert(&qso);
fresh.push(qso);
}
outcome.added = fresh.len();
store.put_all(fresh)
})?;
Ok(outcome)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::qso::parse_adif_datetime;
fn new_book() -> (tempfile::TempDir, Logbook) {
let dir = tempfile::tempdir().expect("tempdir");
let book = Logbook::open(&dir.path().join("logbook.jsonl")).expect("open");
(dir, book)
}
fn qso(call: &str, hz: u64, mode: &str, time: &str) -> Qso {
Qso::new(
new_id(),
parse_adif_datetime("20260807", Some(time)).expect("time"),
call,
hz,
mode,
)
}
#[test]
fn a_log_answers_the_filters_it_is_asked() {
let (_dir, book) = new_book();
book.put(qso("SP1AA", 14_074_000, "FT8", "120000"))
.expect("put");
book.put(qso("SP2BB", 7_030_000, "CW", "130000"))
.expect("put");
book.put(qso("DL3CC", 14_200_000, "SSB", "140000"))
.expect("put");
assert_eq!(book.count(), 3);
assert_eq!(book.query(&LogQuery::default()).len(), 3);
// Newest first.
assert_eq!(book.query(&LogQuery::default())[0].call, "DL3CC");
let by_band = LogQuery {
band: Some("20m".into()),
..LogQuery::default()
};
assert_eq!(book.query(&by_band).len(), 2);
let by_call = LogQuery {
call: Some("sp".into()),
..LogQuery::default()
};
assert_eq!(book.query(&by_call).len(), 2);
let by_mode = LogQuery {
mode: Some("cw".into()),
..LogQuery::default()
};
assert_eq!(book.query(&by_mode)[0].call, "SP2BB");
let paged = LogQuery {
limit: Some(1),
offset: Some(1),
..LogQuery::default()
};
assert_eq!(book.query(&paged).len(), 1);
// Newest first, so offset 1 is the middle contact.
assert_eq!(book.query(&paged)[0].call, "SP2BB");
}
#[test]
fn an_import_skips_what_is_already_held_and_says_so() {
let (_dir, book) = new_book();
book.put(qso("SP1AA", 14_074_000, "FT8", "120000"))
.expect("put");
let file = concat!(
// the one already held, a minute out, as another logger stamped it
"<call:5>SP1AA<qso_date:8>20260807<time_on:6>120100<freq:6>14.074<mode:3>FT8<eor>\n",
// a new one
"<call:5>DL2XY<qso_date:8>20260807<time_on:6>121500<freq:6>14.074<mode:3>FT8<eor>\n",
// and something that is not a contact
"<call:5>NODAT<eor>\n",
);
let outcome = book.import_adi(file.as_bytes()).expect("import");
assert_eq!(outcome.added, 1);
assert_eq!(outcome.duplicate, 1);
assert_eq!(outcome.rejected.len(), 1);
assert_eq!(book.count(), 2);
// And importing the same file again adds nothing.
let again = book.import_adi(file.as_bytes()).expect("import again");
assert_eq!(again.added, 0);
assert_eq!(again.duplicate, 2);
assert_eq!(book.count(), 2);
}
#[test]
fn an_export_can_be_imported_into_an_empty_log() {
let (_dir, book) = new_book();
book.put(qso("SP1AA", 14_074_000, "FT8", "120000"))
.expect("put");
book.put(qso("DL3CC", 14_200_000, "SSB", "140000"))
.expect("put");
let exported = book.export_adi(&LogQuery::default(), "0.1.0");
let (_other_dir, other) = new_book();
let outcome = other.import_adi(exported.as_bytes()).expect("import");
assert_eq!(outcome.added, 2);
assert_eq!(other.count(), 2);
assert_eq!(other.query(&LogQuery::default())[0].call, "DL3CC");
}
#[test]
fn worked_before_reads_through_the_log() {
let (_dir, book) = new_book();
book.put(qso("SP1AA", 14_074_000, "FT8", "120000"))
.expect("put");
book.put(qso("SP1AA", 7_030_000, "CW", "130000"))
.expect("put");
assert_eq!(book.worked_before("sp1aa").len(), 2);
assert!(book.worked_before("SP9ZZ").is_empty());
}
#[test]
fn a_contact_can_be_changed_and_forgotten() {
let (_dir, book) = new_book();
let mut entry = qso("SP1AA", 14_074_000, "FT8", "120000");
let id = entry.id.clone();
book.put(entry.clone()).expect("put");
entry.rst_rcvd = Some("-11".into());
book.put(entry).expect("edit");
assert_eq!(book.get(&id).and_then(|q| q.rst_rcvd), Some("-11".into()));
assert!(book.delete(&id).expect("delete"));
assert!(book.get(&id).is_none());
assert_eq!(book.count(), 0);
}
}
+380
View File
@@ -0,0 +1,380 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! The record a contact is kept as, and the translations between what the radio
//! says and what a log file wants.
use std::collections::BTreeMap;
use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, NaiveTime, Timelike, Utc};
use serde::{Deserialize, Serialize};
/// One contact.
///
/// The named fields are the ones the application fills, reads or filters on.
/// Everything else an imported file carried is kept in [`Qso::extra`] under its
/// ADIF field name, so a log that came from another program leaves with what it
/// arrived with rather than what this one happens to understand.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Qso {
/// Stable identity, ours, not from the file.
pub id: String,
/// UTC, always: the log is kept in the radio's time, not a browser's.
pub started_at: DateTime<Utc>,
/// Present once the operator records the end of a contact.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ended_at: Option<DateTime<Utc>>,
/// The station worked.
pub call: String,
/// Dial frequency in Hz. The band is derived from it rather than stored
/// twice and allowed to disagree.
pub freq_hz: u64,
/// ADIF mode, e.g. `SSB`, `CW`, `FT8`.
pub mode: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub submode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rst_sent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rst_rcvd: Option<String>,
/// The worked station's Maidenhead locator.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gridsquare: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub qth: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tx_pwr_w: Option<f64>,
/// The callsign this station was on the air with.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub station_callsign: Option<String>,
/// The person at the key, which is not always the station.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operator: Option<String>,
/// Our locator *for this contact*: rigs can stand in different places.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub my_gridsquare: Option<String>,
/// The rig it was worked on, by display name (ADIF `MY_RIG`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub my_rig: Option<String>,
/// Which rig of this client, by id — ours, not ADIF's.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rig_id: Option<String>,
/// ADIF fields this application does not model, kept verbatim.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, String>,
}
impl Qso {
/// A contact with only the fields every log needs.
pub fn new(
id: String,
started_at: DateTime<Utc>,
call: &str,
freq_hz: u64,
mode: &str,
) -> Self {
Self {
id,
started_at,
ended_at: None,
call: normalize_call(call),
freq_hz,
mode: mode.trim().to_uppercase(),
submode: None,
rst_sent: None,
rst_rcvd: None,
gridsquare: None,
name: None,
qth: None,
comment: None,
tx_pwr_w: None,
station_callsign: None,
operator: None,
my_gridsquare: None,
my_rig: None,
rig_id: None,
extra: BTreeMap::new(),
}
}
/// The ADIF band name for the frequency, if it falls in an amateur band.
pub fn band(&self) -> Option<&'static str> {
band_for_hz(self.freq_hz)
}
/// 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
/// two programs rarely stamp the same QSO to the same minute.
pub fn match_key(&self) -> (String, String, String) {
(
self.call.clone(),
self.band().unwrap_or("").to_string(),
self.mode.clone(),
)
}
}
/// Callsigns are compared, sorted and deduplicated, so they are held one way:
/// upper case, without the spaces a hurried operator leaves behind.
pub fn normalize_call(call: &str) -> String {
call.trim().to_uppercase()
}
/// Amateur bands by their ADIF names, as frequency ranges in Hz.
///
/// Ranges are the widest any region allows, since a log records what was heard
/// rather than what a licence permits, and a QSO on a band edge must not fall
/// out of the log because one region stops earlier than another.
const BANDS: &[(&str, u64, u64)] = &[
("2190m", 135_700, 137_800),
("630m", 472_000, 479_000),
("560m", 501_000, 504_000),
("160m", 1_800_000, 2_000_000),
("80m", 3_500_000, 4_000_000),
("60m", 5_060_000, 5_450_000),
("40m", 7_000_000, 7_300_000),
("30m", 10_100_000, 10_150_000),
("20m", 14_000_000, 14_350_000),
("17m", 18_068_000, 18_168_000),
("15m", 21_000_000, 21_450_000),
("12m", 24_890_000, 24_990_000),
("10m", 28_000_000, 29_700_000),
("8m", 40_000_000, 45_000_000),
("6m", 50_000_000, 54_000_000),
("5m", 54_000_001, 69_900_000),
("4m", 70_000_000, 71_000_000),
("2m", 144_000_000, 148_000_000),
("1.25m", 222_000_000, 225_000_000),
("70cm", 420_000_000, 450_000_000),
("33cm", 902_000_000, 928_000_000),
("23cm", 1_240_000_000, 1_300_000_000),
("13cm", 2_300_000_000, 2_450_000_000),
("9cm", 3_300_000_000, 3_500_000_000),
("6cm", 5_650_000_000, 5_925_000_000),
("3cm", 10_000_000_000, 10_500_000_000),
];
/// The ADIF band a frequency falls in, or `None` outside the amateur bands —
/// a broadcast or utility frequency has no band name, and inventing one would
/// put a lie in the log.
pub fn band_for_hz(hz: u64) -> Option<&'static str> {
BANDS
.iter()
.find(|(_, low, high)| hz >= *low && hz <= *high)
.map(|(name, _, _)| *name)
}
/// The middle of a named band.
///
/// A log that recorded `20m` and no frequency knows the band and not the dial;
/// the midpoint is the honest reading of that, and the band name it exports
/// comes back the same.
pub fn band_midpoint_for_name(band: &str) -> Option<u64> {
let wanted = band.trim().to_lowercase();
BANDS
.iter()
.find(|(name, _, _)| *name == wanted)
.map(|(_, low, high)| low + (high - low) / 2)
}
/// 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
// other loggers write. The zeros are then dropped from the fraction only:
// trimming the whole string would turn 20.000000 into 2, since the run of
// trailing zeros reaches back through the point into the integer.
let text = format!("{:.6}", hz as f64 / 1e6);
match text.split_once('.') {
Some((whole, fraction)) => {
let fraction = fraction.trim_end_matches('0');
if fraction.is_empty() {
whole.to_string()
} else {
format!("{whole}.{fraction}")
}
}
None => text,
}
}
/// ADIF's MHz back to Hz.
pub fn hz_from_mhz_string(text: &str) -> Option<u64> {
let mhz: f64 = text.trim().parse().ok()?;
if !mhz.is_finite() || mhz < 0.0 {
return None;
}
Some((mhz * 1e6).round() as u64)
}
/// What a rig mode becomes in a log.
///
/// A rig reports what it is demodulating; a log records what the contact was
/// made on, and the two are not the same word. `DIG` is deliberately absent:
/// a rig in `DIG` is in FT8 or FT4 or something else depending on which decoder
/// is running, so the caller has to say which — see [`mode_for_decoder`].
pub fn adif_mode_for_rig_mode(rig_mode: &str) -> Option<(&'static str, Option<&'static str>)> {
match rig_mode.trim().to_uppercase().as_str() {
"USB" => Some(("SSB", Some("USB"))),
"LSB" => Some(("SSB", Some("LSB"))),
"CW" | "CWR" => Some(("CW", None)),
"AM" | "SAM" => Some(("AM", None)),
"FM" | "WFM" => Some(("FM", None)),
"PKT" => Some(("PKT", None)),
// AIS and VDES are maritime, not amateur: nothing on them is a QSO.
// DIG says nothing on its own, and Other(..) is whatever a backend
// called it, which is the operator's to confirm rather than ours to
// guess into the log.
_ => None,
}
}
/// What a decoder's contacts are logged as.
///
/// WSPR is absent on purpose: it is a beacon mode. Hearing a beacon is not a
/// contact, so a WSPR spot never opens a log entry.
pub fn mode_for_decoder(decoder_id: &str) -> Option<(&'static str, Option<&'static str>)> {
match decoder_id.trim().to_lowercase().as_str() {
"ft8" => Some(("FT8", None)),
// FT4 is a submode of MFSK in ADIF, which is how WSJT-X logs it.
"ft4" => Some(("MFSK", Some("FT4"))),
"ft2" => Some(("MFSK", Some("FT2"))),
"cw" => Some(("CW", None)),
"aprs" | "hf-aprs" | "hf_aprs" => Some(("PKT", None)),
_ => None,
}
}
/// ADIF writes dates as `YYYYMMDD`, always UTC.
pub fn adif_date(at: &DateTime<Utc>) -> String {
format!("{:04}{:02}{:02}", at.year(), at.month(), at.day())
}
/// ADIF writes times as `HHMMSS`, always UTC.
pub fn adif_time(at: &DateTime<Utc>) -> String {
format!("{:02}{:02}{:02}", at.hour(), at.minute(), at.second())
}
/// ADIF's date and time back to an instant.
///
/// Times come as `HHMMSS` or `HHMM`; both are in the wild, and a missing time
/// is treated as midnight rather than rejecting the record, because a log with
/// a date and no time is still a log.
pub fn parse_adif_datetime(date: &str, time: Option<&str>) -> Option<DateTime<Utc>> {
let date = date.trim();
if date.len() != 8 || !date.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let year: i32 = date[0..4].parse().ok()?;
let month: u32 = date[4..6].parse().ok()?;
let day: u32 = date[6..8].parse().ok()?;
let date = NaiveDate::from_ymd_opt(year, month, day)?;
let time = time.map(str::trim).filter(|t| !t.is_empty());
let time = match time {
None => NaiveTime::from_hms_opt(0, 0, 0)?,
Some(text) if text.len() == 6 || text.len() == 4 => {
if !text.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let hour: u32 = text[0..2].parse().ok()?;
let minute: u32 = text[2..4].parse().ok()?;
let second: u32 = if text.len() == 6 {
text[4..6].parse().ok()?
} else {
0
};
NaiveTime::from_hms_opt(hour, minute, second)?
}
Some(_) => return None,
};
Some(NaiveDateTime::new(date, time).and_utc())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bands_are_named_from_the_dial() {
assert_eq!(band_for_hz(14_074_000), Some("20m"));
assert_eq!(band_for_hz(7_030_000), Some("40m"));
assert_eq!(band_for_hz(144_300_000), Some("2m"));
// A band edge is in the band: a QSO at 14.350 is a 20 m QSO.
assert_eq!(band_for_hz(14_350_000), Some("20m"));
// Broadcast and utility frequencies have no band, and must not be given
// one -- a log that says 49m would be wrong, not merely unhelpful.
assert_eq!(band_for_hz(6_075_000), None);
assert_eq!(band_for_hz(162_025_000), None);
}
#[test]
fn frequencies_survive_the_trip_through_megahertz() {
for hz in [
14_074_000_u64,
7_030_000,
1_840_000,
432_100_000,
10_138_700,
20_000_000,
100_000_000,
] {
let text = freq_mhz_string(hz);
assert_eq!(
hz_from_mhz_string(&text),
Some(hz),
"{text} came back wrong"
);
}
assert_eq!(freq_mhz_string(14_074_000), "14.074");
assert_eq!(freq_mhz_string(14_000_000), "14");
// A whole number of MHz ending in a zero: trimming the string rather
// than the fraction turned 20 MHz into 2 MHz, and 100 into 1.
assert_eq!(freq_mhz_string(20_000_000), "20");
assert_eq!(freq_mhz_string(100_000_000), "100");
assert_eq!(freq_mhz_string(10_000_000), "10");
assert_eq!(freq_mhz_string(50_313_000), "50.313");
}
#[test]
fn a_rig_mode_is_not_an_adif_mode() {
assert_eq!(adif_mode_for_rig_mode("USB"), Some(("SSB", Some("USB"))));
assert_eq!(adif_mode_for_rig_mode("lsb"), Some(("SSB", Some("LSB"))));
assert_eq!(adif_mode_for_rig_mode("CWR"), Some(("CW", None)));
assert_eq!(adif_mode_for_rig_mode("SAM"), Some(("AM", None)));
// The rig cannot answer for DIG, and must not be allowed to guess.
assert_eq!(adif_mode_for_rig_mode("DIG"), None);
// Nor are the maritime modes contacts at all.
assert_eq!(adif_mode_for_rig_mode("AIS"), None);
assert_eq!(adif_mode_for_rig_mode("VDES"), None);
}
#[test]
fn a_decoder_answers_for_the_digital_modes() {
assert_eq!(mode_for_decoder("ft8"), Some(("FT8", None)));
assert_eq!(mode_for_decoder("ft4"), Some(("MFSK", Some("FT4"))));
assert_eq!(mode_for_decoder("aprs"), Some(("PKT", None)));
// Hearing a beacon is not a contact.
assert_eq!(mode_for_decoder("wspr"), None);
}
#[test]
fn adif_times_are_read_in_both_widths_and_written_in_one() {
let with_seconds = parse_adif_datetime("20260807", Some("121530")).expect("HHMMSS");
let without = parse_adif_datetime("20260807", Some("1215")).expect("HHMM");
assert_eq!(adif_date(&with_seconds), "20260807");
assert_eq!(adif_time(&with_seconds), "121530");
assert_eq!(adif_time(&without), "121500");
// A date with no time is still a log entry.
assert!(parse_adif_datetime("20260807", None).is_some());
// Nonsense is not.
assert!(parse_adif_datetime("2026080", Some("1215")).is_none());
assert!(parse_adif_datetime("20261307", Some("1215")).is_none());
assert!(parse_adif_datetime("20260807", Some("12:15")).is_none());
}
}
+330
View File
@@ -0,0 +1,330 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Where the log lives.
//!
//! One JSON Lines file, appended to and never rewritten in place. A log is the
//! one thing in this application that cannot be regenerated: a bookmark can be
//! typed again and a decode will come round again, but a contact happened once.
//! So every write is an append of a single line — atomic as far as a crash is
//! concerned, since a torn last line loses only the record being written and is
//! skipped on the next load — rather than a dump of the whole file, which at
//! forty thousand QSOs would rewrite megabytes to log one contact and lose all
//! of them if the power went halfway.
//!
//! An edit appends a new revision of the same id and a delete appends a
//! tombstone; the load keeps the last word on each id. Compaction rewrites the
//! file, through a temporary and a rename, once the superseded records outnumber
//! the live ones.
use std::collections::HashMap;
use std::fs::{create_dir_all, File, OpenOptions};
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::qso::Qso;
/// One line of the file.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
enum Entry {
/// A contact, as it stands after this line.
Qso(Box<Qso>),
/// This id is gone.
Delete { id: String },
}
/// Compact once more than this share of the file is superseded.
const COMPACT_WHEN_STALE_RATIO: f64 = 0.5;
/// ...but not while the file is still trivially small.
const COMPACT_MIN_LINES: usize = 256;
pub struct LogStore {
path: PathBuf,
/// Live records by id. The log is small enough to hold: forty thousand
/// QSOs is a few tens of megabytes, and every read wants it in memory.
qsos: HashMap<String, Qso>,
/// Lines in the file, live or not, for deciding when to compact.
lines_written: usize,
}
impl LogStore {
/// Open the log at `path`, creating it and its directory if need be.
///
/// A line that cannot be read is skipped and counted rather than failing the
/// open: a half-written last line from a crash must not cost the operator
/// the rest of the log.
pub fn open(path: &Path) -> std::io::Result<Self> {
if let Some(parent) = path.parent() {
create_dir_all(parent)?;
}
let mut store = Self {
path: path.to_path_buf(),
qsos: HashMap::new(),
lines_written: 0,
};
if path.exists() {
let file = File::open(path)?;
let mut damaged = 0usize;
for line in BufReader::new(file).lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
store.lines_written += 1;
match serde_json::from_str::<Entry>(&line) {
Ok(Entry::Qso(qso)) => {
store.qsos.insert(qso.id.clone(), *qso);
}
Ok(Entry::Delete { id }) => {
store.qsos.remove(&id);
}
Err(_) => damaged += 1,
}
}
if damaged > 0 {
tracing::warn!(
"logbook: skipped {damaged} unreadable line(s) in {}",
path.display()
);
}
}
Ok(store)
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn len(&self) -> usize {
self.qsos.len()
}
pub fn is_empty(&self) -> bool {
self.qsos.is_empty()
}
/// Every contact, newest first.
pub fn all(&self) -> Vec<Qso> {
let mut all: Vec<Qso> = self.qsos.values().cloned().collect();
all.sort_by(|a, b| b.started_at.cmp(&a.started_at).then(a.id.cmp(&b.id)));
all
}
pub fn get(&self, id: &str) -> Option<&Qso> {
self.qsos.get(id)
}
/// Write a contact, new or changed.
pub fn put(&mut self, qso: Qso) -> std::io::Result<()> {
self.append(&Entry::Qso(Box::new(qso.clone())))?;
self.qsos.insert(qso.id.clone(), qso);
self.compact_if_needed()
}
/// Write several contacts, appending them in one open of the file.
pub fn put_all(&mut self, qsos: Vec<Qso>) -> std::io::Result<()> {
if qsos.is_empty() {
return Ok(());
}
let mut file = BufWriter::new(
OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?,
);
for qso in &qsos {
let line = serde_json::to_string(&Entry::Qso(Box::new(qso.clone())))
.map_err(std::io::Error::other)?;
writeln!(file, "{line}")?;
self.lines_written += 1;
}
file.flush()?;
for qso in qsos {
self.qsos.insert(qso.id.clone(), qso);
}
self.compact_if_needed()
}
/// Forget a contact. `Ok(false)` if there was nothing to forget.
pub fn delete(&mut self, id: &str) -> std::io::Result<bool> {
if !self.qsos.contains_key(id) {
return Ok(false);
}
self.append(&Entry::Delete { id: id.to_string() })?;
self.qsos.remove(id);
self.compact_if_needed()?;
Ok(true)
}
fn append(&mut self, entry: &Entry) -> std::io::Result<()> {
let line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
writeln!(file, "{line}")?;
file.flush()?;
self.lines_written += 1;
Ok(())
}
fn compact_if_needed(&mut self) -> std::io::Result<()> {
if self.lines_written < COMPACT_MIN_LINES {
return Ok(());
}
let live = self.qsos.len();
let stale = self.lines_written.saturating_sub(live);
if (stale as f64) < (self.lines_written as f64) * COMPACT_WHEN_STALE_RATIO {
return Ok(());
}
self.compact()
}
/// Rewrite the file with one line per live contact.
///
/// Through a temporary and a rename, so an interrupted compaction leaves
/// the old file whole rather than a half-written new one.
pub fn compact(&mut self) -> std::io::Result<()> {
let temporary = self.path.with_extension("jsonl.compacting");
{
let mut file = BufWriter::new(File::create(&temporary)?);
for qso in self.all() {
let line = serde_json::to_string(&Entry::Qso(Box::new(qso)))
.map_err(std::io::Error::other)?;
writeln!(file, "{line}")?;
}
file.flush()?;
}
std::fs::rename(&temporary, &self.path)?;
self.lines_written = self.qsos.len();
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::qso::parse_adif_datetime;
fn qso(id: &str, call: &str, minute: u32) -> Qso {
Qso::new(
id.to_string(),
parse_adif_datetime("20260807", Some(&format!("12{minute:02}00"))).expect("time"),
call,
14_074_000,
"FT8",
)
}
#[test]
fn a_log_survives_being_closed_and_opened() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("logbook.jsonl");
let mut store = LogStore::open(&path).expect("open");
store.put(qso("a", "SP1AA", 0)).expect("put");
store.put(qso("b", "SP2BB", 1)).expect("put");
drop(store);
let store = LogStore::open(&path).expect("reopen");
assert_eq!(store.len(), 2);
assert_eq!(store.get("a").map(|q| q.call.as_str()), Some("SP1AA"));
// Newest first.
assert_eq!(store.all()[0].call, "SP2BB");
}
#[test]
fn the_last_word_on_an_id_is_the_one_that_counts() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("logbook.jsonl");
let mut store = LogStore::open(&path).expect("open");
store.put(qso("a", "SP1AA", 0)).expect("put");
let mut edited = qso("a", "SP1AA", 0);
edited.rst_sent = Some("-05".into());
store.put(edited).expect("edit");
store.put(qso("b", "SP2BB", 1)).expect("put");
assert!(store.delete("b").expect("delete"));
assert!(!store.delete("b").expect("delete again"));
drop(store);
let store = LogStore::open(&path).expect("reopen");
assert_eq!(store.len(), 1);
assert_eq!(
store.get("a").and_then(|q| q.rst_sent.clone()),
Some("-05".into())
);
}
/// A crash mid-write leaves a torn line. It costs that record and nothing
/// else — the point of appending one line at a time.
#[test]
fn a_torn_last_line_costs_only_itself() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("logbook.jsonl");
let mut store = LogStore::open(&path).expect("open");
store.put(qso("a", "SP1AA", 0)).expect("put");
store.put(qso("b", "SP2BB", 1)).expect("put");
drop(store);
let mut text = std::fs::read_to_string(&path).expect("read");
text.push_str("{\"op\":\"qso\",\"id\":\"c\",\"call\":\"SP3");
std::fs::write(&path, text).expect("write");
let store = LogStore::open(&path).expect("reopen past the tear");
assert_eq!(store.len(), 2);
assert!(store.get("a").is_some() && store.get("b").is_some());
}
#[test]
fn compaction_keeps_the_log_and_shortens_the_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("logbook.jsonl");
let mut store = LogStore::open(&path).expect("open");
// One live record, rewritten until compaction triggers.
for index in 0..COMPACT_MIN_LINES + 10 {
let mut entry = qso("a", "SP1AA", 0);
entry.comment = Some(format!("revision {index}"));
store.put(entry).expect("put");
}
let lines = std::fs::read_to_string(&path)
.expect("read")
.lines()
.count();
assert_eq!(store.len(), 1);
// Compacted along the way: the file holds the revisions written since,
// not the two hundred and sixty-six that were written.
assert!(
lines < COMPACT_MIN_LINES,
"the file kept {lines} lines for one contact"
);
assert_eq!(
store.get("a").and_then(|q| q.comment.clone()),
Some(format!("revision {}", COMPACT_MIN_LINES + 9))
);
// And the compacted file still reads.
drop(store);
let store = LogStore::open(&path).expect("reopen");
assert_eq!(store.len(), 1);
}
#[test]
fn an_import_appends_in_one_pass() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("logbook.jsonl");
let mut store = LogStore::open(&path).expect("open");
store
.put_all(vec![qso("a", "SP1AA", 0), qso("b", "SP2BB", 1)])
.expect("put_all");
assert_eq!(store.len(), 2);
drop(store);
assert_eq!(LogStore::open(&path).expect("reopen").len(), 2);
}
}
+18
View File
@@ -34,6 +34,23 @@ pub struct ClientConfig {
pub remotes: Vec<RemoteEntry>, pub remotes: Vec<RemoteEntry>,
/// Frontend configurations /// Frontend configurations
pub frontends: FrontendsConfig, pub frontends: FrontendsConfig,
/// Station logbook
pub logbook: LogbookConfig,
}
/// Station logbook settings.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct LogbookConfig {
/// Where the log is kept.
///
/// Defaults to `logbook.jsonl` in the user's data directory — not the
/// config directory the bookmarks use, and not the cache directory the
/// decode logs use: a log of contacts is neither a setting nor disposable,
/// and cache directories are swept by cleaners. Configurable because a
/// station that keeps its log on a synced or backed-up volume has to be
/// able to say so.
pub path: Option<String>,
} }
/// General application settings. /// General application settings.
@@ -907,6 +924,7 @@ impl ClientConfig {
http_json: HttpJsonFrontendConfig::default(), http_json: HttpJsonFrontendConfig::default(),
audio: AudioClientConfig::default(), audio: AudioClientConfig::default(),
}, },
logbook: LogbookConfig { path: None },
} }
} }
+4
View File
@@ -130,6 +130,10 @@ const SECTION_COMMENTS: &[(&str, &str)] = &[
"trx-client.frontends.audio.bridge", "trx-client.frontends.audio.bridge",
"Play RX audio on a local sound device and capture TX from one.", "Play RX audio on a local sound device and capture TX from one.",
), ),
(
"trx-client.logbook",
"Station log of contacts. path defaults to logbook.jsonl in the user's\n data directory; set it to keep the log on a backed-up volume.",
),
]; ];
/// Render the combined `trx-rs.toml.example` contents. /// Render the combined `trx-rs.toml.example` contents.
+4
View File
@@ -246,3 +246,7 @@ enabled = false
bitrate_bps = 192000 bitrate_bps = 192000
rx_gain = 1.0 rx_gain = 1.0
tx_gain = 1.0 tx_gain = 1.0
# Station log of contacts. path defaults to logbook.jsonl in the user's
# data directory; set it to keep the log on a backed-up volume.
[trx-client.logbook]