feat: Contest mode added
This commit is contained in:
@@ -29,6 +29,7 @@ import (
|
|||||||
"hamlog/internal/cat"
|
"hamlog/internal/cat"
|
||||||
"hamlog/internal/clublog"
|
"hamlog/internal/clublog"
|
||||||
"hamlog/internal/cluster"
|
"hamlog/internal/cluster"
|
||||||
|
"hamlog/internal/contest"
|
||||||
"hamlog/internal/cwdecode"
|
"hamlog/internal/cwdecode"
|
||||||
"hamlog/internal/db"
|
"hamlog/internal/db"
|
||||||
"hamlog/internal/dxcc"
|
"hamlog/internal/dxcc"
|
||||||
@@ -3768,6 +3769,12 @@ func (a *App) writeCabrillo(path string, iterate func(func(qso.QSO) error) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ExportCabrillo writes every QSO to a Cabrillo file.
|
// ExportCabrillo writes every QSO to a Cabrillo file.
|
||||||
|
// ListContests returns the built-in contest catalogue (ADIF CONTEST_ID + default
|
||||||
|
// exchange), sorted by name, for the contest-mode picker.
|
||||||
|
func (a *App) ListContests() []contest.Def {
|
||||||
|
return contest.List()
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) ExportCabrillo(path string) (CabrilloResult, error) {
|
func (a *App) ExportCabrillo(path string) (CabrilloResult, error) {
|
||||||
return a.writeCabrillo(path, func(fn func(qso.QSO) error) error { return a.qso.IterateAll(a.ctx, fn) })
|
return a.writeCabrillo(path, func(fn func(qso.QSO) error) error { return a.qso.IterateAll(a.ctx, fn) })
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-6
@@ -68,6 +68,7 @@ import { ClusterGrid } from '@/components/ClusterGrid';
|
|||||||
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
||||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||||
import { NetControlPanel } from '@/components/NetControlPanel';
|
import { NetControlPanel } from '@/components/NetControlPanel';
|
||||||
|
import { ContestBar, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestBar';
|
||||||
import { AlertsModal } from '@/components/AlertsModal';
|
import { AlertsModal } from '@/components/AlertsModal';
|
||||||
import { BulkEditModal } from '@/components/BulkEditModal';
|
import { BulkEditModal } from '@/components/BulkEditModal';
|
||||||
import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover';
|
import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover';
|
||||||
@@ -339,6 +340,11 @@ export default function App() {
|
|||||||
const [grid, setGrid] = useState('');
|
const [grid, setGrid] = useState('');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [qth, setQth] = useState('');
|
const [qth, setQth] = useState('');
|
||||||
|
// Contest mode: a picked contest + exchange config; persisted so a running
|
||||||
|
// contest (serial counter included) survives a restart. `rcv` is the received
|
||||||
|
// exchange for the current QSO.
|
||||||
|
const [contest, setContest] = useState<ContestSession>(CONTEST_DEFAULT);
|
||||||
|
const [rcv, setRcv] = useState('');
|
||||||
const [country, setCountry] = useState('');
|
const [country, setCountry] = useState('');
|
||||||
const [comment, setComment] = useState('');
|
const [comment, setComment] = useState('');
|
||||||
const [note, setNote] = useState('');
|
const [note, setNote] = useState('');
|
||||||
@@ -576,6 +582,20 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
useEffect(() => { writeUiPref('hamlog.qsoLimit', String(qsoLimit)); }, [qsoLimit]);
|
useEffect(() => { writeUiPref('hamlog.qsoLimit', String(qsoLimit)); }, [qsoLimit]);
|
||||||
|
|
||||||
|
// Contest session: load once, then persist on every change (merge + save).
|
||||||
|
useEffect(() => {
|
||||||
|
GetUIPref('opslog.contest').then((s) => {
|
||||||
|
if (s) { try { setContest({ ...CONTEST_DEFAULT, ...JSON.parse(s) }); } catch {} }
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
const updateContest = (patch: Partial<ContestSession>) => {
|
||||||
|
setContest((c) => {
|
||||||
|
const next = { ...c, ...patch };
|
||||||
|
writeUiPref('opslog.contest', JSON.stringify(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// === DX Cluster live state ===
|
// === DX Cluster live state ===
|
||||||
type ClusterSpot = {
|
type ClusterSpot = {
|
||||||
source_id: number;
|
source_id: number;
|
||||||
@@ -1833,7 +1853,23 @@ export default function App() {
|
|||||||
email: details.email,
|
email: details.email,
|
||||||
};
|
};
|
||||||
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
|
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
|
||||||
|
// Contest mode overrides the exchange fields: CONTEST_ID, the sent
|
||||||
|
// exchange (running serial or fixed value) and the received exchange.
|
||||||
|
if (contest.active && contest.code) {
|
||||||
|
const sent = contest.exchange === 'serial' ? String(contest.nextSerial) : contest.sentFixed;
|
||||||
|
const sE = exchangeFields(sent);
|
||||||
|
const rE = exchangeFields(rcv.trim());
|
||||||
|
Object.assign(payload, {
|
||||||
|
contest_id: contest.code,
|
||||||
|
stx: sE.num, stx_string: sE.str,
|
||||||
|
srx: rE.num, srx_string: rE.str,
|
||||||
|
});
|
||||||
|
}
|
||||||
await AddQSO(payload);
|
await AddQSO(payload);
|
||||||
|
// Advance the sent serial only after a successful log.
|
||||||
|
if (contest.active && contest.code && contest.exchange === 'serial') {
|
||||||
|
updateContest({ nextSerial: contest.nextSerial + 1 });
|
||||||
|
}
|
||||||
resetEntry(); // clears the call AND the Worked-before matrix
|
resetEntry(); // clears the call AND the Worked-before matrix
|
||||||
callsignRef.current?.focus(); // return focus to the call field, wherever it was (e.g. Name)
|
callsignRef.current?.focus(); // return focus to the call field, wherever it was (e.g. Name)
|
||||||
await refresh();
|
await refresh();
|
||||||
@@ -1849,7 +1885,7 @@ export default function App() {
|
|||||||
// Discard any in-progress QSO recording (no-op if it was already saved on
|
// Discard any in-progress QSO recording (no-op if it was already saved on
|
||||||
// log, or if the recorder is off).
|
// log, or if the recorder is off).
|
||||||
QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
|
QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
|
||||||
setCallsign(''); setComment(''); setNote('');
|
setCallsign(''); setComment(''); setNote(''); setRcv('');
|
||||||
if (!locks.start) setQsoStartedAt(null);
|
if (!locks.start) setQsoStartedAt(null);
|
||||||
if (!locks.end) setQsoEndedAt(null);
|
if (!locks.end) setQsoEndedAt(null);
|
||||||
resetAutoFill();
|
resetAutoFill();
|
||||||
@@ -2539,7 +2575,22 @@ export default function App() {
|
|||||||
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
|
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const qthBlock = (
|
// In contest mode QTH is replaced by the sent (read-only, auto) and received
|
||||||
|
// exchange fields — the exchange is what matters, not the town.
|
||||||
|
const contestSnt = contest.exchange === 'serial'
|
||||||
|
? String(contest.nextSerial).padStart(3, '0')
|
||||||
|
: (contest.sentFixed || '—');
|
||||||
|
const qthBlock = contest.active ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col w-16 shrink-0"><Label className="mb-1 h-3.5">Snt</Label>
|
||||||
|
<Input readOnly tabIndex={-1} value={contestSnt} className="font-mono bg-muted/40" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col w-24 shrink-0"><Label className="mb-1 h-3.5 text-amber-600 font-bold">Rcv</Label>
|
||||||
|
<Input value={rcv} placeholder="exch" className="font-mono"
|
||||||
|
onChange={(e) => setRcv(e.target.value.toUpperCase())} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<div className="flex flex-col flex-[0.55] min-w-[70px]"><Label className="mb-1 h-3.5">QTH</Label>
|
<div className="flex flex-col flex-[0.55] min-w-[70px]"><Label className="mb-1 h-3.5">QTH</Label>
|
||||||
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
|
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -2602,7 +2653,7 @@ export default function App() {
|
|||||||
// CQ/ITU zones moved to the Info (F2) tab (DetailsPanel).
|
// CQ/ITU zones moved to the Info (F2) tab (DetailsPanel).
|
||||||
const freqBlock = (
|
const freqBlock = (
|
||||||
<div className="flex flex-col w-32">
|
<div className="flex flex-col w-32">
|
||||||
<Label className="mb-1 h-3.5 flex items-center gap-1">{catState.split ? 'TX Freq (MHz)' : 'Freq (MHz)'} <LockBtn k="freq" title="frequency" /></Label>
|
<Label className="mb-1 h-3.5 flex items-center gap-1">TX Freq (MHz) <LockBtn k="freq" title="frequency" /></Label>
|
||||||
<Input
|
<Input
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
@@ -3423,9 +3474,7 @@ export default function App() {
|
|||||||
<div className="flex flex-col w-[300px] shrink-0"><Label className="mb-1 h-3.5">Name</Label>
|
<div className="flex flex-col w-[300px] shrink-0"><Label className="mb-1 h-3.5">Name</Label>
|
||||||
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
|
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col flex-1 min-w-[70px]"><Label className="mb-1 h-3.5">QTH</Label>
|
{qthBlock}
|
||||||
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
|
|
||||||
</div>
|
|
||||||
{gridBlock}
|
{gridBlock}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -3638,6 +3687,7 @@ export default function App() {
|
|||||||
|
|
||||||
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
|
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
|
||||||
{compact ? null : <>
|
{compact ? null : <>
|
||||||
|
<ContestBar session={contest} onChange={updateContest} />
|
||||||
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
|
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
|
||||||
showBandMap ? (bandMapSide === 'left' ? 'grid-cols-[300px_1fr]' : 'grid-cols-[1fr_300px]') : 'grid-cols-[1fr]')}>
|
showBandMap ? (bandMapSide === 'left' ? 'grid-cols-[300px_1fr]' : 'grid-cols-[1fr_300px]') : 'grid-cols-[1fr]')}>
|
||||||
<section className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
|
<section className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ListContests } from '../../wailsjs/go/main/App';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ContestSession is the live contest-mode state, persisted as JSON in a UI pref
|
||||||
|
// so a running contest survives a restart (serial counter included).
|
||||||
|
export type ContestSession = {
|
||||||
|
active: boolean;
|
||||||
|
code: string; // ADIF CONTEST_ID written to each QSO
|
||||||
|
name: string; // display name
|
||||||
|
exchange: 'serial' | 'fixed';
|
||||||
|
fixedKind: string; // label for the fixed exchange (CQ Zone, Département…)
|
||||||
|
sentFixed: string; // the value YOU send when the exchange is fixed
|
||||||
|
nextSerial: number; // running sent serial number
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CONTEST_DEFAULT: ContestSession = {
|
||||||
|
active: false, code: '', name: '', exchange: 'serial',
|
||||||
|
fixedKind: 'CQ Zone', sentFixed: '', nextSerial: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FIXED_KINDS = ['CQ Zone', 'ITU Zone', 'State/Prov', 'Département', 'Grid', 'Name', 'Age', 'Section', 'Custom'];
|
||||||
|
|
||||||
|
// Map the catalogue's default exchange hint onto a fixed-kind label.
|
||||||
|
const KIND_LABEL: Record<string, string> = {
|
||||||
|
'cq-zone': 'CQ Zone', 'itu-zone': 'ITU Zone', 'state': 'State/Prov',
|
||||||
|
'dept': 'Département', 'grid': 'Grid', 'name': 'Name', 'class-section': 'Section',
|
||||||
|
};
|
||||||
|
|
||||||
|
type Def = { code: string; name: string; exchange: string };
|
||||||
|
|
||||||
|
export function ContestBar({ session, onChange }: {
|
||||||
|
session: ContestSession;
|
||||||
|
onChange: (patch: Partial<ContestSession>) => void;
|
||||||
|
}) {
|
||||||
|
const [contests, setContests] = useState<Def[]>([]);
|
||||||
|
useEffect(() => { ListContests().then((c) => setContests((c ?? []) as Def[])).catch(() => {}); }, []);
|
||||||
|
|
||||||
|
const pickContest = (code: string) => {
|
||||||
|
const def = contests.find((c) => c.code === code);
|
||||||
|
if (!def) { onChange({ code: '', name: '' }); return; }
|
||||||
|
if (def.exchange === 'serial' || def.exchange === 'rst') {
|
||||||
|
onChange({ code: def.code, name: def.name, exchange: 'serial' });
|
||||||
|
} else {
|
||||||
|
onChange({ code: def.code, name: def.name, exchange: 'fixed', fixedKind: KIND_LABEL[def.exchange] || 'Custom' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!session.active) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center px-3 py-1.5">
|
||||||
|
<button type="button" onClick={() => onChange({ active: true })}
|
||||||
|
className="rounded-md border border-border bg-card px-2.5 py-1 text-xs font-bold text-muted-foreground hover:bg-muted">
|
||||||
|
▶ Contest mode
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snt = session.exchange === 'serial'
|
||||||
|
? String(session.nextSerial).padStart(3, '0')
|
||||||
|
: (session.sentFixed || '—');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 border-y-2 border-amber-500 bg-amber-500/10 flex-wrap">
|
||||||
|
<span className="rounded bg-amber-500 px-2 py-0.5 text-[11px] font-black uppercase tracking-wider text-white shrink-0">
|
||||||
|
Contest
|
||||||
|
</span>
|
||||||
|
<select value={session.code} onChange={(e) => pickContest(e.target.value)}
|
||||||
|
className="rounded-md border border-border bg-card px-2 py-1 text-xs max-w-[260px]">
|
||||||
|
<option value="">— pick contest —</option>
|
||||||
|
{contests.map((c) => <option key={c.code} value={c.code}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
{session.code && <span className="text-[11px] font-mono text-muted-foreground shrink-0">{session.code}</span>}
|
||||||
|
|
||||||
|
{/* Serial vs fixed exchange. */}
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
|
||||||
|
{(['serial', 'fixed'] as const).map((x) => (
|
||||||
|
<button key={x} type="button" onClick={() => onChange({ exchange: x })}
|
||||||
|
className={cn('px-2 py-1 text-[11px] font-bold border-l border-border first:border-l-0',
|
||||||
|
session.exchange === x ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{x === 'serial' ? 'Serial' : 'Fixed'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{session.exchange === 'serial' ? (
|
||||||
|
<label className="flex items-center gap-1 text-[11px] text-muted-foreground shrink-0">
|
||||||
|
Next#
|
||||||
|
<input type="number" min={1} value={session.nextSerial}
|
||||||
|
onChange={(e) => onChange({ nextSerial: Math.max(1, parseInt(e.target.value || '1', 10)) })}
|
||||||
|
className="w-16 rounded-md border border-border bg-card px-1.5 py-1 text-xs font-mono" />
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<select value={session.fixedKind} onChange={(e) => onChange({ fixedKind: e.target.value })}
|
||||||
|
className="rounded-md border border-border bg-card px-2 py-1 text-xs">
|
||||||
|
{FIXED_KINDS.map((k) => <option key={k} value={k}>{k}</option>)}
|
||||||
|
</select>
|
||||||
|
<input value={session.sentFixed} onChange={(e) => onChange({ sentFixed: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="your exch" className="w-24 rounded-md border border-border bg-card px-2 py-1 text-xs font-mono" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="text-[11px] text-muted-foreground shrink-0">
|
||||||
|
Snt <b className="font-mono text-foreground">{snt}</b>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button type="button" onClick={() => onChange({ active: false })}
|
||||||
|
className="ml-auto rounded-md border border-border bg-card px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted shrink-0">
|
||||||
|
Exit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -426,7 +426,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
{flagURL(draft.dxcc) && <img src={flagURL(draft.dxcc)} alt="" className="h-4 rounded-[2px] border border-border/50" referrerPolicy="no-referrer" />}
|
{flagURL(draft.dxcc) && <img src={flagURL(draft.dxcc)} alt="" className="h-4 rounded-[2px] border border-border/50" referrerPolicy="no-referrer" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0">Freq</Label>
|
<Label className="w-20 shrink-0">TX Freq</Label>
|
||||||
<Input value={freqKHz} onChange={(e) => setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
<Input value={freqKHz} onChange={(e) => setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
||||||
<Input value={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
<Input value={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Vendored
+3
@@ -14,6 +14,7 @@ import {powergenius} from '../models';
|
|||||||
import {winkeyer} from '../models';
|
import {winkeyer} from '../models';
|
||||||
import {alerts} from '../models';
|
import {alerts} from '../models';
|
||||||
import {audio} from '../models';
|
import {audio} from '../models';
|
||||||
|
import {contest} from '../models';
|
||||||
import {operating} from '../models';
|
import {operating} from '../models';
|
||||||
import {udp} from '../models';
|
import {udp} from '../models';
|
||||||
import {lookup} from '../models';
|
import {lookup} from '../models';
|
||||||
@@ -391,6 +392,8 @@ 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 ListContests():Promise<Array<contest.Def>>;
|
||||||
|
|
||||||
export function ListCountries():Promise<Array<string>>;
|
export function ListCountries():Promise<Array<string>>;
|
||||||
|
|
||||||
export function ListOperatingTree():Promise<Array<operating.Station>>;
|
export function ListOperatingTree():Promise<Array<operating.Station>>;
|
||||||
|
|||||||
@@ -746,6 +746,10 @@ export function ListClusterServers() {
|
|||||||
return window['go']['main']['App']['ListClusterServers']();
|
return window['go']['main']['App']['ListClusterServers']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListContests() {
|
||||||
|
return window['go']['main']['App']['ListContests']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ListCountries() {
|
export function ListCountries() {
|
||||||
return window['go']['main']['App']['ListCountries']();
|
return window['go']['main']['App']['ListCountries']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -879,6 +879,27 @@ export namespace cluster {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace contest {
|
||||||
|
|
||||||
|
export class Def {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
exchange: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Def(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.code = source["code"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.exchange = source["exchange"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace extsvc {
|
export namespace extsvc {
|
||||||
|
|
||||||
export class ServiceConfig {
|
export class ServiceConfig {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Package contest holds a curated catalogue of the major amateur-radio contests
|
||||||
|
// with their official ADIF CONTEST_ID codes and a default exchange kind. It is
|
||||||
|
// deliberately a built-in static list (not an online fetch): the ADIF code is
|
||||||
|
// what must be written to each logged QSO's CONTEST_ID, and those codes don't
|
||||||
|
// change — bundling them means correct codes, offline, no fragile scraping.
|
||||||
|
//
|
||||||
|
// The Exchange field is only a UI hint for what a station typically sends; the
|
||||||
|
// operator can always override it (serial vs fixed, and the fixed kind) when
|
||||||
|
// activating the contest.
|
||||||
|
package contest
|
||||||
|
|
||||||
|
import "sort"
|
||||||
|
|
||||||
|
// Exchange kinds (UI prefill for the sent/received exchange).
|
||||||
|
const (
|
||||||
|
ExSerial = "serial" // RST + running serial number
|
||||||
|
ExCQZone = "cq-zone" // RST + CQ zone
|
||||||
|
ExITUZone = "itu-zone" // RST + ITU zone
|
||||||
|
ExState = "state" // RST + state / province
|
||||||
|
ExDept = "dept" // RST + département (REF)
|
||||||
|
ExGrid = "grid" // RST + Maidenhead grid
|
||||||
|
ExName = "name" // name + state (NAQP-style)
|
||||||
|
ExClassSection = "class-section" // Field Day: class + ARRL/RAC section
|
||||||
|
ExRST = "rst" // RST only
|
||||||
|
)
|
||||||
|
|
||||||
|
// Def is one contest: its ADIF CONTEST_ID, a readable name, and the exchange a
|
||||||
|
// station typically sends.
|
||||||
|
type Def struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Exchange string `json:"exchange"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// catalogue is the built-in list. Codes are ADIF CONTEST_ID values.
|
||||||
|
var catalogue = []Def{
|
||||||
|
{"CQ-WW-CW", "CQ WW DX Contest (CW)", ExCQZone},
|
||||||
|
{"CQ-WW-SSB", "CQ WW DX Contest (SSB)", ExCQZone},
|
||||||
|
{"CQ-WW-RTTY", "CQ WW DX RTTY Contest", ExCQZone},
|
||||||
|
{"CQ-WPX-CW", "CQ WPX Contest (CW)", ExSerial},
|
||||||
|
{"CQ-WPX-SSB", "CQ WPX Contest (SSB)", ExSerial},
|
||||||
|
{"CQ-WPX-RTTY", "CQ WPX RTTY Contest", ExSerial},
|
||||||
|
{"CQ-160-CW", "CQ 160-Meter Contest (CW)", ExState},
|
||||||
|
{"CQ-160-SSB", "CQ 160-Meter Contest (SSB)", ExState},
|
||||||
|
{"CQ-VHF", "CQ WW VHF Contest", ExGrid},
|
||||||
|
{"ARRL-DX-CW", "ARRL International DX Contest (CW)", ExState},
|
||||||
|
{"ARRL-DX-SSB", "ARRL International DX Contest (SSB)", ExState},
|
||||||
|
{"ARRL-10", "ARRL 10-Meter Contest", ExState},
|
||||||
|
{"ARRL-160", "ARRL 160-Meter Contest", ExState},
|
||||||
|
{"ARRL-SS-CW", "ARRL Sweepstakes (CW)", ExSerial},
|
||||||
|
{"ARRL-SS-SSB", "ARRL Sweepstakes (SSB)", ExSerial},
|
||||||
|
{"ARRL-FIELD-DAY", "ARRL Field Day", ExClassSection},
|
||||||
|
{"ARRL-RTTY", "ARRL RTTY Roundup", ExState},
|
||||||
|
{"ARRL-DIGI", "ARRL Digital Contest", ExGrid},
|
||||||
|
{"ARRL-VHF-JAN", "ARRL January VHF Contest", ExGrid},
|
||||||
|
{"ARRL-VHF-JUN", "ARRL June VHF Contest", ExGrid},
|
||||||
|
{"ARRL-VHF-SEP", "ARRL September VHF Contest", ExGrid},
|
||||||
|
{"ARRL-UHF-AUG", "ARRL August UHF Contest", ExGrid},
|
||||||
|
{"IARU-HF", "IARU HF World Championship", ExITUZone},
|
||||||
|
{"WAE-DX-CW", "WAE DX Contest (CW)", ExSerial},
|
||||||
|
{"WAE-DX-SSB", "WAE DX Contest (SSB)", ExSerial},
|
||||||
|
{"WAE-DX-RTTY", "WAE DX Contest (RTTY)", ExSerial},
|
||||||
|
{"NAQP-CW", "North American QSO Party (CW)", ExName},
|
||||||
|
{"NAQP-SSB", "North American QSO Party (SSB)", ExName},
|
||||||
|
{"NAQP-RTTY", "North American QSO Party (RTTY)", ExName},
|
||||||
|
{"STEW-PERRY", "Stew Perry Topband Challenge", ExGrid},
|
||||||
|
{"REF-CW", "REF Contest (CW)", ExDept},
|
||||||
|
{"REF-SSB", "REF Contest (SSB)", ExDept},
|
||||||
|
{"REF-160M", "REF 160m Contest", ExDept},
|
||||||
|
{"OCEANIA-DX-CW", "Oceania DX Contest (CW)", ExSerial},
|
||||||
|
{"OCEANIA-DX-SSB", "Oceania DX Contest (SSB)", ExSerial},
|
||||||
|
{"HELVETIA", "Helvetia Contest", ExSerial},
|
||||||
|
{"UKRAINIAN-DX", "Ukrainian DX Contest", ExSerial},
|
||||||
|
{"WW-DIGI", "World Wide Digi DX Contest", ExGrid},
|
||||||
|
{"EU-HF-C", "EU HF Championship", ExSerial},
|
||||||
|
{"DARC-WAEDC", "WAEDC", ExSerial},
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns the catalogue sorted by name (copy — callers must not mutate).
|
||||||
|
func List() []Def {
|
||||||
|
out := make([]Def, len(catalogue))
|
||||||
|
copy(out, catalogue)
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return out
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user