feat(hamlog): read confirmations back from a HAMLOG.online ADIF

Their agent protocol has no download verb, so the return path is a file
exported from their site. Running that file through the ordinary ADIF
import is the wrong tool and does real damage: it matches on callsign +
UTC minute + band + mode, their export is rebuilt from their own
database and rarely agrees to the minute, and 'update duplicates' then
inserts everything that failed to match -- several hundred copies of
contacts already in the log.

This path matches only. It stamps the confirmation on QSOs it finds,
falls back to the mode-CLASS key for the modes their export renames, and
REPORTS what it could not match instead of adding it: an unmatched
confirmation is a question about the log, not a contact to create.

Also logs and reports how many rows a delete actually removed -- silence
there made a delete that did nothing indistinguishable from one that
worked.
This commit is contained in:
2026-08-23 15:32:41 +02:00
parent 20784c2fb1
commit 803f4dc4ed
10 changed files with 259 additions and 8 deletions
+10 -1
View File
@@ -6508,7 +6508,16 @@ func (a *App) DeleteQSOs(ids []int64) (int64, error) {
}
a.deleteRemoteCopies(ids)
a.syncPublishDeletes(ids)
return a.qso.DeleteMany(a.ctx, ids)
n, err := a.qso.DeleteMany(a.ctx, ids)
// Logged because a delete that does nothing is otherwise indistinguishable
// from a delete that worked: the rows leave the grid either way once it
// reloads, and the only place the truth survives is here.
if err != nil {
applog.Printf("delete: %d QSO(s) requested, FAILED: %v", len(ids), err)
} else {
applog.Printf("delete: %d QSO(s) requested, %d row(s) removed", len(ids), n)
}
return n, err
}
// DuplicateGroup is a set of QSOs the log considers the same contact.
+6 -2
View File
@@ -19,7 +19,9 @@
"The QSO right-click menu can now export the selection to ADIF with the OpsLog fields, so the award references (RDA@KR-04 and the like) travel with the contacts. The existing entry still writes standard ADIF for other loggers.",
"The filtered-view export gets the same treatment: a second right-click entry writes the whole filtered view to ADIF with the OpsLog fields.",
"Edit QSO: HAMLOG.online joins the QSL Info tab — its own channel in the picker, with sent/received and both dates, and a row in the status table. Its four columns also move from the QSL group to Uploads, next to Club Log and QRZ.com.",
"The filter can now match on when a QSO was added to the log ('Added to the log on'), which is the only way to isolate what an import brought in — an import of old contacts carries old QSO dates and otherwise hides inside the log."
"The filter can now match on when a QSO was added to the log ('Added to the log on'), which is the only way to isolate what an import brought in — an import of old contacts carries old QSO dates and otherwise hides inside the log.",
"QSL Manager: HAMLOG.online gains 'Import confirmations (ADIF)…'. Their site exports a log, this reads it back and stamps the confirmations on QSOs already present — it never inserts. Importing that same file through the ordinary ADIF import does insert, because it matches on the UTC minute and their export rarely agrees to the minute.",
"Deleting QSOs now says how many rows were actually removed, and writes it to the log. A delete that removes nothing used to look exactly like one that worked."
],
"fr": [
"Yaesu : une ligne ANT dans le panneau du poste sélectionne la prise d'antenne, et le choix est mémorisé pour la bande sur laquelle il a été fait — on change de bande, l'antenne suit. Aucune page à configurer : choisir l'antenne une fois sur une bande le dit très bien. Les postes à une seule prise n'affichent jamais la ligne.",
@@ -38,7 +40,9 @@
"Le menu contextuel des QSO peut maintenant exporter la sélection en ADIF avec les champs OpsLog, pour que les références de diplômes (RDA@KR-04 et compagnie) suivent les contacts. L'entrée existante écrit toujours de l'ADIF standard pour les autres carnets.",
"L'export de la vue filtrée reçoit le même traitement : une seconde entrée du menu contextuel écrit toute la vue filtrée en ADIF avec les champs OpsLog.",
"Édition de QSO : HAMLOG.online rejoint l'onglet QSL Info — son propre canal dans la liste, avec envoyé/reçu et les deux dates, et une ligne dans le tableau de statut. Ses quatre colonnes passent aussi du groupe QSL au groupe Uploads, à côté de Club Log et QRZ.com.",
"Le filtre peut maintenant porter sur la date d'ajout au journal (« Ajouté au journal le »), seul moyen d'isoler ce qu'un import a apporté : un import de vieux contacts porte de vieilles dates de QSO et se fond sinon dans le journal."
"Le filtre peut maintenant porter sur la date d'ajout au journal (« Ajouté au journal le »), seul moyen d'isoler ce qu'un import a apporté : un import de vieux contacts porte de vieilles dates de QSO et se fond sinon dans le journal.",
"Gestionnaire QSL : HAMLOG.online reçoit « Importer les confirmations (ADIF)… ». Leur site exporte un log, cette fonction le relit et appose les confirmations sur les QSO déjà présents — elle n'ajoute jamais rien. Le même fichier passé par l'import ADIF ordinaire, lui, ajoute : il compare à la minute UTC près et leur export s'accorde rarement à la minute.",
"La suppression de QSO indique maintenant combien de lignes ont réellement été supprimées, et l'écrit dans le journal. Une suppression sans effet ressemblait exactement à une suppression réussie."
]
},
{
+8 -2
View File
@@ -4297,8 +4297,14 @@ export default function App() {
if (deletingIds.length === 0) return;
const ids = deletingIds;
try {
if (ids.length === 1) await DeleteQSO(ids[0]);
else await DeleteQSOs(ids as any);
// Report what was actually removed rather than assuming it matched what
// was asked: a delete that silently removes nothing looks exactly like one
// that worked, since the rows leave the grid when it reloads either way.
if (ids.length === 1) { await DeleteQSO(ids[0]); showToast(t('toast.deletedOne')); }
else {
const n = await DeleteQSOs(ids as any);
showToast(t('toast.deletedN', { n: Number(n ?? 0), asked: ids.length }));
}
setDeletingIds([]);
setSelectedId(null);
setSelectedIds([]);
+23 -1
View File
@@ -8,7 +8,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, OpenADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { EventsOn } from '../../wailsjs/runtime/runtime';
@@ -350,6 +350,19 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
}
// HAMLOG.online has no download verb — their agent protocol only uploads —
// so confirmations come back as an ADIF exported from their site. It is read
// here rather than through the ordinary ADIF import because that one matches
// on the UTC minute and INSERTS whatever fails to match: a few hundred copies
// of contacts already in the log. This path only ever stamps QSOs it finds.
async function importHamlogCfm() {
const path = await OpenADIFFile();
if (!path) return;
setLogLines([]); setBusy(true); setLogAction('download'); setShowLog(true);
try { await ImportHamlogConfirmations(path); }
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
}
function viewResults() {
setShowLog(false);
if (logAction === 'upload') selectRequired();
@@ -666,11 +679,19 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
{service !== 'pota' && service !== 'paper' && (
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
<div className="flex items-center gap-2 flex-wrap">
{service === 'hamlog' ? (
<Button variant="outline" size="sm" onClick={importHamlogCfm} disabled={busy}
title={t('qslm.hamlogImportTitle')}>
<DownloadCloud className="size-3.5" /> {t('qslm.hamlogImportCfm')}
</Button>
) : (
<Button variant="outline" size="sm" onClick={download} disabled={busy}
title={t('qslm.downloadTitle')}>
<DownloadCloud className="size-3.5" /> {t('qslm.downloadConf')}
</Button>
)}
{/* Date window */}
{service !== 'hamlog' && (<>
<Select value={sinceMode} onValueChange={(v) => setSinceMode(v as any)}>
<SelectTrigger className="h-8 w-[150px] text-xs" title={t('qslm.downloadRangeTitle')}>
<SelectValue />
@@ -694,6 +715,7 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
{t('qslm.addNotFound')}
</label>
</>)}
</div>
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
<UploadCloud className="size-3.5" /> {t('qslm.uploadTo', { n: selectedCount, service: serviceLabel })}
File diff suppressed because one or more lines are too long
+2
View File
@@ -693,6 +693,8 @@ export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<numbe
export function ImportAwards():Promise<main.AwardImportResult>;
export function ImportHamlogConfirmations(arg1:string):Promise<main.HamlogCfmResult>;
export function InspectAwardImport():Promise<main.AwardImportPreview>;
export function IsNewUSCounty(arg1:string,arg2:string):Promise<boolean>;
+4
View File
@@ -1326,6 +1326,10 @@ export function ImportAwards() {
return window['go']['main']['App']['ImportAwards']();
}
export function ImportHamlogConfirmations(arg1) {
return window['go']['main']['App']['ImportHamlogConfirmations'](arg1);
}
export function InspectAwardImport() {
return window['go']['main']['App']['InspectAwardImport']();
}
+22
View File
@@ -2699,6 +2699,28 @@ export namespace main {
return a;
}
}
export class HamlogCfmResult {
total: number;
confirmed: number;
matched: number;
by_class: number;
unmatched: number;
samples: string[];
static createFrom(source: any = {}) {
return new HamlogCfmResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.total = source["total"];
this.confirmed = source["confirmed"];
this.matched = source["matched"];
this.by_class = source["by_class"];
this.unmatched = source["unmatched"];
this.samples = source["samples"];
}
}
export class ModePreset {
name: string;
default_rst_sent?: string;
+178
View File
@@ -0,0 +1,178 @@
package main
// Reading confirmations back from a HAMLOG.online ADIF export.
//
// Their agent protocol only uploads — it has no verb for asking what has been
// confirmed — so the return path is a file: their site exports an ADIF, and the
// operator feeds it back here.
//
// That file must NOT be imported the ordinary way. A plain import matches a
// record to a local QSO on callsign + UTC MINUTE + band + mode, and their
// export is rebuilt from their own database: a minute of rounding, SSB where
// the log says USB, and the key no longer matches. "Update duplicates" then
// does what it is told — no duplicate found, so it inserts — and a few hundred
// copies of contacts the operator already had land in the log. That is exactly
// what happened once, and it is why this path exists instead.
//
// So: match only, never insert. Confirmations are stamped onto the QSOs already
// in the log, and anything that cannot be matched is REPORTED rather than
// added, because an unmatched confirmation is a question about the log (a
// minute off, a portable call) and not a contact to create.
import (
"context"
"fmt"
"os"
"strings"
"time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"hamlog/internal/adif"
"hamlog/internal/applog"
"hamlog/internal/award"
"hamlog/internal/qso"
)
// HamlogCfmResult is what the import did.
type HamlogCfmResult struct {
Total int `json:"total"` // records read from the file
Confirmed int `json:"confirmed"` // records carrying HAMLOG's confirmation flag
Matched int `json:"matched"` // local QSOs stamped
ByClass int `json:"by_class"` // matched on mode CLASS rather than exact mode
Unmatched int `json:"unmatched"` // confirmations with no local QSO
// Samples names a few unmatched contacts, so "12 unmatched" can be looked
// into rather than merely worried about.
Samples []string `json:"samples"`
}
// hamlogCfmSamples caps the reported list — enough to see the pattern, not so
// many that the dialog becomes a log file.
const hamlogCfmSamples = 30
// ImportHamlogConfirmations stamps the confirmations from a HAMLOG.online ADIF
// export onto the matching local QSOs. It inserts nothing.
func (a *App) ImportHamlogConfirmations(path string) (HamlogCfmResult, error) {
var res HamlogCfmResult
if a.qso == nil {
return res, fmt.Errorf("db not initialized")
}
if strings.TrimSpace(path) == "" {
return res, fmt.Errorf("empty path")
}
f, err := os.Open(path)
if err != nil {
return res, err
}
defer f.Close()
ctx := a.ctx
if ctx == nil {
ctx = context.Background()
}
// The same two indexes the LoTW download uses: the exact key, then the
// mode-CLASS key for the contacts whose mode was written differently at the
// other end (FT8 exported as DATA, SSB where the log says USB).
keyIDs, err := a.qso.DedupeKeyIDs(ctx)
if err != nil {
return res, fmt.Errorf("read local log: %w", err)
}
classIDs, _ := a.qso.DedupeClassKeyIDs(ctx)
emit := func(line string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
}
}
emit("Reading " + path + "…")
perr := adif.Parse(f, func(rec adif.Record) error {
q, ok := adif.RecordToQSO(rec)
if !ok {
return nil
}
res.Total++
// Only the records they actually confirmed. The export carries the whole
// log, and stamping "confirmed" on all of it would turn every uploaded
// contact into a confirmed one.
if !hamlogRecordConfirmed(rec) {
return nil
}
res.Confirmed++
minute := q.QSODate.UTC().Format("2006-01-02T15:04")
id, found := keyIDs[qso.DedupeKey(q.Callsign, minute, q.Band, q.Mode)]
if !found {
// id 0 means the class key is ambiguous — several local QSOs share
// it — and guessing between them would stamp the wrong one.
if cid, ok := classIDs[qso.DedupeClassKey(q.Callsign, minute, q.Band, q.Mode)]; ok && cid != 0 {
id, found = cid, true
res.ByClass++
}
}
if !found {
res.Unmatched++
if len(res.Samples) < hamlogCfmSamples {
res.Samples = append(res.Samples, fmt.Sprintf("%s · %s · %s · %s",
q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04Z"), q.Band, q.Mode))
}
return nil
}
date := hamlogCfmDate(rec)
if e := a.qso.SetExtra(ctx, id, award.HamlogQSLKey, "Y"); e != nil {
return nil
}
_ = a.qso.SetExtra(ctx, id, hamlogQSLDateKey, date)
// A confirmed contact is by definition one they hold, so the sent side
// is true whether or not OpsLog is what uploaded it — a log uploaded
// from their website would otherwise read "never sent, yet confirmed".
_ = a.qso.SetExtra(ctx, id, hamlogSentKey, "Y")
res.Matched++
return nil
})
if perr != nil {
return res, perr
}
a.invalidateAwardStats() // confirmations move award counts
applog.Printf("hamlog cfm import: %d records, %d confirmed, %d matched (%d by mode class), %d unmatched",
res.Total, res.Confirmed, res.Matched, res.ByClass, res.Unmatched)
emit(fmt.Sprintf("%d records read, %d confirmed by HAMLOG.online, %d matched in the log (%d by mode class), %d unmatched",
res.Total, res.Confirmed, res.Matched, res.ByClass, res.Unmatched))
for _, s := range res.Samples {
emit(" unmatched: " + s)
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": res.Matched, "total": res.Confirmed})
}
return res, nil
}
// hamlogQSLDateKey stamps WHEN the confirmation was read back. Their export
// carries no confirmation date of its own, so this is the import date — which
// is honest about what it knows, unlike borrowing the QSO date.
const hamlogQSLDateKey = "APP_OPSLOG_HAMLOG_QSL_DATE"
// hamlogRecordConfirmed reads their flag off a raw ADIF record. The primary key
// is the one their own export writes; the alternates are the names OpsLog and
// other tools have used, so a file that went through another logger still reads.
func hamlogRecordConfirmed(rec adif.Record) bool {
keys := append([]string{award.HamlogQSLKey}, award.HamlogAltKeys()...)
for _, k := range keys {
v := strings.ToUpper(strings.TrimSpace(rec[strings.ToLower(k)]))
if v != "" && v != "N" && v != "NO" {
return true
}
}
return false
}
// hamlogCfmDate is the date to stamp: the import date, in ADIF form.
func hamlogCfmDate(rec adif.Record) string {
// If a future export ever carries one, take it rather than today's date.
for _, k := range []string{"app_hamlog_qso_cfm_date", "qslrdate"} {
if v := strings.TrimSpace(rec[k]); len(v) == 8 {
return v
}
}
return time.Now().UTC().Format("20060102")
}
+4
View File
@@ -1632,3 +1632,7 @@ func sortedBands(m map[string]int) []string {
})
return out
}
// HamlogAltKeys exposes the alternate spellings so a reader outside this package
// (the confirmations import) accepts exactly what the award engine accepts.
func HamlogAltKeys() []string { return append([]string(nil), hamlogAltKeys...) }