feat(hamqth): upload the whole log in one file

The per-QSO API is the only correct way to send a SELECTION, and at the
pace it must be driven a 14k backlog costs the better part of an hour.
HamQTH's other endpoint takes a whole log as one file — and REPLACES
what is on the site with it: its documentation says plainly that partial
uploads do not exist. So it is offered as its own deliberate act behind
a confirmation, never as the batch path behind 'send these', where it
would delete every QSO the operator had not selected.

Scoped to the callsign this profile uploads as, so a database holding
two operators' contacts cannot push one into the other's log; tar.gz
above 12 MB because the ceiling is 20 and a six-figure log passes it as
text; and every QSO not already stamped is marked sent afterwards, in
bulk, so the backlog list agrees with reality.
This commit is contained in:
2026-08-31 20:07:46 +02:00
parent bcd7e409ba
commit 386a8ad531
7 changed files with 283 additions and 6 deletions
+114
View File
@@ -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) {
+4 -2
View File
@@ -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 naffiche plus « OpsLog is already running » : la relance automatique attend désormais que linstance 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é dune session à lautre.",
"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 linfobulle."
"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 linfobulle.",
"HamQTH : un bouton « Envoyer tout le log » dans le QSL Manager — un seul fichier au lieu dune requête par QSO, une première synchro passe de près dune heure à quelques secondes. Il REMPLACE le log stocké sur HamQTH (le site na pas denvoi partiel) : il demande donc confirmation, se limite à lindicatif du profil et compresse un gros log pour rester sous la limite de 20 Mo."
]
},
{
+18 -2
View File
@@ -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' && (
<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' ? (
{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.
<Button variant="outline" size="sm" disabled={busy}
title={t('qslm.hqFullTitle')}
onClick={async () => {
if (!window.confirm(t('qslm.hqFullConfirm'))) return;
setBusy(true);
setLogLines([]);
try { await UploadFullLogHamQTH(); }
catch (e: any) { setBusy(false); setLogLines((l) => [...l, String(e?.message ?? e)]); }
}}>
<UploadCloud className="size-3.5" /> {t('qslm.hqFull')}
</Button>
) : service === 'hamlog' ? (
<>
<Button variant="outline" size="sm" onClick={importHamlogCfm} disabled={busy}
title={t('qslm.hamlogImportTitle')}>
File diff suppressed because one or more lines are too long
+2
View File
@@ -1397,6 +1397,8 @@ export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>;
export function UploadCallsign(arg1:string):Promise<string>;
export function UploadFullLogHamQTH():Promise<void>;
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
export function WatchlistAdd(arg1:string,arg2:boolean):Promise<void>;
+4
View File
@@ -2730,6 +2730,10 @@ export function UploadCallsign(arg1) {
return window['go']['main']['App']['UploadCallsign'](arg1);
}
export function UploadFullLogHamQTH() {
return window['go']['main']['App']['UploadFullLogHamQTH']();
}
export function UploadQSOsManual(arg1, arg2) {
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
}
+139
View File
@@ -1,9 +1,13 @@
package extsvc
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
@@ -18,6 +22,20 @@ import (
// duplicate…, reason in the body), 403 wrong credentials, 500 server error.
const hamqthUploadURL = "https://www.hamqth.com/qso_realtime.php"
// hamqthFullLogURL takes a WHOLE log as a file. Note "whole": HamQTH's own
// documentation says "you always have to upload whole log. HamQTH doesn't
// support partial upload" — the file REPLACES what is on the site. That is why
// it is not the batch path for a selection, and why the caller must have said
// so out loud before we get here.
const hamqthFullLogURL = "https://www.hamqth.com/prg_log_upload.php"
// hamqthMaxUpload is the documented ceiling for one upload.
const hamqthMaxUpload = 20 << 20
// hamqthCompressAbove is where a plain .adi stops being sent as text. Well
// under the limit: the multipart envelope and the form fields ride along too.
const hamqthCompressAbove = 12 << 20
// hamqthLoginURL is the callbook session login — the one authenticated HamQTH
// endpoint that cannot change anything in the log, which is what the settings
// Test button must call.
@@ -92,6 +110,127 @@ func uploadHamQTHTo(ctx context.Context, client *http.Client, endpoint string, c
}
}
// UploadHamQTHFullLog replaces the account's log with the given ADIF.
//
// DESTRUCTIVE by design of the remote API, not by ours: everything on HamQTH
// for this callsign that is not in this file stops existing. The caller owns
// the confirmation.
//
// The file goes in the multipart field "f" (HamQTH's own curl example:
// curl -F [email protected] -F send_log=OK -F u=… -F p=…). A large log is sent as a
// tar.gz — one of the archive formats the site unpacks — because the ceiling is
// 20 MB and a six-figure log passes it as plain text.
func UploadHamQTHFullLog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifText string) (UploadResult, error) {
user := strings.TrimSpace(cfg.Username)
switch {
case user == "":
return UploadResult{}, fmt.Errorf("hamqth: username not set")
case cfg.Password == "":
return UploadResult{}, fmt.Errorf("hamqth: password not set")
case strings.TrimSpace(adifText) == "":
return UploadResult{}, fmt.Errorf("hamqth: nothing to upload")
}
payload := []byte(adifText)
name := "opslog.adi"
if len(payload) > hamqthCompressAbove {
gz, err := tarGzADIF(payload)
if err != nil {
return UploadResult{}, fmt.Errorf("hamqth: compressing the log: %w", err)
}
payload, name = gz, "opslog.tar.gz"
}
if len(payload) > hamqthMaxUpload {
return UploadResult{}, fmt.Errorf("hamqth: the log is %d MB compressed, over HamQTH's %d MB limit",
len(payload)>>20, hamqthMaxUpload>>20)
}
var body bytes.Buffer
mw := multipart.NewWriter(&body)
_ = mw.WriteField("u", user)
_ = mw.WriteField("p", cfg.Password)
if c := strings.ToUpper(strings.TrimSpace(cfg.Callsign)); c != "" {
_ = mw.WriteField("c", c)
}
_ = mw.WriteField("send_log", "OK")
fw, err := mw.CreateFormFile("f", name)
if err != nil {
return UploadResult{}, err
}
if _, err := fw.Write(payload); err != nil {
return UploadResult{}, err
}
if err := mw.Close(); err != nil {
return UploadResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hamqthFullLogURL, &body)
if err != nil {
return UploadResult{}, err
}
req.Header.Set("Content-Type", mw.FormDataContentType())
if client == nil {
// A whole log is a long POST on a slow uplink.
client = &http.Client{Timeout: 10 * time.Minute}
}
resp, err := client.Do(req)
if err != nil {
return UploadResult{}, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
msg := strings.TrimSpace(string(raw))
if looksLikeHTML(msg) {
msg = ""
}
if resp.StatusCode != http.StatusOK {
if msg != "" && len(msg) < 300 {
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d: %s", resp.StatusCode, msg)
}
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d", resp.StatusCode)
}
// The site answers in prose, and only its own refusals are worth reading
// back: the ADIF itself is validated later, in the background, and any
// complaint about it reaches the operator by e-mail rather than here.
low := strings.ToLower(msg)
switch {
case strings.Contains(low, "successfully"):
return UploadResult{OK: true, Message: msg}, nil
case strings.Contains(low, "wrong username"), strings.Contains(low, "password"):
return UploadResult{}, fmt.Errorf("hamqth: %s", msg)
case strings.Contains(low, "cannot upload log for this callsign"):
return UploadResult{}, fmt.Errorf("hamqth: %s", msg)
case msg == "":
// HTTP 200 with nothing to say: taken as accepted, and said so.
return UploadResult{OK: true, Message: "uploaded (no reply text)"}, nil
default:
return UploadResult{OK: false, Message: msg}, nil
}
}
// tarGzADIF wraps the ADIF as log.adi inside a tar.gz — the archive must carry
// a .adi/.adif member for HamQTH to find the log in it.
func tarGzADIF(adif []byte) ([]byte, error) {
var out bytes.Buffer
gz := gzip.NewWriter(&out)
tw := tar.NewWriter(gz)
if err := tw.WriteHeader(&tar.Header{
Name: "opslog.adi", Mode: 0o644, Size: int64(len(adif)),
}); err != nil {
return nil, err
}
if _, err := tw.Write(adif); err != nil {
return nil, err
}
if err := tw.Close(); err != nil {
return nil, err
}
if err := gz.Close(); err != nil {
return nil, err
}
return out.Bytes(), nil
}
// TestHamQTH verifies the credentials against the callbook session login —
// authenticated, and unable to touch the log.
func TestHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {