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
+86 -2
View File
@@ -6223,8 +6223,12 @@ func (a *App) BulkUpdateQSL(ids []int64, u QSLBulkUpdate) (int, error) {
if v := strings.TrimSpace(u.RcvdDate); v != "" { if v := strings.TrimSpace(u.RcvdDate); v != "" {
q.QSLRcvdDate, changed = v, true q.QSLRcvdDate, changed = v, true
} }
if v := strings.TrimSpace(u.Via); v != "" { // The QSL Manager panel's "Via" is a routing method (bureau / direct /
q.QSLVia, changed = v, true // electronic), so it belongs in QSL_SENT_VIA, not in QSL_VIA — which
// holds the manager. It used to write the manager field, in full words,
// which is half of why the two got mixed up in operators' logs.
if v := adif.NormaliseQSLVia(u.Via); v != "" {
q.QSLSentVia, changed = v, true
} }
if v := strings.TrimSpace(u.Notes); v != "" { if v := strings.TrimSpace(u.Notes); v != "" {
q.Notes, changed = v, true q.Notes, changed = v, true
@@ -6255,6 +6259,8 @@ var bulkFieldColumns = map[string]string{
"qsl_sent": "qsl_sent", "qsl_sent": "qsl_sent",
"qsl_rcvd": "qsl_rcvd", "qsl_rcvd": "qsl_rcvd",
"qsl_via": "qsl_via", "qsl_via": "qsl_via",
"qsl_sent_via": "qsl_sent_via",
"qsl_rcvd_via": "qsl_rcvd_via",
"qrz_sent": "qrzcom_qso_upload_status", "qrz_sent": "qrzcom_qso_upload_status",
"qrz_rcvd": "qrzcom_qso_download_status", "qrz_rcvd": "qrzcom_qso_download_status",
"clublog_sent": "clublog_qso_upload_status", "clublog_sent": "clublog_qso_upload_status",
@@ -10544,6 +10550,84 @@ func (a *App) DownloadULSCounties() error {
} }
// BackfillUSCountiesResult summarises a bulk county/grid backfill over the log. // BackfillUSCountiesResult summarises a bulk county/grid backfill over the log.
// ── QSL routing repair (QSL_VIA vs QSL_SENT_VIA) ───────────────────────
// keyQSLViaRepairDone records that the operator has answered the offer to move
// routing words out of the manager field — whether they accepted or declined.
// Asked once per log, never again: a repair that keeps proposing itself after a
// "no" is a nag, and the answer does not change.
const keyQSLViaRepairDone = "migr.qsl_via_routing.v1"
// QSLViaRepairStatus counts the QSOs whose QSL_VIA holds a routing method
// rather than a manager, so the operator can be shown a number before anything
// is touched.
type QSLViaRepairStatus struct {
Affected int `json:"affected"` // QSOs holding a routing word in qsl_via
Asked bool `json:"asked"` // the offer has already been answered
}
// QSLViaRepairStatus reports whether this log needs the QSL_VIA repair.
//
// OpsLog wrote "Bureau" / "Direct" / "Electronic" into QSL_VIA from its own QSL
// Manager panel, and ADIF imports folded QSL_SENT_VIA there as well, so the
// manager column in an existing log is a mixture of the two. Moving them is
// safe — a manager is a callsign and can never be one of those words — but it
// rewrites what an operator sees in their own log, so it is counted and offered
// rather than done.
func (a *App) QSLViaRepairStatus() (QSLViaRepairStatus, error) {
var res QSLViaRepairStatus
if a.qso == nil {
return res, fmt.Errorf("db not initialized")
}
if a.settings != nil {
if v, _ := a.settings.GetGlobal(a.ctx, keyQSLViaRepairDone); v == "1" {
res.Asked = true
}
}
n, err := a.qso.CountQSLViaRouting(a.ctx, adif.IsQSLViaRouting)
if err != nil {
return res, err
}
res.Affected = n
return res, nil
}
// QSLViaRepairResult reports what the repair moved.
type QSLViaRepairResult struct {
Moved int `json:"moved"`
}
// RepairQSLVia moves routing words out of QSL_VIA into QSL_SENT_VIA.
//
// Only where QSL_SENT_VIA is still empty: a QSO that already carries a real
// sent-via — from an import made after this was fixed — knows better than a
// word left in the manager column, and must not be overwritten by it.
func (a *App) RepairQSLVia() (QSLViaRepairResult, error) {
var res QSLViaRepairResult
if a.qso == nil {
return res, fmt.Errorf("db not initialized")
}
n, err := a.qso.RepairQSLViaRouting(a.ctx, adif.NormaliseQSLVia)
res.Moved = n
if err != nil {
return res, err
}
a.markQSLViaRepairAsked()
applog.Printf("qsl via repair: moved %d routing values out of the manager field", n)
return res, nil
}
// DismissQSLViaRepair records a "no" so the offer is not made again.
func (a *App) DismissQSLViaRepair() {
a.markQSLViaRepairAsked()
}
func (a *App) markQSLViaRepairAsked() {
if a.settings != nil {
_ = a.settings.SetGlobal(a.ctx, keyQSLViaRepairDone, "1")
}
}
// keyDistanceBackfilled marks the one-time distance fill as done. // keyDistanceBackfilled marks the one-time distance fill as done.
// //
// A migration, not a setting. It was briefly a button in Preferences, which was // A migration, not a setting. It was briefly a button in Preferences, which was
+8 -2
View File
@@ -10,7 +10,10 @@
"Appearance: new Sahara theme, warm sand tones for long sessions in daylight.", "Appearance: new Sahara theme, warm sand tones for long sessions in daylight.",
"US counties: Connecticut resolved to the census planning regions, so every CT station showed as a new county. Re-download the database.", "US counties: Connecticut resolved to the census planning regions, so every CT station showed as a new county. Re-download the database.",
"US counties: San Francisco, Doña Ana, Baltimore city, St. Louis city and several Alaska boroughs matched no county at all.", "US counties: San Francisco, Doña Ana, Baltimore city, St. Louis city and several Alaska boroughs matched no county at all.",
"DX cluster: the US county column now shows the county the station is logged with, so it agrees with the Info panel." "DX cluster: the US county column now shows the county the station is logged with, so it agrees with the Info panel.",
"QSL: the manager (QSL_VIA) and the routing method (QSL_SENT_VIA / QSL_RCVD_VIA) are now separate fields, as ADIF defines them.",
"ADIF import: QSL_SENT_VIA no longer lands in the manager field, and QSL_RCVD_VIA is no longer discarded. Both are exported.",
"QSL: logs where a routing word sits in the manager field are counted at startup and corrected only if you accept."
], ],
"fr": [ "fr": [
"Amplificateurs : coche ceux qui partagent un combiner et ON, OFF et OPERATE agissent sur tous à la fois. Chacun garde ses mesures.", "Amplificateurs : coche ceux qui partagent un combiner et ON, OFF et OPERATE agissent sur tous à la fois. Chacun garde ses mesures.",
@@ -20,7 +23,10 @@
"Apparence : nouveau thème Sahara, tons sable chauds pour les longues sessions en plein jour.", "Apparence : nouveau thème Sahara, tons sable chauds pour les longues sessions en plein jour.",
"Comtés US : le Connecticut renvoyait les planning regions du recensement, toute station CT semblait un nouveau comté. Rechargez la base.", "Comtés US : le Connecticut renvoyait les planning regions du recensement, toute station CT semblait un nouveau comté. Rechargez la base.",
"Comtés US : San Francisco, Doña Ana, Baltimore city, St. Louis city et plusieurs districts dAlaska ne correspondaient à aucun comté.", "Comtés US : San Francisco, Doña Ana, Baltimore city, St. Louis city et plusieurs districts dAlaska ne correspondaient à aucun comté.",
"Cluster DX : la colonne comté US affiche le comté du log quand la station y figure, donc identique au panneau Info." "Cluster DX : la colonne comté US affiche le comté du log quand la station y figure, donc identique au panneau Info.",
"QSL : le manager (QSL_VIA) et le mode denvoi (QSL_SENT_VIA / QSL_RCVD_VIA) sont désormais deux champs distincts, comme le veut lADIF.",
"Import ADIF : QSL_SENT_VIA ne se retrouve plus dans le champ manager, et QSL_RCVD_VIA nest plus perdu. Les deux sont exportés.",
"QSL : les logs où un mode denvoi occupe le champ manager sont comptés au démarrage et corrigés seulement si vous acceptez."
] ]
}, },
{ {
+36
View File
@@ -52,6 +52,7 @@ import {
GetAmpStatuses, AmpOperate, GetAmpStatuses, AmpOperate,
GetFlexState, FlexAmpOperate, GetFlexState, FlexAmpOperate,
GetPSKReporterStatus, GetLiveOpenings, GetPSKReporterStatus, GetLiveOpenings,
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
} from '../wailsjs/go/main/App'; } from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs'; import { applyAwardRefs } from '@/lib/awardRefs';
@@ -1793,6 +1794,20 @@ export default function App() {
// close so the next plain "Preferences" launch reverts to default. // close so the next plain "Preferences" launch reverts to default.
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined); const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
const [showDeleteAll, setShowDeleteAll] = useState(false); 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); const [showAbout, setShowAbout] = useState(false);
// "What's new": the changelog for the version(s) since the operator last ran, // "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). // 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 && ( {showDeleteAll && (
<ConfirmDialog <ConfirmDialog
title="Delete ALL QSOs?" title="Delete ALL QSOs?"
@@ -30,6 +30,8 @@ const FIELDS: FieldDef[] = [
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' }, { id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_rcvd_date', label: 'bulk.fQslRcvdDate', group: 'QSL / upload', kind: 'date' }, { id: 'qsl_rcvd_date', label: 'bulk.fQslRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' }, { id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
{ id: 'qsl_sent_via', label: 'bulk.fQslSentVia', group: 'QSL / upload', kind: 'text' },
{ id: 'qsl_rcvd_via', label: 'bulk.fQslRcvdVia', group: 'QSL / upload', kind: 'text' },
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' }, { id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
{ id: 'lotw_sent_date', label: 'bulk.fLotwSentDate', group: 'QSL / upload', kind: 'date' }, { id: 'lotw_sent_date', label: 'bulk.fLotwSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' }, { id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
@@ -68,6 +68,8 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' }, { value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
{ value: 'qsl_rcvd_date', label: 'fltb.fQslRcvdDate', type: 'adifdate' }, { value: 'qsl_rcvd_date', label: 'fltb.fQslRcvdDate', type: 'adifdate' },
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' }, { value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
{ value: 'qsl_sent_via', label: 'fltb.fQslSentVia', type: 'text' },
{ value: 'qsl_rcvd_via', label: 'fltb.fQslRcvdVia', type: 'text' },
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' }, { value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
{ value: 'lotw_sent_date', label: 'fltb.fLotwSentDate', type: 'adifdate' }, { value: 'lotw_sent_date', label: 'fltb.fLotwSentDate', type: 'adifdate' },
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' }, { value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
+3 -3
View File
@@ -57,9 +57,9 @@ const QSL_STATUSES = [
// QSL routing methods for the paper-QSL "Via" dropdown (was free text). // QSL routing methods for the paper-QSL "Via" dropdown (was free text).
const QSL_VIA_OPTIONS = [ const QSL_VIA_OPTIONS = [
{ v: '_', label: 'qslm.leave' }, { v: '_', label: 'qslm.leave' },
{ v: 'Bureau', label: 'qslm.viaBureau' }, { v: 'B', label: 'qslm.viaBureau' },
{ v: 'Direct', label: 'qslm.viaDirect' }, { v: 'D', label: 'qslm.viaDirect' },
{ v: 'Electronic', label: 'qslm.viaElectronic' }, { v: 'E', label: 'qslm.viaElectronic' },
]; ];
// Maps a service value → its i18n label key (only for services with // Maps a service value → its i18n label key (only for services with
+40 -5
View File
@@ -57,10 +57,12 @@ type ConfDef = {
key: string; label: string; key: string; label: string;
sent?: keyof QSOForm; rcvd?: keyof QSOForm; sent?: keyof QSOForm; rcvd?: keyof QSOForm;
sentDate?: keyof QSOForm; rcvdDate?: keyof QSOForm; sentDate?: keyof QSOForm; rcvdDate?: keyof QSOForm;
via?: keyof QSOForm; // How the card travelled, each way (ADIF QSL_SENT_VIA / QSL_RCVD_VIA). Paper
// only — the electronic channels below ARE the route.
sentVia?: keyof QSOForm; rcvdVia?: keyof QSOForm;
}; };
const CONFIRMATIONS: ConfDef[] = [ const CONFIRMATIONS: ConfDef[] = [
{ key: 'QSL', label: 'QSL (paper)', sent: 'qsl_sent', rcvd: 'qsl_rcvd', sentDate: 'qsl_sent_date', rcvdDate: 'qsl_rcvd_date', via: 'qsl_via' }, { key: 'QSL', label: 'QSL (paper)', sent: 'qsl_sent', rcvd: 'qsl_rcvd', sentDate: 'qsl_sent_date', rcvdDate: 'qsl_rcvd_date', sentVia: 'qsl_sent_via', rcvdVia: 'qsl_rcvd_via' },
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' }, { key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' }, { key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any }, { key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
@@ -216,6 +218,28 @@ function QslSelect({ value, onChange }: { value?: string; onChange: (v: string)
); );
} }
// The ADIF QSL Via enumeration. M (manager) is import-only in the standard, so
// it is not offered here — a file that arrives carrying it keeps it, and this
// list is what an operator may choose.
const QSL_VIA_CHOICES = [
{ value: '_', label: 'qedit.qslDash' },
{ value: 'B', label: 'qedit.viaBureau' },
{ value: 'D', label: 'qedit.viaDirect' },
{ value: 'E', label: 'qedit.viaElectronic' },
];
function QslViaSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
const { t } = useI18n();
return (
<Select value={value || '_'} onValueChange={(v) => onChange(v === '_' ? '' : v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{QSL_VIA_CHOICES.map((s) => <SelectItem key={s.value} value={s.value}>{t(s.label)}</SelectItem>)}
</SelectContent>
</Select>
);
}
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) { export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) {
const { t } = useI18n(); const { t } = useI18n();
// Use the operator's configured band/mode lists (incl. custom ones like 13cm); // Use the operator's configured band/mode lists (incl. custom ones like 13cm);
@@ -633,7 +657,12 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<Label>{t('qedit.qslMsg')}</Label> <Label>{t('qedit.qslMsg')}</Label>
<Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /> <Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} />
</div> </div>
<div><Label>{t('qedit.qslVia')}</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></div> {/* The manager, and only the manager — ADIF QSL_VIA. How the
card travelled is a separate field, on the QSL Info tab. */}
<div>
<Label>{t('qedit.qslVia')}</Label>
<Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} placeholder={t('qedit.qslViaPlaceholder')} />
</div>
</div> </div>
</div> </div>
</TabsContent> </TabsContent>
@@ -734,8 +763,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
</div> </div>
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></div> <div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></div>
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={val(def.rcvdDate)} onChange={(v) => put(def.rcvdDate, v)} disabled={!def.rcvdDate} /></div> <div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={val(def.rcvdDate)} onChange={(v) => put(def.rcvdDate, v)} disabled={!def.rcvdDate} /></div>
{def.via && ( {/* How the card travelled, each way — ADIF's QSL Via
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div> enumeration. Not the manager: that is a callsign
and lives on the Contact's details tab. */}
{def.sentVia && (
<div><Label>{t('qedit.sentVia')}</Label><QslViaSelect value={val(def.sentVia)} onChange={(v) => put(def.sentVia, v)} /></div>
)}
{def.rcvdVia && (
<div><Label>{t('qedit.rcvdVia')}</Label><QslViaSelect value={val(def.rcvdVia)} onChange={(v) => put(def.rcvdVia, v)} /></div>
)} )}
</div> </div>
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
@@ -186,6 +186,8 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
{ group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 }, { group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 },
{ group: 'QSL', label: t('rqg.c.qsl_sent_via'), colId: 'qsl_sent_via', headerName: t('rqg.h.qsl_sent_via'), field: 'qsl_sent_via' as any, width: 110 },
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_via'), colId: 'qsl_rcvd_via', headerName: t('rqg.h.qsl_rcvd_via'), field: 'qsl_rcvd_via' as any, width: 110 },
{ group: 'QSL', label: t('rqg.c.qsl_msg'), colId: 'qsl_msg', headerName: t('rqg.c.qsl_msg'), field: 'qsl_msg' as any, width: 200 }, { group: 'QSL', label: t('rqg.c.qsl_msg'), colId: 'qsl_msg', headerName: t('rqg.c.qsl_msg'), field: 'qsl_msg' as any, width: 200 },
{ group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 }, { group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
File diff suppressed because one or more lines are too long
+6
View File
@@ -176,6 +176,8 @@ export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
export function DismissAwardUpdate(arg1:string):Promise<void>; export function DismissAwardUpdate(arg1:string):Promise<void>;
export function DismissQSLViaRepair():Promise<void>;
export function DownloadAllReferenceLists():Promise<string>; export function DownloadAllReferenceLists():Promise<string>;
export function DownloadAndApplyUpdate(arg1:string):Promise<void>; export function DownloadAndApplyUpdate(arg1:string):Promise<void>;
@@ -808,6 +810,8 @@ export function QSLSetDefaultTemplate(arg1:number):Promise<void>;
export function QSLStylePresets():Promise<Array<main.QSLPresetInfo>>; export function QSLStylePresets():Promise<Array<main.QSLPresetInfo>>;
export function QSLViaRepairStatus():Promise<main.QSLViaRepairStatus>;
export function QSOAudioBegin():Promise<boolean>; export function QSOAudioBegin():Promise<boolean>;
export function QSOAudioCancel():Promise<void>; export function QSOAudioCancel():Promise<void>;
@@ -846,6 +850,8 @@ export function RenameLogbook(arg1:string):Promise<void>;
export function RenderEQSL(arg1:number,arg2:number):Promise<string>; export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
export function RepairQSLVia():Promise<main.QSLViaRepairResult>;
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>; export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
export function ReportLiveActivity(arg1:number,arg2:string,arg3:string):Promise<void>; export function ReportLiveActivity(arg1:number,arg2:string,arg3:string):Promise<void>;
+12
View File
@@ -294,6 +294,10 @@ export function DismissAwardUpdate(arg1) {
return window['go']['main']['App']['DismissAwardUpdate'](arg1); return window['go']['main']['App']['DismissAwardUpdate'](arg1);
} }
export function DismissQSLViaRepair() {
return window['go']['main']['App']['DismissQSLViaRepair']();
}
export function DownloadAllReferenceLists() { export function DownloadAllReferenceLists() {
return window['go']['main']['App']['DownloadAllReferenceLists'](); return window['go']['main']['App']['DownloadAllReferenceLists']();
} }
@@ -1558,6 +1562,10 @@ export function QSLStylePresets() {
return window['go']['main']['App']['QSLStylePresets'](); return window['go']['main']['App']['QSLStylePresets']();
} }
export function QSLViaRepairStatus() {
return window['go']['main']['App']['QSLViaRepairStatus']();
}
export function QSOAudioBegin() { export function QSOAudioBegin() {
return window['go']['main']['App']['QSOAudioBegin'](); return window['go']['main']['App']['QSOAudioBegin']();
} }
@@ -1634,6 +1642,10 @@ export function RenderEQSL(arg1, arg2) {
return window['go']['main']['App']['RenderEQSL'](arg1, arg2); return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
} }
export function RepairQSLVia() {
return window['go']['main']['App']['RepairQSLVia']();
}
export function ReplaceAwardReferences(arg1, arg2) { export function ReplaceAwardReferences(arg1, arg2) {
return window['go']['main']['App']['ReplaceAwardReferences'](arg1, arg2); return window['go']['main']['App']['ReplaceAwardReferences'](arg1, arg2);
} }
+30
View File
@@ -2859,6 +2859,32 @@ export namespace main {
this.updated_at = source["updated_at"]; this.updated_at = source["updated_at"];
} }
} }
export class QSLViaRepairResult {
moved: number;
static createFrom(source: any = {}) {
return new QSLViaRepairResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.moved = source["moved"];
}
}
export class QSLViaRepairStatus {
affected: number;
asked: boolean;
static createFrom(source: any = {}) {
return new QSLViaRepairStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.affected = source["affected"];
this.asked = source["asked"];
}
}
export class QSOAwardRef { export class QSOAwardRef {
code: string; code: string;
ref: string; ref: string;
@@ -4330,6 +4356,8 @@ export namespace qso {
qsl_sent_date?: string; qsl_sent_date?: string;
qsl_rcvd_date?: string; qsl_rcvd_date?: string;
qsl_via?: string; qsl_via?: string;
qsl_sent_via?: string;
qsl_rcvd_via?: string;
qsl_msg?: string; qsl_msg?: string;
qslmsg_rcvd?: string; qslmsg_rcvd?: string;
lotw_sent?: string; lotw_sent?: string;
@@ -4469,6 +4497,8 @@ export namespace qso {
this.qsl_sent_date = source["qsl_sent_date"]; this.qsl_sent_date = source["qsl_sent_date"];
this.qsl_rcvd_date = source["qsl_rcvd_date"]; this.qsl_rcvd_date = source["qsl_rcvd_date"];
this.qsl_via = source["qsl_via"]; this.qsl_via = source["qsl_via"];
this.qsl_sent_via = source["qsl_sent_via"];
this.qsl_rcvd_via = source["qsl_rcvd_via"];
this.qsl_msg = source["qsl_msg"]; this.qsl_msg = source["qsl_msg"];
this.qslmsg_rcvd = source["qslmsg_rcvd"]; this.qslmsg_rcvd = source["qslmsg_rcvd"];
this.lotw_sent = source["lotw_sent"]; this.lotw_sent = source["lotw_sent"];
+8
View File
@@ -241,6 +241,14 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
w("QSLSDATE", q.QSLSentDate) w("QSLSDATE", q.QSLSentDate)
w("QSLRDATE", q.QSLRcvdDate) w("QSLRDATE", q.QSLRcvdDate)
w("QSL_VIA", q.QSLVia) w("QSL_VIA", q.QSLVia)
// M (manager) is import-only in the QSL Via enumeration: we keep it when a
// file gives it to us, but a file we write must not carry it.
if q.QSLSentVia != QSLViaManager {
w("QSL_SENT_VIA", q.QSLSentVia)
}
if q.QSLRcvdVia != QSLViaManager {
w("QSL_RCVD_VIA", q.QSLRcvdVia)
}
w("QSLMSG", q.QSLMsg) w("QSLMSG", q.QSLMsg)
w("QSLMSG_RCVD", q.QSLMsgRcvd) w("QSLMSG_RCVD", q.QSLMsgRcvd)
w("LOTW_QSL_SENT", q.LOTWSent) w("LOTW_QSL_SENT", q.LOTWSent)
+2 -2
View File
@@ -135,8 +135,8 @@ var Fields = []FieldDef{
{Name: "QSLSDATE", Kind: KindDate, Category: "QSL", Promoted: true}, {Name: "QSLSDATE", Kind: KindDate, Category: "QSL", Promoted: true},
{Name: "QSLRDATE", Kind: KindDate, Category: "QSL", Promoted: true}, {Name: "QSLRDATE", Kind: KindDate, Category: "QSL", Promoted: true},
{Name: "QSL_VIA", Kind: KindText, Category: "QSL", Promoted: true}, {Name: "QSL_VIA", Kind: KindText, Category: "QSL", Promoted: true},
{Name: "QSL_SENT_VIA", Kind: KindEnum, Category: "QSL"}, {Name: "QSL_SENT_VIA", Kind: KindEnum, Category: "QSL", Promoted: true},
{Name: "QSL_RCVD_VIA", Kind: KindEnum, Category: "QSL"}, {Name: "QSL_RCVD_VIA", Kind: KindEnum, Category: "QSL", Promoted: true},
{Name: "QSLMSG", Kind: KindText, Category: "QSL", Promoted: true}, {Name: "QSLMSG", Kind: KindText, Category: "QSL", Promoted: true},
{Name: "QSLMSG_INTL", Kind: KindText, Category: "QSL", Intl: true}, {Name: "QSLMSG_INTL", Kind: KindText, Category: "QSL", Intl: true},
{Name: "QSLMSG_RCVD", Kind: KindText, Category: "QSL", Promoted: true}, {Name: "QSLMSG_RCVD", Kind: KindText, Category: "QSL", Promoted: true},
+10 -3
View File
@@ -464,10 +464,17 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
q.QSLRcvd = rec["qsl_rcvd"] q.QSLRcvd = rec["qsl_rcvd"]
q.QSLSentDate = rec["qslsdate"] q.QSLSentDate = rec["qslsdate"]
q.QSLRcvdDate = rec["qslrdate"] q.QSLRcvdDate = rec["qslrdate"]
// QSL_VIA is the manager. QSL_SENT_VIA / QSL_RCVD_VIA are the routing
// method, an enumeration of their own.
//
// These used to be one field here: an empty QSL_VIA was filled from
// QSL_SENT_VIA, on the theory that loggers writing one meant the other.
// They do not — Log4OM defaults QSL_SENT_VIA to E, and the import put "E"
// where every panel in OpsLog shows the manager's callsign. Keeping them
// apart is also what lets an export give them back.
q.QSLVia = rec["qsl_via"] q.QSLVia = rec["qsl_via"]
if q.QSLVia == "" { // many loggers (Log4OM) write QSL_SENT_VIA instead q.QSLSentVia = NormaliseQSLVia(rec["qsl_sent_via"])
q.QSLVia = rec["qsl_sent_via"] q.QSLRcvdVia = NormaliseQSLVia(rec["qsl_rcvd_via"])
}
q.QSLMsg = rec["qslmsg"] q.QSLMsg = rec["qslmsg"]
q.QSLMsgRcvd = rec["qslmsg_rcvd"] q.QSLMsgRcvd = rec["qslmsg_rcvd"]
q.LOTWSent = rec["lotw_qsl_sent"] q.LOTWSent = rec["lotw_qsl_sent"]
+53
View File
@@ -0,0 +1,53 @@
package adif
import "strings"
// The ADIF "QSL Via" enumeration, used by QSL_SENT_VIA and QSL_RCVD_VIA. It
// says how a card travelled, and is a different thing entirely from QSL_VIA,
// which is the manager's callsign.
const (
QSLViaBureau = "B"
QSLViaDirect = "D"
QSLViaElectronic = "E"
// QSLViaManager is import-only in the standard: it may be read from another
// logger's file, never written to one. OpsLog keeps it when it arrives so
// the operator's own data is not silently altered, and NormaliseQSLVia is
// the only place that decides so.
QSLViaManager = "M"
)
// NormaliseQSLVia folds what other loggers and OpsLog's own older versions put
// in a routing field down to the ADIF enumeration.
//
// It accepts the letter, the English word, and the French one — OpsLog wrote
// "Bureau", "Direct" and "Electronic" in full for a long time, and the QSL
// Manager panel still shows those words to a French operator. Anything it does
// not recognise comes back empty rather than being passed through: this feeds
// an enumerated ADIF field, and inventing a value there breaks the file for
// every other logger that reads it.
func NormaliseQSLVia(s string) string {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "B", "BUREAU", "BURO", "VIA BUREAU":
return QSLViaBureau
case "D", "DIRECT":
return QSLViaDirect
case "E", "ELECTRONIC", "ELECTRONIQUE", "ÉLECTRONIQUE", "OQRS":
return QSLViaElectronic
case "M", "MANAGER":
return QSLViaManager
}
return ""
}
// IsQSLViaRouting reports whether a QSL_VIA value is in fact a routing method
// that ended up in the manager field.
//
// It exists for one repair: OpsLog's QSL Manager panel wrote "Bureau",
// "Direct" and "Electronic" into QSL_VIA, and imports folded QSL_SENT_VIA
// there too, so logs hold a mixture of managers and routing words in one
// column. A manager is a callsign, never one of these six words, so the test
// is exact — but it is deliberately narrow: anything else, including a manager
// whose callsign happens to be unusual, is left alone.
func IsQSLViaRouting(s string) bool {
return NormaliseQSLVia(s) != ""
}
+37
View File
@@ -0,0 +1,37 @@
package adif
import "testing"
func TestNormaliseQSLVia(t *testing.T) {
for in, want := range map[string]string{
"B": "B", "b": "B", "Bureau": "B", "BUREAU": "B", " buro ": "B",
"D": "D", "Direct": "D", "direct": "D",
"E": "E", "Electronic": "E", "électronique": "E", "OQRS": "E",
"M": "M", "Manager": "M",
// A manager's callsign is not a routing method, and neither is noise.
"M0OXO": "", "EA5GL": "", "": "", "Bureau via M0OXO": "", "X": "",
} {
if got := NormaliseQSLVia(in); got != want {
t.Errorf("NormaliseQSLVia(%q) = %q, want %q", in, got, want)
}
}
}
// The repair moves values out of the manager column. A false positive would
// erase a real manager, so the guard is worth its own test: every callsign-like
// value must be refused.
func TestIsQSLViaRoutingRefusesManagers(t *testing.T) {
for _, call := range []string{
"M0OXO", "EA5GL", "F5CWU", "DJ9ZB", "W3HNK", "IK2DUW", "N7RO",
"BUREAU M0OXO", "via bureau DL1XYZ", "QSL DIRECT ONLY",
} {
if IsQSLViaRouting(call) {
t.Errorf("%q was taken for a routing method — the repair would erase it", call)
}
}
for _, v := range []string{"B", "D", "E", "M", "Bureau", "Direct", "Electronic"} {
if !IsQSLViaRouting(v) {
t.Errorf("%q should be recognised as a routing method", v)
}
}
}
+82
View File
@@ -119,3 +119,85 @@ func renderRecord(q qso.QSO, includeApp bool) string {
bw.Flush() bw.Flush()
return buf.String() return buf.String()
} }
// TestQSLViaFieldsRoundTrip covers the case reported as issue #16: a log
// exported by another logger carries a manager in QSL_VIA and a routing method
// in QSL_SENT_VIA, and the two must stay apart.
//
// Before this, QSL_SENT_VIA was folded into QSL_VIA whenever QSL_VIA was empty
// — so a Log4OM log, which defaults QSL_SENT_VIA to E, showed "E" wherever
// OpsLog displays the manager — and QSL_RCVD_VIA was thrown away outright: it
// was listed as a promoted field with no column behind it, so it was not even
// kept among the extras. Neither was ever exported, which made an import
// followed by an export destroy both.
func TestQSLViaFieldsRoundTrip(t *testing.T) {
in := qso.QSO{
Callsign: "3B9FR", Band: "20m", Mode: "CW",
QSODate: time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC),
QSLVia: "M0OXO", // the manager
QSLSentVia: "D", // sent direct
QSLRcvdVia: "B", // came back via the bureau
}
var buf bytes.Buffer
bw := bufio.NewWriter(&buf)
bw.WriteString("<EOH>\n")
writeRecord(bw, in, true, nil)
bw.Flush()
var rec Record
if err := Parse(strings.NewReader(buf.String()), func(r Record) error { rec = r; return nil }); err != nil {
t.Fatalf("parse: %v", err)
}
out, ok := recordToQSO(rec)
if !ok {
t.Fatal("recordToQSO returned !ok")
}
for name, c := range map[string]struct{ got, want string }{
"QSL_VIA": {out.QSLVia, in.QSLVia},
"QSL_SENT_VIA": {out.QSLSentVia, in.QSLSentVia},
"QSL_RCVD_VIA": {out.QSLRcvdVia, in.QSLRcvdVia},
} {
if c.got != c.want {
t.Errorf("%s: got %q, want %q", name, c.got, c.want)
}
}
}
// TestQSLSentViaDoesNotBecomeManager pins the exact shape a Log4OM export has:
// no QSL_VIA at all, QSL_SENT_VIA defaulted to E. The manager field must come
// back empty rather than holding "E".
func TestQSLSentViaDoesNotBecomeManager(t *testing.T) {
const rec = "<CALL:5>OE6CLD<QSO_DATE:8>20260606<TIME_ON:4>1200<BAND:3>20m<MODE:2>CW" +
"<QSL_SENT_VIA:1>E<EOR>\n"
var got Record
if err := Parse(strings.NewReader("<EOH>\n"+rec), func(r Record) error { got = r; return nil }); err != nil {
t.Fatalf("parse: %v", err)
}
q, ok := recordToQSO(got)
if !ok {
t.Fatal("recordToQSO returned !ok")
}
if q.QSLVia != "" {
t.Errorf("QSL_VIA = %q — the routing method leaked into the manager field again", q.QSLVia)
}
if q.QSLSentVia != "E" {
t.Errorf("QSL_SENT_VIA = %q, want %q", q.QSLSentVia, "E")
}
}
// M is import-only in the ADIF QSL Via enumeration: keep it when given, never
// write it back out.
func TestQSLViaManagerIsImportOnly(t *testing.T) {
in := qso.QSO{
Callsign: "3B9FR", Band: "20m", Mode: "CW",
QSODate: time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC),
QSLSentVia: "M", QSLRcvdVia: "M",
}
var buf bytes.Buffer
bw := bufio.NewWriter(&buf)
writeRecord(bw, in, true, nil)
bw.Flush()
if s := buf.String(); strings.Contains(s, "QSL_SENT_VIA") || strings.Contains(s, "QSL_RCVD_VIA") {
t.Errorf("exported an import-only value:\n%s", s)
}
}
@@ -0,0 +1,18 @@
-- QSL_SENT_VIA / QSL_RCVD_VIA — the ADIF fields that say HOW a card travelled.
--
-- Until now OpsLog had one column, qsl_via, and used it for two unrelated
-- things: the QSL manager (what ADIF's QSL_VIA holds) and the routing method.
-- Imports made it worse — QSL_SENT_VIA was folded into qsl_via when qsl_via was
-- empty, so a Log4OM log arrived with "E" sitting where the manager belongs —
-- and QSL_RCVD_VIA was dropped outright, listed as a promoted field with no
-- column behind it, so it was not even kept among the extras.
--
-- These two columns hold the ADIF enumeration: B (bureau), D (direct),
-- E (electronic). M (manager) is accepted on import only, per the standard.
--
-- Adding the columns is all that happens here. Existing qsl_via values are NOT
-- touched: separating a manager from a routing word rewrites what an operator
-- can see in their own log, so it is offered once, with a count, and only runs
-- when they say yes.
ALTER TABLE qso ADD COLUMN qsl_sent_via TEXT;
ALTER TABLE qso ADD COLUMN qsl_rcvd_via TEXT;
+93 -6
View File
@@ -99,7 +99,9 @@ type QSO struct {
QSLRcvd string `json:"qsl_rcvd,omitempty"` QSLRcvd string `json:"qsl_rcvd,omitempty"`
QSLSentDate string `json:"qsl_sent_date,omitempty"` QSLSentDate string `json:"qsl_sent_date,omitempty"`
QSLRcvdDate string `json:"qsl_rcvd_date,omitempty"` QSLRcvdDate string `json:"qsl_rcvd_date,omitempty"`
QSLVia string `json:"qsl_via,omitempty"` QSLVia string `json:"qsl_via,omitempty"` // ADIF QSL_VIA — the QSL manager
QSLSentVia string `json:"qsl_sent_via,omitempty"` // ADIF enumeration B/D/E — how the card was sent
QSLRcvdVia string `json:"qsl_rcvd_via,omitempty"` // same enumeration, for the card received
QSLMsg string `json:"qsl_msg,omitempty"` QSLMsg string `json:"qsl_msg,omitempty"`
QSLMsgRcvd string `json:"qslmsg_rcvd,omitempty"` QSLMsgRcvd string `json:"qslmsg_rcvd,omitempty"`
@@ -246,7 +248,7 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
grid, gridsquare_ext, vucc_grids, grid, gridsquare_ext, vucc_grids,
country, state, cnty, dxcc, cont, cqz, ituz, iota, sota_ref, pota_ref, country, state, cnty, dxcc, cont, cqz, ituz, iota, sota_ref, pota_ref,
age, lat, lon, rig, ant, age, lat, lon, rig, ant,
qsl_sent, qsl_rcvd, qsl_sent_date, qsl_rcvd_date, qsl_via, qsl_msg, qslmsg_rcvd, qsl_sent, qsl_rcvd, qsl_sent_date, qsl_rcvd_date, qsl_via, qsl_sent_via, qsl_rcvd_via, qsl_msg, qslmsg_rcvd,
lotw_sent, lotw_rcvd, lotw_sent_date, lotw_rcvd_date, lotw_sent, lotw_rcvd, lotw_sent_date, lotw_rcvd_date,
eqsl_sent, eqsl_rcvd, eqsl_sent_date, eqsl_rcvd_date, eqsl_sent, eqsl_rcvd, eqsl_sent_date, eqsl_rcvd_date,
clublog_qso_upload_date, clublog_qso_upload_status, clublog_qso_upload_date, clublog_qso_upload_status,
@@ -321,7 +323,7 @@ func (q *QSO) args() []any {
q.Grid, q.GridExt, q.VUCCGrids, q.Grid, q.GridExt, q.VUCCGrids,
q.Country, q.State, q.County, q.DXCC, q.Continent, q.CQZ, q.ITUZ, q.IOTA, q.SOTARef, q.POTARef, q.Country, q.State, q.County, q.DXCC, q.Continent, q.CQZ, q.ITUZ, q.IOTA, q.SOTARef, q.POTARef,
q.Age, q.Lat, q.Lon, q.Rig, q.Ant, q.Age, q.Lat, q.Lon, q.Rig, q.Ant,
q.QSLSent, q.QSLRcvd, q.QSLSentDate, q.QSLRcvdDate, q.QSLVia, q.QSLMsg, q.QSLMsgRcvd, q.QSLSent, q.QSLRcvd, q.QSLSentDate, q.QSLRcvdDate, q.QSLVia, q.QSLSentVia, q.QSLRcvdVia, q.QSLMsg, q.QSLMsgRcvd,
q.LOTWSent, q.LOTWRcvd, q.LOTWSentDate, q.LOTWRcvdDate, q.LOTWSent, q.LOTWRcvd, q.LOTWSentDate, q.LOTWRcvdDate,
q.EQSLSent, q.EQSLRcvd, q.EQSLSentDate, q.EQSLRcvdDate, q.EQSLSent, q.EQSLRcvd, q.EQSLSentDate, q.EQSLRcvdDate,
q.ClublogUploadDate, q.ClublogUploadStatus, q.ClublogUploadDate, q.ClublogUploadStatus,
@@ -771,6 +773,8 @@ var bulkEditableCols = map[string]bool{
"qsl_sent": true, "qsl_sent": true,
"qsl_rcvd": true, "qsl_rcvd": true,
"qsl_via": true, "qsl_via": true,
"qsl_sent_via": true,
"qsl_rcvd_via": true,
"qrzcom_qso_upload_status": true, "qrzcom_qso_upload_status": true,
"qrzcom_qso_download_status": true, "qrzcom_qso_download_status": true,
"clublog_qso_upload_status": true, "clublog_qso_upload_status": true,
@@ -1250,7 +1254,7 @@ var filterableColumns = map[string]bool{
"grid": true, "country": true, "state": true, "cnty": true, "grid": true, "country": true, "state": true, "cnty": true,
"dxcc": true, "cont": true, "cqz": true, "ituz": true, "dxcc": true, "cont": true, "cqz": true, "ituz": true,
"iota": true, "sota_ref": true, "pota_ref": true, "wwff_ref": true, "rig": true, "ant": true, "iota": true, "sota_ref": true, "pota_ref": true, "wwff_ref": true, "rig": true, "ant": true,
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true, "qsl_sent": true, "qsl_rcvd": true, "qsl_via": true, "qsl_sent_via": true, "qsl_rcvd_via": true,
"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true, "lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
"qrzcom_qso_upload_status": true, "qrzcom_qso_download_status": true, "qrzcom_qso_upload_status": true, "qrzcom_qso_download_status": true,
"clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true, "clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true,
@@ -2265,6 +2269,86 @@ func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty stri
return out, rows.Err() return out, rows.Err()
} }
// CountQSLViaRouting counts the QSOs whose qsl_via holds a routing method
// instead of a manager, per isRouting.
//
// The test is applied in Go rather than in SQL because it has to hold the same
// vocabulary as the import and the QSL panel — one list of accepted spellings,
// in internal/adif, not a LIKE pattern drifting apart from it here. Only the
// distinct values are examined, so the log is scanned once whatever its size.
func (r *Repo) CountQSLViaRouting(ctx context.Context, isRouting func(string) bool) (int, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT qsl_via, COUNT(*) FROM qso
WHERE qsl_via IS NOT NULL AND qsl_via != ''
AND (qsl_sent_via IS NULL OR qsl_sent_via = '')
GROUP BY qsl_via`)
if err != nil {
return 0, err
}
defer rows.Close()
n := 0
for rows.Next() {
var via string
var c int
if err := rows.Scan(&via, &c); err != nil {
return 0, err
}
if isRouting(via) {
n += c
}
}
return n, rows.Err()
}
// RepairQSLViaRouting moves routing words out of qsl_via into qsl_sent_via,
// returning how many QSOs were changed.
//
// One UPDATE per distinct spelling, not per QSO: a log holds a handful of them
// ("Bureau", "E", "Direct"…), and a remote MySQL logbook must not be made to
// carry one round trip per contact for a tidy-up. Rows that already have a
// sent-via are left alone by the same condition the count uses, so running this
// twice cannot undo a later import.
func (r *Repo) RepairQSLViaRouting(ctx context.Context, normalise func(string) string) (int, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT qsl_via FROM qso
WHERE qsl_via IS NOT NULL AND qsl_via != ''
AND (qsl_sent_via IS NULL OR qsl_sent_via = '')`)
if err != nil {
return 0, err
}
type move struct{ from, to string }
var moves []move
for rows.Next() {
var via string
if err := rows.Scan(&via); err != nil {
rows.Close()
return 0, err
}
if to := normalise(via); to != "" {
moves = append(moves, move{from: via, to: to})
}
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
total := 0
for _, m := range moves {
res, err := r.db.ExecContext(ctx,
`UPDATE qso SET qsl_sent_via = ?, qsl_via = ''
WHERE qsl_via = ? AND (qsl_sent_via IS NULL OR qsl_sent_via = '')`,
m.to, m.from)
if err != nil {
return total, err
}
if n, err := res.RowsAffected(); err == nil {
total += int(n)
}
}
return total, nil
}
// CallCounties returns callsign → "STATE,County" for every US station already // CallCounties returns callsign → "STATE,County" for every US station already
// logged with a county, newest QSO winning. // logged with a county, newest QSO winning.
// //
@@ -2889,7 +2973,8 @@ func scanQSO(s scanner) (QSO, error) {
rig, ant sql.NullString rig, ant sql.NullString
qslSent, qslRcvd sql.NullString qslSent, qslRcvd sql.NullString
qslSentDate, qslRcvdDate sql.NullString qslSentDate, qslRcvdDate sql.NullString
qslVia, qslMsg, qslMsgRcvd sql.NullString qslVia, qslSentVia, qslRcvdVia sql.NullString
qslMsg, qslMsgRcvd sql.NullString
lotwSent, lotwRcvd sql.NullString lotwSent, lotwRcvd sql.NullString
lotwSentDate, lotwRcvdDate sql.NullString lotwSentDate, lotwRcvdDate sql.NullString
eqslSent, eqslRcvd sql.NullString eqslSent, eqslRcvd sql.NullString
@@ -2934,7 +3019,7 @@ func scanQSO(s scanner) (QSO, error) {
&grid, &gridExt, &vucc, &grid, &gridExt, &vucc,
&country, &state, &cnty, &dxcc, &cont, &cqz, &ituz, &iota, &sota, &pota, &country, &state, &cnty, &dxcc, &cont, &cqz, &ituz, &iota, &sota, &pota,
&age, &lat, &lon, &rig, &ant, &age, &lat, &lon, &rig, &ant,
&qslSent, &qslRcvd, &qslSentDate, &qslRcvdDate, &qslVia, &qslMsg, &qslMsgRcvd, &qslSent, &qslRcvd, &qslSentDate, &qslRcvdDate, &qslVia, &qslSentVia, &qslRcvdVia, &qslMsg, &qslMsgRcvd,
&lotwSent, &lotwRcvd, &lotwSentDate, &lotwRcvdDate, &lotwSent, &lotwRcvd, &lotwSentDate, &lotwRcvdDate,
&eqslSent, &eqslRcvd, &eqslSentDate, &eqslRcvdDate, &eqslSent, &eqslRcvd, &eqslSentDate, &eqslRcvdDate,
&clublogDate, &clublogStatus, &clublogDate, &clublogStatus,
@@ -3018,6 +3103,8 @@ func scanQSO(s scanner) (QSO, error) {
q.QSLSentDate = qslSentDate.String q.QSLSentDate = qslSentDate.String
q.QSLRcvdDate = qslRcvdDate.String q.QSLRcvdDate = qslRcvdDate.String
q.QSLVia = qslVia.String q.QSLVia = qslVia.String
q.QSLSentVia = qslSentVia.String
q.QSLRcvdVia = qslRcvdVia.String
q.QSLMsg = qslMsg.String q.QSLMsg = qslMsg.String
q.QSLMsgRcvd = qslMsgRcvd.String q.QSLMsgRcvd = qslMsgRcvd.String
q.LOTWSent = lotwSent.String q.LOTWSent = lotwSent.String