fix(qsl): separate the QSL manager from the routing method (#16)

QSL_VIA is the manager. QSL_SENT_VIA and QSL_RCVD_VIA are the ADIF "QSL Via"
enumeration — B bureau, D direct, E electronic, M manager (import-only) —
and say how a card travelled. OpsLog had one column for all three:

  - the import folded QSL_SENT_VIA into QSL_VIA whenever QSL_VIA was empty,
    which is exactly a Log4OM export (it defaults QSL_SENT_VIA to E), so
    OE6CLD saw "E" everywhere OpsLog shows the manager;
  - QSL_RCVD_VIA was listed in adifPromoted with no column behind it, so it
    was not stored, not kept among the extras, and not exported — dropped
    outright on import;
  - neither was ever written on export, so an import followed by an export
    destroyed both;
  - and OpsLog polluted the field itself: the QSL Manager panel wrote
    "Bureau" / "Direct" / "Electronic", in full words, into QSL_VIA.

Two columns added (migration 0027), carried through the five places a
promoted ADIF field has to touch, with round-trip tests pinning the reported
case. The QSL panel now offers Bureau / Direct / Electronic for each
direction and stores the enumeration; the manager field is labelled as the
manager and holds only that. M is kept when a file gives it and never
written back out.

Existing logs hold a mixture of the two in one column. The repair is offered,
not performed: the count is shown once per log with a plain question, and a
"no" is remembered. It moves only where QSL_SENT_VIA is still empty, and only
values that normalise to the enumeration — a manager is a callsign and can
never be one of those six words, which a test pins against real manager calls.
This commit is contained in:
2026-08-14 11:50:25 +02:00
parent 30143b01bf
commit 4e88bdfaa7
20 changed files with 542 additions and 35 deletions
+36
View File
@@ -52,6 +52,7 @@ import {
GetAmpStatuses, AmpOperate,
GetFlexState, FlexAmpOperate,
GetPSKReporterStatus, GetLiveOpenings,
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs';
@@ -1793,6 +1794,20 @@ export default function App() {
// close so the next plain "Preferences" launch reverts to default.
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
const [showDeleteAll, setShowDeleteAll] = useState(false);
// How many QSOs hold a QSL routing word where the manager belongs — null when
// there is nothing to offer, or the operator has already answered. Asked once
// per log, a moment after startup so it does not race the first paint.
const [qslViaRepair, setQslViaRepair] = useState<number | null>(null);
useEffect(() => {
let alive = true;
const id = window.setTimeout(async () => {
try {
const s: any = await QSLViaRepairStatus();
if (alive && !s?.asked && (s?.affected ?? 0) > 0) setQslViaRepair(s.affected);
} catch { /* a log we cannot query yet will be offered next start */ }
}, 4000);
return () => { alive = false; window.clearTimeout(id); };
}, []);
const [showAbout, setShowAbout] = useState(false);
// "What's new": the changelog for the version(s) since the operator last ran,
// shown once on the first launch after an update (EN/FR per the UI language).
@@ -7275,6 +7290,27 @@ export default function App() {
/>
);
})()}
{/* One-time offer to move QSL routing words out of the manager field.
Shown with a count and never acted on without an answer: it rewrites
what the operator sees in their own log. Either answer is final
asking again after a "no" would be a nag. */}
{qslViaRepair !== null && (
<ConfirmDialog
title={t('qslvia.title')}
message={t('qslvia.body', { n: qslViaRepair.toLocaleString() })}
confirmLabel={t('qslvia.confirm')}
cancelLabel={t('qslvia.cancel')}
onConfirm={async () => {
const n = qslViaRepair;
setQslViaRepair(null);
try {
const r: any = await RepairQSLVia();
showToast(t('qslvia.done', { n: (r?.moved ?? n).toLocaleString() }));
} catch { /* the log keeps the reason; nothing to undo */ }
}}
onCancel={() => { setQslViaRepair(null); DismissQSLViaRepair().catch(() => {}); }}
/>
)}
{showDeleteAll && (
<ConfirmDialog
title="Delete ALL QSOs?"