diff --git a/app.go b/app.go index 72763c2..3a5a2b0 100644 --- a/app.go +++ b/app.go @@ -11460,6 +11460,120 @@ func (a *App) TestClublogUpload() (string, error) { return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog) } +// UploadFullLogHamQTH replaces the HamQTH log with this one, in one request. +// +// The per-QSO API is the only correct way to send a SELECTION, and at the pace +// it has to be driven a full backlog costs the better part of an hour. This is +// the other endpoint HamQTH offers: a whole log as one file, which is why it +// only ever runs on an explicit "replace my HamQTH log" — the site keeps +// nothing that is not in the file. +// +// Scoped to the callsign this profile uploads as: a database holding two +// operators' contacts must not push one operator's QSOs into the other's log. +func (a *App) UploadFullLogHamQTH() error { + if a.qso == nil { + return fmt.Errorf("db not initialized") + } + cfg := a.loadExternalServices().HamQTH + if strings.TrimSpace(cfg.Username) == "" || cfg.Password == "" { + return fmt.Errorf("set the HamQTH username and password first") + } + go a.runFullLogHamQTH(cfg) + return nil +} + +func (a *App) runFullLogHamQTH(cfg extsvc.ServiceConfig) { + emit := func(line string) { + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "qslmgr:log", line) + } + } + done := func(n int) { + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": n, "total": n}) + } + } + ctx := a.ctx + owner := a.uploadOwnerCall(extsvc.ServiceHamQTH) + + // Written to a temp file rather than a buffer so the ordinary, tested + // exporter does the work — the same one the Export menu uses. + tmp, err := os.CreateTemp("", "opslog-hamqth-*.adi") + if err != nil { + emit("Export failed: " + err.Error()) + done(0) + return + } + path := tmp.Name() + _ = tmp.Close() + defer os.Remove(path) + + emit("Exporting the log…") + var res adif.ExportResult + if owner != "" { + // station_callsign empty OR the owner call — an old QSO logged before + // the field existed belongs to whoever is uploading now. + f := qso.QueryFilter{Match: "OR", Conditions: []qso.Condition{ + {Field: "station_callsign", Op: "eq", Value: ""}, + {Field: "station_callsign", Op: "eq", Value: owner}, + }} + res, err = a.ExportADIFFiltered(path, false, f, nil) + } else { + res, err = a.ExportADIF(path, false, nil) + } + if err != nil { + emit("Export failed: " + err.Error()) + done(0) + return + } + data, rerr := os.ReadFile(path) + if rerr != nil { + emit("Export failed: " + rerr.Error()) + done(0) + return + } + emit(fmt.Sprintf("Uploading %d QSO(s) to HamQTH — this REPLACES the log there…", res.Count)) + + up, uerr := extsvc.UploadHamQTHFullLog(ctx, nil, cfg, string(data)) + if uerr != nil || !up.OK { + msg := up.Message + if uerr != nil { + msg = uerr.Error() + } + emit("Upload failed: " + msg) + applog.Printf("hamqth: full-log upload failed: %s", msg) + done(0) + return + } + emit("HamQTH accepted the log: " + up.Message) + emit("HamQTH imports it in the background — errors in the ADIF are e-mailed to you, not reported here.") + + // Everything is on HamQTH now, so nothing is still waiting to be sent. Only + // the rows that are not already stamped need writing. + pending, lerr := a.qso.ListMissingExtra(ctx, hamqthSentKey) + if lerr != nil { + applog.Printf("hamqth: marking sent: %v", lerr) + } else if len(pending) > 0 { + ids := make([]int64, 0, len(pending)) + for _, q := range pending { + ids = append(ids, q.ID) + } + date := time.Now().UTC().Format("20060102") + if _, e := a.qso.BulkSetExtra(ctx, ids, hamqthSentKey, "Y"); e != nil { + applog.Printf("hamqth: marking sent: %v", e) + } + if _, e := a.qso.BulkSetExtra(ctx, ids, hamqthSentDateKey, date); e != nil { + applog.Printf("hamqth: marking sent date: %v", e) + } + emit(fmt.Sprintf("Marked %d QSO(s) as sent to HamQTH.", len(ids))) + } + applog.Printf("hamqth: full-log upload OK (%d QSOs)", res.Count) + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("HamQTH: %d QSO uploaded", res.Count)) + } + done(res.Count) +} + // TestHamQTHUpload checks the HamQTH credentials against the callbook login — // authenticated, and unable to touch the log. func (a *App) TestHamQTHUpload() (string, error) { diff --git a/changelog.json b/changelog.json index 374a77a..557261b 100644 --- a/changelog.json +++ b/changelog.json @@ -15,7 +15,8 @@ "QSO editor, QSL Info: the confirmation channels are listed paper QSL and LoTW first — the two that carry an ARRL award — then alphabetically, in both the picker and the status table.", "Band map: a chevron in the footer folds the colour legend away and brings it back — four lines of a short screen, remembered between sessions.", "Band map: ctrl+wheel no longer zooms it — that gesture is the window zoom everywhere else in OpsLog. The + and − buttons keep the zoom.", - "DX Cluster: a pill per configured server, connected or not. The CONNECTED/DISCONNECTED word is gone — the colour already said it — and clicking a pill connects or disconnects that server on its own, without opening Settings. State, retries, address and last error moved into the tooltip." + "DX Cluster: a pill per configured server, connected or not. The CONNECTED/DISCONNECTED word is gone — the colour already said it — and clicking a pill connects or disconnects that server on its own, without opening Settings. State, retries, address and last error moved into the tooltip.", + "HamQTH: an “Upload the whole log” button in the QSL Manager — one file instead of one request per QSO, so a first sync takes seconds rather than the better part of an hour. It REPLACES the log held on HamQTH (the site has no partial upload), so it asks first, is scoped to the callsign this profile uploads as, and compresses a large log to stay under the 20 MB limit." ], "fr": [ "Changer de base de réglages n’affiche plus « OpsLog is already running » : la relance automatique attend désormais que l’instance qui se ferme libère son verrou au lieu de la prendre de vitesse.", @@ -30,7 +31,8 @@ "Éditeur de QSO, onglet QSL : les canaux de confirmation sont classés QSL papier puis LoTW — les deux qui comptent pour un diplôme ARRL — puis par ordre alphabétique, dans le sélecteur comme dans le tableau.", "Band map : un chevron dans le pied de page replie la légende des couleurs et la fait revenir — quatre lignes gagnées sur un petit écran, mémorisé d’une session à l’autre.", "Band map : ctrl+molette ne zoome plus la carte — ce geste est le zoom de la fenêtre partout ailleurs dans OpsLog. Les boutons + et − gardent le zoom.", - "DX Cluster : une pastille par serveur configuré, connecté ou non. Le mot CONNECTED/DISCONNECTED disparaît — la couleur le disait déjà — et cliquer sur une pastille connecte ou déconnecte ce serveur seul, sans passer par les réglages. État, tentatives, adresse et dernière erreur passent dans l’infobulle." + "DX Cluster : une pastille par serveur configuré, connecté ou non. Le mot CONNECTED/DISCONNECTED disparaît — la couleur le disait déjà — et cliquer sur une pastille connecte ou déconnecte ce serveur seul, sans passer par les réglages. État, tentatives, adresse et dernière erreur passent dans l’infobulle.", + "HamQTH : un bouton « Envoyer tout le log » dans le QSL Manager — un seul fichier au lieu d’une requête par QSO, une première synchro passe de près d’une heure à quelques secondes. Il REMPLACE le log stocké sur HamQTH (le site n’a pas d’envoi partiel) : il demande donc confirmation, se limite à l’indicatif du profil et compresse un gros log pour rester sous la limite de 20 Mo." ] }, { diff --git a/frontend/src/components/QSLManagerModal.tsx b/frontend/src/components/QSLManagerModal.tsx index 4a2a2db..62b3a75 100644 --- a/frontend/src/components/QSLManagerModal.tsx +++ b/frontend/src/components/QSLManagerModal.tsx @@ -8,7 +8,7 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { cn } from '@/lib/utils'; -import { GetLoTWQSLDetail, SetLoTWQSLDetail, GetLoTWDownloadAllCalls, SetLoTWDownloadAllCalls, OpenExternalURL, FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App'; +import { GetLoTWQSLDetail, SetLoTWQSLDetail, GetLoTWDownloadAllCalls, SetLoTWDownloadAllCalls, OpenExternalURL, FindQSOsForUpload, UploadFullLogHamQTH, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, 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'; @@ -723,7 +723,23 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: { {service !== 'pota' && service !== 'paper' && (
- {service === 'hamlog' ? ( + {service === 'hamqth' ? ( + // HamQTH's file endpoint REPLACES the remote log — its own + // documentation is explicit that partial uploads do not exist. So + // it is offered as its own deliberate act, never as the batch path + // behind "send these": that would delete everything not selected. + + ) : service === 'hamlog' ? ( <>