fix: batch upload to HRDLog instead of one by one
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user