chore: release v0.25.8
This commit is contained in:
@@ -10376,6 +10376,15 @@ func (a *App) TestCloudlogUpload() (string, error) {
|
||||
// ── QSL Manager (manual upload) ────────────────────────────────────────
|
||||
|
||||
// uploadColumnFor maps a service id to its QSO sent-status column.
|
||||
// manualUploadPace is the shortest gap between two consecutive single-QSO
|
||||
// uploads in a bulk "Send to …" run, for the services with no batch endpoint
|
||||
// (QRZ.com, HRDLog). Selecting twenty-five thousand QSOs in the QSL Manager and
|
||||
// firing them off as fast as the link allows is exactly the traffic a logbook
|
||||
// service reads as a robot rather than an operator — Club Log threatens to block
|
||||
// an IP for it. The gap is free in practice: a round trip to either already
|
||||
// takes longer than it.
|
||||
const manualUploadPace = 200 * time.Millisecond
|
||||
|
||||
func uploadColumnFor(service string) string {
|
||||
switch extsvc.Service(service) {
|
||||
case extsvc.ServiceQRZ:
|
||||
@@ -10470,10 +10479,13 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if svc == extsvc.ServiceClublog || svc == extsvc.ServiceHRDLog {
|
||||
} else if svc == extsvc.ServiceClublog || svc == extsvc.ServiceHRDLog || svc == extsvc.ServiceEQSL {
|
||||
statusCol, dateCol := "clublog_qso_upload_status", "clublog_qso_upload_date"
|
||||
if svc == extsvc.ServiceHRDLog {
|
||||
switch svc {
|
||||
case extsvc.ServiceHRDLog:
|
||||
statusCol, dateCol = "hrdlog_qso_upload_status", "hrdlog_qso_upload_date"
|
||||
case extsvc.ServiceEQSL:
|
||||
statusCol, dateCol = "eqsl_sent", "eqsl_sent_date"
|
||||
}
|
||||
type item struct {
|
||||
id int64
|
||||
@@ -10537,10 +10549,62 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
||||
applog.Printf("extsvc: Club Log batch FAILED (%s) — QSOs: %s", msg, strings.Join(who, ", "))
|
||||
}
|
||||
}
|
||||
} else if svc == extsvc.ServiceEQSL {
|
||||
// eQSL's ImportADIF.cfm is a file importer — it answers "X out of Y
|
||||
// records added" — so send a whole chunk per request instead of one
|
||||
// request per contact. eQSL asks that an upload stay under about a
|
||||
// thousand records; 100 keeps a single refused record from taking the
|
||||
// rest of the chunk with it.
|
||||
const chunk = 100
|
||||
emit(fmt.Sprintf("eQSL: uploading %d QSO(s) in batches of %d…", len(items), chunk))
|
||||
for start := 0; start < len(items); start += chunk {
|
||||
end := start + chunk
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
batch := items[start:end]
|
||||
recs := make([]string, len(batch))
|
||||
batchIDs := make([]int64, len(batch))
|
||||
for i, it := range batch {
|
||||
recs[i] = it.rec
|
||||
batchIDs[i] = it.id
|
||||
}
|
||||
res, err := extsvc.UploadEQSLBatch(ctx, nil, cfg.EQSL.Username, cfg.EQSL.Password, cfg.EQSL.QTHNickname, recs)
|
||||
if err == nil && res.OK {
|
||||
if merr := a.qso.MarkUploadedBatch(ctx, statusCol, dateCol, date, batchIDs); merr != nil {
|
||||
applog.Printf("extsvc: eQSL batch mark: %v", merr)
|
||||
}
|
||||
uploaded += len(batch)
|
||||
// eQSL took the file but left records out. It never says WHICH
|
||||
// — normally they are QSOs it already holds — so quote its own
|
||||
// count rather than claim a clean run.
|
||||
if res.Ignored {
|
||||
emit(fmt.Sprintf("eQSL: %d/%d uploaded — %s", end, len(items), res.Message))
|
||||
} else {
|
||||
emit(fmt.Sprintf("eQSL: %d/%d uploaded", end, len(items)))
|
||||
}
|
||||
} else {
|
||||
msg := res.Message
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
// Name the QSOs in the failing batch, same as Club Log: a
|
||||
// per-record rejection is otherwise impossible to locate.
|
||||
who := make([]string, 0, len(batch))
|
||||
for _, it := range batch {
|
||||
who = append(who, fmt.Sprintf("%s#%d", it.call, it.id))
|
||||
}
|
||||
emit(fmt.Sprintf("eQSL: batch of %d FAILED: %s", len(batch), msg))
|
||||
applog.Printf("extsvc: eQSL batch FAILED (%s) — QSOs: %s", msg, strings.Join(who, ", "))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// HRDLog's NewEntry.aspx inserts only the FIRST record of a multi-
|
||||
// record ADIF, so upload ONE record per request. The DB stays cheap:
|
||||
// bulk fetch above + the marks flushed in batches (not one per QSO).
|
||||
// Paced: HRDLog is the one service here with no way to batch, and a
|
||||
// few thousand requests as fast as the link allows is what a logbook
|
||||
// reads as a robot.
|
||||
emit(fmt.Sprintf("HRDLog: uploading %d QSO(s) (one request each)…", len(items)))
|
||||
var doneIDs []int64
|
||||
flush := func() {
|
||||
@@ -10553,6 +10617,9 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
||||
doneIDs = doneIDs[:0]
|
||||
}
|
||||
for i, it := range items {
|
||||
if i > 0 {
|
||||
time.Sleep(manualUploadPace)
|
||||
}
|
||||
res, err := extsvc.UploadHRDLog(ctx, nil, cfg.HRDLog.Callsign, cfg.HRDLog.Code, it.rec)
|
||||
if err == nil && res.OK {
|
||||
doneIDs = append(doneIDs, it.id)
|
||||
@@ -10574,34 +10641,26 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
||||
flush()
|
||||
}
|
||||
} else {
|
||||
// QRZ.com: one record per request (its logbook API has no batch upload).
|
||||
for _, id := range ids {
|
||||
// QRZ.com: one record per request (its logbook API has no batch upload),
|
||||
// paced for the same reason as HRDLog above.
|
||||
for i, id := range ids {
|
||||
if i > 0 {
|
||||
time.Sleep(manualUploadPace)
|
||||
}
|
||||
q, gerr := a.qso.GetByID(ctx, id)
|
||||
call := ""
|
||||
if gerr == nil {
|
||||
call = q.Callsign
|
||||
}
|
||||
force := ""
|
||||
if svc == extsvc.ServiceQRZ {
|
||||
force = cfg.QRZ.ForceStationCallsign
|
||||
}
|
||||
rec, ok := a.buildUploadADIF(id, force)
|
||||
// QRZ rewrites STATION_CALLSIGN to the registered call.
|
||||
rec, ok := a.buildUploadADIF(id, cfg.QRZ.ForceStationCallsign)
|
||||
if !ok {
|
||||
emit(call + " — skipped (no record)")
|
||||
continue
|
||||
}
|
||||
var res extsvc.UploadResult
|
||||
var err error
|
||||
switch svc {
|
||||
case extsvc.ServiceQRZ:
|
||||
res, err = extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec)
|
||||
case extsvc.ServiceHRDLog:
|
||||
res, err = extsvc.UploadHRDLog(ctx, nil, cfg.HRDLog.Callsign, cfg.HRDLog.Code, rec)
|
||||
case extsvc.ServiceEQSL:
|
||||
res, err = extsvc.UploadEQSL(ctx, nil, cfg.EQSL.Username, cfg.EQSL.Password, cfg.EQSL.QTHNickname, rec)
|
||||
default:
|
||||
res, err = extsvc.UploadClublog(ctx, nil, cfg.Clublog, rec)
|
||||
}
|
||||
// Only QRZ reaches this branch: LoTW, Club Log, HRDLog and eQSL are
|
||||
// all handled above, and UploadQSOsManual rejects anything else.
|
||||
res, err := extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec)
|
||||
if err == nil && res.OK {
|
||||
a.markExtUploaded(svc, id, "")
|
||||
uploaded++
|
||||
@@ -17713,16 +17772,26 @@ func (a *App) SendClusterCommand(cmd string) error {
|
||||
if cmd == "" {
|
||||
return fmt.Errorf("empty command")
|
||||
}
|
||||
servers, err := a.listClusterServers()
|
||||
srv, err := a.masterClusterServer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.cluster.SendCommand(srv.ID, cmd)
|
||||
}
|
||||
|
||||
// masterClusterServer returns the master node — the first ENABLED server in
|
||||
// sort order, which is where commands and spots go.
|
||||
func (a *App) masterClusterServer() (cluster.ServerConfig, error) {
|
||||
servers, err := a.listClusterServers()
|
||||
if err != nil {
|
||||
return cluster.ServerConfig{}, err
|
||||
}
|
||||
for _, s := range servers {
|
||||
if s.Enabled {
|
||||
return a.cluster.SendCommand(s.ID, cmd)
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("no enabled cluster server to send to")
|
||||
return cluster.ServerConfig{}, fmt.Errorf("no enabled cluster server to send to")
|
||||
}
|
||||
|
||||
// SendClusterSpot announces a DX spot on the **master** cluster (first
|
||||
@@ -17744,8 +17813,26 @@ func (a *App) SendClusterSpot(call string, freqKHz float64, comment string) erro
|
||||
if c := strings.TrimSpace(comment); c != "" {
|
||||
cmd += " " + c
|
||||
}
|
||||
if a.cluster == nil {
|
||||
return fmt.Errorf("cluster not initialized")
|
||||
}
|
||||
srv, err := a.masterClusterServer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applog.Printf("cluster: send spot — freqKHz=%v → command %q", freqKHz, cmd)
|
||||
return a.SendClusterCommand(cmd)
|
||||
if err := a.cluster.SendCommand(srv.ID, cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
// Show it in OUR OWN spot list straight away. Most nodes never broadcast a
|
||||
// spot back to the station that sent it, so the operator saw nothing appear
|
||||
// and reported the spot as not sent — several times. It goes through the same
|
||||
// queue as a spot off the wire, so it gets the same DXCC/POTA enrichment,
|
||||
// alert evaluation and panadapter mirroring, and the UI de-dupes it against
|
||||
// the node's echo when there is one.
|
||||
sp := cluster.NewLocalSpot(srv, a.resolveClusterLogin(srv.LoginOverride), call, freqKHz, comment)
|
||||
a.enqueueClusterEvent(clusterEvent{spot: &sp})
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClusterStatus returns a snapshot of every active session. Used by
|
||||
|
||||
Reference in New Issue
Block a user