[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>
This commit is contained in:
sjg
2026-08-07 21:12:03 +02:00
co-authored by Claude Opus 5
parent a19633e81f
commit 18b2d0efe6
45 changed files with 3843 additions and 20 deletions
+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);
}
}