diff --git a/app.go b/app.go index 666c5e0..e04b0d3 100644 --- a/app.go +++ b/app.go @@ -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 { diff --git a/frontend/src/components/BulkEditModal.tsx b/frontend/src/components/BulkEditModal.tsx index 2086710..792561d 100644 --- a/frontend/src/components/BulkEditModal.tsx +++ b/frontend/src/components/BulkEditModal.tsx @@ -112,45 +112,47 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) { -
- - - - - {isStatus ? ( - - {STATUS_VALUES.map((v) => {v.label})} + {GROUPS.map((g) => ( +
+
{g}
+ {FIELDS.filter((f) => f.group === g).map((f) => ( + {f.label} + ))} +
+ ))}
- ) : ( - setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)} - /> - )} -
-
- Will set {def.label} ={' '} - {effectiveValue === '' ? '(blank)' : effectiveValue} on {ids.length} QSO{ids.length > 1 ? 's' : ''}. + + {isStatus ? ( + + ) : ( + setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)} + /> + )} +
+ +
+ Will set {def.label} ={' '} + {effectiveValue === '' ? '(blank)' : effectiveValue} on {ids.length} QSO{ids.length > 1 ? 's' : ''}. +
+ {error &&
{error}
} - {error &&
{error}
} diff --git a/internal/extsvc/hrdlog.go b/internal/extsvc/hrdlog.go index 85e0ee0..e485a1d 100644 --- a/internal/extsvc/hrdlog.go +++ b/internal/extsvc/hrdlog.go @@ -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 record and +// replies "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), "") { + return UploadResult{OK: false, Message: body}, fmt.Errorf("hrdlog: %s", body) + } + return UploadResult{OK: true, Message: "uploaded"}, nil +} + +// parseHRDLogInsert reads N from "N" (or "N"). +func parseHRDLogInsert(body string) (int, bool) { + b := strings.ToLower(body) + i := strings.Index(b, "") + if i < 0 { + return 0, false + } + rest := b[i+len(""):] + 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