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 }