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)) emit(fmt.Sprintf("LoTW: %d QSO(s) uploaded", uploaded))
} }
} else if svc == extsvc.ServiceClublog { } else if svc == extsvc.ServiceClublog || svc == extsvc.ServiceHRDLog {
// Club Log accepts a whole ADIF file (putlogs.php) and dedupes // Club Log and HRDLog both accept a whole ADIF document in one request
// server-side, so upload in chunks instead of one realtime.php request // and dedupe server-side, so upload in chunks instead of one request per
// per QSO. Chunked so a single failure doesn't lose the whole run and // QSO. Chunked so a single failure doesn't lose the whole run and the
// the user sees progress. // user sees progress.
const clublogChunk = 100 name, chunk := "Club Log", 100
if svc == extsvc.ServiceHRDLog {
name, chunk = "HRDLog", 50
}
type item struct { type item struct {
id int64 id int64
rec string 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}) 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)) emit(fmt.Sprintf("%s: uploading %d QSO(s) in batches of %d…", name, len(items), chunk))
for start := 0; start < len(items); start += clublogChunk { for start := 0; start < len(items); start += chunk {
end := start + clublogChunk end := start + chunk
if end > len(items) { if end > len(items) {
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 recs[i] = it.rec
} }
doc := adif.BatchRecordsADIF(recs) 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 { if err == nil && res.OK {
for _, it := range batch { for _, it := range batch {
a.markExtUploaded(svc, it.id, "") a.markExtUploaded(svc, it.id, "")
uploaded++ uploaded++
} }
emit(fmt.Sprintf("Club Log: %d/%d uploaded", end, len(items))) emit(fmt.Sprintf("%s: %d/%d uploaded", name, end, len(items)))
} else { } else {
msg := res.Message msg := res.Message
if err != nil { if err != nil {
msg = err.Error() 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 { } else {
+35 -33
View File
@@ -112,45 +112,47 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid grid-cols-[90px_1fr] gap-3 items-center py-2"> <div className="px-5 py-2 space-y-3">
<Label className="text-sm">Field</Label> <div className="grid grid-cols-[90px_1fr] gap-3 items-center">
<Select value={field} onValueChange={setField}> <Label className="text-sm">Field</Label>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger> <Select value={field} onValueChange={setField}>
<SelectContent>
{GROUPS.map((g) => (
<div key={g}>
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{g}</div>
{FIELDS.filter((f) => f.group === g).map((f) => (
<SelectItem key={f.id} value={f.id}>{f.label}</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
<Label className="text-sm">Value</Label>
{isStatus ? (
<Select value={statusValue} onValueChange={setStatusValue}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{v.label}</SelectItem>)} {GROUPS.map((g) => (
<div key={g}>
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{g}</div>
{FIELDS.filter((f) => f.group === g).map((f) => (
<SelectItem key={f.id} value={f.id}>{f.label}</SelectItem>
))}
</div>
))}
</SelectContent> </SelectContent>
</Select> </Select>
) : (
<Input
className="h-8 text-xs"
value={textValue}
placeholder="leave empty to clear the field"
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
/>
)}
</div>
<div className="text-[11px] text-muted-foreground"> <Label className="text-sm">Value</Label>
Will set <span className="font-semibold">{def.label}</span> ={' '} {isStatus ? (
<span className="font-mono">{effectiveValue === '' ? '(blank)' : effectiveValue}</span> on {ids.length} QSO{ids.length > 1 ? 's' : ''}. <Select value={statusValue} onValueChange={setStatusValue}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{v.label}</SelectItem>)}
</SelectContent>
</Select>
) : (
<Input
className="h-8 text-xs"
value={textValue}
placeholder="leave empty to clear the field"
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
/>
)}
</div>
<div className="text-[11px] text-muted-foreground">
Will set <span className="font-semibold">{def.label}</span> ={' '}
<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> </div>
{error && <div className="text-xs text-rose-700">{error}</div>}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button> <Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
+57
View File
@@ -6,6 +6,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"strings" "strings"
"time" "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) 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: // 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 // 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 // errors. A wrong upload code comes back as "Invalid token", a wrong callsign