fix: batch upload to HRDLog instead of one by one

This commit is contained in:
2026-06-18 19:28:36 +02:00
parent 679e8f8d39
commit 4d074de27e
3 changed files with 113 additions and 45 deletions
+21 -12
View File
@@ -5223,12 +5223,15 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
}
emit(fmt.Sprintf("LoTW: %d QSO(s) uploaded", uploaded))
}
} else if svc == extsvc.ServiceClublog {
// Club Log accepts a whole ADIF file (putlogs.php) and dedupes
// server-side, so upload in chunks instead of one realtime.php request
// per QSO. Chunked so a single failure doesn't lose the whole run and
// the user sees progress.
const clublogChunk = 100
} else if svc == extsvc.ServiceClublog || svc == extsvc.ServiceHRDLog {
// Club Log and HRDLog both accept a whole ADIF document in one request
// and dedupe server-side, so upload in chunks instead of one request per
// QSO. Chunked so a single failure doesn't lose the whole run and the
// user sees progress.
name, chunk := "Club Log", 100
if svc == extsvc.ServiceHRDLog {
name, chunk = "HRDLog", 50
}
type item struct {
id int64
rec string
@@ -5248,9 +5251,9 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
}
items = append(items, item{id: id, rec: rec, call: call})
}
emit(fmt.Sprintf("Club Log: uploading %d QSO(s) in batches of %d…", len(items), clublogChunk))
for start := 0; start < len(items); start += clublogChunk {
end := start + clublogChunk
emit(fmt.Sprintf("%s: uploading %d QSO(s) in batches of %d…", name, len(items), chunk))
for start := 0; start < len(items); start += chunk {
end := start + chunk
if end > len(items) {
end = len(items)
}
@@ -5260,19 +5263,25 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
recs[i] = it.rec
}
doc := adif.BatchRecordsADIF(recs)
res, err := extsvc.UploadClublogADIF(ctx, nil, cfg.Clublog, doc)
var res extsvc.UploadResult
var err error
if svc == extsvc.ServiceHRDLog {
res, err = extsvc.UploadHRDLogADIF(ctx, nil, cfg.HRDLog.Callsign, cfg.HRDLog.Code, doc)
} else {
res, err = extsvc.UploadClublogADIF(ctx, nil, cfg.Clublog, doc)
}
if err == nil && res.OK {
for _, it := range batch {
a.markExtUploaded(svc, it.id, "")
uploaded++
}
emit(fmt.Sprintf("Club Log: %d/%d uploaded", end, len(items)))
emit(fmt.Sprintf("%s: %d/%d uploaded", name, end, len(items)))
} else {
msg := res.Message
if err != nil {
msg = err.Error()
}
emit(fmt.Sprintf("Club Log: batch of %d FAILED: %s", len(batch), msg))
emit(fmt.Sprintf("%s: batch of %d FAILED: %s", name, len(batch), msg))
}
}
} else {
+3 -1
View File
@@ -112,7 +112,8 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-[90px_1fr] gap-3 items-center py-2">
<div className="px-5 py-2 space-y-3">
<div className="grid grid-cols-[90px_1fr] gap-3 items-center">
<Label className="text-sm">Field</Label>
<Select value={field} onValueChange={setField}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
@@ -151,6 +152,7 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
<span className="font-mono">{effectiveValue === '' ? '(blank)' : effectiveValue}</span> on {ids.length} QSO{ids.length > 1 ? 's' : ''}.
</div>
{error && <div className="text-xs text-rose-700">{error}</div>}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
+57
View File
@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
@@ -116,6 +117,62 @@ func UploadHRDLog(ctx context.Context, client *http.Client, callsign, code, adif
return UploadResult{OK: false, Message: reason}, fmt.Errorf("hrdlog: upload failed: %s", reason)
}
// UploadHRDLogADIF pushes a WHOLE ADIF document (header + many records) to
// HRDLog.net in one NewEntry request — HRDLog parses every <eor> record and
// replies "<insert>N" with the number actually inserted (duplicates aren't
// counted but aren't errors). Use this for bulk uploads instead of calling
// UploadHRDLog once per QSO.
func UploadHRDLogADIF(ctx context.Context, client *http.Client, callsign, code, adifDoc string) (UploadResult, error) {
callsign = strings.ToUpper(strings.TrimSpace(callsign))
code = strings.TrimSpace(code)
if callsign == "" {
return UploadResult{}, fmt.Errorf("hrdlog: station callsign not set")
}
if code == "" {
return UploadResult{}, fmt.Errorf("hrdlog: upload code not set")
}
if strings.TrimSpace(adifDoc) == "" {
return UploadResult{}, fmt.Errorf("hrdlog: empty adif")
}
body, err := hrdlogPost(ctx, client, callsign, code, adifDoc)
if err != nil {
return UploadResult{OK: false, Message: body}, err
}
if reason := authErrHRDLog(body); reason != "" {
return UploadResult{OK: false, Message: reason}, fmt.Errorf("hrdlog: %s", reason)
}
if n, ok := parseHRDLogInsert(body); ok {
return UploadResult{OK: true, Message: fmt.Sprintf("%d added", n)}, nil
}
if strings.Contains(strings.ToLower(body), "<error>") {
return UploadResult{OK: false, Message: body}, fmt.Errorf("hrdlog: %s", body)
}
return UploadResult{OK: true, Message: "uploaded"}, nil
}
// parseHRDLogInsert reads N from "<insert>N" (or "<insert>N</insert>").
func parseHRDLogInsert(body string) (int, bool) {
b := strings.ToLower(body)
i := strings.Index(b, "<insert>")
if i < 0 {
return 0, false
}
rest := b[i+len("<insert>"):]
j := 0
for j < len(rest) && rest[j] >= '0' && rest[j] <= '9' {
j++
}
if j == 0 {
return 0, false
}
n, err := strconv.Atoi(rest[:j])
if err != nil {
return 0, false
}
return n, true
}
// TestHRDLog validates the configured HRDLog credentials with a REAL request:
// it posts an empty ADIF so nothing is inserted, then checks for HRDLog's auth
// errors. A wrong upload code comes back as "Invalid token", a wrong callsign