New entries were being prepended, so a version block read backwards: an operator met "the Yaesu meters are corrected" and "the console follows the mode" several entries before learning that a Yaesu console existed. The fixes made sense only to someone who had followed the development. 0.22.0 is reordered so each feature appears before what was fixed on it, and CLAUDE.md now says to append at the bottom. Restored the entry that INTRODUCES the Yaesu console: I had overwritten it when editing the meters entry in place, so the release described corrections to a panel it never announced.
9.1 KiB
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
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 <file>
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:
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 indata/config.json.a.logDb— the logbook (QSOs). Either a per-profile SQLite file, the defaultlogbook.db, or a shared MySQL so several operators log into one database.a.dbBackendsays 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<Thing>string constants near the top ofapp.go(~180 of them) — grepkey[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:
internal/<device>/— a self-contained package exposing aClientwithNew(...),Start(),Stop(),GetStatus(). The client owns its own goroutine: a reconnecting poll loop, a mutex serialising the shared connection, and a cached last-knownStatus. Transports are usually TCP or serial behind oneio.ReadWriteCloser.- Settings —
key<Thing>*constants plusGet<Thing>Settings()/Save<Thing>Settings()bindings inapp.go. - Lifecycle — a
start<Thing>()method that tears down any existing client and rebuilds it from settings; called at startup and again whenever settings are saved. - Status binding —
Get<Thing>Status(), polled by the frontend. - UI — a settings panel in
frontend/src/components/SettingsModal.tsxand a live widget infrontend/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.
Append new entries at the BOTTOM of a version block, never at the top. A block is read top to bottom, so a feature must appear before the fixes made to it — prepending meant an operator read "the Yaesu meters are corrected" several entries before learning a Yaesu console existed at all.
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/adifpromotes ~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 ininternal/qso/qso.go; the dictionary entry (Promoted: true) ininternal/adif/fields.go; theadifPromotedlist and the assignment ininternal/adif/import.go; the writer ininternal/adif/export.go; then the frontend column inRecentQSOsGrid.tsxwith 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) andinternal/awardref/uscounties_gen.go(emitted bycmd/cntygenfrom 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.