From e3aeeae61657d59a48b4eb44e161a9fd35c4fbaa Mon Sep 17 00:00:00 2001 From: rouggy Date: Fri, 24 Jul 2026 10:56:07 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20ADIF=20export=20field=20picker=20?= =?UTF-8?q?=E2=80=94=20choose=20exactly=20which=20fields=20to=20write?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Global "Export to ADIF" gains a third option "Choose fields…" next to the standard-only and full-OpsLog choices, and right-clicking selected QSOs adds "Export selected — choose fields…". The picker separates official ADIF 3.1.7 fields (grouped by category) from OpsLog/non-standard tags actually present in the log (discovered via DistinctExtraKeys over extras_json), with All/None per group and reset-to-defaults; the selection is persisted per user. Backend: adif.Exporter gains a Fields allow-set that gates every promoted and extras field in writeRecord; app.go ExportADIF/ExportADIFFiltered/ ExportADIFSelected take a fields []string param, plus new ADIFExtraFields. --- app.go | 52 +++- changelog.json | 2 + frontend/src/App.tsx | 75 +++-- .../src/components/ExportFieldsDialog.tsx | 120 +++++++ frontend/src/components/QSOContextMenu.tsx | 12 +- frontend/src/components/RecentQSOsGrid.tsx | 4 +- frontend/src/lib/i18n.tsx | 16 +- frontend/wailsjs/go/main/App.d.ts | 8 +- frontend/wailsjs/go/main/App.js | 16 +- internal/adif/export.go | 293 ++++++++++-------- internal/qso/qso.go | 36 +++ 11 files changed, 463 insertions(+), 171 deletions(-) create mode 100644 frontend/src/components/ExportFieldsDialog.tsx diff --git a/app.go b/app.go index c95e53d..395fcfd 100644 --- a/app.go +++ b/app.go @@ -5465,37 +5465,55 @@ func (a *App) ADIFFields() []adif.FieldDef { return adif.Fields } // ADIFVersion returns the ADIF spec version OpsLog conforms to (e.g. "3.1.7"). func (a *App) ADIFVersion() string { return adif.ADIFVersion() } +// adifFieldSet turns a list of ADIF tag names into an allow-set (uppercased), +// or nil when the list is empty — nil means "all fields" (standard/full mode), +// so the two-mode export path is unchanged when no explicit field list is given. +func adifFieldSet(fields []string) map[string]bool { + if len(fields) == 0 { + return nil + } + set := make(map[string]bool, len(fields)) + for _, f := range fields { + if t := strings.ToUpper(strings.TrimSpace(f)); t != "" { + set[t] = true + } + } + return set +} + // ExportADIF writes every QSO to the given file path in ADIF 3.1 format. // Streams from DB so memory stays flat even with 100k+ records. // includeAppFields=false → portable standard ADIF (for other loggers); // true → full export keeping OpsLog/app-specific APP_* fields (round-trip). -func (a *App) ExportADIF(path string, includeAppFields bool) (adif.ExportResult, error) { +// fields (optional): when non-empty, export EXACTLY those ADIF tags — the +// "choose which fields to export" mode (overrides includeAppFields). +func (a *App) ExportADIF(path string, includeAppFields bool, fields []string) (adif.ExportResult, error) { if a.qso == nil { return adif.ExportResult{}, fmt.Errorf("db not initialized") } if path == "" { return adif.ExportResult{}, fmt.Errorf("empty path") } - ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields} + ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields, Fields: adifFieldSet(fields)} return ex.ExportFile(a.ctx, path) } // ExportADIFFiltered writes the QSOs matching the current filter to path, with // NO row limit (the on-screen list is capped by the threshold; this is not). -func (a *App) ExportADIFFiltered(path string, includeAppFields bool, f qso.QueryFilter) (adif.ExportResult, error) { +func (a *App) ExportADIFFiltered(path string, includeAppFields bool, f qso.QueryFilter, fields []string) (adif.ExportResult, error) { if a.qso == nil { return adif.ExportResult{}, fmt.Errorf("db not initialized") } if path == "" { return adif.ExportResult{}, fmt.Errorf("empty path") } - ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields} + ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields, Fields: adifFieldSet(fields)} return ex.ExportFileFiltered(a.ctx, path, f) } // ExportADIFSelected writes only the QSOs whose ids are given (the rows the // operator highlighted in the grid). -func (a *App) ExportADIFSelected(path string, includeAppFields bool, ids []int64) (adif.ExportResult, error) { +func (a *App) ExportADIFSelected(path string, includeAppFields bool, ids []int64, fields []string) (adif.ExportResult, error) { if a.qso == nil { return adif.ExportResult{}, fmt.Errorf("db not initialized") } @@ -5505,10 +5523,32 @@ func (a *App) ExportADIFSelected(path string, includeAppFields bool, ids []int64 if len(ids) == 0 { return adif.ExportResult{}, fmt.Errorf("no QSOs selected") } - ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields} + ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields, Fields: adifFieldSet(fields)} return ex.ExportFileByIDs(a.ctx, path, ids) } +// ADIFExtraFields returns the distinct NON-standard ADIF tags actually present +// in the logbook's stored extras (APP_* and vendor tags) — the "OpsLog / extra +// fields" group in the export field picker, alongside the static official-ADIF +// list from ADIFFields(). Sampled + capped so it stays fast on a big remote log. +func (a *App) ADIFExtraFields() []string { + if a.qso == nil { + return nil + } + keys, err := a.qso.DistinctExtraKeys(a.ctx, 5000) + if err != nil { + return nil + } + out := make([]string, 0, len(keys)) + for _, k := range keys { + if t := strings.ToUpper(strings.TrimSpace(k)); t != "" && !adif.IsStandardField(t) { + out = append(out, t) + } + } + sort.Strings(out) + return out +} + // CabrilloResult reports how many QSOs a Cabrillo export wrote and where. type CabrilloResult struct { Count int `json:"count"` diff --git a/changelog.json b/changelog.json index 5de2267..a7d60a3 100644 --- a/changelog.json +++ b/changelog.json @@ -3,6 +3,7 @@ "version": "0.20.12", "date": "2026-07-23", "en": [ + "ADIF export can now pick exactly which fields to write. The global 'Export to ADIF' dialog gains a third choice, 'Choose fields…', alongside 'Standard ADIF fields' and 'All OpsLog fields'; and right-clicking selected QSOs adds 'Export selected — choose fields…'. The picker separates the official ADIF 3.1.7 fields (grouped by category) from OpsLog / non-standard tags actually present in your log, with All/None per group and a one-click reset to defaults. Your selection is remembered for next time.", "Fixed 'Incorrect string value' (MySQL error 1366) when a QSO contained non-Latin characters — Cyrillic (Я), Polish ł, etc. — e.g. updating from QRZ. It happened when the shared MySQL database had been pre-created by the hosting panel as latin1, so OpsLog's tables couldn't store those letters. OpsLog now converts the database and its tables to utf8mb4 on connect (once, automatically), and everything stores correctly. Local SQLite logbooks were never affected.", "NET Control: the on-air list now follows a mic-pass order you control instead of log time. A pinned '#' column numbers the round, ⬆⬇ buttons move the selected station up/down the queue, new check-ins join the end, and 'Log & end' advances to the next in line. The order is remembered per net. Header sorting is disabled on this list so a click can't shuffle the round.", "The CW decoder (RX audio → text) is back, with a rebuilt decoding engine. The first version struggled on real signals; the new one adds a windowed tone detector, an adaptive dB envelope with noise squelch, two-way debouncing (a brief fade inside a dash no longer shatters it into dots), per-character dit/dah classification (even the first letter of an over decodes at any speed), and gap-fed speed tracking. It rides QSB, ignores QRM on other pitches, follows 12–40 WPM and sloppy hand keying — all proven by a synthetic-signal test suite. Tools → CW decoder (or the ear button): decodes while the mode is CW, with WPM/pitch/level display, pitch lock (follows the FlexRadio CW pitch automatically), and click-a-word to fill the callsign. Weak signals in heavy QRM remain hard — that's CW Skimmer territory — but clean-to-moderate signals now decode solidly.", @@ -12,6 +13,7 @@ "Fixed the colour theme sometimes resetting to light after an update/relaunch: an update can clear the WebView's localStorage, and the fallback that restores the theme from the local settings database gave up after ~2.4s — occasionally too soon during the brief startup window before that store is ready. It now keeps retrying until the store actually answers, so a dark theme is reliably restored." ], "fr": [ + "L'export ADIF permet maintenant de choisir précisément les champs à écrire. La fenêtre globale « Exporter en ADIF » gagne un troisième choix, « Choisir les champs… », à côté de « Champs ADIF standard » et « Tous les champs OpsLog » ; et le clic droit sur des QSO sélectionnés ajoute « Exporter la sélection — choisir les champs… ». Le sélecteur sépare les champs ADIF 3.1.7 officiels (regroupés par catégorie) des balises OpsLog / non standard réellement présentes dans ton log, avec Tout/Aucun par groupe et un retour aux valeurs par défaut en un clic. Ta sélection est mémorisée pour la prochaine fois.", "Correction de « Incorrect string value » (erreur MySQL 1366) quand un QSO contenait des caractères non latins — cyrillique (Я), polonais ł, etc. — p. ex. lors d'une mise à jour depuis QRZ. Ça arrivait quand la base MySQL partagée avait été pré-créée en latin1 par le panel d'hébergement, empêchant les tables d'OpsLog de stocker ces lettres. OpsLog convertit désormais la base et ses tables en utf8mb4 à la connexion (une seule fois, automatiquement), et tout s'enregistre correctement. Les logbooks SQLite locaux n'étaient pas concernés.", "NET Control : la liste on-air suit maintenant un ordre de passage du micro que tu contrôles, au lieu de l'heure de log. Une colonne « # » épinglée numérote le tour, les boutons ⬆⬇ montent/descendent la station sélectionnée dans la file, les nouveaux arrivants rejoignent la fin, et « Logger & terminer » passe au suivant. L'ordre est mémorisé par NET. Le tri par en-tête est désactivé sur cette liste pour qu'un clic ne bouscule pas le tour.", "Le décodeur CW (audio RX → texte) est de retour, avec un moteur de décodage reconstruit. La première version peinait sur les vrais signaux ; la nouvelle ajoute un détecteur de tonalité fenêtré, une enveloppe dB adaptative avec squelch de bruit, un anti-rebond bilatéral (un bref fading dans un trait ne le brise plus en points), une classification point/trait par caractère (même la première lettre d'un over se décode à n'importe quelle vitesse) et un suivi de vitesse nourri par les espaces. Il tient le QSB, ignore le QRM sur d'autres tonalités, suit de 12 à 40 WPM et la manipulation humaine approximative — le tout prouvé par une suite de tests sur signaux synthétiques. Outils → Décodeur CW (ou le bouton oreille) : décode quand le mode est CW, avec affichage WPM/tonalité/niveau, verrouillage de tonalité (suit automatiquement le pitch CW du FlexRadio) et clic-sur-un-mot pour remplir l'indicatif. Les signaux faibles dans un fort QRM restent difficiles — c'est le territoire de CW Skimmer — mais les signaux propres à moyens se décodent maintenant solidement.", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bd2e68f..298b864 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -74,6 +74,7 @@ import { AwardsPanel } from '@/components/AwardsPanel'; import { StatsPanel } from '@/components/StatsPanel'; import { StationControlPanel } from '@/components/StationControlPanel'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; +import { ExportFieldsDialog } from '@/components/ExportFieldsDialog'; import { ShutdownProgress } from '@/components/ShutdownProgress'; import { ClusterGrid } from '@/components/ClusterGrid'; import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot'; @@ -1328,6 +1329,9 @@ export default function App() { const [exporting, setExporting] = useState(false); // Export mode chooser: standard ADIF (other loggers) vs full (OpsLog round-trip). const [showExportChoice, setShowExportChoice] = useState(false); + // Field-picker export: null when closed; ids present = export those selected + // rows, absent = export the whole logbook (global "choose fields" flow). + const [fieldPicker, setFieldPicker] = useState<{ ids?: number[]; count: number } | null>(null); const [importResult, setImportResult] = useState(null); const [importErrorsOpen, setImportErrorsOpen] = useState(false); const [importDupsOpen, setImportDupsOpen] = useState(false); @@ -2614,25 +2618,32 @@ export default function App() { } // Right-click "Export filtered to ADIF (no limit)": exports every QSO that // matches the current filter, bypassing the on-screen row threshold. - async function exportFilteredADIF() { + // fields (optional): a chosen ADIF-tag list — the "choose which fields" export. + // Empty = the standard/full behaviour (all fields, includeAppFields=false here). + async function exportFilteredADIF(fields: string[] = []) { try { const path = await SaveADIFFile(); if (!path) return; const f = buildActiveFilter(); - const r = await ExportADIFFiltered(path, false, { ...f, limit: 0, offset: 0 } as any); + const r = await ExportADIFFiltered(path, false, { ...f, limit: 0, offset: 0 } as any, fields); showToast(`Exported ${r.count} QSO${r.count > 1 ? 's' : ''} → ${r.path}`); } catch (e: any) { setError(String(e?.message ?? e)); } } // Right-click "Export selected to ADIF": only the highlighted rows. - async function exportSelectedADIF(ids: number[]) { + async function exportSelectedADIF(ids: number[], fields: string[] = []) { if (ids.length === 0) return; try { const path = await SaveADIFFile(); if (!path) return; - const r = await ExportADIFSelected(path, false, ids as any); + const r = await ExportADIFSelected(path, false, ids as any, fields); showToast(`Exported ${r.count} selected QSO${r.count > 1 ? 's' : ''} → ${r.path}`); } catch (e: any) { setError(String(e?.message ?? e)); } } + // Open the field-picker to export the highlighted rows with chosen fields. + function exportSelectedFields(ids: number[]) { + if (ids.length === 0) return; + setFieldPicker({ ids, count: ids.length }); + } // Cabrillo (contest log) export → a .log file. The contest name comes from the // QSOs' ADIF CONTEST_ID (no prompt). All / filtered / selected mirror the ADIF ones. async function exportCabrillo() { @@ -2872,7 +2883,10 @@ export default function App() { if (exporting) return; setShowExportChoice(true); // pick standard vs full first } - async function runExport(includeAppFields: boolean) { + // includeAppFields is ignored when fields is a non-empty chosen set (the set + // decides everything). Used by both the standard/full buttons and the + // "choose fields" flow (global export of the whole logbook). + async function runExport(includeAppFields: boolean, fields: string[] = []) { setShowExportChoice(false); if (exporting) return; setError(''); @@ -2880,9 +2894,10 @@ export default function App() { const path = await SaveADIFFile(); if (!path) return; setExporting(true); - const res = await ExportADIF(path, includeAppFields); + const res = await ExportADIF(path, includeAppFields, fields); + const kind = fields.length ? ` (${fields.length} fields)` : includeAppFields ? ' (full)' : ' (standard)'; // Green success toast (auto-dismiss) — not the red error banner. - showToast(`ADIF exported${includeAppFields ? ' (full)' : ' (standard)'}: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`); + showToast(`ADIF exported${kind}: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`); } catch (e: any) { setError(`ADIF export failed: ${String(e?.message ?? e)}`); } finally { @@ -3855,6 +3870,7 @@ export default function App() { onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} + onExportSelectedFields={exportSelectedFields} onExportFiltered={exportFilteredADIF} onDelete={(ids) => setDeletingIds(ids)} onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }} @@ -4933,6 +4949,7 @@ export default function App() { onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} + onExportSelectedFields={exportSelectedFields} onExportFiltered={exportFilteredADIF} onExportCabrilloSelected={exportSelectedCabrillo} onExportCabrilloFiltered={exportFilteredCabrillo} @@ -5566,10 +5583,8 @@ export default function App() { { if (!o) setShowExportChoice(false); }}> - Export ADIF - - Choose which fields to include. OpsLog writes ADIF 3.1.7. - + {t('exp.title')} + {t('exp.desc')}
+
- +
)} + {fieldPicker && ( + setFieldPicker(null)} + onExport={(fields) => { + const fp = fieldPicker; + setFieldPicker(null); + if (!fp) return; + if (fp.ids) exportSelectedADIF(fp.ids, fields); + else runExport(false, fields); + }} + /> + )} {pendingImportPath && ( { if (!o) setPendingImportPath(null); }}> diff --git a/frontend/src/components/ExportFieldsDialog.tsx b/frontend/src/components/ExportFieldsDialog.tsx new file mode 100644 index 0000000..dd50a49 --- /dev/null +++ b/frontend/src/components/ExportFieldsDialog.tsx @@ -0,0 +1,120 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { useI18n } from '@/lib/i18n'; +import { writeUiPref } from '@/lib/uiPref'; +import { ADIFFields, ADIFExtraFields } from '@/../wailsjs/go/main/App'; +import { adif } from '@/../wailsjs/go/models'; + +type FieldDef = adif.FieldDef; +const PREF_KEY = 'opslog.exportFields'; + +// ExportFieldsDialog lets the operator pick exactly which ADIF fields an export +// writes. Two groups: the official ADIF 3.1.7 dictionary (grouped by category) +// and the OpsLog / non-standard tags actually present in the log's extras. The +// chosen set is returned to the parent (which runs the selected/global export) +// and persisted so it's the default next time. +export function ExportFieldsDialog({ open, count, onExport, onClose }: { + open: boolean; + count: number; // how many QSOs the pending export covers + onExport: (fields: string[]) => void; + onClose: () => void; +}) { + const { t } = useI18n(); + const [official, setOfficial] = useState([]); + const [extras, setExtras] = useState([]); + const [sel, setSel] = useState>(new Set()); + + const defaultSet = (flds: FieldDef[]) => + new Set(flds.filter((d) => d.promoted && !d.deprecated).map((d) => d.name)); + + useEffect(() => { + if (!open) return; + let cancelled = false; + (async () => { + const [f, e] = await Promise.all([ADIFFields(), ADIFExtraFields()]); + if (cancelled) return; + const flds = (f ?? []) as FieldDef[]; + setOfficial(flds); + setExtras((e ?? []) as string[]); + let saved: string[] | null = null; + try { const raw = localStorage.getItem(PREF_KEY); if (raw) saved = JSON.parse(raw); } catch { /* ignore */ } + setSel(saved && saved.length ? new Set(saved.map((x) => x.toUpperCase())) : defaultSet(flds)); + })().catch(() => {}); + return () => { cancelled = true; }; + }, [open]); + + // Official fields grouped by category (deprecated ones are import-only → hidden). + const groups = useMemo(() => { + const m = new Map(); + for (const d of official) { + if (d.deprecated) continue; + const g = d.category || 'Misc'; + (m.get(g) ?? m.set(g, []).get(g)!).push(d); + } + return [...m.entries()]; + }, [official]); + + const toggle = (name: string, on: boolean) => + setSel((s) => { const n = new Set(s); if (on) n.add(name); else n.delete(name); return n; }); + const setMany = (names: string[], on: boolean) => + setSel((s) => { const n = new Set(s); for (const x of names) { if (on) n.add(x); else n.delete(x); } return n; }); + + const doExport = () => { + const fields = [...sel]; + writeUiPref(PREF_KEY, JSON.stringify(fields)); + onExport(fields); + }; + + const GroupCard = ({ title, tags, warn }: { title: string; tags: string[]; warn?: boolean }) => ( +
+
+ {title} + + + + +
+ {tags.map((name) => ( + + ))} +
+ ); + + return ( + { if (!o) onClose(); }}> + + + {t('exf.title')} + {t('exf.desc')} + + +
+ {t('exf.chosen', { n: sel.size })} + +
+ +
+ {/* OpsLog / non-standard group first (most relevant to keep or drop). */} + {extras.length > 0 && } + {groups.map(([g, list]) => ( + d.name)} /> + ))} +
+ + + + + +
+
+ ); +} diff --git a/frontend/src/components/QSOContextMenu.tsx b/frontend/src/components/QSOContextMenu.tsx index a6b36f4..b19a4f6 100644 --- a/frontend/src/components/QSOContextMenu.tsx +++ b/frontend/src/components/QSOContextMenu.tsx @@ -15,6 +15,7 @@ type Props = { onSendEQSL?: (ids: number[]) => void; onBulkEdit?: (ids: number[]) => void; onExportSelected?: (ids: number[]) => void; + onExportSelectedFields?: (ids: number[]) => void; onExportFiltered?: () => void; onExportCabrilloSelected?: (ids: number[]) => void; onExportCabrilloFiltered?: () => void; @@ -35,7 +36,7 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [ // or picks a command. (We deliberately do NOT close on scroll/resize: the QSO // list auto-refreshes and AG Grid fires internal scroll events on refresh, // which used to dismiss the menu the instant it appeared.) -export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) { +export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) { const { t } = useI18n(); useEffect(() => { if (!menu) return; @@ -137,6 +138,15 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ {t('qctx.exportSelectedAdif', { n })} )} + {onExportSelectedFields && ( + + )} {onExportFiltered && (