Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b952e074f4 | ||
|
|
e01fc39abc | ||
|
|
deabb74393 | ||
|
|
803f4dc4ed | ||
|
|
20784c2fb1 | ||
|
|
83dbdf0539 |
@@ -6451,6 +6451,26 @@ func (a *App) DeleteQSO(id int64) error {
|
||||
// Failures never block the local delete. The operator asked for this QSO to go;
|
||||
// a refusing website is not a reason to keep it, and the log says what happened
|
||||
// so it can be sorted out on the site afterwards.
|
||||
// deleteSnapshot reads the rows behind a set of ids in ONE query.
|
||||
//
|
||||
// The delete hooks used to fetch them one by one. That is a round trip per
|
||||
// contact, and on a remote MySQL logbook 200 deletions became 400 sequential
|
||||
// queries before the DELETE was even issued — the reason deleting a selection
|
||||
// felt broken rather than slow.
|
||||
func (a *App) deleteSnapshot(ids []int64) []qso.QSO {
|
||||
if a.qso == nil || len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]qso.QSO, 0, len(ids))
|
||||
if err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error {
|
||||
out = append(out, q)
|
||||
return nil
|
||||
}); err != nil {
|
||||
applog.Printf("delete: reading %d QSO(s) before deletion failed: %v", len(ids), err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) deleteRemoteCopies(ids []int64) {
|
||||
if a.qso == nil || len(ids) == 0 {
|
||||
return
|
||||
@@ -6469,9 +6489,37 @@ func (a *App) deleteRemoteCopies(ids []int64) {
|
||||
if !doQRZ && !doClublog {
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
q, err := a.qso.GetByID(a.ctx, id)
|
||||
if err != nil || q.Callsign == "" {
|
||||
// Snapshot first — the rows are about to disappear — then withdraw them in
|
||||
// the background. Each service is an HTTP round trip PER CONTACT, so doing
|
||||
// this on the caller's goroutine held the whole UI for as long as the
|
||||
// websites took: minutes for a large selection, with nothing on screen
|
||||
// saying why. The local log is the operator's own copy and must not wait on
|
||||
// two remote sites to answer.
|
||||
rows := a.deleteSnapshot(ids)
|
||||
go a.withdrawRemoteCopies(rows, cfg, doQRZ, doClublog)
|
||||
}
|
||||
|
||||
// maxClublogRefusals is how many consecutive refusals are tolerated before the
|
||||
// withdrawals stop. Three is enough to tell a one-off from a blocked account.
|
||||
const maxClublogRefusals = 3
|
||||
|
||||
// clublogWasUploaded reports whether this QSO ever reached Club Log. "M"
|
||||
// (modified since upload) counts: the copy is there, it is merely out of date.
|
||||
func clublogWasUploaded(status string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(status)) {
|
||||
case "Y", "M":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) withdrawRemoteCopies(rows []qso.QSO, cfg extsvc.ExternalServices, doQRZ, doClublog bool) {
|
||||
started := time.Now()
|
||||
clublogRefused := 0
|
||||
for i := range rows {
|
||||
q := rows[i]
|
||||
id := q.ID
|
||||
if q.Callsign == "" {
|
||||
continue
|
||||
}
|
||||
if doQRZ {
|
||||
@@ -6490,14 +6538,31 @@ func (a *App) deleteRemoteCopies(ids []int64) {
|
||||
applog.Printf("extsvc: QRZ delete of QSO %d (%s): %s", id, q.Callsign, msg)
|
||||
}
|
||||
}
|
||||
if doClublog {
|
||||
// Only ask Club Log about contacts it was actually given. A QSO that was
|
||||
// never uploaded has no copy to withdraw, and asking anyway is not merely
|
||||
// pointless: Club Log answers 403 and starts blocking the account, so a
|
||||
// selection of never-uploaded QSOs turned into hundreds of refused
|
||||
// requests, one after another, each waiting on the network.
|
||||
if doClublog && !clublogWasUploaded(q.ClublogUploadStatus) {
|
||||
applog.Printf("extsvc: QSO %d (%s) was never uploaded to Club Log — nothing to withdraw", id, q.Callsign)
|
||||
} else if doClublog {
|
||||
if msg, err := extsvc.DeleteClublog(a.ctx, nil, cfg.Clublog, q.Callsign, q.QSODate, q.Band); err != nil {
|
||||
clublogRefused++
|
||||
applog.Printf("extsvc: Club Log delete of QSO %d (%s) failed: %v", id, q.Callsign, err)
|
||||
// Club Log blocks an account that keeps sending requests it
|
||||
// refuses. Stop after a few in a row rather than work through
|
||||
// the whole selection earning a longer block.
|
||||
if clublogRefused >= maxClublogRefusals {
|
||||
applog.Printf("extsvc: Club Log refused %d deletions in a row — giving up on the rest", clublogRefused)
|
||||
doClublog = false
|
||||
}
|
||||
} else {
|
||||
clublogRefused = 0
|
||||
applog.Printf("extsvc: Club Log delete of QSO %d (%s): %s", id, q.Callsign, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
applog.Printf("extsvc: withdrew %d deleted QSO(s) from the remote services in %s", len(rows), time.Since(started).Round(time.Millisecond))
|
||||
}
|
||||
|
||||
// DeleteQSOs removes several QSOs at once (multi-row selection). Returns the
|
||||
@@ -6506,9 +6571,23 @@ func (a *App) DeleteQSOs(ids []int64) (int64, error) {
|
||||
if a.qso == nil {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
}
|
||||
started := time.Now()
|
||||
a.deleteRemoteCopies(ids)
|
||||
a.syncPublishDeletes(ids)
|
||||
return a.qso.DeleteMany(a.ctx, ids)
|
||||
beforeDelete := time.Since(started)
|
||||
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 after %s: %v", len(ids), time.Since(started).Round(time.Millisecond), err)
|
||||
} else {
|
||||
// Both timings, because they blame different things: a long prelude is
|
||||
// the sync/withdraw hooks, a long DELETE is the database itself.
|
||||
applog.Printf("delete: %d QSO(s) requested, %d row(s) removed — %s before the delete, %s total",
|
||||
len(ids), n, beforeDelete.Round(time.Millisecond), time.Since(started).Round(time.Millisecond))
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// DuplicateGroup is a set of QSOs the log considers the same contact.
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
[
|
||||
{
|
||||
"version": "0.26.9",
|
||||
"date": "",
|
||||
"en": [
|
||||
"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.",
|
||||
"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.",
|
||||
"Deleting QSOs is fast again when 'delete from the remote services' is on. Club Log is only asked about contacts that were actually uploaded to it — asking about the others earned a 403 each time, one network round trip per QSO — the withdrawals now run in the background instead of holding the window, the rows behind a selection are read in one query rather than one per QSO, and repeated refusals stop the run rather than earning a longer block."
|
||||
],
|
||||
"fr": [
|
||||
"É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.",
|
||||
"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.",
|
||||
"La suppression de QSO redevient rapide quand « supprimer aussi des services externes » est activé. Club Log n'est plus interrogé que pour les contacts qui y ont réellement été envoyés — pour les autres il répondait 403, un aller-retour réseau par QSO — les retraits se font désormais en arrière-plan au lieu de bloquer la fenêtre, les lignes d'une sélection sont lues en une seule requête au lieu d'une par QSO, et une série de refus interrompt le traitement au lieu d'aggraver le blocage."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.26.8",
|
||||
"date": "",
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -33,6 +33,8 @@ type FieldType = 'text' | 'number' | 'date' | 'adifdate';
|
||||
const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
||||
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
|
||||
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' },
|
||||
// When the record entered the logbook — the way to isolate what an import added.
|
||||
{ value: 'created_at', label: 'fltb.fCreated', type: 'date' },
|
||||
{ value: 'qso_date_off', label: 'fltb.fEndDate', type: 'date' },
|
||||
{ value: 'band', label: 'fltb.fBand', type: 'text' },
|
||||
{ value: 'band_rx', label: 'fltb.fRxBand', type: 'text' },
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -85,6 +85,19 @@ const CONF_LABEL_KEYS: Record<string, string> = {
|
||||
// the channel picker and the status table alongside the rest.
|
||||
const OPSLOG_CONF = 'OPSLOG';
|
||||
|
||||
// HAMLOG.online. Out of CONFIRMATIONS for the same reason as the OpsLog card —
|
||||
// it is stored in the ADIF extras, not in QSO columns — but it is an upload
|
||||
// service like QRZ.com or Club Log, so it gets the ordinary sent/received/date
|
||||
// editor rather than a special one. The received key is THEIR field name, the
|
||||
// one their ADIF export writes; see award.HamlogQSLKey.
|
||||
const HAMLOG_CONF = 'HAMLOG';
|
||||
const HAMLOG_KEYS = {
|
||||
sent: 'APP_OPSLOG_HAMLOG_SENT',
|
||||
sentDate: 'APP_OPSLOG_HAMLOG_SENT_DATE',
|
||||
rcvd: 'APP_HAMLOG_QSO_CFM',
|
||||
rcvdDate: 'APP_OPSLOG_HAMLOG_QSL_DATE',
|
||||
};
|
||||
|
||||
// Colour-coded status cell for the confirmation grid.
|
||||
function StatusCell({ value }: { value?: string }) {
|
||||
const { t } = useI18n();
|
||||
@@ -715,6 +728,16 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
const def = CONFIRMATIONS.find((c) => c.key === confSel) ?? CONFIRMATIONS[0];
|
||||
const val = (k?: keyof QSOForm) => (k ? ((draft as any)[k] ?? '') : '');
|
||||
const put = (k: keyof QSOForm | undefined, v: any) => { if (k) (set as any)(k, v); };
|
||||
// The extras-backed channel reads and writes the same way, one
|
||||
// level down. Save merges the extras rather than replacing them,
|
||||
// so an emptied field keeps its stored value — which is why the
|
||||
// statuses are written as explicit Y/N and never removed.
|
||||
const exVal = (k: string) => String(draft.extras?.[k] ?? '');
|
||||
const exPut = (k: string, v: any) => {
|
||||
const next = { ...(draft.extras ?? {}) };
|
||||
next[k] = String(v ?? '');
|
||||
set('extras', next as any);
|
||||
};
|
||||
return (
|
||||
<div className="flex gap-6">
|
||||
{/* Left: edit one confirmation channel at a time */}
|
||||
@@ -731,11 +754,24 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
generic sent/received/date grid, which has no field
|
||||
to bind to. */}
|
||||
<SelectItem value={OPSLOG_CONF}>{t('qedit.confOpsLog')}</SelectItem>
|
||||
<SelectItem value={HAMLOG_CONF}>HAMLOG.online</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{confSel === OPSLOG_CONF ? (
|
||||
{confSel === HAMLOG_CONF ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMLOG_KEYS.sent)} onChange={(v) => exPut(HAMLOG_KEYS.sent, v)} /></div>
|
||||
<div><Label>{t('qedit.received')}</Label><QslSelect value={exVal(HAMLOG_KEYS.rcvd)} onChange={(v) => exPut(HAMLOG_KEYS.rcvd, v)} /></div>
|
||||
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={exVal(HAMLOG_KEYS.sentDate)} onChange={(v) => exPut(HAMLOG_KEYS.sentDate, v)} /></div>
|
||||
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={exVal(HAMLOG_KEYS.rcvdDate)} onChange={(v) => exPut(HAMLOG_KEYS.rcvdDate, v)} /></div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
||||
</p>
|
||||
</>
|
||||
) : confSel === OPSLOG_CONF ? (
|
||||
/* OpsLog's own card. "Sent" is stamped when the card
|
||||
actually goes out, so it is shown, not offered: ticking
|
||||
it by hand would record something that never happened.
|
||||
@@ -827,6 +863,12 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<td className="w-24"><StatusCell value={opslogQslSent ? 'Y' : 'N'} /></td>
|
||||
<td className="w-24"><StatusCell value={qslReceived ? 'Y' : 'N'} /></td>
|
||||
</tr>
|
||||
{/* HAMLOG.online — extras again, same hand-written row. */}
|
||||
<tr className="text-xs">
|
||||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">HAMLOG.online</td>
|
||||
<td className="w-24"><StatusCell value={exVal(HAMLOG_KEYS.sent)} /></td>
|
||||
<td className="w-24"><StatusCell value={exVal(HAMLOG_KEYS.rcvd)} /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -213,10 +213,10 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
||||
// hamlog.EU and none for hamlog.ONLINE — so these read the extras, exactly
|
||||
// like the OpsLog card columns above. Hidden by default: a column per
|
||||
// service, shown to everyone, is how a grid becomes unreadable.
|
||||
{ group: 'QSL', label: t('rqg.c.hamlog_sent'), colId: 'hamlog_sent', headerName: t('rqg.h.hamlog_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_HAMLOG_SENT'] || 'N'; }, defaultVisible: false },
|
||||
{ group: 'QSL', label: t('rqg.c.hamlog_sent_date'), colId: 'hamlog_sent_date', headerName: t('rqg.h.hamlog_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_SENT_DATE']), defaultVisible: false },
|
||||
{ group: 'QSL', label: t('rqg.c.hamlog_rcvd'), colId: 'hamlog_rcvd', headerName: t('rqg.h.hamlog_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_HAMLOG_QSO_CFM'] || e['APP_OPSLOG_HAMLOG_QSL'] || 'N'; }, defaultVisible: false },
|
||||
{ group: 'QSL', label: t('rqg.c.hamlog_rcvd_date'), colId: 'hamlog_rcvd_date', headerName: t('rqg.h.hamlog_rcvd_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_QSL_DATE']), defaultVisible: false },
|
||||
{ group: 'Uploads', label: t('rqg.c.hamlog_sent'), colId: 'hamlog_sent', headerName: t('rqg.h.hamlog_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_HAMLOG_SENT'] || 'N'; }, defaultVisible: false },
|
||||
{ group: 'Uploads', label: t('rqg.c.hamlog_sent_date'), colId: 'hamlog_sent_date', headerName: t('rqg.h.hamlog_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_SENT_DATE']), defaultVisible: false },
|
||||
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd'), colId: 'hamlog_rcvd', headerName: t('rqg.h.hamlog_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_HAMLOG_QSO_CFM'] || e['APP_OPSLOG_HAMLOG_QSL'] || 'N'; }, defaultVisible: false },
|
||||
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd_date'), colId: 'hamlog_rcvd_date', headerName: t('rqg.h.hamlog_rcvd_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_QSL_DATE']), defaultVisible: false },
|
||||
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.26.8';
|
||||
export const APP_VERSION = '0.26.9';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+2
@@ -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>;
|
||||
|
||||
@@ -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']();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
@@ -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...) }
|
||||
|
||||
+6
-1
@@ -1291,11 +1291,16 @@ var filterableColumns = map[string]bool{
|
||||
"my_wwff_ref": true, "my_street": true, "my_city": true, "my_postal_code": true,
|
||||
"my_rig": true, "my_antenna": true, "my_sig": true, "my_sig_info": true,
|
||||
"tx_pwr": true, "comment": true, "notes": true,
|
||||
// When the record entered THIS logbook, as opposed to when the contact was
|
||||
// made. The question it answers is "what did that import actually add?" —
|
||||
// unanswerable otherwise, since an import of old contacts carries old QSO
|
||||
// dates and hides inside the log.
|
||||
"created_at": true,
|
||||
}
|
||||
|
||||
// dateColumns are stored as full ISO timestamps; a filter on a bare YYYY-MM-DD
|
||||
// value compares on the date part (see conditionSQL) so day filters are exact.
|
||||
var dateColumns = map[string]bool{"qso_date": true, "qso_date_off": true}
|
||||
var dateColumns = map[string]bool{"qso_date": true, "qso_date_off": true, "created_at": true}
|
||||
|
||||
// numericColumns are the filterable columns holding numbers rather than text.
|
||||
//
|
||||
|
||||
+5
-5
@@ -315,11 +315,11 @@ func (a *App) syncPublishDeletes(ids []int64) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
q, err := a.qso.GetByID(a.ctx, id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// One read for the whole set: see deleteSnapshot. Tombstones are written
|
||||
// before the rows go, because a QSO that never had a sync identity has to be
|
||||
// given one here — after the DELETE there would be nothing left to stamp.
|
||||
for _, q := range a.deleteSnapshot(ids) {
|
||||
id := q.ID
|
||||
// A contact never touched since the sync was switched on has no identity,
|
||||
// and giving it one now is what makes the deletion addressable at all.
|
||||
a.syncMu.Lock()
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.26.8"
|
||||
appVersion = "0.26.9"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user