This commit is contained in:
2026-06-05 22:35:28 +02:00
parent 88623f55df
commit 51d3a734e8
21 changed files with 2613 additions and 153 deletions
+363 -1
View File
@@ -22,6 +22,7 @@ import (
"hamlog/internal/cat" "hamlog/internal/cat"
"hamlog/internal/clublog" "hamlog/internal/clublog"
"hamlog/internal/award" "hamlog/internal/award"
"hamlog/internal/awardref"
"hamlog/internal/cluster" "hamlog/internal/cluster"
"hamlog/internal/pota" "hamlog/internal/pota"
"hamlog/internal/db" "hamlog/internal/db"
@@ -92,6 +93,8 @@ const (
keyAudioMicGain = "audio.mic_gain" // mic 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 keyClublogCtyEnabled = "clublog.cty_exceptions" // "1" → apply ClubLog exceptions
@@ -329,6 +332,7 @@ type App struct {
dxcc *dxcc.Manager dxcc *dxcc.Manager
cluster *cluster.Manager cluster *cluster.Manager
pota *pota.Cache pota *pota.Cache
awardRefs *awardref.Repo
operating *operating.Repo operating *operating.Repo
udp *udp.Manager udp *udp.Manager
udpRepo *udp.Repo udpRepo *udp.Repo
@@ -515,6 +519,8 @@ func (a *App) startup(ctx context.Context) {
a.qso = qso.NewRepo(conn) a.qso = qso.NewRepo(conn)
a.settings = settings.NewStore(conn) a.settings = settings.NewStore(conn)
a.profiles = profile.NewRepo(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.operating = operating.NewRepo(conn)
a.udpRepo = udp.NewRepo(conn) a.udpRepo = udp.NewRepo(conn)
a.udp = udp.NewManager(a.udpRepo) a.udp = udp.NewManager(a.udpRepo)
@@ -1114,6 +1120,12 @@ func (a *App) DXCCForCountry(name string) int {
return dxcc.EntityDXCC(name) 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 // ComputeStationInfo resolves a station's structured metadata from the
// callsign (via cty.dat) and grid (via Maidenhead → lat/lon). The // callsign (via cty.dat) and grid (via Maidenhead → lat/lon). The
// frontend calls this whenever Callsign or Grid changes in the Station // frontend calls this whenever Callsign or Grid changes in the Station
@@ -1367,7 +1379,357 @@ func (a *App) GetAwards() ([]award.Result, error) {
} }
return "" 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 { func continentName(code string) string {
+19
View File
@@ -27,8 +27,10 @@ import {
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace, WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop, GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
QSOAudioBegin, QSOAudioCancel, QSOAudioBegin, QSOAudioCancel,
GetAwardDefs,
} from '../wailsjs/go/main/App'; } from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs';
import { EventsOn } from '../wailsjs/runtime/runtime'; import { EventsOn } from '../wailsjs/runtime/runtime';
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models'; import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -94,6 +96,7 @@ const emptyDetails: DetailsState = {
sat_name: '', sat_mode: '', sat_name: '', sat_mode: '',
contest_id: '', srx: undefined, stx: undefined, contest_id: '', srx: undefined, stx: undefined,
email: '', email: '',
award_refs: '',
}; };
function fmtDateUTC(s: any): string { function fmtDateUTC(s: any): string {
@@ -658,6 +661,19 @@ export default function App() {
}); });
myCallRef.current = (station.callsign || '').toUpperCase(); 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 === // === Clock ===
const [utcNow, setUtcNow] = useState(''); const [utcNow, setUtcNow] = useState('');
useEffect(() => { useEffect(() => {
@@ -1077,6 +1093,7 @@ export default function App() {
srx: details.srx, stx: details.stx, srx: details.srx, stx: details.stx,
email: details.email, email: details.email,
}; };
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
await AddQSO(payload); await AddQSO(payload);
resetEntry(); resetEntry();
await refresh(); await refresh();
@@ -1096,6 +1113,7 @@ export default function App() {
if (!locks.start) setQsoStartedAt(null); if (!locks.start) setQsoStartedAt(null);
if (!locks.end) setQsoEndedAt(null); if (!locks.end) setQsoEndedAt(null);
resetAutoFill(); resetAutoFill();
setWb(null); // clear the Worked-before grid for the just-cleared callsign
setLookupError(''); setLookupError('');
rstUserEditedRef.current = false; rstUserEditedRef.current = false;
applyModePreset(mode); applyModePreset(mode);
@@ -1106,6 +1124,7 @@ export default function App() {
qsl_msg: '', qsl_via: '', qsl_msg: '', qsl_via: '',
contest_id: '', srx: undefined, stx: undefined, contest_id: '', srx: undefined, stx: undefined,
email: '', email: '',
award_refs: '',
})); }));
} }
+431 -85
View File
@@ -1,134 +1,338 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Plus, Trash2, RotateCcw, Save } from 'lucide-react'; import { Plus, Trash2, RotateCcw, Save, Download, Loader2, Search } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox'; 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 { 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 = { export type AwardDef = {
code: string; name: string; field: string; pattern: string; code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
dxcc_filter: number[] | null; confirm: string[] | null; total: number; builtin?: 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 { type Preset = { key: string; name: string; field: string; dxcc: number; refs: AwardRef[] };
open: boolean;
onClose: () => void; const AWARD_TYPES = ['QSOFIELDS', 'REFERENCE', 'DXCC', 'GRID'];
onSaved: () => void; 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) { export function AwardEditor({ open, onClose, onSaved }: Props) {
const [defs, setDefs] = useState<AwardDef[]>([]); const [defs, setDefs] = useState<AwardDef[]>([]);
const [fields, setFields] = useState<string[]>([]); 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 [err, setErr] = useState('');
const loadMeta = () => GetAwardReferenceMeta()
.then((m) => setMeta(Object.fromEntries(((m ?? []) as RefMeta[]).map((x) => [x.code.toUpperCase(), x]))))
.catch(() => {});
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setErr(''); setErr('');
Promise.all([GetAwardDefs(), AwardFields()]) Promise.all([GetAwardDefs(), AwardFields(), GetAwardPresets(), ListCountries()])
.then(([d, f]) => { setDefs((d ?? []) as any); setFields((f ?? []) as any); }) .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))); .catch((e) => setErr(String(e?.message ?? e)));
loadMeta();
}, [open]); }, [open]);
const patch = (i: number, p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === i ? { ...d, ...p } : d))); const cur = defs[sel];
const addAward = () => setDefs((ds) => [...ds, { code: 'NEW', name: 'New award', field: 'dxcc', pattern: '', dxcc_filter: null, confirm: ['lotw', 'qsl'], total: 0 }]); const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
const removeAward = (i: number) => setDefs((ds) => ds.filter((_, j) => j !== i)); const toggleIn = (key: keyof AwardDef, v: string) => {
const arr = ((cur?.[key] as string[]) ?? []);
const toggleConfirm = (i: number, id: string) => { patch({ [key]: arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v] } as any);
const cur = defs[i].confirm ?? [];
patch(i, { confirm: cur.includes(id) ? cur.filter((c) => c !== id) : [...cur, id] });
}; };
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() { async function save() {
setErr(''); setErr('');
try { try {
// Normalise codes (uppercase, no blanks). const clean = defs.filter((d) => d.code.trim())
const clean = defs .map((d) => ({ ...d, code: d.code.trim().toUpperCase(), confirm: d.confirm ?? [], validate: d.validate ?? [] }));
.filter((d) => d.code.trim())
.map((d) => ({ ...d, code: d.code.trim().toUpperCase(), confirm: d.confirm ?? [] }));
await SaveAwardDefs(clean as any); await SaveAwardDefs(clean as any);
onSaved(); onSaved();
onClose(); onClose();
} catch (e: any) { setErr(String(e?.message ?? e)); } } catch (e: any) { setErr(String(e?.message ?? e)); }
} }
async function reset() { 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 ( return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}> <Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-4xl"> <DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogHeader className="px-6 py-4"> <DialogHeader className="px-5 py-3 border-b">
<DialogTitle>Edit awards</DialogTitle> <DialogTitle>Award management</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="px-6 pb-2"> <div className="grid grid-cols-[220px_1fr] min-h-0 overflow-hidden">
<p className="text-xs text-muted-foreground mb-3"> {/* Left: award list */}
Each award scans one QSO <strong>field</strong>. Leave <strong>pattern</strong> empty to use the whole field value, <div className="border-r flex flex-col min-h-0">
or enter a regular expression where <span className="font-mono">group&nbsp;1</span> is the reference e.g. scan <div className="p-2 border-b">
the <span className="font-mono">note</span> field with <span className="font-mono">{'D(\\d{1,2}[AB]?)'}</span> so <div className="relative">
"D74" counts department 74. <Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
</p> <Input className="h-7 pl-7 text-xs" placeholder="Search awards…" value={search} onChange={(e) => setSearch(e.target.value)} />
{err && <div className="text-xs text-destructive mb-2">{err}</div>} </div>
</div>
<div className="space-y-2 max-h-[55vh] overflow-auto pr-1"> <div className="flex-1 overflow-auto">
{defs.map((d, i) => ( {filtered.map(({ d, i }) => (
<div key={i} className="rounded-lg border border-border p-3 space-y-2 bg-card"> <button key={i} onClick={() => setSel(i)}
<div className="flex items-center gap-2"> className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
<Input className="h-8 w-24 font-mono font-semibold text-xs" value={d.code} i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
onChange={(e) => patch(i, { code: e.target.value })} placeholder="CODE" /> <span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-emerald-500')} />
<Input className="h-8 flex-1 text-sm" value={d.name} <span className="font-mono font-semibold shrink-0">{d.code}</span>
onChange={(e) => patch(i, { name: e.target.value })} placeholder="Award name" /> <span className="text-muted-foreground truncate">{d.name}</span>
{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> </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> <Button variant="ghost" size="sm" className="m-2 h-7 justify-start" onClick={addAward}>
</div> <Plus className="size-3.5 mr-1" /> New award
))}
</div>
<Button variant="outline" size="sm" className="h-8 mt-2" onClick={addAward}>
<Plus className="size-3.5 mr-1" /> Add award
</Button> </Button>
</div> </div>
<DialogFooter className="px-6 py-4 bg-transparent border-t-0"> {/* 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-5 py-3 border-t !flex-row">
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> Reset to defaults</Button> <Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> Reset to defaults</Button>
<div className="flex-1" /> <div className="flex-1" />
<Button variant="outline" onClick={onClose}>Cancel</Button> <Button variant="outline" onClick={onClose}>Cancel</Button>
@@ -138,3 +342,145 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
</Dialog> </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>
);
}
+14 -6
View File
@@ -8,12 +8,13 @@ import { AwardEditor } from '@/components/AwardEditor';
type BandCount = { band: string; worked: number; confirmed: number }; type BandCount = { band: string; worked: number; confirmed: number };
type AwardRef = { 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[]; bands: string[]; confirmed_bands: string[];
}; };
type AwardResult = { type AwardResult = {
code: string; name: string; dimension: string; code: string; name: string; dimension: string;
worked: number; confirmed: number; total: number; worked: number; confirmed: number; validated: number; total: number;
bands: BandCount[]; refs: AwardRef[]; bands: BandCount[]; refs: AwardRef[];
}; };
@@ -121,6 +122,7 @@ export function AwardsPanel() {
<div className="mt-1 flex items-center gap-4 text-sm"> <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-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-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 && ( {current.total > 0 && (
<span className="text-muted-foreground">of {current.total} · {pct(current.confirmed, current.total)}% confirmed</span> <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"> <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 w-24">Ref</th>
<th className="py-1 pr-2 font-medium">Name</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> <th className="py-1 font-medium">Bands</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredRefs.map((r) => ( {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 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"> <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="inline-flex items-center gap-1 text-emerald-600"><CheckCircle2 className="size-3" /> conf.</span>
) : ( ) : (
<span className="text-amber-600">worked</span> <span className="text-amber-600">worked</span>
+11 -5
View File
@@ -1,5 +1,4 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Construction } from 'lucide-react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
@@ -9,6 +8,7 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { pathBetween } from '@/lib/maidenhead'; import { pathBetween } from '@/lib/maidenhead';
import { BandSlotGrid } from '@/components/BandSlotGrid'; import { BandSlotGrid } from '@/components/BandSlotGrid';
import { AwardRefSelector } from '@/components/AwardRefSelector';
export interface DetailsState { export interface DetailsState {
state: string; state: string;
@@ -37,6 +37,10 @@ export interface DetailsState {
srx?: number; srx?: number;
stx?: number; stx?: number;
email: string; 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 { 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) { 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 [internalOpen, setInternalOpen] = useState<TabName>('stats');
const open = tab ?? internalOpen; // controlled when `tab` is provided const open = tab ?? internalOpen; // controlled when `tab` is provided
// Bearing/distance from operator's home grid to the remote station. // Bearing/distance from operator's home grid to the remote station.
// Recomputed only when either grid actually changes. // Recomputed only when either grid actually changes.
const path = useMemo( const path = useMemo(
@@ -197,9 +200,12 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
)} )}
{open === 'awards' && ( {open === 'awards' && (
<div className="px-4 py-6 text-center text-xs text-muted-foreground"> <div className="px-3 py-2.5">
<Construction className="size-6 mx-auto mb-2 text-muted-foreground/60" /> <AwardRefSelector
<div className="font-semibold text-sm text-foreground/70">Awards module coming soon</div> dxcc={details.dxcc}
value={details.award_refs ?? ''}
onChange={(v) => onChange({ award_refs: v })}
/>
</div> </div>
)} )}
+81 -5
View File
@@ -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 { 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 { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
@@ -166,6 +168,47 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [looking, setLooking] = 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]) { function set<K extends keyof QSO>(key: K, value: QSO[K]) {
setDraft((d) => ({ ...d, [key]: value })); setDraft((d) => ({ ...d, [key]: value }));
} }
@@ -228,9 +271,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
operator: (draft.operator ?? '').trim().toUpperCase(), operator: (draft.operator ?? '').trim().toUpperCase(),
my_grid: (draft.my_grid ?? '').trim().toUpperCase(), my_grid: (draft.my_grid ?? '').trim().toUpperCase(),
my_gridsquare_ext: (draft.my_gridsquare_ext ?? '').trim().toUpperCase(), my_gridsquare_ext: (draft.my_gridsquare_ext ?? '').trim().toUpperCase(),
iota: (draft.iota ?? '').trim().toUpperCase(), // iota / sota_ref / pota_ref are set below from the Award Refs tab.
sota_ref: (draft.sota_ref ?? '').trim().toUpperCase(),
pota_ref: (draft.pota_ref ?? '').trim().toUpperCase(),
my_iota: (draft.my_iota ?? '').trim().toUpperCase(), my_iota: (draft.my_iota ?? '').trim().toUpperCase(),
my_sota_ref: (draft.my_sota_ref ?? '').trim().toUpperCase(), my_sota_ref: (draft.my_sota_ref ?? '').trim().toUpperCase(),
my_pota_ref: (draft.my_pota_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), tx_pwr: numOrUndef(draft.tx_pwr),
extras: parseExtras(extrasText), 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); onSave(out);
} }
@@ -283,6 +329,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
<TabsList className="px-3 overflow-x-auto"> <TabsList className="px-3 overflow-x-auto">
<TabsTrigger value="qsoinfo">QSO Info</TabsTrigger> <TabsTrigger value="qsoinfo">QSO Info</TabsTrigger>
<TabsTrigger value="contact">Contact's details</TabsTrigger> <TabsTrigger value="contact">Contact's details</TabsTrigger>
<TabsTrigger value="awards">Award Refs</TabsTrigger>
<TabsTrigger value="qsl">QSL Info</TabsTrigger> <TabsTrigger value="qsl">QSL Info</TabsTrigger>
<TabsTrigger value="contest">Contest</TabsTrigger> <TabsTrigger value="contest">Contest</TabsTrigger>
<TabsTrigger value="sat">Sat / Prop</TabsTrigger> <TabsTrigger value="sat">Sat / Prop</TabsTrigger>
@@ -411,6 +458,35 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
</div> </div>
</TabsContent> </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"> <TabsContent value="qsl" className="mt-0">
{(() => { {(() => {
const def = CONFIRMATIONS.find((c) => c.key === confSel) ?? CONFIRMATIONS[0]; const def = CONFIRMATIONS.find((c) => c.key === confSel) ?? CONFIRMATIONS[0];
+98
View File
@@ -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(';');
}
+29
View File
@@ -5,6 +5,7 @@ import {main} from '../models';
import {profile} from '../models'; import {profile} from '../models';
import {adif} from '../models'; import {adif} from '../models';
import {award} from '../models'; import {award} from '../models';
import {awardref} from '../models';
import {cat} from '../models'; import {cat} from '../models';
import {cluster} from '../models'; import {cluster} from '../models';
import {extsvc} 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 AddQSO(arg1:qso.QSO):Promise<number>;
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
export function AwardFields():Promise<Array<string>>; export function AwardFields():Promise<Array<string>>;
export function ClearLookupCache():Promise<void>; export function ClearLookupCache():Promise<void>;
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>; 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 ComputeStationInfo(arg1:string,arg2:string):Promise<main.StationInfoComputed>;
export function ConnectAllClusters():Promise<void>; export function ConnectAllClusters():Promise<void>;
@@ -50,8 +55,12 @@ export function DVKStopRecord():Promise<void>;
export function DXCCForCountry(arg1:string):Promise<number>; export function DXCCForCountry(arg1:string):Promise<number>;
export function DXCCName(arg1:number):Promise<string>;
export function DeleteAllQSO():Promise<number>; export function DeleteAllQSO():Promise<number>;
export function DeleteAwardReference(arg1:string,arg2:string):Promise<void>;
export function DeleteClusterServer(arg1:number):Promise<void>; export function DeleteClusterServer(arg1:number):Promise<void>;
export function DeleteOperatingAntenna(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 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 GetAwards():Promise<Array<award.Result>>;
export function GetBackupSettings():Promise<main.BackupSettings>; export function GetBackupSettings():Promise<main.BackupSettings>;
@@ -140,12 +153,18 @@ export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
export function GetWinkeyerStatus():Promise<winkeyer.Status>; 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 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 ListAudioInputDevices():Promise<Array<audio.Device>>;
export function ListAudioOutputDevices():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 ListClusterServers():Promise<Array<cluster.ServerConfig>>;
export function ListCountries():Promise<Array<string>>; export function ListCountries():Promise<Array<string>>;
@@ -186,6 +205,8 @@ export function PickOpenDatabase():Promise<string>;
export function PickSaveDatabase():Promise<string>; export function PickSaveDatabase():Promise<string>;
export function PopulateBuiltinReferences(arg1:string):Promise<number>;
export function QSOAudioBegin():Promise<boolean>; export function QSOAudioBegin():Promise<boolean>;
export function QSOAudioCancel():Promise<void>; export function QSOAudioCancel():Promise<void>;
@@ -196,6 +217,8 @@ export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function ReloadUDPIntegrations():Promise<Array<string>>; 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 ResetAwardDefs():Promise<Array<award.Def>>;
export function ResetDatabaseToDefault():Promise<void>; 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 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 SaveBackupSettings(arg1:main.BackupSettings):Promise<void>;
export function SaveCATSettings(arg1:main.CATSettings):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 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 SendClusterCommand(arg1:string):Promise<void>;
export function SendClusterSpot(arg1:string,arg2:number,arg3: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 TestRotator(arg1:main.RotatorSettings):Promise<void>;
export function UpdateAwardReferenceList(arg1:string):Promise<main.AwardRefMeta>;
export function UpdateQSO(arg1:qso.QSO):Promise<void>; export function UpdateQSO(arg1:qso.QSO):Promise<void>;
export function UpdateQSOsFromClublog(arg1:Array<number>):Promise<number>; export function UpdateQSOsFromClublog(arg1:Array<number>):Promise<number>;
+56
View File
@@ -10,6 +10,10 @@ export function AddQSO(arg1) {
return window['go']['main']['App']['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() { export function AwardFields() {
return window['go']['main']['App']['AwardFields'](); return window['go']['main']['App']['AwardFields']();
} }
@@ -22,6 +26,10 @@ export function ClusterSpotStatuses(arg1) {
return window['go']['main']['App']['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) { export function ComputeStationInfo(arg1, arg2) {
return window['go']['main']['App']['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); return window['go']['main']['App']['DXCCForCountry'](arg1);
} }
export function DXCCName(arg1) {
return window['go']['main']['App']['DXCCName'](arg1);
}
export function DeleteAllQSO() { export function DeleteAllQSO() {
return window['go']['main']['App']['DeleteAllQSO'](); return window['go']['main']['App']['DeleteAllQSO']();
} }
export function DeleteAwardReference(arg1, arg2) {
return window['go']['main']['App']['DeleteAwardReference'](arg1, arg2);
}
export function DeleteClusterServer(arg1) { export function DeleteClusterServer(arg1) {
return window['go']['main']['App']['DeleteClusterServer'](arg1); return window['go']['main']['App']['DeleteClusterServer'](arg1);
} }
@@ -154,6 +170,14 @@ export function GetAwardDefs() {
return window['go']['main']['App']['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() { export function GetAwards() {
return window['go']['main']['App']['GetAwards'](); return window['go']['main']['App']['GetAwards']();
} }
@@ -254,10 +278,18 @@ export function GetWinkeyerStatus() {
return window['go']['main']['App']['GetWinkeyerStatus'](); return window['go']['main']['App']['GetWinkeyerStatus']();
} }
export function HasBuiltinReferences(arg1) {
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
}
export function ImportADIF(arg1, arg2, arg3) { export function ImportADIF(arg1, arg2, arg3) {
return window['go']['main']['App']['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() { export function ListAudioInputDevices() {
return window['go']['main']['App']['ListAudioInputDevices'](); return window['go']['main']['App']['ListAudioInputDevices']();
} }
@@ -266,6 +298,10 @@ export function ListAudioOutputDevices() {
return window['go']['main']['App']['ListAudioOutputDevices'](); return window['go']['main']['App']['ListAudioOutputDevices']();
} }
export function ListAwardReferences(arg1) {
return window['go']['main']['App']['ListAwardReferences'](arg1);
}
export function ListClusterServers() { export function ListClusterServers() {
return window['go']['main']['App']['ListClusterServers'](); return window['go']['main']['App']['ListClusterServers']();
} }
@@ -346,6 +382,10 @@ export function PickSaveDatabase() {
return window['go']['main']['App']['PickSaveDatabase'](); return window['go']['main']['App']['PickSaveDatabase']();
} }
export function PopulateBuiltinReferences(arg1) {
return window['go']['main']['App']['PopulateBuiltinReferences'](arg1);
}
export function QSOAudioBegin() { export function QSOAudioBegin() {
return window['go']['main']['App']['QSOAudioBegin'](); return window['go']['main']['App']['QSOAudioBegin']();
} }
@@ -366,6 +406,10 @@ export function ReloadUDPIntegrations() {
return window['go']['main']['App']['ReloadUDPIntegrations'](); return window['go']['main']['App']['ReloadUDPIntegrations']();
} }
export function ReplaceAwardReferences(arg1, arg2) {
return window['go']['main']['App']['ReplaceAwardReferences'](arg1, arg2);
}
export function ResetAwardDefs() { export function ResetAwardDefs() {
return window['go']['main']['App']['ResetAwardDefs'](); return window['go']['main']['App']['ResetAwardDefs']();
} }
@@ -406,6 +450,10 @@ export function SaveAwardDefs(arg1) {
return window['go']['main']['App']['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) { export function SaveBackupSettings(arg1) {
return window['go']['main']['App']['SaveBackupSettings'](arg1); return window['go']['main']['App']['SaveBackupSettings'](arg1);
} }
@@ -466,6 +514,10 @@ export function SaveWinkeyerSettings(arg1) {
return window['go']['main']['App']['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) { export function SendClusterCommand(arg1) {
return window['go']['main']['App']['SendClusterCommand'](arg1); return window['go']['main']['App']['SendClusterCommand'](arg1);
} }
@@ -538,6 +590,10 @@ export function TestRotator(arg1) {
return window['go']['main']['App']['TestRotator'](arg1); return window['go']['main']['App']['TestRotator'](arg1);
} }
export function UpdateAwardReferenceList(arg1) {
return window['go']['main']['App']['UpdateAwardReferenceList'](arg1);
}
export function UpdateQSO(arg1) { export function UpdateQSO(arg1) {
return window['go']['main']['App']['UpdateQSO'](arg1); return window['go']['main']['App']['UpdateQSO'](arg1);
} }
+171
View File
@@ -85,10 +85,33 @@ export namespace award {
export class Def { export class Def {
code: string; code: string;
name: 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; field: string;
match_by?: string;
exact_match?: boolean;
pattern: string; pattern: string;
leading_str?: string;
trailing_str?: string;
multi?: boolean;
dynamic?: boolean;
add_prefixes?: string[];
dxcc_filter: number[]; dxcc_filter: number[];
valid_bands?: string[];
valid_modes?: string[];
emission?: string[];
confirm: string[]; confirm: string[];
validate?: string[];
grant_codes?: string;
export_credit_granted?: boolean;
total: number; total: number;
builtin: boolean; builtin: boolean;
@@ -100,10 +123,33 @@ export namespace award {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.code = source["code"]; this.code = source["code"];
this.name = source["name"]; 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.field = source["field"];
this.match_by = source["match_by"];
this.exact_match = source["exact_match"];
this.pattern = source["pattern"]; 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.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.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.total = source["total"];
this.builtin = source["builtin"]; this.builtin = source["builtin"];
} }
@@ -111,8 +157,11 @@ export namespace award {
export class Ref { export class Ref {
ref: string; ref: string;
name?: string; name?: string;
group?: string;
subgrp?: string;
worked: boolean; worked: boolean;
confirmed: boolean; confirmed: boolean;
validated: boolean;
bands: string[]; bands: string[];
confirmed_bands: string[]; confirmed_bands: string[];
@@ -124,8 +173,11 @@ export namespace award {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.ref = source["ref"]; this.ref = source["ref"];
this.name = source["name"]; this.name = source["name"];
this.group = source["group"];
this.subgrp = source["subgrp"];
this.worked = source["worked"]; this.worked = source["worked"];
this.confirmed = source["confirmed"]; this.confirmed = source["confirmed"];
this.validated = source["validated"];
this.bands = source["bands"]; this.bands = source["bands"];
this.confirmed_bands = source["confirmed_bands"]; this.confirmed_bands = source["confirmed_bands"];
} }
@@ -136,6 +188,7 @@ export namespace award {
field: string; field: string;
worked: number; worked: number;
confirmed: number; confirmed: number;
validated: number;
total: number; total: number;
bands: BandCount[]; bands: BandCount[];
refs: Ref[]; refs: Ref[];
@@ -152,6 +205,7 @@ export namespace award {
this.field = source["field"]; this.field = source["field"];
this.worked = source["worked"]; this.worked = source["worked"];
this.confirmed = source["confirmed"]; this.confirmed = source["confirmed"];
this.validated = source["validated"];
this.total = source["total"]; this.total = source["total"];
this.bands = this.convertValues(source["bands"], BandCount); this.bands = this.convertValues(source["bands"], BandCount);
this.refs = this.convertValues(source["refs"], Ref); 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 namespace cat {
export class RigState { export class RigState {
@@ -523,6 +658,24 @@ export namespace main {
this.mic_gain = source["mic_gain"]; 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 { export class BackupSettings {
enabled: boolean; enabled: boolean;
folder: string; folder: string;
@@ -796,6 +949,24 @@ export namespace main {
this.qrzcom_confirmed = source["qrzcom_confirmed"]; 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 { export class RotatorHeading {
enabled: boolean; enabled: boolean;
ok: boolean; ok: boolean;
+327 -43
View File
@@ -21,31 +21,83 @@ import (
"hamlog/internal/qso" "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 { type Def struct {
// --- Identity ---
Code string `json:"code"` // unique key, e.g. "DXCC" Code string `json:"code"` // unique key, e.g. "DXCC"
Name string `json:"name"` // friendly name 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) Field string `json:"field"` // QSO field to scan (see fieldRaw)
Pattern string `json:"pattern"` // optional Go regexp; group 1 = reference 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) DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
Confirm []string `json:"confirm"` // accepted confirmations: lotw|qsl|eqsl ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands
Total int `json:"total"` // known denominator (0 = unknown) 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) Builtin bool `json:"builtin"` // shipped default (informational)
} }
// Defaults are the built-in awards seeded on first run (then user-editable). // Defaults are the built-in awards seeded on first run (then user-editable).
func Defaults() []Def { func Defaults() []Def {
lq := []string{"lotw", "qsl"}
return []Def{ return []Def{
{Code: "DXCC", Name: "DX Century Club", Field: "dxcc", Confirm: []string{"lotw", "qsl"}, Total: 340, 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", Field: "state", DXCCFilter: []int{291, 110, 6}, Confirm: []string{"lotw", "qsl"}, Total: 50, Builtin: 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)", Field: "cqz", Confirm: []string{"lotw", "qsl"}, Total: 40, Builtin: 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", Field: "cont", Confirm: []string{"lotw", "qsl", "eqsl"}, Total: 6, Builtin: 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)", Field: "prefix", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: 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", Field: "note", Pattern: `(?i)\bD(\d{1,2}[AB]?)\b`, DXCCFilter: []int{227}, Confirm: []string{"lotw", "qsl"}, Total: 96, Builtin: 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", Field: "iota", Confirm: []string{"qsl"}, Total: 0, Builtin: 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", Field: "pota_ref", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: 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", Field: "sota_ref", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: 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", Field: "wwff", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: 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 { type Ref struct {
Ref string `json:"ref"` Ref string `json:"ref"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Group string `json:"group,omitempty"`
SubGrp string `json:"subgrp,omitempty"`
Worked bool `json:"worked"` Worked bool `json:"worked"`
Confirmed bool `json:"confirmed"` Confirmed bool `json:"confirmed"`
Validated bool `json:"validated"`
Bands []string `json:"bands"` Bands []string `json:"bands"`
ConfirmedBands []string `json:"confirmed_bands"` ConfirmedBands []string `json:"confirmed_bands"`
} }
@@ -83,6 +138,7 @@ type Result struct {
Field string `json:"field"` Field string `json:"field"`
Worked int `json:"worked"` Worked int `json:"worked"`
Confirmed int `json:"confirmed"` Confirmed int `json:"confirmed"`
Validated int `json:"validated"`
Total int `json:"total"` Total int `json:"total"`
Bands []BandCount `json:"bands"` Bands []BandCount `json:"bands"`
Refs []Ref `json:"refs"` Refs []Ref `json:"refs"`
@@ -96,11 +152,63 @@ type refAgg struct {
bands map[string]struct{} bands map[string]struct{}
confirmedBands map[string]struct{} confirmedBands map[string]struct{}
anyConfirmed bool anyConfirmed bool
anyValidated bool
} }
// Compute runs every definition over the QSOs in a single pass. // refList is the per-award reference data Compute needs (a thin view of
func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result { // awardref.Ref, kept local so the award package stays storage-agnostic).
// Pre-compile patterns once per award (not per QSO). 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)) res := make([]*regexp.Regexp, len(defs))
perr := make([]string, len(defs)) perr := make([]string, len(defs))
for i := range defs { for i := range defs {
@@ -123,18 +231,17 @@ func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
q := &qsos[qi] q := &qsos[qi]
for i := range defs { for i := range defs {
d := &defs[i] d := &defs[i]
if perr[i] != "" { if perr[i] != "" || !inScope(d, q) {
continue continue
} }
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) { rl, hasList := refLists[strings.ToUpper(d.Code)]
continue refs := candidates(d, res[i], q, rl, hasList)
}
refs := refValues(d, res[i], q)
if len(refs) == 0 { if len(refs) == 0 {
continue continue
} }
band := strings.ToLower(strings.TrimSpace(q.Band)) band := strings.ToLower(strings.TrimSpace(q.Band))
isConf := confirmed(q, d.Confirm) isConf := confirmed(q, d.Confirm)
isVal := confirmed(q, d.Validate)
for _, ref := range refs { for _, ref := range refs {
a := agg[i][ref] a := agg[i][ref]
if a == nil { if a == nil {
@@ -150,6 +257,9 @@ func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
a.confirmedBands[band] = struct{}{} 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)) out := make([]Result, len(defs))
for i := range defs { for i := range defs {
d := &defs[i] 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]} r := Result{Code: d.Code, Name: d.Name, Field: d.Field, Total: d.Total, Error: perr[i]}
bandWorked := map[string]int{} bandWorked := map[string]int{}
bandConfirmed := map[string]int{} bandConfirmed := map[string]int{}
@@ -165,10 +277,12 @@ func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
if a.anyConfirmed { if a.anyConfirmed {
r.Confirmed++ r.Confirmed++
} }
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands)} if a.anyValidated {
if nameOf != nil { r.Validated++
rf.Name = nameOf(d.Field, ref)
} }
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) r.Refs = append(r.Refs, rf)
for b := range a.bands { for b := range a.bands {
bandWorked[b]++ bandWorked[b]++
@@ -177,7 +291,29 @@ func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
bandConfirmed[b]++ 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 { 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 { if r.Refs[a].Confirmed != r.Refs[b].Confirmed {
return r.Refs[a].Confirmed return r.Refs[a].Confirmed
} }
@@ -191,41 +327,189 @@ func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
return out return out
} }
// refValues extracts the reference(s) a QSO contributes to an award. // labelRef fills a worked reference's name/group from the reference list (or the
func refValues(d *Def, re *regexp.Regexp, q *qso.QSO) []string { // name resolver as a fallback).
raw := fieldRaw(d.Field, q) func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) {
if strings.TrimSpace(raw) == "" { 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 return nil
} }
if re == nil { predefined := hasList && !d.Dynamic
return []string{normalizeRef(raw)}
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)
} }
matches := re.FindAllStringSubmatch(raw, -1)
if len(matches) == 0 {
return nil
} }
seen := map[string]struct{}{} default:
// Whole field value is the candidate.
found = []string{normalizeRef(raw)}
}
if !predefined {
return dedupe(found)
}
// Enforce the predefined list: keep only listed, valid references whose
// per-reference DXCC scope (if any) includes the QSO's entity.
var out []string 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 { for _, m := range matches {
ref := m[0] ref := m[0]
if len(m) > 1 && m[1] != "" { if len(m) > 1 && m[1] != "" {
ref = m[1] ref = m[1]
} }
ref = normalizeRef(ref) if ref = normalizeRef(ref); ref != "" {
if ref == "" {
continue
}
if _, dup := seen[ref]; dup {
continue
}
seen[ref] = struct{}{}
out = append(out, 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
}
seen[s] = struct{}{}
out = append(out, s)
}
return out 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)) } 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 / // fieldRaw returns the raw string value of a QSO field (computed for numeric /
// derived fields). Unknown fields yield "". // derived fields). Unknown fields yield "".
func fieldRaw(field string, q *qso.QSO) string { func fieldRaw(field string, q *qso.QSO) string {
+35 -1
View File
@@ -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: "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 {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{} by := map[string]Result{}
for _, r := range res { for _, r := range res {
by[r.Code] = r by[r.Code] = r
@@ -73,3 +73,37 @@ func refCodes(r Result) []string {
} }
return out 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))
}
}
+282
View File
@@ -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
}
+99
View File
@@ -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
}
+161
View File
@@ -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
}
+77
View File
@@ -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 '';
+21 -1
View File
@@ -1,6 +1,9 @@
package dxcc package dxcc
import "strings" import (
"sort"
"strings"
)
// The dxccByName table itself lives in dxcc_names_gen.go (generated by joining // 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 // 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 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. // dxccByCanon is dxccByName re-keyed by the canonical entity form, built once.
var dxccByCanon = func() map[string]int { var dxccByCanon = func() map[string]int {
m := make(map[string]int, len(dxccByName)) m := make(map[string]int, len(dxccByName))