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
+198
View File
@@ -0,0 +1,198 @@
package extsvc
// Withdrawing a QSO from the external services after it is deleted locally.
//
// The two services could hardly be less alike, and the difference decides what
// is possible for QSOs already in the log:
//
// - QRZ.com deletes ONLY by logid — the identifier it returns on INSERT.
// There is no delete-by-callsign-and-date. A QSO uploaded before OpsLog
// started recording that identifier therefore cannot be withdrawn from
// here; the operator has to do it on the website. QRZ's own documentation
// puts it plainly: "There is no undo from this operation."
// - Club Log deletes by MATCH: the worked callsign, the exact timestamp and
// the band. Nothing has to have been stored in advance, so this works for
// every QSO in the log, past or future.
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// clublogDeleteURL is Club Log's real-time single-QSO delete endpoint.
const clublogDeleteURL = "https://clublog.org/delete.php"
// DeleteQRZ removes QSOs from a QRZ logbook by their logid.
//
// The identifiers come from the LOGID QRZ returns on INSERT, which OpsLog
// stores on the QSO as APP_OPSLOG_QRZ_LOGID.
//
// QRZ answers RESULT=OK when every id was deleted, PARTIAL when some were not
// found, FAIL when none were. PARTIAL and FAIL are NOT treated as errors here:
// the QSO is being deleted locally either way, and "it was already gone from
// QRZ" is the outcome the operator wanted. The message is returned so the
// caller can log what actually happened.
func DeleteQRZ(ctx context.Context, client *http.Client, apiKey string, logIDs []string) (string, error) {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return "", fmt.Errorf("qrz: api key not set")
}
ids := make([]string, 0, len(logIDs))
for _, id := range logIDs {
if id = strings.TrimSpace(id); id != "" {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return "", fmt.Errorf("qrz: no logid recorded for this QSO — it can only be removed on qrz.com")
}
form := url.Values{}
form.Set("KEY", apiKey)
form.Set("ACTION", "DELETE")
form.Set("LOGIDS", strings.Join(ids, ","))
body, err := postForm(ctx, client, qrzAPIURL, form, 20*time.Second)
if err != nil {
return "", fmt.Errorf("qrz: %w", err)
}
LogSink("qrz: DELETE raw response: %s", strings.TrimSpace(body))
vals, _ := url.ParseQuery(strings.TrimSpace(body))
result := strings.ToUpper(strings.TrimSpace(vals.Get("RESULT")))
count := strings.TrimSpace(vals.Get("COUNT"))
switch result {
case "OK", "PARTIAL", "FAIL":
return fmt.Sprintf("RESULT=%s COUNT=%s", result, count), nil
case "AUTH":
return "", fmt.Errorf("qrz: the logbook key was refused")
}
return "", fmt.Errorf("qrz: unexpected reply %q", strings.TrimSpace(body))
}
// DeleteClublog removes one QSO from a Club Log logbook.
//
// whenUTC must be the QSO's start time; Club Log matches on it EXACTLY, to the
// second, so a rounded or local-time value simply finds nothing. band is an
// ADIF band name ("20m"), translated below to the band number Club Log wants.
func DeleteClublog(ctx context.Context, client *http.Client, cfg ServiceConfig, dxCall string, whenUTC time.Time, band string) (string, error) {
email := strings.TrimSpace(cfg.Email)
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
dx := strings.ToUpper(strings.TrimSpace(dxCall))
switch {
case email == "":
return "", fmt.Errorf("clublog: account email not set")
case cfg.Password == "":
return "", fmt.Errorf("clublog: password not set")
case call == "":
return "", fmt.Errorf("clublog: logbook callsign not set")
case dx == "":
return "", fmt.Errorf("clublog: no callsign to delete")
case whenUTC.IsZero():
return "", fmt.Errorf("clublog: the QSO has no time — Club Log matches on it exactly")
}
bandID, ok := clublogBandID(band)
if !ok {
return "", fmt.Errorf("clublog: band %q is not one Club Log accepts for deletion", band)
}
form := url.Values{}
form.Set("email", email)
form.Set("password", cfg.Password)
form.Set("callsign", call)
form.Set("dxcall", dx)
form.Set("datetime", whenUTC.UTC().Format("2006-01-02 15:04:05"))
form.Set("bandid", strconv.Itoa(bandID))
api := strings.TrimSpace(cfg.APIKey)
if api == "" {
api = clublogAppAPIKey
}
form.Set("api", api)
body, err := postForm(ctx, client, clublogDeleteURL, form, 20*time.Second)
if err != nil {
return "", fmt.Errorf("clublog: %w", err)
}
msg := strings.TrimSpace(body)
LogSink("clublog: DELETE raw response: %s", msg)
// "QSO Not Deleted" means no record matched. That is not an error worth
// stopping for — the QSO is going away locally regardless, and a QSO absent
// from Club Log is the state being asked for.
return msg, nil
}
// clublogBandID maps an ADIF band name to Club Log's band number.
//
// The list is Club Log's own, and it is deliberately NOT derived from the
// frequency: Club Log accepts these values and no others, so a band outside it
// must be reported rather than approximated to a neighbour.
func clublogBandID(band string) (int, bool) {
switch strings.ToLower(strings.TrimSpace(band)) {
case "160m":
return 160, true
case "80m":
return 80, true
case "60m":
return 60, true
case "40m":
return 40, true
case "30m":
return 30, true
case "20m":
return 20, true
case "17m":
return 17, true
case "15m":
return 15, true
case "12m":
return 12, true
case "10m":
return 10, true
case "6m":
return 6, true
case "4m":
return 4, true
case "2m":
return 2, true
case "70cm":
return 70, true
case "23cm":
return 23, true
case "13cm":
return 13, true
}
return 0, false
}
// postForm is the shared request/read for both deletes.
func postForm(ctx context.Context, client *http.Client, endpoint string, form url.Values, timeout time.Duration) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", clublogUserAgent)
if client == nil {
client = &http.Client{Timeout: timeout}
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
b := string(raw)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(b))
}
return b, nil
}