diff --git a/app.go b/app.go index 97bcc13..41e43d8 100644 --- a/app.go +++ b/app.go @@ -5171,6 +5171,11 @@ func (a *App) BulkUpdateField(ids []int64, field, value string) (int64, error) { if a.qso == nil { return 0, fmt.Errorf("db not initialized") } + // Frequency is numeric and drives the band, so it takes a dedicated path + // (freq_hz + band updated together) rather than the generic text setter. + if field == "freq" { + return a.bulkSetFrequency(ids, value) + } col, ok := bulkFieldColumns[field] if !ok { return 0, fmt.Errorf("unknown field %q", field) @@ -5189,6 +5194,34 @@ func (a *App) BulkUpdateField(ids []int64, field, value string) (int64, error) { return n, nil } +// bulkSetFrequency parses a MHz frequency and sets freq_hz + band across the +// selected QSOs. Accepts a comma or dot decimal separator (fr locale) and +// rejects anything outside the ham bands so a typo can't corrupt the batch. +func (a *App) bulkSetFrequency(ids []int64, value string) (int64, error) { + s := strings.ReplaceAll(strings.TrimSpace(value), ",", ".") + if s == "" { + return 0, fmt.Errorf("enter a frequency in MHz (e.g. 7.155)") + } + mhz, err := strconv.ParseFloat(s, 64) + if err != nil || mhz <= 0 { + return 0, fmt.Errorf("invalid frequency %q — enter MHz, e.g. 7.155", value) + } + hz := int64(math.Round(mhz * 1_000_000)) + band := cat.BandFromHz(hz) + if band == "" { + return 0, fmt.Errorf("%.4f MHz is not in a ham band", mhz) + } + n, err := a.qso.BulkSetFrequency(a.ctx, ids, hz, band) + if err != nil { + return 0, err + } + if n > 0 { + a.invalidateAwardStats() + a.materializeAwardRefsForIDs(ids) // band change may shift award slots → refresh + } + return n, nil +} + // WorkedBefore returns prior contacts with the given callsign at both // call and DXCC granularity. Pass dxccHint=0 when unknown — the function // will infer it from past QSOs with the same call when possible. @@ -7178,6 +7211,55 @@ func (a *App) SendQSORecordingEmail(id int64) error { return nil } +// developerEmail is where "Help → Send log to F4BPO" delivers diagnostic logs. +const developerEmail = "legreg002@hotmail.com" + +// SMTPConfigured reports whether an SMTP server is set up, so the UI can show the +// "Send log to F4BPO" help entry only when there's actually a way to send. +func (a *App) SMTPConfigured() bool { + s, _ := a.GetEmailSettings() + return strings.TrimSpace(s.Host) != "" +} + +// SendLogToDeveloper e-mails the app's diagnostic logs (opslog.log, the previous +// session's opslog.log.1, and any crash log) to the developer so problems users +// hit in the field can be diagnosed. Requires a configured SMTP server +// (Settings → E-mail). A short header identifies the sender's call and version. +func (a *App) SendLogToDeveloper() error { + s, _ := a.GetEmailSettings() + if strings.TrimSpace(s.Host) == "" { + return fmt.Errorf("no SMTP server configured — set one in Settings → E-mail") + } + logPath := applog.Path() + if logPath == "" { + return fmt.Errorf("log file not available") + } + // The current log, plus the rotated one and the crash log when they exist. + attach := []string{logPath} + for _, extra := range []string{logPath + ".1", filepath.Join(filepath.Dir(logPath), "opslog-crash.log")} { + if _, err := os.Stat(extra); err == nil { + attach = append(attach, extra) + } + } + call := "" + if a.profiles != nil { + if p, err := a.profiles.Active(a.ctx); err == nil { + call = p.Callsign + } + } + subject := fmt.Sprintf("OpsLog log — v%s", appVersion) + if call != "" { + subject = fmt.Sprintf("OpsLog log — %s v%s", call, appVersion) + } + body := fmt.Sprintf("OpsLog diagnostic log attached.\n\nCallsign: %s\nVersion: %s\n\n73", call, appVersion) + if err := email.SendFiles(a.emailConfig(s), developerEmail, subject, body, attach); err != nil { + applog.Printf("email: send log to developer failed: %v", err) + return err + } + applog.Printf("email: diagnostic log sent to developer (%s)", developerEmail) + return nil +} + // ── ClubLog Country File (cty.xml) exceptions ───────────────────────── // ClublogCtyInfo is the UI status of the ClubLog exception data. diff --git a/changelog.json b/changelog.json index c2d3dac..9c5d113 100644 --- a/changelog.json +++ b/changelog.json @@ -4,21 +4,29 @@ "date": "2026-07-22", "en": [ "Clicking an OpsLog spot on the FlexRadio panadapter now switches the mode too, not just the frequency: cluster spots (which carry no mode) get one inferred from the comment or the band plan, and every spot's mode is sent to the radio as a real SmartSDR mode (SSB resolves to USB/LSB by frequency), so the click tunes AND sets the mode.", - "Station Control is now a clean dashboard of fixed-width panels that wrap to fill the window. Each panel has a grip handle on its left edge — drag it to move the panel anywhere, and the order is remembered. Relay buttons are compact. The amplifier panel now shows the same live meters (forward power, drain current, PA temperature) as the FlexRadio panel.", + "Station Control is now a clean dashboard of fixed-width panels that wrap to fill the window. Each panel has a grip handle on its left edge — drag it to move the panel anywhere, and the order is remembered. Relay buttons are compact. The amplifier panel is now the exact same card as the FlexRadio panel's (OPERATE/STANDBY, ON/OFF, power level, output-power bar and live FWD/drain-current/temperature meters), and if you run several amplifiers they all appear — one card each, no dropdown.", "Station Control: the Ultrabeam element list now shows the right number of elements (3 for a 3-element beam) instead of extra bogus rows that came from over-reading the controller's reply.", "Fixed the FlexRadio amplifier meters showing twice after the amp was power-cycled — the old (stale) meters are now cleared when the amplifier is torn down, so only the fresh set remains.", "Fixed QSO audio recordings silently not being saved: the recording snapshot had moved onto the background (post-log) path, where it raced the entry-form clearing that cancels the recorder — so the audio was discarded before it was captured. The snapshot is now taken the instant you log, before the form clears; the file encoding still happens in the background.", + "Digital Voice Keyer: an Auto CQ (like the CW keyer's auto-call) repeats a CQ-labelled voice message on a timer until you stop it, play another slot or press ESC. The DVK now also refuses to transmit on non-phone modes (CW/FT8/RTTY…) — the buttons grey out and it won't key the rig with speech on a data mode.", "The Digital Voice Keyer (DVK) panel now stays open across a relaunch if you had it open — it used to reset to closed every time.", - "FlexRadio meter scales now match SmartSDR: MIC (level) tops out at 0 dB and COMP (compression) tops out at -25 dB (was -20)." + "FlexRadio meter scales now match SmartSDR: MIC (level) tops out at 0 dB and COMP (compression) tops out at -25 dB (was -20).", + "New Help → 'Send log to F4BPO' (shown only when you have an SMTP server configured in Settings → E-mail): e-mails OpsLog's diagnostic logs (current session, previous session and any crash log) straight to the developer, so a problem you hit in the field can be looked at without hunting for the log files by hand.", + "Bulk edit can now set the frequency: pick 'Frequency (MHz)', enter e.g. 7.155, and every selected QSO gets that frequency with its band recomputed to match. Handy for fixing a run that was logged on a stale/default frequency (e.g. 7.000) after CAT control dropped. Out-of-band values are rejected so a typo can't corrupt the batch.", + "Fixed the Cluster spot list appearing to 'freeze' at the UTC midnight rollover: the Time column was sorted as plain text, so a spot at 0001Z sorted below 2359Z and new spots after 0000Z dropped to the bottom of the list instead of the top — it looked like the cluster had stopped. The column now sorts by actual arrival time, which crosses midnight correctly." ], "fr": [ "Cliquer sur un spot OpsLog du panadapter FlexRadio change maintenant aussi le mode, plus seulement la fréquence : les spots cluster (qui n'ont pas de mode) en reçoivent un déduit du commentaire ou du plan de bande, et le mode de chaque spot est envoyé à la radio comme un vrai mode SmartSDR (SSB devient USB/LSB selon la fréquence), donc le clic accorde ET règle le mode.", - "Station Control est maintenant un tableau de bord de panneaux à largeur fixe qui s'alignent pour remplir la fenêtre. Chaque panneau a une poignée sur son bord gauche — glisse-la pour déplacer le panneau où tu veux, et l'ordre est mémorisé. Les boutons relais sont compacts. Le panneau amplificateur affiche maintenant les mêmes mesures en direct (puissance directe, courant de drain, température PA) que le panneau FlexRadio.", + "Station Control est maintenant un tableau de bord de panneaux à largeur fixe qui s'alignent pour remplir la fenêtre. Chaque panneau a une poignée sur son bord gauche — glisse-la pour déplacer le panneau où tu veux, et l'ordre est mémorisé. Les boutons relais sont compacts. Le panneau amplificateur est désormais exactement la même carte que celle du panneau FlexRadio (OPERATE/STANDBY, ON/OFF, niveau de puissance, barre de puissance de sortie et mesures en direct puissance directe/courant de drain/température), et si tu utilises plusieurs amplis ils apparaissent tous — une carte chacun, sans liste déroulante.", "Station Control : la liste des éléments Ultrabeam affiche maintenant le bon nombre d'éléments (3 pour une beam 3 éléments) au lieu de lignes fantômes issues d'une sur-lecture de la réponse du contrôleur.", "Correction des mesures de l'amplificateur FlexRadio affichées en double après un cycle d'alimentation de l'ampli — les anciennes mesures (périmées) sont maintenant effacées quand l'amplificateur est retiré, seul le jeu à jour reste.", "Correction des enregistrements audio des QSO qui n'étaient plus sauvegardés : la capture de l'enregistrement était passée sur le chemin d'arrière-plan (après le log), où elle entrait en concurrence avec le vidage du formulaire qui annule l'enregistreur — l'audio était donc jeté avant d'être capturé. La capture se fait maintenant à l'instant du log, avant le vidage du formulaire ; l'encodage du fichier reste en arrière-plan.", + "Manipulateur vocal (DVK) : un Auto CQ (comme l'auto-call du keyer CW) répète un message vocal libellé CQ à intervalle régulier jusqu'à l'arrêt, la lecture d'un autre slot ou ÉCHAP. Le DVK refuse aussi désormais d'émettre hors phonie (CW/FT8/RTTY…) — les boutons sont grisés et il ne manipule plus la radio avec de la voix sur un mode data.", "Le panneau Digital Voice Keyer (DVK) reste maintenant ouvert après un relancement si vous l'aviez ouvert — il se refermait à chaque démarrage.", - "Les échelles des meters FlexRadio collent maintenant à SmartSDR : MIC (niveau) plafonne à 0 dB et COMP (compression) à -25 dB (au lieu de -20)." + "Les échelles des meters FlexRadio collent maintenant à SmartSDR : MIC (niveau) plafonne à 0 dB et COMP (compression) à -25 dB (au lieu de -20).", + "Nouveau Aide → « Envoyer le journal à F4BPO » (affiché seulement si un serveur SMTP est configuré dans Réglages → E-mail) : envoie par e-mail les journaux de diagnostic d'OpsLog (session en cours, session précédente et éventuel journal de crash) directement au développeur, pour qu'un problème rencontré sur le terrain puisse être examiné sans avoir à chercher les fichiers de journal à la main.", + "L'édition groupée permet maintenant de définir la fréquence : choisis « Fréquence (MHz) », saisis p. ex. 7.155, et chaque QSO sélectionné reçoit cette fréquence avec sa bande recalculée en conséquence. Pratique pour corriger une série loguée sur une fréquence par défaut/figée (p. ex. 7.000) après une perte du CAT. Les valeurs hors bande sont refusées pour qu'une faute de frappe ne corrompe pas le lot.", + "Correction de la liste des spots Cluster qui semblait « se figer » au passage de minuit UTC : la colonne Heure était triée comme du texte, donc un spot à 0001Z se retrouvait sous 2359Z et les nouveaux spots après 0000Z tombaient en bas de la liste au lieu du haut — on aurait dit que le cluster s'était arrêté. La colonne trie désormais selon l'heure d'arrivée réelle, qui franchit minuit correctement." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0b3ae04..39b2994 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail, LookupCallsign, GetStationSettings, GetListsSettings, GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations, GetWhatsNew, GetChangelog, + SMTPConfigured, SendLogToDeveloper, WorkedBefore, SetCompactMode, GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, @@ -959,8 +960,10 @@ export default function App() { const dvkActiveRef = useRef(false); const dvkPlayingRef = useRef(false); const dvkPlayRef = useRef<(slot: number) => void>(() => {}); + const dvkMsgsRef = useRef([]); useEffect(() => { dvkActiveRef.current = dvkEnabled; }, [dvkEnabled]); useEffect(() => { dvkPlayingRef.current = dvkStat.playing; }, [dvkStat.playing]); + useEffect(() => { dvkMsgsRef.current = dvkMsgs; }, [dvkMsgs]); useEffect(() => { const off = EventsOn('audio:status', (s: any) => setDvkStat(s as DVKStat)); return () => { off?.(); }; @@ -972,7 +975,48 @@ export default function App() { reloadDvk(); GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {}); }, [dvkEnabled, reloadDvk]); - const dvkPlay = useCallback((slot: number) => { DVKPlay(slot).catch((e: any) => setError(String(e?.message ?? e))); }, []); + // DVK auto-CQ: repeat a CQ voice message on a timer, like the CW keyer's + // auto-call. Only slots whose LABEL contains "CQ" loop; any other slot plays + // once (and stops a running loop), so a report doesn't keep repeating. + const [dvkAutoCq, setDvkAutoCq] = useState(() => localStorage.getItem('opslog.dvkAutoCq') === '1'); + const [dvkAutoCqSecs, setDvkAutoCqSecs] = useState(() => Number(localStorage.getItem('opslog.dvkAutoCqSecs')) || 3); + const dvkAutoCqRef = useRef(dvkAutoCq); + const dvkAutoCqSecsRef = useRef(dvkAutoCqSecs); + const dvkAutoCqGenRef = useRef(0); + const dvkAutoCqSlotRef = useRef(-1); + useEffect(() => { dvkAutoCqRef.current = dvkAutoCq; localStorage.setItem('opslog.dvkAutoCq', dvkAutoCq ? '1' : '0'); }, [dvkAutoCq]); + useEffect(() => { dvkAutoCqSecsRef.current = dvkAutoCqSecs; localStorage.setItem('opslog.dvkAutoCqSecs', String(dvkAutoCqSecs)); }, [dvkAutoCqSecs]); + // The DVK is a VOICE keyer — transmitting it on CW/FT8/RTTY would key the rig + // with speech on a data slot. Only allow it on phone modes. + const isPhoneMode = (m: string) => RECORDABLE_MODES.has((m || '').toUpperCase()); + const modeRef = useRef(mode); + useEffect(() => { modeRef.current = mode; }, [mode]); + function stopDvkAutoCq() { dvkAutoCqSlotRef.current = -1; dvkAutoCqGenRef.current++; } + async function runDvkAutoCq(slot: number) { + const gen = ++dvkAutoCqGenRef.current; + dvkAutoCqSlotRef.current = slot; + const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms)); + while (dvkAutoCqSlotRef.current === slot && gen === dvkAutoCqGenRef.current && dvkActiveRef.current) { + if (!isPhoneMode(modeRef.current)) { stopDvkAutoCq(); break; } + await DVKPlay(slot).catch(() => {}); + await sleep(300); // let playback flip "playing" true + let guard = 0; // then wait for it to finish (cap ~90 s) + while (dvkPlayingRef.current && gen === dvkAutoCqGenRef.current && guard < 600) { await sleep(150); guard++; } + if (gen !== dvkAutoCqGenRef.current) break; + await sleep(Math.max(0, dvkAutoCqSecsRef.current) * 1000); // gap before the next CQ + } + } + const dvkPlay = useCallback((slot: number) => { + if (!isPhoneMode(modeRef.current)) { setError(t('dvkp.notPhone')); return; } + const m = dvkMsgsRef.current.find((x) => x.slot === slot); + const isCQ = (m?.label || '').toUpperCase().includes('CQ'); + if (dvkAutoCqRef.current && isCQ) { + runDvkAutoCq(slot); // loop this CQ until Stop / a non-CQ slot / mode change + } else { + stopDvkAutoCq(); + DVKPlay(slot).catch((e: any) => setError(String(e?.message ?? e))); + } + }, []); useEffect(() => { dvkPlayRef.current = dvkPlay; }, [dvkPlay]); // Controlled active tab of the F1-F5 detail panel (so Ctrl+F1-F5 can switch // it from the keyboard without clashing with the F1-F12 keyer macros). @@ -1190,6 +1234,15 @@ export default function App() { useEffect(() => { GetWhatsNew().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); }).catch(() => {}); }, []); + // Whether an SMTP server is configured — gates the "Send log to F4BPO" help + // entry. Re-checked when the Settings modal closes (the user may have just set + // it up), so the entry appears without a restart. + const [smtpConfigured, setSmtpConfigured] = useState(false); + const [sendingLog, setSendingLog] = useState(false); + useEffect(() => { + if (showSettings) return; // re-check after closing Settings + SMTPConfigured().then((v: any) => setSmtpConfigured(!!v)).catch(() => {}); + }, [showSettings]); const [showDuplicates, setShowDuplicates] = useState(false); const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null); const [checkingUpdate, setCheckingUpdate] = useState(false); @@ -2869,9 +2922,15 @@ export default function App() { ]}, { name: 'help', label: t('menu.help'), items: [ { type: 'item', label: t('whatsnew.title'), action: 'help.whatsnew' }, + // Only when SMTP is set up: e-mail the diagnostic logs to the developer. + ...(smtpConfigured ? [ + { type: 'separator' as const }, + { type: 'item' as const, label: sendingLog ? t('help.sendLogBusy') : t('help.sendLog'), action: 'help.sendlog', disabled: sendingLog }, + ] : []), + { type: 'separator' }, { type: 'item', label: t('help.about'), action: 'help.about' }, ]}, - ], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, netEnabled, contestTabEnabled, t]); + ], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]); function handleMenu(action: string) { switch (action) { @@ -2900,6 +2959,21 @@ export default function App() { case 'tools.downloadRefs': downloadRefs(); break; case 'help.about': setShowAbout(true); checkUpdateNow(); break; case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break; + case 'help.sendlog': sendLogToDeveloper(); break; + } + } + + // E-mail the diagnostic logs to the developer (Help → Send log to F4BPO). + async function sendLogToDeveloper() { + if (sendingLog) return; + setSendingLog(true); + try { + await SendLogToDeveloper(); + showToast(t('help.sendLogOk')); + } catch (e: any) { + showToast(t('help.sendLogFail', { err: String(e?.message ?? e) })); + } finally { + setSendingLog(false); } } @@ -2943,8 +3017,10 @@ export default function App() { // callsign depends on the "ESC clears callsign" option; with the keyer // off it always resets the entry (the classic behaviour). if (e.key === 'Escape') { - // If a voice message is transmitting, ESC just stops it (keeps entry). - if (dvkActiveRef.current && dvkPlayingRef.current) { + // If a voice message is transmitting (or auto-CQ is looping), ESC stops it + // and cancels the loop (keeps entry). + if (dvkActiveRef.current && (dvkPlayingRef.current || dvkAutoCqSlotRef.current >= 0)) { + stopDvkAutoCq(); DVKStop(); e.preventDefault(); return; @@ -4420,7 +4496,11 @@ export default function App() { Digital Voice Keyer take this slot when enabled (Log4OM-style); otherwise it shows the QRZ profile photo. */} {!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && ( -
+ // relative + absolute inner (like the F1-F5 panel): a taller widget (e.g. + // the DVK with Auto CQ) can't grow the row — the row height stays set by + // the entry strip and each widget fills that height, scrolling inside. +
+
{/* Multi-op "who's on air" widget: every operator on the shared logbook, their freq/mode (colour-coded) and OpsLog version. */} {showLiveStations && dbConn?.backend === 'mysql' && ( @@ -4503,13 +4583,18 @@ export default function App() {
)} {dvkEnabled && ( -
+
DVKStop()} + onStop={() => { stopDvkAutoCq(); DVKStop(); }} onClose={() => setDvkEnabled(false)} + autoCq={dvkAutoCq} + autoCqSecs={dvkAutoCqSecs} + onToggleAutoCq={setDvkAutoCq} + onSetAutoCqSecs={(n) => setDvkAutoCqSecs(Math.max(0, Math.min(120, n || 0)))} + phoneOk={isPhoneMode(mode)} />
)} @@ -4574,6 +4659,7 @@ export default function App() {
)}
+
)} {/* /entry + aside row */} diff --git a/frontend/src/components/AmpCard.tsx b/frontend/src/components/AmpCard.tsx new file mode 100644 index 0000000..630d7b8 --- /dev/null +++ b/frontend/src/components/AmpCard.tsx @@ -0,0 +1,260 @@ +import { useRef } from 'react'; +import { Flame } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App'; + +// AmpCard renders the amplifier card exactly like the one in the FlexRadio panel, +// so Station Control (and anywhere else that needs it) shows the SAME card instead +// of a stripped-down variant. It handles all three amp families: +// • SPE Expert / ACOM — driven by OpsLog's own serial/TCP link (amp.spe/amp.acom) +// • PowerGenius XL — OPERATE + meters come from the Flex (which reports the +// amp), fan mode from the direct GSCP link (amp.pgxl) +// Controls use the multi-amp API (AmpOperate/AmpPower/… by amp id) so several amps +// can each get their own card. + +const METER_SEGMENTS = 26; + +function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', display, segColor, compact }: { + label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; display?: string; + segColor?: (frac: number) => string; compact?: boolean; +}) { + const span = hi - lo; + const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0; + const lit = Math.round((pct / 100) * METER_SEGMENTS); + return ( +
+
+ {label} + + {display !== undefined ? display : ( + <>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}{unit} + )} + +
+
+ {Array.from({ length: METER_SEGMENTS }).map((_, i) => { + const on = i < lit; + const frac = i / METER_SEGMENTS; + const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent); + return ( +
+ ); + })} +
+
+ ); +} + +function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) { + return ( +
+
+ + {title} +
+
{children}
+
+ ); +} + +// speMaxW / powerLevelLabel — same helpers the Flex panel uses. +function speMaxW(model?: string): number { + const m = (model || '').toUpperCase(); + if (m.includes('20K') || m.includes('2K')) return 2000; + if (m.includes('15K')) return 1500; + return 1300; +} +function powerLevelLabel(pl?: string): string { + switch ((pl || '').trim().toUpperCase()) { + case 'L': return 'Low'; + case 'M': return 'Mid'; + case 'H': return 'High'; + default: return pl || ''; + } +} + +type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any }; + +export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) { + // Peak-hold so the jittery VITA-49 meters read steadily (own ref per card). + const peak = useRef>({}); + const peakHold = (key: string, val: number) => { + const now = Date.now(); + const p = peak.current[key]; + if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; } + return p.v; + }; + + const isSPE = !!amp.spe; + const isACOM = !!amp.acom; + + if (isSPE) { + const spe = amp.spe; + return ( + +
+ +
+ + +
+
+ {(['L', 'M', 'H'] as const).map((lvl, i) => { + const active = (spe.power_level || '').trim().toUpperCase() === lvl; + return ( + + ); + })} +
+ + + {spe.connected ? (spe.tx ? 'TX' : 'RX') : t('flxp.speOffline')} + + {spe.connected && ( + + {spe.band ? `${spe.band} · ` : ''}{spe.output_w}W · SWR {Number(spe.swr_ant ?? 0).toFixed(1)} · {spe.temp_c}°C · {powerLevelLabel(spe.power_level)} + + )} +
+ {(spe.warnings || spe.alarms) && ( + ⚠ {spe.warnings} {spe.alarms} + )} +
+ {spe.connected && ( + (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} /> + )} + + ); + } + + if (isACOM) { + const acom = amp.acom; + return ( + +
+ +
+ + +
+ + + {acom.connected ? acom.state : t('flxp.acomOffline')} + + {acom.connected && ( + + {acom.band ? `${acom.band} · ` : ''}{acom.fwd_w}W · SWR {Number(acom.swr ?? 0).toFixed(1)} · {acom.temp_c}°C · Fan {acom.fan} + + )} +
+ {acom.err_text && ( + ⚠ {acom.err_text} + )} +
+ {acom.connected && ( + (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} /> + )} + + ); + } + + // PowerGenius XL — OPERATE + meters ride on the Flex; fan mode on the GSCP link. + const pg = amp.pgxl || {}; + const viaFlex = !!flex?.amp_available; + const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate; + const connected = !!pg.connected || viaFlex; + const fault = flex?.amp_fault; + return ( + +
+ + + {operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')} + + {(pg.host || pg.connected) && ( + + )} +
+ {fault && fault !== 'NONE' && ( + {t('flxp.fault')}: {fault} + )} +
+ {/* Amplifier meters (FWD / ID / TEMP …) from the FlexRadio UDP stream. */} + {viaFlex && (() => { + const meters = (flex?.meters as any[]) || []; + const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10); + const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP') + && !/^(RL|DRV)$/i.test((m.name || '').trim())); + if (amps.length === 0) return null; + return ( +
+ {amps.map((m) => { + if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) { + return ; + } + const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a'; + let lo = m.lo, hi = m.hi; + if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) { + lo = 0; + hi = m.hi >= 25 ? m.hi : 25; + } + return ; + })} +
+ ); + })()} + + ); +} diff --git a/frontend/src/components/BulkEditModal.tsx b/frontend/src/components/BulkEditModal.tsx index 07025b2..1669ae7 100644 --- a/frontend/src/components/BulkEditModal.tsx +++ b/frontend/src/components/BulkEditModal.tsx @@ -12,7 +12,7 @@ import { } from '@/components/ui/select'; import { useI18n } from '@/lib/i18n'; -type FieldKind = 'status' | 'text'; +type FieldKind = 'status' | 'text' | 'freq'; type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean }; // Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input. @@ -74,6 +74,9 @@ const FIELDS: FieldDef[] = [ { id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' }, { id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' }, // Misc + // Frequency (MHz) — sets freq_hz AND recomputes band. Main use: fixing a batch + // logged on a stale/default frequency after CAT dropped. + { id: 'freq', label: 'bulk.fFreq', group: 'Misc', kind: 'freq' }, { id: 'comment', label: 'bulk.fComment', group: 'Misc', kind: 'text' }, { id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' }, { id: 'rig', label: 'bulk.fRig', group: 'Misc', kind: 'text' }, @@ -180,7 +183,8 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) { setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)} /> )} @@ -188,7 +192,7 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
{t('bulk.willSet')} {t(def.label)} ={' '} - {effectiveValue === '' ? t('bulk.blank') : effectiveValue} {t('bulk.onQsos', { n: ids.length })} + {effectiveValue === '' ? t('bulk.blank') : effectiveValue}{def.kind === 'freq' && effectiveValue !== '' ? ' MHz' : ''} {t('bulk.onQsos', { n: ids.length })}
{error &&
{error}
}
diff --git a/frontend/src/components/ClusterGrid.tsx b/frontend/src/components/ClusterGrid.tsx index 019e98f..e36cce3 100644 --- a/frontend/src/components/ClusterGrid.tsx +++ b/frontend/src/components/ClusterGrid.tsx @@ -149,6 +149,16 @@ const makeColCatalog = (t: TFn): ColEntry[] => [ headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono', defaultVisible: true, sort: 'desc', + // Sort by the real arrival timestamp, NOT the "HHMMZ" display string. A + // lexical sort of time_utc breaks at the UTC day rollover: "0001Z" sorts + // below "2359Z", so spots received just after midnight fell to the bottom + // and looked like the cluster had stopped. received_at is a full datetime + // that keeps ordering correct across 0000Z. + comparator: (_a: any, _b: any, nodeA: any, nodeB: any) => { + const ta = Date.parse(nodeA?.data?.received_at ?? '') || 0; + const tb = Date.parse(nodeB?.data?.received_at ?? '') || 0; + return ta - tb; + }, cellStyle: { color: 'var(--muted-foreground)' }, }, { diff --git a/frontend/src/components/DvkPanel.tsx b/frontend/src/components/DvkPanel.tsx index e3d359e..cf30d80 100644 --- a/frontend/src/components/DvkPanel.tsx +++ b/frontend/src/components/DvkPanel.tsx @@ -1,4 +1,4 @@ -import { Mic, Square, X, Radio } from 'lucide-react'; +import { Mic, Square, X, Radio, Repeat } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; @@ -12,14 +12,20 @@ type Props = { onPlay: (slot: number) => void; onStop: () => void; onClose: () => void; + autoCq: boolean; + autoCqSecs: number; + onToggleAutoCq: (on: boolean) => void; + onSetAutoCqSecs: (n: number) => void; + phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked }; // Operating panel for the Digital Voice Keyer — transmits the recorded F1–F6 // voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in // the reserved area. Recording/labeling lives in Settings → Audio. -export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) { +export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) { const { t } = useI18n(); - const anyAudio = messages.some((m) => m.has_audio); + const recorded = messages.filter((m) => m.has_audio); + const anyAudio = recorded.length > 0; return (
@@ -43,29 +49,54 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) { {t('dvkp.noMsgPre')} {t('dvkp.settingsPath')} {t('dvkp.noMsgPost')}
) : ( -
- {messages.map((m) => ( + // Only the recorded slots are shown. Two columns filling top-to-bottom + // (rows sized to the count) — use the width instead of scrolling, so the + // panel keeps the same height as the other reserved-area widgets. +
+ {recorded.map((m) => ( ))}
)}
+ + {/* Auto-CQ footer — repeats a CQ-labelled slot on a timer. Greyed out on a + non-phone mode (the DVK won't transmit there). */} +
+ +
+ {t('dvkp.gap')} + onSetAutoCqSecs(parseInt(e.target.value, 10))} + className="w-12 h-6 rounded border border-input bg-background px-1 text-[11px] font-mono text-center" /> + s +
+ + {!phoneOk && ( +
{t('dvkp.notPhone')}
+ )}
); } diff --git a/frontend/src/components/StationControlPanel.tsx b/frontend/src/components/StationControlPanel.tsx index fe40127..b4bb39c 100644 --- a/frontend/src/components/StationControlPanel.tsx +++ b/frontend/src/components/StationControlPanel.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, Flame, GripVertical } from 'lucide-react'; +import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -8,12 +8,13 @@ import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { writeUiPref } from '@/lib/uiPref'; import { RotorCompass } from '@/components/RotorCompass'; +import { AmpCard } from '@/components/AmpCard'; import { GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay, GetRotatorHeading, RotatorGoTo, RotatorStop, GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements, ListDenkoviDevices, ListSerialPorts, TestStationDevice, - GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, GetFlexState, FlexAmpOperate, + GetAmpStatuses, GetFlexState, } from '../../wailsjs/go/main/App'; type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null }; @@ -249,171 +250,6 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () = ); } -// AmplifierWidget brings the amplifier controls (Settings → Amplifier) into the -// Station Control tab, so an operator WITHOUT a FlexRadio/Icom panel still has -// them. Several amps can be configured (some ops run two SPEs in parallel) — -// the header dropdown picks which one this card shows; the choice is remembered. -function AmplifierWidget({ t }: { t: (k: string, v?: any) => string }) { - const [list, setList] = useState([]); - const [sel, setSel] = useState(() => localStorage.getItem('opslog.ampSel.station') || ''); - const [flex, setFlex] = useState(null); - useEffect(() => { - let alive = true; - // The Flex state rides along because a PGXL's OPERATE state comes from the - // radio (the direct GSCP status doesn't carry it). - const tick = () => Promise.all([ - GetAmpStatuses().catch(() => []), - GetFlexState().catch(() => null), - ]).then(([l, fx]: any[]) => { if (alive) { setList((l ?? []) as any[]); setFlex(fx); } }); - tick(); - const id = window.setInterval(tick, 1500); - return () => { alive = false; window.clearInterval(id); }; - }, []); - const amp = list.find((a) => a.id === sel) ?? list[0]; - if (!amp) return null; - const isACOM = !!amp.acom; - const isSPE = !!amp.spe; - const isPGXL = !isACOM && !isSPE; - const viaFlex = isPGXL && !!flex?.amp_available; - const raw = amp.spe ?? amp.acom ?? amp.pgxl ?? { connected: false }; - const st: any = isPGXL - ? { ...raw, connected: !!raw.connected || viaFlex, operate: viaFlex ? !!flex.amp_operate : !!raw.operate } - : raw; - const doOperate = () => { - const want = !st.operate; - (isPGXL && viaFlex ? FlexAmpOperate(want) : AmpOperate(amp.id, want)).catch(() => {}); - }; - const maxW = isACOM ? (Number(st.max_w) || 800) : ({ '13K': 1300, '15K': 1500, '2K': 2000 } as Record)[st.model] || 1500; - const outW = Number(isACOM ? st.fwd_w : st.output_w) || 0; - const frac = Math.min(1, outW / maxW); - return ( -
-
- -
-
{t('flxp.amplifier')}
- {list.length <= 1 &&
{amp.name}
} -
- {list.length > 1 && ( - - )} - -
-
- {isPGXL ? ( - <> -
- - {(['STANDARD', 'CONTEST', 'BROADCAST'] as const).map((m) => ( - - ))} - {st.state || ''}{st.temperature ? ` · ${Math.round(st.temperature)}°C` : ''} -
- {/* Live amp meters (FWD / ID / TEMP …) from the FlexRadio UDP stream, so - this card matches the amplifier card in the Flex panel. */} - {viaFlex && (() => { - const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10); - const ampMeters = ((flex?.meters as any[]) || []).filter((m) => (m.src || '').toUpperCase().includes('AMP') && !/^(RL|DRV)$/i.test((m.name || '').trim())); - if (ampMeters.length === 0) return null; - return ( -
- {ampMeters.map((m) => { - const isPwr = /fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || ''); - const val = isPwr ? dbmToW(m.value) : m.value; - const unit = isPwr ? 'W' : (m.unit || ''); - const hi = isPwr ? 2000 : (/amp/i.test(m.unit || '') || /^ID$/i.test((m.name || '').trim())) ? 25 : (m.hi > 0 ? m.hi : 100); - const frac = Math.min(1, Math.max(0, val / hi)); - const acc = isPwr ? '#dc2626' : /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a'; - return ( -
-
- {m.name} - {val.toFixed(isPwr ? 0 : 1)}{unit} -
-
-
-
-
- ); - })} -
- ); - })()} - - ) : ( - <> -
- -
- - -
- {!isACOM && ( -
- {(['L', 'M', 'H'] as const).map((lvl, i) => ( - - ))} -
- )} -
-
- {st.connected - ? <> - {isACOM ? (st.state || '') : (st.tx ? 'TX' : 'RX')} - {st.band ? ` · ${st.band}` : ''} · {outW}W · SWR {Number((isACOM ? st.swr : st.swr_ant) ?? 0).toFixed(1)} · {st.temp_c}°C - {isACOM && st.fan ? ` · Fan ${st.fan}` : ''} - - : (isACOM ? t('flxp.acomOffline') : t('flxp.speOffline'))} -
- {st.connected && ( -
-
-
0.9 ? 'bg-danger' : frac > 0.75 ? 'bg-warning' : 'bg-primary')} - style={{ width: `${Math.round(frac * 100)}%` }} /> -
-
{t('flxp.outputPower')} — {outW} W / {maxW} W
-
- )} - {(st.err_text || st.warnings || st.alarms) && ( -
⚠ {st.err_text || `${st.warnings || ''} ${st.alarms || ''}`}
- )} - - )} -
-
- ); -} - export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) { const { t } = useI18n(); const [devices, setDevices] = useState([]); @@ -427,15 +263,24 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } }); const dragId = useRef(null); + // Max columns per row (persisted). "Auto" fills the window; a number caps the + // row so cards wrap onto further lines even when there's horizontal room. + const [cols, setCols] = useState(() => localStorage.getItem('opslog.stationCols') || 'auto'); // Amplifiers (Settings → Amplifier): shown here so operators without a - // FlexRadio panel still get the controls. Re-read every 5s so enabling an - // amp in Settings makes the widget appear without reopening the tab. - const [ampCount, setAmpCount] = useState(0); + // FlexRadio panel still get the controls. EVERY configured amp gets its own + // card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the + // FlexRadio meters read live; the Flex state rides along because a PGXL's + // OPERATE/meters come from the radio, not the direct GSCP link. + const [amps, setAmps] = useState([]); + const [flexState, setFlexState] = useState(null); useEffect(() => { let alive = true; - const load = () => GetAmpStatuses().then((l: any) => { if (alive) setAmpCount((l ?? []).length); }).catch(() => {}); + const load = () => Promise.all([ + GetAmpStatuses().catch(() => []), + GetFlexState().catch(() => null), + ]).then(([l, fx]: any[]) => { if (alive) { setAmps((l ?? []) as any[]); setFlexState(fx); } }); load(); - const id = window.setInterval(load, 5000); + const id = window.setInterval(load, 1500); return () => { alive = false; window.clearInterval(id); }; }, []); @@ -559,24 +404,36 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr if (ant.enabled) { widgets.push({ id: 'antenna', node: }); } - if (ampCount > 0) { - widgets.push({ id: 'amplifier', node: }); - } + // One card per configured amplifier (identical to the Flex panel's card). + for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: }); for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) }); const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; }; const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i)); const widgetIds = ordered.map((w) => w.id); - const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && ampCount === 0; + const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && amps.length === 0; return (

{t('station.title')}

- +
+ {/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a + card can sit on a further line even with horizontal room to spare. */} +
+ {(['auto', '1', '2', '3', '4'] as const).map((c) => ( + + ))} +
+ +
{noDevices && !editing && ( @@ -585,11 +442,12 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
)} - {/* Dashboard of FIXED-WIDTH cards that wrap to fill the window — a clean, - evenly-sized grid. Each card has a grip handle (left rail) as the drag - initiator (the card body is full of buttons, so dragging the whole card - was unreliable). Drop on another card to reorder. */} -
+ {/* Dashboard of FIXED-WIDTH cards that wrap. "Auto" fills the window; a fixed + column count caps the container width so cards wrap onto more lines. Each + card has a grip handle (left rail) as the drag initiator (the card body is + full of buttons, so dragging the whole card was unreliable). */} +
{ordered.map((w) => (
{ if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }} diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index b59b347..2d2f969 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -35,6 +35,8 @@ const en: Dict = { 'tools.net': 'NET Control', 'tools.alerts': 'Alert management…', 'tools.contest': 'Contest mode', 'alert.tuneHint': 'Click to tune the rig to this spot (freq + mode) and fill the call', 'alert.dismiss': 'Dismiss', 'alert.pending': '{n} recent spot alert(s) — click to view', 'alert.noneShort': 'No recent alerts', 'alert.recent': 'Recent alerts', 'alert.clear': 'Clear', 'menu.help': 'Help', 'help.about': 'About OpsLog', 'tools.duplicates': 'Find duplicates…', + 'help.sendLog': 'Send log to F4BPO', 'help.sendLogBusy': 'Sending log…', + 'help.sendLogOk': 'Log sent to F4BPO — thanks, 73!', 'help.sendLogFail': 'Could not send log: {err}', // Duplicates modal 'dup.title': 'Find duplicates', 'dup.scanning': 'Scanning the log…', 'dup.none': 'No duplicates found. 🎉', 'dup.hint': 'Each group is the same contact logged more than once (callsign + band + mode). The first (oldest) is left unchecked; tick the ones to delete.', @@ -278,7 +280,7 @@ const en: Dict = { 'wkp.cwSpeed': 'CW speed (WPM)', 'wkp.faster': 'Faster', 'wkp.slower': 'Slower', 'wkp.cwText': 'CW text', 'wkp.sendOnTypeHint': 'Key each character live as you type (backspace removes un-sent chars)', 'wkp.sendOnType': 'send on type', 'wkp.phLive': 'Type — sent live…', 'wkp.phEnter': 'Type and press Enter to send…', 'wkp.clear': 'Clear', 'wkp.send': 'Send', 'wkp.abort': 'Abort (clear keyer buffer)', 'wkp.stop': 'Stop', 'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL", 'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}', - 'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1–F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message', + 'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Repeat a CQ-labelled message on a timer until you stop it or play another slot', 'dvkp.gap': 'Gap', 'dvkp.notPhone': 'The voice keyer only transmits on a phone mode (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1–F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message', 'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band', 'flxp.ritHint': 'RIT — shifts your RECEIVE frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.xitHint': 'XIT — shifts your TRANSMIT frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay', @@ -325,7 +327,7 @@ const en: Dict = { 'awed.importCopy': 'Import as {code}-2', // QSO modals (context menu / bulk edit / QSL manager / QSO edit) 'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from QRZ.com', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…', - 'bulk.fLotwSent': 'LoTW sent', 'bulk.fLotwRcvd': 'LoTW received', 'bulk.fEqslSent': 'eQSL sent', 'bulk.fEqslRcvd': 'eQSL received', 'bulk.fQslSent': 'Paper QSL sent', 'bulk.fQslRcvd': 'Paper QSL received', 'bulk.fQrzUpload': 'QRZ.com upload', 'bulk.fClublogUpload': 'Club Log upload', 'bulk.fHrdlogUpload': 'HRDLog upload', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Station callsign', 'bulk.fOperator': 'Operator', 'bulk.fMyGrid': 'My grid', 'bulk.fMyAntenna': 'My antenna', 'bulk.fMyRig': 'My rig', 'bulk.fMyStreet': 'My street', 'bulk.fMyCity': 'My city', 'bulk.fMyPostal': 'My postal code', 'bulk.fMyCountry': 'My country', 'bulk.fMyState': 'My state', 'bulk.fMyCounty': 'My county', 'bulk.fMyIota': 'My IOTA', 'bulk.fMySota': 'My SOTA ref', 'bulk.fMyPota': 'My POTA ref', 'bulk.fMyWwff': 'My WWFF ref', 'bulk.fMySig': 'My SIG', 'bulk.fMyName': 'My name', 'bulk.fMyArrlSect': 'My ARRL section', 'bulk.fMyDarcDok': 'My DARC DOK', 'bulk.fMyVuccGrids': 'My VUCC grids', 'bulk.fMySigInfo': 'My SIG info', 'bulk.fContestId': 'Contest ID', 'bulk.fSrxString': 'Serial rcvd (exchange)', 'bulk.fStxString': 'Serial sent (exchange)', 'bulk.fArrlSect': 'ARRL section', 'bulk.fPrecedence': 'Precedence', 'bulk.fClass': 'Class', 'bulk.fPropMode': 'Propagation mode', 'bulk.fSatName': 'Satellite name', 'bulk.fSatMode': 'Satellite mode', 'bulk.fState': 'State', 'bulk.fCnty': 'County', 'bulk.fPotaRef': 'POTA ref', 'bulk.fSotaRef': 'SOTA ref', 'bulk.fWwffRef': 'WWFF ref', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'SIG info', 'bulk.fComment': 'Comment', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Rig (contacted)', 'bulk.fAnt': 'Antenna (contacted)', 'bulk.statusY': 'Y — Yes / uploaded', 'bulk.statusN': 'N — No', 'bulk.statusR': 'R — Requested', 'bulk.statusI': 'I — Ignore', 'bulk.statusBlank': '(blank — clear)', 'bulk.groupQsl': 'QSL / upload', 'bulk.groupMyStation': 'My station', 'bulk.groupContacted': 'Contacted station', 'bulk.groupContest': 'Contest', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Misc', 'bulk.title': 'Bulk edit field', 'bulk.desc': 'Set one field on the {n} selected QSO(s). This overwrites the current value — there is no undo.', 'bulk.fieldLabel': 'Field', 'bulk.valueLabel': 'Value', 'bulk.clearPlaceholder': 'leave empty to clear the field', 'bulk.willSet': 'Will set', 'bulk.blank': '(blank)', 'bulk.onQsos': 'on {n} QSO(s).', 'bulk.cancel': 'Cancel', 'bulk.applyTo': 'Apply to {n}', + 'bulk.fLotwSent': 'LoTW sent', 'bulk.fLotwRcvd': 'LoTW received', 'bulk.fEqslSent': 'eQSL sent', 'bulk.fEqslRcvd': 'eQSL received', 'bulk.fQslSent': 'Paper QSL sent', 'bulk.fQslRcvd': 'Paper QSL received', 'bulk.fQrzUpload': 'QRZ.com upload', 'bulk.fClublogUpload': 'Club Log upload', 'bulk.fHrdlogUpload': 'HRDLog upload', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Station callsign', 'bulk.fOperator': 'Operator', 'bulk.fMyGrid': 'My grid', 'bulk.fMyAntenna': 'My antenna', 'bulk.fMyRig': 'My rig', 'bulk.fMyStreet': 'My street', 'bulk.fMyCity': 'My city', 'bulk.fMyPostal': 'My postal code', 'bulk.fMyCountry': 'My country', 'bulk.fMyState': 'My state', 'bulk.fMyCounty': 'My county', 'bulk.fMyIota': 'My IOTA', 'bulk.fMySota': 'My SOTA ref', 'bulk.fMyPota': 'My POTA ref', 'bulk.fMyWwff': 'My WWFF ref', 'bulk.fMySig': 'My SIG', 'bulk.fMyName': 'My name', 'bulk.fMyArrlSect': 'My ARRL section', 'bulk.fMyDarcDok': 'My DARC DOK', 'bulk.fMyVuccGrids': 'My VUCC grids', 'bulk.fMySigInfo': 'My SIG info', 'bulk.fContestId': 'Contest ID', 'bulk.fSrxString': 'Serial rcvd (exchange)', 'bulk.fStxString': 'Serial sent (exchange)', 'bulk.fArrlSect': 'ARRL section', 'bulk.fPrecedence': 'Precedence', 'bulk.fClass': 'Class', 'bulk.fPropMode': 'Propagation mode', 'bulk.fSatName': 'Satellite name', 'bulk.fSatMode': 'Satellite mode', 'bulk.fState': 'State', 'bulk.fCnty': 'County', 'bulk.fPotaRef': 'POTA ref', 'bulk.fSotaRef': 'SOTA ref', 'bulk.fWwffRef': 'WWFF ref', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'SIG info', 'bulk.fFreq': 'Frequency (MHz)', 'bulk.freqPlaceholder': 'MHz — e.g. 7.155', 'bulk.fComment': 'Comment', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Rig (contacted)', 'bulk.fAnt': 'Antenna (contacted)', 'bulk.statusY': 'Y — Yes / uploaded', 'bulk.statusN': 'N — No', 'bulk.statusR': 'R — Requested', 'bulk.statusI': 'I — Ignore', 'bulk.statusBlank': '(blank — clear)', 'bulk.groupQsl': 'QSL / upload', 'bulk.groupMyStation': 'My station', 'bulk.groupContacted': 'Contacted station', 'bulk.groupContest': 'Contest', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Misc', 'bulk.title': 'Bulk edit field', 'bulk.desc': 'Set one field on the {n} selected QSO(s). This overwrites the current value — there is no undo.', 'bulk.fieldLabel': 'Field', 'bulk.valueLabel': 'Value', 'bulk.clearPlaceholder': 'leave empty to clear the field', 'bulk.willSet': 'Will set', 'bulk.blank': '(blank)', 'bulk.onQsos': 'on {n} QSO(s).', 'bulk.cancel': 'Cancel', 'bulk.applyTo': 'Apply to {n}', 'qslm.leave': '— leave —', 'qslm.slots': 'slots', 'qslm.bands': 'bands', 'qslm.countsTip': 'Worked / confirmed (LoTW + paper QSL). Slots = distinct DXCC × band × class (Phone/CW/Digital). RTTY ≠ FT8 granularity is only used for the cluster/matrix NEW SLOT flag.', 'qslm.yes': 'Yes', 'qslm.no': 'No', 'qslm.requested': 'Requested', 'qslm.ignore': 'Ignore', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Electronic', 'qslm.svcPota': 'POTA hunter log', 'qslm.svcPaper': 'Paper QSL', 'qslm.sentRequested': 'Requested', 'qslm.sentNo': 'No', 'qslm.sentQueued': 'Queued', 'qslm.sentYes': 'Yes (already sent)', 'qslm.sentInvalid': 'Invalid', 'qslm.sentBlank': '— blank —', 'qslm.qsoUpdated': '{n} QSO updated.', 'qslm.service': 'Service', 'qslm.callsign': 'Callsign', 'qslm.callsignScopeTitle': "Upload/download is scoped to this callsign (Force station callsign, else the active profile's call)", 'qslm.syncHunterLog': 'Sync hunter log', 'qslm.onlyMyCallTitle': "Only sync hunts made under your active profile's callsign — skip QSOs you made under another call (e.g. XV9Q, NQ2H) that aren't in this logbook", 'qslm.onlyMyCall': 'Only my profile callsign', 'qslm.addMissingTitle': "Insert hunter-log contacts whose callsign isn't in your log yet (callsign/date/band/mode/park)", 'qslm.addMissing': 'Add not-found QSOs to my log', 'qslm.potaToken': 'Token in Settings → External services → POTA.', 'qslm.callsignPlaceholder': 'e.g. DL1ABC', 'qslm.search': 'Search', 'qslm.paperHint': 'Find a callsign, then set QSL sent/received + via + date on the selection.', 'qslm.sentStatus': 'Sent status', 'qslm.selectRequired': 'Select required', 'qslm.potaSummaryShort': '{updated} updated · {added} added · {already} already · {unmatched} unmatched', 'qslm.potaOtherCall': ' · {n} other call', 'qslm.paperCount': '{total} QSO · {selected} selected', 'qslm.filter': 'Filter', 'qslm.filterAll': 'All', 'qslm.filterNew': 'New (any)', 'qslm.filterNewDxcc': 'New DXCC', 'qslm.filterNewBand': 'New band', 'qslm.filterNewMode': 'New mode', 'qslm.filterNewSlot': 'New slot', 'qslm.results': 'Results', 'qslm.log': 'Log', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} found · {selected} selected', 'qslm.paperEmpty': 'Search a callsign to list its QSOs, then set QSL status below.', 'qslm.potaEmpty': 'Click "Sync hunter log" to fetch your pota.app log and stamp park references.', 'qslm.potaSyncing': 'Syncing with pota.app…', 'qslm.potaSummary': '{updated} QSO updated · {added} added to log · {already} already tagged · {unmatched} unmatched (of {fetched} hunter-log entries).', 'qslm.potaSkipped': ' {n} hunt(s) made under another callsign were skipped', 'qslm.potaKeptOnly': ' (kept only {call})', 'qslm.potaRescan': 'Rescan the POTA award to count the new references.', 'qslm.thActivator': 'Activator', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Band', 'qslm.thPark': 'Park', 'qslm.thWhyUnmatched': 'Why unmatched', 'qslm.openToFix': 'Open this QSO to fix it', 'qslm.starting': 'starting…', 'qslm.working': 'working…', 'qslm.noNewConf': 'No new confirmations.', 'qslm.noConfMatch': 'No confirmations match this filter.', 'qslm.thCallsign': 'Callsign', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Country', 'qslm.thNew': 'New?', 'qslm.newDxcc': 'NEW DXCC', 'qslm.newBand': 'NEW BAND', 'qslm.newMode': 'NEW MODE', 'qslm.newSlot': 'NEW SLOT', 'qslm.uploadEmpty': 'Pick a service + sent status, then "Select required".', 'qslm.qslReceived': 'QSL received', 'qslm.qslRcvdDateTitle': 'QSL received date', 'qslm.qslSent': 'QSL sent', 'qslm.qslSentDateTitle': 'QSL sent date', 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'e.g. paid 3€', 'qslm.comment': 'Comment', 'qslm.commentPlaceholder': 'comment', 'qslm.applyToSelected': 'Apply to {n} selected', 'qslm.downloadTitle': 'Fetch confirmations from the service and update received status', 'qslm.downloadConf': 'Download confirmations', 'qslm.downloadRangeTitle': 'How far back to download', 'qslm.sinceLast': 'Since last download', 'qslm.sinceDate': 'Since date…', 'qslm.sinceAll': 'All', 'qslm.sinceDateTitleQrz': 'QRZ: filters by QSO date (no server-side received-date filter)', 'qslm.sinceDateTitleLotw': 'LoTW: confirmations received since this date', 'qslm.addNotFoundTitle': "Insert confirmed QSOs that aren't in your log yet", 'qslm.addNotFound': 'Add not-found', 'qslm.uploadTo': 'Upload {n} to {service}', 'qedit.qslDash': '—', 'qedit.qslYes': 'Yes', 'qedit.qslNo': 'No', 'qedit.qslRequested': 'Requested', 'qedit.qslIgnore': 'Ignore', 'qedit.statusModified': 'Modified', 'qedit.confQslPaper': 'QSL (paper)', 'qedit.callsignRequired': 'Callsign required', 'qedit.lookupError': 'Lookup: {msg}', 'qedit.title': 'Edit QSO', 'qedit.editFieldsFor': 'Edit fields for QSO #{id}', 'qedit.tabQsoInfo': 'QSO Info', 'qedit.tabContact': "Contact's details", 'qedit.tabAwards': 'Award Refs', 'qedit.tabQsl': 'QSL Info', 'qedit.tabContest': 'Contest', 'qedit.tabSat': 'Sat / Prop', 'qedit.tabMyStation': 'My Station', 'qedit.tabMoreAdif': 'More ADIF', 'qedit.tabAdifFields': 'ADIF fields', 'qedit.callsign': 'Callsign', 'qedit.fetchTitle': 'Look up this callsign (QRZ.com / HamQTH) and refresh name, country, grid, zones…', 'qedit.fetch': 'Fetch', 'qedit.name': 'Name', 'qedit.band': 'Band', 'qedit.rxBand': 'RX Band', 'qedit.mode': 'Mode', 'qedit.country': 'Country', 'qedit.dxccTitle': 'DXCC entity # — set automatically from Country', 'qedit.txFreq': 'TX Freq', 'qedit.rxFreq': 'RX Freq', 'qedit.qsoStart': 'QSO Start (UTC)', 'qedit.qsoEnd': 'QSO End (UTC)', 'qedit.grid': 'Grid', 'qedit.comment': 'Comment', 'qedit.note': 'Note', 'qedit.county': 'County', 'qedit.state': 'State', 'qedit.continent': 'Continent', 'qedit.address': 'Address', 'qedit.email': 'E-mail address', 'qedit.qslMsg': 'QSL Msg', 'qedit.qslVia': 'QSL Via', 'qedit.computedAuto': 'Computed (automatic)', 'qedit.computedHint': "Derived from this QSO's fields (DXCC, zones, prefix, notes…). Not editable here.", 'qedit.noneYet': 'None yet.', 'qedit.manageConf': 'Manage Confirmation', 'qedit.sent': 'Sent', 'qedit.received': 'Received', 'qedit.dateSent': 'Date sent', 'qedit.dateReceived': 'Date received', 'qedit.via': 'Via', 'qedit.viaPlaceholder': 'BUREAU / DIRECT / manager…', 'qedit.qslPanelHint': 'Pick a channel, edit it — the table on the right updates live. Everything is written when you click', 'qedit.saveChanges': 'Save changes', 'qedit.thType': 'Type', 'qedit.contestId': 'Contest ID', 'qedit.rcvdExchange': 'rcvd exchange', 'qedit.sentExchange': 'sent exchange', 'qedit.check': 'Check', 'qedit.precedence': 'Precedence', 'qedit.arrlSection': 'ARRL section', 'qedit.propMode': 'Propagation mode', 'qedit.satName': 'Satellite name', 'qedit.satMode': 'Satellite mode', 'qedit.antAz': 'Antenna AZ (°)', 'qedit.antEl': 'Antenna EL (°)', 'qedit.antPath': 'Antenna path', 'qedit.myStationHint': 'These override the active station profile for this QSO only.', 'qedit.stationCallsign': 'Station callsign', 'qedit.operator': 'Operator', 'qedit.myGrid': 'My grid', 'qedit.gridExt': 'Grid ext', 'qedit.cqZone': 'CQ zone', 'qedit.ituZone': 'ITU zone', 'qedit.sotaRef': 'SOTA ref', 'qedit.potaRef': 'POTA ref', 'qedit.street': 'Street', 'qedit.city': 'City', 'qedit.postal': 'Postal', 'qedit.rig': 'Rig', 'qedit.antenna': 'Antenna', 'qedit.specialActivity': 'Special activity', 'qedit.sigInfo': 'SIG info', 'qedit.wwffRef': 'WWFF ref', 'qedit.region': 'Region', 'qedit.powerWeather': 'Power & space weather', 'qedit.rxPower': 'RX power (W)', 'qedit.distance': 'Distance (km)', 'qedit.aIndex': 'A index', 'qedit.kIndex': 'K index', 'qedit.identityClubs': 'Identity & clubs', 'qedit.contactedOp': 'Contacted op', 'qedit.formerCall': 'Former call (EQ_CALL)', 'qedit.class': 'Class', 'qedit.flagsCredits': 'Flags & credits', 'qedit.qsoComplete': 'QSO complete', 'qedit.qsoRandom': 'QSO random', 'qedit.silentKey': 'Silent key', 'qedit.creditGranted': 'Credit granted', 'qedit.creditSubmitted': 'Credit submitted', 'qedit.myStationAdif': 'My station (ADIF)', 'qedit.myName': 'My name', 'qedit.myWwffRef': 'My WWFF ref', 'qedit.myArrlSect': 'My ARRL sect', 'qedit.mySig': 'My SIG', 'qedit.mySigInfo': 'My SIG info', 'qedit.myDarcDok': 'My DARC DOK', 'qedit.myVuccGrids': 'My VUCC grids', 'qedit.delete': 'Delete', 'qedit.cancel': 'Cancel', 'qedit.saving': 'Saving…', // Grids column headers (recent / worked-before / cluster) @@ -352,6 +354,8 @@ const fr: Dict = { 'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.contest': 'Mode contest', 'alert.tuneHint': 'Cliquer pour accorder la radio sur ce spot (fréq + mode) et remplir l\'indicatif', 'alert.dismiss': 'Fermer', 'alert.pending': '{n} alerte(s) de spot récente(s) — cliquer pour voir', 'alert.noneShort': 'Aucune alerte récente', 'alert.recent': 'Alertes récentes', 'alert.clear': 'Effacer', 'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog', 'tools.duplicates': 'Trouver les doublons…', + 'help.sendLog': 'Envoyer le journal à F4BPO', 'help.sendLogBusy': 'Envoi du journal…', + 'help.sendLogOk': 'Journal envoyé à F4BPO — merci, 73 !', 'help.sendLogFail': 'Échec de l\'envoi du journal : {err}', 'dup.title': 'Trouver les doublons', 'dup.scanning': 'Analyse du journal…', 'dup.none': 'Aucun doublon trouvé. 🎉', 'dup.hint': 'Chaque groupe est le même contact enregistré plusieurs fois (indicatif + bande + mode). Le premier (le plus ancien) est laissé décoché ; coche ceux à supprimer.', 'dup.window': 'En moins de', 'dup.minutes': 'min', 'dup.sameDay': 'même jour', 'dup.summary': '{groups} groupes · {qsos} QSO en double · {sel} à supprimer', @@ -575,7 +579,7 @@ const fr: Dict = { 'wkp.cwSpeed': 'Vitesse CW (WPM)', 'wkp.faster': 'Plus rapide', 'wkp.slower': 'Plus lent', 'wkp.cwText': 'Texte CW', 'wkp.sendOnTypeHint': 'Manipule chaque caractère en direct à la frappe (retour arrière supprime les caractères non émis)', 'wkp.sendOnType': 'émission à la frappe', 'wkp.phLive': 'Tape — émis en direct…', 'wkp.phEnter': 'Tape et appuie sur Entrée pour émettre…', 'wkp.clear': 'Effacer', 'wkp.send': 'Émettre', 'wkp.abort': 'Interrompre (vider le tampon du manipulateur)', 'wkp.stop': 'Stop', 'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL", 'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}', - 'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1–F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message', + 'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Répète un message libellé CQ à intervalle régulier jusqu\'à l\'arrêt ou la lecture d\'un autre slot', 'dvkp.gap': 'Intervalle', 'dvkp.notPhone': 'Le manipulateur vocal n\'émet qu\'en mode phonie (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1–F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message', 'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour n’afficher que la bande courante', 'flxp.ritHint': "RIT — décale uniquement ta fréquence de RÉCEPTION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.xitHint': "XIT — décale uniquement ta fréquence d'ÉMISSION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai', @@ -619,7 +623,7 @@ const fr: Dict = { 'awed.importReplace': 'Remplacer le mien', 'awed.importCopy': 'Importer en {code}-2', 'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis QRZ.com', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…', - 'bulk.fLotwSent': 'LoTW envoyé', 'bulk.fLotwRcvd': 'LoTW reçu', 'bulk.fEqslSent': 'eQSL envoyé', 'bulk.fEqslRcvd': 'eQSL reçu', 'bulk.fQslSent': 'QSL papier envoyée', 'bulk.fQslRcvd': 'QSL papier reçue', 'bulk.fQrzUpload': 'Envoi QRZ.com', 'bulk.fClublogUpload': 'Envoi Club Log', 'bulk.fHrdlogUpload': 'Envoi HRDLog', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Indicatif de la station', 'bulk.fOperator': 'Opérateur', 'bulk.fMyGrid': 'Mon locator', 'bulk.fMyAntenna': 'Mon antenne', 'bulk.fMyRig': 'Mon équipement', 'bulk.fMyStreet': 'Ma rue', 'bulk.fMyCity': 'Ma ville', 'bulk.fMyPostal': 'Mon code postal', 'bulk.fMyCountry': 'Mon pays', 'bulk.fMyState': 'Mon état', 'bulk.fMyCounty': 'Mon comté', 'bulk.fMyIota': 'Mon IOTA', 'bulk.fMySota': 'Ma réf. SOTA', 'bulk.fMyPota': 'Ma réf. POTA', 'bulk.fMyWwff': 'Ma réf. WWFF', 'bulk.fMySig': 'Mon SIG', 'bulk.fMyName': 'Mon nom', 'bulk.fMyArrlSect': 'Ma section ARRL', 'bulk.fMyDarcDok': 'Mon DARC DOK', 'bulk.fMyVuccGrids': 'Mes carrés VUCC', 'bulk.fMySigInfo': 'Mon info SIG', 'bulk.fContestId': 'ID concours', 'bulk.fSrxString': 'Série reçue (échange)', 'bulk.fStxString': 'Série envoyée (échange)', 'bulk.fArrlSect': 'Section ARRL', 'bulk.fPrecedence': 'Précédence', 'bulk.fClass': 'Classe', 'bulk.fPropMode': 'Mode de propagation', 'bulk.fSatName': 'Nom du satellite', 'bulk.fSatMode': 'Mode satellite', 'bulk.fState': 'État', 'bulk.fCnty': 'Comté', 'bulk.fPotaRef': 'Réf POTA', 'bulk.fSotaRef': 'Réf SOTA', 'bulk.fWwffRef': 'Réf WWFF', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'Info SIG', 'bulk.fComment': 'Commentaire', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Équipement (contacté)', 'bulk.fAnt': 'Antenne (contactée)', 'bulk.statusY': 'Y — Oui / envoyé', 'bulk.statusN': 'N — Non', 'bulk.statusR': 'R — Demandé', 'bulk.statusI': 'I — Ignorer', 'bulk.statusBlank': '(vide — effacer)', 'bulk.groupQsl': 'QSL / envoi', 'bulk.groupMyStation': 'Ma station', 'bulk.groupContacted': 'Station contactée', 'bulk.groupContest': 'Concours', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Divers', 'bulk.title': "Édition groupée d'un champ", 'bulk.desc': 'Définir un champ sur les {n} QSO sélectionné(s). Cela écrase la valeur actuelle — aucune annulation possible.', 'bulk.fieldLabel': 'Champ', 'bulk.valueLabel': 'Valeur', 'bulk.clearPlaceholder': 'laisser vide pour effacer le champ', 'bulk.willSet': 'Définira', 'bulk.blank': '(vide)', 'bulk.onQsos': 'sur {n} QSO.', 'bulk.cancel': 'Annuler', 'bulk.applyTo': 'Appliquer à {n}', + 'bulk.fLotwSent': 'LoTW envoyé', 'bulk.fLotwRcvd': 'LoTW reçu', 'bulk.fEqslSent': 'eQSL envoyé', 'bulk.fEqslRcvd': 'eQSL reçu', 'bulk.fQslSent': 'QSL papier envoyée', 'bulk.fQslRcvd': 'QSL papier reçue', 'bulk.fQrzUpload': 'Envoi QRZ.com', 'bulk.fClublogUpload': 'Envoi Club Log', 'bulk.fHrdlogUpload': 'Envoi HRDLog', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Indicatif de la station', 'bulk.fOperator': 'Opérateur', 'bulk.fMyGrid': 'Mon locator', 'bulk.fMyAntenna': 'Mon antenne', 'bulk.fMyRig': 'Mon équipement', 'bulk.fMyStreet': 'Ma rue', 'bulk.fMyCity': 'Ma ville', 'bulk.fMyPostal': 'Mon code postal', 'bulk.fMyCountry': 'Mon pays', 'bulk.fMyState': 'Mon état', 'bulk.fMyCounty': 'Mon comté', 'bulk.fMyIota': 'Mon IOTA', 'bulk.fMySota': 'Ma réf. SOTA', 'bulk.fMyPota': 'Ma réf. POTA', 'bulk.fMyWwff': 'Ma réf. WWFF', 'bulk.fMySig': 'Mon SIG', 'bulk.fMyName': 'Mon nom', 'bulk.fMyArrlSect': 'Ma section ARRL', 'bulk.fMyDarcDok': 'Mon DARC DOK', 'bulk.fMyVuccGrids': 'Mes carrés VUCC', 'bulk.fMySigInfo': 'Mon info SIG', 'bulk.fContestId': 'ID concours', 'bulk.fSrxString': 'Série reçue (échange)', 'bulk.fStxString': 'Série envoyée (échange)', 'bulk.fArrlSect': 'Section ARRL', 'bulk.fPrecedence': 'Précédence', 'bulk.fClass': 'Classe', 'bulk.fPropMode': 'Mode de propagation', 'bulk.fSatName': 'Nom du satellite', 'bulk.fSatMode': 'Mode satellite', 'bulk.fState': 'État', 'bulk.fCnty': 'Comté', 'bulk.fPotaRef': 'Réf POTA', 'bulk.fSotaRef': 'Réf SOTA', 'bulk.fWwffRef': 'Réf WWFF', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'Info SIG', 'bulk.fFreq': 'Fréquence (MHz)', 'bulk.freqPlaceholder': 'MHz — p. ex. 7.155', 'bulk.fComment': 'Commentaire', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Équipement (contacté)', 'bulk.fAnt': 'Antenne (contactée)', 'bulk.statusY': 'Y — Oui / envoyé', 'bulk.statusN': 'N — Non', 'bulk.statusR': 'R — Demandé', 'bulk.statusI': 'I — Ignorer', 'bulk.statusBlank': '(vide — effacer)', 'bulk.groupQsl': 'QSL / envoi', 'bulk.groupMyStation': 'Ma station', 'bulk.groupContacted': 'Station contactée', 'bulk.groupContest': 'Concours', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Divers', 'bulk.title': "Édition groupée d'un champ", 'bulk.desc': 'Définir un champ sur les {n} QSO sélectionné(s). Cela écrase la valeur actuelle — aucune annulation possible.', 'bulk.fieldLabel': 'Champ', 'bulk.valueLabel': 'Valeur', 'bulk.clearPlaceholder': 'laisser vide pour effacer le champ', 'bulk.willSet': 'Définira', 'bulk.blank': '(vide)', 'bulk.onQsos': 'sur {n} QSO.', 'bulk.cancel': 'Annuler', 'bulk.applyTo': 'Appliquer à {n}', 'qslm.leave': '— laisser —', 'qslm.slots': 'slots', 'qslm.bands': 'bandes', 'qslm.countsTip': "Contactés / confirmés (LoTW + QSL papier). Slots = combinaisons distinctes entité × bande × classe (Phone/CW/Digital). La granularité RTTY ≠ FT8 ne sert qu'au tag NEW SLOT du cluster/matrice.", 'qslm.yes': 'Oui', 'qslm.no': 'Non', 'qslm.requested': 'Demandé', 'qslm.ignore': 'Ignorer', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Électronique', 'qslm.svcPota': 'Journal chasseur POTA', 'qslm.svcPaper': 'QSL papier', 'qslm.sentRequested': 'Demandé', 'qslm.sentNo': 'Non', 'qslm.sentQueued': 'En file', 'qslm.sentYes': 'Oui (déjà envoyé)', 'qslm.sentInvalid': 'Invalide', 'qslm.sentBlank': '— vide —', 'qslm.qsoUpdated': '{n} QSO mis à jour.', 'qslm.service': 'Service', 'qslm.callsign': 'Indicatif', 'qslm.callsignScopeTitle': "L'envoi/téléchargement est limité à cet indicatif (indicatif de station forcé, sinon celui du profil actif)", 'qslm.syncHunterLog': 'Synchroniser le journal chasseur', 'qslm.onlyMyCallTitle': "Ne synchroniser que les chasses faites sous l'indicatif de votre profil actif — ignorer les QSO faits sous un autre indicatif (ex. XV9Q, NQ2H) absents de ce journal", 'qslm.onlyMyCall': "Uniquement l'indicatif de mon profil", 'qslm.addMissingTitle': "Insérer les contacts du journal chasseur dont l'indicatif n'est pas encore dans votre journal (indicatif/date/bande/mode/parc)", 'qslm.addMissing': 'Ajouter les QSO introuvables à mon journal', 'qslm.potaToken': 'Jeton dans Réglages → Services externes → POTA.', 'qslm.callsignPlaceholder': 'ex. DL1ABC', 'qslm.search': 'Rechercher', 'qslm.paperHint': 'Trouvez un indicatif, puis définissez QSL envoyée/reçue + via + date sur la sélection.', 'qslm.sentStatus': 'Statut envoyé', 'qslm.selectRequired': 'Sélectionner les requis', 'qslm.potaSummaryShort': '{updated} mis à jour · {added} ajoutés · {already} déjà · {unmatched} sans correspondance', 'qslm.potaOtherCall': ' · {n} autre indicatif', 'qslm.paperCount': '{total} QSO · {selected} sélectionné(s)', 'qslm.filter': 'Filtre', 'qslm.filterAll': 'Tous', 'qslm.filterNew': 'Nouveau (tout)', 'qslm.filterNewDxcc': 'Nouveau DXCC', 'qslm.filterNewBand': 'Nouvelle bande', 'qslm.filterNewMode': 'Nouveau mode', 'qslm.filterNewSlot': 'Nouveau créneau', 'qslm.results': 'Résultats', 'qslm.log': 'Journal', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} trouvé(s) · {selected} sélectionné(s)', 'qslm.paperEmpty': 'Recherchez un indicatif pour lister ses QSO, puis définissez le statut QSL ci-dessous.', 'qslm.potaEmpty': 'Cliquez sur « Synchroniser le journal chasseur » pour récupérer votre journal pota.app et tamponner les références de parc.', 'qslm.potaSyncing': 'Synchronisation avec pota.app…', 'qslm.potaSummary': '{updated} QSO mis à jour · {added} ajoutés au journal · {already} déjà tamponnés · {unmatched} sans correspondance (sur {fetched} entrées du journal chasseur).', 'qslm.potaSkipped': ' {n} chasse(s) faites sous un autre indicatif ont été ignorées', 'qslm.potaKeptOnly': ' (conservé uniquement {call})', 'qslm.potaRescan': 'Relancez le scan du diplôme POTA pour compter les nouvelles références.', 'qslm.thActivator': 'Activateur', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Bande', 'qslm.thPark': 'Parc', 'qslm.thWhyUnmatched': 'Pourquoi sans correspondance', 'qslm.openToFix': 'Ouvrir ce QSO pour le corriger', 'qslm.starting': 'démarrage…', 'qslm.working': 'en cours…', 'qslm.noNewConf': 'Aucune nouvelle confirmation.', 'qslm.noConfMatch': 'Aucune confirmation ne correspond à ce filtre.', 'qslm.thCallsign': 'Indicatif', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Pays', 'qslm.thNew': 'Nouveau ?', 'qslm.newDxcc': 'NOUVEAU DXCC', 'qslm.newBand': 'NOUVELLE BANDE', 'qslm.newMode': 'NOUVEAU MODE', 'qslm.newSlot': 'NOUVEAU CRÉNEAU', 'qslm.uploadEmpty': 'Choisissez un service + statut envoyé, puis « Sélectionner les requis ».', 'qslm.qslReceived': 'QSL reçue', 'qslm.qslRcvdDateTitle': 'Date de réception QSL', 'qslm.qslSent': 'QSL envoyée', 'qslm.qslSentDateTitle': "Date d'envoi QSL", 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'ex. payé 3€', 'qslm.comment': 'Commentaire', 'qslm.commentPlaceholder': 'commentaire', 'qslm.applyToSelected': 'Appliquer à {n} sélectionné(s)', 'qslm.downloadTitle': 'Récupérer les confirmations du service et mettre à jour le statut reçu', 'qslm.downloadConf': 'Télécharger les confirmations', 'qslm.downloadRangeTitle': "Jusqu'où télécharger", 'qslm.sinceLast': 'Depuis le dernier téléchargement', 'qslm.sinceDate': 'Depuis une date…', 'qslm.sinceAll': 'Tout', 'qslm.sinceDateTitleQrz': 'QRZ : filtre par date de QSO (pas de filtre par date de réception côté serveur)', 'qslm.sinceDateTitleLotw': 'LoTW : confirmations reçues depuis cette date', 'qslm.addNotFoundTitle': "Insérer les QSO confirmés qui ne sont pas encore dans votre journal", 'qslm.addNotFound': 'Ajouter les introuvables', 'qslm.uploadTo': 'Envoyer {n} vers {service}', 'qedit.qslDash': '—', 'qedit.qslYes': 'Oui', 'qedit.qslNo': 'Non', 'qedit.qslRequested': 'Demandé', 'qedit.qslIgnore': 'Ignorer', 'qedit.statusModified': 'Modifié', 'qedit.confQslPaper': 'QSL (papier)', 'qedit.callsignRequired': 'Indicatif requis', 'qedit.lookupError': 'Recherche : {msg}', 'qedit.title': 'Modifier le QSO', 'qedit.editFieldsFor': 'Modifier les champs du QSO #{id}', 'qedit.tabQsoInfo': 'Infos QSO', 'qedit.tabContact': 'Détails du contact', 'qedit.tabAwards': 'Réf. diplômes', 'qedit.tabQsl': 'Infos QSL', 'qedit.tabContest': 'Concours', 'qedit.tabSat': 'Sat / Prop', 'qedit.tabMyStation': 'Ma station', 'qedit.tabMoreAdif': "Plus d'ADIF", 'qedit.tabAdifFields': 'Champs ADIF', 'qedit.callsign': 'Indicatif', 'qedit.fetchTitle': 'Rechercher cet indicatif (QRZ.com / HamQTH) et actualiser nom, pays, locator, zones…', 'qedit.fetch': 'Rechercher', 'qedit.name': 'Nom', 'qedit.band': 'Bande', 'qedit.rxBand': 'Bande RX', 'qedit.mode': 'Mode', 'qedit.country': 'Pays', 'qedit.dxccTitle': "Entité DXCC n° — définie automatiquement d'après le pays", 'qedit.txFreq': 'Fréq. TX', 'qedit.rxFreq': 'Fréq. RX', 'qedit.qsoStart': 'Début QSO (UTC)', 'qedit.qsoEnd': 'Fin QSO (UTC)', 'qedit.grid': 'Locator', 'qedit.comment': 'Commentaire', 'qedit.note': 'Note', 'qedit.county': 'Comté', 'qedit.state': 'État', 'qedit.continent': 'Continent', 'qedit.address': 'Adresse', 'qedit.email': 'Adresse e-mail', 'qedit.qslMsg': 'Message QSL', 'qedit.qslVia': 'QSL Via', 'qedit.computedAuto': 'Calculé (automatique)', 'qedit.computedHint': 'Dérivé des champs de ce QSO (DXCC, zones, préfixe, notes…). Non modifiable ici.', 'qedit.noneYet': "Aucun pour l'instant.", 'qedit.manageConf': 'Gérer la confirmation', 'qedit.sent': 'Envoyé', 'qedit.received': 'Reçu', 'qedit.dateSent': "Date d'envoi", 'qedit.dateReceived': 'Date de réception', 'qedit.via': 'Via', 'qedit.viaPlaceholder': 'BUREAU / DIRECT / manager…', 'qedit.qslPanelHint': 'Choisissez un canal, modifiez-le — le tableau de droite se met à jour en direct. Tout est enregistré quand vous cliquez sur', 'qedit.saveChanges': 'Enregistrer', 'qedit.thType': 'Type', 'qedit.contestId': 'ID concours', 'qedit.rcvdExchange': 'échange reçu', 'qedit.sentExchange': 'échange envoyé', 'qedit.check': 'Check', 'qedit.precedence': 'Précédence', 'qedit.arrlSection': 'Section ARRL', 'qedit.propMode': 'Mode de propagation', 'qedit.satName': 'Nom du satellite', 'qedit.satMode': 'Mode satellite', 'qedit.antAz': 'Azimut antenne (°)', 'qedit.antEl': 'Élévation antenne (°)', 'qedit.antPath': "Chemin d'antenne", 'qedit.myStationHint': 'Ces valeurs remplacent le profil de station actif pour ce QSO uniquement.', 'qedit.stationCallsign': 'Indicatif de la station', 'qedit.operator': 'Opérateur', 'qedit.myGrid': 'Mon locator', 'qedit.gridExt': 'Ext. locator', 'qedit.cqZone': 'Zone CQ', 'qedit.ituZone': 'Zone ITU', 'qedit.sotaRef': 'Réf. SOTA', 'qedit.potaRef': 'Réf. POTA', 'qedit.street': 'Rue', 'qedit.city': 'Ville', 'qedit.postal': 'Code postal', 'qedit.rig': 'Équipement', 'qedit.antenna': 'Antenne', 'qedit.specialActivity': 'Activité spéciale', 'qedit.sigInfo': 'Info SIG', 'qedit.wwffRef': 'Réf. WWFF', 'qedit.region': 'Région', 'qedit.powerWeather': 'Puissance et météo spatiale', 'qedit.rxPower': 'Puissance RX (W)', 'qedit.distance': 'Distance (km)', 'qedit.aIndex': 'Indice A', 'qedit.kIndex': 'Indice K', 'qedit.identityClubs': 'Identité et clubs', 'qedit.contactedOp': 'Opérateur contacté', 'qedit.formerCall': 'Ancien indicatif (EQ_CALL)', 'qedit.class': 'Classe', 'qedit.flagsCredits': 'Indicateurs et crédits', 'qedit.qsoComplete': 'QSO complet', 'qedit.qsoRandom': 'QSO aléatoire', 'qedit.silentKey': 'Silent key', 'qedit.creditGranted': 'Crédit accordé', 'qedit.creditSubmitted': 'Crédit soumis', 'qedit.myStationAdif': 'Ma station (ADIF)', 'qedit.myName': 'Mon nom', 'qedit.myWwffRef': 'Ma réf. WWFF', 'qedit.myArrlSect': 'Ma section ARRL', 'qedit.mySig': 'Mon SIG', 'qedit.mySigInfo': 'Mon info SIG', 'qedit.myDarcDok': 'Mon DARC DOK', 'qedit.myVuccGrids': 'Mes locators VUCC', 'qedit.delete': 'Supprimer', 'qedit.cancel': 'Annuler', 'qedit.saving': 'Enregistrement…', 'rqg.c.qso_date': 'Date UTC', 'rqg.c.qso_date_off': 'Date fin', 'rqg.c.callsign': 'Indicatif', 'rqg.c.band': 'Bande', 'rqg.c.band_rx': 'Bande RX', 'rqg.c.mode': 'Mode', 'rqg.c.submode': 'Sous-mode', 'rqg.c.freq_hz': 'Fréq (TX)', 'rqg.h.freq_hz': 'Fréq', 'rqg.c.freq_rx_hz': 'Fréq (RX)', 'rqg.h.freq_rx_hz': 'Fréq RX', 'rqg.c.rst_sent': 'RST env', 'rqg.c.rst_rcvd': 'RST reçu', 'rqg.c.tx_pwr': 'Puiss. TX', 'rqg.c.name': 'Nom', 'rqg.c.qth': 'QTH', 'rqg.c.address': 'Adresse', 'rqg.c.country': 'Pays', 'rqg.c.state': 'État', 'rqg.c.cnty': 'Comté', 'rqg.c.cont': 'Continent', 'rqg.h.cont': 'Cont', 'rqg.c.grid': 'Locator', 'rqg.c.gridsquare_ext': 'Ext. loc.', 'rqg.h.gridsquare_ext': 'ExtLoc', 'rqg.c.vucc_grids': 'Locators VUCC', 'rqg.h.vucc_grids': 'VUCC', 'rqg.c.dxcc': 'DXCC #', 'rqg.c.cqz': 'CQZ', 'rqg.c.ituz': 'ITU', 'rqg.c.iota': 'IOTA', 'rqg.c.sota_ref': 'Réf. SOTA', 'rqg.h.sota_ref': 'SOTA', 'rqg.c.pota_ref': 'Réf. POTA', 'rqg.h.pota_ref': 'POTA', 'rqg.c.age': 'Âge', 'rqg.c.lat': 'Lat', 'rqg.c.lon': 'Lon', 'rqg.c.email': 'E-mail', 'rqg.c.web': 'Web', 'rqg.c.qsl_sent': 'QSL env', 'rqg.c.qsl_rcvd': 'QSL reçu', 'rqg.c.qsl_sent_date': 'Date env QSL', 'rqg.h.qsl_sent_date': 'QSL env.', 'rqg.c.qsl_rcvd_date': 'Date reçu QSL', 'rqg.h.qsl_rcvd_date': 'QSL reçu', 'rqg.c.qsl_via': 'QSL via', 'rqg.c.qsl_msg': 'Msg QSL', 'rqg.c.qslmsg_rcvd': 'Msg QSL reçu', 'rqg.c.lotw_sent': 'LoTW env', 'rqg.c.lotw_rcvd': 'LoTW reçu', 'rqg.c.lotw_sent_date': 'Date env LoTW', 'rqg.h.lotw_sent_date': 'LoTW env.', 'rqg.c.lotw_rcvd_date': 'Date reçu LoTW', 'rqg.h.lotw_rcvd_date': 'LoTW reçu', 'rqg.c.eqsl_sent': 'eQSL env', 'rqg.c.eqsl_rcvd': 'eQSL reçu', 'rqg.c.eqsl_sent_date': 'Date env eQSL', 'rqg.h.eqsl_sent_date': 'eQSL env.', 'rqg.c.eqsl_rcvd_date': 'Date reçu eQSL', 'rqg.h.eqsl_rcvd_date': 'eQSL reçu', 'rqg.c.opslog_qsl_card_sent': 'QSL OpsLog', 'rqg.c.opslog_recording_sent': 'Enreg. envoyé', 'rqg.h.opslog_recording_sent': 'Enr. env', 'rqg.c.clublog_sent': 'ClubLog env', 'rqg.c.clublog_sent_date': 'Date env ClubLog', 'rqg.h.clublog_sent_date': 'ClubLog env.', 'rqg.c.hrdlog_sent': 'HRDLog env', 'rqg.c.hrdlog_sent_date': 'Date env HRDLog', 'rqg.h.hrdlog_sent_date': 'HRDLog env.', 'rqg.c.qrz_sent': 'QRZ.com env', 'rqg.c.qrz_rcvd': 'QRZ.com reçu', 'rqg.c.qrz_sent_date': 'Date env QRZ.com', 'rqg.h.qrz_sent_date': 'QRZ.com env.', 'rqg.c.qrz_rcvd_date': 'Date reçu QRZ.com', 'rqg.h.qrz_rcvd_date': 'QRZ.com reçu', 'rqg.c.contest_id': 'ID concours', 'rqg.h.contest_id': 'Concours', 'rqg.c.srx': 'SRX', 'rqg.c.stx': 'STX', 'rqg.c.srx_string': 'Chaîne SRX', 'rqg.h.srx_string': 'SRX str', 'rqg.c.stx_string': 'Chaîne STX', 'rqg.h.stx_string': 'STX str', 'rqg.c.check': 'Check', 'rqg.c.precedence': 'Précédence', 'rqg.c.arrl_sect': 'Section ARRL', 'rqg.h.arrl_sect': 'Sect. ARRL', 'rqg.c.prop_mode': 'Mode prop.', 'rqg.h.prop_mode': 'Prop', 'rqg.c.sat_name': 'Nom sat.', 'rqg.h.sat_name': 'Sat', 'rqg.c.sat_mode': 'Mode sat.', 'rqg.c.ant_az': 'Azimut ant.', 'rqg.h.ant_az': 'Az', 'rqg.c.ant_el': 'Élévation ant.', 'rqg.h.ant_el': 'Él', 'rqg.c.ant_path': 'Chemin ant.', 'rqg.h.ant_path': 'Chemin', 'rqg.c.station_callsign': 'Indicatif station', 'rqg.h.station_callsign': 'Station', 'rqg.c.operator': 'Opérateur', 'rqg.c.my_grid': 'Mon locator', 'rqg.c.my_country': 'Mon pays', 'rqg.h.my_country': 'Mon pays', 'rqg.c.my_state': 'Mon état', 'rqg.c.my_cnty': 'Mon comté', 'rqg.h.my_cnty': 'Mon comté', 'rqg.c.my_iota': 'Mon IOTA', 'rqg.c.my_sota': 'Mon SOTA', 'rqg.c.my_pota': 'Mon POTA', 'rqg.c.my_dxcc': 'Mon DXCC', 'rqg.h.my_dxcc': 'Mon DXCC#', 'rqg.c.my_cq_zone': 'Ma zone CQ', 'rqg.h.my_cq_zone': 'Ma CQZ', 'rqg.c.my_itu_zone': 'Ma zone ITU', 'rqg.h.my_itu_zone': 'Ma ITU', 'rqg.c.my_lat': 'Ma lat', 'rqg.c.my_lon': 'Ma lon', 'rqg.c.my_street': 'Ma rue', 'rqg.h.my_street': 'Rue', 'rqg.c.my_city': 'Ma ville', 'rqg.h.my_city': 'Ville', 'rqg.c.my_zip': 'Mon code postal', 'rqg.h.my_zip': 'CP', 'rqg.c.my_rig': 'Mon équipement', 'rqg.c.my_antenna': 'Mon antenne', 'rqg.c.my_name': 'Mon nom', 'rqg.c.my_wwff': 'Mon WWFF', 'rqg.c.my_sig': 'Mon SIG', 'rqg.c.my_sig_info': 'Mon info SIG', 'rqg.c.my_arrl_sect': 'Ma section ARRL', 'rqg.c.my_darc_dok': 'Mon DARC DOK', 'rqg.c.my_vucc_grids': 'Mes carrés VUCC', 'rqg.h.my_antenna': 'Mon ant.', 'rqg.c.comment': 'Commentaire', 'rqg.c.notes': 'Notes', 'rqg.c.created': 'Créé', 'rqg.h.created': 'Créé le', 'rqg.c.updated': 'Mis à jour', 'rqg.h.updated': 'Mis à jour le', 'rqg.grpQso': 'QSO', 'rqg.grpContacted': 'Station contactée', 'rqg.grpQsl': 'QSL', 'rqg.grpLotw': 'LoTW', 'rqg.grpEqsl': 'eQSL', 'rqg.grpUploads': 'Envois', 'rqg.grpContest': 'Concours', 'rqg.grpProp': 'Propagation', 'rqg.grpMyStation': 'Ma station', 'rqg.grpMisc': 'Divers', 'rqg.grpAwards': 'Diplômes', 'rqg.awardTip': '{name} — référence comptée pour ce QSO', 'rqg.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'rqg.clearFilters': 'Effacer les filtres', 'rqg.selectedCount': '{n} sélectionné(s)', 'rqg.selectAll': 'Tout sélectionner', 'rqg.selectAllTitle': 'Sélectionner toutes les lignes affichées (respecte les filtres de colonnes actifs)', 'rqg.unselectAll': 'Tout désélectionner', 'rqg.unselectAllTitle': 'Effacer la sélection', 'rqg.columns': 'Colonnes', 'rqg.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau des QSO récents. Votre sélection est enregistrée.', 'rqg.all': 'tout', 'rqg.none': 'aucun', 'rqg.resetDefaults': 'Réinitialiser', 'rqg.done': 'Terminé', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index cfe70bb..2415c2c 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -762,6 +762,8 @@ export function RotatorStop():Promise; export function RunBackupNow():Promise; +export function SMTPConfigured():Promise; + export function SPESetOperate(arg1:boolean):Promise; export function SPESetPower(arg1:boolean):Promise; @@ -842,6 +844,8 @@ export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise; +export function SendLogToDeveloper():Promise; + export function SendQSORecordingEmail(arg1:number):Promise; export function SetAlertEmailTo(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index bb41393..d22961b 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -1478,6 +1478,10 @@ export function RunBackupNow() { return window['go']['main']['App']['RunBackupNow'](); } +export function SMTPConfigured() { + return window['go']['main']['App']['SMTPConfigured'](); +} + export function SPESetOperate(arg1) { return window['go']['main']['App']['SPESetOperate'](arg1); } @@ -1638,6 +1642,10 @@ export function SendEQSL(arg1, arg2, arg3) { return window['go']['main']['App']['SendEQSL'](arg1, arg2, arg3); } +export function SendLogToDeveloper() { + return window['go']['main']['App']['SendLogToDeveloper'](); +} + export function SendQSORecordingEmail(arg1) { return window['go']['main']['App']['SendQSORecordingEmail'](arg1); } diff --git a/internal/email/email.go b/internal/email/email.go index a214e7e..383045e 100644 --- a/internal/email/email.go +++ b/internal/email/email.go @@ -41,6 +41,16 @@ func (c Config) opts() []mail.Option { // Send delivers a plain-text email to `to`, optionally attaching a file. func Send(cfg Config, to, subject, body, attachPath string) error { + var attach []string + if attachPath != "" { + attach = []string{attachPath} + } + return SendFiles(cfg, to, subject, body, attach) +} + +// SendFiles is like Send but attaches any number of files (missing paths are +// skipped by the mail library at send time). +func SendFiles(cfg Config, to, subject, body string, attachPaths []string) error { if cfg.Host == "" { return fmt.Errorf("SMTP server not configured") } @@ -65,8 +75,10 @@ func Send(cfg Config, to, subject, body, attachPath string) error { } m.Subject(subject) m.SetBodyString(mail.TypeTextPlain, body) - if attachPath != "" { - m.AttachFile(attachPath) + for _, p := range attachPaths { + if p != "" { + m.AttachFile(p) + } } client, err := mail.NewClient(cfg.Host, cfg.opts()...) if err != nil { diff --git a/internal/qso/qso.go b/internal/qso/qso.go index c2e79b5..3ed8c6d 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -792,6 +792,31 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri return n, nil } +// BulkSetFrequency sets freq_hz AND band together on every listed QSO. Kept +// separate from BulkSetField because frequency is numeric and must keep the band +// consistent — the main use is fixing a batch that was logged on a stale/default +// frequency after CAT dropped. freqHz "" is not allowed (caller validates). +func (r *Repo) BulkSetFrequency(ctx context.Context, ids []int64, freqHz int64, band string) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + ph := make([]string, len(ids)) + args := make([]any, 0, len(ids)+3) + args = append(args, freqHz, band, db.NowISO()) + for i, id := range ids { + ph[i] = "?" + args = append(args, id) + } + res, err := r.db.ExecContext(ctx, + `UPDATE qso SET freq_hz = ?, band = ?, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`, + args...) + if err != nil { + return 0, fmt.Errorf("bulk set frequency: %w", err) + } + n, _ := res.RowsAffected() + return n, nil +} + // SetExtra merges ONE key into a QSO's extras JSON without rewriting the rest of // the row. A slow caller that only wants to stamp its own app field (e-mailing a // QSL card, saving a recording) must not do a full-row Update: the row it read may