feat: ADIF export field picker — choose exactly which fields to write

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.
This commit is contained in:
2026-07-24 10:56:07 +02:00
parent a5cfecbf76
commit e3aeeae616
11 changed files with 463 additions and 171 deletions
+53 -22
View File
@@ -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<ImportResult | null>(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() {
<Dialog open onOpenChange={(o) => { if (!o) setShowExportChoice(false); }}>
<DialogContent className="max-w-lg px-6">
<DialogHeader className="px-2">
<DialogTitle>Export ADIF</DialogTitle>
<DialogDescription>
Choose which fields to include. OpsLog writes ADIF 3.1.7.
</DialogDescription>
<DialogTitle>{t('exp.title')}</DialogTitle>
<DialogDescription>{t('exp.desc')}</DialogDescription>
</DialogHeader>
<div className="px-2 py-1 space-y-2.5">
<button
@@ -5577,30 +5592,46 @@ export default function App() {
onClick={() => runExport(false)}
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
>
<div className="font-semibold text-sm">Standard ADIF only</div>
<div className="text-xs text-muted-foreground mt-0.5">
Only fields defined in the ADIF 3.1.7 spec portable to other loggers (Log4OM, N1MM, LoTW).
Application-specific <span className="font-mono">APP_*</span> and any non-standard / vendor tags are stripped.
</div>
<div className="font-semibold text-sm">{t('exp.stdTitle')}</div>
<div className="text-xs text-muted-foreground mt-0.5">{t('exp.stdDesc')}</div>
</button>
<button
type="button"
onClick={() => runExport(true)}
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
>
<div className="font-semibold text-sm">All fields (OpsLog round-trip)</div>
<div className="text-xs text-muted-foreground mt-0.5">
Every field including application-specific <span className="font-mono">APP_*</span> and vendor tags
a lossless backup you'll re-import into OpsLog.
</div>
<div className="font-semibold text-sm">{t('exp.allTitle')}</div>
<div className="text-xs text-muted-foreground mt-0.5">{t('exp.allDesc')}</div>
</button>
<button
type="button"
onClick={() => { setShowExportChoice(false); setFieldPicker({ count: total }); }}
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
>
<div className="font-semibold text-sm">{t('exp.chooseTitle')}</div>
<div className="text-xs text-muted-foreground mt-0.5">{t('exp.chooseDesc')}</div>
</button>
</div>
<DialogFooter className="px-2 bg-transparent border-t-0">
<Button variant="outline" onClick={() => setShowExportChoice(false)}>Cancel</Button>
<Button variant="outline" onClick={() => setShowExportChoice(false)}>{t('exf.cancel')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{fieldPicker && (
<ExportFieldsDialog
open
count={fieldPicker.count}
onClose={() => setFieldPicker(null)}
onExport={(fields) => {
const fp = fieldPicker;
setFieldPicker(null);
if (!fp) return;
if (fp.ids) exportSelectedADIF(fp.ids, fields);
else runExport(false, fields);
}}
/>
)}
{pendingImportPath && (
<Dialog open onOpenChange={(o) => { if (!o) setPendingImportPath(null); }}>
<DialogContent className="max-w-lg px-6">