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:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package extsvc
|
||||
|
||||
import "testing"
|
||||
|
||||
// Club Log matches a deletion on callsign, exact time and BAND NUMBER. A band
|
||||
// that maps to the wrong number does not fail — it points the delete at a
|
||||
// different QSO, or at nothing, silently. So the table is pinned.
|
||||
func TestClublogBandID(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
band string
|
||||
want int
|
||||
}{
|
||||
{"160m", 160}, {"80m", 80}, {"60m", 60}, {"40m", 40}, {"30m", 30},
|
||||
{"20m", 20}, {"17m", 17}, {"15m", 15}, {"12m", 12}, {"10m", 10},
|
||||
{"6m", 6}, {"4m", 4}, {"2m", 2},
|
||||
{"70cm", 70}, {"23cm", 23}, {"13cm", 13},
|
||||
{"20M", 20}, {" 20m ", 20}, // ADIF case and stray spaces
|
||||
} {
|
||||
got, ok := clublogBandID(c.band)
|
||||
if !ok || got != c.want {
|
||||
t.Errorf("clublogBandID(%q) = %d,%v — want %d", c.band, got, ok, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Bands Club Log does not accept must be REFUSED, never approximated to a
|
||||
// neighbour: 6 cm silently becoming 13 cm would delete somebody else's QSO.
|
||||
for _, bad := range []string{"", "6cm", "3cm", "2200m", "630m", "9cm", "banana"} {
|
||||
if id, ok := clublogBandID(bad); ok {
|
||||
t.Errorf("clublogBandID(%q) accepted as %d — it is not a Club Log band", bad, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,13 @@ type ExternalServices struct {
|
||||
HRDLog ServiceConfig `json:"hrdlog"`
|
||||
EQSL ServiceConfig `json:"eqsl"`
|
||||
Cloudlog ServiceConfig `json:"cloudlog"`
|
||||
|
||||
// DeleteRemote asks OpsLog to withdraw a QSO from QRZ.com and Club Log when
|
||||
// it is deleted locally. Off unless the operator turns it on: neither
|
||||
// service can undo it, and a local delete is not always meant to reach the
|
||||
// world — a duplicate cleared from a contest log was never meant to be a
|
||||
// statement about the DX's log.
|
||||
DeleteRemote bool `json:"delete_remote"`
|
||||
}
|
||||
|
||||
// UploadResult is the outcome of a single upload attempt.
|
||||
|
||||
Reference in New Issue
Block a user