diff --git a/app.go b/app.go index f536610..872b1bc 100644 --- a/app.go +++ b/app.go @@ -3629,6 +3629,29 @@ func (a *App) computeAwardsForMode(defs []award.Def, modeClass string) ([]award. } refMetas := a.awardRefMetas(defs) results := award.Compute(defs, all, refMetas, nameOf) + // DXCC: fill each reference's Group with the entity's primary prefix (XE, + // DL, F…). Sorting/searching a 340-row list by bare entity number is + // hopeless — the prefix is how operators actually identify an entity. + // Derived from the live cty.dat rather than stored on the reference row, so + // it needs no re-seed of an existing installation. + if a.dxcc != nil { + var pfx map[int]string + for i := range results { + if !strings.EqualFold(results[i].Code, "DXCC") { + continue + } + if pfx == nil { + if pfx = a.dxcc.PrefixByDXCC(); pfx == nil { + break + } + } + for j := range results[i].Refs { + if n, err := strconv.Atoi(results[i].Refs[j].Ref); err == nil { + results[i].Refs[j].Group = pfx[n] + } + } + } + } // Dynamic awards (POTA/SOTA/…) aren't fully loaded into the engine — their // list can be huge. Enrich them after the fact: real Total from the stored // count, and reference names for the worked references only. @@ -6054,6 +6077,38 @@ func (a *App) WorkedBefore(callsign string, dxccHint int) (qso.WorkedBefore, err return wb, err } +// BandSlotQSOs lists the contacts behind ONE cell of the entry Stats (F1) +// matrix — "everything I have on 20m CW with this entity". Same drill-down the +// Awards grid offers, for the panel an operator actually stares at while +// working a station. +// +// modeClass is the matrix column class ("PH", "CW", "DIG"); empty means all. +// Both the exact callsign and the entity are returned: a cell is amber for the +// entity and green for the call, and the useful answer to "who gave me this +// slot?" is the whole list. +func (a *App) BandSlotQSOs(callsign string, dxccNum int, band, modeClass string) ([]qso.QSO, error) { + if a.qso == nil { + return nil, fmt.Errorf("db not initialized") + } + all, err := a.qso.BandSlotQSOs(a.ctx, callsign, dxccNum, band) + if err != nil { + return nil, err + } + // "PH"/"DIG" are the matrix's column labels; award.ModeClass speaks + // "PHONE"/"DIGI". Translate rather than teach one of them the other's names. + want := map[string]string{"PH": "PHONE", "CW": "CW", "DIG": "DIGI"}[strings.ToUpper(strings.TrimSpace(modeClass))] + if want == "" { + return all, nil + } + out := make([]qso.QSO, 0, len(all)) + for _, q := range all { + if award.ModeClass(q.Mode) == want { + out = append(out, q) + } + } + return out, nil +} + // SetCompactMode toggles a tiny always-on-top window that exposes just the // QSO entry — useful when running on a single screen alongside WSJT-X, // JT-Alert or the cluster. diff --git a/changelog.json b/changelog.json index ebe0420..f79950b 100644 --- a/changelog.json +++ b/changelog.json @@ -9,7 +9,12 @@ "CAT sharing (FT8): JTDX/WSJT-X transmit correctly again through OpsLog's shared rig. get_ptt now echoes the PTT state the client last commanded — clients poll it DURING transmit to confirm the rig is keyed, and OpsLog always answering 'receiving' made them give up and drop the over after a second or two. The 0.23.5 'Fake It' anti-drift tweak was also reverted (it had made this total — no transmit at all); the small dial creep in 'Fake It' split can return, and JTDX's 'Split Operation: None' avoids it.", "CAT settings: trimmed the long help text under the Kenwood and CAT-sharing fields down to the essentials — less clutter.", "Entry form: fixed two ways a stray QSO could sneak in. Pressing Enter in the TX/RX frequency field now only tunes the radio — it was also submitting the contact. And a bare number with no letter (JTDX/WSJT-X sometimes broadcasts \"1\" as the DX call) is no longer accepted into the callsign field, where it could get logged as callsign \"1\".", - "SPE amplifier: switching the amp off in OpsLog now shows \"off\" straight away and lets you switch it back on without restarting. The already-open connection kept reading as connected after the off keystroke, so the panel stayed \"on\" and the ON button did nothing until a restart. OpsLog now drops the link on power-off, exactly as a fresh start would — the ON button's RTS/DTR wake then works." + "SPE amplifier: switching the amp off in OpsLog now shows \"off\" straight away and lets you switch it back on without restarting. The already-open connection kept reading as connected after the off keystroke, so the panel stayed \"on\" and the ON button did nothing until a restart. OpsLog now drops the link on power-off, exactly as a fresh start would — the ON button's RTS/DTR wake then works.", + "Alt+W clears the QSO entry, the shortcut used by most other loggers. Unlike Esc it always works, even when the CW keyer has Esc reserved.", + "The Grid box no longer pops outside the QSO entry panel when the window is narrowed — it wraps to its own line instead.", + "Selecting a QSO in the Recent QSOs list now shows that station in the Stats (F1) matrix, instead of leaving it blank.", + "Stats (F1): click any coloured band/mode square to list the contacts behind it.", + "Awards: the DXCC list now shows each entity’s main prefix (XE, DL, F…) in its own sortable, searchable column." ], "fr": [ "Visionneuse de log : la fenêtre conserve deux fois plus d'historique (512 Ko au lieu de 256 Ko, ~3200 lignes). Lors d'une trace chargée, les plus vieilles lignes défilaient hors du buffer pendant qu'on les lisait encore ; la fenêtre agrandie les garde.", @@ -18,7 +23,12 @@ "Partage CAT (FT8) : JTDX/WSJT-X émettent de nouveau correctement via la radio partagée d'OpsLog. get_ptt renvoie désormais l'état PTT commandé en dernier par le client — les clients l'interrogent PENDANT l'émission pour confirmer que la radio est en émission, et comme OpsLog répondait toujours « réception », ils abandonnaient et coupaient l'émission au bout d'une seconde ou deux. L'ajustement anti-drift « Fake It » de la 0.23.5 a aussi été annulé (il rendait le problème total — aucune émission) ; le léger glissement du VFO en split « Fake It » peut réapparaître, et « Split Operation : None » dans JTDX l'évite.", "Réglages CAT : textes d'aide raccourcis sous les champs Kenwood et partage CAT — moins de fouillis.", "Formulaire de saisie : deux façons de logguer un QSO parasite corrigées. Appuyer sur Entrée dans le champ fréquence TX/RX ne fait plus que syntoniser la radio — cela validait aussi le contact. Et un nombre nu sans lettre (JTDX/WSJT-X diffuse parfois « 1 » comme DX call) n'est plus accepté dans le champ indicatif, où il pouvait être loggué comme indicatif « 1 ».", - "Ampli SPE : éteindre l'ampli depuis OpsLog affiche désormais « off » tout de suite et permet de le rallumer sans redémarrer. La connexion déjà ouverte continuait d'être vue comme connectée après la touche d'extinction, le panneau restait donc « on » et le bouton ON ne faisait rien jusqu'à un redémarrage. OpsLog largue maintenant le lien à l'extinction, exactement comme un démarrage à neuf — le réveil RTS/DTR du bouton ON fonctionne alors." + "Ampli SPE : éteindre l'ampli depuis OpsLog affiche désormais « off » tout de suite et permet de le rallumer sans redémarrer. La connexion déjà ouverte continuait d'être vue comme connectée après la touche d'extinction, le panneau restait donc « on » et le bouton ON ne faisait rien jusqu'à un redémarrage. OpsLog largue maintenant le lien à l'extinction, exactement comme un démarrage à neuf — le réveil RTS/DTR du bouton ON fonctionne alors.", + "Alt+W efface la saisie du QSO, le raccourci utilisé par la plupart des autres logiciels. Contrairement à Échap, il fonctionne toujours, même quand Échap est réservé au manipulateur CW.", + "Le champ Locator ne déborde plus du panneau de saisie quand la fenêtre est rétrécie — il passe à la ligne.", + "Sélectionner un QSO dans la liste des QSO récents affiche désormais cette station dans la matrice Stats (F1), au lieu de la laisser vide.", + "Stats (F1) : cliquer sur une case bande/mode colorée liste les contacts correspondants.", + "Diplômes : la liste DXCC affiche le préfixe principal de chaque entité (XE, DL, F…) dans une colonne triable et cherchable." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 30913c0..35696ee 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1512,6 +1512,11 @@ export default function App() { const [deletingIds, setDeletingIds] = useState([]); const [selectedId, setSelectedId] = useState(null); const [selectedIds, setSelectedIds] = useState([]); + // Call/band/mode of the QSO highlighted in the Recent-QSOs grid. Drives the + // Stats (F1) matrix whenever the entry form is empty — clicking a past contact + // is the natural way to ask "what else have I got with this one?", and until + // now the panel just sat blank. + const [selQso, setSelQso] = useState<{ call: string; band: string; mode: string } | null>(null); const [bulkEditIds, setBulkEditIds] = useState([]); const [bulkEditOpen, setBulkEditOpen] = useState(false); const [showSettings, setShowSettings] = useState(false); @@ -3315,6 +3320,24 @@ export default function App() { finally { setDeletingAll(false); } } + // Worked-before for the QSO highlighted in the grid. Deliberately NOT + // runWorkedBefore: that one owns the entry form's wbRef and can trigger a + // field backfill, which browsing the log must never do. + const [selWb, setSelWb] = useState(null); + const [selWbBusy, setSelWbBusy] = useState(false); + useEffect(() => { + const call = (selQso?.call ?? '').trim().toUpperCase(); + // The entry form wins whenever it has a call — it's the live QSO. + if (!call || callsign.trim()) { setSelWb(null); return; } + let dead = false; + setSelWbBusy(true); + WorkedBefore(call, 0) + .then((w: any) => { if (!dead) setSelWb(w); }) + .catch(() => { if (!dead) setSelWb(null); }) + .finally(() => { if (!dead) setSelWbBusy(false); }); + return () => { dead = true; }; + }, [selQso, callsign]); + async function runWorkedBefore(call: string, dxccHint: number = 0) { setWbBusy(true); try { @@ -3859,6 +3882,16 @@ export default function App() { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'o') { e.preventDefault(); importAdif(); return; } + // Alt+W: wipe the entry. The near-universal logger convention (N1MM, + // Log4OM, DXLog), and unlike ESC it is never swallowed by the CW keyer — + // so there is always one key that clears, whatever else is running. + // Alt-combos carry no character on Windows, so this is safe while typing. + if (e.altKey && !e.ctrlKey && !e.metaKey && e.key.toLowerCase() === 'w') { + e.preventDefault(); + resetEntry(); + callsignRef.current?.focus(); + return; + } if (typing) return; if (selectedId !== null) { if (e.key === 'Delete') { e.preventDefault(); askDeleteSelected(); return; } @@ -4131,7 +4164,7 @@ export default function App() { ) : ( -
+
{ setQth(e.target.value); markEdited('qth'); }} />
); @@ -4412,7 +4445,7 @@ export default function App() { )} @@ -4746,6 +4779,7 @@ export default function App() { onExportFiltered={exportFilteredADIF} onDelete={(ids) => setDeletingIds(ids)} onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }} + onRowSelectedQso={(r) => setSelQso(r ? { call: String(r.callsign ?? ""), band: String(r.band ?? ""), mode: String(r.mode ?? "") } : null)} />
); @@ -5483,7 +5517,10 @@ export default function App() { {/* Row 2: Name fixed to the Band/Mode/Country column width (300px) so its right edge lines up with that column below; QTH grows to fill. gap-4 matches Row 3 so QTH's left edge aligns with Comment/Note. */} -
+ {/* flex-wrap, not overflow: at the narrowest window the Grid box used + to be pushed outside the entry panel and clipped. Wrapping drops + it to its own line instead, which stays readable. */} +
{ setName(e.target.value); markEdited('name'); }} />
@@ -5549,6 +5586,10 @@ export default function App() { band={band} mode={mode} bands={bands} + {...(!callsign.trim() && selQso ? { + slotCall: selQso.call, slotBand: selQso.band, slotMode: selQso.mode, + slotWb: selWb, slotWbBusy: selWbBusy, + } : {})} tab={detailTab} onTab={setDetailTab} keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled} @@ -5999,6 +6040,7 @@ export default function App() { onExportCabrilloFiltered={exportFilteredCabrillo} onDelete={(ids) => setDeletingIds(ids)} onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }} + onRowSelectedQso={(r) => setSelQso(r ? { call: String(r.callsign ?? ""), band: String(r.band ?? ""), mode: String(r.mode ?? "") } : null)} />
diff --git a/frontend/src/components/AwardsPanel.tsx b/frontend/src/components/AwardsPanel.tsx index e04b72d..d55a3c1 100644 --- a/frontend/src/components/AwardsPanel.tsx +++ b/frontend/src/components/AwardsPanel.tsx @@ -211,11 +211,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n // Alicante" — which reference order scatters across the whole table. // Remembered through writeUiPref rather than localStorage, so the choice // travels with a copied data/ folder like every other UI preference here. - const [refSort, setRefSort] = useState<'ref' | 'name'>( - () => (localStorage.getItem('opslog.awardRefSort') === 'name' ? 'name' : 'ref')); + const [refSort, setRefSort] = useState<'ref' | 'name' | 'group'>( + () => { + const v = localStorage.getItem('opslog.awardRefSort'); + return v === 'name' || v === 'group' ? v : 'ref'; + }); const [refSortDir, setRefSortDir] = useState<'asc' | 'desc'>( () => (localStorage.getItem('opslog.awardRefSortDir') === 'desc' ? 'desc' : 'asc')); - const toggleRefSort = (k: 'ref' | 'name') => { + const toggleRefSort = (k: 'ref' | 'name' | 'group') => { if (k === refSort) { const d = refSortDir === 'asc' ? 'desc' : 'asc'; setRefSortDir(d); @@ -258,10 +261,26 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n if (c !== 0) return c * dir; return a.ref.localeCompare(b.ref) * dir; // stable tie-break } + if (refSort === 'group') { + // Prefix order (XE, YV, ZL…) — how a DX operator actually thinks about + // entities. Blank prefixes sink to the bottom regardless of direction, + // so an unresolved entity never heads the list. + const ag = a.group ?? '', bg = b.group ?? ''; + if (!ag !== !bg) return ag ? -1 : 1; + const c = ag.localeCompare(bg, undefined, { sensitivity: 'base' }); + if (c !== 0) return c * dir; + return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir; + } return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir; }); }, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]); + // The group column earns its width only when the list actually carries one + // (DXCC prefixes, POTA locations); most custom lists have none. For DXCC the + // value IS the primary prefix, so it is labelled as such. + const hasGroup = useMemo(() => filteredRefs.some((r) => !!r.group), [filteredRefs]); + const groupColLabel = current && current.code.toUpperCase() === 'DXCC' ? t('awp.prefixCol') : t('awp.groupCol'); + return (
{/* Award list */} @@ -459,6 +478,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n {refSort === 'ref' && (refSortDir === 'asc' ? : )} + {hasGroup && ( + + + + )} - {t('awp.groupCol')} + + + {t('awp.status')} {t('awp.bands')} diff --git a/frontend/src/components/BandSlotGrid.tsx b/frontend/src/components/BandSlotGrid.tsx index 32f7836..cf34665 100644 --- a/frontend/src/components/BandSlotGrid.tsx +++ b/frontend/src/components/BandSlotGrid.tsx @@ -1,8 +1,9 @@ -import { useMemo } from 'react'; -import { Star, Radio, Sunrise, Sunset } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { Star, Radio, Sunrise, Sunset, X, Loader2 } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import { sunTimes } from '@/lib/sun'; +import { BandSlotQSOs } from '../../wailsjs/go/main/App'; import type { WorkedBeforeView } from '@/types'; type WorkedBefore = WorkedBeforeView; @@ -18,6 +19,9 @@ interface Props { // to an entity with no position at all, and the block simply does not appear. lat?: number; lon?: number; + // Set when the matrix is showing a QSO picked in the log grid rather than the + // entry form — labelled so the two can never be confused. + forCall?: string; } // Compact column label for a band tag: keep the classic V/U for 2m/70cm, @@ -89,7 +93,9 @@ function cellTitle(band: string, cls: string, status: string, current: boolean): return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`; } -export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon }: Props) { +export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall }: Props) { + // Cell drill-down: which band+class the operator clicked, or null. + const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null); // Columns from the operator's configured bands (so the matrix shows only the // bands they actually use), falling back to the built-in default set. const cols = useMemo( @@ -208,6 +214,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal ) : hasDxcc ? ( <> + {/* Says WHOSE stats these are when they come from a row picked in + the log rather than from what's being typed. */} + {forCall && ( + + {forCall} + + )} {dxccName || `DXCC #${dxcc}`} @@ -282,10 +296,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal return ( setSlot({ band: b.tag, cls }) : undefined} className={cn( 'w-[28px] h-[24px] rounded transition-colors p-0', st ? STATUS_CLASSES[st] : 'bg-mx-none', + // Only a filled cell has anything to show — an empty one + // stays inert rather than opening a "no QSOs" dialog. + st && 'cursor-pointer hover:brightness-110', isCurrent && 'ring-2 ring-warning ring-inset', )} /> @@ -313,6 +331,107 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal ))}
+ + {slot && ( + setSlot(null)} + /> + )} ); } + +// ── Cell drill-down ────────────────────────────────────────────────────────── +// The contacts behind one band+class cell: this exact callsign AND anyone else +// in the entity, because that is the pair of facts the cell's colour encodes. +// The call's own QSOs are marked so the two never blur together. + +function SlotQSOModal({ call, dxcc, entity, band, cls, onClose }: { + call: string; dxcc: number; entity: string; band: string; cls: string; onClose: () => void; +}) { + const [rows, setRows] = useState(null); + const [err, setErr] = useState(''); + + useEffect(() => { + let dead = false; + BandSlotQSOs(call, dxcc, band, cls) + .then((r: any) => { if (!dead) setRows((r ?? []) as any[]); }) + .catch((e: any) => { if (!dead) { setErr(String(e?.message ?? e)); setRows([]); } }); + return () => { dead = true; }; + }, [call, dxcc, band, cls]); + + useEffect(() => { + // Capture phase: the app's global ESC handler resets the entry form, and + // closing a dialog must not also wipe what the operator was typing. + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') { e.stopImmediatePropagation(); e.preventDefault(); onClose(); } + } + window.addEventListener('keydown', onKey, true); + return () => window.removeEventListener('keydown', onKey, true); + }, [onClose]); + + return ( +
+
e.stopPropagation()}> +
+ + {band} · {cls} + {entity && — {entity}} + + + {rows ? `${rows.length} QSO` : ''} + + +
+
+ {err &&

{err}

} + {!rows ? ( +

+ Loading… +

+ ) : rows.length === 0 ? ( +

No QSOs.

+ ) : ( + + + + + + + + + + + + + {rows.map((q, i) => { + const cfm = q.lotw_rcvd === 'Y' || q.eqsl_rcvd === 'Y' || q.qsl_rcvd === 'Y'; + return ( + + + + + + + + + ); + })} + +
Date UTCCallsignModeFreqNameCfm
+ {String(q.qso_date ?? '').slice(0, 16).replace('T', ' ')} + {q.callsign}{q.mode}{q.freq_hz ? (q.freq_hz / 1e6).toFixed(3) : ''}{q.name}{cfm ? : ''}
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/DetailsPanel.tsx b/frontend/src/components/DetailsPanel.tsx index 9e58eee..d9109e3 100644 --- a/frontend/src/components/DetailsPanel.tsx +++ b/frontend/src/components/DetailsPanel.tsx @@ -69,6 +69,14 @@ interface Props { band: string; mode: string; bands?: string[]; // configured bands for the worked-before matrix columns + // When the entry form is empty and a QSO is selected in the log grid, the + // Stats (F1) matrix shows THAT contact's entity instead of sitting blank. + // Only the matrix is redirected — every other tab still edits the live entry. + slotCall?: string; + slotBand?: string; + slotMode?: string; + slotWb?: any; + slotWbBusy?: boolean; imageUrl?: string; onOpenImage?: () => void; // Optional controlled active tab (so the app can switch it via keyboard). @@ -139,7 +147,7 @@ function Field({ label, span = 1, className, children }: { label: string; span?: ); } -export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) { +export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive }: Props) { const { t } = useI18n(); const [internalOpen, setInternalOpen] = useState('stats'); const open = tab ?? internalOpen; // controlled when `tab` is provided @@ -272,7 +280,14 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
{open === 'stats' && (
-
)} diff --git a/frontend/src/components/RecentQSOsGrid.tsx b/frontend/src/components/RecentQSOsGrid.tsx index 449a485..c2f7c9c 100644 --- a/frontend/src/components/RecentQSOsGrid.tsx +++ b/frontend/src/components/RecentQSOsGrid.tsx @@ -56,6 +56,10 @@ type Props = { onRowDoubleClicked?: (q: QSOForm) => void; onRowClicked?: (q: QSOForm) => void; onRowSelected?: (ids: number[]) => void; + // The full first selected row — the Stats (F1) panel shows its entity when the + // entry form is empty, so browsing the log answers "what have I got with this + // one?" without retyping the call. + onRowSelectedQso?: (row: QSOForm | null) => void; onUpdateFromCty?: (ids: number[]) => void; onUpdateFromQRZ?: (ids: number[]) => void; onUpdateFromClublog?: (ids: number[]) => void; @@ -283,7 +287,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g); const stripAwardCols = (st: any[] | null | undefined): any[] => (st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_')); -export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) { +export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) { const { t } = useI18n(); const gridRef = useRef(null); const [pickerOpen, setPickerOpen] = useState(false); @@ -542,6 +546,7 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, setSelCount(sel.length); setDispCount(api?.getDisplayedRowCount?.() ?? 0); onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null)); + onRowSelectedQso?.(sel[0] ?? null); } // Select every row when the caller bumps selectAllSignal (QSL Manager search). useEffect(() => { diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index ea11a31..6d87527 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -376,7 +376,7 @@ const en: Dict = { // 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.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.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.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', '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.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', '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.', 'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards 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.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.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', '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.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', @@ -775,7 +775,7 @@ const fr: Dict = { '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', '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', 'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.', '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.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.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", '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.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", '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.', 'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés 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.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.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', '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.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/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index f9825dd..6db5187 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -87,6 +87,8 @@ export function AwardsFolder():Promise; export function BackfillUSCounties():Promise; +export function BandSlotQSOs(arg1:string,arg2:number,arg3:string,arg4:string):Promise>; + export function BrowseExecutable():Promise; export function BulkUpdateField(arg1:Array,arg2:string,arg3:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 3b2d674..f7ed8ae 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -122,6 +122,10 @@ export function BackfillUSCounties() { return window['go']['main']['App']['BackfillUSCounties'](); } +export function BandSlotQSOs(arg1, arg2, arg3, arg4) { + return window['go']['main']['App']['BandSlotQSOs'](arg1, arg2, arg3, arg4); +} + export function BrowseExecutable() { return window['go']['main']['App']['BrowseExecutable'](); } diff --git a/internal/dxcc/manager.go b/internal/dxcc/manager.go index 5800308..cbe328b 100644 --- a/internal/dxcc/manager.go +++ b/internal/dxcc/manager.go @@ -173,6 +173,37 @@ func (m *Manager) EntityNames() []string { return out } +// PrefixByDXCC maps ADIF DXCC entity number → canonical primary prefix (F, DL, +// XE…) from the loaded cty.dat. cty.dat is keyed by entity NAME, so the join +// goes through EntityDXCC; entries whose name doesn't resolve are skipped +// rather than guessed. Empty until cty.dat has loaded. +func (m *Manager) PrefixByDXCC() map[int]string { + m.mu.RLock() + db := m.db + m.mu.RUnlock() + if db == nil { + return nil + } + out := make(map[int]string, 400) + for _, e := range db.Entities() { + p := strings.TrimSpace(e.Primary) + if p == "" { + continue + } + // cty.dat marks non-DXCC sub-entities with a leading '*' — they report + // under a parent entity and must not overwrite the parent's prefix. + if strings.HasPrefix(p, "*") { + continue + } + if n := EntityDXCC(e.Name); n > 0 { + if _, dup := out[n]; !dup { + out[n] = p + } + } + } + return out +} + // Info returns metadata about the currently-loaded cty.dat (or zero value // if nothing loaded). func (m *Manager) Info() ctySource { diff --git a/internal/qso/qso.go b/internal/qso/qso.go index efcd85c..b20d2a9 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -1461,6 +1461,51 @@ func (r *Repo) IterateByIDs(ctx context.Context, ids []int64, fn func(QSO) error return rows.Err() } +// BandSlotQSOs returns every contact on one band that belongs to a slot of the +// entry matrix: the exact callsign, or any callsign in the same DXCC entity. +// Mode is NOT filtered here — the class (phone / CW / digital) is a derived +// notion the caller owns, so it filters the (small) band-scoped result itself. +// +// The DXCC arm matches the stored dxcc column only. A QSO logged before the +// entity was resolved has a NULL there and is genuinely unattributed; guessing +// it back from the callsign here would disagree with the matrix above, which is +// built from the same column. +func (r *Repo) BandSlotQSOs(ctx context.Context, callsign string, dxccNum int, band string) ([]QSO, error) { + call := upperTrim(callsign) + b := strings.TrimSpace(band) + if b == "" || (call == "" && dxccNum <= 0) { + return []QSO{}, nil + } + where := "band = ?" + args := []any{b} + switch { + case call != "" && dxccNum > 0: + where += " AND (callsign = ? OR dxcc = ?)" + args = append(args, call, dxccNum) + case call != "": + where += " AND callsign = ?" + args = append(args, call) + default: + where += " AND dxcc = ?" + args = append(args, dxccNum) + } + rows, err := r.db.QueryContext(ctx, + `SELECT `+selectCols+` FROM qso WHERE `+where+` ORDER BY qso_date DESC, id DESC LIMIT 500`, args...) + if err != nil { + return nil, fmt.Errorf("query band slot: %w", err) + } + defer rows.Close() + out := []QSO{} + for rows.Next() { + q, err := scanQSO(rows) + if err != nil { + return nil, err + } + out = append(out, q) + } + return out, rows.Err() +} + // WorkedBefore summarises prior contacts at two granularities: // - by exact callsign → shown as "this call worked N×" // - by DXCC entity → drives NEW ONE / NEW BAND / NEW MODE / NEW SLOT