feat: withdraw a deleted QSO from QRZ.com and Club Log

Opt-in, off by default: neither service can undo it, and a local delete is not
always a statement about the world — a duplicate cleared out of a contest log
was never meant to change the DX's log.

The two APIs are not alike, and the difference decides what is possible:

  - Club Log deletes by MATCH (callsign, exact timestamp, band number), so it
    works for every QSO in the log, past or future. The band table is its own
    list and is pinned by a test: a band mapped to the wrong number does not
    fail, it points the delete at a different QSO.
  - QRZ.com deletes ONLY by logid. So markExtUploaded now stores the LOGID it
    already received on every upload — it was being logged and thrown away. A
    QSO uploaded before this cannot be withdrawn from here at all, and the log
    says so by name rather than leaving the operator to assume it worked.

Both run BEFORE the local delete, because both need the QSO's own fields to
find their copy and there is nothing left to ask with once the row is gone.
Neither can block it: the operator asked for the QSO to go, and a refusing
website is not a reason to keep it.
This commit is contained in:
2026-07-30 21:57:40 +02:00
parent 83a8708d60
commit d42fdab85c
8 changed files with 343 additions and 8 deletions
+84 -2
View File
@@ -272,7 +272,10 @@ const (
// External services (logbook upload). QRZ.com first; Clublog / LoTW
// will add their own keys under the same extsvc.* prefix.
keyExtQRZAPIKey = "extsvc.qrz.api_key"
keyExtQRZAPIKey = "extsvc.qrz.api_key"
// Opt-in, and off by default: withdrawing a QSO from QRZ or Club Log cannot
// be undone, and a local delete is not always meant to reach the world.
keyExtDeleteRemote = "extsvc.delete_remote" // also delete from QRZ/Club Log when a QSO is deleted
keyExtQRZForceCall = "extsvc.qrz.force_station_callsign"
keyExtQRZAutoUpload = "extsvc.qrz.auto_upload"
keyExtQRZUploadMode = "extsvc.qrz.upload_mode"
@@ -5513,15 +5516,77 @@ func (a *App) DeleteQSO(id int64) error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
a.deleteRemoteCopies([]int64{id})
return a.qso.Delete(a.ctx, id)
}
// deleteRemoteCopies withdraws QSOs from QRZ.com and Club Log, when the
// operator has asked for that.
//
// Called BEFORE the local delete, because both services need the QSO's own
// fields to find their copy — Club Log matches on callsign, exact time and
// band, and QRZ needs the logid stored on the record. Once the row is gone
// there is nothing left to ask with.
//
// Failures never block the local delete. The operator asked for this QSO to go;
// a refusing website is not a reason to keep it, and the log says what happened
// so it can be sorted out on the site afterwards.
func (a *App) deleteRemoteCopies(ids []int64) {
if a.qso == nil || len(ids) == 0 {
return
}
prefix := ""
if a.profileHasGroup(markerExtsvc) {
prefix = a.profileScope()
}
m, err := a.getManyScoped(prefix, keyExtDeleteRemote)
if err != nil || m[keyExtDeleteRemote] != "1" {
return
}
cfg := a.loadExternalServices()
doQRZ := strings.TrimSpace(cfg.QRZ.APIKey) != ""
doClublog := strings.TrimSpace(cfg.Clublog.Email) != "" && cfg.Clublog.Password != ""
if !doQRZ && !doClublog {
return
}
for _, id := range ids {
q, err := a.qso.GetByID(a.ctx, id)
if err != nil || q.Callsign == "" {
continue
}
if doQRZ {
logID := ""
if q.Extras != nil {
logID = q.Extras["APP_OPSLOG_QRZ_LOGID"]
}
if strings.TrimSpace(logID) == "" {
// Uploaded before OpsLog kept the identifier, or never uploaded.
// QRZ has no delete-by-callsign-and-date, so say so plainly rather
// than let the operator believe it was withdrawn.
applog.Printf("extsvc: QSO %d (%s) has no QRZ logid — remove it on qrz.com by hand", id, q.Callsign)
} else if msg, err := extsvc.DeleteQRZ(a.ctx, nil, cfg.QRZ.APIKey, []string{logID}); err != nil {
applog.Printf("extsvc: QRZ delete of QSO %d (%s) failed: %v", id, q.Callsign, err)
} else {
applog.Printf("extsvc: QRZ delete of QSO %d (%s): %s", id, q.Callsign, msg)
}
}
if doClublog {
if msg, err := extsvc.DeleteClublog(a.ctx, nil, cfg.Clublog, q.Callsign, q.QSODate, q.Band); err != nil {
applog.Printf("extsvc: Club Log delete of QSO %d (%s) failed: %v", id, q.Callsign, err)
} else {
applog.Printf("extsvc: Club Log delete of QSO %d (%s): %s", id, q.Callsign, msg)
}
}
}
}
// DeleteQSOs removes several QSOs at once (multi-row selection). Returns the
// number actually deleted.
func (a *App) DeleteQSOs(ids []int64) (int64, error) {
if a.qso == nil {
return 0, fmt.Errorf("db not initialized")
}
a.deleteRemoteCopies(ids)
return a.qso.DeleteMany(a.ctx, ids)
}
@@ -9088,10 +9153,11 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode,
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode,
keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID,
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode)
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote)
if err != nil {
return out
}
out.DeleteRemote = m[keyExtDeleteRemote] == "1"
out.QRZ = extsvc.ServiceConfig{
APIKey: m[keyExtQRZAPIKey],
ForceStationCallsign: m[keyExtQRZForceCall],
@@ -9233,8 +9299,13 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
if cfg.Cloudlog.AutoUpload {
clgAuto = "1"
}
delRemote := "0"
if cfg.DeleteRemote {
delRemote = "1"
}
scope := a.profileScope() // write under the active profile's prefix
for k, v := range map[string]string{
keyExtDeleteRemote: delRemote,
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
keyExtQRZForceCall: strings.ToUpper(strings.TrimSpace(cfg.QRZ.ForceStationCallsign)),
keyExtQRZAutoUpload: auto,
@@ -10713,6 +10784,17 @@ func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
if a.qso == nil {
return
}
// Keep the identifier the service handed back. QRZ deletes ONLY by logid —
// its API has no delete-by-callsign-and-date — so a QSO uploaded without
// recording this can never be withdrawn from QRZ afterwards except by hand.
// It costs one small write per upload and it is the only chance to get it.
if strings.TrimSpace(logID) != "" && id != 0 {
key := "APP_OPSLOG_" + strings.ToUpper(string(svc)) + "_LOGID"
if err := a.qso.SetExtra(ctx, id, key, strings.TrimSpace(logID)); err != nil {
applog.Printf("extsvc: store %s logid for QSO %d: %v", svc, id, err)
}
}
var err error
switch svc {
case extsvc.ServiceQRZ: