195 lines
5.6 KiB
Go
195 lines
5.6 KiB
Go
package awardref
|
|
|
|
import (
|
|
"context"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Importer downloads and parses a program's reference list into []Ref.
|
|
type Importer struct {
|
|
AwardCode string
|
|
URL string
|
|
Fetch func(ctx context.Context, body io.Reader) ([]Ref, error)
|
|
}
|
|
|
|
// Importers is the registry of built-in reference-list updaters, keyed by
|
|
// award code. Awards not present here have no online list (manual only).
|
|
var Importers = map[string]Importer{
|
|
"POTA": {AwardCode: "POTA", URL: "https://pota.app/all_parks.csv", Fetch: parsePOTA},
|
|
"SOTA": {AwardCode: "SOTA", URL: "https://www.sotadata.org.uk/summitslist.csv", Fetch: parseSOTA},
|
|
"WWFF": {AwardCode: "WWFF", URL: "https://wwff.co/wwff-data/wwff_directory.csv", Fetch: parseWWFF},
|
|
"IOTA": {AwardCode: "IOTA", URL: "https://www.iota-world.org/islands-on-the-air/downloads/download-file.html?path=groups.json", Fetch: parseIOTA},
|
|
}
|
|
|
|
// parseIOTA reads iota-world.org's groups.json (refreshed daily): an array of
|
|
// {refno, name, dxcc_num, grp_region}. The reference is the IOTA number
|
|
// (EU-005); the DXCC number lets the per-QSO picker filter by entity.
|
|
func parseIOTA(_ context.Context, body io.Reader) ([]Ref, error) {
|
|
var groups []struct {
|
|
RefNo string `json:"refno"`
|
|
Name string `json:"name"`
|
|
DXCC string `json:"dxcc_num"`
|
|
Region string `json:"grp_region"`
|
|
}
|
|
if err := json.NewDecoder(body).Decode(&groups); err != nil {
|
|
return nil, fmt.Errorf("parse IOTA json: %w", err)
|
|
}
|
|
out := make([]Ref, 0, len(groups))
|
|
for _, g := range groups {
|
|
ref := strings.ToUpper(strings.TrimSpace(g.RefNo))
|
|
if ref == "" {
|
|
continue
|
|
}
|
|
dxcc, _ := strconv.Atoi(strings.TrimSpace(g.DXCC))
|
|
grp := strings.TrimSpace(g.Region)
|
|
if grp == "" { // fall back to the continent prefix (AF/EU/NA/…)
|
|
if i := strings.IndexByte(ref, '-'); i > 0 {
|
|
grp = ref[:i]
|
|
}
|
|
}
|
|
out = append(out, Ref{Code: ref, Name: strings.TrimSpace(g.Name), DXCC: dxcc, Group: grp})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// CanUpdate reports whether an award has an online reference list.
|
|
func CanUpdate(awardCode string) bool {
|
|
_, ok := Importers[strings.ToUpper(strings.TrimSpace(awardCode))]
|
|
return ok
|
|
}
|
|
|
|
// Download fetches and parses the reference list for an award (does not store).
|
|
func Download(ctx context.Context, awardCode string) ([]Ref, error) {
|
|
imp, ok := Importers[strings.ToUpper(strings.TrimSpace(awardCode))]
|
|
if !ok {
|
|
return nil, fmt.Errorf("no online list for award %q", awardCode)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, "GET", imp.URL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("User-Agent", "OpsLog")
|
|
client := &http.Client{Timeout: 5 * time.Minute}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("download %s: %w", imp.URL, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("download %s: http %d", imp.URL, resp.StatusCode)
|
|
}
|
|
return imp.Fetch(ctx, resp.Body)
|
|
}
|
|
|
|
// headerIndex maps lowercased header names to their column index.
|
|
func headerIndex(header []string) map[string]int {
|
|
m := make(map[string]int, len(header))
|
|
for i, h := range header {
|
|
m[strings.ToLower(strings.TrimSpace(h))] = i
|
|
}
|
|
return m
|
|
}
|
|
|
|
func get(rec []string, idx int) string {
|
|
if idx < 0 || idx >= len(rec) {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(rec[idx])
|
|
}
|
|
|
|
// parsePOTA: "reference","name","active","entityId","locationDesc"
|
|
func parsePOTA(_ context.Context, body io.Reader) ([]Ref, error) {
|
|
r := csv.NewReader(body)
|
|
r.FieldsPerRecord = -1
|
|
header, err := r.Read()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
h := headerIndex(header)
|
|
iRef, iName, iActive, iEnt, iLoc := h["reference"], h["name"], h["active"], h["entityid"], h["locationdesc"]
|
|
var out []Ref
|
|
for {
|
|
rec, err := r.Read()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if iActive >= 0 && get(rec, iActive) == "0" {
|
|
continue
|
|
}
|
|
dxcc, _ := strconv.Atoi(get(rec, iEnt))
|
|
out = append(out, Ref{Code: get(rec, iRef), Name: get(rec, iName), DXCC: dxcc, Group: get(rec, iLoc)})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// parseSOTA: first line is a title, then header
|
|
// SummitCode,AssociationName,RegionName,SummitName,…
|
|
func parseSOTA(_ context.Context, body io.Reader) ([]Ref, error) {
|
|
r := csv.NewReader(body)
|
|
r.FieldsPerRecord = -1
|
|
// First record is the "SOTA Summits List (Date=…)" title line — skip it.
|
|
if _, err := r.Read(); err != nil {
|
|
return nil, err
|
|
}
|
|
header, err := r.Read()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
h := headerIndex(header)
|
|
iRef, iName, iAssoc, iRegion := h["summitcode"], h["summitname"], h["associationname"], h["regionname"]
|
|
var out []Ref
|
|
for {
|
|
rec, err := r.Read()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out = append(out, Ref{Code: get(rec, iRef), Name: get(rec, iName), Group: get(rec, iAssoc), SubGrp: get(rec, iRegion)})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// parseWWFF: reference,status,name,…,dxccEnum,… (header-driven)
|
|
func parseWWFF(_ context.Context, body io.Reader) ([]Ref, error) {
|
|
r := csv.NewReader(body)
|
|
r.FieldsPerRecord = -1
|
|
header, err := r.Read()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
h := headerIndex(header)
|
|
iRef, iName, iStatus, iCountry := h["reference"], h["name"], h["status"], h["country"]
|
|
iDXCC := h["dxccenum"]
|
|
if iDXCC < 0 {
|
|
iDXCC = h["dxcc"]
|
|
}
|
|
var out []Ref
|
|
for {
|
|
rec, err := r.Read()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if iStatus >= 0 && !strings.EqualFold(get(rec, iStatus), "active") {
|
|
continue
|
|
}
|
|
dxcc, _ := strconv.Atoi(get(rec, iDXCC))
|
|
out = append(out, Ref{Code: get(rec, iRef), Name: get(rec, iName), DXCC: dxcc, Group: get(rec, iCountry)})
|
|
}
|
|
return out, nil
|
|
}
|