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:
+53
-22
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user