Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83b453135a | ||
|
|
1794f899f3 | ||
|
|
ebe0462b45 |
@@ -56,7 +56,6 @@ 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
@@ -3218,7 +3218,6 @@ dependencies = [
|
|||||||
"base64",
|
"base64",
|
||||||
"brotli 7.0.0",
|
"brotli 7.0.0",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
|
||||||
"dirs",
|
"dirs",
|
||||||
"flate2",
|
"flate2",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -3227,13 +3226,11 @@ 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",
|
||||||
@@ -3272,19 +3269,6 @@ 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"
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ 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",
|
||||||
|
|||||||
@@ -322,3 +322,255 @@ trx-configurator
|
|||||||
| `trx-app` | Config types and validation | Yes |
|
| `trx-app` | Config types and validation | Yes |
|
||||||
| `serialport` | Serial port enumeration | Yes (transitive) |
|
| `serialport` | Serial port enumeration | Yes (transitive) |
|
||||||
| `soapysdr` | SDR device enumeration (optional) | Yes (feature-gated) |
|
| `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 — `<FIELD:length>value`, records ended by `<EOR>`, a header ended
|
||||||
|
by `<EOH>`, 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 /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
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| `GET` | `/logbook` | Query: filters, paging |
|
||||||
|
| `POST` | `/logbook` | Add a QSO |
|
||||||
|
| `PUT` | `/logbook/{id}` | Edit |
|
||||||
|
| `DELETE` | `/logbook/{id}` | Delete |
|
||||||
|
| `GET` | `/logbook/export.adi` | ADIF export, honouring the current filter |
|
||||||
|
| `GET` | `/logbook/export.cbr` | Cabrillo export of a contest selection |
|
||||||
|
| `POST` | `/logbook/import` | Import, answering with counts: added, duplicate, rejected |
|
||||||
|
| `GET` | `/logbook/worked/{call}` | Worked-before: bands and modes |
|
||||||
|
| `GET` | `/logbook/now` | The server's UTC clock, for stamping entries and checking the browser's |
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|||||||
@@ -477,57 +477,6 @@ 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
|
||||||
|
|||||||
@@ -279,10 +279,6 @@ 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 {
|
||||||
|
|||||||
@@ -292,10 +292,6 @@ 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 {
|
||||||
@@ -309,8 +305,6 @@ 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,8 +11,6 @@ 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"] }
|
||||||
@@ -32,6 +30,3 @@ 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,12 +633,9 @@ 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, ham: false };
|
const layoutCapabilities = { broadcast: false, digital: false };
|
||||||
let activeRigId = null;
|
let activeRigId = null;
|
||||||
const layoutSections = [
|
const layoutSections = [
|
||||||
{ id: "advanced-radio-controls", key: "advanced" },
|
{ id: "advanced-radio-controls", key: "advanced" },
|
||||||
@@ -1533,7 +1530,6 @@ function decodeCbor(buffer) {
|
|||||||
var TAB_ORDER = [
|
var TAB_ORDER = [
|
||||||
"main",
|
"main",
|
||||||
"bookmarks",
|
"bookmarks",
|
||||||
"logbook",
|
|
||||||
"digital-modes",
|
"digital-modes",
|
||||||
"map",
|
"map",
|
||||||
"satellites",
|
"satellites",
|
||||||
@@ -1545,7 +1541,6 @@ 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",
|
||||||
@@ -1769,7 +1764,6 @@ 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"]
|
||||||
};
|
};
|
||||||
@@ -1794,7 +1788,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", "logbook", "settings"].map(loadPlugins));
|
await Promise.all(["digital-modes", "bookmarks", "settings"].map(loadPlugins));
|
||||||
}
|
}
|
||||||
async function loadPluginsForTab(tab) {
|
async function loadPluginsForTab(tab) {
|
||||||
await loadPlugins(tab);
|
await loadPlugins(tab);
|
||||||
@@ -2956,10 +2950,7 @@ 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-REYSUJQ4.js";
|
} from "./chunk-OPEIVJGD.js";
|
||||||
import {
|
import {
|
||||||
forActiveRig,
|
forActiveRig,
|
||||||
isActiveRigDecode
|
isActiveRigDecode
|
||||||
@@ -139,9 +139,6 @@ 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" });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-20
@@ -214,16 +214,6 @@ 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";
|
||||||
@@ -234,7 +224,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>>${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.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>>${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.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();
|
||||||
@@ -243,15 +233,6 @@ 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) => {
|
||||||
-18
@@ -152,24 +152,6 @@ 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-WU5EWJX4.js";
|
} from "./chunk-PJ5Q7CWJ.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-WU5EWJX4.js";
|
} from "./chunk-PJ5Q7CWJ.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-WU5EWJX4.js";
|
} from "./chunk-PJ5Q7CWJ.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-REYSUJQ4.js";
|
} from "./chunk-OPEIVJGD.js";
|
||||||
import {
|
import {
|
||||||
forActiveRig,
|
forActiveRig,
|
||||||
isActiveRigDecode
|
isActiveRigDecode
|
||||||
|
|||||||
@@ -1,331 +0,0 @@
|
|||||||
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-REYSUJQ4.js";
|
} from "./chunk-OPEIVJGD.js";
|
||||||
|
|
||||||
// src/map-core.ts
|
// src/map-core.ts
|
||||||
function mapEl(id) {
|
function mapEl(id) {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ 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>
|
||||||
@@ -54,10 +53,6 @@ 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>
|
||||||
@@ -485,111 +480,6 @@ 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…"
|
|
||||||
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,248 +6388,3 @@ 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,7 +33,6 @@ 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 && node tests/logbook.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",
|
||||||
"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" | "ham", boolean>>): void;
|
setLayoutCapabilities(capabilities?: Partial<Record<"broadcast" | "digital", 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,9 +1522,6 @@ 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),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -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", "logbook", "digital-modes", "map", "satellites", "statistics", "recorder", "settings", "about",
|
"main", "bookmarks", "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,7 +11,6 @@ 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" | "logbook" | "recorder" | "settings";
|
| "bookmarks" | "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,7 +20,6 @@ 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"],
|
||||||
};
|
};
|
||||||
@@ -48,7 +47,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", "logbook", "settings"].map(loadPlugins));
|
await Promise.all(["digital-modes", "bookmarks", "settings"].map(loadPlugins));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadPluginsForTab(tab: string): Promise<void> {
|
export async function loadPluginsForTab(tab: string): Promise<void> {
|
||||||
|
|||||||
@@ -324,21 +324,6 @@ 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 {
|
||||||
@@ -385,7 +370,6 @@ 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>`;
|
||||||
|
|
||||||
@@ -397,17 +381,6 @@ 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,7 +23,6 @@ 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;
|
||||||
@@ -165,9 +164,6 @@ 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,8 +49,6 @@ 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;
|
||||||
@@ -226,26 +224,6 @@ 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,8 +22,6 @@ 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;
|
||||||
@@ -45,7 +43,6 @@ 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;
|
||||||
|
|||||||
@@ -1,430 +0,0 @@
|
|||||||
// 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" | "ham";
|
type LayoutCapability = "broadcast" | "digital";
|
||||||
type LayoutName = "compact" | "broadcast" | "digital" | "full" | "ham";
|
type LayoutName = "compact" | "broadcast" | "digital" | "full";
|
||||||
interface OperatorLayout {
|
interface OperatorLayout {
|
||||||
label: string;
|
label: string;
|
||||||
unavailable?: string;
|
unavailable?: string;
|
||||||
@@ -191,11 +191,8 @@ 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, ham: false };
|
const layoutCapabilities: Record<LayoutCapability, boolean> = { broadcast: false, digital: 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.
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
// 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,7 +140,6 @@ export async function startWebFixture({
|
|||||||
history = {},
|
history = {},
|
||||||
bookmarks = [],
|
bookmarks = [],
|
||||||
backgroundDecode = null,
|
backgroundDecode = null,
|
||||||
logbook = [],
|
|
||||||
bandplan = {},
|
bandplan = {},
|
||||||
bandplanEnabled = false,
|
bandplanEnabled = false,
|
||||||
bandplanUnauthorizedFirst = false,
|
bandplanUnauthorizedFirst = false,
|
||||||
@@ -280,83 +279,6 @@ 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,17 +138,6 @@ 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();
|
||||||
|
|||||||
@@ -1,372 +0,0 @@
|
|||||||
// 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,7 +7,6 @@
|
|||||||
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;
|
||||||
@@ -665,7 +664,6 @@ 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)
|
||||||
@@ -708,17 +706,7 @@ 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)]
|
||||||
@@ -947,173 +935,6 @@ 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,39 +106,6 @@ 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(
|
||||||
@@ -197,7 +164,6 @@ 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 {
|
||||||
@@ -226,7 +192,6 @@ 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);
|
||||||
@@ -244,7 +209,6 @@ 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() {
|
||||||
@@ -303,7 +267,6 @@ 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()
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
# 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"
|
|
||||||
@@ -1,414 +0,0 @@
|
|||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
// 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,380 +0,0 @@
|
|||||||
// 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,23 +34,6 @@ 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.
|
||||||
@@ -924,7 +907,6 @@ impl ClientConfig {
|
|||||||
http_json: HttpJsonFrontendConfig::default(),
|
http_json: HttpJsonFrontendConfig::default(),
|
||||||
audio: AudioClientConfig::default(),
|
audio: AudioClientConfig::default(),
|
||||||
},
|
},
|
||||||
logbook: LogbookConfig { path: None },
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,10 +130,6 @@ 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.
|
||||||
|
|||||||
@@ -246,7 +246,3 @@ 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]
|
|
||||||
|
|||||||
Reference in New Issue
Block a user