From cb430a22eefc39437cbecdb38adcc55e345adbd9 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sat, 22 Aug 2026 14:07:00 +0200 Subject: [PATCH] feat: Ultrabeam over USB, Paper QSL from the awards grid, per-role schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultrabeam on a serial port never worked, and three faults were stacked so each hid the next: - Stop() did not wait for the poll loop, so a stopped client kept the COM port. Every later client then failed with "Serial port busy" — the program holding it being OpsLog itself. - startUltrabeam tore the old client down CONCURRENTLY with starting the new one, and "Test connection" built a second client on a port already ours. Harmless over TCP, fatal on a port with one owner. - A silent serial port returns (0, nil) and bufio retries that a hundred times: a 4 s timeout became ~7 minutes of a frozen poll loop logging nothing. The controller then answered at once. Confirmed on hardware: the USB cable presents TWO COM ports, only the second reaches the controller, and only at 19200 baud — so the speed is pinned in code (an FTDI cable opens at any speed, and a wrong one is indistinguishable from a dead controller) and the port field says which one to pick. The first exchange after each connect is hex-dumped, which separates silence from a wrong baud from a misread frame. Databases now carry only the tables their role needs. Every target used to get the whole migration set, so a shared MySQL logbook grew settings and station_profiles tables nothing ever wrote to — an operator inspecting the server could not tell which copy was authoritative. Statements are filtered by role, unknown tables are kept in both (fail-safe), and existing databases are cleaned once, dropping only EMPTY tables. Settings → Database gains a Compact button, since SQLite frees pages inside the file and never shrinks it. Also: - Awards: the callsigns behind a cell open the QSL Manager on Paper QSL, searched, ready for the card dates. - The record button no longer goes missing after an update: whether manual recording is possible is a per-profile question that was asked once, at startup, before the profile was known. - Alert rules and filter presets confirm that they were saved. - Spot clicks on the radio panadapter carry the POTA park into F3. - The build gate is re-checked wherever the active callsign can change; it ran at startup alone, and a fresh install has no callsign then. --- app.go | 140 ++++++++++++++-- app_gate.go | 1 + changelog.json | 30 ++++ frontend/src/App.tsx | 25 ++- frontend/src/components/AlertsModal.tsx | 32 +++- frontend/src/components/AwardsPanel.tsx | 27 ++- frontend/src/components/FilterBuilder.tsx | 15 +- frontend/src/components/QSLManagerModal.tsx | 28 +++- frontend/src/components/SettingsModal.tsx | 21 ++- frontend/src/lib/i18n.tsx | 16 +- gate_coverage_test.go | 64 +++++++ internal/db/roles.go | 16 ++ internal/db/roles_test.go | 68 ++++++++ internal/steppir/steppir.go | 60 ++++++- internal/ultrabeam/stop_test.go | 80 +++++++++ internal/ultrabeam/ultrabeam.go | 175 +++++++++++++++++++- motor_bandfreq_test.go | 27 +++ motor_trackmode_test.go | 39 ++++- 18 files changed, 819 insertions(+), 45 deletions(-) create mode 100644 gate_coverage_test.go create mode 100644 internal/ultrabeam/stop_test.go diff --git a/app.go b/app.go index bfbbdb3..e7386fe 100644 --- a/app.go +++ b/app.go @@ -716,6 +716,7 @@ type App struct { clublogMW *clublog.MostWanted // ClubLog "Most Wanted" DXCC ranking (opt-in) motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off + motorStartMu sync.Mutex // serialises startUltrabeam: two restarts at once left two poll loops on one COM port motorInhibStop chan struct{} // stops the "inhibit TX while moving" loop; nil when off motorMoveCmdNs atomic.Int64 // unixnano of the last commanded antenna move (grace window) motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher @@ -14827,7 +14828,11 @@ func (a *App) restartAsync(name string, f func()) { go func() { t0 := time.Now() f() - if d := time.Since(t0); d > 2*time.Second { + // Half a second, not two: "switching profile is not instant" is a report + // that cannot be answered without knowing WHICH device took the time, and + // a device that takes 1.5 s is already visible to the operator while + // staying silent under the old threshold. + if d := time.Since(t0); d > 500*time.Millisecond { applog.Printf("%s: restart took %s (device slow to release/connect)", name, d.Round(time.Millisecond)) } }() @@ -15170,7 +15175,18 @@ func (a *App) SaveStationSettings(s StationSettings) error { // "eu-005" would otherwise put a reference no award matcher recognises on // every QSO of an activation. p.MyIOTA = strings.ToUpper(strings.TrimSpace(s.MyIOTA)) - return a.profiles.Save(a.ctx, &p) + if err := a.profiles.Save(a.ctx, &p); err != nil { + return err + } + // The gate, at the moment the callsign is actually set. + // + // A fresh install has NO callsign when the startup check runs, so the check + // passes on an empty string and the operator types theirs afterwards — which + // is precisely the window a denied build ran in for a whole session, and long + // enough to show up in the telemetry (which reads the same field). Saved + // first, then checked: the call must be on disk so the next launch sees it too. + enforceCallGate(p.Callsign) + return nil } // --- Profile bindings (multi-profile CRUD) --- @@ -15203,6 +15219,11 @@ func (a *App) SaveProfile(p profile.Profile) (profile.Profile, error) { if err := a.profiles.Save(a.ctx, &p); err != nil { return profile.Profile{}, err } + // Only for the profile in use: editing some other profile's callsign is not + // running under it, and "--profile " has to stay a way back in. + if act, aerr := a.profiles.Active(a.ctx); aerr == nil && act.ID == p.ID { + enforceCallGate(p.Callsign) + } a.refreshOperatorGrid() return p, nil } @@ -15228,6 +15249,16 @@ func (a *App) ActivateProfile(id int64) error { // EVERY setting is per-profile: re-scope the settings store first, so all // the reloads below read this profile's values. a.settings.SetProfile(id) + // The gate again, on the profile just activated. + // + // It ran at startup on the profile that was active THEN, which left an + // in-session way past it: launch on an allowed profile, switch to the denied + // one, and the build kept running under that callsign for the rest of the + // session — visible in the telemetry, which reads the same active-profile + // callsign. Startup is not the only moment the answer can change. + if p, err := a.profiles.Get(a.ctx, id); err == nil { + enforceCallGate(p.Callsign) + } a.refreshOperatorGrid() // The logbook follows the active profile: reconnect to this profile's DB // target (local SQLite or its own MySQL) so QSOs go to the right logbook. @@ -15264,6 +15295,13 @@ func (a *App) reloadAfterProfileSwitch() { // arriving every few minutes still means something is asking for it, and // nothing in the log said so. applog.Printf("profile: re-applying every subsystem for the active profile") + t0 := time.Now() + defer func() { + // The restarts themselves are asynchronous, so this measures what the + // SWITCH cost before handing back — anything slow after it is a device, + // and names itself in restartAsync above. + applog.Printf("profile: re-apply issued in %s", time.Since(t0).Round(time.Millisecond)) + }() a.reloadLookupProviders() if a.extsvc != nil { a.extsvc.SetConfig(a.loadExternalServices()) @@ -16449,6 +16487,7 @@ func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) { if st, _ := strconv.Atoi(m[keyUltrabeamStep]); st == 25 || st == 50 || st == 100 { out.StepKHz = st } + out.Baud = normMotorBaud(out.Type, out.Transport, out.Baud) out.TrackMode = normMotorTrackMode(m[keyMotorTrackMode]) out.BandFreqs = decodeMotorBandFreqs(m[keyMotorBandFreqs]) out.FreqMinMHz, _ = strconv.Atoi(m[keyMotorFreqMin]) @@ -16521,7 +16560,7 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error { keyMotorType: s.Type, keyMotorTransport: s.Transport, keyMotorCOM: strings.TrimSpace(s.COM), - keyMotorBaud: strconv.Itoa(s.Baud), + keyMotorBaud: strconv.Itoa(normMotorBaud(s.Type, s.Transport, s.Baud)), keyMotorTXInhibit: boolStr(s.TXInhibit), keyMotorFreqMin: strconv.Itoa(s.FreqMinMHz), keyMotorFreqMax: strconv.Itoa(s.FreqMaxMHz), @@ -16535,6 +16574,28 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error { return nil } +// ubSerialBaud is the ONLY line speed an Ultrabeam controller answers on over +// its USB cable. Confirmed on hardware: 9600 opens the port and returns nothing, +// 19200 replies immediately. +const ubSerialBaud = 19200 + +// normMotorBaud pins the Ultrabeam's serial speed and leaves the SteppIR's +// alone. +// +// A choice that has exactly one right answer is not a setting, it is a trap: the +// port opens at any speed — it is an FTDI cable, it will open at 300 — and a +// wrong one looks exactly like a dead controller. The SteppIR keeps its list: +// that controller genuinely runs at several speeds. +func normMotorBaud(typ, transport string, baud int) int { + if typ == "ultrabeam" && transport == "serial" { + return ubSerialBaud + } + if baud < 1200 || baud > 115200 { + return 9600 + } + return baud +} + // newMotorClient builds the concrete client for the configured antenna type and // transport, wrapped in the shared interface. Returns nil if nothing usable is // configured (no host for TCP, no COM for serial). @@ -16566,6 +16627,15 @@ func newMotorClient(s UltrabeamSettings) motorAntenna { // antenna is enabled and configured. Safe to call repeatedly (on startup and // after a settings save). func (a *App) startUltrabeam() { + // ONE restart at a time. + // + // Every caller but the boot one arrives on its own goroutine (restartAsync), + // and two overlapping restarts each tore down "the" old client and started a + // new one — leaving two poll loops fighting over one serial port, which is + // exactly what a log full of "Serial port busy" was showing, with the + // timestamps of two interleaved loops in it. + a.motorStartMu.Lock() + defer a.motorStartMu.Unlock() // Stop any running follow loop first. if a.ubFollowStop != nil { close(a.ubFollowStop) @@ -16576,12 +16646,8 @@ func (a *App) startUltrabeam() { close(a.motorInhibStop) a.motorInhibStop = nil } - if a.motorAnt != nil { - // Background teardown so saving Settings doesn't block on an in-progress - // connect (Stop waits for the dial timeout). - go a.motorAnt.Stop() - a.motorAnt = nil - } + old := a.motorAnt + a.motorAnt = nil // Every way out of here used to be silent, which is how an antenna that had // been working for three minutes went off the air with NOTHING in the log // after the disconnection — the operator could not tell a deliberate stop @@ -16609,8 +16675,19 @@ func (a *App) startUltrabeam() { } else { applog.Printf("antenna: %s starting on %s:%d", s.Type, s.Host, s.Port) } + // Release the previous client BEFORE opening the new one. + // + // The teardown used to run concurrently with the new client's start, which is + // harmless over TCP and fatal over serial: the old poll loop still owned + // COM12, so every attempt by the new one failed with "Serial port busy" — + // forever, since the program holding the port was OpsLog itself, and each + // save added another loop to the fight. Stop now waits for the port to be + // genuinely released, and this function runs off the UI thread already. + if old != nil { + old.Stop() + } a.motorAnt = c - _ = a.motorAnt.Start() + _ = c.Start() // One place starts the follow loop, whether the antenna just connected or the // operator flipped tracking from the Station Control widget. Two copies of // this drifting apart is how a step change quietly stops taking effect. @@ -17212,6 +17289,27 @@ func (a *App) TestUltrabeam(s UltrabeamSettings) error { if s.Port <= 0 || s.Port > 65535 { s.Port = 23 } + // TESTING A SERIAL PORT WE ALREADY OWN. + // + // A COM port has exactly one owner, and when the antenna is enabled that + // owner is the running client. Building a second client to "test" it opened + // the same port a second time: whichever of the two won, the other spent the + // session logging "Serial port busy" — the test reporting no response on a + // port that was working, or the live antenna losing its link because the test + // had taken it. Over TCP two connections are harmless, which is why this went + // unnoticed until the first serial installation. + // + // So when the link under test is the one already running, the answer is that + // client's own status. It is also the more truthful test: it reports the state + // of the connection the operator actually uses. + if s.Transport == "serial" { + if live, ok := a.liveMotorFor(s); ok { + if live.Status().Connected { + return nil + } + return fmt.Errorf("no response on %s — the antenna is connected to this port but not answering", s.COM) + } + } c := newMotorClient(s) if c == nil { return fmt.Errorf("antenna not configured") @@ -17235,6 +17333,28 @@ func (a *App) TestUltrabeam(s UltrabeamSettings) error { return fmt.Errorf("no response from %s:%d", s.Host, s.Port) } +// liveMotorFor returns the running antenna client when it is the one the given +// settings describe — same type, same transport, same port. +// +// Same port is the whole question: a test of some OTHER port must still build +// its own client, because nothing of ours is holding that one. +func (a *App) liveMotorFor(s UltrabeamSettings) (motorAntenna, bool) { + if a.motorAnt == nil { + return nil, false + } + cur, err := a.GetUltrabeamSettings() + if err != nil || !cur.Enabled { + return nil, false + } + if cur.Type != s.Type || cur.Transport != s.Transport { + return nil, false + } + if !strings.EqualFold(strings.TrimSpace(cur.COM), strings.TrimSpace(s.COM)) { + return nil, false + } + return a.motorAnt, true +} + // ── Antenna Genius (4O3A) antenna switch (TCP, port fixed 9007) ───────────── // AntGeniusSettings is the JSON shape for the Hardware → Antenna Genius panel. diff --git a/app_gate.go b/app_gate.go index ce3fb75..1f58253 100644 --- a/app_gate.go +++ b/app_gate.go @@ -18,6 +18,7 @@ var deniedCallHashes = map[string]struct{}{ "46fb61e71afb40627cb6493a6a59c9d4d0231ee3cad1a2bf3fcc4cdfce36fabc": {}, "d1ae6212ec057f9d5c6f379fb57acedac7c94142c9d49667dc04b2016d03d1b6": {}, "0741c9e394b42f43191899105553b47155ddc3026da12b5360701f9c181ff123": {}, + "ab4926a3a0ab76d41b5b99cd3ad0683584970c341c29427c1dfa4b3c329ce415": {}, } // callDenied reports whether a callsign is on deniedCallHashes. The call is diff --git a/changelog.json b/changelog.json index 34a189e..a32bc0b 100644 --- a/changelog.json +++ b/changelog.json @@ -1,4 +1,34 @@ [ + { + "version": "0.26.5", + "date": "", + "en": [ + "Fixed the record button going missing after an update, until the audio settings were opened and saved. Whether a manual recording is possible depends on a per-profile setting, and the question was asked once at startup — before the profile was known on a launch that had more work to do.", + "Switching profile is quicker on a shared MySQL logbook: the one-off cleanup of unused settings tables is now recorded as done instead of being re-checked on every connection.", + "A profile switch now logs how long it took, and any device slower than half a second names itself — a switch that feels sluggish can be diagnosed from the log file.", + "Saving an alert rule or a filter preset now says so, with the name, for a few seconds. Both used to save in silence, leaving the dialog looking exactly as it did before the click.", + "Awards: the callsigns listed behind an award cell are now links. Clicking one opens the QSL Manager on Paper QSL with that station searched, ready for the card sent/received dates — chasing a missing confirmation no longer means retyping the callsign into another tab.", + "Motorised antennas over serial: the line format is now stated explicitly as 8N1, the baud list covers 1200 to 115200, and the first exchange after each connect is dumped to the log — which separates a silent controller from a wrong baud rate from a frame we misread.", + "Fixed a motorised antenna on a serial port failing with \"Serial port busy\" for ever. OpsLog was holding the port itself: stopping a client did not wait for its poll loop to leave, and the new client was started while the old one still owned the COM port — every settings save added another loop to the fight.", + "A serial antenna controller whose port is held by another program now says so by name in the log, instead of repeating \"Serial port busy\" — a COM port has one owner, usually the manufacturer control window left open.", + "Fixed \"Test connection\" on a serial motorised antenna taking the COM port away from the running antenna. A port has one owner, so the test now reports the live link's own status instead of opening a second connection to it.", + "A serial antenna controller that never answers now fails after four seconds with \"nothing came back\", instead of freezing the poll loop for minutes with nothing in the log. A silent serial port returns no bytes AND no error, and the buffered reader retried that a hundred times before giving up.", + "Ultrabeam over USB now works. The cable presents TWO COM ports and only the second reaches the controller, at 19200 baud — so the speed is now fixed at 19200 and the port field says which one to pick. Stopping a client no longer waits out the read timeout either, so changing port takes effect at once instead of leaving the old one held." + ], + "fr": [ + "Correction du bouton d'enregistrement disparu après une mise à jour, jusqu'à ce qu'on ouvre et enregistre les réglages audio. La possibilité d'enregistrer dépend d'un réglage par profil, et la question n'était posée qu'une fois au démarrage — avant que le profil soit connu, sur un lancement qui avait plus de travail à faire.", + "Le changement de profil est plus rapide sur un journal MySQL partagé : le nettoyage ponctuel des tables de réglages inutilisées est désormais marqué comme fait, au lieu d'être revérifié à chaque connexion.", + "Un changement de profil enregistre maintenant sa durée dans le journal, et tout appareil dépassant la demi-seconde se nomme — un changement qui traîne peut donc être diagnostiqué depuis le fichier de log.", + "L'enregistrement d'une règle d'alerte ou d'un filtre affiche maintenant une confirmation avec son nom, quelques secondes. Les deux enregistraient en silence, la fenêtre restant identique à ce qu'elle était avant le clic.", + "Diplômes : les indicatifs listés derrière une case sont désormais cliquables. Un clic ouvre le gestionnaire QSL sur QSL papier avec la station déjà recherchée, prête pour les dates d'envoi et de réception — relancer une confirmation manquante ne demande plus de retaper l'indicatif dans un autre onglet.", + "Antennes motorisées en série : le format de ligne est désormais fixé explicitement en 8N1, la liste des vitesses va de 1200 à 115200, et le premier échange après chaque connexion est écrit dans le log — ce qui distingue un contrôleur muet d'une mauvaise vitesse et d'une trame mal relue.", + "Correction d'une antenne motorisée en série qui échouait indéfiniment sur « Serial port busy ». OpsLog occupait le port lui-même : l'arrêt d'un client n'attendait pas la sortie de sa boucle de scrutation, et le nouveau client démarrait alors que l'ancien détenait encore le port COM — chaque enregistrement des réglages ajoutait une boucle à la mêlée.", + "Un contrôleur d'antenne en série dont le port est occupé par un autre programme le dit maintenant explicitement dans le log, au lieu de répéter « Serial port busy » — un port COM n'a qu'un propriétaire, le plus souvent la fenêtre de contrôle du fabricant restée ouverte.", + "Correction du « Test de connexion » d'une antenne motorisée en série qui prenait le port COM à l'antenne en service. Un port n'a qu'un propriétaire : le test rapporte désormais l'état du lien en cours au lieu d'ouvrir une seconde connexion dessus.", + "Un contrôleur d'antenne en série qui ne répond jamais échoue maintenant en quatre secondes sur « rien n'est revenu », au lieu de figer la boucle de scrutation pendant des minutes sans rien écrire dans le log. Un port série muet ne renvoie ni octet ni erreur, et le lecteur tamponné réessayait cent fois avant d'abandonner.", + "Ultrabeam en USB fonctionne. Le câble présente DEUX ports COM et seul le second atteint le contrôleur, à 19200 bauds — la vitesse est donc fixée à 19200 et le champ du port indique lequel choisir. L'arrêt d'un client n'attend plus la fin du délai de lecture, donc un changement de port prend effet immédiatement au lieu de laisser l'ancien occupé." + ] + }, { "version": "0.26.4", "date": "", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 14a958a..314f7eb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -891,6 +891,13 @@ export default function App() { QSOAudioPlayOnAir().catch((e: any) => setError(String(e?.message ?? e))); }; const refreshManualRecReady = () => { QSOAudioManualReady().then(setManualRecReady).catch(() => {}); }; + // Asked at mount, and asked AGAIN once the backend is really up (see the + // initial grid load) and on every profile change. The audio devices are a + // per-profile setting, so this question cannot be answered before the active + // profile is known — and a "no" then was final, which is why the record button + // went missing for a whole session after an update, when startup does more + // work and the first ask loses the race. Opening and closing the audio panel + // was the only way back. useEffect(() => { refreshManualRecReady(); }, []); const startManualRecording = () => { QSOAudioManualStart().then((active) => { @@ -1104,6 +1111,17 @@ export default function App() { }; // QSL Manager is a closable tab opened on demand from Tools → QSL Manager. const [qslTabOpen, setQslTabOpen] = useState(false); + // A callsign sent to the QSL Manager's Paper QSL view from elsewhere (the + // awards grid). The counter makes two clicks on the same callsign two + // requests — the panel is force-mounted and would otherwise see no change. + const [qslPaperReq, setQslPaperReq] = useState<{ call: string; n: number } | undefined>(undefined); + function openPaperQSLFor(call: string) { + const c = call.trim().toUpperCase(); + if (!c) return; + setQslTabOpen(true); + setActiveTab('qsl'); + setQslPaperReq((r) => ({ call: c, n: (r?.n ?? 0) + 1 })); + } const [qslDesignerOpen, setQslDesignerOpen] = useState(false); const [eqslQsoId, setEqslQsoId] = useState(null); // QSO being sent as eQSL function closeQslTab() { @@ -3087,6 +3105,7 @@ export default function App() { // Same race, same fix: the toolbar options were read once at mount, // possibly before the profile was active, and a "no" then was final. refreshChaseNew(); + refreshManualRecReady(); } else if (!ok && alive && tries++ < 360) { // Quick retries at first (normal startup connects in ~2 s); then keep // trying for several minutes, because the very first migration against a @@ -3641,6 +3660,8 @@ export default function App() { setGridPrefsProfile(id ?? null); setActiveProfileId(typeof id === 'number' ? id : null); loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes(); loadProfileList(); + // The sound devices are per profile: one may record and the next not. + refreshManualRecReady(); // The chat is per shared logbook — clear the previous profile's messages // and reload for the new logbook (or hide if it isn't a MySQL log). setChatMsgs([]); chatSeen.current.clear(); setChatOnline([]); setChatUnread(0); @@ -7688,6 +7709,7 @@ export default function App() { - setAwardsVersion((v) => v + 1)} /> + setAwardsVersion((v) => v + 1)} + onPaperQSL={openPaperQSLFor} /> {statsTabOpen && ( diff --git a/frontend/src/components/AlertsModal.tsx b/frontend/src/components/AlertsModal.tsx index 41608e2..7d17d81 100644 --- a/frontend/src/components/AlertsModal.tsx +++ b/frontend/src/components/AlertsModal.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Bell, Plus, Trash2, Volume2, Mail, Eye, X, Search } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, @@ -102,17 +102,40 @@ export function AlertsModal({ onClose, bands, modes, countries }: { return alerts.Rule.createFrom({ ...d, [key]: next }); }); + // Saving used to be silent: the dialog stays open and the rule looks exactly + // as it did a second earlier, so there was nothing to tell an operator whether + // the click had registered. A line that says so, and clears itself. + const [savedMsg, setSavedMsg] = useState(''); + const savedTimer = useRef(0); + function flashSaved(text: string) { + setSavedMsg(text); + window.clearTimeout(savedTimer.current); + savedTimer.current = window.setTimeout(() => setSavedMsg(''), 3000); + } + useEffect(() => () => window.clearTimeout(savedTimer.current), []); + async function save() { if (!draft) return; if (!draft.name.trim()) { setErr(t('altm.giveName')); return; } - try { const saved = await SaveAlertRule(draft); await refresh(); loadDraft(saved as Rule); setErr(''); } - catch (e: any) { setErr(String(e?.message ?? e)); } + try { + const saved = await SaveAlertRule(draft); + await refresh(); + loadDraft(saved as Rule); + setErr(''); + flashSaved(t('altm.saved', { name: draft.name.trim() })); + } catch (e: any) { setErr(String(e?.message ?? e)); } } async function del() { if (!draft) return; if (!draft.id) { loadDraft(null); return; } if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return; - try { await DeleteAlertRule(draft.id); loadDraft(null); await refresh(); } + try { + const name = draft.name; + await DeleteAlertRule(draft.id); + loadDraft(null); + await refresh(); + flashSaved(t('altm.deleted', { name })); + } catch (e: any) { setErr(String(e?.message ?? e)); } } @@ -257,6 +280,7 @@ export function AlertsModal({ onClose, bands, modes, countries }: { {/* Editor actions */}
{err && {err}} + {!err && savedMsg && {savedMsg}}
diff --git a/frontend/src/components/AwardsPanel.tsx b/frontend/src/components/AwardsPanel.tsx index 2d1be11..ffb9e34 100644 --- a/frontend/src/components/AwardsPanel.tsx +++ b/frontend/src/components/AwardsPanel.tsx @@ -68,7 +68,11 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed: // detection only means anything for those — see the Missing refs button. type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[]; scoped?: boolean }; -export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: number) => void; onAwardsChanged?: () => void } = {}) { +export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: { + onEditQSO?: (id: number) => void; + onAwardsChanged?: () => void; + onPaperQSL?: (call: string) => void; +} = {}) { const { t } = useI18n(); const [awardList, setAwardList] = useState([]); // Computed results are cached per award code — each award is scanned only the @@ -604,7 +608,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
{cell && current && ( - setCell(null)} /> + setCell(null)} onPaperQSL={onPaperQSL} /> )} {showMissing && current && ( setShowMissing(false)} onEditQSO={onEditQSO} /> @@ -835,7 +839,14 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam } // CellQSOModal lists the QSOs behind one award-grid cell (reference × band). -function CellQSOModal({ code, cell, modeClass, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; modeClass: string; onClose: () => void }) { +function CellQSOModal({ code, cell, modeClass, onClose, onPaperQSL }: { + code: string; cell: { ref: string; band: string; name?: string }; modeClass: string; + onClose: () => void; + // Send this callsign to the QSL Manager's Paper QSL view. Chasing a + // confirmation starts here — an unconfirmed entity, the contact behind it — + // and used to continue by retyping the callsign into another tab. + onPaperQSL?: (call: string) => void; +}) { const { t } = useI18n(); const [qsos, setQsos] = useState([]); const [loading, setLoading] = useState(true); @@ -872,7 +883,15 @@ function CellQSOModal({ code, cell, modeClass, onClose }: { code: string; cell: {qsos.map((q, i) => ( {fmt(q.qso_date)} - {q.callsign} + + {onPaperQSL ? ( + + ) : q.callsign} + {q.band} {q.mode} {[isQSLConfirmed(q.lotw_rcvd) && 'LoTW', isQSLConfirmed(q.qsl_rcvd) && 'QSL', isQSLConfirmed(q.eqsl_rcvd) && 'eQSL'].filter(Boolean).join(', ')} diff --git a/frontend/src/components/FilterBuilder.tsx b/frontend/src/components/FilterBuilder.tsx index f7e64f6..243ff03 100644 --- a/frontend/src/components/FilterBuilder.tsx +++ b/frontend/src/components/FilterBuilder.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Plus, Trash2, Save, FolderOpen, X } from 'lucide-react'; import { writeUiPref } from '@/lib/uiPref'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; @@ -192,6 +192,17 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) { function apply() { onApply(buildFilter()); } + // Same silence as the alert rules: the preset landed in the list, but the list + // is not where the operator is looking when they press Save. + const [savedMsg, setSavedMsg] = useState(''); + const savedTimer = useRef(0); + function flashSaved(text: string) { + setSavedMsg(text); + window.clearTimeout(savedTimer.current); + savedTimer.current = window.setTimeout(() => setSavedMsg(''), 3000); + } + useEffect(() => () => window.clearTimeout(savedTimer.current), []); + function saveCurrentPreset() { const name = presetName.trim(); if (!name) return; @@ -199,6 +210,7 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) { savePresets(next); setPresets(next); setPresetName(''); + flashSaved(t('fltb.presetSaved', { name })); } function loadPreset(name: string) { const f = presets[name]; @@ -335,6 +347,7 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) { + {savedMsg && {savedMsg}}
diff --git a/frontend/src/components/QSLManagerModal.tsx b/frontend/src/components/QSLManagerModal.tsx index 2e45aef..d025f5b 100644 --- a/frontend/src/components/QSLManagerModal.tsx +++ b/frontend/src/components/QSLManagerModal.tsx @@ -134,9 +134,14 @@ export type GridActions = { onDelete?: (ids: number[]) => void; }; -export function QSLManagerPanel({ onEditQSO, actions }: { +export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: { onEditQSO?: (id: number) => void; actions?: GridActions; + // A callsign to open the Paper QSL view on, sent from elsewhere in the app + // (the awards grid). Carries a counter rather than being a bare string: the + // panel is force-mounted and keeps its state, so asking twice for the SAME + // callsign has to be two requests, not one unchanged prop. + paperRequest?: { call: string; n: number }; } = {}) { const { t } = useI18n(); const [service, setService] = useState('lotw'); @@ -198,6 +203,27 @@ export function QSLManagerPanel({ onEditQSO, actions }: { }, [paperCall]); + // Honour an incoming request: switch to Paper QSL, fill the callsign, search. + // The search runs from the effect below once paperCall has actually changed — + // searchPaper closes over paperCall, so calling it here would search the + // PREVIOUS callsign. + // The pending request's callsign, so the search fires for THAT callsign and + // for nothing else: the effect below also wakes on every keystroke in the + // field, and searching on each of them would query the log letter by letter. + const pendingPaperCall = useRef(''); + useEffect(() => { + const call = paperRequest?.call?.trim().toUpperCase(); + if (!call) return; + setService('paper'); + setPaperCall(call); + pendingPaperCall.current = call; + }, [paperRequest?.call, paperRequest?.n]); + useEffect(() => { + if (!pendingPaperCall.current || paperCall !== pendingPaperCall.current) return; + pendingPaperCall.current = ''; + searchPaper(); + }, [paperCall, searchPaper]); + async function applyPaper() { const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id); if (ids.length === 0) return; diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index c2413fc..c95db7b 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -3589,7 +3589,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan {isSerial ? (
- + {/* A list AND a text field, which is why this is a Combobox and not the Select the other serial devices use: the port for a USB adapter that is currently unplugged does not appear in @@ -3613,12 +3613,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
- + {/* The Ultrabeam answers on 19200 and on nothing else, so there is + no choice to offer — an FTDI cable opens at any speed, and a + wrong one is indistinguishable from a dead controller. */} + {!isSteppir ? ( + + ) : ( + + )}
) : ( diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 68a31fe..63697d2 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -338,7 +338,7 @@ const en: Dict = { 'rotor.azTitle': 'Azimuth 0-359, Enter to turn the rotor', 'rot.enable': 'Enable rotator control', 'rot.testOkRG': 'Connected — the Rotator Genius accepted the command (moving to 0°).', 'rot.testOkRead': 'Connected — the controller answered with its heading. Nothing was moved: this test only reads the position.', 'rot.type': 'Rotator type', 'rot.rotatorNum': 'Rotator #', 'rot.dual': 'Two rotors', 'rot.rotor1': 'Rotor 1', 'rot.rotor2': 'Rotor 2', 'rot.name': 'Name', 'rot.antenna': 'Antenna', 'rot.antenna1': 'Rotor 1 antenna', 'rot.antenna2': 'Rotor 2 antenna', 'rot.name1': 'Rotor 1 name', 'rot.name2': 'Rotor 2 name', 'rot.antStd': 'Standard antenna', 'rot.antMotor': 'Motorized (Ultrabeam/SteppIR)', 'rot.add': 'Add rotor', 'rot.remove': 'Remove rotor', 'rot.none': 'No rotor configured. Click “Add rotor” to add one.', 'rot.rgDual': 'This Rotator Genius drives two rotors (adds a second one)', 'rot.testRotor1': 'Test rotor 1', 'rot.testRotor2': 'Test rotor 2', 'rot.dualHint': 'Two independent rotors of any type — for example an ARCO and a Rotator Genius side by side. Configure each below; OpsLog shows a Rotor 1/2 toggle on the compass to pick which one it turns and displays. Set each rotor to "Motorized" so the boom/pattern paths (reverse, bidirectional) show only for the one that carries the Ultrabeam/SteppIR. (For a single Rotator Genius driving two rotors, set both to Rotator Genius with the same host and rotator # 1 and 2.)', 'rot.rgHint': 'Talks directly to a 4O3A Rotator Genius over TCP (default port 9006) — no PstRotator needed. Rotator # selects which of the two rotators to drive.', 'rot.arcoHint': "Talks directly to any controller set to Yaesu GS-232A — no PstRotator needed. microHAM ARCO: set Config → LAN → CONTROL PROTOCOL (or USB CONTROL PROTOCOL) to 'Yaesu GS-232A'; on USB the baud rate does not matter. ERC (Easy Rotor Control, incl. ERC Mini): its emulation MUST be set to GS-232 — an ERC left on Hy-Gain DCU-1 speaks a different command set and will not answer — then pick its COM port and the same baud rate as in the ERC configuration.", 'rot.dcu1Hint': "Speaks the Hy-Gain DCU-1 command set (RotorCard DXA, Idiom Press Rotor-EZ, Green Heron). Connect over the controller's COM port (a DCU-1 is 4800 baud; RotorCard/Green Heron may differ — match the controller) or over TCP through a serial-over-IP bridge. Azimuth only, no elevation. New backend — please report if your controller needs a different command or baud.", 'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.", 'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).', - 'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m–6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 13–54 MHz (20 m–6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.motorBands': 'Covered bands', 'hw.motorStep': 'Re-tune step', 'hw.motorBandFreqHint': 'Frequency each band button tunes the antenna to (kHz). Leave empty for the default shown.', 'hw.motorFollow': 'Follow rig frequency (auto-tune the antenna)', 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer', + 'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorComUb': '(2nd port of the USB cable)', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m–6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 13–54 MHz (20 m–6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.motorBands': 'Covered bands', 'hw.motorStep': 'Re-tune step', 'hw.motorBandFreqHint': 'Frequency each band button tunes the antenna to (kHz). Leave empty for the default shown.', 'hw.motorFollow': 'Follow rig frequency (auto-tune the antenna)', 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer', // CAT panel body 'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'Another program then reaches the radio through OpsLog — with every backend, not only the native ones. For Hamlib pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532; for TCI point the program at 127.0.0.1:40001.', 'cat.shareProto': 'Protocol', 'cat.shareRigctl': 'Hamlib NET rigctl — WSJT-X, JTDX, MSHV, Log4OM', 'cat.shareTci': 'TCI — Expert Electronics', 'cat.shareTciClash': 'The CAT backend is TCI too: ExpertSDR is probably already using port 40001 on this PC. Give the server another port, or share over Hamlib instead.', 'cat.sharePort': 'Sharing port', 'cat.pttKey': 'Enable PTT hotkey', 'cat.pttKeyPress': 'Press a key…', 'cat.pttKeyNone': 'Click to set a key', 'cat.pttKeyClear': 'Clear', 'cat.pttKeyToggle': 'Toggle mode (press to key, press again to unkey)', 'cat.pttKeyHint': 'While OpsLog is focused, this key keys the transmitter — held down by default (release to stop), or latched in toggle mode. It uses the Audio → PTT method (CAT / RTS / DTR), falling back to CAT keying. Pick a key you never type while logging (e.g. Pause, ScrollLock, or a footswitch mapped to one) — OpsLog swallows it so it never lands in a field.', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Xiegu CAT port', 'cat.xieguBaudHint': 'Must match the radio menu (G90 default: 19200).', 'cat.xieguAddrHint': 'Factory CI-V address of the G90/X6100 family: 0x70.', 'cat.xieguPTTLine': 'How the rig is keyed', 'cat.xieguPTTCiv': 'CI-V command', 'cat.xieguPTTHint': 'A G90 does not transmit on the CI-V command: interfaces like the DE-19 key it on RTS or DTR. Pick the line yours uses \u2014 it is also what lets WSJT-X transmit through the shared CAT link.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, network)', 'cat.optElecraft': 'Elecraft K3/K4 (USB, network)', 'cat.elecraftHint': 'Digital modes automatically use DATA A (MD6+DT0) — the sub-mode FT8 audio needs.', 'cat.civTrace': 'Log the CAT protocol', 'cat.civTraceHint': 'Writes every CAT frame to and from the radio into the log \u2014 CI-V as hex, Kenwood as the text it exchanges. For reporting a rig that answers oddly \u2014 a button that does the wrong thing, a frequency that jumps. Session only: it is a diagnostic, and it makes the log large.', 'cat.kenwoodPort': 'Kenwood COM port', 'cat.kenwoodPortHint': 'The rig\u2019s CAT/USB serial port (TS-590, TS-890, TS-990, TS-2000, and Elecraft K3/K4 which speak the same dialect).', 'cat.kenwoodBaudHint': 'Must match MENU on the radio: a TS-590 leaves the factory at 9600, a TS-890 at 115200.', 'cat.kenwoodHost': 'Or over the network (host:port)', 'cat.kenwoodHostHint': 'A serial-over-network bridge \u2014 ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio. Filled in, it is used INSTEAD of the COM port above. This is not the radio\u2019s own RJ45 socket, which speaks Kenwood\u2019s KNS protocol and is not supported.', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.lowerLines': 'Lower the DTR and RTS lines on connect', 'cat.lowerLinesHint': 'If your radio is always on TX, tick this.', 'cat.kwDataMode': 'Data modes (FT8/PSK…) use', 'cat.kwDataUsb': 'USB', 'cat.kwDataMd6': 'DATA A — MD6+DT0 (Elecraft K3/K4)', 'cat.kwDataKeep': 'Leave the rig’s mode unchanged', 'cat.kwDataHint': 'What OpsLog sets on the rig for a data mode. No single command fits every rig: an Elecraft K3/K4 wants DATA (MD6); a TS-590SG/TS-990S data mode is a USB modifier set on the rig, so pick USB or, safest, "Leave unchanged" and switch the rig to DATA yourself. On MD6 a plain Kenwood (TS-590/990) would land on FSK/RTTY — do not use it there.', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V network)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 power per band and mode', 'flxpw.hint': 'TX power applied when the band or mode changes. Leave a box empty to leave the power alone.', 'flxpw.band': 'Band', 'flxb.title': 'FlexRadio \u2014 per-band antennas and power', 'flxb.hint': 'Antennas are applied when the band changes; power when the band or the mode changes. Leave a power box empty to leave it alone.', 'flxb.rxAnt': 'RX antenna', 'flxb.txAnt': 'TX antenna', 'flxb.noRadio': 'No antennas reported yet \u2014 connect the FlexRadio, then reopen this panel.', 'flxpw.phone': 'Phone', 'flxpw.digi': 'Digital', 'flxpw.noBands': 'No bands configured \u2014 add them in Lists \u2192 Bands.', 'flxpw.saved': 'Saved', 'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password', @@ -433,18 +433,18 @@ const en: Dict = { 'rst.clickToFill': 'Click to set RST tx from the signal', 'qrz.openTitle': 'Open {call} on QRZ.com', // Misc panels/modals (alerts / send-spot / net / udp / filter / details) - 'altm.filterPh': 'Filter…', 'altm.noMatch': 'no match', 'altm.noneAll': 'none selected = ALL', 'altm.nSelected': '{n} selected', 'altm.giveName': 'Give the rule a name', 'altm.deleteConfirm': 'Delete alert "{name}"?', 'altm.title': 'Alert management', 'altm.desc': 'Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).', 'altm.rules': 'Rules', 'altm.noRules': 'No rules yet — click +', 'altm.emailTo': 'Alert e-mail to', 'altm.selectOrCreate': 'Select or create a rule.', 'altm.tabDef': 'Definition', 'altm.tabCall': 'Call / DXCC', 'altm.tabBandMode': 'Band / Mode', 'altm.tabOrigin': 'Origin', 'altm.ruleName': 'Rule name', 'altm.alertEnabled': 'Alert enabled', 'altm.againAfter': 'Alert again after (min)', 'altm.againHint': '0 = once/session · -1 = always', 'altm.actions': 'Actions', 'altm.visual': 'Visual', 'altm.sound': 'Sound', 'altm.email': 'E-mail', 'altm.skipWorked': 'Skip calls already worked (same band + mode)', 'altm.callsigns': 'Callsigns (one per line, wildcards: IW3*, */P)', 'altm.countries': 'Countries (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bands', 'altm.modes': 'Modes', 'altm.spotterCall': 'Spotter callsign (wildcard)', 'altm.spotterCallPh': 'e.g. F* or DL1ABC', 'altm.spotterContinents': 'Spotter continents', 'altm.spotterCountries': 'Spotter countries', 'altm.delete': 'Delete', 'altm.saveRule': 'Save rule', 'altm.close': 'Close', + 'altm.filterPh': 'Filter…', 'altm.noMatch': 'no match', 'altm.noneAll': 'none selected = ALL', 'altm.nSelected': '{n} selected', 'altm.saved': 'Alert “{name}” saved', 'altm.deleted': 'Alert “{name}” deleted', 'altm.giveName': 'Give the rule a name', 'altm.deleteConfirm': 'Delete alert "{name}"?', 'altm.title': 'Alert management', 'altm.desc': 'Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).', 'altm.rules': 'Rules', 'altm.noRules': 'No rules yet — click +', 'altm.emailTo': 'Alert e-mail to', 'altm.selectOrCreate': 'Select or create a rule.', 'altm.tabDef': 'Definition', 'altm.tabCall': 'Call / DXCC', 'altm.tabBandMode': 'Band / Mode', 'altm.tabOrigin': 'Origin', 'altm.ruleName': 'Rule name', 'altm.alertEnabled': 'Alert enabled', 'altm.againAfter': 'Alert again after (min)', 'altm.againHint': '0 = once/session · -1 = always', 'altm.actions': 'Actions', 'altm.visual': 'Visual', 'altm.sound': 'Sound', 'altm.email': 'E-mail', 'altm.skipWorked': 'Skip calls already worked (same band + mode)', 'altm.callsigns': 'Callsigns (one per line, wildcards: IW3*, */P)', 'altm.countries': 'Countries (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bands', 'altm.modes': 'Modes', 'altm.spotterCall': 'Spotter callsign (wildcard)', 'altm.spotterCallPh': 'e.g. F* or DL1ABC', 'altm.spotterContinents': 'Spotter continents', 'altm.spotterCountries': 'Spotter countries', 'altm.delete': 'Delete', 'altm.saveRule': 'Save rule', 'altm.close': 'Close', 'spm.callRequired': 'Callsign required', 'spm.freqRequired': 'Frequency (kHz) required', 'spm.title': 'Send DX Spot', 'spm.callsign': 'Callsign', 'spm.callPh': 'DX call', 'spm.frequency': 'Frequency (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'e.g. CW · TNX QSO', 'spm.latestQsos': 'Latest QSOs', 'spm.spotSent': 'Spot sent ✓', 'spm.masterCluster': 'Master cluster', 'spm.cancel': 'Cancel', 'spm.sending': 'Sending…', 'spm.sendSpot': 'Send spot', 'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Worked before', 'ncp.wbHint': 'Click a station (on air or roster) to see prior QSOs', 'ncp.wbNone': 'No prior QSO with', 'ncp.wbFirst': 'first', 'ncp.wbLast': 'last', 'ncp.wbResize': 'Drag to resize', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'mic-pass order · ⬆⬇ to reorder · double-click → edit · "Log & end" to save', 'ncp.moveUp': 'Move up the mic-pass order', 'ncp.moveDown': 'Move down the mic-pass order', 'ncp.logEndSelected': 'Log & end selected', 'ncp.logAll': 'Log everyone ({n})', 'ncp.logAllConfirm': 'Log all {n} on-air station(s) to the logbook?', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET', 'udpp.relayInstead': 'For an antenna switch or a relay board, use Station Control → relays instead: it holds the state, reads the boards at startup and does not re-switch while you tune inside a band. A home-made switch is the “HTTP relay” type there.', 'udpp.svcCustomLabel': 'Custom message', 'udpp.svcCustomHint': 'You choose what fires it and what it says. A UDP datagram or an HTTP request — the latter is how most antenna switches are driven.', 'udpp.trigger': 'Fires on', 'udpp.trgBand': 'Band change (radio)', 'udpp.trgQso': 'QSO logged', 'udpp.trgRotator': 'Rotator command', 'udpp.trgLookup': 'Callsign lookup', 'udpp.transport': 'Sends as', 'udpp.transportUdp': 'UDP message', 'udpp.transportUrl': 'URL (HTTP GET)', 'udpp.url': 'URL', 'udpp.urlHint': 'Values are URL-encoded. Credentials may be included as http://user:pass@host/… — stored as typed, so keep it to your own network.', 'udpp.template': 'Message', 'udpp.lineEnd': 'Line end', 'udpp.lineEndNone': 'None', 'udpp.fieldsAvailable': 'Fields for this trigger', 'udpp.fieldsHint': 'Anything else renders empty.', 'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcWsjtRelayLabel': 'Relay the WSJT-X stream', 'udpp.svcWsjtRelayHint': 'Re-sends every datagram received from WSJT-X / JTDX / MSHV, byte for byte, to another program — JTAlert, GridTracker, a second logger. The sender only talks to one address, so this is what lets them run alongside OpsLog. Point it at the OTHER program’s port, never at one of OpsLog’s own.', 'udpp.svcWsjtLogLabel': 'WSJT-X logged QSO', 'udpp.svcWsjtLogHint': 'Announces each logged QSO on the WSJT-X UDP interface — both messages WSJT-X itself sends. For any logger that listens there rather than for plain-text ADIF (Logger32’s additional UDP sockets, for one).', 'udpp.svcDbLabel': 'ADIF Message', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.svcPstLabel': 'PstRotator frequency', 'udpp.svcPstHint': 'Sends the rig frequency as whenever it changes — set PstRotatorAz tracker to DXLog.net (default port 12040).', 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (freq + mode)', 'udpp.svcN1mmRadioHint': 'Broadcasts the rig frequency/mode as N1MM Logger+ RadioInfo XML on every change — consumed by PstRotator (N1MM tracker) and many other tools.', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'Connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save', - 'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'Paper QSL sent status', 'fltb.fQslSentDate': 'Paper QSL sent date', 'fltb.fQslRcvd': 'Paper QSL received status', 'fltb.fQslRcvdDate': 'Paper QSL received date', 'fltb.fQslSentVia': 'QSL sent via', 'fltb.fQslRcvdVia': 'QSL rcvd via', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent status', 'fltb.fLotwSentDate': 'LoTW sent date', 'fltb.fLotwRcvd': 'LoTW received status', 'fltb.fLotwRcvdDate': 'LoTW received date', 'fltb.fEqslSent': 'eQSL sent status', 'fltb.fEqslSentDate': 'eQSL sent date', 'fltb.fEqslRcvd': 'eQSL received status', 'fltb.fEqslRcvdDate': 'eQSL received date', 'fltb.fQrzSent': 'QRZ.com sent status', 'fltb.fQrzSentDate': 'QRZ.com sent date', 'fltb.fQrzRcvd': 'QRZ.com received status', 'fltb.fQrzRcvdDate': 'QRZ.com received date', 'fltb.fClublogSent': 'Club Log sent status', 'fltb.fClublogSentDate': 'Club Log sent date', 'fltb.fHrdlogSent': 'HRDLog sent status', 'fltb.fHrdlogSentDate': 'HRDLog sent date', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Station callsign (my call)', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opIn': 'is one of', 'fltb.opNotIn': 'is none of', 'fltb.listPh': '2m, 70cm — comma separated', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close', + 'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'Paper QSL sent status', 'fltb.fQslSentDate': 'Paper QSL sent date', 'fltb.fQslRcvd': 'Paper QSL received status', 'fltb.fQslRcvdDate': 'Paper QSL received date', 'fltb.fQslSentVia': 'QSL sent via', 'fltb.fQslRcvdVia': 'QSL rcvd via', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent status', 'fltb.fLotwSentDate': 'LoTW sent date', 'fltb.fLotwRcvd': 'LoTW received status', 'fltb.fLotwRcvdDate': 'LoTW received date', 'fltb.fEqslSent': 'eQSL sent status', 'fltb.fEqslSentDate': 'eQSL sent date', 'fltb.fEqslRcvd': 'eQSL received status', 'fltb.fEqslRcvdDate': 'eQSL received date', 'fltb.fQrzSent': 'QRZ.com sent status', 'fltb.fQrzSentDate': 'QRZ.com sent date', 'fltb.fQrzRcvd': 'QRZ.com received status', 'fltb.fQrzRcvdDate': 'QRZ.com received date', 'fltb.fClublogSent': 'Club Log sent status', 'fltb.fClublogSentDate': 'Club Log sent date', 'fltb.fHrdlogSent': 'HRDLog sent status', 'fltb.fHrdlogSentDate': 'HRDLog sent date', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Station callsign (my call)', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opIn': 'is one of', 'fltb.opNotIn': 'is none of', 'fltb.listPh': '2m, 70cm — comma separated', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.presetSaved': 'Filter “{name}” saved', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close', 'detp.propAS': 'Aircraft Scatter', 'detp.propAUR': 'Aurora', 'detp.propAUE': 'Aurora-E', 'detp.propBS': 'Back Scatter', 'detp.propEME': 'Earth-Moon-Earth', 'detp.propES': 'Sporadic E', 'detp.propFAI': 'Field Aligned Irregularities', 'detp.propF2': 'F2 Reflection', 'detp.propGWAVE': 'Ground Wave', 'detp.propINTERNET': 'Internet-assisted', 'detp.propION': 'Ionoscatter', 'detp.propLOS': 'Line of Sight', 'detp.propMS': 'Meteor Scatter', 'detp.propRPT': 'Terrestrial / atmospheric repeater', 'detp.propRS': 'Rain Scatter', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-Equatorial', 'detp.propTR': 'Tropospheric Ducting', 'detp.pathShort': 'Short Path', 'detp.pathLong': 'Long Path', 'detp.pathGrayline': 'Grayline', 'detp.pathOther': 'Other', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Awards', 'detp.tabMy': 'My', 'detp.tabExtended': 'Extended', 'detp.statePref': 'State / pref', 'detp.county': 'County', 'detp.newCounty': 'NEW', 'detp.newCountyTip': 'County never worked before', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ', 'detp.ituZone': 'ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimuth LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Address', 'detp.qslMessage': 'QSL message', 'detp.qslVia': 'QSL via (manager)', 'detp.detected': 'Detected — this contact will count for:', 'detp.ambiguous': 'Ambiguous — pick one:', 'detp.azimuth': 'Azimuth (°)', 'detp.elevation': 'Elevation (°)', 'detp.txPower': 'TX power (W)', 'detp.satelliteMode': 'Satellite mode', 'detp.antPath': 'Ant. path', 'detp.propagation': 'Propagation', 'detp.rig': 'Rig', 'detp.antenna': 'Antenna', 'detp.satName': 'Satellite name', 'detp.contestId': 'Contest ID', 'detp.rcvdExchangePh': 'rcvd exchange', 'detp.sentExchangePh': 'sent exchange', 'detp.contactedEmail': 'Contacted email', 'detp.contactedWeb': 'Website', // Awards (ref picker / ref selector / awards panel / award editor) 'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.', 'awrs.new': 'NEW', 'awrs.newRefHint': 'Never worked before', 'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.', - 'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.modePhone': 'Phone', 'awp.modeDigital': 'Digital', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.prefixCol': 'Prefix', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.refsNarrow': '…and {n} more — type in the box to narrow the list.', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found — every contact in this award’s scope already carries a reference.', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.', + 'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.modePhone': 'Phone', 'awp.modeDigital': 'Digital', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.prefixCol': 'Prefix', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.refsNarrow': '…and {n} more — type in the box to narrow the list.', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found — every contact in this award’s scope already carries a reference.', 'awp.dateUtc': 'Date (UTC)', 'awp.paperQslTip': 'Open in the QSL Manager — Paper QSL', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.', 'awed.addCountry': 'Add country…', 'awed.refValidFrom': 'Valid from', 'awed.refValidTo': 'Valid to', 'awed.refValidHint': 'A QSO only counts for this reference if it was made inside this window. Leave empty if the reference has always existed.', 'awed.refValidHintAward': 'Leave empty to use the award’s own window ({from} → {to}).', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.exportedOneTo': '{code} exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.fieldNone': '— no field (references assigned by hand) —', 'awed.fieldNoneHint': 'Nothing is matched automatically: this award counts only the references you assign to a contact yourself, from the QSO’s reference editor. For an award like WWBOTA, which has no ADIF field of its own anywhere.', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.oneRef': 'One reference per QSO', 'awed.oneRefHint': 'When several references match the same contact, assign none and list it under missing references. For awards where two entries can share a description — two DOKs called Gießen — picking one at random would write the wrong one into the log.', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Fallback searches', 'awed.orAlsoMatch': '— tried in order, only if nothing matched yet; first hit wins', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefix': 'Prefix', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.customLabel': 'Custom source', 'awed.customField': 'ADIF field', 'awed.customValue': 'Value(s) that confirm (optional)', 'awed.customHint': 'For confirmations OpsLog has no column of its own for. Name any QSO field or ADIF tag — APP_OPSLOG_QSL_RCVD for a card received through OpsLog, or a tag a club list stamped on import. Leave the value empty and ANY non-empty content confirms (the OpsLog marker stores a date, not Y/N); give a comma-separated list to require one of them. A custom source naming no field confirms nothing.', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference', 'awed.refCodeTip': 'The reference number. Change it to renumber this reference: everything else about it is kept, and the log is refreshed.', 'awed.refCodeEmpty': 'A reference needs a number.', 'awed.updateAvailable': 'An updated version of this award is available', 'awed.updateOverwrites': 'You have modified this award, so the update was not applied. Taking it replaces your definition and reference list.', 'awed.updateApply': 'Update', 'awed.updateKeepMine': 'Keep mine', 'awed.tabTest': 'Test', 'awed.testCallsign': 'Test against callsign', 'awed.testRun': 'Test', 'awed.testSavedOnly': 'Tests the SAVED award — save your changes first.', 'awed.testNoMatch': 'no match', 'awed.testOutOfScope': 'QSO out of scope — no rule was run.', 'awed.testSkipped': 'not run: an earlier rule already matched', 'awed.testFieldValue': 'Field', 'awed.testEmptyField': 'empty', 'awed.testNoCandidate': 'produced no candidate', 'awed.testManual': 'Manual override', 'awed.testAmbiguous': 'Ambiguous', 'awed.testAmbiguousHint': '— none kept, this award allows one reference per QSO. Assign the right one by hand.', 'awed.testSameAs': '+{n} other QSO(s), same result', @@ -817,7 +817,7 @@ const fr: Dict = { 'rotor.azTitle': 'Azimut 0-359, Entrée pour lancer le rotor', 'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.testOkRead': 'Connecté — le contrôleur a répondu avec son azimut. Rien n’a bougé : ce test ne fait que lire la position.', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.dual': 'Deux rotors', 'rot.rotor1': 'Rotor 1', 'rot.rotor2': 'Rotor 2', 'rot.name': 'Nom', 'rot.antenna': 'Antenne', 'rot.antenna1': 'Antenne rotor 1', 'rot.antenna2': 'Antenne rotor 2', 'rot.name1': 'Nom du rotor 1', 'rot.name2': 'Nom du rotor 2', 'rot.antStd': 'Antenne standard', 'rot.antMotor': 'Motorisée (Ultrabeam/SteppIR)', 'rot.add': 'Ajouter un rotor', 'rot.remove': 'Supprimer le rotor', 'rot.none': 'Aucun rotor configuré. Cliquez sur « Ajouter un rotor ».', 'rot.rgDual': 'Ce Rotator Genius pilote deux rotors (en ajoute un second)', 'rot.testRotor1': 'Tester rotor 1', 'rot.testRotor2': 'Tester rotor 2', 'rot.dualHint': 'Deux rotors indépendants de n\'importe quel type — par exemple un ARCO et un Rotator Genius côte à côte. Configurez chacun ci-dessous ; OpsLog affiche un sélecteur Rotor 1/2 sur le compas pour choisir celui qu\'il tourne et affiche. Réglez chaque rotor sur « Motorisée » pour que les tracés de boom/diagramme (inverse, bidirectionnel) n\'apparaissent que pour celui qui porte l\'Ultrabeam/SteppIR. (Pour un seul Rotator Genius pilotant deux rotors, réglez les deux sur Rotator Genius avec le même hôte et le rotator n° 1 et 2.)', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à tout contrôleur réglé sur Yaesu GS-232A — sans PstRotator. microHAM ARCO : régler Config → LAN → CONTROL PROTOCOL (ou USB CONTROL PROTOCOL) sur « Yaesu GS-232A » ; en USB la vitesse est sans importance. ERC (Easy Rotor Control, ERC Mini compris) : son émulation DOIT être réglée sur GS-232 — un ERC laissé en Hy-Gain DCU-1 parle un autre jeu de commandes et ne répondra pas — puis choisir son port COM et la même vitesse que dans la configuration de l'ERC.", 'rot.dcu1Hint': "Parle le jeu de commandes Hy-Gain DCU-1 (RotorCard DXA, Idiom Press Rotor-EZ, Green Heron). Connexion via le port COM du contrôleur (un DCU-1 est à 4800 bauds ; RotorCard/Green Heron peuvent différer — reprendre la vitesse du contrôleur) ou en TCP via un pont série-sur-IP. Azimut uniquement, pas d'élévation. Nouveau pilote — signalez si votre contrôleur nécessite une autre commande ou vitesse.", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.", 'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).", - 'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.motorBands': 'Bandes couvertes', 'hw.motorStep': 'Pas de réaccord', 'hw.motorBandFreqHint': "Fréquence sur laquelle chaque bouton de bande accorde l'antenne (kHz). Laisser vide pour le défaut affiché.", 'hw.motorFollow': "Suivre la fréquence du rig (accord auto de l'antenne)", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal', + 'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorComUb': '(2e port du câble USB)', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.motorBands': 'Bandes couvertes', 'hw.motorStep': 'Pas de réaccord', 'hw.motorBandFreqHint': "Fréquence sur laquelle chaque bouton de bande accorde l'antenne (kHz). Laisser vide pour le défaut affiché.", 'hw.motorFollow': "Suivre la fréquence du rig (accord auto de l'antenne)", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal', 'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Un autre logiciel atteint alors la radio à travers OpsLog — avec tous les backends, pas seulement les natifs. Pour Hamlib, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532 ; pour TCI, pointez le logiciel sur 127.0.0.1:40001.", 'cat.shareProto': 'Protocole', 'cat.shareRigctl': 'Hamlib NET rigctl — WSJT-X, JTDX, MSHV, Log4OM', 'cat.shareTci': 'TCI — Expert Electronics', 'cat.shareTciClash': 'Le backend CAT est aussi en TCI : ExpertSDR occupe probablement déjà le port 40001 sur ce PC. Donnez un autre port au serveur, ou partagez en Hamlib.', 'cat.sharePort': 'Port de partage', 'cat.pttKey': 'Activer la touche PTT', 'cat.pttKeyPress': 'Appuyez sur une touche…', 'cat.pttKeyNone': 'Cliquez pour définir une touche', 'cat.pttKeyClear': 'Effacer', 'cat.pttKeyToggle': 'Mode bascule (appui = émission, nouvel appui = arrêt)', 'cat.pttKeyHint': "Quand OpsLog a le focus, cette touche passe la radio en émission — maintenue par défaut (relâcher pour arrêter), ou verrouillée en mode bascule. Elle utilise la méthode PTT de Audio → PTT (CAT / RTS / DTR), avec repli sur le CAT. Choisissez une touche que vous ne tapez jamais en journalisant (ex. Pause, Arrêt défil., ou une pédale mappée dessus) — OpsLog l'intercepte pour qu'elle n'atterrisse pas dans un champ.", 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Port CAT Xiegu', 'cat.xieguBaudHint': 'Doit correspondre au menu de la radio (G90 par défaut : 19200).', 'cat.xieguAddrHint': "Adresse CI-V d'usine de la famille G90/X6100 : 0x70.", 'cat.xieguPTTLine': 'Passage en \u00e9mission', 'cat.xieguPTTCiv': 'Commande CI-V', 'cat.xieguPTTHint': 'Un G90 ne passe pas en \u00e9mission sur la commande CI-V : les interfaces comme le DE-19 le pilotent par RTS ou DTR. Choisissez la ligne de la v\u00f4tre \u2014 c\u2019est aussi ce qui permet \u00e0 WSJT-X d\u2019\u00e9mettre via le partage CAT.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, réseau)', 'cat.optElecraft': 'Elecraft K3/K4 (USB, réseau)', 'cat.elecraftHint': 'Les modes numériques passent automatiquement en DATA A (MD6+DT0) — le sous-mode dont l’audio FT8 a besoin.', 'cat.civTrace': 'Journaliser le protocole CAT', 'cat.civTraceHint': '\u00c9crit dans le journal chaque trame CAT \u00e9chang\u00e9e avec la radio \u2014 CI-V en hexad\u00e9cimal, Kenwood en texte. Pour signaler une radio qui r\u00e9pond de travers \u2014 un bouton qui fait autre chose, une fr\u00e9quence qui saute. Valable pour la session seulement : c\u2019est un diagnostic, et le journal grossit vite.', 'cat.kenwoodPort': 'Port COM Kenwood', 'cat.kenwoodPortHint': 'Le port s\u00e9rie CAT/USB de la radio (TS-590, TS-890, TS-990, TS-2000, ainsi que les Elecraft K3/K4 qui parlent le m\u00eame dialecte).', 'cat.kenwoodBaudHint': 'Doit correspondre au MENU de la radio : un TS-590 sort d\u2019usine \u00e0 9600, un TS-890 \u00e0 115200.', 'cat.kenwoodHost': 'Ou par le r\u00e9seau (h\u00f4te:port)', 'cat.kenwoodHostHint': 'Un pont s\u00e9rie-r\u00e9seau \u2014 ser2net, un bo\u00eetier Ethernet-s\u00e9rie, un Raspberry Pi pr\u00e8s de la radio. S\u2019il est rempli, il est utilis\u00e9 \u00c0 LA PLACE du port COM ci-dessus. Il ne s\u2019agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood et n\u2019est pas prise en charge.', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.lowerLines': 'Abaisser les lignes DTR et RTS à la connexion', 'cat.lowerLinesHint': 'Si votre radio est toujours en émission, cochez ceci.', 'cat.kwDataMode': 'Modes data (FT8/PSK…)', 'cat.kwDataUsb': 'USB', 'cat.kwDataMd6': 'DATA A — MD6+DT0 (Elecraft K3/K4)', 'cat.kwDataKeep': 'Ne pas changer le mode de la radio', 'cat.kwDataHint': "Ce qu'OpsLog règle sur la radio pour un mode data. Aucune commande unique ne convient à toutes : un Elecraft K3/K4 veut DATA (MD6) ; sur un TS-590SG/TS-990S le mode data est un modificateur d'USB réglé sur la radio, choisissez donc USB ou, plus sûr, « Ne pas changer » et passez la radio en DATA vous-même. En MD6 un Kenwood classique (TS-590/990) tomberait en FSK/RTTY — à ne pas utiliser là.", 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V réseau)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 puissance par bande et par mode', 'flxpw.hint': 'Puissance d\u2019\u00e9mission appliqu\u00e9e au changement de bande ou de mode. Laissez une case vide pour ne pas toucher \u00e0 la puissance.', 'flxpw.band': 'Bande', 'flxb.title': 'FlexRadio \u2014 antennes et puissance par bande', 'flxb.hint': 'Les antennes sont appliqu\u00e9es au changement de bande ; la puissance au changement de bande ou de mode. Laissez une case de puissance vide pour ne pas y toucher.', 'flxb.rxAnt': 'Antenne RX', 'flxb.txAnt': 'Antenne TX', 'flxb.noRadio': 'Aucune antenne remont\u00e9e \u2014 connectez le FlexRadio, puis rouvrez ce panneau.', 'flxpw.phone': 'Phonie', 'flxpw.digi': 'Num\u00e9rique', 'flxpw.noBands': 'Aucune bande configur\u00e9e \u2014 ajoutez-les dans Listes \u2192 Bandes.', 'flxpw.saved': 'Enregistr\u00e9', 'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau', 'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.", @@ -905,17 +905,17 @@ const fr: Dict = { 'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope −50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.bandCurrent': 'Le poste est sur {b} m', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?', 'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal', 'qrz.openTitle': 'Ouvrir {call} sur QRZ.com', - 'altm.filterPh': 'Filtrer…', 'altm.noMatch': 'aucun résultat', 'altm.noneAll': 'aucune sélection = TOUT', 'altm.nSelected': '{n} sélectionné(s)', 'altm.giveName': 'Donne un nom à la règle', 'altm.deleteConfirm': "Supprimer l'alerte « {name} » ?", 'altm.title': 'Gestion des alertes', 'altm.desc': 'Alerte quand un spot correspond à une règle. Filtres vides = TOUT ; les filtres définis sont combinés par ET (ex. France + 20m = stations françaises sur 20m).', 'altm.rules': 'Règles', 'altm.noRules': 'Aucune règle — clique sur +', 'altm.emailTo': "E-mail d'alerte à", 'altm.selectOrCreate': 'Sélectionne ou crée une règle.', 'altm.tabDef': 'Définition', 'altm.tabCall': 'Indicatif / DXCC', 'altm.tabBandMode': 'Bande / Mode', 'altm.tabOrigin': 'Origine', 'altm.ruleName': 'Nom de la règle', 'altm.alertEnabled': 'Alerte activée', 'altm.againAfter': 'Réalerter après (min)', 'altm.againHint': '0 = une fois/session · -1 = toujours', 'altm.actions': 'Actions', 'altm.visual': 'Visuel', 'altm.sound': 'Son', 'altm.email': 'E-mail', 'altm.skipWorked': 'Ignorer les indicatifs déjà contactés (même bande + mode)', 'altm.callsigns': 'Indicatifs (un par ligne, jokers : IW3*, */P)', 'altm.countries': 'Pays (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bandes', 'altm.modes': 'Modes', 'altm.spotterCall': 'Indicatif du spotteur (joker)', 'altm.spotterCallPh': 'ex. F* ou DL1ABC', 'altm.spotterContinents': 'Continents du spotteur', 'altm.spotterCountries': 'Pays du spotteur', 'altm.delete': 'Supprimer', 'altm.saveRule': 'Enregistrer la règle', 'altm.close': 'Fermer', + 'altm.filterPh': 'Filtrer…', 'altm.noMatch': 'aucun résultat', 'altm.noneAll': 'aucune sélection = TOUT', 'altm.nSelected': '{n} sélectionné(s)', 'altm.saved': 'Alerte « {name} » enregistrée', 'altm.deleted': 'Alerte « {name} » supprimée', 'altm.giveName': 'Donne un nom à la règle', 'altm.deleteConfirm': "Supprimer l'alerte « {name} » ?", 'altm.title': 'Gestion des alertes', 'altm.desc': 'Alerte quand un spot correspond à une règle. Filtres vides = TOUT ; les filtres définis sont combinés par ET (ex. France + 20m = stations françaises sur 20m).', 'altm.rules': 'Règles', 'altm.noRules': 'Aucune règle — clique sur +', 'altm.emailTo': "E-mail d'alerte à", 'altm.selectOrCreate': 'Sélectionne ou crée une règle.', 'altm.tabDef': 'Définition', 'altm.tabCall': 'Indicatif / DXCC', 'altm.tabBandMode': 'Bande / Mode', 'altm.tabOrigin': 'Origine', 'altm.ruleName': 'Nom de la règle', 'altm.alertEnabled': 'Alerte activée', 'altm.againAfter': 'Réalerter après (min)', 'altm.againHint': '0 = une fois/session · -1 = toujours', 'altm.actions': 'Actions', 'altm.visual': 'Visuel', 'altm.sound': 'Son', 'altm.email': 'E-mail', 'altm.skipWorked': 'Ignorer les indicatifs déjà contactés (même bande + mode)', 'altm.callsigns': 'Indicatifs (un par ligne, jokers : IW3*, */P)', 'altm.countries': 'Pays (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bandes', 'altm.modes': 'Modes', 'altm.spotterCall': 'Indicatif du spotteur (joker)', 'altm.spotterCallPh': 'ex. F* ou DL1ABC', 'altm.spotterContinents': 'Continents du spotteur', 'altm.spotterCountries': 'Pays du spotteur', 'altm.delete': 'Supprimer', 'altm.saveRule': 'Enregistrer la règle', 'altm.close': 'Fermer', 'spm.callRequired': 'Indicatif requis', 'spm.freqRequired': 'Fréquence (kHz) requise', 'spm.title': 'Envoyer un spot DX', 'spm.callsign': 'Indicatif', 'spm.callPh': 'Indicatif DX', 'spm.frequency': 'Fréquence (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'ex. CW · TNX QSO', 'spm.latestQsos': 'Derniers QSO', 'spm.spotSent': 'Spot envoyé ✓', 'spm.masterCluster': 'Cluster maître', 'spm.cancel': 'Annuler', 'spm.sending': 'Envoi…', 'spm.sendSpot': 'Envoyer le spot', 'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Déjà contacté', 'ncp.wbHint': 'Cliquer une station (on air ou roster) pour voir les QSO précédents', 'ncp.wbNone': 'Aucun QSO précédent avec', 'ncp.wbFirst': 'premier', 'ncp.wbLast': 'dernier', 'ncp.wbResize': 'Glisser pour redimensionner', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'ordre de passage du micro · ⬆⬇ pour réordonner · double-clic → éditer · « Logger & terminer »', 'ncp.moveUp': "Monter dans l'ordre de passage", 'ncp.moveDown': "Descendre dans l'ordre de passage", 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.logAll': 'Logger tout le monde ({n})', 'ncp.logAllConfirm': 'Logger les {n} station(s) on air dans le logbook ?', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET', 'udpp.relayInstead': 'Pour un commutateur d’antennes ou une carte de relais, préférez Station Control → relais : il tient l’état, relit les cartes au démarrage et ne recommute pas quand vous bougez dans la même bande. Un commutateur fait main s’y déclare en type « Relais HTTP ».', 'udpp.svcCustomLabel': 'Message personnalisé', 'udpp.svcCustomHint': 'Vous choisissez ce qui le déclenche et ce qu’il dit. Datagramme UDP ou requête HTTP — cette dernière est la façon dont se pilotent la plupart des commutateurs d’antennes.', 'udpp.trigger': 'Déclencheur', 'udpp.trgBand': 'Changement de bande (radio)', 'udpp.trgQso': 'QSO enregistré', 'udpp.trgRotator': 'Commande de rotor', 'udpp.trgLookup': 'Recherche d’indicatif', 'udpp.transport': 'Envoi', 'udpp.transportUdp': 'Message UDP', 'udpp.transportUrl': 'URL (HTTP GET)', 'udpp.url': 'URL', 'udpp.urlHint': 'Les valeurs sont encodées pour l’URL. Les identifiants peuvent s’écrire http://user:pass@hôte/… — stockés tels quels, à réserver à votre réseau local.', 'udpp.template': 'Message', 'udpp.lineEnd': 'Fin de ligne', 'udpp.lineEndNone': 'Aucune', 'udpp.fieldsAvailable': 'Champs de ce déclencheur', 'udpp.fieldsHint': 'Tout autre champ rendra du vide.', 'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcWsjtRelayLabel': 'Relayer le flux WSJT-X', 'udpp.svcWsjtRelayHint': 'Réémet chaque datagramme reçu de WSJT-X / JTDX / MSHV, octet pour octet, vers un autre logiciel — JTAlert, GridTracker, un second carnet. L’émetteur ne parle qu’à une seule adresse : c’est ce qui permet de les faire tourner à côté d’OpsLog. À pointer sur le port de l’AUTRE logiciel, jamais sur un port d’écoute d’OpsLog.', 'udpp.svcWsjtLogLabel': 'QSO enregistré WSJT-X', 'udpp.svcWsjtLogHint': 'Annonce chaque QSO enregistré sur l’interface UDP WSJT-X — les deux messages que WSJT-X émet lui-même. Pour tout logger qui écoute là plutôt que l’ADIF en texte brut (les sockets UDP supplémentaires de Logger32, par exemple).', 'udpp.svcDbLabel': "ADIF Message", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.svcPstLabel': 'Fréquence PstRotator', 'udpp.svcPstHint': "Envoie la fréquence du poste en à chaque changement — règle le tracker de PstRotatorAz sur DXLog.net (port 12040 par défaut).", 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (fréq + mode)', 'udpp.svcN1mmRadioHint': "Diffuse la fréquence/mode du poste en XML RadioInfo N1MM Logger+ à chaque changement — lu par PstRotator (tracker N1MM) et beaucoup d'autres outils.", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer', - 'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'Paper QSL sent status', 'fltb.fQslSentDate': 'Paper QSL sent date', 'fltb.fQslRcvd': 'Paper QSL received status', 'fltb.fQslRcvdDate': 'Paper QSL received date', 'fltb.fQslSentVia': 'QSL envoyée via', 'fltb.fQslRcvdVia': 'QSL reçue via', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent status', 'fltb.fLotwSentDate': 'LoTW sent date', 'fltb.fLotwRcvd': 'LoTW received status', 'fltb.fLotwRcvdDate': 'LoTW received date', 'fltb.fEqslSent': 'eQSL sent status', 'fltb.fEqslSentDate': 'eQSL sent date', 'fltb.fEqslRcvd': 'eQSL received status', 'fltb.fEqslRcvdDate': 'eQSL received date', 'fltb.fQrzSent': 'QRZ.com sent status', 'fltb.fQrzSentDate': 'QRZ.com sent date', 'fltb.fQrzRcvd': 'QRZ.com received status', 'fltb.fQrzRcvdDate': 'QRZ.com received date', 'fltb.fClublogSent': 'Club Log sent status', 'fltb.fClublogSentDate': 'Club Log sent date', 'fltb.fHrdlogSent': 'HRDLog sent status', 'fltb.fHrdlogSentDate': 'HRDLog sent date', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Station callsign (my call)', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opIn': 'est parmi', 'fltb.opNotIn': 'n est pas parmi', 'fltb.listPh': '2m, 70cm — séparés par des virgules', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer', + 'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'Paper QSL sent status', 'fltb.fQslSentDate': 'Paper QSL sent date', 'fltb.fQslRcvd': 'Paper QSL received status', 'fltb.fQslRcvdDate': 'Paper QSL received date', 'fltb.fQslSentVia': 'QSL envoyée via', 'fltb.fQslRcvdVia': 'QSL reçue via', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent status', 'fltb.fLotwSentDate': 'LoTW sent date', 'fltb.fLotwRcvd': 'LoTW received status', 'fltb.fLotwRcvdDate': 'LoTW received date', 'fltb.fEqslSent': 'eQSL sent status', 'fltb.fEqslSentDate': 'eQSL sent date', 'fltb.fEqslRcvd': 'eQSL received status', 'fltb.fEqslRcvdDate': 'eQSL received date', 'fltb.fQrzSent': 'QRZ.com sent status', 'fltb.fQrzSentDate': 'QRZ.com sent date', 'fltb.fQrzRcvd': 'QRZ.com received status', 'fltb.fQrzRcvdDate': 'QRZ.com received date', 'fltb.fClublogSent': 'Club Log sent status', 'fltb.fClublogSentDate': 'Club Log sent date', 'fltb.fHrdlogSent': 'HRDLog sent status', 'fltb.fHrdlogSentDate': 'HRDLog sent date', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Station callsign (my call)', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opIn': 'est parmi', 'fltb.opNotIn': 'n est pas parmi', 'fltb.listPh': '2m, 70cm — séparés par des virgules', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.presetSaved': 'Filtre « {name} » enregistré', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer', 'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.newCounty': 'NOUV', 'detp.newCountyTip': 'Comté jamais contacté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'CQ', 'detp.ituZone': 'ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via (manager)', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.ambiguous': 'Ambigu — choisissez :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact', 'detp.contactedWeb': 'Site web', 'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.', 'awrs.new': 'NEW', 'awrs.newRefHint': 'Jamais contactée', 'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.', - 'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.modePhone': 'Phonie', 'awp.modeDigital': 'Numérique', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.prefixCol': 'Préfixe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.refsNarrow': '…et {n} autres — tape dans le champ pour réduire la liste.', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': 'Aucun manque trouvé — tous les contacts dans le périmètre de ce diplôme portent déjà une référence.', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.', + 'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.modePhone': 'Phonie', 'awp.modeDigital': 'Numérique', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.prefixCol': 'Préfixe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.refsNarrow': '…et {n} autres — tape dans le champ pour réduire la liste.', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': 'Aucun manque trouvé — tous les contacts dans le périmètre de ce diplôme portent déjà une référence.', 'awp.dateUtc': 'Date (UTC)', 'awp.paperQslTip': 'Ouvrir dans le gestionnaire QSL — QSL papier', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.', 'awed.addCountry': 'Ajouter un pays…', 'awed.refValidFrom': 'Valide à partir du', 'awed.refValidTo': 'Valide jusqu’au', 'awed.refValidHint': 'Un QSO ne compte pour cette référence que s’il a été fait dans cette fenêtre. Laisser vide si la référence a toujours existé.', 'awed.refValidHintAward': 'Laisser vide pour utiliser la fenêtre du diplôme ({from} → {to}).', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.exportedOneTo': '{code} exporté vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.fieldNone': '— aucun champ (références attribuées à la main) —', 'awed.fieldNoneHint': 'Rien n’est reconnu automatiquement : ce diplôme ne compte que les références que tu attribues toi-même à un contact, depuis l’éditeur de références du QSO. Pour un diplôme comme WWBOTA, qui n’a de champ ADIF nulle part.', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.oneRef': 'Une seule référence par QSO', 'awed.oneRefHint': 'Quand plusieurs références correspondent au même contact, n'+'’en affecter aucune et le lister dans les références manquantes. Pour les diplômes où deux entrées partagent une description — deux DOK nommés Gießen — en choisir une au hasard inscrirait la mauvaise dans le journal.', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches de repli', 'awed.orAlsoMatch': "— essayées dans l'ordre, seulement si rien n'a encore été trouvé ; la première qui marche gagne", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefix': 'Préfixe', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.customLabel': 'Source personnalisée', 'awed.customField': 'Champ ADIF', 'awed.customValue': 'Valeur(s) qui confirment (facultatif)', 'awed.customHint': "Pour les confirmations dont OpsLog n'a pas de colonne dédiée. Indique n'importe quel champ de QSO ou balise ADIF — APP_OPSLOG_QSL_RCVD pour une carte reçue via OpsLog, ou une balise inscrite à l'import d'une liste de club. Laisse la valeur vide et TOUT contenu non vide confirme (le marqueur OpsLog stocke une date, pas un Y/N) ; mets une liste séparée par des virgules pour en exiger une. Une source personnalisée sans champ ne confirme rien.", 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence', 'awed.refCodeTip': 'Le numéro de la référence. Modifie-le pour la renuméroter : tout le reste est conservé, et le journal est rafraîchi.', 'awed.refCodeEmpty': 'Une référence a besoin d’un numéro.', 'awed.updateAvailable': 'Une nouvelle version de ce diplôme est disponible', 'awed.updateOverwrites': "Tu as modifié ce diplôme, la mise à jour n'a donc pas été appliquée. L'accepter remplacera ta définition et ta liste de références.", 'awed.updateApply': 'Mettre à jour', 'awed.updateKeepMine': 'Garder les miennes', 'awed.tabTest': 'Test', 'awed.testCallsign': 'Tester avec un indicatif', 'awed.testRun': 'Tester', 'awed.testSavedOnly': 'Teste le diplôme ENREGISTRÉ — enregistre tes modifications avant.', 'awed.testNoMatch': 'aucune correspondance', 'awed.testOutOfScope': "QSO hors périmètre — aucune règle n'a été exécutée.", 'awed.testSkipped': "non exécutée : une règle précédente a déjà trouvé", 'awed.testFieldValue': 'Champ', 'awed.testEmptyField': 'vide', 'awed.testNoCandidate': "n'a produit aucun candidat", 'awed.testManual': 'Référence forcée à la main', 'awed.testAmbiguous': 'Ambigu', 'awed.testAmbiguousHint': '— aucune retenue, ce diplôme n’admet qu’une référence par QSO. Affectez la bonne à la main.', 'awed.testSameAs': '+{n} autre(s) QSO, même résultat', diff --git a/gate_coverage_test.go b/gate_coverage_test.go new file mode 100644 index 0000000..2509a45 --- /dev/null +++ b/gate_coverage_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +// The build gate is only as good as the moments it runs at. +// +// It used to run at startup alone, on the profile active then — and a fresh +// install has NO callsign at that point. The operator typed theirs afterwards, +// so a denied call ran for the whole session and reached the telemetry, which +// reads the very same field. Every place that can make a denied callsign the +// ACTIVE one has to re-ask. +func TestCallGateRunsWhereverTheActiveCallsignChanges(t *testing.T) { + src, err := os.ReadFile("app.go") + if err != nil { + t.Fatal(err) + } + for _, fn := range []string{ + "func (a *App) startup(ctx context.Context) {", // launch + "func (a *App) SaveStationSettings(s StationSettings) error {", // the call is entered here + "func (a *App) SaveProfile(p profile.Profile) (profile.Profile, error) {", + "func (a *App) ActivateProfile(id int64) error {", + } { + body := funcBody(t, string(src), fn) + if !strings.Contains(body, "enforceCallGate(") { + t.Errorf("%s can change the active callsign but never re-checks the gate", fn) + } + } +} + +// The hashes are the whole mechanism: a truncated or re-cased entry silently +// matches nothing, and nothing in the running program would ever say so. +func TestDeniedCallHashesAreWellFormed(t *testing.T) { + if len(deniedCallHashes) == 0 { + t.Fatal("the deny list is empty — the gate has stopped gating") + } + for h := range deniedCallHashes { + if len(h) != 64 { + t.Errorf("hash %q is %d chars, want 64", h, len(h)) + } + if h != strings.ToLower(h) { + t.Errorf("hash %q is not lower-case, so it can never match", h) + } + } +} + +// A slashed call must reduce to the real one, or /P is a way round the gate. +func TestDenyBaseCall(t *testing.T) { + for in, want := range map[string]string{ + "f4xyz": "F4XYZ", + " F4XYZ ": "F4XYZ", + "F4XYZ/P": "F4XYZ", + "TM/F4XYZ": "F4XYZ", + "F4XYZ/MM": "F4XYZ", + "": "", + } { + if got := denyBaseCall(in); got != want { + t.Errorf("denyBaseCall(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/db/roles.go b/internal/db/roles.go index 055d858..391e3e7 100644 --- a/internal/db/roles.go +++ b/internal/db/roles.go @@ -153,6 +153,16 @@ func pruneForeignTables(conn *sql.DB, role Role, label string) { if role != RoleLogbook || conn == nil { return } + // Once per database, recorded like a migration. + // + // The check itself is a COUNT(*) per settings table — twelve round trips, + // which is nothing locally and is paid on EVERY open of a remote MySQL + // logbook, including every profile switch. There is nothing to find after the + // first pass: the role filter means no settings table is ever created in a + // logbook again. + if _, err := conn.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, prunedMarker); err != nil { + return // already recorded (primary key), or the table is not writable + } dropped := 0 for _, t := range settingsTables { var n int @@ -175,6 +185,12 @@ func pruneForeignTables(conn *sql.DB, role Role, label string) { } } +// prunedMarker records, in schema_migrations, that a logbook has had its unused +// settings tables removed. Named like a migration and stored beside them because +// that is exactly what it is: a one-off schema step, and the applied set is +// already read in a single query at every open. +const prunedMarker = "_opslog_pruned_settings_tables" + // quoteIdent quotes one of OUR OWN table names for either dialect. Backticks // work in MySQL and in SQLite alike, which is why the migrations use them. func quoteIdent(s string) string { return "`" + s + "`" } diff --git a/internal/db/roles_test.go b/internal/db/roles_test.go index 908ed83..5296027 100644 --- a/internal/db/roles_test.go +++ b/internal/db/roles_test.go @@ -3,6 +3,7 @@ package db import ( "path/filepath" "sort" + "strings" "testing" ) @@ -213,3 +214,70 @@ func TestDropAndRecreateQSOTable(t *testing.T) { t.Fatalf("EnsureQSOTable disturbed an existing table (err=%v n=%d)", err, n) } } + +// A brand-new logbook — the case of a fresh install, or the "New database" +// button — is right from the first open: the contacts and the migration ledger, +// nothing else. Pinned as an exact list so an accidentally unfiltered future +// migration shows up here rather than in an operator's phpMyAdmin. +func TestFreshLogbookHasOnlyContactTables(t *testing.T) { + path := filepath.Join(t.TempDir(), "fresh.db") + conn, err := OpenLogbook(path) + if err != nil { + t.Fatal(err) + } + rows, err := conn.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`) + if err != nil { + t.Fatal(err) + } + var got []string + for rows.Next() { + var n string + if err := rows.Scan(&n); err != nil { + t.Fatal(err) + } + got = append(got, n) + } + rows.Close() + conn.Close() + sort.Strings(got) + want := []string{"qso", "schema_migrations"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("fresh logbook holds %v, want %v", got, want) + } +} + +// The cleanup is a one-off, recorded like a migration: it must not be repeated +// at every connection. Twelve COUNT(*) round trips on a remote MySQL is a cost +// paid at every profile switch for something that can no longer be found. +func TestPruneRunsOnlyOnce(t *testing.T) { + path := filepath.Join(t.TempDir(), "once.db") + conn, err := Open(path) // full legacy schema + if err != nil { + t.Fatal(err) + } + conn.Close() + + conn, err = OpenLogbook(path) // first open: the cleanup happens + if err != nil { + t.Fatal(err) + } + var n int + if err := conn.QueryRow(`SELECT COUNT(*) FROM settings`).Scan(&n); err == nil { + t.Fatal("first open did not clean up") + } + // Put one back by hand. A second pass would remove it again; a cleanup that + // knows it is done leaves it alone. + if _, err := conn.Exec("CREATE TABLE `settings` (`key` TEXT PRIMARY KEY, value TEXT)"); err != nil { + t.Fatal(err) + } + conn.Close() + + conn, err = OpenLogbook(path) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + if err := conn.QueryRow(`SELECT COUNT(*) FROM settings`).Scan(&n); err != nil { + t.Fatal("the cleanup ran a second time — it is not recorded as done") + } +} diff --git a/internal/steppir/steppir.go b/internal/steppir/steppir.go index 1e7d5d0..2309d5b 100644 --- a/internal/steppir/steppir.go +++ b/internal/steppir/steppir.go @@ -30,6 +30,7 @@ import ( "io" "log" "net" + "strings" "sync" "time" @@ -126,7 +127,10 @@ type Client struct { pendingDirSet bool stopChan chan struct{} - running bool + // done is closed by the poll loop on its way out, so Stop can wait for the + // serial port to be genuinely released — see Stop. + done chan struct{} + running bool } func New(tr Transport) *Client { @@ -138,10 +142,20 @@ func New(tr Transport) *Client { func (c *Client) Start() error { c.running = true + c.done = make(chan struct{}) go c.pollLoop() return nil } +// Stop closes the link and WAITS for the poll loop to leave. +// +// Closing the port from another goroutine does not undo an open() the loop is +// already inside: that open returns a fresh handle which the loop then stores, +// and the stopped client goes on owning the serial port. The next client — a +// settings save, a profile switch — cannot open it, and the operator sees +// "Serial port busy" with no other program running. +// +// Bounded, so a device stuck in the driver cannot freeze a settings save. func (c *Client) Stop() { if !c.running { return @@ -154,6 +168,14 @@ func (c *Client) Stop() { c.conn = nil } c.connMu.Unlock() + if c.done == nil { + return + } + select { + case <-c.done: + case <-time.After(6 * time.Second): + log.Printf("steppir: poll loop did not exit within 6s — %s may stay busy a moment longer", c.target()) + } } // LastSetKHz returns the frequency last commanded, or 0. @@ -179,7 +201,14 @@ func (c *Client) open() (io.ReadWriteCloser, error) { if c.tr.COM == "" { return nil, fmt.Errorf("steppir: no serial port configured") } - p, err := serial.Open(c.tr.COM, &serial.Mode{BaudRate: c.tr.Baud}) + // 8N1 spelled out rather than left to the library defaults: a controller + // that answers nothing must not have a line format that depends on them. + p, err := serial.Open(c.tr.COM, &serial.Mode{ + BaudRate: c.tr.Baud, + DataBits: 8, + Parity: serial.NoParity, + StopBits: serial.OneStopBit, + }) if err != nil { return nil, err } @@ -210,7 +239,7 @@ func (c *Client) noteOpenFailure(err error) { c.connMu.Unlock() switch { case n <= openFailQuiet: - log.Printf("steppir: cannot open %s: %v (attempt %d)", c.target(), err, n) + log.Printf("steppir: cannot open %s: %v%s (attempt %d)", c.target(), err, portBusyHint(c.tr.Mode, c.tr.COM, err), n) case n == openFailQuiet+1: log.Printf("steppir: still cannot open %s — retrying every 2 s, further attempts will not be logged until it comes back", c.target()) } @@ -225,6 +254,18 @@ func (c *Client) target() string { } func (c *Client) pollLoop() { + // Signals Stop that the port is genuinely released. + defer func() { + c.connMu.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.connMu.Unlock() + if c.done != nil { + close(c.done) + } + }() ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() for { @@ -632,3 +673,16 @@ func (c *Client) Retract() error { } return c.writeCmd(buildSet(khz*1000, DirNormal, 'S')) } + +// portBusyHint turns "Serial port busy" into something actionable — see the +// identical note in internal/ultrabeam. +func portBusyHint(mode, com string, err error) string { + if mode != "serial" || err == nil { + return "" + } + msg := strings.ToLower(err.Error()) + if !strings.Contains(msg, "busy") && !strings.Contains(msg, "access is denied") && !strings.Contains(msg, "denied") { + return "" + } + return " — another program already has " + com + " open (the SteppIR control window, PstRotator, a terminal). A COM port has one owner: close the other program, then OpsLog can connect." +} diff --git a/internal/ultrabeam/stop_test.go b/internal/ultrabeam/stop_test.go new file mode 100644 index 0000000..166e4e3 --- /dev/null +++ b/internal/ultrabeam/stop_test.go @@ -0,0 +1,80 @@ +package ultrabeam + +import ( + "bufio" + "errors" + "strings" + "testing" + "time" +) + +// Stop must not return while the poll loop is still alive: on a serial link the +// loop owns the port, and a client that outlives its Stop is what turned every +// later connection into "Serial port busy". +func TestStopWaitsForPollLoop(t *testing.T) { + // A transport that cannot connect, so the loop spends its life in open() and + // the reconnect path — the state the real fault happened in. + c := New(Transport{Mode: "tcp", Host: "127.0.0.1", Port: 1}) + if err := c.Start(); err != nil { + t.Fatal(err) + } + done := c.done + time.Sleep(50 * time.Millisecond) + c.Stop() + select { + case <-done: + default: + t.Fatal("Stop returned while the poll loop was still running") + } + // Stopping twice must not panic on the closed channel. + c.Stop() +} + +func TestPortBusyHint(t *testing.T) { + // The Windows driver's own words, and the ones an operator has to act on. + if h := portBusyHint("serial", "COM13", errors.New("Serial port busy")); !strings.Contains(h, "COM13") { + t.Fatalf("no hint for a busy port: %q", h) + } + if h := portBusyHint("serial", "COM13", errors.New("Access is denied.")); h == "" { + t.Fatal("no hint for access denied") + } + // A port that simply is not there is a different problem, and saying "another + // program has it" would send the operator hunting for a program that is not + // running. + if h := portBusyHint("serial", "COM13", errors.New("The system cannot find the file specified.")); h != "" { + t.Fatalf("hinted at a busy port for a missing one: %q", h) + } + if h := portBusyHint("tcp", "", errors.New("connection refused")); h != "" { + t.Fatalf("serial hint on a TCP link: %q", h) + } +} + +// silentPort answers every read the way a serial port with nothing on the other +// end does: no bytes, no error. bufio turns a run of those into ErrNoProgress. +type silentPort struct{ writes int } + +func (s *silentPort) Read(p []byte) (int, error) { return 0, nil } +func (s *silentPort) Write(p []byte) (int, error) { s.writes++; return len(p), nil } +func (s *silentPort) Close() error { return nil } + +// A controller that never answers must fail ONCE, promptly, with a message that +// says so — not wedge the poll loop for minutes inside bufio's retry budget. +func TestSilentControllerFailsWithinTheReadTimeout(t *testing.T) { + c := New(Transport{Mode: "serial", COM: "COM_TEST", Baud: 9600}) + c.conn = &silentPort{} + c.reader = bufio.NewReader(c.conn) + + start := time.Now() + _, err := c.sendCommand(CMD_STATUS, nil) + elapsed := time.Since(start) + + if err == nil { + t.Fatal("a silent controller reported success") + } + if elapsed > 3*ubReadTimeout { + t.Fatalf("took %s to give up — the read deadline is not bounding the exchange", elapsed.Round(time.Millisecond)) + } + if !strings.Contains(err.Error(), "no reply") { + t.Fatalf("unhelpful error for a silent port: %v", err) + } +} diff --git a/internal/ultrabeam/ultrabeam.go b/internal/ultrabeam/ultrabeam.go index a258bdd..ff77a22 100644 --- a/internal/ultrabeam/ultrabeam.go +++ b/internal/ultrabeam/ultrabeam.go @@ -17,6 +17,7 @@ import ( "log" "net" "runtime" + "strings" "sync" "time" @@ -91,9 +92,19 @@ type Client struct { lastStatus *Status statusMu sync.RWMutex stopChan chan struct{} - running bool - seqNum byte - seqMu sync.Mutex + // done is closed by pollLoop as it exits, so Stop can WAIT for it. Without + // that wait the loop outlives the client that owns it, and on a serial link + // the zombie keeps the port open: every later client then fails with "Serial + // port busy" — forever, because the thing holding the port is us. + done chan struct{} + running bool + seqNum byte + seqMu sync.Mutex + + // First-exchange diagnostics — see armDiag. + diagMu sync.Mutex + diag bool + diagJunk []byte // Optimistic pattern direction kept until the antenna's status poll reports // it (or it ages out) — the motors take a second or two, and a stale poll in @@ -165,7 +176,15 @@ func (c *Client) open() (io.ReadWriteCloser, error) { if c.tr.COM == "" { return nil, fmt.Errorf("ultrabeam: no serial port configured") } - p, err := serial.Open(c.tr.COM, &serial.Mode{BaudRate: c.tr.Baud}) + // 8N1 spelled out. The library's zero values happen to mean the same + // thing today, but a controller that answers nothing is impossible to + // diagnose with a line count that depends on a default. + p, err := serial.Open(c.tr.COM, &serial.Mode{ + BaudRate: c.tr.Baud, + DataBits: 8, + Parity: serial.NoParity, + StopBits: serial.OneStopBit, + }) if err != nil { return nil, err } @@ -181,6 +200,27 @@ func (c *Client) open() (io.ReadWriteCloser, error) { return dialer.Dial("tcp", net.JoinHostPort(c.tr.Host, fmt.Sprintf("%d", c.tr.Port))) } +// diagNextExchange asks for the next command/reply to be dumped to the log. +// +// "It does not work" is unanswerable for a serial link, because the three +// possible causes look identical from the outside: nothing arrives (wrong port, +// dead cable, controller off), something arrives but is not our protocol (wrong +// baud), or a valid frame arrives and we misread it. One hex dump of the first +// exchange after each connect separates them, and costs two log lines per +// connection. +func (c *Client) armDiag() { + c.diagMu.Lock() + c.diag = true + c.diagJunk = c.diagJunk[:0] + c.diagMu.Unlock() +} + +func (c *Client) diagOn() bool { + c.diagMu.Lock() + defer c.diagMu.Unlock() + return c.diag +} + // target names what the client is talking to, for the log. func (c *Client) target() string { if c.tr.Mode == "serial" { @@ -219,10 +259,22 @@ func transientRead(err error) bool { func (c *Client) Start() error { c.running = true + c.done = make(chan struct{}) go c.pollLoop() return nil } +// Stop closes the link and WAITS for the poll loop to leave. +// +// The wait is the point. Closing the port from another goroutine does not undo +// an open() the loop is already inside: that open returns a fresh handle, the +// loop stores it, and the client that was told to stop goes on owning the serial +// port. The next client — a settings save, a profile switch — then cannot open +// it, and the operator sees "Serial port busy" with no other program running. +// +// Bounded, because a serial open can sit in the driver for a while and a stuck +// device must not freeze a settings save. If the deadline passes, the loop is +// still on its way out and the log says so. func (c *Client) Stop() { if !c.running { return @@ -236,9 +288,30 @@ func (c *Client) Stop() { c.conn = nil } c.connMu.Unlock() + + if c.done == nil { + return + } + select { + case <-c.done: + case <-time.After(6 * time.Second): + log.Printf("Ultrabeam: poll loop did not exit within 6s — %s may stay busy a moment longer", c.target()) + } } func (c *Client) pollLoop() { + // Signals Stop that the port is genuinely released. + defer func() { + c.connMu.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.connMu.Unlock() + if c.done != nil { + close(c.done) + } + }() ticker := time.NewTicker(2 * time.Second) // Increased from 500ms to 2s defer ticker.Stop() @@ -256,7 +329,7 @@ func (c *Client) pollLoop() { log.Printf("Ultrabeam: Not connected, attempting connection to %s...", c.target()) conn, err := c.open() if err != nil { - log.Printf("Ultrabeam: Connection failed: %v", err) + log.Printf("Ultrabeam: Connection failed: %v%s", err, portBusyHint(c.tr.Mode, c.tr.COM, err)) c.connMu.Unlock() // Mark as disconnected @@ -266,9 +339,25 @@ func (c *Client) pollLoop() { continue } c.conn = conn + // Stopped while open() was running? Let go of the port at once. + // Stop cannot interrupt an open already in flight, so without this + // the fresh handle is stored by a client that has been told to die, + // and it keeps the port — which is precisely the "Serial port busy" + // the next client then reports, forever. + select { + case <-c.stopChan: + conn.Close() + c.conn = nil // already closed; keep the deferred cleanup from closing it twice + c.connMu.Unlock() + return + default: + } c.reader = bufio.NewReader(c.conn) pollFails = 0 log.Printf("Ultrabeam: Connected to %s", c.target()) + // Dump the first exchange on this link. A connection that opens and + // then says nothing is the whole of what a serial user can see. + c.armDiag() } c.connMu.Unlock() @@ -502,6 +591,10 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) { seq := c.getNextSeq() packet := c.buildPacket(seq, cmd, data) + diag := c.diagOn() + if diag { + log.Printf("Ultrabeam: first exchange on %s — sending %d bytes: % X", c.target(), len(packet), packet) + } if _, err := c.conn.Write(packet); err != nil { return nil, fmt.Errorf("failed to write: %w", err) } @@ -509,6 +602,20 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) { // Read the reply with a timeout generous enough for a remote link. c.setReadTimeout(ubReadTimeout) buffer, err := c.readPacket() + if diag { + c.diagMu.Lock() + junk := append([]byte(nil), c.diagJunk...) + c.diag = false + c.diagMu.Unlock() + switch { + case err != nil && len(junk) == 0: + log.Printf("Ultrabeam: first exchange — NOTHING came back (%v). The controller is not answering on this port. Note that the USB cable presents TWO COM ports and only the SECOND one reaches the controller (at 19200 baud on the units seen so far); also check the cable and that the controller is on.", err) + case err != nil: + log.Printf("Ultrabeam: first exchange — %d bytes came back but no frame started (% X): %v. Bytes with no frame usually mean the wrong baud rate.", len(junk), junk, err) + default: + log.Printf("Ultrabeam: first exchange — reply %d bytes: % X (%d discarded before the frame: % X)", len(buffer), buffer, len(junk), junk) + } + } if err != nil { return nil, err } @@ -561,13 +668,53 @@ func (c *Client) drainStale() { // so a raw ETX only ever appears as the real terminator. Caller holds connMu and // has set a read deadline. func (c *Client) readPacket() ([]byte, error) { + // A DEADLINE for the whole frame, not a timeout per read. + // + // A silent serial port does not error: it returns (0, nil) on every read once + // its timeout expires, and bufio retries that a hundred times before giving up + // with ErrNoProgress. With a 4-second port timeout that is over six minutes of + // a poll loop frozen mid-exchange, logging nothing — which is exactly how a + // controller that never answered looked like a program that had hung. Short + // port timeouts, checked against a deadline here, turn it into one clear + // "nothing came back" after four seconds. + deadline := time.Now().Add(ubReadTimeout) + c.setReadTimeout(250 * time.Millisecond) + defer c.setReadTimeout(ubReadTimeout) var buffer []byte for { + // A stop must not have to wait out the deadline. Without this, tearing the + // client down mid-exchange took up to four seconds — long enough for the + // replacement client to find the port still held, and for Stop to give up + // waiting and say so. + select { + case <-c.stopChan: + return nil, fmt.Errorf("stopped") + default: + } + if time.Now().After(deadline) { + if len(buffer) == 0 { + return nil, fmt.Errorf("no reply within %s", ubReadTimeout) + } + return nil, fmt.Errorf("incomplete frame within %s (% X)", ubReadTimeout, buffer) + } b, err := c.reader.ReadByte() if err != nil { + // A quiet port between bytes is normal — go.bug.st returns (0, nil) on + // its timeout, which bufio eventually reports as ErrNoProgress. Only the + // deadline above decides that the exchange has failed. + if transientRead(err) { + continue + } return nil, fmt.Errorf("failed to read: %w", err) } if len(buffer) == 0 && b != STX { + // Kept, briefly, for the first exchange after a connect: what gets + // discarded here IS the diagnosis when the link is misconfigured. + c.diagMu.Lock() + if c.diag && len(c.diagJunk) < 32 { + c.diagJunk = append(c.diagJunk, b) + } + c.diagMu.Unlock() continue // resync to the start of a frame } buffer = append(buffer, b) @@ -725,3 +872,21 @@ func (c *Client) ModifyElement(elementNum int, lengthMm int) error { _, err := c.sendCommand(CMD_MODIFY_ELEM, data) return err } + +// portBusyHint turns "Serial port busy" into something actionable. +// +// A COM port has exactly one owner. The message the driver gives back says the +// port is busy and stops there, which reads like a fault in OpsLog — and the +// program actually holding it is usually the antenna manufacturer's own control +// window, sitting open on the same desktop. Naming that is the difference +// between a bug report and a five-second fix. +func portBusyHint(mode, com string, err error) string { + if mode != "serial" || err == nil { + return "" + } + msg := strings.ToLower(err.Error()) + if !strings.Contains(msg, "busy") && !strings.Contains(msg, "access is denied") && !strings.Contains(msg, "denied") { + return "" + } + return " — another program already has " + com + " open (the UltraBeam Controller window, PstRotator, a terminal). A COM port has one owner: close the other program, then OpsLog can connect." +} diff --git a/motor_bandfreq_test.go b/motor_bandfreq_test.go index 6c0b59d..ab6339f 100644 --- a/motor_bandfreq_test.go +++ b/motor_bandfreq_test.go @@ -76,3 +76,30 @@ func TestMotorTuneKHzForBandFallsBack(t *testing.T) { } } } + +// The Ultrabeam's USB link answers on 19200 and on nothing else — confirmed on +// hardware, where 9600 opened the port and returned not one byte. A speed with a +// single right answer must not be storable as anything else, whatever an older +// config or a hand-edited setting says. +func TestNormMotorBaud(t *testing.T) { + for _, tc := range []struct { + typ, transport string + in, want int + }{ + {"ultrabeam", "serial", 9600, 19200}, + {"ultrabeam", "serial", 0, 19200}, + {"ultrabeam", "serial", 115200, 19200}, + // Over TCP the speed is the adapter's business, not ours. + {"ultrabeam", "tcp", 9600, 9600}, + // The SteppIR controller really does run at several speeds. + {"steppir", "serial", 4800, 4800}, + {"steppir", "serial", 19200, 19200}, + // Nonsense falls back rather than reaching the driver. + {"steppir", "serial", 0, 9600}, + {"steppir", "serial", 999999, 9600}, + } { + if got := normMotorBaud(tc.typ, tc.transport, tc.in); got != tc.want { + t.Errorf("normMotorBaud(%q, %q, %d) = %d, want %d", tc.typ, tc.transport, tc.in, got, tc.want) + } + } +} diff --git a/motor_trackmode_test.go b/motor_trackmode_test.go index c702be3..57ea8bd 100644 --- a/motor_trackmode_test.go +++ b/motor_trackmode_test.go @@ -1,6 +1,10 @@ package main -import "testing" +import ( + "os" + "strings" + "testing" +) // The tracking mode decides how often a motorized antenna's elements run, so a // value that fails to parse must not silently become the most aggressive @@ -46,3 +50,36 @@ func TestBandForHzDrivesBandTracking(t *testing.T) { t.Errorf("30 m and 20 m both read %q — band mode would never re-tune between them", a) } } + +// A "Test connection" on a serial port must not open a port OpsLog already +// holds: a COM port has one owner, and the owner is the running antenna client. +// Building a second client made the two fight — one of them logging "Serial port +// busy" for the rest of the session. +func TestTestUltrabeamReusesTheLiveSerialClient(t *testing.T) { + src, err := os.ReadFile("app.go") + if err != nil { + t.Fatal(err) + } + body := funcBody(t, string(src), "func (a *App) TestUltrabeam(s UltrabeamSettings) error {") + if !strings.Contains(body, "a.liveMotorFor(s)") { + t.Error("TestUltrabeam builds a client without first checking whether the live one already owns that port") + } + // The check has to come BEFORE the second client is created, or it is no check. + if i, j := strings.Index(body, "a.liveMotorFor(s)"), strings.Index(body, "newMotorClient(s)"); i < 0 || j < 0 || i > j { + t.Error("the live-client check must precede newMotorClient") + } +} + +// funcBody returns the source of a function, up to the closing brace in column 0. +func funcBody(t *testing.T, src, signature string) string { + t.Helper() + i := strings.Index(src, signature) + if i < 0 { + t.Fatalf("function not found: %s", signature) + } + rest := src[i:] + if j := strings.Index(rest, "\n}\n"); j >= 0 { + return rest[:j] + } + return rest +}