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 }