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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user