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
@@ -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<FieldDef[]>([]);
const [extras, setExtras] = useState<string[]>([]);
const [sel, setSel] = useState<Set<string>>(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<string, FieldDef[]>();
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 }) => (
<div className={`rounded-md border p-2 ${warn ? 'border-warning-border/50 bg-warning-muted/20' : 'border-border/60'}`}>
<div className="flex items-center justify-between mb-1 gap-2">
<span className={`text-[11px] font-semibold uppercase tracking-wide ${warn ? 'text-warning-muted-foreground' : 'text-muted-foreground'}`}>{title}</span>
<span className="flex gap-1.5 shrink-0">
<button type="button" className="text-[10px] text-primary hover:underline" onClick={() => setMany(tags, true)}>{t('exf.all')}</button>
<button type="button" className="text-[10px] text-muted-foreground hover:underline" onClick={() => setMany(tags, false)}>{t('exf.none')}</button>
</span>
</div>
{tags.map((name) => (
<label key={name} className="flex items-center gap-1.5 text-[11px] cursor-pointer py-0.5">
<Checkbox checked={sel.has(name)} onCheckedChange={(c) => toggle(name, !!c)} />
<span className="font-mono break-all">{name}</span>
</label>
))}
</div>
);
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>{t('exf.title')}</DialogTitle>
<DialogDescription>{t('exf.desc')}</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2 px-1 text-[11px] text-muted-foreground">
<span>{t('exf.chosen', { n: sel.size })}</span>
<Button variant="ghost" size="sm" className="ml-auto h-6 text-[11px]" onClick={() => setSel(defaultSet(official))}>
{t('exf.defaults')}
</Button>
</div>
<div className="grid grid-cols-3 gap-3 max-h-[56vh] overflow-y-auto pr-1">
{/* OpsLog / non-standard group first (most relevant to keep or drop). */}
{extras.length > 0 && <GroupCard title={t('exf.opslogGroup')} tags={extras} warn />}
{groups.map(([g, list]) => (
<GroupCard key={g} title={g} tags={list.map((d) => d.name)} />
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>{t('exf.cancel')}</Button>
<Button disabled={sel.size === 0} onClick={doExport}>{t('exf.export', { n: count })}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+11 -1
View File
@@ -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
<span>{t('qctx.exportSelectedAdif', { n })}</span>
</button>
)}
{onExportSelectedFields && (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onExportSelectedFields(menu.ids); onClose(); }}
>
<FileDown className="size-4 text-info" />
<span>{t('qctx.exportSelectedFields', { n })}</span>
</button>
)}
{onExportFiltered && (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
+3 -1
View File
@@ -58,6 +58,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;
@@ -262,7 +263,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, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
export function RecentQSOsGrid({ rows, 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) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
@@ -600,6 +601,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
onSendEQSL={onSendEQSL}
onBulkEdit={onBulkEdit}
onExportSelected={onExportSelected}
onExportSelectedFields={onExportSelectedFields}
onExportFiltered={onExportFiltered}
onExportCabrilloSelected={onExportCabrilloSelected}
onExportCabrilloFiltered={onExportCabrilloFiltered}