diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bd30c42 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,109 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +OpsLog is a Windows ham-radio logger built with **Wails v2** — Go backend, React/TypeScript frontend, compiled into a single `.exe`. Author F4BPO. See `README.md` for the user-facing feature list and `wiki/` for end-user documentation. + +**Pure Go, no CGO** — SQLite is `modernc.org/sqlite`, serial is `go.bug.st/serial`. Any dependency requiring cgo breaks the build; check before adding one. + +## Commands + +```bash +wails dev # hot-reload dev (Go methods also reachable at http://localhost:34115) +wails build # full build → build/bin/OpsLog.exe (~25 s) +wails generate module # REQUIRED after changing exported App methods (regenerates TS bindings) + +go build ./... # fast Go-only check +go test ./... # all Go tests +go test ./internal/steppir/ -run TestApplyPendingDirHold -v # one test +gofmt -w + +cd frontend && npx tsc --noEmit # frontend typecheck alone (wails build does this too) +``` + +Release: `.vscode/release.ps1` (Ctrl+Shift+P → *Tasks: Run Task* → *Release OpsLog*) — bumps the version, pushes to Gitea, builds and publishes to Gitea + GitHub. + +Prefer `wails build` for final validation: it is the only command that exercises Go, the TS typecheck, the bindings and the asset embed together. + +## Navigating app.go + +`app.go` is ~14 000 lines and holds nearly every Wails binding. **Do not read it linearly** — it is organised into banner-delimited sections: + +```bash +grep -n "^// ──\|^// ---" app.go # table of contents +``` + +Sections map to features (`── Motorized antenna (Ultrabeam / SteppIR) ──`, `── NET Control ──`, `── DX-cluster spot alerts ──`, …). Find the banner, then read that range. The `App` struct (~line 448) is the other useful landmark: its fields document every subsystem and carry substantial explanatory comments. + +Smaller root files split off self-contained areas: `app_cw.go`, `app_qsl_designer.go`, `app_secret.go`, `chat.go`, `offline.go`, `relayauto.go`, `adifwatch.go`, `livestatus.go`, `update.go`, `telemetry.go`. + +## Architecture + +### Wails binding boundary + +Exported methods on `*App` are the entire frontend API. Adding or changing a signature requires `wails generate module`, which writes `frontend/wailsjs/go/main/App.d.ts` and `models.ts`. Parameters and return types must be Wails-serializable — the generator prints `Not found: time.Time` noise for unsupported types (that particular message is long-standing and harmless). + +Backend→frontend push uses Wails events (`runtime.EventsEmit` / `EventsOn`), e.g. `qso:logged`, `update:progress`. Most hardware status is **polled** by the frontend on an interval rather than pushed. + +### Two separate databases + +This split is easy to get wrong and matters: + +- **`a.db`** — settings database (`settings.db` / `opslog.db`). Always SQLite. Holds settings and profiles. Its location is chosen by the user and recorded in `data/config.json`. +- **`a.logDb`** — the logbook (QSOs). Either a per-profile SQLite file, the default `logbook.db`, or a **shared MySQL** so several operators log into one database. `a.dbBackend` says which. + +QSOs never go to `a.db`. `internal/db` holds the connection helpers and the MySQL specifics; `internal/qso` is the repository. + +Remote MySQL is the slow path: startup deliberately brings CAT/rig links up **before** connecting the logbook, and hot paths (cluster spot enrichment, alert matching) avoid per-row queries — hence the in-memory `wcbm` worked-index and the `clusterEvents` queue that keeps the telnet socket draining. + +### Per-profile settings scoping + +`internal/settings.Store` is a key/value store over the settings DB. Every key is transparently prefixed with the active profile (`p3.`), so each station profile has a complete independent set. Two consequences: + +- `App.settingsScoped` (atomic) gates reads until the active profile is known. Anything reading settings early must respect it or it reads the wrong scope. +- Settings keys are declared as `key` string constants near the top of `app.go` (~180 of them) — grep `key[A-Z].*= "` to find one. + +Password-type keys are encrypted at rest when the secret vault is unlocked (`internal/secret`); a locked vault returns `""` rather than ciphertext, and callers treat that as "not configured". + +### Hardware device pattern + +The codebase talks to ~20 devices (rigs, amplifiers, antenna controllers, switches, keyers, rotators). They all follow the same shape — match it when adding one: + +1. **`internal//`** — a self-contained package exposing a `Client` with `New(...)`, `Start()`, `Stop()`, `GetStatus()`. The client owns its own goroutine: a reconnecting poll loop, a mutex serialising the shared connection, and a cached last-known `Status`. Transports are usually TCP *or* serial behind one `io.ReadWriteCloser`. +2. **Settings** — `key*` constants plus `GetSettings()` / `SaveSettings()` bindings in `app.go`. +3. **Lifecycle** — a `start()` method that tears down any existing client and rebuilds it from settings; called at startup and again whenever settings are saved. +4. **Status binding** — `GetStatus()`, polled by the frontend. +5. **UI** — a settings panel in `frontend/src/components/SettingsModal.tsx` and a live widget in `frontend/src/components/`. + +`internal/steppir` and `internal/antgenius` are compact, well-commented references. Wire-protocol packages carry the byte layout in the package doc comment and pin it with table tests against **real captured frames** — keep that up, since a wrong byte silently mistunes an antenna. + +Where two devices are interchangeable (Ultrabeam / SteppIR), `app.go` defines a small interface (`motorAntenna`) plus thin per-device adapters rather than branching everywhere. + +### CAT + +`internal/cat` (~7 000 lines, the largest package) is the rig abstraction: OmniRig, native FlexRadio/SmartSDR, native Icom CI-V (USB **and** remote-over-internet), and TCI (SunSDR / Expert Electronics). `cat.Manager` exposes a backend-agnostic `State()`; backend-specific features are reached through typed escapes such as `FlexDo(func(cat.FlexController) error)`. Check `State().Backend` before using one. + +### Logging + +`internal/applog` — Wails builds with the Windows GUI subsystem, so `fmt.Println` is discarded. Use `applog.Printf` (or plain `log.Printf` inside `internal/` packages) so output reaches the rotating log file in the user data dir. That file is usually the only evidence available when diagnosing a user's hardware problem. + +## Conventions + +**Changelog is mandatory.** Every user-visible change gets an entry in `changelog.json`, **in both `en` and `fr`**, in the same session as the change. Keep entries to one or two sentences — rationale belongs in the commit message. New work goes under the next version number; the release script bumps the version constants. + +**Version lives in two places** and must stay in lockstep: `appVersion` in `telemetry.go` and `APP_VERSION` in `frontend/src/version.ts`. + +**Bilingual UI.** Every user-visible string goes through `t()` from `frontend/src/lib/i18n.tsx` (~700 keys), with both English and French provided. No hardcoded display strings. + +**Commit messages must not include a `Co-Authored-By` line or any mention of the model.** + +**Comments explain *why*, not *what*.** The existing code documents the reasoning behind non-obvious decisions — which protocol source was trusted, what field failure a workaround addresses, why an ordering matters. Match that: a comment that restates the code is noise, a comment recording the hard-won reason is why this codebase is navigable. + +## Gotchas + +- **Adding a promoted ADIF field touches five places in lockstep.** `internal/adif` promotes ~30 ADIF fields to real QSO columns; miss one and imports silently drop data. All of: the struct field, column list, insert args and scan in `internal/qso/qso.go`; the dictionary entry (`Promoted: true`) in `internal/adif/fields.go`; the `adifPromoted` list *and* the assignment in `internal/adif/import.go`; the writer in `internal/adif/export.go`; then the frontend column in `RecentQSOsGrid.tsx` with its EN+FR i18n keys. Trace an existing field (e.g. `ant_path`) across the repo as a template. +- **Generated files — don't hand-edit.** `internal/dxcc/dxcc_names_gen.go` (cty.dat joined to the ARRL/ADIF entity list) and `internal/awardref/uscounties_gen.go` (emitted by `cmd/cntygen` from the FIPS county CSV; a one-shot generator, not part of the build). +- **Single-instance guard** (`main.go`): a second process would open its own CAT and antenna-follow loops and the two would fight over the rig frequency. +- **Frontend controlled inputs**: several editors normalise a value on every keystroke (trim, split, filter). Binding an input directly to the normalised form makes Enter/Space appear dead — keep raw text in local state and derive the stored value from it. diff --git a/README.fr.md b/README.fr.md index 980759a..89839a5 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,8 +4,8 @@ Rejoindre notre Discord -Un logiciel de log radioamateur moderne et rapide pour Windows — saisie façon -Log4OM, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif, +Un logiciel de log radioamateur moderne et rapide pour Windows — saisie en +bandeau unique, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif, **Icom CI-V** natif (USB **et** à distance par internet, en remplacement de RS-BA1) et **TCI** (SunSDR / Expert Electronics), cluster DX avec alertes de spots, suivi des diplômes, cartes, log de concours, gestion des QSL et un @@ -32,7 +32,7 @@ Développé par **F4BPO**. ## Journalisation -- **Bandeau de saisie façon Log4OM :** indicatif, RST émis/reçu, nom/QTH/locator, +- **Bandeau de saisie unique :** indicatif, RST émis/reçu, nom/QTH/locator, bande/mode, fréquence TX/RX (split), heure de début/fin, commentaire/note. Le **drapeau** de l'entité contactée est affiché en grand à côté des champs RST. - **Recherche d'indicatif** (QRZ.com / HamQTH) avec photo, pré-remplissage du @@ -91,7 +91,7 @@ Développé par **F4BPO**. matrice des déjà-contactés (Réglages → Général). - Les spots **POTA** sont étiquetés avec leur référence de parc (via `api.pota.app`). -- **Alertes de spots** (façon Log4OM) : règles sur indicatif / pays / bande / +- **Alertes de spots :** règles sur indicatif / pays / bande / mode / spotter, avec notification sonore, visuelle et e-mail (Outils → *Gestion des alertes*). diff --git a/README.md b/README.md index 7ec4e1e..a0f8f5b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Join our Discord -A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT +A modern, fast ham-radio logger for Windows — single-strip entry, real-time CAT for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB **and** remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert Electronics), DX cluster with spot alerts, awards tracking, maps, contest logging, QSL @@ -31,7 +31,7 @@ Developed by **F4BPO**. ## Logging -- **Log4OM-style entry strip:** callsign, RST tx/rx, name/QTH/grid, band/mode, +- **Single-strip entry:** callsign, RST tx/rx, name/QTH/grid, band/mode, TX/RX frequency (split), start/end time, comment/note. The contacted entity's **flag** is shown large next to the RST fields. - **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid @@ -81,7 +81,7 @@ Developed by **F4BPO**. **digital modes count as one** (DXCC-style) for the new/new-slot colouring and the worked-before matrix badges (Settings → General). - **POTA** spots are tagged with their park reference (via `api.pota.app`). -- **Spot alerts** (Log4OM-style): rules on call / country / band / mode / +- **Spot alerts:** rules on call / country / band / mode / spotter, with sound, visual and e-mail notification (Tools → *Alert management*). diff --git a/app.go b/app.go index d58fbe3..3a68abd 100644 --- a/app.go +++ b/app.go @@ -793,6 +793,14 @@ func (a *App) startup(ctx context.Context) { cat.LogSink = applog.Printf audio.LogSink = applog.Printf // capture audio-goroutine panics in the app log extsvc.LogSink = applog.Printf // log raw QRZ (and other) service responses for diagnosis + lookup.LogSink = applog.Printf // which call was queried, and why a portable lookup fell back + db.LogSink = applog.Printf // which schema migrations ran, and how long they took + // Version and executable FIRST, before anything else can fail. Diagnosing a + // report means knowing which build produced the log, and that was previously + // impossible: the copy someone is actually running is not always the one they + // think they installed, and the version shown in the UI is the only clue. + exe, _ := os.Executable() + applog.Printf("startup: OpsLog %s — %s", appVersion, exe) applog.Printf("startup: data dir = %s", dataDir) // The local SQLite file ALWAYS holds per-operator configuration — settings, // station profiles, rigs/antennas, cluster nodes, UDP, QSL templates, award @@ -1568,9 +1576,44 @@ func (a *App) restoreWindowPosition() { if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH { return // corrupt / absurd — leave the default placement } + // The SIZE was sanity-checked above but the POSITION never was, and that is + // the one that makes OpsLog unusable: close it on a second monitor, unplug + // that monitor (or dock elsewhere, or change the layout), and the saved + // coordinates put the window somewhere no screen covers. It opens, invisibly, + // forever — with no way back short of deleting window.json, which nobody + // knows to do. Fall back to the default placement instead. + if !onSomeMonitor(ws.X, ws.Y, ws.Width, ws.Height) { + applog.Printf("window: saved position %d,%d (%dx%d) is off every monitor — opening at the default placement", + ws.X, ws.Y, ws.Width, ws.Height) + return + } wruntime.WindowSetPosition(a.ctx, ws.X, ws.Y) } +// onSomeMonitor reports whether a window at these coordinates would land on the +// visible desktop. It demands a real slab of the title bar rather than a single +// pixel: a window overlapping the screen edge by 2 px is, in practice, as lost +// as one entirely outside it. When the desktop bounds can't be read, it says yes +// — better to honour the operator's saved position than to second-guess it. +func onSomeMonitor(x, y, w, h int) bool { + vx, vy, vw, vh, ok := virtualScreenBounds() + if !ok { + return true + } + return overlapsEnough(x, y, w, h, vx, vy, vw, vh) +} + +// overlapsEnough is the geometry behind onSomeMonitor, split out so it can be +// tested — the virtual desktop origin is NEGATIVE when a monitor sits left of or +// above the primary one, which is precisely the layout that produces a lost +// window and precisely where sign errors hide. +func overlapsEnough(x, y, w, h, vx, vy, vw, vh int) bool { + const grabW, grabH = 160, 32 // enough of the title bar to see and drag + overlapW := min(x+w, vx+vw) - max(x, vx) + overlapH := min(y+h, vy+vh) - max(y, vy) + return overlapW >= grabW && overlapH >= grabH +} + // readBootstrap returns the full bootstrap config (DB path + MySQL), or a zero // value if the file is missing/unreadable. func readBootstrap(dataDir string) dbPointer { @@ -5472,6 +5515,20 @@ func (a *App) BulkUpdateField(ids []int64, field, value string) (int64, error) { if field == "freq" { return a.bulkSetFrequency(ids, value) } + // Some ADIF fields have no promoted column and live in extras_json + // (OWNER_CALLSIGN) — those take the JSON path so the rest of the extras on + // each QSO survive the edit. + if key := qso.BulkExtraKey(field); key != "" { + n, err := a.qso.BulkSetExtra(a.ctx, ids, key, strings.TrimSpace(value)) + if err != nil { + return 0, err + } + if n > 0 { + a.invalidateAwardStats() + a.materializeAwardRefsForIDs(ids) + } + return n, nil + } col, ok := bulkFieldColumns[field] if !ok { return 0, fmt.Errorf("unknown field %q", field) @@ -6076,12 +6133,23 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) { if a.lookup == nil { return lookup.Result{}, fmt.Errorf("lookup not initialized") } - // Bound the whole lookup: give the providers a couple of seconds, then let - // Lookup fall through to cty.dat (country/zones). Without this a call that isn't - // in QRZ.com — or a slow/unresponsive provider — left the "looking up" spinner - // turning for 10 s+ before the cty.dat fallback showed. The providers respect - // the context, so they're cancelled at the deadline and cty.dat answers instantly. - ctx, cancel := context.WithTimeout(a.ctx, 2*time.Second) + // Bound the whole lookup, then let Lookup fall through to cty.dat + // (country/zones). Without this a call that isn't in QRZ.com — or a slow + // provider — left the "looking up" spinner turning for 10 s+ before the + // cty.dat fallback showed. The providers respect the context, so they're + // cancelled at the deadline and cty.dat answers instantly. + // + // A slashed/portable call needs a bigger slice, because it costs TWO provider + // round trips rather than one: the full form is looked up first, and only when + // that comes back not-found does the home call get tried (F4LYI/M → F4LYI). + // Two seconds covered one request but not two, so /M and /P calls always ran + // out of time on the second and fell back to cty.dat — even though the + // operator's QRZ record was there and the plain call resolved fine. + budget := 2 * time.Second + if strings.Contains(callsign, "/") { + budget = 6 * time.Second + } + ctx, cancel := context.WithTimeout(a.ctx, budget) defer cancel() r, err := a.lookup.Lookup(ctx, callsign) if errors.Is(err, lookup.ErrNotFound) { @@ -8023,7 +8091,7 @@ func (a *App) pttKey(cfg AudioSettings) error { a.pttKeyedMethod = "cat" a.pttGen++ a.pttMu.Unlock() - applog.Printf("dvk: PTT keyed (CAT/OmniRig)") + applog.Printf("dvk: PTT keyed (CAT via %s)", a.cat.State().Backend) return nil case "rts", "dtr": if strings.TrimSpace(cfg.PTTPort) == "" { @@ -8212,6 +8280,19 @@ func (a *App) GetLogFilePath() string { return applog.Path() } +// UILog lets the frontend write to the same diagnostic log as the backend. +// +// Frontend-only logic (entry-strip auto-fill, debounce ordering, which field the +// operator has touched) was previously invisible in a bug report: console output +// dies with the window, and these problems reproduce on other operators' +// machines, not here. A line in opslog.log can simply be sent along with the +// report. Callers prefix their own subsystem, e.g. "backfill: …". +func (a *App) UILog(msg string) { + if msg = strings.TrimSpace(msg); msg != "" { + applog.Printf("ui: %s", msg) + } +} + // ── QSL defaults ────────────────────────────────────────────────────── // GetQSLDefaults returns the stored defaults — empty strings when the diff --git a/changelog.json b/changelog.json index c6bd4aa..680ad81 100644 --- a/changelog.json +++ b/changelog.json @@ -1,4 +1,64 @@ [ + { + "version": "0.21.3", + "date": "2026-07-25", + "en": [ + "SteppIR: the direction button no longer snaps back to Normal a few seconds after you select 180° or Bidirectional. OpsLog was reading status frames the controller had queued up minutes earlier; it now flushes them before each poll and holds the direction you chose until the controller confirms it.", + "Alert rules: Enter and Space now work in the callsigns box — you can type a list one call per line again.", + "Fixed the US county database (USA-CA) download, broken since the FCC moved its weekly files on 24 July 2026. OpsLog now locates the file instead of assuming a fixed address, so it keeps working when the FCC reshuffles its download directories.", + "Tuner Genius XL: the power and SWR meters now fill the card instead of leaving a third of it empty. In Station Control the amplifier and tuner cards span the dashboard, so their meters are readable.", + "The Audio devices settings panel is now translated — it was still entirely in English.", + "Award management: the editor no longer spills outside its window. Wide rows now scroll inside the panel, and the button bar wraps instead of stretching the dialog (it overflowed in French, where the labels are longer).", + "Tuner Genius XL: the power and SWR meters now peak-hold like the amplifier's. They were showing raw 400 ms samples, so on SSB they fell to zero between syllables and looked like a dropped connection.", + "Fixed recovering name/QTH/locator from the last QSO when you don't use QRZ/HamQTH: the callsign resolved from cty.dat faster than the logbook could answer, so the recovery found no history and never retried. It could also, on a slow logbook, fill in the PREVIOUS station's details — it is now tied to the callsign it belongs to.", + "Big logbooks: finding a callsign in your history no longer scans the whole log. The query could not use the callsign index — on a 190 000-QSO log that made it ~70x slower, on every keystroke, slow enough that the entry strip gave up before the history arrived.", + "One-off with this update: OpsLog has stored every callsign in your logbook in UPPER CASE — that is what lets the lookup above use the index. It changes letter case and nothing else, and runs once. Because the auto-updater migrates before you get to read this, a copy of your logbook was saved next to it first (logbook.db.pre-0024_normalise_callsign.bak) — delete it once you are happy.", + "The diagnostic log now also records what the entry strip does (auto-fill decisions), so a problem that only happens on your station can be reported from the log file instead of guessed at.", + "Worked-before grid: the right-click menu now offers bulk edit and the ADIF/Cabrillo export of the selected rows, like the Recent QSOs grid — no need to go looking for the same QSOs in the main log to change them.", + "Tuner Genius XL: the SWR meter no longer stays frozen on the last transmission once you stop transmitting.", + "Portable callsigns (F4LYI/M, .../P) are looked up on QRZ/HamQTH again. They need two requests — the full form, then the home call — and the time limit only allowed one, so they always fell back to cty.dat even though the operator was listed.", + "LoTW download: a rejected login now says so, instead of pasting a fragment of the ARRL web page into the error.", + "French UI: field names in the filter builder and bulk edit are back in English. They are ADIF field names — a standard vocabulary — and translating them made it harder to relate a filter to an export or to another logger. The operators and buttons around them stay translated.", + "Bulk edit: added Owner callsign. It was filterable but not bulk-editable, because unlike the other fields it has no dedicated column and lives among the ADIF extras; editing it now merges into those and leaves the rest of them alone.", + "Some installations opened with the window invisible: the saved size was sanity-checked but the saved position never was, so a window last closed on a monitor that is no longer attached reopened off-screen every time, with no way back. The position is now checked against the monitors actually present.", + "Icom console: it now follows the radio, not just drives it. AGC, attenuator, preamp and filter were read once when connecting and never again, so switching AGC from FAST to MID on the rig left the panel showing FAST for good. They are re-read continuously now, one per poll cycle so the CAT link stays free for your own commands.", + "Icom: fixed the CI-V model table. The IC-7800 (6Ah) and IC-7700 (74h) were missing, and their addresses were assigned to the wrong radios — 80h is the IC-7410 and 88h the IC-7100. On an IC-7800 that also meant a 20 dB attenuator button the radio does not have, instead of its real 6/12/18 dB steps.", + "Icom console: the band row now highlights the band the radio is actually on. The buttons only ever sent a frequency and never showed where you were.", + "Voice keyer PTT: the CAT option was labelled \"CAT (OmniRig)\", so operators on an Icom CI-V, FlexRadio or TCI rig assumed it was not for them and fell back to RTS/DTR or VOX. It has always driven whichever CAT backend is active — every one of them supports PTT. It now reads \"CAT — Icom CI-V (USB serial)\" and so on, naming the link you actually configured.", + "OmniRig: the frequency now follows the VFO you are actually on. It always read VFO A when the rig reported one, so pressing SUB VFO on an FTDX101D left OpsLog showing the main VFO — and the band and the logged frequency with it.", + "OmniRig split: the PM_SPLITON flag is now read as a bit rather than compared for exact equality, so a rig that reports it alongside another flag is no longer seen as simplex. Split still requires two distinct VFOs in the same band, which keeps a stale VFO B from faking one.", + "CAT connection failures are now written to the diagnostic log. The status pill condenses everything to a few words (\"OmniRig not found\"), and the real reason — the COM error code, the serial or TCP error — previously existed only in a tooltip, so it never reached a bug report. Logged once per distinct message.", + "OmniRig running as administrator while OpsLog is not (or the reverse) is now detected and named. Windows keeps the two privilege levels apart, so OpsLog could not reach OmniRig even with its window open on screen — and reported \"OmniRig not found\", which sent people hunting for a driver or COM-port fault. It now says to start both the same way, and stops repeating the failure in the log every five seconds." + ], + "fr": [ + "SteppIR : le bouton de direction ne repasse plus sur Normal quelques secondes après avoir choisi 180° ou Bidirectionnel. OpsLog lisait des trames d'état que le contrôleur avait empilées plusieurs minutes plus tôt ; elles sont désormais purgées avant chaque interrogation, et la direction choisie est conservée jusqu'à confirmation du contrôleur.", + "Règles d'alerte : Entrée et Espace fonctionnent de nouveau dans la zone des indicatifs — on peut ressaisir une liste, un indicatif par ligne.", + "Correction du téléchargement de la base des comtés américains (USA-CA), cassé depuis le déplacement des fichiers hebdomadaires par la FCC le 24 juillet 2026. OpsLog localise désormais le fichier au lieu de supposer une adresse fixe, et continuera donc de fonctionner lors des prochains remaniements de leurs répertoires.", + "Tuner Genius XL : les jauges de puissance et de ROS occupent toute la carte au lieu d'en laisser un tiers vide. Dans Station Control, les cartes ampli et tuner s'étendent sur toute la largeur du tableau de bord pour que leurs jauges soient lisibles.", + "Le panneau de réglages « Périphériques audio » est désormais traduit — il était resté entièrement en anglais.", + "Gestion des diplômes : l'éditeur ne déborde plus de sa fenêtre. Les lignes trop larges défilent à l'intérieur du panneau et la barre de boutons passe à la ligne au lieu d'élargir la boîte de dialogue (elle débordait en français, où les libellés sont plus longs).", + "Tuner Genius XL : les jauges de puissance et de ROS maintiennent la crête, comme celles de l'ampli. Elles affichaient l'échantillon brut toutes les 400 ms et retombaient donc à zéro entre les syllabes en BLU, ce qui ressemblait à une perte de connexion.", + "Correction de la récupération du nom/QTH/locator depuis le dernier QSO quand on n'utilise pas QRZ/HamQTH : l'indicatif était résolu par cty.dat plus vite que le log ne répondait, la récupération ne trouvait donc aucun historique et ne réessayait jamais. Sur un log lent, elle pouvait aussi reprendre les données de la station PRÉCÉDENTE — elle est désormais liée à l'indicatif concerné.", + "Gros logs : retrouver un indicatif dans l'historique ne parcourt plus tout le log. La requête ne pouvait pas utiliser l'index sur l'indicatif — sur un log de 190 000 QSO, ~70× plus lent que nécessaire, à chaque frappe, et assez lentement pour que la saisie renonce avant l'arrivée de l'historique.", + "Une seule fois, avec cette mise à jour : OpsLog a mis tous les indicatifs de votre log en MAJUSCULES — c'est ce qui permet à la recherche ci-dessus d'utiliser l'index. Seule la casse des lettres change, et l'opération ne se produit qu'une fois. Comme la mise à jour automatique s'exécute avant que vous puissiez lire ceci, une copie de votre log a été enregistrée à côté au préalable (logbook.db.pre-0024_normalise_callsign.bak) — supprimez-la quand vous serez rassuré.", + "Le journal de diagnostic enregistre désormais aussi ce que fait le bandeau de saisie (décisions de remplissage automatique), pour qu'un problème qui n'arrive que chez vous puisse être rapporté depuis le fichier de log au lieu d'être deviné.", + "Grille « Déjà contacté » : le menu du clic droit propose maintenant la modification en masse et l'export ADIF/Cabrillo des lignes sélectionnées, comme la grille des QSO récents — plus besoin d'aller rechercher les mêmes QSO dans le log principal pour les modifier.", + "Tuner Genius XL : la jauge de ROS ne reste plus figée sur la dernière émission une fois que vous cessez d'émettre.", + "Les indicatifs portables (F4LYI/M, .../P) sont de nouveau trouvés sur QRZ/HamQTH. Ils nécessitent deux requêtes — la forme complète, puis l'indicatif de base — et le délai n'en autorisait qu'une : on retombait donc toujours sur cty.dat alors que l'opérateur y était bien référencé.", + "Téléchargement LoTW : un login refusé est désormais annoncé comme tel, au lieu de recopier un fragment de la page web de l'ARRL dans l'erreur.", + "Interface française : les noms de champs du constructeur de filtres et de la modification en masse repassent en anglais. Ce sont des noms de champs ADIF — un vocabulaire standard — et les traduire compliquait le rapprochement avec un export ou un autre logiciel. Les opérateurs et les boutons autour restent traduits.", + "Modification en masse : ajout de « Owner callsign ». Il était filtrable mais pas modifiable en masse car, contrairement aux autres champs, il n'a pas de colonne dédiée et vit parmi les champs ADIF supplémentaires ; sa modification s'y intègre désormais sans toucher aux autres.", + "Chez certains, la fenêtre s'ouvrait invisible : la taille enregistrée était contrôlée mais jamais la position, si bien qu'une fenêtre fermée sur un écran depuis débranché se réouvrait hors champ à chaque fois, sans retour possible. La position est désormais vérifiée contre les écrans réellement présents.", + "Console Icom : elle suit désormais la radio et ne fait plus que la piloter. AGC, atténuateur, préampli et filtre étaient lus une seule fois à la connexion et plus jamais ensuite : passer l'AGC de FAST à MID sur le poste laissait le panneau sur FAST définitivement. Ils sont maintenant relus en continu, un par cycle d'interrogation pour laisser la liaison CAT libre pour vos propres commandes.", + "Icom : correction de la table des modèles CI-V. L'IC-7800 (6Ah) et l'IC-7700 (74h) étaient absents, et leurs adresses attribuées aux mauvais postes — 80h est l'IC-7410 et 88h l'IC-7100. Sur un IC-7800, cela donnait aussi un bouton d'atténuateur 20 dB que la radio ne possède pas, au lieu de ses vrais crans 6/12/18 dB.", + "Console Icom : la rangée des bandes met en évidence celle sur laquelle le poste se trouve réellement. Les boutons ne faisaient qu'envoyer une fréquence, sans jamais indiquer où l'on était.", + "PTT du manipulateur vocal : l'option CAT était libellée « CAT (OmniRig) », si bien que les opérateurs en Icom CI-V, FlexRadio ou TCI la croyaient hors de portée et se rabattaient sur RTS/DTR ou le VOX. Elle a toujours piloté le backend CAT actif, quel qu'il soit — tous gèrent le PTT. Elle affiche désormais « CAT — Icom CI-V (USB série) » et ainsi de suite, en nommant la liaison réellement configurée.", + "OmniRig : la fréquence suit désormais le VFO réellement actif. Le VFO A était toujours lu dès que le poste en rapportait un, si bien qu'appuyer sur SUB VFO sur un FTDX101D laissait OpsLog sur le VFO principal — et avec lui la bande et la fréquence enregistrée.", + "Split OmniRig : le drapeau PM_SPLITON est lu comme un bit au lieu d'être comparé par égalité exacte, donc un poste qui le rapporte accompagné d'un autre drapeau n'est plus vu comme simplex. Le split exige toujours deux VFO distincts sur la même bande, ce qui évite qu'un VFO B périmé en simule un.", + "Les échecs de connexion CAT sont désormais écrits dans le journal de diagnostic. La pastille d'état condense tout en quelques mots (« OmniRig not found ») et la vraie raison — le code d'erreur COM, l'erreur série ou TCP — n'existait que dans une infobulle : elle n'arrivait donc jamais jusqu'à un rapport de bug. Journalisé une fois par message distinct.", + "OmniRig lancé en administrateur alors qu'OpsLog ne l'est pas (ou l'inverse) est désormais détecté et nommé. Windows sépare les deux niveaux de privilège : OpsLog ne pouvait donc pas atteindre OmniRig, fenêtre ouverte à l'écran, et annonçait « OmniRig not found » — de quoi partir chercher un problème de pilote ou de port COM. Il indique maintenant de lancer les deux de la même façon, et cesse de répéter l'échec dans le journal toutes les cinq secondes." + ] + }, { "version": "0.21.2", "date": "2026-07-25", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6f3b11e..20b03b3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,7 +22,7 @@ import { RefreshCtyDat, DownloadAllReferenceLists, RotatorGoTo, RotatorStop, GetRotatorHeading, GetDBConnectionInfo, GetLogbookRevision, - GetUltrabeamStatus, SetUltrabeamDirection, + GetUltrabeamStatus, SetUltrabeamDirection, UILog, GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate, GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate, GetScpStatus, ScpLookup, @@ -249,6 +249,13 @@ function FreqWheelDisplay({ mhz, onNudge, className, placeholder = '—.—— function shortCatError(err?: string): string { if (!err) return ''; const e = err.toLowerCase(); + // Checked BEFORE the not-found cases: a privilege mismatch used to be condensed + // into "OmniRig not found", which is actively misleading — OmniRig is running, + // visibly, and the operator goes looking for a driver or COM-port fault. The + // full explanation is in the tooltip and the log. + if (e.includes('privilege level') || e.includes('elevation') || e.includes('élévation')) { + return 'OmniRig: run as admin?'; + } if (e.includes('not registered') || e.includes('not available')) return 'OmniRig not found'; if (e.includes('not connected')) return 'not connected'; if (e.includes('coinitialize')) return 'COM error'; @@ -1452,6 +1459,17 @@ export default function App() { // entries synchronously (the two run on separate debounce timers). const wbRef = useRef(null); useEffect(() => { wbRef.current = wb; }, [wb]); + // Which callsign wbRef.current actually belongs to. Worked-before is replaced + // only when its query RESOLVES, so between two calls the ref still holds the + // previous station — and the backfill below would happily copy that station's + // name, QTH and grid onto the new one. + const wbCallRef = useRef(''); + // A backfill the lookup asked for before the history had arrived. Parked here + // and replayed by runWorkedBefore: with no QRZ/HamQTH configured the lookup + // answers from local cty.dat almost instantly, while the history query — a + // round trip to a possibly remote MySQL logbook — is still in flight, so the + // backfill found nothing and nothing ever ran it again. + const pendingBackfillRef = useRef<{ call: string; r?: any } | null>(null); const [wbBusy, setWbBusy] = useState(false); // Per-award columns for the Recent QSOs / Worked-before grids: load the award @@ -2886,9 +2904,26 @@ export default function App() { async function runWorkedBefore(call: string, dxccHint: number = 0) { setWbBusy(true); - try { setWb(await WorkedBefore(call, dxccHint)); } - catch { setWb(null); } - finally { setWbBusy(false); } + try { + const w = await WorkedBefore(call, dxccHint); + setWb(w); + // Mirrored synchronously rather than through the effect above: a backfill + // parked by the lookup has to read this on the very next line, not a + // render later. + wbRef.current = w; + wbCallRef.current = call; + // The lookup finished before this history did and parked its backfill — + // run it now that we can answer "who did we work last?". + const p = pendingBackfillRef.current; + if (p && p.call === call) { + pendingBackfillRef.current = null; + fillFromLastQso(p.r, call); + } + } catch { + setWb(null); + wbRef.current = null; + wbCallRef.current = ''; + } finally { setWbBusy(false); } } // fillFromLastQso enriches the entry from the LAST QSO we logged with this call // when the live lookup came up short — the callsign isn't on QRZ/HamQTH, or no @@ -2896,11 +2931,32 @@ export default function App() { // ONLY the fields the provider left empty and the operator hasn't edited, so a // real QRZ/HamQTH hit is never overridden. `r` is the provider result (omitted // on a lookup error, where every provider field counts as empty). - function fillFromLastQso(r?: any) { + function fillFromLastQso(r: any, call: string) { + // Only ever fill from THIS callsign's history. If worked-before hasn't + // resolved for it yet, park the request instead of reading whatever the ref + // happens to hold — that would be the previously entered station. + if (wbCallRef.current !== call) { + pendingBackfillRef.current = { call, r }; + UILog(`backfill ${call}: history not in yet (have "${wbCallRef.current}") — parked`).catch(() => {}); + return; + } + // Parked backfills can land late; drop it if the operator has moved on. + if (call !== callsignValRef.current.trim().toUpperCase()) { + UILog(`backfill ${call}: abandoned, entry now holds "${callsignValRef.current}"`).catch(() => {}); + return; + } const last: any = wbRef.current?.entries?.[0]; // entries are qso_date DESC → most recent - if (!last) return; + if (!last) { + UILog(`backfill ${call}: no prior QSO in the log (count=${wbRef.current?.count ?? 0}, entries=${wbRef.current?.entries?.length ?? 0})`).catch(() => {}); + return; + } const ue = userEditedRef.current; const empty = (v: any) => (v ?? '') === ''; + // One line saying what we found and what blocked each field, so this can be + // diagnosed from another operator's log instead of guessed at. + UILog(`backfill ${call}: last QSO ${last.qso_date ?? '?'} name="${last.name ?? ''}" qth="${last.qth ?? ''}" grid="${last.grid ?? ''}"` + + ` | provider name="${r?.name ?? ''}" grid="${r?.grid ?? ''}" src=${r?.source ?? 'none'}` + + ` | edited=[${[...ue].join(',')}]`).catch(() => {}); if (!ue.has('name') && empty(r?.name) && last.name) setName(last.name); if (!ue.has('qth') && empty(r?.qth) && last.qth) setQth(last.qth); if (!ue.has('country') && empty(r?.country) && last.country) setCountry(last.country); @@ -2985,7 +3041,7 @@ export default function App() { })); // Backfill anything the provider didn't supply from the last time we worked // this call (call not found on QRZ/HamQTH, or lookup off → cty.dat only). - fillFromLastQso(r); + fillFromLastQso(r, call); if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc); // The DX location is now known (grid set above) — force the world map to // auto-zoom right away, so it doesn't lag behind the resolved QSO. @@ -3006,7 +3062,7 @@ export default function App() { setLookupResult(null); setLookupError(String(e?.message ?? e)); // Lookup failed outright — still borrow from the last logged QSO. - fillFromLastQso(); + fillFromLastQso(undefined, call); } } finally { // Only clear the spinner if we're still the current lookup — a newer one @@ -3021,6 +3077,9 @@ export default function App() { const call = value.trim().toUpperCase(); if (call.length < 3) { setLookupResult(null); setWb(null); + // Drop the history and any backfill waiting on it, so clearing the field + // can't let a late one repopulate the next callsign typed. + wbRef.current = null; wbCallRef.current = ''; pendingBackfillRef.current = null; if (lastLookedUpRef.current !== '') resetAutoFill(); return; } @@ -4082,7 +4141,9 @@ export default function App() {
openEdit(q.id as number)} onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} - onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} /> + onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} + onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields} + onExportCabrilloSelected={exportSelectedCabrillo} onDelete={(ids) => setDeletingIds(ids)} />
); case 'flex': @@ -5491,7 +5552,9 @@ export default function App() { openEdit(q.id as number)} onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} - onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} /> + onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} + onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields} + onExportCabrilloSelected={exportSelectedCabrillo} onDelete={(ids) => setDeletingIds(ids)} /> {/* Opened on demand from Tools → QSL Manager; closable via the diff --git a/frontend/src/components/AlertsModal.tsx b/frontend/src/components/AlertsModal.tsx index 13d4acf..41608e2 100644 --- a/frontend/src/components/AlertsModal.tsx +++ b/frontend/src/components/AlertsModal.tsx @@ -70,6 +70,13 @@ export function AlertsModal({ onClose, bands, modes, countries }: { const { t } = useI18n(); const [rules, setRules] = useState([]); const [draft, setDraft] = useState(null); + // Raw text of the callsigns box, kept separately from draft.calls. The list is + // normalised (trim + drop empties) on the way into the rule, so binding the + // textarea straight to calls.join('\n') round-trips every keystroke through + // that normalisation — a trailing newline or space is stripped before React + // re-renders, and Enter/Space appear to do nothing. Editing the raw text and + // deriving the list from it keeps typing intact. + const [callsText, setCallsText] = useState(''); const [tab, setTab] = useState('def'); // active editor tab (reset to Definition on new/select) const [emailTo, setEmailTo] = useState(''); const [err, setErr] = useState(''); @@ -80,6 +87,12 @@ export function AlertsModal({ onClose, bands, modes, countries }: { }, []); useEffect(() => { refresh(); GetAlertEmailTo().then((v) => setEmailTo(v || '')).catch(() => {}); }, [refresh]); + // loadDraft opens a rule in the editor — always through here, so the raw + // callsigns text is re-seeded from whatever rule is now being edited. + const loadDraft = (r: Rule | null) => { + setDraft(r); + setCallsText((r?.calls ?? []).join('\n')); + }; const patch = (p: Partial) => setDraft((d) => (d ? alerts.Rule.createFrom({ ...d, ...p }) : d)); const toggleIn = (key: keyof Rule, v: string) => setDraft((d) => { if (!d) return d; @@ -92,14 +105,14 @@ export function AlertsModal({ onClose, bands, modes, countries }: { async function save() { if (!draft) return; if (!draft.name.trim()) { setErr(t('altm.giveName')); return; } - try { const saved = await SaveAlertRule(draft); await refresh(); setDraft(saved as Rule); setErr(''); } + try { const saved = await SaveAlertRule(draft); await refresh(); loadDraft(saved as Rule); setErr(''); } catch (e: any) { setErr(String(e?.message ?? e)); } } async function del() { if (!draft) return; - if (!draft.id) { setDraft(null); return; } + if (!draft.id) { loadDraft(null); return; } if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return; - try { await DeleteAlertRule(draft.id); setDraft(null); await refresh(); } + try { await DeleteAlertRule(draft.id); loadDraft(null); await refresh(); } catch (e: any) { setErr(String(e?.message ?? e)); } } @@ -116,12 +129,12 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
{t('altm.rules')} - +
{rules.length === 0 &&
{t('altm.noRules')}
} {rules.map((r) => ( -