award
This commit is contained in:
@@ -22,6 +22,7 @@ import (
|
||||
"hamlog/internal/cat"
|
||||
"hamlog/internal/clublog"
|
||||
"hamlog/internal/award"
|
||||
"hamlog/internal/awardref"
|
||||
"hamlog/internal/cluster"
|
||||
"hamlog/internal/pota"
|
||||
"hamlog/internal/db"
|
||||
@@ -91,7 +92,9 @@ const (
|
||||
keyAudioFromGain = "audio.from_gain" // From Radio (RX) mix level, percent
|
||||
keyAudioMicGain = "audio.mic_gain" // mic mix level, percent
|
||||
|
||||
keyAwardDefs = "awards.defs" // JSON array of award definitions (editable)
|
||||
keyAwardDefs = "awards.defs" // JSON array of award definitions (editable)
|
||||
keyAwardRefsUpdated = "awards.refs.updated." // + CODE → last list-update timestamp
|
||||
keyAwardRefsSeeded = "awards.refs.seeded" // "1" once built-in lists were seeded
|
||||
|
||||
keyClublogCtyEnabled = "clublog.cty_exceptions" // "1" → apply ClubLog exceptions
|
||||
|
||||
@@ -329,6 +332,7 @@ type App struct {
|
||||
dxcc *dxcc.Manager
|
||||
cluster *cluster.Manager
|
||||
pota *pota.Cache
|
||||
awardRefs *awardref.Repo
|
||||
operating *operating.Repo
|
||||
udp *udp.Manager
|
||||
udpRepo *udp.Repo
|
||||
@@ -515,6 +519,8 @@ func (a *App) startup(ctx context.Context) {
|
||||
a.qso = qso.NewRepo(conn)
|
||||
a.settings = settings.NewStore(conn)
|
||||
a.profiles = profile.NewRepo(conn)
|
||||
a.awardRefs = awardref.NewRepo(conn)
|
||||
a.seedBuiltinReferences() // first-run: populate built-in award reference lists
|
||||
a.operating = operating.NewRepo(conn)
|
||||
a.udpRepo = udp.NewRepo(conn)
|
||||
a.udp = udp.NewManager(a.udpRepo)
|
||||
@@ -1114,6 +1120,12 @@ func (a *App) DXCCForCountry(name string) int {
|
||||
return dxcc.EntityDXCC(name)
|
||||
}
|
||||
|
||||
// DXCCName returns a display name for a DXCC entity number (or "" if unknown).
|
||||
// Used by the award editor to label the DXCC-filter chips.
|
||||
func (a *App) DXCCName(n int) string {
|
||||
return dxcc.NameForDXCC(n)
|
||||
}
|
||||
|
||||
// ComputeStationInfo resolves a station's structured metadata from the
|
||||
// callsign (via cty.dat) and grid (via Maidenhead → lat/lon). The
|
||||
// frontend calls this whenever Callsign or Grid changes in the Station
|
||||
@@ -1367,7 +1379,357 @@ func (a *App) GetAwards() ([]award.Result, error) {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return award.Compute(a.awardDefs(), all, nameOf), nil
|
||||
defs := a.awardDefs()
|
||||
refMetas := a.awardRefMetas(defs)
|
||||
results := award.Compute(defs, all, refMetas, nameOf)
|
||||
// Dynamic awards (POTA/SOTA/…) aren't fully loaded into the engine — their
|
||||
// list can be huge. Enrich them after the fact: real Total from the stored
|
||||
// count, and reference names for the worked references only.
|
||||
if a.awardRefs != nil {
|
||||
counts, _ := a.awardRefs.Counts(a.ctx)
|
||||
for i := range results {
|
||||
r := &results[i]
|
||||
if _, predef := refMetas[strings.ToUpper(r.Code)]; predef {
|
||||
continue // predefined awards are already complete (totals + names)
|
||||
}
|
||||
if total := counts[strings.ToUpper(r.Code)]; total > 0 {
|
||||
r.Total = total
|
||||
}
|
||||
codes := make([]string, 0, len(r.Refs))
|
||||
for _, rf := range r.Refs {
|
||||
if rf.Name == "" {
|
||||
codes = append(codes, rf.Ref)
|
||||
}
|
||||
}
|
||||
if len(codes) == 0 {
|
||||
continue
|
||||
}
|
||||
if names, err := a.awardRefs.NamesFor(a.ctx, r.Code, codes); err == nil {
|
||||
for j := range r.Refs {
|
||||
if r.Refs[j].Name == "" {
|
||||
r.Refs[j].Name = names[strings.ToUpper(r.Refs[j].Ref)]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// awardRefMetas loads the reference lists of PREDEFINED awards (Dynamic=false)
|
||||
// into the engine view. Dynamic awards (POTA/SOTA/…) are skipped — their lists
|
||||
// are large and not needed for matching; their names are filled afterwards.
|
||||
func (a *App) awardRefMetas(defs []award.Def) map[string][]award.RefMeta {
|
||||
out := map[string][]award.RefMeta{}
|
||||
if a.awardRefs == nil {
|
||||
return out
|
||||
}
|
||||
for _, d := range defs {
|
||||
if d.Dynamic {
|
||||
continue
|
||||
}
|
||||
code := strings.ToUpper(d.Code)
|
||||
refs, err := a.awardRefs.List(a.ctx, code)
|
||||
if err != nil || len(refs) == 0 {
|
||||
continue
|
||||
}
|
||||
metas := make([]award.RefMeta, 0, len(refs))
|
||||
for _, rf := range refs {
|
||||
dxccList := rf.DXCCList
|
||||
if len(dxccList) == 0 && rf.DXCC > 0 {
|
||||
dxccList = []int{rf.DXCC}
|
||||
}
|
||||
metas = append(metas, award.RefMeta{
|
||||
Code: rf.Code, Name: rf.Name, Group: rf.Group, SubGrp: rf.SubGrp,
|
||||
DXCCList: dxccList, Pattern: rf.Pattern, Valid: rf.Valid,
|
||||
})
|
||||
}
|
||||
out[code] = metas
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// QSOAwardRef is one award reference a single QSO contributes to. Pickable
|
||||
// marks awards backed by a reference list (POTA, SOTA, …) — those are assigned
|
||||
// manually; the rest (DXCC, WAZ, WPX, DDFM, …) are computed from QSO fields.
|
||||
type QSOAwardRef struct {
|
||||
Code string `json:"code"`
|
||||
Ref string `json:"ref"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Pickable bool `json:"pickable"`
|
||||
}
|
||||
|
||||
// ComputeQSOAwardRefs returns every award reference a single QSO contributes to
|
||||
// — manual (POTA/SOTA/IOTA/WWFF) and computed (DXCC/WAZ/WAC/WPX/DDFM/…) — for
|
||||
// the per-QSO Award Refs editor. Reuses the same engine as GetAwards.
|
||||
func (a *App) ComputeQSOAwardRefs(q qso.QSO) ([]QSOAwardRef, error) {
|
||||
nameOf := func(field, ref string) string {
|
||||
switch field {
|
||||
case "dxcc":
|
||||
if n, err := strconv.Atoi(ref); err == nil {
|
||||
return dxcc.NameForDXCC(n)
|
||||
}
|
||||
case "cont":
|
||||
return continentName(ref)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
defs := a.awardDefs()
|
||||
results := award.Compute(defs, []qso.QSO{q}, a.awardRefMetas(defs), nameOf)
|
||||
var counts map[string]int
|
||||
if a.awardRefs != nil {
|
||||
counts, _ = a.awardRefs.Counts(a.ctx)
|
||||
}
|
||||
var out []QSOAwardRef
|
||||
for i := range results {
|
||||
r := &results[i]
|
||||
pickable := counts[strings.ToUpper(r.Code)] > 0 || awardref.CanUpdate(r.Code)
|
||||
for _, rf := range r.Refs {
|
||||
if !rf.Worked {
|
||||
continue // a single QSO only contributes worked references
|
||||
}
|
||||
out = append(out, QSOAwardRef{Code: r.Code, Ref: rf.Ref, Name: rf.Name, Pickable: pickable})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AwardRefMeta describes a reference list's state for the UI.
|
||||
type AwardRefMeta struct {
|
||||
Code string `json:"code"`
|
||||
Count int `json:"count"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
CanUpdate bool `json:"can_update"`
|
||||
}
|
||||
|
||||
// GetAwardReferenceMeta returns the reference-list status for every defined
|
||||
// award (count + last update + whether an online updater exists).
|
||||
func (a *App) GetAwardReferenceMeta() ([]AwardRefMeta, error) {
|
||||
if a.awardRefs == nil {
|
||||
return nil, fmt.Errorf("db not initialized")
|
||||
}
|
||||
counts, err := a.awardRefs.Counts(a.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []AwardRefMeta
|
||||
for _, d := range a.awardDefs() {
|
||||
code := strings.ToUpper(d.Code)
|
||||
updated := ""
|
||||
if a.settings != nil {
|
||||
updated, _ = a.settings.Get(a.ctx, keyAwardRefsUpdated+code)
|
||||
}
|
||||
out = append(out, AwardRefMeta{
|
||||
Code: d.Code,
|
||||
Count: counts[code],
|
||||
UpdatedAt: updated,
|
||||
CanUpdate: awardref.CanUpdate(d.Code),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateAwardReferenceList downloads the latest reference list for an award and
|
||||
// replaces the stored set. Returns the new reference count.
|
||||
func (a *App) UpdateAwardReferenceList(code string) (AwardRefMeta, error) {
|
||||
if a.awardRefs == nil {
|
||||
return AwardRefMeta{}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
if !awardref.CanUpdate(code) {
|
||||
return AwardRefMeta{}, fmt.Errorf("no online reference list for %q", code)
|
||||
}
|
||||
refs, err := awardref.Download(a.ctx, code)
|
||||
if err != nil {
|
||||
return AwardRefMeta{}, err
|
||||
}
|
||||
n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs)
|
||||
if err != nil {
|
||||
return AwardRefMeta{}, err
|
||||
}
|
||||
now := time.Now().Format("2006-01-02 15:04")
|
||||
if a.settings != nil {
|
||||
_ = a.settings.Set(a.ctx, keyAwardRefsUpdated+strings.ToUpper(code), now)
|
||||
}
|
||||
applog.Printf("award-refs: %s updated — %d references", strings.ToUpper(code), n)
|
||||
return AwardRefMeta{Code: strings.ToUpper(code), Count: n, UpdatedAt: now, CanUpdate: true}, nil
|
||||
}
|
||||
|
||||
// SearchAwardReferences finds references of an award by code/name (for the
|
||||
// per-QSO reference picker). dxcc>0 restricts to one entity.
|
||||
func (a *App) SearchAwardReferences(code, query string, dxcc, limit int) ([]awardref.Ref, error) {
|
||||
if a.awardRefs == nil {
|
||||
return nil, fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.awardRefs.Search(a.ctx, code, query, dxcc, limit)
|
||||
}
|
||||
|
||||
// ListAwardReferences returns every reference of an award (for the editor).
|
||||
func (a *App) ListAwardReferences(code string) ([]awardref.Ref, error) {
|
||||
if a.awardRefs == nil {
|
||||
return nil, fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.awardRefs.List(a.ctx, code)
|
||||
}
|
||||
|
||||
// SaveAwardReference inserts or updates a single reference.
|
||||
func (a *App) SaveAwardReference(code string, ref awardref.Ref) error {
|
||||
if a.awardRefs == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.awardRefs.Upsert(a.ctx, code, ref)
|
||||
}
|
||||
|
||||
// DeleteAwardReference removes one reference from an award.
|
||||
func (a *App) DeleteAwardReference(code, refCode string) error {
|
||||
if a.awardRefs == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.awardRefs.Delete(a.ctx, code, refCode)
|
||||
}
|
||||
|
||||
// ReplaceAwardReferences atomically replaces an award's whole reference list
|
||||
// (used by paste / CSV import and presets). Returns the new count.
|
||||
func (a *App) ReplaceAwardReferences(code string, refs []awardref.Ref) (int, error) {
|
||||
if a.awardRefs == nil {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
}
|
||||
n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if a.settings != nil {
|
||||
_ = a.settings.Set(a.ctx, keyAwardRefsUpdated+strings.ToUpper(code), time.Now().Format("2006-01-02 15:04"))
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// GetAwardPresets returns the catalogue of built-in reference lists.
|
||||
func (a *App) GetAwardPresets() []awardref.Preset { return awardref.Presets() }
|
||||
|
||||
// ApplyAwardPreset replaces an award's reference list with a built-in preset.
|
||||
// Returns the new reference count.
|
||||
func (a *App) ApplyAwardPreset(code, presetKey string) (int, error) {
|
||||
p, ok := awardref.PresetByKey(presetKey)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown preset %q", presetKey)
|
||||
}
|
||||
return a.ReplaceAwardReferences(code, p.Refs)
|
||||
}
|
||||
|
||||
// PopulateBuiltinReferences seeds an award's reference list from the built-in
|
||||
// data (DXCC entities, CQ zones, continents, US states, French departments).
|
||||
// Returns the new count; ok=false awards (online / custom) yield an error.
|
||||
func (a *App) PopulateBuiltinReferences(code string) (int, error) {
|
||||
refs, ok := awardref.BuiltinRefs(strings.ToUpper(strings.TrimSpace(code)))
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("no built-in reference list for %q", code)
|
||||
}
|
||||
return a.ReplaceAwardReferences(code, refs)
|
||||
}
|
||||
|
||||
// HasBuiltinReferences reports whether an award code ships a built-in list.
|
||||
func (a *App) HasBuiltinReferences(code string) bool {
|
||||
_, ok := awardref.BuiltinRefs(strings.ToUpper(strings.TrimSpace(code)))
|
||||
return ok
|
||||
}
|
||||
|
||||
// seedBuiltinReferences populates the reference lists of built-in awards on
|
||||
// first run (idempotent: only seeds an award that currently has none, and only
|
||||
// once overall, tracked by a settings flag so a user who clears a list is not
|
||||
// overruled on the next launch).
|
||||
func (a *App) seedBuiltinReferences() {
|
||||
if a.awardRefs == nil || a.settings == nil {
|
||||
return
|
||||
}
|
||||
if done, _ := a.settings.Get(a.ctx, keyAwardRefsSeeded); done == "1" {
|
||||
return
|
||||
}
|
||||
counts, err := a.awardRefs.Counts(a.ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, d := range a.awardDefs() {
|
||||
code := strings.ToUpper(d.Code)
|
||||
if counts[code] > 0 {
|
||||
continue
|
||||
}
|
||||
if refs, ok := awardref.BuiltinRefs(code); ok {
|
||||
if n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs); err == nil {
|
||||
applog.Printf("award-refs: seeded %s — %d references", code, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = a.settings.Set(a.ctx, keyAwardRefsSeeded, "1")
|
||||
}
|
||||
|
||||
// ImportAwardReferencesText parses pasted lines or CSV into references and
|
||||
// replaces the award's list. Accepted per line (comma/semicolon/tab separated):
|
||||
//
|
||||
// CODE
|
||||
// CODE,Description
|
||||
// CODE,Description,Group
|
||||
// CODE,Description,Group,Subgroup
|
||||
// CODE,Description,Group,Subgroup,DXCC
|
||||
//
|
||||
// A leading header row (first field "code"/"ref"/"reference") is skipped.
|
||||
func (a *App) ImportAwardReferencesText(code, text string) (int, error) {
|
||||
refs := parseRefLines(text)
|
||||
if len(refs) == 0 {
|
||||
return 0, fmt.Errorf("no references found in input")
|
||||
}
|
||||
return a.ReplaceAwardReferences(code, refs)
|
||||
}
|
||||
|
||||
// parseRefLines turns pasted/CSV text into references (best-effort, tolerant of
|
||||
// comma, semicolon or tab delimiters).
|
||||
func parseRefLines(text string) []awardref.Ref {
|
||||
var out []awardref.Ref
|
||||
for i, raw := range strings.Split(text, "\n") {
|
||||
line := strings.TrimSpace(strings.TrimRight(raw, "\r"))
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var fields []string
|
||||
switch {
|
||||
case strings.Contains(line, "\t"):
|
||||
fields = strings.Split(line, "\t")
|
||||
case strings.Contains(line, ";"):
|
||||
fields = strings.Split(line, ";")
|
||||
default:
|
||||
fields = strings.Split(line, ",")
|
||||
}
|
||||
for j := range fields {
|
||||
fields[j] = strings.TrimSpace(fields[j])
|
||||
}
|
||||
code := strings.ToUpper(fields[0])
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
// Skip a header row.
|
||||
if i == 0 {
|
||||
switch strings.ToLower(fields[0]) {
|
||||
case "code", "ref", "reference", "ref_code":
|
||||
continue
|
||||
}
|
||||
}
|
||||
ref := awardref.Ref{Code: code, Valid: true}
|
||||
if len(fields) > 1 {
|
||||
ref.Name = fields[1]
|
||||
}
|
||||
if len(fields) > 2 {
|
||||
ref.Group = fields[2]
|
||||
}
|
||||
if len(fields) > 3 {
|
||||
ref.SubGrp = fields[3]
|
||||
}
|
||||
if len(fields) > 4 {
|
||||
if n, err := strconv.Atoi(fields[4]); err == nil {
|
||||
ref.DXCC = n
|
||||
}
|
||||
}
|
||||
out = append(out, ref)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func continentName(code string) string {
|
||||
|
||||
@@ -27,8 +27,10 @@ import {
|
||||
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
|
||||
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
||||
QSOAudioBegin, QSOAudioCancel,
|
||||
GetAwardDefs,
|
||||
} from '../wailsjs/go/main/App';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||
import { EventsOn } from '../wailsjs/runtime/runtime';
|
||||
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
||||
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
@@ -94,6 +96,7 @@ const emptyDetails: DetailsState = {
|
||||
sat_name: '', sat_mode: '',
|
||||
contest_id: '', srx: undefined, stx: undefined,
|
||||
email: '',
|
||||
award_refs: '',
|
||||
};
|
||||
|
||||
function fmtDateUTC(s: any): string {
|
||||
@@ -658,6 +661,19 @@ export default function App() {
|
||||
});
|
||||
myCallRef.current = (station.callsign || '').toUpperCase();
|
||||
|
||||
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
|
||||
// picked award references to the QSO field/extras each award actually reads.
|
||||
const awardFieldRef = useRef<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
GetAwardDefs()
|
||||
.then((defs) => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const d of (defs ?? []) as any[]) m[String(d.code).toUpperCase()] = String(d.field || '').toLowerCase();
|
||||
awardFieldRef.current = m;
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// === Clock ===
|
||||
const [utcNow, setUtcNow] = useState('');
|
||||
useEffect(() => {
|
||||
@@ -1077,6 +1093,7 @@ export default function App() {
|
||||
srx: details.srx, stx: details.stx,
|
||||
email: details.email,
|
||||
};
|
||||
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
|
||||
await AddQSO(payload);
|
||||
resetEntry();
|
||||
await refresh();
|
||||
@@ -1096,6 +1113,7 @@ export default function App() {
|
||||
if (!locks.start) setQsoStartedAt(null);
|
||||
if (!locks.end) setQsoEndedAt(null);
|
||||
resetAutoFill();
|
||||
setWb(null); // clear the Worked-before grid for the just-cleared callsign
|
||||
setLookupError('');
|
||||
rstUserEditedRef.current = false;
|
||||
applyModePreset(mode);
|
||||
@@ -1106,6 +1124,7 @@ export default function App() {
|
||||
qsl_msg: '', qsl_via: '',
|
||||
contest_id: '', srx: undefined, stx: undefined,
|
||||
email: '',
|
||||
award_refs: '',
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,134 +1,338 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Trash2, RotateCcw, Save } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, Trash2, RotateCcw, Save, Download, Loader2, Search } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields } from '../../wailsjs/go/main/App';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields,
|
||||
GetAwardReferenceMeta, UpdateAwardReferenceList,
|
||||
ListAwardReferences, SaveAwardReference, DeleteAwardReference,
|
||||
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
||||
ListCountries, DXCCForCountry, DXCCName,
|
||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
type RefMeta = { code: string; count: number; updated_at: string; can_update: boolean };
|
||||
|
||||
export type AwardDef = {
|
||||
code: string; name: string; field: string; pattern: string;
|
||||
dxcc_filter: number[] | null; confirm: string[] | null; total: number; builtin?: boolean;
|
||||
code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
|
||||
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
||||
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
|
||||
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[];
|
||||
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
||||
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
||||
total: number; builtin?: boolean;
|
||||
};
|
||||
|
||||
const CONFIRM_SRC = [{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' }];
|
||||
type AwardRef = {
|
||||
code: string; name: string; dxcc: number; group: string; subgrp: string;
|
||||
dxcc_list?: number[]; pattern?: string; valid: boolean; valid_from?: string; valid_to?: string;
|
||||
score?: number; bonus?: number; gridsquare?: string; alias?: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
type Preset = { key: string; name: string; field: string; dxcc: number; refs: AwardRef[] };
|
||||
|
||||
const AWARD_TYPES = ['QSOFIELDS', 'REFERENCE', 'DXCC', 'GRID'];
|
||||
const CONFIRM_SRC = [
|
||||
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
|
||||
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
|
||||
];
|
||||
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','23cm','13cm'];
|
||||
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
|
||||
const EMISSIONS = ['CW', 'PHONE', 'DIGITAL'];
|
||||
|
||||
const emptyAward = (): AwardDef => ({
|
||||
code: 'NEW', name: 'New award', description: '', valid: true,
|
||||
type: 'QSOFIELDS', field: 'note', match_by: 'code', exact_match: true, pattern: '',
|
||||
dxcc_filter: null, confirm: ['lotw', 'qsl'], validate: ['lotw', 'qsl'], total: 0,
|
||||
});
|
||||
|
||||
interface Props { open: boolean; onClose: () => void; onSaved: () => void; }
|
||||
|
||||
// Small reusable multi-toggle chip group.
|
||||
function Chips({ all, value, onToggle }: { all: string[]; value: string[]; onToggle: (v: string) => void }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{all.map((v) => {
|
||||
const on = value.includes(v);
|
||||
return (
|
||||
<button key={v} type="button" onClick={() => onToggle(v)}
|
||||
className={cn('px-2 py-0.5 rounded text-[11px] border', on
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background border-border text-muted-foreground hover:bg-accent')}>
|
||||
{v}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field2({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[120px_1fr] items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// DxccFilter — pick the entities an award is scoped to by country name (like the
|
||||
// QSO editor), resolving each to its ADIF DXCC number. Stored as number[].
|
||||
function DxccFilter({ value, onChange, countries }: { value: number[]; onChange: (v: number[]) => void; countries: string[] }) {
|
||||
const [names, setNames] = useState<Record<number, string>>({});
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
(async () => {
|
||||
const miss = value.filter((n) => names[n] === undefined);
|
||||
if (miss.length === 0) return;
|
||||
const got: Record<number, string> = {};
|
||||
for (const n of miss) { try { got[n] = await DXCCName(n); } catch { got[n] = ''; } }
|
||||
if (live) setNames((m) => ({ ...m, ...got }));
|
||||
})();
|
||||
return () => { live = false; };
|
||||
}, [value]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function addCountry(name: string) {
|
||||
const n = await DXCCForCountry(name);
|
||||
if (n && n > 0 && !value.includes(n)) {
|
||||
setNames((m) => ({ ...m, [n]: name }));
|
||||
onChange([...value, n]);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Combobox value="" options={countries} placeholder="Add country…" onChange={addCountry} className="h-8 w-full" />
|
||||
{value.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{value.map((n) => (
|
||||
<span key={n} className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] bg-accent border border-border">
|
||||
<span className="font-mono">#{n}</span>
|
||||
<span className="text-muted-foreground">{names[n] || '…'}</span>
|
||||
<button className="hover:text-destructive" onClick={() => onChange(value.filter((x) => x !== n))}>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
const [defs, setDefs] = useState<AwardDef[]>([]);
|
||||
const [fields, setFields] = useState<string[]>([]);
|
||||
const [meta, setMeta] = useState<Record<string, RefMeta>>({});
|
||||
const [presets, setPresets] = useState<Preset[]>([]);
|
||||
const [countries, setCountries] = useState<string[]>([]);
|
||||
const [sel, setSel] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const loadMeta = () => GetAwardReferenceMeta()
|
||||
.then((m) => setMeta(Object.fromEntries(((m ?? []) as RefMeta[]).map((x) => [x.code.toUpperCase(), x]))))
|
||||
.catch(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setErr('');
|
||||
Promise.all([GetAwardDefs(), AwardFields()])
|
||||
.then(([d, f]) => { setDefs((d ?? []) as any); setFields((f ?? []) as any); })
|
||||
Promise.all([GetAwardDefs(), AwardFields(), GetAwardPresets(), ListCountries()])
|
||||
.then(([d, f, p, c]) => { setDefs((d ?? []) as any); setFields((f ?? []) as any); setPresets((p ?? []) as any); setCountries((c ?? []) as any); })
|
||||
.catch((e) => setErr(String(e?.message ?? e)));
|
||||
loadMeta();
|
||||
}, [open]);
|
||||
|
||||
const patch = (i: number, p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === i ? { ...d, ...p } : d)));
|
||||
const addAward = () => setDefs((ds) => [...ds, { code: 'NEW', name: 'New award', field: 'dxcc', pattern: '', dxcc_filter: null, confirm: ['lotw', 'qsl'], total: 0 }]);
|
||||
const removeAward = (i: number) => setDefs((ds) => ds.filter((_, j) => j !== i));
|
||||
|
||||
const toggleConfirm = (i: number, id: string) => {
|
||||
const cur = defs[i].confirm ?? [];
|
||||
patch(i, { confirm: cur.includes(id) ? cur.filter((c) => c !== id) : [...cur, id] });
|
||||
const cur = defs[sel];
|
||||
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
|
||||
const toggleIn = (key: keyof AwardDef, v: string) => {
|
||||
const arr = ((cur?.[key] as string[]) ?? []);
|
||||
patch({ [key]: arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v] } as any);
|
||||
};
|
||||
|
||||
function addAward() {
|
||||
setDefs((ds) => [...ds, emptyAward()]);
|
||||
setSel(defs.length);
|
||||
}
|
||||
function removeAward(i: number) {
|
||||
setDefs((ds) => ds.filter((_, j) => j !== i));
|
||||
setSel((s) => Math.max(0, s >= i ? s - 1 : s));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setErr('');
|
||||
try {
|
||||
// Normalise codes (uppercase, no blanks).
|
||||
const clean = defs
|
||||
.filter((d) => d.code.trim())
|
||||
.map((d) => ({ ...d, code: d.code.trim().toUpperCase(), confirm: d.confirm ?? [] }));
|
||||
const clean = defs.filter((d) => d.code.trim())
|
||||
.map((d) => ({ ...d, code: d.code.trim().toUpperCase(), confirm: d.confirm ?? [], validate: d.validate ?? [] }));
|
||||
await SaveAwardDefs(clean as any);
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
try { setDefs((await ResetAwardDefs()) as any); } catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
try { setDefs((await ResetAwardDefs()) as any); setSel(0); } catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function updateList(code: string) {
|
||||
setUpdating(code); setErr('');
|
||||
try { await UpdateAwardReferenceList(code); await loadMeta(); }
|
||||
catch (e: any) { setErr(`${code}: ${String(e?.message ?? e)}`); }
|
||||
finally { setUpdating(null); }
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toUpperCase();
|
||||
return defs.map((d, i) => ({ d, i })).filter(({ d }) => !q || d.code.toUpperCase().includes(q) || d.name.toUpperCase().includes(q));
|
||||
}, [defs, search]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader className="px-6 py-4">
|
||||
<DialogTitle>Edit awards</DialogTitle>
|
||||
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogHeader className="px-5 py-3 border-b">
|
||||
<DialogTitle>Award management</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-2">
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Each award scans one QSO <strong>field</strong>. Leave <strong>pattern</strong> empty to use the whole field value,
|
||||
or enter a regular expression where <span className="font-mono">group 1</span> is the reference — e.g. scan
|
||||
the <span className="font-mono">note</span> field with <span className="font-mono">{'D(\\d{1,2}[AB]?)'}</span> so
|
||||
"D74" counts department 74.
|
||||
</p>
|
||||
{err && <div className="text-xs text-destructive mb-2">{err}</div>}
|
||||
|
||||
<div className="space-y-2 max-h-[55vh] overflow-auto pr-1">
|
||||
{defs.map((d, i) => (
|
||||
<div key={i} className="rounded-lg border border-border p-3 space-y-2 bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="h-8 w-24 font-mono font-semibold text-xs" value={d.code}
|
||||
onChange={(e) => patch(i, { code: e.target.value })} placeholder="CODE" />
|
||||
<Input className="h-8 flex-1 text-sm" value={d.name}
|
||||
onChange={(e) => patch(i, { name: e.target.value })} placeholder="Award name" />
|
||||
{d.builtin && <span className="text-[10px] text-muted-foreground border border-border rounded px-1.5 py-0.5">built-in</span>}
|
||||
<button className="text-muted-foreground hover:text-destructive" title="Remove" onClick={() => removeAward(i)}>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[auto_1fr_auto_1fr] gap-x-3 gap-y-2 items-center text-xs">
|
||||
<label className="text-muted-foreground">Field</label>
|
||||
<Select value={d.field} onValueChange={(v) => patch(i, { field: v })}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label className="text-muted-foreground">Pattern</label>
|
||||
<Input className="h-8 font-mono text-xs" value={d.pattern}
|
||||
onChange={(e) => patch(i, { pattern: e.target.value })} placeholder="(optional regex, group 1 = ref)" />
|
||||
|
||||
<label className="text-muted-foreground">DXCC filter</label>
|
||||
<Input className="h-8 font-mono text-xs"
|
||||
value={(d.dxcc_filter ?? []).join(', ')}
|
||||
onChange={(e) => patch(i, { dxcc_filter: e.target.value.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => Number.isFinite(n)) })}
|
||||
placeholder="e.g. 227 (empty = any)" />
|
||||
<label className="text-muted-foreground">Total</label>
|
||||
<Input type="number" className="h-8 font-mono text-xs w-28" value={d.total}
|
||||
onChange={(e) => patch(i, { total: parseInt(e.target.value, 10) || 0 })} placeholder="0 = unknown" />
|
||||
|
||||
<label className="text-muted-foreground">Confirmed by</label>
|
||||
<div className="col-span-3 flex items-center gap-4">
|
||||
{CONFIRM_SRC.map((c) => (
|
||||
<label key={c.id} className="flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox checked={(d.confirm ?? []).includes(c.id)} onCheckedChange={() => toggleConfirm(i, c.id)} />
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[220px_1fr] min-h-0 overflow-hidden">
|
||||
{/* Left: award list */}
|
||||
<div className="border-r flex flex-col min-h-0">
|
||||
<div className="p-2 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input className="h-7 pl-7 text-xs" placeholder="Search awards…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{filtered.map(({ d, i }) => (
|
||||
<button key={i} onClick={() => setSel(i)}
|
||||
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
|
||||
i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-emerald-500')} />
|
||||
<span className="font-mono font-semibold shrink-0">{d.code}</span>
|
||||
<span className="text-muted-foreground truncate">{d.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="m-2 h-7 justify-start" onClick={addAward}>
|
||||
<Plus className="size-3.5 mr-1" /> New award
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" className="h-8 mt-2" onClick={addAward}>
|
||||
<Plus className="size-3.5 mr-1" /> Add award
|
||||
</Button>
|
||||
{/* Right: tabbed editor for selected award */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||
{err && <div className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5">{err}</div>}
|
||||
{!cur ? (
|
||||
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">Select or create an award.</div>
|
||||
) : (
|
||||
<Tabs defaultValue="info" className="flex flex-col min-h-0 overflow-hidden">
|
||||
<TabsList className="px-3 justify-start">
|
||||
<TabsTrigger value="info">Award info</TabsTrigger>
|
||||
<TabsTrigger value="type">Award type</TabsTrigger>
|
||||
<TabsTrigger value="conf">Confirmation</TabsTrigger>
|
||||
<TabsTrigger value="refs">References</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{/* ── Award info ── */}
|
||||
<TabsContent value="info" className="mt-0 space-y-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
|
||||
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder="Award name" />
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> Valid</label>
|
||||
<button className="text-muted-foreground hover:text-destructive" title="Delete award" onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
|
||||
</div>
|
||||
<Field2 label="Description"><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
|
||||
<Field2 label="Award URL"><Input className="h-8" value={cur.url ?? ''} onChange={(e) => patch({ url: e.target.value })} /></Field2>
|
||||
<Field2 label="Reference URL"><Input className="h-8" value={cur.ref_url ?? ''} onChange={(e) => patch({ ref_url: e.target.value })} placeholder="https://…/<REF>" /></Field2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field2 label="Valid from"><Input type="date" className="h-8" value={cur.valid_from ?? ''} onChange={(e) => patch({ valid_from: e.target.value })} /></Field2>
|
||||
<Field2 label="Valid to"><Input type="date" className="h-8" value={cur.valid_to ?? ''} onChange={(e) => patch({ valid_to: e.target.value })} /></Field2>
|
||||
</div>
|
||||
<div className="grid grid-cols-[120px_1fr] gap-2">
|
||||
<Label className="text-xs text-muted-foreground pt-1.5">DXCC filter</Label>
|
||||
<DxccFilter value={cur.dxcc_filter ?? []} onChange={(v) => patch({ dxcc_filter: v })} countries={countries} />
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-xs text-muted-foreground">Valid bands (empty = all)</Label><Chips all={BANDS} value={cur.valid_bands ?? []} onToggle={(v) => toggleIn('valid_bands', v)} /></div>
|
||||
<div className="space-y-1"><Label className="text-xs text-muted-foreground">Emission (empty = all)</Label><Chips all={EMISSIONS} value={cur.emission ?? []} onToggle={(v) => toggleIn('emission', v)} /></div>
|
||||
<div className="space-y-1"><Label className="text-xs text-muted-foreground">Valid modes (empty = all)</Label><Chips all={MODES} value={cur.valid_modes ?? []} onToggle={(v) => toggleIn('valid_modes', v)} /></div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Award type ── */}
|
||||
<TabsContent value="type" className="mt-0 space-y-2.5">
|
||||
<Field2 label="Award type">
|
||||
<Select value={cur.type || 'QSOFIELDS'} onValueChange={(v) => patch({ type: v })}>
|
||||
<SelectTrigger className="h-8 text-xs w-48"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{AWARD_TYPES.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field2>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.multi} onCheckedChange={(c) => patch({ multi: !!c })} /> Allow multiple references on a single QSO</label>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> Dynamic references (not predefined — any value counts, like POTA)</label>
|
||||
|
||||
<div className="border-t pt-2.5 mt-1 space-y-2.5">
|
||||
<p className="text-[11px] text-muted-foreground">QSO parameters (used by QSOFIELDS / REFERENCE types)</p>
|
||||
<Field2 label="Search in field">
|
||||
<Select value={cur.field} onValueChange={(v) => patch({ field: v })}>
|
||||
<SelectTrigger className="h-8 text-xs w-56"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field2>
|
||||
<Field2 label="Match by">
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{['code', 'description', 'pattern'].map((m) => (
|
||||
<label key={m} className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="radio" name="matchby" checked={(cur.match_by || 'code') === m} onChange={() => patch({ match_by: m })} className="accent-primary" /> {m}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Field2>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]"><Checkbox checked={!!cur.exact_match} onCheckedChange={(c) => patch({ exact_match: !!c })} /> Exact match (else search reference inside the field)</label>
|
||||
<Field2 label="Pattern (regex)"><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder="group 1 = reference (for match-by pattern / dynamic)" /></Field2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field2 label="Leading string"><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
||||
<Field2 label="Trailing string"><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Confirmation ── */}
|
||||
<TabsContent value="conf" className="mt-0 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">Confirmation (worked → confirmed)</Label>
|
||||
{CONFIRM_SRC.map((c) => (
|
||||
<label key={c.id} className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={(cur.confirm ?? []).includes(c.id)} onCheckedChange={() => toggleIn('confirm', c.id)} /> {c.label}</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">Validation (confirmed → validated)</Label>
|
||||
{CONFIRM_SRC.map((c) => (
|
||||
<label key={c.id} className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={(cur.validate ?? []).includes(c.id)} onCheckedChange={() => toggleIn('validate', c.id)} /> {c.label}</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Field2 label="Grant codes"><Input className="h-8" value={cur.grant_codes ?? ''} onChange={(e) => patch({ grant_codes: e.target.value })} /></Field2>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.export_credit_granted} onCheckedChange={(c) => patch({ export_credit_granted: !!c })} /> Export award in ADIF credit_granted field</label>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── References ── */}
|
||||
<TabsContent value="refs" className="mt-0">
|
||||
<ReferencesPanel
|
||||
code={cur.code.trim().toUpperCase()} presets={presets} meta={meta[cur.code.toUpperCase()]}
|
||||
onUpdateOnline={() => updateList(cur.code.toUpperCase())} updating={updating === cur.code.toUpperCase()}
|
||||
onChanged={loadMeta} setErr={setErr}
|
||||
/>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-6 py-4 bg-transparent border-t-0">
|
||||
<DialogFooter className="px-5 py-3 border-t !flex-row">
|
||||
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> Reset to defaults</Button>
|
||||
<div className="flex-1" />
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
@@ -138,3 +342,145 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ReferencesPanel — manage the reference list of one award: search/list on the
|
||||
// left, a per-reference editor on the right, plus bulk paste/CSV, presets and
|
||||
// the online updater (POTA/SOTA/WWFF).
|
||||
function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChanged, setErr }: {
|
||||
code: string; presets: Preset[]; meta?: RefMeta;
|
||||
onUpdateOnline: () => void; updating: boolean; onChanged: () => void; setErr: (s: string) => void;
|
||||
}) {
|
||||
const [refs, setRefs] = useState<AwardRef[]>([]);
|
||||
const [q, setQ] = useState('');
|
||||
const [selCode, setSelCode] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [bulk, setBulk] = useState('');
|
||||
const [showBulk, setShowBulk] = useState(false);
|
||||
const [hasBuiltin, setHasBuiltin] = useState(false);
|
||||
|
||||
const load = () => {
|
||||
if (!code) return;
|
||||
setBusy(true);
|
||||
ListAwardReferences(code).then((r) => setRefs((r ?? []) as any)).catch(() => {}).finally(() => setBusy(false));
|
||||
};
|
||||
useEffect(load, [code]);
|
||||
useEffect(() => { HasBuiltinReferences(code).then(setHasBuiltin).catch(() => setHasBuiltin(false)); }, [code]);
|
||||
|
||||
async function populateBuiltin() {
|
||||
try { const n = await PopulateBuiltinReferences(code); load(); onChanged(); setErr(`Populated ${n} built-in references.`); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
const sel = refs.find((r) => r.code === selCode) || null;
|
||||
const filtered = useMemo(() => {
|
||||
const s = q.trim().toUpperCase();
|
||||
return refs.filter((r) => !s || r.code.toUpperCase().includes(s) || (r.name ?? '').toUpperCase().includes(s));
|
||||
}, [refs, q]);
|
||||
|
||||
const patchSel = (p: Partial<AwardRef>) => setRefs((rs) => rs.map((r) => (r.code === selCode ? { ...r, ...p } : r)));
|
||||
|
||||
async function saveRef(r: AwardRef) {
|
||||
try { await SaveAwardReference(code, r as any); load(); onChanged(); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function addRef() {
|
||||
const c = prompt('New reference code:')?.trim().toUpperCase();
|
||||
if (!c) return;
|
||||
const r: AwardRef = { code: c, name: '', dxcc: 0, group: '', subgrp: '', valid: true };
|
||||
await saveRef(r); setSelCode(c);
|
||||
}
|
||||
async function delRef(c: string) {
|
||||
try { await DeleteAwardReference(code, c); setSelCode(null); load(); onChanged(); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function applyPreset(key: string) {
|
||||
if (!key) return;
|
||||
try { await ApplyAwardPreset(code, key); load(); onChanged(); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function importBulk() {
|
||||
try { const n = await ImportAwardReferencesText(code, bulk); setBulk(''); setShowBulk(false); load(); onChanged(); setErr(`Imported ${n} references.`); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">Reference count: <span className="font-mono text-foreground">{refs.length}</span></span>
|
||||
<div className="flex-1" />
|
||||
<Select value="" onValueChange={applyPreset}>
|
||||
<SelectTrigger className="h-7 w-44 text-xs"><SelectValue placeholder="Apply preset…" /></SelectTrigger>
|
||||
<SelectContent>{presets.map((p) => <SelectItem key={p.key} value={p.key}>{p.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={() => setShowBulk((s) => !s)}>Paste / CSV</Button>
|
||||
{hasBuiltin && (
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={populateBuiltin} title="Replace with the shipped built-in list (DXCC entities, French departments, …)">
|
||||
Populate built-in
|
||||
</Button>
|
||||
)}
|
||||
{meta?.can_update && (
|
||||
<Button variant="outline" size="sm" className="h-7" disabled={updating} onClick={onUpdateOnline}>
|
||||
{updating ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <Download className="size-3.5 mr-1" />} Update online
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={addRef}><Plus className="size-3.5 mr-1" /> Add</Button>
|
||||
</div>
|
||||
|
||||
{showBulk && (
|
||||
<div className="space-y-1.5 border rounded p-2 bg-muted/30">
|
||||
<p className="text-[11px] text-muted-foreground">One reference per line: <span className="font-mono">CODE,Description,Group,Subgroup,DXCC</span> (comma/semicolon/tab). Replaces the whole list.</p>
|
||||
<Textarea rows={6} className="font-mono text-xs" value={bulk} onChange={(e) => setBulk(e.target.value)} placeholder={'ON,Ontario,,,1\nQC,Quebec,,,1'} />
|
||||
<div className="flex justify-end gap-2"><Button variant="ghost" size="sm" className="h-7" onClick={() => setShowBulk(false)}>Cancel</Button><Button size="sm" className="h-7" onClick={importBulk}>Import</Button></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-[200px_1fr] gap-3">
|
||||
{/* List */}
|
||||
<div className="border rounded flex flex-col min-h-0 max-h-[46vh]">
|
||||
<div className="p-1.5 border-b"><Input className="h-7 text-xs" placeholder="Search…" value={q} onChange={(e) => setQ(e.target.value)} /></div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{busy && <div className="px-2 py-1.5 text-[11px] text-muted-foreground flex items-center gap-1.5"><Loader2 className="size-3 animate-spin" /> Loading…</div>}
|
||||
{!busy && filtered.length === 0 && <div className="px-2 py-1.5 text-[11px] text-muted-foreground">No references.</div>}
|
||||
{filtered.map((r) => (
|
||||
<button key={r.code} onClick={() => setSelCode(r.code)}
|
||||
className={cn('flex w-full items-baseline gap-2 px-2 py-1 text-left text-xs border-b border-border/30', r.code === selCode ? 'bg-accent' : 'hover:bg-accent/50', !r.valid && 'opacity-50')}>
|
||||
<span className="font-mono font-semibold shrink-0">{r.code}</span>
|
||||
<span className="text-muted-foreground truncate">{r.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-reference editor */}
|
||||
<div className="border rounded p-3">
|
||||
{!sel ? (
|
||||
<div className="grid place-items-center h-full text-xs text-muted-foreground">Select a reference, or Add / import a list.</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="h-8 w-28 font-mono font-semibold" value={sel.code} readOnly />
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={sel.valid} onCheckedChange={(c) => patchSel({ valid: !!c })} /> Valid</label>
|
||||
<div className="flex-1" />
|
||||
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
|
||||
</div>
|
||||
<Field2 label="Description"><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field2 label="Group"><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
|
||||
<Field2 label="Subgroup"><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
|
||||
</div>
|
||||
<Field2 label="DXCC"><Input type="number" className="h-8 w-32 font-mono" value={sel.dxcc || ''} onChange={(e) => patchSel({ dxcc: parseInt(e.target.value, 10) || 0 })} /></Field2>
|
||||
<Field2 label="Pattern (regex)"><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder="optional per-reference regex" /></Field2>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Field2 label="Score"><Input type="number" className="h-8 font-mono" value={sel.score ?? 0} onChange={(e) => patchSel({ score: parseInt(e.target.value, 10) || 0 })} /></Field2>
|
||||
<Field2 label="Bonus"><Input type="number" className="h-8 font-mono" value={sel.bonus ?? 0} onChange={(e) => patchSel({ bonus: parseInt(e.target.value, 10) || 0 })} /></Field2>
|
||||
<Field2 label="Grid"><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
|
||||
</div>
|
||||
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> Save reference</Button></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { X, Loader2 } from 'lucide-react';
|
||||
import { SearchAwardReferences } from '../../wailsjs/go/main/App';
|
||||
|
||||
type Ref = { code: string; name: string; dxcc: number; group: string; subgrp: string };
|
||||
|
||||
interface Props {
|
||||
code: string; // award code, e.g. "POTA"
|
||||
label: string; // display label
|
||||
dxcc?: number; // contacted-station DXCC; used when "this country" is on
|
||||
countryOnly: boolean;
|
||||
value: string; // currently assigned reference
|
||||
onChange: (ref: string) => void;
|
||||
}
|
||||
|
||||
// AwardRefPicker — type-ahead search over an award's reference list (POTA parks,
|
||||
// SOTA summits, …), optionally restricted to the contacted DXCC. Picking a
|
||||
// result assigns it to the QSO.
|
||||
export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange }: Props) {
|
||||
const [q, setQ] = useState('');
|
||||
const [results, setResults] = useState<Ref[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const t = window.setTimeout(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await SearchAwardReferences(code, q, countryOnly ? (dxcc ?? 0) : 0, 30);
|
||||
setResults((r ?? []) as any);
|
||||
} catch { setResults([]); }
|
||||
finally { setBusy(false); }
|
||||
}, 200);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [q, open, code, dxcc, countryOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); };
|
||||
window.addEventListener('mousedown', close);
|
||||
return () => window.removeEventListener('mousedown', close);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[60px_1fr] items-center gap-2">
|
||||
<label className="text-xs font-semibold text-muted-foreground">{label}</label>
|
||||
<div className="relative" ref={boxRef}>
|
||||
{value ? (
|
||||
<div className="flex items-center gap-1.5 h-7 px-2 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 text-xs">
|
||||
<span className="font-mono font-semibold">{value}</span>
|
||||
<button className="ml-auto hover:text-emerald-950" onClick={() => onChange('')} title="Remove"><X className="size-3.5" /></button>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
className="h-7 w-full rounded-md border border-input bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder={`Search ${label}…`}
|
||||
value={q}
|
||||
onFocus={() => setOpen(true)}
|
||||
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
|
||||
/>
|
||||
)}
|
||||
{open && !value && (
|
||||
<div className="absolute z-50 mt-1 w-[320px] max-h-64 overflow-auto rounded-md border border-border bg-popover shadow-lg text-xs">
|
||||
{busy && <div className="px-3 py-2 text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> Searching…</div>}
|
||||
{!busy && results.length === 0 && (
|
||||
<div className="px-3 py-2 text-muted-foreground">No match{countryOnly && dxcc ? ' for this DXCC' : ''}.</div>
|
||||
)}
|
||||
{results.map((r) => (
|
||||
<button
|
||||
key={r.code}
|
||||
className="flex w-full items-baseline gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onChange(r.code); setOpen(false); setQ(''); }}
|
||||
>
|
||||
<span className="font-mono font-semibold shrink-0">{r.code}</span>
|
||||
<span className="text-muted-foreground truncate">{r.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { X, Plus, Loader2 } from 'lucide-react';
|
||||
import { SearchAwardReferences, GetAwardDefs, GetAwardReferenceMeta } from '../../wailsjs/go/main/App';
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
type AwardRef = { code: string; name: string; dxcc: number; group: string; subgrp: string };
|
||||
type AwardDef = { code: string; name: string; dxcc_filter?: number[] | null; dynamic?: boolean };
|
||||
type Meta = { code: string; count: number; can_update: boolean };
|
||||
|
||||
interface Props {
|
||||
dxcc?: number;
|
||||
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064"
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}
|
||||
|
||||
export function AwardRefSelector({ dxcc, value, onChange }: Props) {
|
||||
const [defs, setDefs] = useState<AwardDef[]>([]);
|
||||
const [metas, setMetas] = useState<Record<string, Meta>>({});
|
||||
const [awardCode, setAwardCode] = useState('POTA');
|
||||
|
||||
const [q, setQ] = useState('');
|
||||
const [results, setResults] = useState<AwardRef[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [selectedRef, setSelectedRef] = useState<AwardRef | null>(null);
|
||||
const [selectedEntry, setSelectedEntry] = useState<string | null>(null);
|
||||
|
||||
const entries = value ? value.split(';').filter(Boolean) : [];
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([GetAwardDefs(), GetAwardReferenceMeta()])
|
||||
.then(([d, m]) => {
|
||||
setDefs((d ?? []) as any);
|
||||
setMetas(Object.fromEntries(((m ?? []) as Meta[]).map((x) => [String(x.code).toUpperCase(), x])));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// An award is offered when its DXCC scope matches the contacted entity (or it
|
||||
// has no scope) AND it has references to pick from (a loaded list, an online
|
||||
// list, or dynamic references like POTA). This is why DDFM (scope 227) shows
|
||||
// for a French call but not for others.
|
||||
const awards = useMemo(() => {
|
||||
return defs.filter((d) => {
|
||||
const m = metas[String(d.code).toUpperCase()];
|
||||
const hasRefs = (m?.count ?? 0) > 0 || !!m?.can_update || !!d.dynamic;
|
||||
if (!hasRefs) return false;
|
||||
const scope = d.dxcc_filter ?? [];
|
||||
if (scope.length > 0 && (!dxcc || !scope.includes(dxcc))) return false;
|
||||
return true;
|
||||
}).map((d) => ({ code: d.code, name: d.name }));
|
||||
}, [defs, metas, dxcc]);
|
||||
|
||||
// Keep the selected award valid as the offered list changes with the call.
|
||||
useEffect(() => {
|
||||
if (awards.length === 0) return;
|
||||
if (!awards.find((a) => a.code === awardCode)) setAwardCode(awards[0].code);
|
||||
}, [awards, awardCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (q.length < 2) { setResults([]); return; }
|
||||
const t = window.setTimeout(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
// References are always scoped to the contacted DXCC entity.
|
||||
const r = await SearchAwardReferences(awardCode, q, dxcc ?? 0, 50);
|
||||
setResults((r ?? []) as any);
|
||||
} catch { setResults([]); }
|
||||
finally { setBusy(false); }
|
||||
}, 200);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [awardCode, q, dxcc]);
|
||||
|
||||
function addRef(ref: AwardRef) {
|
||||
const entry = `${awardCode}@${ref.code}`;
|
||||
if (!entries.includes(entry)) {
|
||||
onChange([...entries, entry].join(';'));
|
||||
}
|
||||
}
|
||||
|
||||
function removeEntry(entry: string) {
|
||||
const next = entries.filter((e) => e !== entry).join(';');
|
||||
onChange(next);
|
||||
if (selectedEntry === entry) setSelectedEntry(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 h-[210px]">
|
||||
{/* Left panel */}
|
||||
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
|
||||
|
||||
<Select
|
||||
value={awardCode}
|
||||
onValueChange={(v) => { setAwardCode(v); setSelectedRef(null); setQ(''); setResults([]); }}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{awards.map((a) => (
|
||||
<SelectItem key={a.code} value={a.code}>{a.code}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Group / Sub from selected ref */}
|
||||
<div className="grid grid-cols-[38px_1fr] items-center gap-x-1.5 gap-y-0.5 text-xs">
|
||||
<span className="text-muted-foreground text-[11px]">Group</span>
|
||||
<span className="font-mono truncate text-[11px]">{selectedRef?.group || '—'}</span>
|
||||
<span className="text-muted-foreground text-[11px]">Sub</span>
|
||||
<span className="font-mono truncate text-[11px]">{selectedRef?.subgrp || '—'}</span>
|
||||
</div>
|
||||
|
||||
{/* Selected ref chip */}
|
||||
{selectedRef ? (
|
||||
<div className="flex items-center gap-1.5 h-6 px-2 rounded border border-emerald-300 bg-emerald-50 text-emerald-800 text-xs min-w-0">
|
||||
<span className="font-mono font-semibold shrink-0">{selectedRef.code}</span>
|
||||
<span className="truncate text-[10px] text-emerald-700">{selectedRef.name}</span>
|
||||
<button className="ml-auto shrink-0 hover:text-emerald-950" onClick={() => setSelectedRef(null)}>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-6 flex items-center px-2 text-[11px] text-muted-foreground italic border border-dashed border-border rounded">
|
||||
← pick a reference
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add — references are always scoped to the contacted DXCC */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={!selectedRef}
|
||||
onClick={() => selectedRef && addRef(selectedRef)}
|
||||
className="flex items-center gap-1 h-6 px-2 text-xs rounded border border-border hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="size-3" />Add
|
||||
</button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{dxcc ? `DXCC #${dxcc}` : 'Enter a callsign first'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border shrink-0" />
|
||||
|
||||
{/* Added refs list */}
|
||||
<div className="flex-1 overflow-auto space-y-0.5 min-h-0">
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground italic py-0.5">No references added yet</p>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<div
|
||||
key={entry}
|
||||
className={`flex items-center gap-1.5 px-2 py-0.5 rounded text-xs cursor-pointer select-none ${
|
||||
selectedEntry === entry ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
}`}
|
||||
onClick={() => setSelectedEntry(selectedEntry === entry ? null : entry)}
|
||||
>
|
||||
<span className="font-mono font-semibold flex-1 truncate">{entry}</span>
|
||||
<button
|
||||
className="shrink-0 hover:text-destructive"
|
||||
onClick={(e) => { e.stopPropagation(); removeEntry(entry); }}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel: reference search */}
|
||||
<div className="w-[172px] shrink-0 flex flex-col gap-1.5 border-l pl-2 min-w-0">
|
||||
<span className="text-xs font-semibold">References</span>
|
||||
<input
|
||||
className="h-6 w-full rounded border border-input bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder="Search…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-0">
|
||||
{busy && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />Searching…
|
||||
</div>
|
||||
)}
|
||||
{!busy && q.length < 2 && (
|
||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
||||
Type 2+ chars to search
|
||||
</div>
|
||||
)}
|
||||
{!busy && q.length >= 2 && results.length === 0 && (
|
||||
<div className="px-2 py-2 text-[11px] text-muted-foreground leading-snug">
|
||||
No results.
|
||||
<br />
|
||||
<span className="text-[10px]">Download reference lists in the Awards panel → Import data.</span>
|
||||
</div>
|
||||
)}
|
||||
{results.map((r) => (
|
||||
<div
|
||||
key={r.code}
|
||||
className={`px-2 py-1 cursor-pointer border-b border-border/30 last:border-0 ${
|
||||
selectedRef?.code === r.code ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
}`}
|
||||
onClick={() => setSelectedRef(r)}
|
||||
onDoubleClick={() => { setSelectedRef(r); addRef(r); }}
|
||||
>
|
||||
<div className="font-mono font-semibold leading-tight text-[11px]">{r.code}</div>
|
||||
{r.name && (
|
||||
<div className="text-[10px] text-muted-foreground leading-tight truncate">{r.name}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,12 +8,13 @@ import { AwardEditor } from '@/components/AwardEditor';
|
||||
|
||||
type BandCount = { band: string; worked: number; confirmed: number };
|
||||
type AwardRef = {
|
||||
ref: string; name?: string; worked: boolean; confirmed: boolean;
|
||||
ref: string; name?: string; group?: string; subgrp?: string;
|
||||
worked: boolean; confirmed: boolean; validated: boolean;
|
||||
bands: string[]; confirmed_bands: string[];
|
||||
};
|
||||
type AwardResult = {
|
||||
code: string; name: string; dimension: string;
|
||||
worked: number; confirmed: number; total: number;
|
||||
worked: number; confirmed: number; validated: number; total: number;
|
||||
bands: BandCount[]; refs: AwardRef[];
|
||||
};
|
||||
|
||||
@@ -121,6 +122,7 @@ export function AwardsPanel() {
|
||||
<div className="mt-1 flex items-center gap-4 text-sm">
|
||||
<span><span className="font-bold text-foreground">{current.worked}</span> <span className="text-muted-foreground">worked</span></span>
|
||||
<span><span className="font-bold text-emerald-600">{current.confirmed}</span> <span className="text-muted-foreground">confirmed</span></span>
|
||||
<span><span className="font-bold text-sky-600">{current.validated}</span> <span className="text-muted-foreground">validated</span></span>
|
||||
{current.total > 0 && (
|
||||
<span className="text-muted-foreground">of {current.total} · {pct(current.confirmed, current.total)}% confirmed</span>
|
||||
)}
|
||||
@@ -158,17 +160,23 @@ export function AwardsPanel() {
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1 pr-2 font-medium w-24">Ref</th>
|
||||
<th className="py-1 pr-2 font-medium">Name</th>
|
||||
<th className="py-1 pr-2 font-medium w-20">Status</th>
|
||||
<th className="py-1 pr-2 font-medium w-40">Group</th>
|
||||
<th className="py-1 pr-2 font-medium w-24">Status</th>
|
||||
<th className="py-1 font-medium">Bands</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRefs.map((r) => (
|
||||
<tr key={r.ref} className="border-b border-border/30">
|
||||
<tr key={r.ref} className={cn('border-b border-border/30', !r.worked && 'opacity-50')}>
|
||||
<td className="py-1 pr-2 font-mono font-semibold">{r.ref}</td>
|
||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[260px]">{r.name}</td>
|
||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[240px]">{r.name}</td>
|
||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[150px]">{r.group}</td>
|
||||
<td className="py-1 pr-2">
|
||||
{r.confirmed ? (
|
||||
{!r.worked ? (
|
||||
<span className="text-muted-foreground/70">— missing</span>
|
||||
) : r.validated ? (
|
||||
<span className="inline-flex items-center gap-1 text-sky-600"><CheckCircle2 className="size-3" /> valid.</span>
|
||||
) : r.confirmed ? (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-600"><CheckCircle2 className="size-3" /> conf.</span>
|
||||
) : (
|
||||
<span className="text-amber-600">worked</span>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Construction } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { pathBetween } from '@/lib/maidenhead';
|
||||
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||
|
||||
export interface DetailsState {
|
||||
state: string;
|
||||
@@ -37,6 +37,10 @@ export interface DetailsState {
|
||||
srx?: number;
|
||||
stx?: number;
|
||||
email: string;
|
||||
// Award references for the contacted station (set via the Awards tab picker).
|
||||
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
|
||||
// App.tsx maps these back to pota_ref/sota_ref/iota when saving the QSO.
|
||||
award_refs?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -84,7 +88,6 @@ function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3
|
||||
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, tab, onTab, keyerActive }: Props) {
|
||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||
|
||||
// Bearing/distance from operator's home grid to the remote station.
|
||||
// Recomputed only when either grid actually changes.
|
||||
const path = useMemo(
|
||||
@@ -197,9 +200,12 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
||||
)}
|
||||
|
||||
{open === 'awards' && (
|
||||
<div className="px-4 py-6 text-center text-xs text-muted-foreground">
|
||||
<Construction className="size-6 mx-auto mb-2 text-muted-foreground/60" />
|
||||
<div className="font-semibold text-sm text-foreground/70">Awards module coming soon</div>
|
||||
<div className="px-3 py-2.5">
|
||||
<AwardRefSelector
|
||||
dxcc={details.dxcc}
|
||||
value={details.award_refs ?? ''}
|
||||
onChange={(v) => onChange({ award_refs: v })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Trash2, Search, Loader2 } from 'lucide-react';
|
||||
import { LookupCallsign, DXCCForCountry } from '../../wailsjs/go/main/App';
|
||||
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
|
||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||
import { applyAwardRefs, buildAwardRefs } from '@/lib/awardRefs';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
@@ -166,6 +168,47 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [looking, setLooking] = useState(false);
|
||||
|
||||
// === Award references (Log4OM-style tab) ===
|
||||
// Manual refs are edited as a "CODE@REF;…" string; computed refs (DXCC, WAZ,
|
||||
// WPX, …) are derived from the QSO by the backend and shown read-only.
|
||||
const awardFieldRef = useRef<Record<string, string>>({});
|
||||
const [awardRefs, setAwardRefs] = useState('');
|
||||
const [computedRefs, setComputedRefs] = useState<Array<{ code: string; ref: string; name?: string }>>([]);
|
||||
|
||||
// Load award definitions once, then seed the editable manual refs from the QSO.
|
||||
useEffect(() => {
|
||||
GetAwardDefs()
|
||||
.then(async (defs) => {
|
||||
const list = (defs ?? []) as any[];
|
||||
const fieldOf: Record<string, string> = {};
|
||||
for (const d of list) fieldOf[String(d.code).toUpperCase()] = String(d.field || '').toLowerCase();
|
||||
awardFieldRef.current = fieldOf;
|
||||
// Which awards are reference-list (manual) ones? Ask the backend, which
|
||||
// also tells us pickable vs computed for the current QSO.
|
||||
try {
|
||||
const all = (await ComputeQSOAwardRefs(draft as any)) ?? [];
|
||||
const pickableCodes = new Set(all.filter((r: any) => r.pickable).map((r: any) => String(r.code).toUpperCase()));
|
||||
const pickable = list
|
||||
.filter((d) => pickableCodes.has(String(d.code).toUpperCase()))
|
||||
.map((d) => ({ code: String(d.code), field: String(d.field || '').toLowerCase() }));
|
||||
setAwardRefs(buildAwardRefs(draft, pickable));
|
||||
} catch { /* leave manual refs empty on failure */ }
|
||||
})
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Recompute the read-only computed refs whenever a source field changes.
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(async () => {
|
||||
try {
|
||||
const all = (await ComputeQSOAwardRefs(draft as any)) ?? [];
|
||||
setComputedRefs(all.filter((r: any) => !r.pickable).map((r: any) => ({ code: r.code, ref: r.ref, name: r.name })));
|
||||
} catch { setComputedRefs([]); }
|
||||
}, 250);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [draft.dxcc, draft.cqz, draft.ituz, draft.cont, draft.state, draft.callsign, draft.notes, draft.band]);
|
||||
|
||||
function set<K extends keyof QSO>(key: K, value: QSO[K]) {
|
||||
setDraft((d) => ({ ...d, [key]: value }));
|
||||
}
|
||||
@@ -228,9 +271,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
|
||||
operator: (draft.operator ?? '').trim().toUpperCase(),
|
||||
my_grid: (draft.my_grid ?? '').trim().toUpperCase(),
|
||||
my_gridsquare_ext: (draft.my_gridsquare_ext ?? '').trim().toUpperCase(),
|
||||
iota: (draft.iota ?? '').trim().toUpperCase(),
|
||||
sota_ref: (draft.sota_ref ?? '').trim().toUpperCase(),
|
||||
pota_ref: (draft.pota_ref ?? '').trim().toUpperCase(),
|
||||
// iota / sota_ref / pota_ref are set below from the Award Refs tab.
|
||||
my_iota: (draft.my_iota ?? '').trim().toUpperCase(),
|
||||
my_sota_ref: (draft.my_sota_ref ?? '').trim().toUpperCase(),
|
||||
my_pota_ref: (draft.my_pota_ref ?? '').trim().toUpperCase(),
|
||||
@@ -253,6 +294,11 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
|
||||
tx_pwr: numOrUndef(draft.tx_pwr),
|
||||
extras: parseExtras(extrasText),
|
||||
};
|
||||
// The Award Refs tab is authoritative for the reference-list awards. Reset
|
||||
// the dedicated columns, then route the picked refs back onto the payload
|
||||
// (POTA/SOTA/IOTA → columns, WWFF/custom → extras).
|
||||
out.iota = ''; out.sota_ref = ''; out.pota_ref = '';
|
||||
applyAwardRefs(out, awardRefs, awardFieldRef.current);
|
||||
onSave(out);
|
||||
}
|
||||
|
||||
@@ -283,6 +329,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
|
||||
<TabsList className="px-3 overflow-x-auto">
|
||||
<TabsTrigger value="qsoinfo">QSO Info</TabsTrigger>
|
||||
<TabsTrigger value="contact">Contact's details</TabsTrigger>
|
||||
<TabsTrigger value="awards">Award Refs</TabsTrigger>
|
||||
<TabsTrigger value="qsl">QSL Info</TabsTrigger>
|
||||
<TabsTrigger value="contest">Contest</TabsTrigger>
|
||||
<TabsTrigger value="sat">Sat / Prop</TabsTrigger>
|
||||
@@ -411,6 +458,35 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="awards" className="mt-0">
|
||||
<div className="grid grid-cols-[1fr_240px] gap-5">
|
||||
{/* Left: pick reference-list awards (POTA/SOTA/IOTA/WWFF/…) */}
|
||||
<div>
|
||||
<AwardRefSelector dxcc={draft.dxcc} value={awardRefs} onChange={setAwardRefs} />
|
||||
</div>
|
||||
|
||||
{/* Right: computed awards (read-only) derived from this QSO */}
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
<span className="text-xs font-semibold">Computed (automatic)</span>
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
Derived from this QSO's fields (DXCC, zones, prefix, notes…). Not editable here.
|
||||
</p>
|
||||
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-[160px] max-h-[210px]">
|
||||
{computedRefs.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">None yet.</div>
|
||||
) : (
|
||||
computedRefs.map((r) => (
|
||||
<div key={`${r.code}@${r.ref}`} className="px-2 py-1 border-b border-border/30 last:border-0">
|
||||
<span className="font-mono font-semibold">{r.code}@{r.ref}</span>
|
||||
{r.name && <span className="text-[10px] text-muted-foreground ml-1.5 truncate">{r.name}</span>}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="qsl" className="mt-0">
|
||||
{(() => {
|
||||
const def = CONFIRMATIONS.find((c) => c.key === confSel) ?? CONFIRMATIONS[0];
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Shared helpers for per-QSO award references.
|
||||
//
|
||||
// In the UI a QSO's manually-assigned award references are edited as a single
|
||||
// semicolon-delimited string of "CODE@REF" entries, e.g.
|
||||
// "POTA@FR-11553;IOTA@EU-064"
|
||||
// On save each entry is routed to the QSO field its award actually reads from
|
||||
// (see internal/award/award.go): POTA/SOTA/IOTA have dedicated columns; WWFF
|
||||
// and custom awards live in uppercase ADIF extras keys.
|
||||
|
||||
// parseAwardRefs turns "POTA@FR-11553;IOTA@EU-064" into
|
||||
// { POTA: "FR-11553", IOTA: "EU-064" }. Repeated codes join with commas.
|
||||
export function parseAwardRefs(v: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const entry of (v ?? '').split(';').filter(Boolean)) {
|
||||
const at = entry.indexOf('@');
|
||||
if (at <= 0) continue;
|
||||
const code = entry.slice(0, at).toUpperCase();
|
||||
const ref = entry.slice(at + 1).trim().toUpperCase();
|
||||
if (!ref) continue;
|
||||
out[code] = out[code] ? `${out[code]},${ref}` : ref;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// appendTokens adds space-separated tokens (a "A,B" ref string) to a text field,
|
||||
// skipping any already present, so re-picking is idempotent.
|
||||
function appendTokens(existing: string | undefined, refs: string): string {
|
||||
let out = (existing ?? '').trim();
|
||||
for (const tok of refs.split(',').map((s) => s.trim()).filter(Boolean)) {
|
||||
const re = new RegExp(`(^|\\s)${tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$)`, 'i');
|
||||
if (!re.test(out)) out = out ? `${out} ${tok}` : tok;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// applyAwardRefs writes picked references onto a QSO payload using each award's
|
||||
// scanned field. fieldOf maps an award CODE (uppercase) to its field name.
|
||||
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>) {
|
||||
const byCode = parseAwardRefs(awardRefs);
|
||||
const extras: Record<string, string> = { ...(payload.extras ?? {}) };
|
||||
for (const [code, ref] of Object.entries(byCode)) {
|
||||
const field = fieldOf[code] || code.toLowerCase();
|
||||
switch (field) {
|
||||
case 'iota': payload.iota = ref; break;
|
||||
case 'sota_ref': payload.sota_ref = ref; break;
|
||||
case 'pota_ref': payload.pota_ref = ref; break;
|
||||
case 'wwff':
|
||||
extras['WWFF_REF'] = ref;
|
||||
extras['SIG'] = 'WWFF';
|
||||
extras['SIG_INFO'] = ref;
|
||||
break;
|
||||
// QSOFIELDS awards read their reference from a free-text field (e.g. DDFM
|
||||
// scans the note for "D06"). Picking such a reference appends its code(s)
|
||||
// to that field so the matcher finds it.
|
||||
case 'note': case 'notes':
|
||||
payload.notes = appendTokens(payload.notes, ref);
|
||||
break;
|
||||
case 'comment':
|
||||
payload.comment = appendTokens(payload.comment, ref);
|
||||
break;
|
||||
default:
|
||||
extras[field.toUpperCase()] = ref;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (Object.keys(extras).length > 0) payload.extras = extras;
|
||||
}
|
||||
|
||||
// awardRefValue reads a single award's stored reference from a QSO, inverse of
|
||||
// applyAwardRefs. Used to seed the editor when opening an existing QSO.
|
||||
export function awardRefValue(qso: any, code: string, field: string): string {
|
||||
switch (field) {
|
||||
case 'iota': return (qso.iota ?? '').toUpperCase();
|
||||
case 'sota_ref': return (qso.sota_ref ?? '').toUpperCase();
|
||||
case 'pota_ref': return (qso.pota_ref ?? '').toUpperCase();
|
||||
case 'wwff': {
|
||||
const ex = qso.extras ?? {};
|
||||
if (ex['WWFF_REF']) return String(ex['WWFF_REF']).toUpperCase();
|
||||
if (String(ex['SIG'] ?? '').toUpperCase() === 'WWFF') return String(ex['SIG_INFO'] ?? '').toUpperCase();
|
||||
return '';
|
||||
}
|
||||
default: {
|
||||
const ex = qso.extras ?? {};
|
||||
return String(ex[field.toUpperCase()] ?? '').toUpperCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// buildAwardRefs reconstructs the "CODE@REF;…" editor string from a QSO for the
|
||||
// given pickable awards (code → field). Only awards with a stored value appear.
|
||||
export function buildAwardRefs(qso: any, pickable: Array<{ code: string; field: string }>): string {
|
||||
const out: string[] = [];
|
||||
for (const { code, field } of pickable) {
|
||||
const v = awardRefValue(qso, code, field);
|
||||
if (v) out.push(`${code.toUpperCase()}@${v}`);
|
||||
}
|
||||
return out.join(';');
|
||||
}
|
||||
Vendored
+29
@@ -5,6 +5,7 @@ import {main} from '../models';
|
||||
import {profile} from '../models';
|
||||
import {adif} from '../models';
|
||||
import {award} from '../models';
|
||||
import {awardref} from '../models';
|
||||
import {cat} from '../models';
|
||||
import {cluster} from '../models';
|
||||
import {extsvc} from '../models';
|
||||
@@ -18,12 +19,16 @@ export function ActivateProfile(arg1:number):Promise<void>;
|
||||
|
||||
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
||||
|
||||
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
||||
|
||||
export function AwardFields():Promise<Array<string>>;
|
||||
|
||||
export function ClearLookupCache():Promise<void>;
|
||||
|
||||
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
|
||||
|
||||
export function ComputeQSOAwardRefs(arg1:qso.QSO):Promise<Array<main.QSOAwardRef>>;
|
||||
|
||||
export function ComputeStationInfo(arg1:string,arg2:string):Promise<main.StationInfoComputed>;
|
||||
|
||||
export function ConnectAllClusters():Promise<void>;
|
||||
@@ -50,8 +55,12 @@ export function DVKStopRecord():Promise<void>;
|
||||
|
||||
export function DXCCForCountry(arg1:string):Promise<number>;
|
||||
|
||||
export function DXCCName(arg1:number):Promise<string>;
|
||||
|
||||
export function DeleteAllQSO():Promise<number>;
|
||||
|
||||
export function DeleteAwardReference(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function DeleteClusterServer(arg1:number):Promise<void>;
|
||||
|
||||
export function DeleteOperatingAntenna(arg1:number):Promise<void>;
|
||||
@@ -90,6 +99,10 @@ export function GetAudioSettings():Promise<main.AudioSettings>;
|
||||
|
||||
export function GetAwardDefs():Promise<Array<award.Def>>;
|
||||
|
||||
export function GetAwardPresets():Promise<Array<awardref.Preset>>;
|
||||
|
||||
export function GetAwardReferenceMeta():Promise<Array<main.AwardRefMeta>>;
|
||||
|
||||
export function GetAwards():Promise<Array<award.Result>>;
|
||||
|
||||
export function GetBackupSettings():Promise<main.BackupSettings>;
|
||||
@@ -140,12 +153,18 @@ export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
||||
|
||||
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||
|
||||
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
||||
|
||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean):Promise<adif.ImportResult>;
|
||||
|
||||
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
||||
|
||||
export function ListAudioInputDevices():Promise<Array<audio.Device>>;
|
||||
|
||||
export function ListAudioOutputDevices():Promise<Array<audio.Device>>;
|
||||
|
||||
export function ListAwardReferences(arg1:string):Promise<Array<awardref.Ref>>;
|
||||
|
||||
export function ListClusterServers():Promise<Array<cluster.ServerConfig>>;
|
||||
|
||||
export function ListCountries():Promise<Array<string>>;
|
||||
@@ -186,6 +205,8 @@ export function PickOpenDatabase():Promise<string>;
|
||||
|
||||
export function PickSaveDatabase():Promise<string>;
|
||||
|
||||
export function PopulateBuiltinReferences(arg1:string):Promise<number>;
|
||||
|
||||
export function QSOAudioBegin():Promise<boolean>;
|
||||
|
||||
export function QSOAudioCancel():Promise<void>;
|
||||
@@ -196,6 +217,8 @@ export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||
|
||||
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
||||
|
||||
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
||||
|
||||
export function ResetAwardDefs():Promise<Array<award.Def>>;
|
||||
|
||||
export function ResetDatabaseToDefault():Promise<void>;
|
||||
@@ -216,6 +239,8 @@ export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
||||
|
||||
export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>;
|
||||
|
||||
export function SaveAwardReference(arg1:string,arg2:awardref.Ref):Promise<void>;
|
||||
|
||||
export function SaveBackupSettings(arg1:main.BackupSettings):Promise<void>;
|
||||
|
||||
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
||||
@@ -246,6 +271,8 @@ export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
||||
|
||||
export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
|
||||
|
||||
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
|
||||
|
||||
export function SendClusterCommand(arg1:string):Promise<void>;
|
||||
|
||||
export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
|
||||
@@ -282,6 +309,8 @@ export function TestQRZUpload():Promise<string>;
|
||||
|
||||
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
||||
|
||||
export function UpdateAwardReferenceList(arg1:string):Promise<main.AwardRefMeta>;
|
||||
|
||||
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
|
||||
|
||||
export function UpdateQSOsFromClublog(arg1:Array<number>):Promise<number>;
|
||||
|
||||
@@ -10,6 +10,10 @@ export function AddQSO(arg1) {
|
||||
return window['go']['main']['App']['AddQSO'](arg1);
|
||||
}
|
||||
|
||||
export function ApplyAwardPreset(arg1, arg2) {
|
||||
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function AwardFields() {
|
||||
return window['go']['main']['App']['AwardFields']();
|
||||
}
|
||||
@@ -22,6 +26,10 @@ export function ClusterSpotStatuses(arg1) {
|
||||
return window['go']['main']['App']['ClusterSpotStatuses'](arg1);
|
||||
}
|
||||
|
||||
export function ComputeQSOAwardRefs(arg1) {
|
||||
return window['go']['main']['App']['ComputeQSOAwardRefs'](arg1);
|
||||
}
|
||||
|
||||
export function ComputeStationInfo(arg1, arg2) {
|
||||
return window['go']['main']['App']['ComputeStationInfo'](arg1, arg2);
|
||||
}
|
||||
@@ -74,10 +82,18 @@ export function DXCCForCountry(arg1) {
|
||||
return window['go']['main']['App']['DXCCForCountry'](arg1);
|
||||
}
|
||||
|
||||
export function DXCCName(arg1) {
|
||||
return window['go']['main']['App']['DXCCName'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteAllQSO() {
|
||||
return window['go']['main']['App']['DeleteAllQSO']();
|
||||
}
|
||||
|
||||
export function DeleteAwardReference(arg1, arg2) {
|
||||
return window['go']['main']['App']['DeleteAwardReference'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function DeleteClusterServer(arg1) {
|
||||
return window['go']['main']['App']['DeleteClusterServer'](arg1);
|
||||
}
|
||||
@@ -154,6 +170,14 @@ export function GetAwardDefs() {
|
||||
return window['go']['main']['App']['GetAwardDefs']();
|
||||
}
|
||||
|
||||
export function GetAwardPresets() {
|
||||
return window['go']['main']['App']['GetAwardPresets']();
|
||||
}
|
||||
|
||||
export function GetAwardReferenceMeta() {
|
||||
return window['go']['main']['App']['GetAwardReferenceMeta']();
|
||||
}
|
||||
|
||||
export function GetAwards() {
|
||||
return window['go']['main']['App']['GetAwards']();
|
||||
}
|
||||
@@ -254,10 +278,18 @@ export function GetWinkeyerStatus() {
|
||||
return window['go']['main']['App']['GetWinkeyerStatus']();
|
||||
}
|
||||
|
||||
export function HasBuiltinReferences(arg1) {
|
||||
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
|
||||
}
|
||||
|
||||
export function ImportADIF(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function ImportAwardReferencesText(arg1, arg2) {
|
||||
return window['go']['main']['App']['ImportAwardReferencesText'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ListAudioInputDevices() {
|
||||
return window['go']['main']['App']['ListAudioInputDevices']();
|
||||
}
|
||||
@@ -266,6 +298,10 @@ export function ListAudioOutputDevices() {
|
||||
return window['go']['main']['App']['ListAudioOutputDevices']();
|
||||
}
|
||||
|
||||
export function ListAwardReferences(arg1) {
|
||||
return window['go']['main']['App']['ListAwardReferences'](arg1);
|
||||
}
|
||||
|
||||
export function ListClusterServers() {
|
||||
return window['go']['main']['App']['ListClusterServers']();
|
||||
}
|
||||
@@ -346,6 +382,10 @@ export function PickSaveDatabase() {
|
||||
return window['go']['main']['App']['PickSaveDatabase']();
|
||||
}
|
||||
|
||||
export function PopulateBuiltinReferences(arg1) {
|
||||
return window['go']['main']['App']['PopulateBuiltinReferences'](arg1);
|
||||
}
|
||||
|
||||
export function QSOAudioBegin() {
|
||||
return window['go']['main']['App']['QSOAudioBegin']();
|
||||
}
|
||||
@@ -366,6 +406,10 @@ export function ReloadUDPIntegrations() {
|
||||
return window['go']['main']['App']['ReloadUDPIntegrations']();
|
||||
}
|
||||
|
||||
export function ReplaceAwardReferences(arg1, arg2) {
|
||||
return window['go']['main']['App']['ReplaceAwardReferences'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ResetAwardDefs() {
|
||||
return window['go']['main']['App']['ResetAwardDefs']();
|
||||
}
|
||||
@@ -406,6 +450,10 @@ export function SaveAwardDefs(arg1) {
|
||||
return window['go']['main']['App']['SaveAwardDefs'](arg1);
|
||||
}
|
||||
|
||||
export function SaveAwardReference(arg1, arg2) {
|
||||
return window['go']['main']['App']['SaveAwardReference'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SaveBackupSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveBackupSettings'](arg1);
|
||||
}
|
||||
@@ -466,6 +514,10 @@ export function SaveWinkeyerSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveWinkeyerSettings'](arg1);
|
||||
}
|
||||
|
||||
export function SearchAwardReferences(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function SendClusterCommand(arg1) {
|
||||
return window['go']['main']['App']['SendClusterCommand'](arg1);
|
||||
}
|
||||
@@ -538,6 +590,10 @@ export function TestRotator(arg1) {
|
||||
return window['go']['main']['App']['TestRotator'](arg1);
|
||||
}
|
||||
|
||||
export function UpdateAwardReferenceList(arg1) {
|
||||
return window['go']['main']['App']['UpdateAwardReferenceList'](arg1);
|
||||
}
|
||||
|
||||
export function UpdateQSO(arg1) {
|
||||
return window['go']['main']['App']['UpdateQSO'](arg1);
|
||||
}
|
||||
|
||||
@@ -85,10 +85,33 @@ export namespace award {
|
||||
export class Def {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
valid: boolean;
|
||||
protected?: boolean;
|
||||
url?: string;
|
||||
download_url?: string;
|
||||
ref_url?: string;
|
||||
valid_from?: string;
|
||||
valid_to?: string;
|
||||
alias?: string;
|
||||
type?: string;
|
||||
field: string;
|
||||
match_by?: string;
|
||||
exact_match?: boolean;
|
||||
pattern: string;
|
||||
leading_str?: string;
|
||||
trailing_str?: string;
|
||||
multi?: boolean;
|
||||
dynamic?: boolean;
|
||||
add_prefixes?: string[];
|
||||
dxcc_filter: number[];
|
||||
valid_bands?: string[];
|
||||
valid_modes?: string[];
|
||||
emission?: string[];
|
||||
confirm: string[];
|
||||
validate?: string[];
|
||||
grant_codes?: string;
|
||||
export_credit_granted?: boolean;
|
||||
total: number;
|
||||
builtin: boolean;
|
||||
|
||||
@@ -100,10 +123,33 @@ export namespace award {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.code = source["code"];
|
||||
this.name = source["name"];
|
||||
this.description = source["description"];
|
||||
this.valid = source["valid"];
|
||||
this.protected = source["protected"];
|
||||
this.url = source["url"];
|
||||
this.download_url = source["download_url"];
|
||||
this.ref_url = source["ref_url"];
|
||||
this.valid_from = source["valid_from"];
|
||||
this.valid_to = source["valid_to"];
|
||||
this.alias = source["alias"];
|
||||
this.type = source["type"];
|
||||
this.field = source["field"];
|
||||
this.match_by = source["match_by"];
|
||||
this.exact_match = source["exact_match"];
|
||||
this.pattern = source["pattern"];
|
||||
this.leading_str = source["leading_str"];
|
||||
this.trailing_str = source["trailing_str"];
|
||||
this.multi = source["multi"];
|
||||
this.dynamic = source["dynamic"];
|
||||
this.add_prefixes = source["add_prefixes"];
|
||||
this.dxcc_filter = source["dxcc_filter"];
|
||||
this.valid_bands = source["valid_bands"];
|
||||
this.valid_modes = source["valid_modes"];
|
||||
this.emission = source["emission"];
|
||||
this.confirm = source["confirm"];
|
||||
this.validate = source["validate"];
|
||||
this.grant_codes = source["grant_codes"];
|
||||
this.export_credit_granted = source["export_credit_granted"];
|
||||
this.total = source["total"];
|
||||
this.builtin = source["builtin"];
|
||||
}
|
||||
@@ -111,8 +157,11 @@ export namespace award {
|
||||
export class Ref {
|
||||
ref: string;
|
||||
name?: string;
|
||||
group?: string;
|
||||
subgrp?: string;
|
||||
worked: boolean;
|
||||
confirmed: boolean;
|
||||
validated: boolean;
|
||||
bands: string[];
|
||||
confirmed_bands: string[];
|
||||
|
||||
@@ -124,8 +173,11 @@ export namespace award {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.ref = source["ref"];
|
||||
this.name = source["name"];
|
||||
this.group = source["group"];
|
||||
this.subgrp = source["subgrp"];
|
||||
this.worked = source["worked"];
|
||||
this.confirmed = source["confirmed"];
|
||||
this.validated = source["validated"];
|
||||
this.bands = source["bands"];
|
||||
this.confirmed_bands = source["confirmed_bands"];
|
||||
}
|
||||
@@ -136,6 +188,7 @@ export namespace award {
|
||||
field: string;
|
||||
worked: number;
|
||||
confirmed: number;
|
||||
validated: number;
|
||||
total: number;
|
||||
bands: BandCount[];
|
||||
refs: Ref[];
|
||||
@@ -152,6 +205,7 @@ export namespace award {
|
||||
this.field = source["field"];
|
||||
this.worked = source["worked"];
|
||||
this.confirmed = source["confirmed"];
|
||||
this.validated = source["validated"];
|
||||
this.total = source["total"];
|
||||
this.bands = this.convertValues(source["bands"], BandCount);
|
||||
this.refs = this.convertValues(source["refs"], Ref);
|
||||
@@ -179,6 +233,87 @@ export namespace award {
|
||||
|
||||
}
|
||||
|
||||
export namespace awardref {
|
||||
|
||||
export class Ref {
|
||||
code: string;
|
||||
name: string;
|
||||
dxcc: number;
|
||||
group: string;
|
||||
subgrp: string;
|
||||
dxcc_list?: number[];
|
||||
pattern?: string;
|
||||
valid: boolean;
|
||||
valid_from?: string;
|
||||
valid_to?: string;
|
||||
score?: number;
|
||||
bonus?: number;
|
||||
gridsquare?: string;
|
||||
alias?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Ref(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.code = source["code"];
|
||||
this.name = source["name"];
|
||||
this.dxcc = source["dxcc"];
|
||||
this.group = source["group"];
|
||||
this.subgrp = source["subgrp"];
|
||||
this.dxcc_list = source["dxcc_list"];
|
||||
this.pattern = source["pattern"];
|
||||
this.valid = source["valid"];
|
||||
this.valid_from = source["valid_from"];
|
||||
this.valid_to = source["valid_to"];
|
||||
this.score = source["score"];
|
||||
this.bonus = source["bonus"];
|
||||
this.gridsquare = source["gridsquare"];
|
||||
this.alias = source["alias"];
|
||||
}
|
||||
}
|
||||
export class Preset {
|
||||
key: string;
|
||||
name: string;
|
||||
field: string;
|
||||
dxcc: number;
|
||||
refs: Ref[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Preset(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.key = source["key"];
|
||||
this.name = source["name"];
|
||||
this.field = source["field"];
|
||||
this.dxcc = source["dxcc"];
|
||||
this.refs = this.convertValues(source["refs"], Ref);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export namespace cat {
|
||||
|
||||
export class RigState {
|
||||
@@ -523,6 +658,24 @@ export namespace main {
|
||||
this.mic_gain = source["mic_gain"];
|
||||
}
|
||||
}
|
||||
export class AwardRefMeta {
|
||||
code: string;
|
||||
count: number;
|
||||
updated_at: string;
|
||||
can_update: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AwardRefMeta(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.code = source["code"];
|
||||
this.count = source["count"];
|
||||
this.updated_at = source["updated_at"];
|
||||
this.can_update = source["can_update"];
|
||||
}
|
||||
}
|
||||
export class BackupSettings {
|
||||
enabled: boolean;
|
||||
folder: string;
|
||||
@@ -796,6 +949,24 @@ export namespace main {
|
||||
this.qrzcom_confirmed = source["qrzcom_confirmed"];
|
||||
}
|
||||
}
|
||||
export class QSOAwardRef {
|
||||
code: string;
|
||||
ref: string;
|
||||
name?: string;
|
||||
pickable: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new QSOAwardRef(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.code = source["code"];
|
||||
this.ref = source["ref"];
|
||||
this.name = source["name"];
|
||||
this.pickable = source["pickable"];
|
||||
}
|
||||
}
|
||||
export class RotatorHeading {
|
||||
enabled: boolean;
|
||||
ok: boolean;
|
||||
|
||||
+331
-47
@@ -21,31 +21,83 @@ import (
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// Def defines one award.
|
||||
// AwardType selects how a QSO is matched to an award's references.
|
||||
//
|
||||
// "DXCC" — match the QSO's DXCC entity number (references keyed by entity)
|
||||
// "QSOFIELDS" — search a QSO field for a reference code/description/pattern
|
||||
// "REFERENCE" — the reference is carried by a dedicated field (POTA_REF, …) or
|
||||
// the per-reference DXCC list (e.g. RAC provinces by state)
|
||||
// "GRID" — match a Maidenhead grid square
|
||||
type AwardType = string
|
||||
|
||||
const (
|
||||
TypeDXCC AwardType = "DXCC"
|
||||
TypeQSOFields AwardType = "QSOFIELDS"
|
||||
TypeReference AwardType = "REFERENCE"
|
||||
TypeGrid AwardType = "GRID"
|
||||
)
|
||||
|
||||
// Def defines one award. Fields mirror Log4OM's Award Management model: an
|
||||
// identity + scope (when the award applies) + a matching rule (how a QSO maps
|
||||
// to a reference) + confirmation rules. Most fields are optional; the zero
|
||||
// value of a legacy Def (only Field/Pattern/DXCCFilter/Confirm/Total set) still
|
||||
// behaves as before.
|
||||
type Def struct {
|
||||
Code string `json:"code"` // unique key, e.g. "DXCC"
|
||||
Name string `json:"name"` // friendly name
|
||||
Field string `json:"field"` // QSO field to scan (see fieldRaw)
|
||||
Pattern string `json:"pattern"` // optional Go regexp; group 1 = reference
|
||||
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
|
||||
Confirm []string `json:"confirm"` // accepted confirmations: lotw|qsl|eqsl
|
||||
Total int `json:"total"` // known denominator (0 = unknown)
|
||||
Builtin bool `json:"builtin"` // shipped default (informational)
|
||||
// --- Identity ---
|
||||
Code string `json:"code"` // unique key, e.g. "DXCC"
|
||||
Name string `json:"name"` // friendly name
|
||||
Description string `json:"description,omitempty"` // free text
|
||||
Valid bool `json:"valid"` // award enabled
|
||||
Protected bool `json:"protected,omitempty"` // shipped/locked award
|
||||
URL string `json:"url,omitempty"` // award home page
|
||||
DownloadURL string `json:"download_url,omitempty"` // reference-list source
|
||||
RefURL string `json:"ref_url,omitempty"` // per-ref link, <REF> placeholder
|
||||
ValidFrom string `json:"valid_from,omitempty"` // ISO date (QSOs before don't count)
|
||||
ValidTo string `json:"valid_to,omitempty"` // ISO date (QSOs after don't count)
|
||||
Alias string `json:"alias,omitempty"`
|
||||
|
||||
// --- Type & matching ---
|
||||
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
|
||||
Field string `json:"field"` // QSO field to scan (see fieldRaw)
|
||||
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
|
||||
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
|
||||
Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference
|
||||
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
|
||||
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
|
||||
Multi bool `json:"multi,omitempty"` // a QSO may count for several references
|
||||
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
|
||||
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes
|
||||
|
||||
// --- Scope ---
|
||||
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
|
||||
ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands
|
||||
ValidModes []string `json:"valid_modes,omitempty"` // empty = all modes
|
||||
Emission []string `json:"emission,omitempty"` // CW | DIGITAL | PHONE (empty = all)
|
||||
|
||||
// --- Confirmation ---
|
||||
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
|
||||
Validate []string `json:"validate,omitempty"` // validated/granted sources
|
||||
GrantCodes string `json:"grant_codes,omitempty"` // ADIF credit grant codes
|
||||
ExportCreditGranted bool `json:"export_credit_granted,omitempty"` // write ADIF credit_granted
|
||||
|
||||
Total int `json:"total"` // known denominator (0 = unknown / derive from list)
|
||||
Builtin bool `json:"builtin"` // shipped default (informational)
|
||||
}
|
||||
|
||||
// Defaults are the built-in awards seeded on first run (then user-editable).
|
||||
func Defaults() []Def {
|
||||
lq := []string{"lotw", "qsl"}
|
||||
return []Def{
|
||||
{Code: "DXCC", Name: "DX Century Club", Field: "dxcc", Confirm: []string{"lotw", "qsl"}, Total: 340, Builtin: true},
|
||||
{Code: "WAS", Name: "Worked All States", Field: "state", DXCCFilter: []int{291, 110, 6}, Confirm: []string{"lotw", "qsl"}, Total: 50, Builtin: true},
|
||||
{Code: "WAZ", Name: "Worked All Zones (CQ)", Field: "cqz", Confirm: []string{"lotw", "qsl"}, Total: 40, Builtin: true},
|
||||
{Code: "WAC", Name: "Worked All Continents", Field: "cont", Confirm: []string{"lotw", "qsl", "eqsl"}, Total: 6, Builtin: true},
|
||||
{Code: "WPX", Name: "Worked All Prefixes (CQ WPX)", Field: "prefix", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true},
|
||||
{Code: "DDFM", Name: "Départements Français Métropolitains", Field: "note", Pattern: `(?i)\bD(\d{1,2}[AB]?)\b`, DXCCFilter: []int{227}, Confirm: []string{"lotw", "qsl"}, Total: 96, Builtin: true},
|
||||
{Code: "IOTA", Name: "Islands On The Air", Field: "iota", Confirm: []string{"qsl"}, Total: 0, Builtin: true},
|
||||
{Code: "POTA", Name: "Parks On The Air", Field: "pota_ref", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true},
|
||||
{Code: "SOTA", Name: "Summits On The Air", Field: "sota_ref", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true},
|
||||
{Code: "WWFF", Name: "World Wide Flora & Fauna", Field: "wwff", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true},
|
||||
{Code: "DXCC", Name: "DX Century Club", Type: TypeDXCC, Field: "dxcc", Confirm: lq, Validate: lq, Total: 340, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WAS", Name: "Worked All States", Type: TypeQSOFields, Field: "state", MatchBy: "code", ExactMatch: true, DXCCFilter: []int{291, 110, 6}, Confirm: lq, Validate: lq, Total: 50, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WAZ", Name: "Worked All Zones (CQ)", Type: TypeQSOFields, Field: "cqz", MatchBy: "code", ExactMatch: true, Confirm: lq, Validate: lq, Total: 40, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WAC", Name: "Worked All Continents", Type: TypeQSOFields, Field: "cont", MatchBy: "code", ExactMatch: true, Confirm: []string{"lotw", "qsl", "eqsl"}, Validate: []string{"lotw", "qsl", "eqsl"}, Total: 6, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WPX", Name: "Worked All Prefixes (CQ WPX)", Type: TypeQSOFields, Field: "prefix", Dynamic: true, Confirm: lq, Validate: lq, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "DDFM", Name: "Départements Français Métropolitains", Type: TypeQSOFields, Field: "note", Pattern: `(?i)\b(D\d{1,2}[AB]?)\b`, DXCCFilter: []int{227}, Confirm: lq, Validate: lq, Total: 96, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "IOTA", Name: "Islands On The Air", Type: TypeReference, Field: "iota", Dynamic: true, Confirm: []string{"qsl"}, Validate: []string{"qsl"}, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "POTA", Name: "Parks On The Air", Type: TypeReference, Field: "pota_ref", Dynamic: true, Confirm: lq, Validate: lq, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "SOTA", Name: "Summits On The Air", Type: TypeReference, Field: "sota_ref", Dynamic: true, Confirm: lq, Validate: lq, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WWFF", Name: "World Wide Flora & Fauna", Type: TypeReference, Field: "wwff", Dynamic: true, Confirm: lq, Validate: lq, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,8 +122,11 @@ type BandCount struct {
|
||||
type Ref struct {
|
||||
Ref string `json:"ref"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
SubGrp string `json:"subgrp,omitempty"`
|
||||
Worked bool `json:"worked"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
Validated bool `json:"validated"`
|
||||
Bands []string `json:"bands"`
|
||||
ConfirmedBands []string `json:"confirmed_bands"`
|
||||
}
|
||||
@@ -83,6 +138,7 @@ type Result struct {
|
||||
Field string `json:"field"`
|
||||
Worked int `json:"worked"`
|
||||
Confirmed int `json:"confirmed"`
|
||||
Validated int `json:"validated"`
|
||||
Total int `json:"total"`
|
||||
Bands []BandCount `json:"bands"`
|
||||
Refs []Ref `json:"refs"`
|
||||
@@ -96,11 +152,63 @@ type refAgg struct {
|
||||
bands map[string]struct{}
|
||||
confirmedBands map[string]struct{}
|
||||
anyConfirmed bool
|
||||
anyValidated bool
|
||||
}
|
||||
|
||||
// Compute runs every definition over the QSOs in a single pass.
|
||||
func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
|
||||
// Pre-compile patterns once per award (not per QSO).
|
||||
// refList is the per-award reference data Compute needs (a thin view of
|
||||
// awardref.Ref, kept local so the award package stays storage-agnostic).
|
||||
type refList struct {
|
||||
byCode map[string]RefMeta // uppercased code → metadata
|
||||
codes []string // codes in input order (for stable unworked listing)
|
||||
}
|
||||
|
||||
// RefMeta is one reference's metadata for the engine: enough to enforce a
|
||||
// predefined list, per-reference DXCC scoping, a per-reference pattern, and to
|
||||
// label results.
|
||||
type RefMeta struct {
|
||||
Code string
|
||||
Name string
|
||||
Group string
|
||||
SubGrp string
|
||||
DXCCList []int // nil = any
|
||||
Pattern string
|
||||
re *regexp.Regexp
|
||||
Valid bool
|
||||
}
|
||||
|
||||
// NewRefList builds the engine's reference view from (code, meta) pairs.
|
||||
func NewRefList(metas []RefMeta) refList {
|
||||
rl := refList{byCode: make(map[string]RefMeta, len(metas))}
|
||||
for _, m := range metas {
|
||||
code := normalizeRef(m.Code)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
if p := strings.TrimSpace(m.Pattern); p != "" {
|
||||
if re, err := regexp.Compile(p); err == nil {
|
||||
m.re = re
|
||||
}
|
||||
}
|
||||
m.Code = code
|
||||
if _, dup := rl.byCode[code]; !dup {
|
||||
rl.codes = append(rl.codes, code)
|
||||
}
|
||||
rl.byCode[code] = m
|
||||
}
|
||||
return rl
|
||||
}
|
||||
|
||||
// Compute runs every definition over the QSOs in a single pass. refMetas maps an
|
||||
// award code to its reference metadata; awards present there with Dynamic=false
|
||||
// are "predefined" (only listed references count, and the full list — including
|
||||
// unworked references — appears in the result).
|
||||
func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf NameResolver) []Result {
|
||||
refLists := make(map[string]refList, len(refMetas))
|
||||
for code, metas := range refMetas {
|
||||
refLists[strings.ToUpper(strings.TrimSpace(code))] = NewRefList(metas)
|
||||
}
|
||||
|
||||
// Pre-compile award-level patterns once.
|
||||
res := make([]*regexp.Regexp, len(defs))
|
||||
perr := make([]string, len(defs))
|
||||
for i := range defs {
|
||||
@@ -123,18 +231,17 @@ func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
|
||||
q := &qsos[qi]
|
||||
for i := range defs {
|
||||
d := &defs[i]
|
||||
if perr[i] != "" {
|
||||
if perr[i] != "" || !inScope(d, q) {
|
||||
continue
|
||||
}
|
||||
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
|
||||
continue
|
||||
}
|
||||
refs := refValues(d, res[i], q)
|
||||
rl, hasList := refLists[strings.ToUpper(d.Code)]
|
||||
refs := candidates(d, res[i], q, rl, hasList)
|
||||
if len(refs) == 0 {
|
||||
continue
|
||||
}
|
||||
band := strings.ToLower(strings.TrimSpace(q.Band))
|
||||
isConf := confirmed(q, d.Confirm)
|
||||
isVal := confirmed(q, d.Validate)
|
||||
for _, ref := range refs {
|
||||
a := agg[i][ref]
|
||||
if a == nil {
|
||||
@@ -150,6 +257,9 @@ func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
|
||||
a.confirmedBands[band] = struct{}{}
|
||||
}
|
||||
}
|
||||
if isVal {
|
||||
a.anyValidated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,6 +267,8 @@ func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
|
||||
out := make([]Result, len(defs))
|
||||
for i := range defs {
|
||||
d := &defs[i]
|
||||
rl, hasList := refLists[strings.ToUpper(d.Code)]
|
||||
predefined := hasList && !d.Dynamic
|
||||
r := Result{Code: d.Code, Name: d.Name, Field: d.Field, Total: d.Total, Error: perr[i]}
|
||||
bandWorked := map[string]int{}
|
||||
bandConfirmed := map[string]int{}
|
||||
@@ -165,10 +277,12 @@ func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
|
||||
if a.anyConfirmed {
|
||||
r.Confirmed++
|
||||
}
|
||||
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands)}
|
||||
if nameOf != nil {
|
||||
rf.Name = nameOf(d.Field, ref)
|
||||
if a.anyValidated {
|
||||
r.Validated++
|
||||
}
|
||||
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Validated: a.anyValidated,
|
||||
Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands)}
|
||||
labelRef(&rf, d, ref, rl, hasList, nameOf)
|
||||
r.Refs = append(r.Refs, rf)
|
||||
for b := range a.bands {
|
||||
bandWorked[b]++
|
||||
@@ -177,7 +291,29 @@ func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
|
||||
bandConfirmed[b]++
|
||||
}
|
||||
}
|
||||
// Predefined awards: the full list is the denominator, and unworked
|
||||
// references are listed too (greyed in the UI).
|
||||
if predefined {
|
||||
r.Total = len(rl.codes)
|
||||
for _, code := range rl.codes {
|
||||
if _, worked := agg[i][code]; worked {
|
||||
continue
|
||||
}
|
||||
m := rl.byCode[code]
|
||||
if !m.Valid {
|
||||
continue
|
||||
}
|
||||
rf := Ref{Ref: code, Name: m.Name, Group: m.Group, SubGrp: m.SubGrp, Bands: []string{}, ConfirmedBands: []string{}}
|
||||
if rf.Name == "" && nameOf != nil {
|
||||
rf.Name = nameOf(d.Field, code)
|
||||
}
|
||||
r.Refs = append(r.Refs, rf)
|
||||
}
|
||||
}
|
||||
sort.Slice(r.Refs, func(a, b int) bool {
|
||||
if r.Refs[a].Worked != r.Refs[b].Worked {
|
||||
return r.Refs[a].Worked // worked first
|
||||
}
|
||||
if r.Refs[a].Confirmed != r.Refs[b].Confirmed {
|
||||
return r.Refs[a].Confirmed
|
||||
}
|
||||
@@ -191,41 +327,189 @@ func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
|
||||
return out
|
||||
}
|
||||
|
||||
// refValues extracts the reference(s) a QSO contributes to an award.
|
||||
func refValues(d *Def, re *regexp.Regexp, q *qso.QSO) []string {
|
||||
raw := fieldRaw(d.Field, q)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
// labelRef fills a worked reference's name/group from the reference list (or the
|
||||
// name resolver as a fallback).
|
||||
func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) {
|
||||
if hasList {
|
||||
if m, ok := rl.byCode[code]; ok {
|
||||
rf.Name, rf.Group, rf.SubGrp = m.Name, m.Group, m.SubGrp
|
||||
}
|
||||
}
|
||||
if rf.Name == "" && nameOf != nil {
|
||||
rf.Name = nameOf(d.Field, code)
|
||||
}
|
||||
}
|
||||
|
||||
// candidates extracts the reference(s) a QSO contributes to an award, enforcing
|
||||
// a predefined list when one applies.
|
||||
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
|
||||
raw := strings.TrimSpace(stripAffix(fieldRaw(d.Field, q), d.LeadingStr, d.TrailingStr))
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
if re == nil {
|
||||
return []string{normalizeRef(raw)}
|
||||
predefined := hasList && !d.Dynamic
|
||||
|
||||
var found []string
|
||||
switch {
|
||||
case re != nil:
|
||||
// Award-level regex: capture group 1 (or whole match) for each hit.
|
||||
found = regexTokens(re, raw)
|
||||
case predefined && !d.ExactMatch:
|
||||
// "Search reference inside the field": keep any reference code that
|
||||
// appears as a token (or whose per-reference pattern matches).
|
||||
up := strings.ToUpper(raw)
|
||||
for _, code := range rl.codes {
|
||||
m := rl.byCode[code]
|
||||
if (m.re != nil && m.re.MatchString(raw)) || containsToken(up, code) {
|
||||
found = append(found, code)
|
||||
}
|
||||
}
|
||||
default:
|
||||
// Whole field value is the candidate.
|
||||
found = []string{normalizeRef(raw)}
|
||||
}
|
||||
matches := re.FindAllStringSubmatch(raw, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
|
||||
if !predefined {
|
||||
return dedupe(found)
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
// Enforce the predefined list: keep only listed, valid references whose
|
||||
// per-reference DXCC scope (if any) includes the QSO's entity.
|
||||
var out []string
|
||||
seen := map[string]struct{}{}
|
||||
for _, c := range found {
|
||||
c = normalizeRef(c)
|
||||
m, ok := rl.byCode[c]
|
||||
if !ok || !m.Valid {
|
||||
continue
|
||||
}
|
||||
if len(m.DXCCList) > 0 && !dxccAllowed(q.DXCC, m.DXCCList) {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[c]; dup {
|
||||
continue
|
||||
}
|
||||
seen[c] = struct{}{}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func regexTokens(re *regexp.Regexp, raw string) []string {
|
||||
matches := re.FindAllStringSubmatch(raw, -1)
|
||||
out := make([]string, 0, len(matches))
|
||||
for _, m := range matches {
|
||||
ref := m[0]
|
||||
if len(m) > 1 && m[1] != "" {
|
||||
ref = m[1]
|
||||
}
|
||||
ref = normalizeRef(ref)
|
||||
if ref == "" {
|
||||
if ref = normalizeRef(ref); ref != "" {
|
||||
out = append(out, ref)
|
||||
}
|
||||
}
|
||||
return dedupe(out)
|
||||
}
|
||||
|
||||
func dedupe(in []string) []string {
|
||||
if len(in) <= 1 {
|
||||
return in
|
||||
}
|
||||
seen := make(map[string]struct{}, len(in))
|
||||
out := in[:0]
|
||||
for _, s := range in {
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[ref]; dup {
|
||||
continue
|
||||
}
|
||||
seen[ref] = struct{}{}
|
||||
out = append(out, ref)
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// containsToken reports whether code appears in up (already uppercased) as a
|
||||
// whole token (delimited by non-alphanumerics), so "D6" doesn't match "D60".
|
||||
func containsToken(up, code string) bool {
|
||||
for from := 0; ; {
|
||||
idx := strings.Index(up[from:], code)
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
i := from + idx
|
||||
j := i + len(code)
|
||||
leftOK := i == 0 || !isAlnum(up[i-1])
|
||||
rightOK := j == len(up) || !isAlnum(up[j])
|
||||
if leftOK && rightOK {
|
||||
return true
|
||||
}
|
||||
from = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
func isAlnum(b byte) bool {
|
||||
return (b >= '0' && b <= '9') || (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z')
|
||||
}
|
||||
|
||||
// stripAffix removes a leading and/or trailing literal string before matching.
|
||||
func stripAffix(s, lead, trail string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if lead != "" {
|
||||
s = strings.TrimPrefix(s, lead)
|
||||
}
|
||||
if trail != "" {
|
||||
s = strings.TrimSuffix(s, trail)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func normalizeRef(s string) string { return strings.ToUpper(strings.TrimSpace(s)) }
|
||||
|
||||
// inScope reports whether a QSO falls within an award's scope (DXCC entity,
|
||||
// bands, modes, emission category, validity dates).
|
||||
func inScope(d *Def, q *qso.QSO) bool {
|
||||
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
|
||||
return false
|
||||
}
|
||||
if len(d.ValidBands) > 0 && !containsFold(d.ValidBands, q.Band) {
|
||||
return false
|
||||
}
|
||||
if len(d.ValidModes) > 0 && !containsFold(d.ValidModes, q.Mode) {
|
||||
return false
|
||||
}
|
||||
if len(d.Emission) > 0 && !containsFold(d.Emission, emissionOf(q.Mode)) {
|
||||
return false
|
||||
}
|
||||
if d.ValidFrom != "" && q.QSODate.Format("2006-01-02") < d.ValidFrom {
|
||||
return false
|
||||
}
|
||||
if d.ValidTo != "" && q.QSODate.Format("2006-01-02") > d.ValidTo {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func containsFold(list []string, v string) bool {
|
||||
v = strings.TrimSpace(v)
|
||||
for _, x := range list {
|
||||
if strings.EqualFold(strings.TrimSpace(x), v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// emissionOf maps an ADIF mode to its broad emission category.
|
||||
func emissionOf(mode string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "CW":
|
||||
return "CW"
|
||||
case "SSB", "USB", "LSB", "AM", "FM", "DV", "DIGITALVOICE", "PHONE", "C4FM":
|
||||
return "PHONE"
|
||||
case "":
|
||||
return ""
|
||||
default:
|
||||
return "DIGITAL"
|
||||
}
|
||||
}
|
||||
|
||||
// fieldRaw returns the raw string value of a QSO field (computed for numeric /
|
||||
// derived fields). Unknown fields yield "".
|
||||
func fieldRaw(field string, q *qso.QSO) string {
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestComputeDXCCAndConfirm(t *testing.T) {
|
||||
{Callsign: "F4BPO", Band: "20m", DXCC: ip(227), Notes: "nice qso D74", EQSLRcvd: "Y"}, // France dept 74 in note
|
||||
{Callsign: "F5ABC", Band: "40m", DXCC: ip(227), Notes: "D2A Corsica", QSLRcvd: "Y"}, // France dept 2A, confirmed
|
||||
}
|
||||
res := Compute(Defaults(), qsos, nil)
|
||||
res := Compute(Defaults(), qsos, nil, nil)
|
||||
by := map[string]Result{}
|
||||
for _, r := range res {
|
||||
by[r.Code] = r
|
||||
@@ -73,3 +73,37 @@ func refCodes(r Result) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A predefined award only counts references present in its list, lists the
|
||||
// unworked ones too, and uses the list size as the denominator.
|
||||
func TestComputePredefinedList(t *testing.T) {
|
||||
def := Def{Code: "RAC", Name: "RAC", Type: TypeQSOFields, Field: "state", ExactMatch: true,
|
||||
DXCCFilter: []int{1}, Confirm: []string{"lotw", "qsl"}, Validate: []string{"lotw"}, Valid: true}
|
||||
qsos := []qso.QSO{
|
||||
{Callsign: "VE3AAA", Band: "20m", DXCC: ip(1), State: "ON", LOTWRcvd: "Y"}, // worked+confirmed+validated
|
||||
{Callsign: "VE7BBB", Band: "40m", DXCC: ip(1), State: "BC", QSLRcvd: "Y"}, // worked+confirmed (not validated)
|
||||
{Callsign: "VE9CCC", Band: "20m", DXCC: ip(1), State: "ZZ"}, // not a real province → ignored
|
||||
{Callsign: "K1ABC", Band: "20m", DXCC: ip(291), State: "MA"}, // wrong DXCC → ignored
|
||||
}
|
||||
refMetas := map[string][]RefMeta{"RAC": {
|
||||
{Code: "ON", Name: "Ontario", Valid: true},
|
||||
{Code: "BC", Name: "British Columbia", Valid: true},
|
||||
{Code: "AB", Name: "Alberta", Valid: true},
|
||||
}}
|
||||
r := Compute([]Def{def}, qsos, refMetas, nil)[0]
|
||||
if r.Worked != 2 {
|
||||
t.Errorf("RAC worked = %d, want 2 (%v)", r.Worked, refCodes(r))
|
||||
}
|
||||
if r.Confirmed != 2 {
|
||||
t.Errorf("RAC confirmed = %d, want 2", r.Confirmed)
|
||||
}
|
||||
if r.Validated != 1 { // only ON via LoTW
|
||||
t.Errorf("RAC validated = %d, want 1", r.Validated)
|
||||
}
|
||||
if r.Total != 3 { // denominator = list size
|
||||
t.Errorf("RAC total = %d, want 3", r.Total)
|
||||
}
|
||||
if len(r.Refs) != 3 { // ON, BC worked + AB unworked
|
||||
t.Errorf("RAC refs = %d, want 3 (%v)", len(r.Refs), refCodes(r))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
// Package awardref stores and updates award reference lists (POTA parks, SOTA
|
||||
// summits, WWFF references, …). These provide award totals, reference names,
|
||||
// and per-DXCC filtering. Lists are downloaded from each program's public file.
|
||||
package awardref
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// allCols is the column list shared by read queries so they stay in sync.
|
||||
const allCols = `ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias`
|
||||
|
||||
func encodeDXCCList(l []int) string {
|
||||
if len(l) == 0 {
|
||||
return ""
|
||||
}
|
||||
b, err := json.Marshal(l)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func decodeDXCCList(s string) []int {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
var l []int
|
||||
if json.Unmarshal([]byte(s), &l) != nil {
|
||||
return nil
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// scanRef reads one row selected with allCols into a Ref.
|
||||
func scanRef(rows *sql.Rows) (Ref, error) {
|
||||
var r Ref
|
||||
var dxccList string
|
||||
var valid int
|
||||
if err := rows.Scan(&r.Code, &r.Name, &r.DXCC, &r.Group, &r.SubGrp,
|
||||
&dxccList, &r.Pattern, &valid, &r.ValidFrom, &r.ValidTo,
|
||||
&r.Score, &r.Bonus, &r.GridSquare, &r.Alias); err != nil {
|
||||
return r, err
|
||||
}
|
||||
r.DXCCList = decodeDXCCList(dxccList)
|
||||
r.Valid = valid != 0
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Ref is one award reference. The first five fields are the original schema;
|
||||
// the rest mirror Log4OM's per-reference editor (group/subgroup, multi-DXCC,
|
||||
// per-reference regex, validity window, score/bonus, grid, alias).
|
||||
type Ref struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"` // description
|
||||
DXCC int `json:"dxcc"` // primary entity (kept for compatibility / fast filter)
|
||||
Group string `json:"group"`
|
||||
SubGrp string `json:"subgrp"`
|
||||
|
||||
DXCCList []int `json:"dxcc_list,omitempty"` // all entities this ref is valid for
|
||||
Pattern string `json:"pattern,omitempty"` // per-reference Go regexp
|
||||
Valid bool `json:"valid"` // reference enabled
|
||||
ValidFrom string `json:"valid_from,omitempty"`
|
||||
ValidTo string `json:"valid_to,omitempty"`
|
||||
Score int `json:"score,omitempty"`
|
||||
Bonus int `json:"bonus,omitempty"`
|
||||
GridSquare string `json:"gridsquare,omitempty"`
|
||||
Alias string `json:"alias,omitempty"`
|
||||
}
|
||||
|
||||
// Repo accesses the award_references table.
|
||||
type Repo struct{ db *sql.DB }
|
||||
|
||||
// NewRepo builds a reference repo on the given connection.
|
||||
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
|
||||
|
||||
// ReplaceAll atomically replaces every reference for one award.
|
||||
func (r *Repo) ReplaceAll(ctx context.Context, awardCode string, refs []Ref) (int, error) {
|
||||
code := strings.ToUpper(strings.TrimSpace(awardCode))
|
||||
if code == "" {
|
||||
return 0, fmt.Errorf("empty award code")
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM award_references WHERE award_code = ?`, code); err != nil {
|
||||
return 0, fmt.Errorf("clear refs: %w", err)
|
||||
}
|
||||
stmt, err := tx.PrepareContext(ctx,
|
||||
`INSERT OR REPLACE INTO award_references
|
||||
(award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
n := 0
|
||||
for _, ref := range refs {
|
||||
rc := strings.ToUpper(strings.TrimSpace(ref.Code))
|
||||
if rc == "" {
|
||||
continue
|
||||
}
|
||||
// A bulk-replaced list is the authoritative enabled set: store every
|
||||
// row as valid. Per-reference disabling is done through Upsert.
|
||||
if _, err := stmt.ExecContext(ctx, code, rc, strings.TrimSpace(ref.Name), ref.DXCC, ref.Group, ref.SubGrp,
|
||||
encodeDXCCList(ref.DXCCList), ref.Pattern, 1, ref.ValidFrom, ref.ValidTo,
|
||||
ref.Score, ref.Bonus, ref.GridSquare, ref.Alias); err != nil {
|
||||
return 0, fmt.Errorf("insert ref %s: %w", rc, err)
|
||||
}
|
||||
n++
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Count returns how many references an award has stored.
|
||||
func (r *Repo) Count(ctx context.Context, awardCode string) (int, error) {
|
||||
var n int
|
||||
err := r.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM award_references WHERE award_code = ?`,
|
||||
strings.ToUpper(strings.TrimSpace(awardCode))).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Counts returns reference counts for every award.
|
||||
func (r *Repo) Counts(ctx context.Context) (map[string]int, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT award_code, COUNT(*) FROM award_references GROUP BY award_code`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]int{}
|
||||
for rows.Next() {
|
||||
var code string
|
||||
var n int
|
||||
if err := rows.Scan(&code, &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[code] = n
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// NamesFor returns ref_code → name for the given codes of one award (batched so
|
||||
// we never load a 250k-row map just to label a few worked references).
|
||||
func (r *Repo) NamesFor(ctx context.Context, awardCode string, codes []string) (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
if len(codes) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
code := strings.ToUpper(strings.TrimSpace(awardCode))
|
||||
// Chunk to stay under SQLite's parameter limit.
|
||||
const chunk = 400
|
||||
for start := 0; start < len(codes); start += chunk {
|
||||
end := start + chunk
|
||||
if end > len(codes) {
|
||||
end = len(codes)
|
||||
}
|
||||
batch := codes[start:end]
|
||||
ph := strings.TrimSuffix(strings.Repeat("?,", len(batch)), ",")
|
||||
args := make([]any, 0, len(batch)+1)
|
||||
args = append(args, code)
|
||||
for _, c := range batch {
|
||||
args = append(args, strings.ToUpper(strings.TrimSpace(c)))
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT ref_code, name FROM award_references WHERE award_code = ? AND ref_code IN (`+ph+`)`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var rc, name string
|
||||
if err := rows.Scan(&rc, &name); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
out[rc] = name
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Search returns up to `limit` references of an award matching a code/name
|
||||
// query, optionally restricted to a DXCC entity. Drives the per-QSO picker.
|
||||
func (r *Repo) Search(ctx context.Context, awardCode, query string, dxcc, limit int) ([]Ref, error) {
|
||||
code := strings.ToUpper(strings.TrimSpace(awardCode))
|
||||
q := strings.TrimSpace(query)
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
sqlStr := `SELECT ` + allCols + ` FROM award_references WHERE award_code = ?`
|
||||
args := []any{code}
|
||||
if dxcc > 0 {
|
||||
// Match the primary dxcc OR the multi-DXCC list (JSON contains the id).
|
||||
sqlStr += ` AND (dxcc = ? OR dxcc_list LIKE ?)`
|
||||
args = append(args, dxcc, fmt.Sprintf("%%%d%%", dxcc))
|
||||
}
|
||||
if q != "" {
|
||||
sqlStr += ` AND (ref_code LIKE ? OR name LIKE ?)`
|
||||
args = append(args, "%"+strings.ToUpper(q)+"%", "%"+q+"%")
|
||||
}
|
||||
sqlStr += ` ORDER BY ref_code LIMIT ?`
|
||||
args = append(args, limit)
|
||||
rows, err := r.db.QueryContext(ctx, sqlStr, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Ref
|
||||
for rows.Next() {
|
||||
ref, err := scanRef(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, ref)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// List returns every reference of an award, ordered by code. Used by the
|
||||
// reference editor and (via the engine) to show unworked references.
|
||||
func (r *Repo) List(ctx context.Context, awardCode string) ([]Ref, error) {
|
||||
code := strings.ToUpper(strings.TrimSpace(awardCode))
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT `+allCols+` FROM award_references WHERE award_code = ? ORDER BY ref_code`, code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Ref
|
||||
for rows.Next() {
|
||||
ref, err := scanRef(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, ref)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Upsert inserts or updates a single reference of an award.
|
||||
func (r *Repo) Upsert(ctx context.Context, awardCode string, ref Ref) error {
|
||||
code := strings.ToUpper(strings.TrimSpace(awardCode))
|
||||
rc := strings.ToUpper(strings.TrimSpace(ref.Code))
|
||||
if code == "" || rc == "" {
|
||||
return fmt.Errorf("empty award or reference code")
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`INSERT OR REPLACE INTO award_references
|
||||
(award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
code, rc, strings.TrimSpace(ref.Name), ref.DXCC, ref.Group, ref.SubGrp,
|
||||
encodeDXCCList(ref.DXCCList), ref.Pattern, b2i(ref.Valid), ref.ValidFrom, ref.ValidTo,
|
||||
ref.Score, ref.Bonus, ref.GridSquare, ref.Alias)
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete removes one reference from an award.
|
||||
func (r *Repo) Delete(ctx context.Context, awardCode, refCode string) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`DELETE FROM award_references WHERE award_code = ? AND ref_code = ?`,
|
||||
strings.ToUpper(strings.TrimSpace(awardCode)), strings.ToUpper(strings.TrimSpace(refCode)))
|
||||
return err
|
||||
}
|
||||
|
||||
func b2i(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package awardref
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"hamlog/internal/dxcc"
|
||||
)
|
||||
|
||||
// BuiltinRefs returns the seed reference list for a built-in award (DXCC
|
||||
// entities, CQ zones, continents, US states, French departments). ok=false for
|
||||
// awards whose list is downloaded online (POTA/SOTA/WWFF) or fully custom.
|
||||
//
|
||||
// The reference CODE must equal what award.Compute extracts from the QSO field
|
||||
// so worked references map onto the list:
|
||||
// - DXCC → entity number ("291")
|
||||
// - WAZ → CQ zone number ("1".."40")
|
||||
// - WAC → continent code ("EU", "NA", …)
|
||||
// - WAS → ADIF STATE code ("AL", …)
|
||||
// - DDFM → "D06" (the award pattern captures the leading D)
|
||||
func BuiltinRefs(code string) ([]Ref, bool) {
|
||||
switch code {
|
||||
case "DXCC":
|
||||
return dxccEntities(), true
|
||||
case "WAZ":
|
||||
return cqZones(), true
|
||||
case "WAC":
|
||||
return continents(), true
|
||||
case "WAS":
|
||||
return usStates().Refs, true
|
||||
case "DDFM":
|
||||
return frenchDepartments(), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func dxccEntities() []Ref {
|
||||
ents := dxcc.AllEntities()
|
||||
out := make([]Ref, 0, len(ents))
|
||||
for _, e := range ents {
|
||||
out = append(out, ref(strconv.Itoa(e.Num), e.Name, e.Num))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cqZones() []Ref {
|
||||
out := make([]Ref, 0, 40)
|
||||
for z := 1; z <= 40; z++ {
|
||||
out = append(out, ref(strconv.Itoa(z), "CQ Zone "+strconv.Itoa(z), 0))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func continents() []Ref {
|
||||
pairs := [][2]string{
|
||||
{"AF", "Africa"}, {"AN", "Antarctica"}, {"AS", "Asia"},
|
||||
{"EU", "Europe"}, {"NA", "North America"}, {"OC", "Oceania"}, {"SA", "South America"},
|
||||
}
|
||||
out := make([]Ref, 0, len(pairs))
|
||||
for _, p := range pairs {
|
||||
out = append(out, ref(p[0], p[1], 0))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// frenchDepartments — the 96 metropolitan French departments (DXCC 227).
|
||||
func frenchDepartments() []Ref {
|
||||
const fr = 227
|
||||
deps := [][2]string{
|
||||
{"D01", "Ain"}, {"D02", "Aisne"}, {"D03", "Allier"}, {"D04", "Alpes-de-Haute-Provence"},
|
||||
{"D05", "Hautes-Alpes"}, {"D06", "Alpes-Maritimes"}, {"D07", "Ardèche"}, {"D08", "Ardennes"},
|
||||
{"D09", "Ariège"}, {"D10", "Aube"}, {"D11", "Aude"}, {"D12", "Aveyron"},
|
||||
{"D13", "Bouches-du-Rhône"}, {"D14", "Calvados"}, {"D15", "Cantal"}, {"D16", "Charente"},
|
||||
{"D17", "Charente-Maritime"}, {"D18", "Cher"}, {"D19", "Corrèze"}, {"D2A", "Corse-du-Sud"},
|
||||
{"D2B", "Haute-Corse"}, {"D21", "Côte-d'Or"}, {"D22", "Côtes-d'Armor"}, {"D23", "Creuse"},
|
||||
{"D24", "Dordogne"}, {"D25", "Doubs"}, {"D26", "Drôme"}, {"D27", "Eure"},
|
||||
{"D28", "Eure-et-Loir"}, {"D29", "Finistère"}, {"D30", "Gard"}, {"D31", "Haute-Garonne"},
|
||||
{"D32", "Gers"}, {"D33", "Gironde"}, {"D34", "Hérault"}, {"D35", "Ille-et-Vilaine"},
|
||||
{"D36", "Indre"}, {"D37", "Indre-et-Loire"}, {"D38", "Isère"}, {"D39", "Jura"},
|
||||
{"D40", "Landes"}, {"D41", "Loir-et-Cher"}, {"D42", "Loire"}, {"D43", "Haute-Loire"},
|
||||
{"D44", "Loire-Atlantique"}, {"D45", "Loiret"}, {"D46", "Lot"}, {"D47", "Lot-et-Garonne"},
|
||||
{"D48", "Lozère"}, {"D49", "Maine-et-Loire"}, {"D50", "Manche"}, {"D51", "Marne"},
|
||||
{"D52", "Haute-Marne"}, {"D53", "Mayenne"}, {"D54", "Meurthe-et-Moselle"}, {"D55", "Meuse"},
|
||||
{"D56", "Morbihan"}, {"D57", "Moselle"}, {"D58", "Nièvre"}, {"D59", "Nord"},
|
||||
{"D60", "Oise"}, {"D61", "Orne"}, {"D62", "Pas-de-Calais"}, {"D63", "Puy-de-Dôme"},
|
||||
{"D64", "Pyrénées-Atlantiques"}, {"D65", "Hautes-Pyrénées"}, {"D66", "Pyrénées-Orientales"}, {"D67", "Bas-Rhin"},
|
||||
{"D68", "Haut-Rhin"}, {"D69", "Rhône"}, {"D70", "Haute-Saône"}, {"D71", "Saône-et-Loire"},
|
||||
{"D72", "Sarthe"}, {"D73", "Savoie"}, {"D74", "Haute-Savoie"}, {"D75", "Paris"},
|
||||
{"D76", "Seine-Maritime"}, {"D77", "Seine-et-Marne"}, {"D78", "Yvelines"}, {"D79", "Deux-Sèvres"},
|
||||
{"D80", "Somme"}, {"D81", "Tarn"}, {"D82", "Tarn-et-Garonne"}, {"D83", "Var"},
|
||||
{"D84", "Vaucluse"}, {"D85", "Vendée"}, {"D86", "Vienne"}, {"D87", "Haute-Vienne"},
|
||||
{"D88", "Vosges"}, {"D89", "Yonne"}, {"D90", "Territoire de Belfort"}, {"D91", "Essonne"},
|
||||
{"D92", "Hauts-de-Seine"}, {"D93", "Seine-Saint-Denis"}, {"D94", "Val-de-Marne"}, {"D95", "Val-d'Oise"},
|
||||
}
|
||||
out := make([]Ref, 0, len(deps))
|
||||
for _, d := range deps {
|
||||
out = append(out, ref(d[0], d[1], fr))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package awardref
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"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},
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package awardref
|
||||
|
||||
// Preset is a ready-made reference list a user can apply to an award in one
|
||||
// click (Canadian provinces, US states, …). Codes match the values that land
|
||||
// in the corresponding QSO field (e.g. ADIF STATE codes).
|
||||
type Preset struct {
|
||||
Key string `json:"key"` // stable id, e.g. "ca_provinces"
|
||||
Name string `json:"name"` // friendly label
|
||||
Field string `json:"field"` // suggested QSO field to scan
|
||||
DXCC int `json:"dxcc"` // suggested DXCC scope (0 = none)
|
||||
Refs []Ref `json:"refs"`
|
||||
}
|
||||
|
||||
// Presets is the catalogue of built-in reference lists, returned to the UI.
|
||||
func Presets() []Preset {
|
||||
return []Preset{
|
||||
caProvinces(),
|
||||
usStates(),
|
||||
}
|
||||
}
|
||||
|
||||
// PresetByKey returns a preset by its key (ok=false if unknown).
|
||||
func PresetByKey(key string) (Preset, bool) {
|
||||
for _, p := range Presets() {
|
||||
if p.Key == key {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return Preset{}, false
|
||||
}
|
||||
|
||||
func ref(code, name string, dxcc int) Ref {
|
||||
return Ref{Code: code, Name: name, DXCC: dxcc, Valid: true}
|
||||
}
|
||||
|
||||
// caProvinces — RAC Canadian Provinces (DXCC 1 = Canada). Codes are ADIF STATE
|
||||
// values for VE provinces/territories.
|
||||
func caProvinces() Preset {
|
||||
const ca = 1
|
||||
return Preset{
|
||||
Key: "ca_provinces", Name: "Canadian Provinces (RAC)", Field: "state", DXCC: ca,
|
||||
Refs: []Ref{
|
||||
ref("AB", "Alberta", ca), ref("BC", "British Columbia", ca),
|
||||
ref("MB", "Manitoba", ca), ref("NB", "New Brunswick", ca),
|
||||
ref("NL", "Newfoundland and Labrador", ca), ref("NS", "Nova Scotia", ca),
|
||||
ref("NT", "Northwest Territories", ca), ref("NU", "Nunavut", ca),
|
||||
ref("ON", "Ontario", ca), ref("PE", "Prince Edward Island", ca),
|
||||
ref("QC", "Quebec", ca), ref("SK", "Saskatchewan", ca),
|
||||
ref("YT", "Yukon", ca),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// usStates — Worked All States (DXCC 291 = United States). 50 ADIF STATE codes.
|
||||
func usStates() Preset {
|
||||
const us = 291
|
||||
codes := [][2]string{
|
||||
{"AL", "Alabama"}, {"AK", "Alaska"}, {"AZ", "Arizona"}, {"AR", "Arkansas"},
|
||||
{"CA", "California"}, {"CO", "Colorado"}, {"CT", "Connecticut"}, {"DE", "Delaware"},
|
||||
{"FL", "Florida"}, {"GA", "Georgia"}, {"HI", "Hawaii"}, {"ID", "Idaho"},
|
||||
{"IL", "Illinois"}, {"IN", "Indiana"}, {"IA", "Iowa"}, {"KS", "Kansas"},
|
||||
{"KY", "Kentucky"}, {"LA", "Louisiana"}, {"ME", "Maine"}, {"MD", "Maryland"},
|
||||
{"MA", "Massachusetts"}, {"MI", "Michigan"}, {"MN", "Minnesota"}, {"MS", "Mississippi"},
|
||||
{"MO", "Missouri"}, {"MT", "Montana"}, {"NE", "Nebraska"}, {"NV", "Nevada"},
|
||||
{"NH", "New Hampshire"}, {"NJ", "New Jersey"}, {"NM", "New Mexico"}, {"NY", "New York"},
|
||||
{"NC", "North Carolina"}, {"ND", "North Dakota"}, {"OH", "Ohio"}, {"OK", "Oklahoma"},
|
||||
{"OR", "Oregon"}, {"PA", "Pennsylvania"}, {"RI", "Rhode Island"}, {"SC", "South Carolina"},
|
||||
{"SD", "South Dakota"}, {"TN", "Tennessee"}, {"TX", "Texas"}, {"UT", "Utah"},
|
||||
{"VT", "Vermont"}, {"VA", "Virginia"}, {"WA", "Washington"}, {"WV", "West Virginia"},
|
||||
{"WI", "Wisconsin"}, {"WY", "Wyoming"},
|
||||
}
|
||||
refs := make([]Ref, 0, len(codes))
|
||||
for _, c := range codes {
|
||||
refs = append(refs, ref(c[0], c[1], us))
|
||||
}
|
||||
return Preset{Key: "us_states", Name: "US States (WAS)", Field: "state", DXCC: us, Refs: refs}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Award reference lists (Parks On The Air, SOTA summits, WWFF, IOTA…).
|
||||
-- Each row is one valid reference for an award, used to provide award totals,
|
||||
-- reference names, and (later) per-QSO reference assignment + per-DXCC filtering.
|
||||
-- Lists are downloaded/updated from each program's published file.
|
||||
CREATE TABLE IF NOT EXISTS award_references (
|
||||
award_code TEXT NOT NULL,
|
||||
ref_code TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
dxcc INTEGER NOT NULL DEFAULT 0,
|
||||
grp TEXT NOT NULL DEFAULT '',
|
||||
subgrp TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (award_code, ref_code)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_award_ref_dxcc ON award_references(award_code, dxcc);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Richer per-reference metadata, mirroring Log4OM's reference editor:
|
||||
-- a per-reference regexp, validity window, score/bonus, grid, alias, a
|
||||
-- "valid" flag, and a multi-DXCC list (JSON array) on top of the single
|
||||
-- primary dxcc kept for fast filtering.
|
||||
ALTER TABLE award_references ADD COLUMN dxcc_list TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE award_references ADD COLUMN pattern TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE award_references ADD COLUMN valid INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE award_references ADD COLUMN valid_from TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE award_references ADD COLUMN valid_to TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE award_references ADD COLUMN score INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE award_references ADD COLUMN bonus INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE award_references ADD COLUMN gridsquare TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE award_references ADD COLUMN alias TEXT NOT NULL DEFAULT '';
|
||||
@@ -1,6 +1,9 @@
|
||||
package dxcc
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The dxccByName table itself lives in dxcc_names_gen.go (generated by joining
|
||||
// cty.dat to the authoritative ARRL/ADIF entity list). cty.dat doesn't carry
|
||||
@@ -55,6 +58,23 @@ func NameForDXCC(n int) string {
|
||||
return strings.Title(name) //nolint:staticcheck // ASCII entity names
|
||||
}
|
||||
|
||||
// EntityNumberName pairs a DXCC entity number with its display name.
|
||||
type EntityNumberName struct {
|
||||
Num int
|
||||
Name string
|
||||
}
|
||||
|
||||
// AllEntities returns every known DXCC entity (number + display name), sorted by
|
||||
// number. Used to seed the DXCC award's reference list.
|
||||
func AllEntities() []EntityNumberName {
|
||||
out := make([]EntityNumberName, 0, len(nameByDXCC))
|
||||
for num := range nameByDXCC {
|
||||
out = append(out, EntityNumberName{Num: num, Name: NameForDXCC(num)})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Num < out[j].Num })
|
||||
return out
|
||||
}
|
||||
|
||||
// dxccByCanon is dxccByName re-keyed by the canonical entity form, built once.
|
||||
var dxccByCanon = func() map[string]int {
|
||||
m := make(map[string]int, len(dxccByName))
|
||||
|
||||
Reference in New Issue
Block a user