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:
@@ -6223,8 +6223,12 @@ func (a *App) BulkUpdateQSL(ids []int64, u QSLBulkUpdate) (int, error) {
|
||||
if v := strings.TrimSpace(u.RcvdDate); v != "" {
|
||||
q.QSLRcvdDate, changed = v, true
|
||||
}
|
||||
if v := strings.TrimSpace(u.Via); v != "" {
|
||||
q.QSLVia, changed = v, true
|
||||
// The QSL Manager panel's "Via" is a routing method (bureau / direct /
|
||||
// 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 != "" {
|
||||
q.Notes, changed = v, true
|
||||
@@ -6255,6 +6259,8 @@ var bulkFieldColumns = map[string]string{
|
||||
"qsl_sent": "qsl_sent",
|
||||
"qsl_rcvd": "qsl_rcvd",
|
||||
"qsl_via": "qsl_via",
|
||||
"qsl_sent_via": "qsl_sent_via",
|
||||
"qsl_rcvd_via": "qsl_rcvd_via",
|
||||
"qrz_sent": "qrzcom_qso_upload_status",
|
||||
"qrz_rcvd": "qrzcom_qso_download_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.
|
||||
// ── 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.
|
||||
//
|
||||
// A migration, not a setting. It was briefly a button in Preferences, which was
|
||||
|
||||
+8
-2
@@ -10,7 +10,10 @@
|
||||
"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: 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": [
|
||||
"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.",
|
||||
"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 d’Alaska 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 d’envoi (QSL_SENT_VIA / QSL_RCVD_VIA) sont désormais deux champs distincts, comme le veut l’ADIF.",
|
||||
"Import ADIF : QSL_SENT_VIA ne se retrouve plus dans le champ manager, et QSL_RCVD_VIA n’est plus perdu. Les deux sont exportés.",
|
||||
"QSL : les logs où un mode d’envoi occupe le champ manager sont comptés au démarrage et corrigés seulement si vous acceptez."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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?"
|
||||
|
||||
@@ -30,6 +30,8 @@ const FIELDS: FieldDef[] = [
|
||||
{ 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_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_date', label: 'bulk.fLotwSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ 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_date', label: 'fltb.fQslRcvdDate', type: 'adifdate' },
|
||||
{ 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_date', label: 'fltb.fLotwSentDate', type: 'adifdate' },
|
||||
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
|
||||
|
||||
@@ -57,9 +57,9 @@ const QSL_STATUSES = [
|
||||
// QSL routing methods for the paper-QSL "Via" dropdown (was free text).
|
||||
const QSL_VIA_OPTIONS = [
|
||||
{ v: '_', label: 'qslm.leave' },
|
||||
{ v: 'Bureau', label: 'qslm.viaBureau' },
|
||||
{ v: 'Direct', label: 'qslm.viaDirect' },
|
||||
{ v: 'Electronic', label: 'qslm.viaElectronic' },
|
||||
{ v: 'B', label: 'qslm.viaBureau' },
|
||||
{ v: 'D', label: 'qslm.viaDirect' },
|
||||
{ v: 'E', label: 'qslm.viaElectronic' },
|
||||
];
|
||||
|
||||
// Maps a service value → its i18n label key (only for services with
|
||||
|
||||
@@ -57,10 +57,12 @@ type ConfDef = {
|
||||
key: string; label: string;
|
||||
sent?: keyof QSOForm; rcvd?: 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[] = [
|
||||
{ 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: '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 },
|
||||
@@ -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) {
|
||||
const { t } = useI18n();
|
||||
// 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>
|
||||
<Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} />
|
||||
</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>
|
||||
</TabsContent>
|
||||
@@ -734,8 +763,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
</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>
|
||||
{def.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>
|
||||
{/* How the card travelled, each way — ADIF's QSL Via
|
||||
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>
|
||||
<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_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_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.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
|
||||
|
||||
|
||||
+12
-12
File diff suppressed because one or more lines are too long
Vendored
+6
@@ -176,6 +176,8 @@ export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
|
||||
|
||||
export function DismissAwardUpdate(arg1:string):Promise<void>;
|
||||
|
||||
export function DismissQSLViaRepair():Promise<void>;
|
||||
|
||||
export function DownloadAllReferenceLists():Promise<string>;
|
||||
|
||||
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 QSLViaRepairStatus():Promise<main.QSLViaRepairStatus>;
|
||||
|
||||
export function QSOAudioBegin():Promise<boolean>;
|
||||
|
||||
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 RepairQSLVia():Promise<main.QSLViaRepairResult>;
|
||||
|
||||
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
||||
|
||||
export function ReportLiveActivity(arg1:number,arg2:string,arg3:string):Promise<void>;
|
||||
|
||||
@@ -294,6 +294,10 @@ export function DismissAwardUpdate(arg1) {
|
||||
return window['go']['main']['App']['DismissAwardUpdate'](arg1);
|
||||
}
|
||||
|
||||
export function DismissQSLViaRepair() {
|
||||
return window['go']['main']['App']['DismissQSLViaRepair']();
|
||||
}
|
||||
|
||||
export function DownloadAllReferenceLists() {
|
||||
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
||||
}
|
||||
@@ -1558,6 +1562,10 @@ export function QSLStylePresets() {
|
||||
return window['go']['main']['App']['QSLStylePresets']();
|
||||
}
|
||||
|
||||
export function QSLViaRepairStatus() {
|
||||
return window['go']['main']['App']['QSLViaRepairStatus']();
|
||||
}
|
||||
|
||||
export function QSOAudioBegin() {
|
||||
return window['go']['main']['App']['QSOAudioBegin']();
|
||||
}
|
||||
@@ -1634,6 +1642,10 @@ export function 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) {
|
||||
return window['go']['main']['App']['ReplaceAwardReferences'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -2859,6 +2859,32 @@ export namespace main {
|
||||
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 {
|
||||
code: string;
|
||||
ref: string;
|
||||
@@ -4330,6 +4356,8 @@ export namespace qso {
|
||||
qsl_sent_date?: string;
|
||||
qsl_rcvd_date?: string;
|
||||
qsl_via?: string;
|
||||
qsl_sent_via?: string;
|
||||
qsl_rcvd_via?: string;
|
||||
qsl_msg?: string;
|
||||
qslmsg_rcvd?: string;
|
||||
lotw_sent?: string;
|
||||
@@ -4469,6 +4497,8 @@ export namespace qso {
|
||||
this.qsl_sent_date = source["qsl_sent_date"];
|
||||
this.qsl_rcvd_date = source["qsl_rcvd_date"];
|
||||
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.qslmsg_rcvd = source["qslmsg_rcvd"];
|
||||
this.lotw_sent = source["lotw_sent"];
|
||||
|
||||
@@ -241,6 +241,14 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
|
||||
w("QSLSDATE", q.QSLSentDate)
|
||||
w("QSLRDATE", q.QSLRcvdDate)
|
||||
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_RCVD", q.QSLMsgRcvd)
|
||||
w("LOTW_QSL_SENT", q.LOTWSent)
|
||||
|
||||
@@ -135,8 +135,8 @@ var Fields = []FieldDef{
|
||||
{Name: "QSLSDATE", 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_SENT_VIA", Kind: KindEnum, Category: "QSL"},
|
||||
{Name: "QSL_RCVD_VIA", Kind: KindEnum, Category: "QSL"},
|
||||
{Name: "QSL_SENT_VIA", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||
{Name: "QSL_RCVD_VIA", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||
{Name: "QSLMSG", Kind: KindText, Category: "QSL", Promoted: true},
|
||||
{Name: "QSLMSG_INTL", Kind: KindText, Category: "QSL", Intl: true},
|
||||
{Name: "QSLMSG_RCVD", Kind: KindText, Category: "QSL", Promoted: true},
|
||||
|
||||
+10
-3
@@ -464,10 +464,17 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
||||
q.QSLRcvd = rec["qsl_rcvd"]
|
||||
q.QSLSentDate = rec["qslsdate"]
|
||||
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"]
|
||||
if q.QSLVia == "" { // many loggers (Log4OM) write QSL_SENT_VIA instead
|
||||
q.QSLVia = rec["qsl_sent_via"]
|
||||
}
|
||||
q.QSLSentVia = NormaliseQSLVia(rec["qsl_sent_via"])
|
||||
q.QSLRcvdVia = NormaliseQSLVia(rec["qsl_rcvd_via"])
|
||||
q.QSLMsg = rec["qslmsg"]
|
||||
q.QSLMsgRcvd = rec["qslmsg_rcvd"]
|
||||
q.LOTWSent = rec["lotw_qsl_sent"]
|
||||
|
||||
@@ -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) != ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,3 +119,85 @@ func renderRecord(q qso.QSO, includeApp bool) string {
|
||||
bw.Flush()
|
||||
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
@@ -99,7 +99,9 @@ type QSO struct {
|
||||
QSLRcvd string `json:"qsl_rcvd,omitempty"`
|
||||
QSLSentDate string `json:"qsl_sent_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"`
|
||||
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,
|
||||
country, state, cnty, dxcc, cont, cqz, ituz, iota, sota_ref, pota_ref,
|
||||
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,
|
||||
eqsl_sent, eqsl_rcvd, eqsl_sent_date, eqsl_rcvd_date,
|
||||
clublog_qso_upload_date, clublog_qso_upload_status,
|
||||
@@ -321,7 +323,7 @@ func (q *QSO) args() []any {
|
||||
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.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.EQSLSent, q.EQSLRcvd, q.EQSLSentDate, q.EQSLRcvdDate,
|
||||
q.ClublogUploadDate, q.ClublogUploadStatus,
|
||||
@@ -771,6 +773,8 @@ var bulkEditableCols = map[string]bool{
|
||||
"qsl_sent": true,
|
||||
"qsl_rcvd": true,
|
||||
"qsl_via": true,
|
||||
"qsl_sent_via": true,
|
||||
"qsl_rcvd_via": true,
|
||||
"qrzcom_qso_upload_status": true,
|
||||
"qrzcom_qso_download_status": true,
|
||||
"clublog_qso_upload_status": true,
|
||||
@@ -1250,7 +1254,7 @@ var filterableColumns = map[string]bool{
|
||||
"grid": true, "country": true, "state": true, "cnty": true,
|
||||
"dxcc": true, "cont": true, "cqz": true, "ituz": 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,
|
||||
"qrzcom_qso_upload_status": true, "qrzcom_qso_download_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()
|
||||
}
|
||||
|
||||
// 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
|
||||
// logged with a county, newest QSO winning.
|
||||
//
|
||||
@@ -2889,7 +2973,8 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
rig, ant sql.NullString
|
||||
qslSent, qslRcvd sql.NullString
|
||||
qslSentDate, qslRcvdDate sql.NullString
|
||||
qslVia, qslMsg, qslMsgRcvd sql.NullString
|
||||
qslVia, qslSentVia, qslRcvdVia sql.NullString
|
||||
qslMsg, qslMsgRcvd sql.NullString
|
||||
lotwSent, lotwRcvd sql.NullString
|
||||
lotwSentDate, lotwRcvdDate sql.NullString
|
||||
eqslSent, eqslRcvd sql.NullString
|
||||
@@ -2934,7 +3019,7 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
&grid, &gridExt, &vucc,
|
||||
&country, &state, &cnty, &dxcc, &cont, &cqz, &ituz, &iota, &sota, &pota,
|
||||
&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,
|
||||
&eqslSent, &eqslRcvd, &eqslSentDate, &eqslRcvdDate,
|
||||
&clublogDate, &clublogStatus,
|
||||
@@ -3018,6 +3103,8 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
q.QSLSentDate = qslSentDate.String
|
||||
q.QSLRcvdDate = qslRcvdDate.String
|
||||
q.QSLVia = qslVia.String
|
||||
q.QSLSentVia = qslSentVia.String
|
||||
q.QSLRcvdVia = qslRcvdVia.String
|
||||
q.QSLMsg = qslMsg.String
|
||||
q.QSLMsgRcvd = qslMsgRcvd.String
|
||||
q.LOTWSent = lotwSent.String
|
||||
|
||||
Reference in New Issue
Block a user