diff --git a/CLAUDE.md b/CLAUDE.md index 5981235c..9ea82d98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,7 @@ src/ trx-backend-ft450d/ # Yaesu FT-450D ASCII CAT trx-backend-soapysdr/ # SoapySDR RX with full DSP pipeline (~5,000+ LOC) trx-client/ # Client binary: remote connection, frontend spawning (~1,500 LOC) + trx-logbook/ # Station log: QSO record, ADIF reader/writer, append-only store trx-frontend/ # Frontend trait (FrontendSpawner), runtime context trx-frontend-http/ # Web UI: REST API, SSE, WebSocket audio, session auth trx-frontend-http-json/ # JSON-over-TCP control frontend diff --git a/Cargo.lock b/Cargo.lock index 7b8488a8..a8466f2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3218,6 +3218,7 @@ dependencies = [ "base64", "brotli 7.0.0", "bytes", + "chrono", "dirs", "flate2", "futures-util", @@ -3226,11 +3227,13 @@ dependencies = [ "rand 0.8.6", "serde", "serde_json", + "tempfile", "tokio", "tokio-stream", "tracing", "trx-core", "trx-frontend", + "trx-logbook", "trx-protocol", "ts-rs", "uuid", @@ -3269,6 +3272,19 @@ dependencies = [ "rustfft", ] +[[package]] +name = "trx-logbook" +version = "0.1.0" +dependencies = [ + "chrono", + "dirs", + "serde", + "serde_json", + "tempfile", + "tracing", + "uuid", +] + [[package]] name = "trx-protocol" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 18246b51..51221f81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ members = [ "src/trx-server/trx-backend/trx-backend-ft450d", "src/trx-server/trx-backend/trx-backend-soapysdr", "src/trx-client", + "src/trx-client/trx-logbook", "src/trx-client/trx-frontend", "src/trx-client/trx-frontend/trx-frontend-http", "src/trx-client/trx-frontend/trx-frontend-http-json", diff --git a/docs/Planned-Features.md b/docs/Planned-Features.md index 4b628f7a..54360ff1 100644 --- a/docs/Planned-Features.md +++ b/docs/Planned-Features.md @@ -322,3 +322,269 @@ trx-configurator | `trx-app` | Config types and validation | Yes | | `serialport` | Serial port enumeration | Yes (transitive) | | `soapysdr` | SDR device enumeration (optional) | Yes (feature-gated) | + +--- + +## Logbook and Ham Radio Layout + +Two halves of one feature ([#54](https://git.haxx.space/sjg/trx-rs/issues/54)): a station +logbook in a panel of its own, and an operator layout that puts a transceiver's controls +around it. + +Nothing in the application records a QSO today. The map's "QSO summary" cards describe +contacts *between other stations*, reconstructed from decoded traffic; bookmarks are +frequencies, not contacts. Neither is the operator's own log. + +### Requirements + +| ID | Description | +|----|-------------| +| REQ-LOG-001 | The system shall record QSOs the operator makes, each holding at minimum callsign, date, time, band, frequency, mode and both signal reports. | +| REQ-LOG-002 | When starting a log entry, the system shall pre-fill exactly six fields: frequency, mode, rig name, time, callsign and locator. | +| REQ-LOG-003 | The system shall leave every other field of a log entry empty for the operator to fill. | +| REQ-LOG-004 | The system shall allow a logged QSO to be edited and deleted. | +| REQ-LOG-005 | The system shall survive a crash without losing a QSO that was recorded before it. | +| REQ-LOG-006 | The system shall list, search and filter the log by callsign, band, mode and date range. | +| REQ-LOG-007 | Where a decoded station is on screen, the system shall offer to start a log entry from it, pre-filled, without logging it unattended. | +| REQ-LOG-008 | The system shall show whether a callsign has been worked before, and on which bands. | +| REQ-FMT-001 | The system shall export the log as an ADIF 3.1.x `.adi` file. | +| REQ-FMT-002 | The system shall import ADIF `.adi` files produced by other logging software, preserving fields it does not itself use. | +| REQ-FMT-003 | When importing, the system shall identify QSOs already held and shall not duplicate them. | +| REQ-FMT-004 | The system shall export a filtered selection of the log as a Cabrillo 3.0 file for contest submission. | +| REQ-LOG-009 | The system shall stamp QSO times from the server's clock in UTC, and shall tell the operator when the browser's clock disagrees with it by more than one second. | +| REQ-LAY-001 | The system shall present the logbook in a panel of its own, reachable whatever layout is selected. | +| REQ-LAY-002 | The system shall offer a "Ham radio" operator layout presenting the transceiver controls and that panel together. | +| REQ-LAY-003 | Where the selected rig cannot transmit, the system shall not offer the ham layout. | + +### A decode is not a QSO + +The decoders are receive-only: FT8, CW, APRS and the rest report what was *heard*. A heard +callsign is the beginning of a log entry, not a contact, and the logbook must never write one +by itself — REQ-LOG-007 says offer and pre-fill, never auto-log. Digital QSOs made in +WSJT-X or similar arrive the way every other logger takes them: through ADIF import. + +### What is pre-filled, and what is not + +Six fields, and no more (REQ-LOG-002, REQ-LOG-003): + +| Field | From | ADIF | +|-------|------|------| +| Frequency | the selected rig's dial | `FREQ`, with `BAND` derived from it | +| Mode | the selected rig | `MODE`, and `SUBMODE` where the mode implies one | +| Rig name | the rig's display name | `MY_RIG` | +| Time | the server's clock, UTC, when the entry opens | `QSO_DATE`, `TIME_ON` | +| Callsign | the decode row or map station the entry was started from, else empty | `CALL` | +| Locator | that station's grid, where the decode carried one, else empty | `GRIDSQUARE` | + +Signal reports, power, name, QTH and the rest stay empty. A report in particular is the +operator's to give: an FT8 SNR is not what was sent, and pre-filling one would put a number in +the log that nobody exchanged. + +The station's own callsign and locator are not pre-filled per entry either — they are station +identity, taken from configuration when the QSO is written (`STATION_CALLSIGN`, `OPERATOR`, +`MY_GRIDSQUARE`), and shown once at the top of the panel rather than typed into every row. + +### Architecture + +#### New crate: `trx-logbook` + +``` +src/trx-client/ + trx-logbook/ + src/ + lib.rs # LogbookHandle: open, add, edit, delete, query, import, export + qso.rs # Qso: the record, its ADIF field mapping, band/mode helpers + adif.rs # ADI reader and writer (pure Rust, no new dependencies) + store.rs # Append-only JSON Lines file, in-memory index, compaction + dedupe.rs # Worked-before and import-collision rules +``` + +A library crate under `src/trx-client/`, beside `trx-frontend`, consumed by +`trx-frontend-http`. The log belongs to the station rather than to a rig or a frontend, so it +sits where any frontend can reach it. + +#### Storage: append-only, not a rewritten blob + +Bookmarks use `PickleDb` with `AutoDump`, which rewrites the whole file on every write. That +is right for a few dozen bookmarks and wrong for a log: a station with 40 000 QSOs would +rewrite several megabytes to log one contact, and lose the lot if the power went during the +dump. + +The log is instead a JSON Lines file — the shape `trx-decode-log` already uses — appended one +record per write and read into an in-memory index at startup. An edit or a delete appends a new revision of that record's id; the load +keeps the last one, and a compaction pass rewrites the file when superseded records exceed a +threshold. Appending is O(1) and atomic per line, so a crash costs at most the line being +written. + +#### Two formats, for the two things a log is asked for + +The file formats were left open ("pick a well-known ham format"), so: **ADIF for interchange, +Cabrillo for contest submission.** Both are implemented in-repo, in the way this project +already implements its decoders, and neither adds a dependency. + +**ADIF has to stay.** It is not one option among several — it is the only thing the ecosystem +reads. LoTW, eQSL, Club Log, QRZ.com and every other logger take ADIF and nothing else, so a +log that cannot write `.adi` cannot be uploaded, confirmed, or moved to another program. That +is a one-way door, and the interoperability is most of the point of keeping a log at all. +Nothing on disk is ADI regardless: the store is JSON Lines, and ADIF is what comes out of an +export. + +ADI is a tagged text format — `value`, records ended by ``, a header ended +by ``, everything outside a tag ignored — small enough to implement exactly. The reader +must be lenient in the ways real files are irregular (lowercase tags, CRLF, missing header, +unknown fields, type indicators) and the writer strict. Unknown fields are carried through +import to export unchanged, so a round trip through trx-rs does not quietly strip what another +logger wrote. ADX, the XML serialisation of the same data model, is out of scope: it is part +of the standard but almost nothing reads it. + +**Cabrillo is the second format, because ADIF cannot do its job.** Contest logs are submitted +to sponsors in Cabrillo 3.0 and are rejected in anything else — a header of `CALLSIGN:`, +`CONTEST:`, `CATEGORY-*` and `CLAIMED-SCORE:` lines, then one fixed-column `QSO:` line per +contact carrying frequency in kHz, a mode code (`CW`, `PH`, `FM`, `RY`, `DG`), the UTC date and +time, and both stations' calls, reports and exchanges. It is export-only and drops everything +outside the contest's exchange, which is why it complements ADIF rather than replacing it. +It arrives with the contest exchange fields in phase 5, since without a serial or a zone to +put in the exchange there is nothing for it to write. + +#### Integration points + +| Source | What it gives the log | How | +|--------|----------------------|-----| +| `RigState` | `FREQ`, `BAND`, `MODE`/`SUBMODE`, and the rig id a QSO was made on | watch channel already in the frontend context | +| Client config `general.callsign` | `STATION_CALLSIGN`, and the default `OPERATOR` | already surfaced as `owner_callsign` in frontend meta | +| The QSO's own rig, and its position | `MY_GRIDSQUARE` | per-rig latitude and longitude already carried in the rig list | +| Server clock | `QSO_DATE`, `TIME_ON` in UTC | new `GET /api/logbook/now`, which also feeds the browser-clock check | +| Decoder panels and map | a pre-filled entry: callsign, grid, and the report to offer | existing decode history; no new plumbing | +| `bandForHz` | `BAND` from a frequency | exists in `map-core.ts`; move to a shared module | + +#### HTTP API + +Under `/api/logbook`, as the recorder's endpoints are, because `/logbook` itself is the page: +the `/bookmarks` API and the bookmarks page already share a path, and whichever is registered +first wins. + +| Method | Path | Purpose | +|--------|------|---------| +| `GET` | `/api/logbook` | Query: filters, paging | +| `POST` | `/api/logbook` | Add a QSO | +| `PUT` | `/api/logbook/{id}` | Edit | +| `DELETE` | `/api/logbook/{id}` | Delete | +| `GET` | `/api/logbook/export.adi` | ADIF export, honouring the current filter | +| `GET` | `/api/logbook/export.cbr` | Cabrillo export of a contest selection | +| `POST` | `/api/logbook/import` | Import, answering with counts: added, duplicate, rejected | +| `GET` | `/api/logbook/worked/{call}` | Worked-before: bands and modes | +| `GET` | `/api/logbook/statistics` | Contacts, stations and confirmations, per band | +| `GET` | `/api/logbook/now` | The server's UTC clock, for checking the browser's | +| `GET` | `/api/logbook/prefill` | The six fields an entry opens with | + +Writes require the control role, as the rig endpoints do. + +### Frontend + +The logbook is **its own panel**, not a strip bolted to the radio page: a `logbook` entry in +the tab order beside Bookmarks, holding the entry form, the table with the filters of +REQ-LOG-006, and import and export. It stands on its own in every layout, so a log can be kept +without adopting the ham layout, and read while another layout is selected (REQ-LAY-001). + +The panel is three parts: the station line at the top (own callsign, locator, the rig a QSO +would be logged against), the entry form beneath it opening with the six pre-filled fields, +and the log itself under that, filtered as REQ-LOG-006 asks. Worked-before shows against +the callsign as it is typed. + +The **ham layout** is a fifth entry in the operator layouts (`compact`, `broadcast`, `digital`, +`full`), which already gate on capability, seed the disclosure sections and persist per rig: + +```ts +ham: { + label: "Ham radio", + unavailable: "Ham radio needs a rig that can transmit", + advanced: true, audio: true, scheduler: false, + preferredTab: "logbook", capability: "ham", +} +``` + +with the `ham` capability set from `RigCapabilities.tx`. It keeps frequency, VFO, mode, filter, +PTT, power and the meters, and hides the broadcast furniture. What it adds over `full` is where +it starts: the logbook panel, with the radio controls a keystroke away rather than the other +way round — the layout an operator working the bands wants, where logging the contact is the +task and the rig is the instrument. + +### Phases + +All five are implemented. + +| Phase | Lands | +|-------|-------| +| 1 | `trx-logbook`: `Qso`, the ADI reader and writer, round-trip tests against files from other loggers | +| 2 | Store, dedupe, and the HTTP API behind the control role | +| 3 | Logbook tab: entry, table, filters, import, export | +| 4 | Ham layout, pre-filled entry from a decode row or the map, worked-before | +| 5 | Contest exchange fields and Cabrillo export; QSL and LoTW/eQSL fields; per-band worked/confirmed statistics | + +Two decisions phase 5 settled. The Cabrillo header cannot be derived from a log — how many +operators, how much power, what the score is claimed to be — so it comes from the operator, +with `SINGLE-OP`, `LOW`, `ALL` and `MIXED` behind it. And a confirmation counts from whichever +bureau answered: an award wants one card or one electronic match, not one of each, so a log +that counted them separately would tell the operator they were short of what they have. + +### Decisions + +**One station log, not one per rig.** Awards and uploads are per station callsign — DXCC, WAS +and LoTW all count the callsign, not the radio — and a station worked on the second rig is +still worked. The rig is recorded on the QSO (`MY_RIG`) rather than dividing the log by it. +The station *location* fields do follow the rig, though: trx-rs rigs can be in different +places, so `MY_GRIDSQUARE` is taken from the rig that made the QSO rather than from one global +setting, which is also what LoTW's station locations expect. + +**The operator is a per-QSO field, set once per session.** ADIF separates `STATION_CALLSIGN` +(the call used on the air) from `OPERATOR` (the person at the key); multi-operator stations +rotate operators through one station callsign, which is why contest loggers record it per QSO. +It is stored per QSO, defaulted from the configured callsign so a single operator never touches +it, and changed on the station line at the top of the panel where it sticks for the session. +It cannot be taken from the session's identity: the auth roles are `control` and `rx`, with no +notion of who is logged in. + +**Server clock, and the log says so.** The server is the machine at the radio; the browser may +be on a phone in another timezone with a clock nobody has checked. QSO times are UTC from the +server, and when a browser's clock disagrees by more than a second the panel says so rather +than silently logging a time the operator did not expect. + +**The log file is configurable, and defaults to the user's data directory.** Bookmarks live in +the config directory because they are settings; decode logs live in the cache directory because +they are disposable. A QSO log is neither — it is irreplaceable, and cache directories are +swept by cleaners. `[logbook].path` in the client config, defaulting to +`dirs::data_dir()/trx-rs/logbook.jsonl`, so a station that keeps its log on a synced or +backed-up volume can say so. + +**Import collisions: callsign, band, mode and a two-minute window.** Two loggers rarely agree +to the second on the same QSO — one records the time the contact started, another the time it +was entered — so an exact-minute key duplicates half of what it is asked to merge. Two minutes +absorbs that. It does not swallow legitimate re-works: 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 +QSO. Times are compared as instants rather than date and time strings, so a QSO either side of +midnight matches. Modes are normalised before comparison, or a log that stored `SSB` would fail +to match ours that stored `USB`. + +### Rig modes to ADIF modes + +The rig reports what it is demodulating; ADIF wants what the contact was made on, which is not +always the same word: + +| Rig mode | ADIF `MODE` | ADIF `SUBMODE` | +|----------|-------------|----------------| +| `USB`, `LSB` | `SSB` | `USB` / `LSB` | +| `CW`, `CWR` | `CW` | — | +| `AM`, `SAM` | `AM` | — | +| `FM`, `WFM` | `FM` | — | +| `PKT` | `PKT` | — | +| `DIG` | decided by the decoder in use, not by the rig | | +| `AIS`, `VDES` | none — not amateur modes, and these rigs do not log | | +| `Other(..)` | passed through when it names an ADIF mode, else left for the operator | | + +`DIG` is the one that cannot come from the rig: a rig in `DIG` is in FT8, FT4 or something else +depending on which decoder is running, and an entry started from an FT8 row logs `FT8` rather +than the rig's word for it. WSPR never opens an entry at all — it is a beacon mode, and hearing +a beacon is not a contact. + +The table is data in `qso.rs`, checked against the ADIF enumeration when it is written, with +anything unrecognised left to the operator rather than guessed into the log. diff --git a/docs/User-Manual.md b/docs/User-Manual.md index 19dc9736..f37fd0ac 100644 --- a/docs/User-Manual.md +++ b/docs/User-Manual.md @@ -477,6 +477,81 @@ 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. + +### Contests + +The **Contest exchange** block on the entry holds the contest's name and the +serials — sent and received. Both stay between contacts, because they belong to +the session rather than to the contact just logged, and the serial sent counts +on by itself so it is not retyped forty times an hour. An exchange that is a +zone, a section or a name rather than a number is kept as written. + +**Contest entry (Cabrillo)** exports the entry sponsors accept. Only the +contacts of the contest named there are included. The header — operator +category, power, claimed score — cannot be worked out from a log, so it is +yours to fill in; the defaults are single operator, low power, all bands, mixed +mode. + +### Confirmations + +The **QSL** column shows a tick when the other station has confirmed, and the +**Confirm** button on a row records a card that has arrived. A confirmation +counts from wherever it came: a paper card, LoTW or eQSL. An award wants one of +them, not all three, so the log does not ask for all three. + +**Bands worked** counts, per band, the contacts made, the distinct stations +worked, and how many of those contacts are confirmed. + +### Ham radio layout + +The **Ham radio** operator layout opens on the logbook with the transceiver +controls around it — the arrangement for working the bands, where logging the +contact is the task and the radio is the instrument. It is offered only where +the selected rig can transmit; a receiver has no contacts to log. + +--- + ## Tune Links Every page of the web UI carries what the radio is doing in its address, so the diff --git a/src/trx-client/src/main.rs b/src/trx-client/src/main.rs index 9146cd82..cde1f992 100644 --- a/src/trx-client/src/main.rs +++ b/src/trx-client/src/main.rs @@ -279,6 +279,10 @@ async fn async_init() -> DynResult { .http .decode_history_retention_min_by_rig .clone(); + frontend_runtime.http_ui.logbook_path = cfg.logbook.path.clone(); + // The callsign the station is on the air with, which every contact is + // logged under and which the operator defaults to. + frontend_runtime.http_ui.station_callsign = cfg.general.callsign.clone(); // Resolve remote entries: CLI --url > [[remotes]] > legacy [remote] > error let resolved_remotes: Vec = if let Some(ref url) = cli.url { diff --git a/src/trx-client/trx-frontend/src/lib.rs b/src/trx-client/trx-frontend/src/lib.rs index 689acf63..d57ba240 100644 --- a/src/trx-client/trx-frontend/src/lib.rs +++ b/src/trx-client/trx-frontend/src/lib.rs @@ -292,6 +292,10 @@ pub struct HttpUiConfig { pub bandplan_region: String, pub decode_history_retention_min: u64, pub decode_history_retention_min_by_rig: HashMap, + /// Where the station log is kept; `None` takes the default. + pub logbook_path: Option, + /// The callsign contacts are logged under, and the operator by default. + pub station_callsign: Option, } impl Default for HttpUiConfig { @@ -305,6 +309,8 @@ impl Default for HttpUiConfig { bandplan_region: "iaru_r1".to_string(), decode_history_retention_min: 24 * 60, decode_history_retention_min_by_rig: HashMap::new(), + logbook_path: None, + station_callsign: None, } } } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml b/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml index 18a130d8..25cfb039 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml +++ b/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml @@ -11,6 +11,8 @@ build = "build.rs" [dependencies] trx-core = { path = "../../../trx-core" } trx-frontend = { path = ".." } +trx-logbook = { path = "../../trx-logbook" } +chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } trx-protocol = { path = "../../../../src/trx-protocol" } tokio = { workspace = true, features = ["full"] } serde = { workspace = true, features = ["derive"] } @@ -30,3 +32,6 @@ pickledb = "0.5" dirs = "6" uuid = { workspace = true } ts-rs = "12.0.1" + +[dev-dependencies] +tempfile = "3" diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js index 4e315f52..3e8a529a 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js @@ -633,9 +633,12 @@ function elementById(id) { compact: { label: "Compact", advanced: false, audio: false, scheduler: false, preferredTab: "main" }, broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" }, digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, audio: false, scheduler: false, preferredTab: "digital-modes", capability: "digital" }, - full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" } + full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" }, + // Working the bands: the log is the task and the radio is the instrument, + // so this one opens on the logbook with the controls a keystroke away. + ham: { label: "Ham radio", unavailable: "Ham radio needs a rig that can transmit", advanced: true, audio: true, scheduler: false, preferredTab: "logbook", capability: "ham" } }; - const layoutCapabilities = { broadcast: false, digital: false }; + const layoutCapabilities = { broadcast: false, digital: false, ham: false }; let activeRigId = null; const layoutSections = [ { id: "advanced-radio-controls", key: "advanced" }, @@ -1530,6 +1533,7 @@ function decodeCbor(buffer) { var TAB_ORDER = [ "main", "bookmarks", + "logbook", "digital-modes", "map", "satellites", @@ -1541,6 +1545,7 @@ var TAB_ORDER = [ var TAB_PATHS = { main: "/", bookmarks: "/bookmarks", + logbook: "/logbook", "digital-modes": "/digital-modes", map: "/map", satellites: "/satellites", @@ -1764,6 +1769,7 @@ var pluginGroups = { satellites: ["/satellite-predictions.js"], statistics: ["/map-core.js"], bookmarks: ["/bookmarks.js"], + logbook: ["/logbook.js"], recorder: [], settings: ["/vchan.js", "/scheduler.js"] }; @@ -1788,7 +1794,7 @@ async function loadPlugins(group) { for (const path of pluginGroups[group]) await loadPlugin(path); } async function loadEagerPlugins() { - await Promise.all(["digital-modes", "bookmarks", "settings"].map(loadPlugins)); + await Promise.all(["digital-modes", "bookmarks", "logbook", "settings"].map(loadPlugins)); } async function loadPluginsForTab(tab) { await loadPlugins(tab); @@ -2950,7 +2956,10 @@ function refreshOperatorLayoutCapabilities() { )); window.trxUi?.setLayoutCapabilities({ broadcast: rigModes.some((modes) => modes.includes("WFM")), - digital: decoderModes.size > 0 && rigModes.some((modes) => modes.some((mode) => decoderModes.has(mode))) + digital: decoderModes.size > 0 && rigModes.some((modes) => modes.some((mode) => decoderModes.has(mode))), + // A logbook is for contacts made, so the layout built around it is offered + // where a rig can transmit. + ham: serverRigs.some((rig) => rig?.tx === true) }); } function showHint(msg, duration) { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js index a64edc72..ae7f12a0 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js @@ -4,7 +4,7 @@ import { collapseAprsDuplicates, normalizeAprsPacket, renderAprsPacketRow -} from "./chunk-OPEIVJGD.js"; +} from "./chunk-REYSUJQ4.js"; import { forActiveRig, isActiveRigDecode @@ -139,6 +139,9 @@ function renderAprsRow(pkt, isFresh) { }, onCopy: (text) => { void copyAprsCoords(text); + }, + onLog: (call, gridsquare) => { + aprsWindow.logContact?.({ call, gridsquare: gridsquare ?? void 0, decoder: "aprs" }); } }); } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-OPEIVJGD.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-REYSUJQ4.js similarity index 91% rename from src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-OPEIVJGD.js rename to src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-REYSUJQ4.js index c13bbc3c..48aec162 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-OPEIVJGD.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-REYSUJQ4.js @@ -214,6 +214,16 @@ function summarizeAprsPayload(packet) { const text = info.replace(/^[>;${escapeAprsHtml(time)}` + (options.badge ? `${escapeAprsHtml(options.badge)}` : "") + renderAprsSymbolSlot(packet, escapeAprsHtml) + `${escapeAprsHtml(packet.srcCall ?? "")}${escapeAprsHtml(aprsCategoryLabel(category))}${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}` + (packet.crcOk ? "" : 'CRC') + (options.distance ? `${escapeAprsHtml(options.distance)}` : "") + `
>${escapeAprsHtml(packet.destCall || "--")}${escapeAprsHtml(packet.path || "no path")}${escapeAprsHtml(aprsAgeText(packet._tsMs))}CRC ${packet.crcOk ? "ok" : "failed"}` + (hasPosition ? `${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}` : "") + `
${renderAprsInfo(packet)}
` + (packet.info_bytes?.length ? `
${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}
` : "") + `
` + (hasPosition ? `` : "") + (hasPosition ? `` : "") + `QRZ
`; + row.innerHTML = `${escapeAprsHtml(time)}` + (options.badge ? `${escapeAprsHtml(options.badge)}` : "") + renderAprsSymbolSlot(packet, escapeAprsHtml) + `${escapeAprsHtml(packet.srcCall ?? "")}${escapeAprsHtml(aprsCategoryLabel(category))}${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}` + (packet.crcOk ? "" : 'CRC') + (options.distance ? `${escapeAprsHtml(options.distance)}` : "") + `
>${escapeAprsHtml(packet.destCall || "--")}${escapeAprsHtml(packet.path || "no path")}${escapeAprsHtml(aprsAgeText(packet._tsMs))}CRC ${packet.crcOk ? "ok" : "failed"}` + (hasPosition ? `${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}` : "") + `
${renderAprsInfo(packet)}
` + (packet.info_bytes?.length ? `
${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}
` : "") + `
` + (hasPosition ? `` : "") + (hasPosition ? `` : "") + `QRZ` + (options.onLog && packet.srcCall ? '' : "") + `
`; row.querySelectorAll("[data-aprs-map]").forEach((element) => { element.addEventListener("click", (event) => { event.preventDefault(); @@ -233,6 +243,15 @@ function renderAprsPacketRow(packet, options = {}) { if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat, lon); }); }); + const logButton = row.querySelector("[data-aprs-log]"); + if (logButton) { + logButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + const grid = packet.lat != null && packet.lon != null ? maidenheadForLatLon(packet.lat, packet.lon) : null; + options.onLog?.(packet.srcCall ?? "", grid); + }); + } const copyButton = row.querySelector("[data-aprs-copy]"); if (copyButton) { copyButton.addEventListener("click", (event) => { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-PJ5Q7CWJ.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-WU5EWJX4.js similarity index 94% rename from src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-PJ5Q7CWJ.js rename to src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-WU5EWJX4.js index e2dd2fc8..cfa7e943 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-PJ5Q7CWJ.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-WU5EWJX4.js @@ -152,6 +152,24 @@ function initializeFtxDecoder(config) { second: "2-digit" }); row.innerHTML = `${time}${snr?.toFixed(1) ?? "--"}${delta?.toFixed(2) ?? "--"}${frequency?.toFixed(0) ?? "--"}${renderMessage(raw)}`; + const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw); + if (station) { + const log = document.createElement("button"); + log.type = "button"; + log.className = "ft8-log-btn"; + log.textContent = "Log"; + log.title = `Start a log entry for ${station}`; + log.addEventListener("click", (event) => { + event.stopPropagation(); + const details = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw); + bridge.logContact?.({ + call: station, + gridsquare: details.find((detail) => detail.station === station)?.grid, + decoder: id + }); + }); + row.appendChild(log); + } return row; }; const render = () => { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js index ad69d39d..dfe8aafd 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js @@ -1,6 +1,6 @@ import { initializeFtxDecoder -} from "./chunk-PJ5Q7CWJ.js"; +} from "./chunk-WU5EWJX4.js"; import "./chunk-S57W63QN.js"; import "./chunk-KL66PICH.js"; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js index 30180ef0..e72ec5d2 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js @@ -1,6 +1,6 @@ import { initializeFtxDecoder -} from "./chunk-PJ5Q7CWJ.js"; +} from "./chunk-WU5EWJX4.js"; import "./chunk-S57W63QN.js"; import "./chunk-KL66PICH.js"; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js index ff94b312..e04a3d55 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js @@ -2,7 +2,7 @@ import { initializeFt8FamilyBar, initializeFtxDecoder, installFtxCompatibilityHelpers -} from "./chunk-PJ5Q7CWJ.js"; +} from "./chunk-WU5EWJX4.js"; import "./chunk-S57W63QN.js"; import "./chunk-KL66PICH.js"; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js index 2bdbcb9b..94c1d89e 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js @@ -4,7 +4,7 @@ import { collapseAprsDuplicates, normalizeAprsPacket, renderAprsPacketRow -} from "./chunk-OPEIVJGD.js"; +} from "./chunk-REYSUJQ4.js"; import { forActiveRig, isActiveRigDecode diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/logbook.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/logbook.js new file mode 100644 index 00000000..089af5f6 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/logbook.js @@ -0,0 +1,433 @@ +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 contestIdInput = el("log-contest-id"); +var stxInput = el("log-stx"); +var srxInput = el("log-srx"); +var statisticsRows = el("log-statistics-rows"); +var cabrilloContest = el("log-cbr-contest"); +var cabrilloCallsign = el("log-cbr-callsign"); +var cabrilloOperator = el("log-cbr-operator"); +var cabrilloPower = el("log-cbr-power"); +var cabrilloScore = el("log-cbr-score"); +var cabrilloExport = el("log-cbr-export"); +var entryStartedAt = null; +var entryRigId = null; +var entryRigName = null; +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 numberOrNull(text) { + const trimmed = (text ?? "").trim(); + if (!trimmed || !/^\d+$/.test(trimmed)) return null; + const value = Number(trimmed); + return Number.isSafeInteger(value) ? value : null; +} +function utcDate(iso) { + const date = new Date(iso); + return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10); +} +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, srxInput]) { + 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, + contest_id: contestIdInput?.value ?? null, + // The exchange is a number when it is a serial and a word when it is a + // zone or a section; both are kept, and the log writes whichever it has. + stx: numberOrNull(stxInput?.value), + stx_string: numberOrNull(stxInput?.value) == null ? stxInput?.value ?? null : null, + srx: numberOrNull(srxInput?.value), + srx_string: numberOrNull(srxInput?.value) == null ? srxInput?.value ?? null : null, + station_callsign: stationCallEl?.textContent?.trim() ?? null, + operator: operatorInput?.value ?? null, + my_gridsquare: entryGrid, + 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`); + const sent = numberOrNull(stxInput?.value); + resetEntry(); + if (stxInput && sent != null) stxInput.value = String(sent + 1); + await refreshLog(); + } catch (error) { + notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error"); + } 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}` : ""}`; + await refreshStatistics(); + } catch (error) { + console.error("logbook read failed", error); + } +} +async function refreshStatistics() { + if (!statisticsRows) return; + try { + const answer = await getJson("/api/logbook/statistics"); + if (answer.bands.length === 0) { + statisticsRows.innerHTML = 'Nothing worked yet.'; + return; + } + const fragment = document.createDocumentFragment(); + for (const band of answer.bands) { + const row = document.createElement("tr"); + for (const value of [band.band, band.contacts, band.stations, band.confirmed]) { + const cell = document.createElement("td"); + cell.textContent = String(value); + row.appendChild(cell); + } + fragment.appendChild(row); + } + statisticsRows.replaceChildren(fragment); + } catch (error) { + console.error("logbook statistics failed", error); + } +} +function syncCabrilloLink() { + if (!cabrilloExport) return; + const params = new URLSearchParams(); + const contest = cabrilloContest?.value.trim(); + if (contest) { + params.set("contest", contest); + } + const callsign = cabrilloCallsign?.value.trim() || (stationCallEl?.textContent?.trim() ?? ""); + if (callsign) params.set("callsign", callsign); + if (cabrilloOperator?.value) params.set("category_operator", cabrilloOperator.value); + if (cabrilloPower?.value) params.set("category_power", cabrilloPower.value); + const score = numberOrNull(cabrilloScore?.value); + if (score != null) params.set("claimed_score", String(score)); + const operator = operatorInput?.value.trim(); + if (operator) params.set("operators", operator); + cabrilloExport.href = `/api/logbook/export.cbr?${params.toString()}`; +} +function renderRows() { + if (!rowsBody) return; + if (qsos.length === 0) { + rowsBody.innerHTML = 'No contacts yet. Work someone and log them here, or bring a log in with Import ADIF.'; + return; + } + const fragment = document.createDocumentFragment(); + for (const qso of qsos) { + const row = document.createElement("tr"); + row.dataset.qsoId = qso.id; + const cells = [ + utcDate(qso.started_at), + utcTime(qso.started_at), + qso.call, + qso.band ?? "", + qso.submode ?? qso.mode, + qso.rst_sent ?? "", + qso.rst_rcvd ?? "", + qso.gridsquare ?? "", + qso.my_rig ?? "", + qso.confirmed ? "✓" : "" + ]; + 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 confirm = document.createElement("button"); + confirm.type = "button"; + confirm.className = "log-row-btn"; + confirm.textContent = qso.confirmed ? "Unconfirm" : "Confirm"; + confirm.title = qso.confirmed ? "Mark this contact as not confirmed" : "Mark this contact confirmed by QSL"; + confirm.addEventListener("click", () => { + void setConfirmed(qso, !qso.confirmed); + }); + actions.appendChild(confirm); + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "log-row-btn"; + 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 setConfirmed(qso, confirmed) { + try { + const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...qso, + // A card is a card: this is the paper one, and an electronic + // confirmation the log already holds is left where it is. + qsl_rcvd: confirmed ? "Y" : "N" + }) + }); + if (!response.ok) throw new Error(`HTTP ${String(response.status)}`); + await refreshLog(); + } catch (error) { + notify(`Could not update: ${error instanceof Error ? error.message : String(error)}`, "error"); + } +} +async function deleteQso(qso) { + const confirmed = await bridge.trxUi.confirm({ + title: "Delete this contact?", + 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(); + }); +} +for (const control of [cabrilloContest, cabrilloCallsign, cabrilloOperator, cabrilloPower, cabrilloScore]) { + control?.addEventListener("input", syncCabrilloLink); + control?.addEventListener("change", syncCabrilloLink); +} +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(); +if (cabrilloCallsign && !cabrilloCallsign.value) { + cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? ""; +} +syncCabrilloLink(); +void openEntry(); +void refreshLog(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js index f493d173..42e9c298 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js @@ -1,6 +1,6 @@ import { aprsSymbolSprite -} from "./chunk-OPEIVJGD.js"; +} from "./chunk-REYSUJQ4.js"; // src/map-core.ts function mapEl(id) { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index 1e1b49dd..fa524ee5 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -22,6 +22,7 @@ SPDX-License-Identifier: GPL-2.0-or-later + @@ -53,6 +54,10 @@ SPDX-License-Identifier: GPL-2.0-or-later Bookmarks + + + + + + + +
+ + + + + Export ADIF + + +
+
+ + + + + + + + + + + + + + + + + +
DateTimeCallsignBandModeSentRcvdLocatorRigQSLActions
+
+
+ +
+ Bands worked +
+ + + + + + + + + + +
BandContactsStationsConfirmed
+
+
+ +
+ Contest entry (Cabrillo) +

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

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