feat: upload to external services clublog qrz

This commit is contained in:
2026-05-28 22:52:50 +02:00
parent e82e30dd02
commit 5c004f5e2f
26 changed files with 1710 additions and 31 deletions
+199 -2
View File
@@ -19,6 +19,7 @@ import (
"hamlog/internal/cat" "hamlog/internal/cat"
"hamlog/internal/cluster" "hamlog/internal/cluster"
"hamlog/internal/db" "hamlog/internal/db"
"hamlog/internal/extsvc"
"hamlog/internal/integrations/udp" "hamlog/internal/integrations/udp"
"hamlog/internal/operating" "hamlog/internal/operating"
"hamlog/internal/dxcc" "hamlog/internal/dxcc"
@@ -83,6 +84,21 @@ const (
keyQSLDefaultEQSLRcvd = "qsl.eqsl_rcvd" keyQSLDefaultEQSLRcvd = "qsl.eqsl_rcvd"
keyQSLDefaultClublogStatus = "qsl.clublog_status" keyQSLDefaultClublogStatus = "qsl.clublog_status"
keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status" keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status"
keyQSLDefaultQRZComStatus = "qsl.qrzcom_status"
// External services (logbook upload). QRZ.com first; Clublog / LoTW
// will add their own keys under the same extsvc.* prefix.
keyExtQRZAPIKey = "extsvc.qrz.api_key"
keyExtQRZForceCall = "extsvc.qrz.force_station_callsign"
keyExtQRZAutoUpload = "extsvc.qrz.auto_upload"
keyExtQRZUploadMode = "extsvc.qrz.upload_mode"
keyExtClublogEmail = "extsvc.clublog.email"
keyExtClublogPassword = "extsvc.clublog.password"
keyExtClublogCallsign = "extsvc.clublog.callsign"
keyExtClublogAPIKey = "extsvc.clublog.api_key"
keyExtClublogAutoUpload = "extsvc.clublog.auto_upload"
keyExtClublogUploadMode = "extsvc.clublog.upload_mode"
) )
// QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload // QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload
@@ -99,6 +115,7 @@ type QSLDefaults struct {
EQSLRcvd string `json:"eqsl_rcvd"` EQSLRcvd string `json:"eqsl_rcvd"`
ClublogStatus string `json:"clublog_status"` ClublogStatus string `json:"clublog_status"`
HRDLogStatus string `json:"hrdlog_status"` HRDLogStatus string `json:"hrdlog_status"`
QRZComStatus string `json:"qrzcom_status"`
} }
// CATSettings is the user-tweakable rig-control configuration. Stored as // CATSettings is the user-tweakable rig-control configuration. Stored as
@@ -184,6 +201,7 @@ type App struct {
operating *operating.Repo operating *operating.Repo
udp *udp.Manager udp *udp.Manager
udpRepo *udp.Repo udpRepo *udp.Repo
extsvc *extsvc.Manager
startupErr string // captured for surfacing to the frontend startupErr string // captured for surfacing to the frontend
dbPath string dbPath string
@@ -417,6 +435,17 @@ func (a *App) startup(ctx context.Context) {
} }
} }
// External-service uploaders (QRZ.com …). The manager is fed config
// from settings and host callbacks to build ADIF, stamp the upload
// status and surface errors to the UI.
a.extsvc = extsvc.NewManager(extsvc.Deps{
BuildADIF: a.buildUploadADIF,
MarkUploaded: a.markExtUploaded,
NotifyError: a.notifyExtError,
Logf: applog.Printf,
})
a.extsvc.SetConfig(a.loadExternalServices())
fmt.Println("OpsLog: db ready at", a.dbPath) fmt.Println("OpsLog: db ready at", a.dbPath)
} }
@@ -648,7 +677,11 @@ func (a *App) AddQSO(q qso.QSO) (int64, error) {
a.applyStationDefaults(&q) a.applyStationDefaults(&q)
a.applyDXCCNumber(&q) a.applyDXCCNumber(&q)
a.applyQSLDefaults(&q) a.applyQSLDefaults(&q)
return a.qso.Add(a.ctx, q) id, err := a.qso.Add(a.ctx, q)
if err == nil && a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
}
return id, err
} }
// StationInfoComputed bundles the data we resolve live from the // StationInfoComputed bundles the data we resolve live from the
@@ -1171,6 +1204,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd, keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd,
keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd, keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd,
keyQSLDefaultClublogStatus, keyQSLDefaultHRDLogStatus, keyQSLDefaultClublogStatus, keyQSLDefaultHRDLogStatus,
keyQSLDefaultQRZComStatus,
) )
if err != nil { if err != nil {
return out, err return out, err
@@ -1183,6 +1217,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
out.EQSLRcvd = m[keyQSLDefaultEQSLRcvd] out.EQSLRcvd = m[keyQSLDefaultEQSLRcvd]
out.ClublogStatus = m[keyQSLDefaultClublogStatus] out.ClublogStatus = m[keyQSLDefaultClublogStatus]
out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus] out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus]
out.QRZComStatus = m[keyQSLDefaultQRZComStatus]
return out, nil return out, nil
} }
@@ -1201,6 +1236,7 @@ func (a *App) SaveQSLDefaults(d QSLDefaults) error {
keyQSLDefaultEQSLRcvd: strings.ToUpper(strings.TrimSpace(d.EQSLRcvd)), keyQSLDefaultEQSLRcvd: strings.ToUpper(strings.TrimSpace(d.EQSLRcvd)),
keyQSLDefaultClublogStatus: strings.ToUpper(strings.TrimSpace(d.ClublogStatus)), keyQSLDefaultClublogStatus: strings.ToUpper(strings.TrimSpace(d.ClublogStatus)),
keyQSLDefaultHRDLogStatus: strings.ToUpper(strings.TrimSpace(d.HRDLogStatus)), keyQSLDefaultHRDLogStatus: strings.ToUpper(strings.TrimSpace(d.HRDLogStatus)),
keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)),
} { } {
if err := a.settings.Set(a.ctx, k, v); err != nil { if err := a.settings.Set(a.ctx, k, v); err != nil {
return err return err
@@ -1229,6 +1265,163 @@ func (a *App) applyQSLDefaults(q *qso.QSO) {
if q.EQSLRcvd == "" { q.EQSLRcvd = d.EQSLRcvd } if q.EQSLRcvd == "" { q.EQSLRcvd = d.EQSLRcvd }
if q.ClublogUploadStatus == "" { q.ClublogUploadStatus = d.ClublogStatus } if q.ClublogUploadStatus == "" { q.ClublogUploadStatus = d.ClublogStatus }
if q.HRDLogUploadStatus == "" { q.HRDLogUploadStatus = d.HRDLogStatus } if q.HRDLogUploadStatus == "" { q.HRDLogUploadStatus = d.HRDLogStatus }
if q.QRZComUploadStatus == "" { q.QRZComUploadStatus = d.QRZComStatus }
}
// ── External services (logbook upload) ─────────────────────────────────
// loadExternalServices reads the configured external-service settings.
func (a *App) loadExternalServices() extsvc.ExternalServices {
var out extsvc.ExternalServices
if a.settings == nil {
return out
}
m, err := a.settings.GetMany(a.ctx,
keyExtQRZAPIKey, keyExtQRZForceCall, keyExtQRZAutoUpload, keyExtQRZUploadMode,
keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign,
keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode)
if err != nil {
return out
}
out.QRZ = extsvc.ServiceConfig{
APIKey: m[keyExtQRZAPIKey],
ForceStationCallsign: m[keyExtQRZForceCall],
AutoUpload: m[keyExtQRZAutoUpload] == "1",
UploadMode: extsvc.UploadMode(m[keyExtQRZUploadMode]),
}
out.Clublog = extsvc.ServiceConfig{
Email: m[keyExtClublogEmail],
Password: m[keyExtClublogPassword],
Callsign: m[keyExtClublogCallsign],
APIKey: m[keyExtClublogAPIKey],
AutoUpload: m[keyExtClublogAutoUpload] == "1",
UploadMode: extsvc.UploadMode(m[keyExtClublogUploadMode]),
}
// Default the Club Log logbook callsign to the active profile's call
// when the user hasn't overridden it.
if out.Clublog.Callsign == "" && a.profiles != nil {
if p, perr := a.profiles.Active(a.ctx); perr == nil {
out.Clublog.Callsign = p.Callsign
}
}
return out
}
// GetExternalServices returns the saved external-service configuration.
func (a *App) GetExternalServices() (extsvc.ExternalServices, error) {
return a.loadExternalServices(), nil
}
// SaveExternalServices persists the config and reloads the live manager so
// the next logged QSO uses the new settings (no restart needed).
func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
mode := string(extsvc.ModeImmediate)
if cfg.QRZ.UploadMode == extsvc.ModeDelayed {
mode = string(extsvc.ModeDelayed)
}
auto := "0"
if cfg.QRZ.AutoUpload {
auto = "1"
}
clMode := string(extsvc.ModeImmediate)
if cfg.Clublog.UploadMode == extsvc.ModeDelayed {
clMode = string(extsvc.ModeDelayed)
}
clAuto := "0"
if cfg.Clublog.AutoUpload {
clAuto = "1"
}
for k, v := range map[string]string{
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
keyExtQRZForceCall: strings.ToUpper(strings.TrimSpace(cfg.QRZ.ForceStationCallsign)),
keyExtQRZAutoUpload: auto,
keyExtQRZUploadMode: mode,
keyExtClublogEmail: strings.TrimSpace(cfg.Clublog.Email),
keyExtClublogPassword: cfg.Clublog.Password,
keyExtClublogCallsign: strings.ToUpper(strings.TrimSpace(cfg.Clublog.Callsign)),
keyExtClublogAPIKey: strings.TrimSpace(cfg.Clublog.APIKey),
keyExtClublogAutoUpload: clAuto,
keyExtClublogUploadMode: clMode,
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
}
}
if a.extsvc != nil {
a.extsvc.SetConfig(a.loadExternalServices())
}
return nil
}
// TestQRZUpload validates the configured QRZ key by querying the logbook's
// status (ACTION=STATUS). Returns a human-readable message for the UI.
func (a *App) TestQRZUpload() (string, error) {
cfg := a.loadExternalServices().QRZ
return extsvc.TestQRZ(a.ctx, nil, cfg.APIKey)
}
// TestClublogUpload validates that the Club Log credentials are complete.
func (a *App) TestClublogUpload() (string, error) {
return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog)
}
// buildUploadADIF builds a single-record ADIF for QSO id, overriding the
// station callsign when forceCall is set (QRZ rejects QSOs whose station
// call differs from the logbook's registered call). ok=false → skip.
func (a *App) buildUploadADIF(id int64, forceCall string) (string, bool) {
if a.qso == nil {
return "", false
}
q, err := a.qso.GetByID(a.ctx, id)
if err != nil {
return "", false
}
if forceCall != "" {
q.StationCallsign = forceCall
}
return adif.SingleRecordADIF(q), true
}
// markExtUploaded stamps the per-service upload status on the QSO row and
// tells the frontend to refresh that row's confirmation columns.
func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
date := time.Now().UTC().Format("20060102")
switch svc {
case extsvc.ServiceQRZ:
if a.qso != nil {
if err := a.qso.MarkQRZUploaded(a.ctx, id, date); err != nil {
applog.Printf("extsvc: mark qrz uploaded %d: %v", id, err)
}
}
case extsvc.ServiceClublog:
if a.qso != nil {
if err := a.qso.MarkClublogUploaded(a.ctx, id, date); err != nil {
applog.Printf("extsvc: mark clublog uploaded %d: %v", id, err)
}
}
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "extsvc:uploaded", map[string]any{
"service": string(svc),
"qso_id": id,
"log_id": logID,
})
}
}
// notifyExtError surfaces a failed upload to the frontend.
func (a *App) notifyExtError(svc extsvc.Service, id int64, err error) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "extsvc:error", map[string]any{
"service": string(svc),
"qso_id": id,
"error": err.Error(),
})
}
} }
// ── UDP integrations ─────────────────────────────────────────────────── // ── UDP integrations ───────────────────────────────────────────────────
@@ -1286,7 +1479,8 @@ func (a *App) ReloadUDPIntegrations() []string {
// LogUDPLoggedADIF takes an ADIF blob received over UDP and inserts the // LogUDPLoggedADIF takes an ADIF blob received over UDP and inserts the
// first record into the local logbook. Returns the ID of the inserted // first record into the local logbook. Returns the ID of the inserted
// row. Used by the auto-log handler (WSJT-X / JTDX / MSHV / JTAlert). // row. Used by the auto-log handler (WSJT-X / JTDX / MSHV / JTAlert /
// N1MM — the latter via a synthesised ADIF record from its XML datagram).
func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) { func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
if a.qso == nil { if a.qso == nil {
return 0, fmt.Errorf("db not initialized") return 0, fmt.Errorf("db not initialized")
@@ -1379,6 +1573,9 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
if err != nil { if err != nil {
return 0, fmt.Errorf("insert qso: %w", err) return 0, fmt.Errorf("insert qso: %w", err)
} }
if a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
}
return id, nil return id, nil
} }
+37 -4
View File
@@ -35,7 +35,7 @@ import { BandMap } from '@/components/BandMap';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { ShutdownProgress } from '@/components/ShutdownProgress'; import { ShutdownProgress } from '@/components/ShutdownProgress';
import { ClusterGrid } from '@/components/ClusterGrid'; import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot'; import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel'; import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
@@ -426,6 +426,10 @@ export default function App() {
// already-worked). Otherwise only matching spots pass. // already-worked). Otherwise only matching spots pass.
type SpotStatusKey = 'new' | 'new-band' | 'new-slot' | 'worked'; type SpotStatusKey = 'new' | 'new-band' | 'new-slot' | 'worked';
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(new Set()); const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(new Set());
// Mode filter chips. Empty set = show every mode. Categories map the
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
type SpotModeCat = 'SSB' | 'CW' | 'DATA';
const [clusterModeFilter, setClusterModeFilter] = useState<Set<SpotModeCat>>(new Set());
const [clusterSearch, setClusterSearch] = useState(''); const [clusterSearch, setClusterSearch] = useState('');
const [showBandMap, setShowBandMap] = useState(false); const [showBandMap, setShowBandMap] = useState(false);
type SortKey = 'time' | 'call' | 'freq' | 'band' | 'mode' | 'spotter' | 'source'; type SortKey = 'time' | 'call' | 'freq' | 'band' | 'mode' | 'spotter' | 'source';
@@ -1279,8 +1283,7 @@ export default function App() {
className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card" className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card"
value={callsign} value={callsign}
onChange={(e) => onCallsignInput(e.target.value)} onChange={(e) => onCallsignInput(e.target.value)}
placeholder="F4XYZ" />
/>
</div> </div>
<div className="flex flex-col w-24"> <div className="flex flex-col w-24">
<Label className="mb-1 h-3.5 flex items-center gap-1">Band <LockBtn k="band" title="band" /></Label> <Label className="mb-1 h-3.5 flex items-center gap-1">Band <LockBtn k="band" title="band" /></Label>
@@ -1443,7 +1446,7 @@ export default function App() {
<Input value={comment} onChange={(e) => setComment(e.target.value)} /> <Input value={comment} onChange={(e) => setComment(e.target.value)} />
</div> </div>
{!compact && ( {!compact && (
<div className="flex flex-col flex-1 min-w-[120px]"><Label className="mb-1 h-3.5">Note (ADIF)</Label> <div className="flex flex-col flex-1 min-w-[120px]"><Label className="mb-1 h-3.5">Note</Label>
<Input value={note} onChange={(e) => setNote(e.target.value)} /> <Input value={note} onChange={(e) => setNote(e.target.value)} />
</div> </div>
)} )}
@@ -1777,6 +1780,32 @@ export default function App() {
</button> </button>
); );
})} })}
<div className="w-px h-4 bg-border mx-1" />
<span className="text-muted-foreground">Mode:</span>
{([
{ k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-sky-100 text-sky-800 border-sky-300' },
{ k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-violet-100 text-violet-800 border-violet-300' },
{ k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-emerald-100 text-emerald-800 border-emerald-300' },
]).map((s) => {
const on = clusterModeFilter.has(s.k);
return (
<button
key={s.k}
type="button"
onClick={() => setClusterModeFilter((cur) => {
const n = new Set(cur);
if (n.has(s.k)) n.delete(s.k); else n.add(s.k);
return n;
})}
className={cn(
'px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity',
on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`,
)}
>
{s.label}
</button>
);
})}
<div className="flex-1" /> <div className="flex-1" />
<label className="flex items-center gap-1.5 text-xs cursor-pointer"> <label className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox checked={clusterGroup} onCheckedChange={(c) => setClusterGroup(!!c)} /> <Checkbox checked={clusterGroup} onCheckedChange={(c) => setClusterGroup(!!c)} />
@@ -1808,6 +1837,10 @@ export default function App() {
const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz); const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz);
if (spotMode && mode && spotMode !== mode) return false; if (spotMode && mode && spotMode !== mode) return false;
} }
if (clusterModeFilter.size > 0) {
const cat = spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz));
if (!cat || !clusterModeFilter.has(cat)) return false;
}
if (clusterStatusFilter.size > 0) { if (clusterStatusFilter.size > 0) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz); const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
const st = spotStatus[k]?.status || ''; const st = spotStatus[k]?.status || '';
+40 -6
View File
@@ -160,13 +160,47 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
filtered.push(s); filtered.push(s);
} }
filtered.sort((a, b) => b.freq_khz - a.freq_khz); filtered.sort((a, b) => b.freq_khz - a.freq_khz);
// Desired pill-CENTRE Y for each spot = its true frequency's Y.
const desired = filtered.map(
(s) => TOP_PAD + (1 - (s.freq_khz - lo) / span) * innerH,
);
// Non-overlapping label placement via isotonic regression (pool-
// adjacent-violators). We want centres c_0 ≤ c_1 ≤ … with
// c_{i+1} c_i ≥ PILL_H, minimising the squared displacement from each
// label's desired centre. Substituting q_i = c_i i·PILL_H turns the
// gap constraint into "q non-decreasing", which PAVA solves exactly in
// one pass. The win over the old greedy push-down: a tight cluster is
// centred on its mean, so its labels fan out symmetrically ABOVE and
// below the frequency (Log4OM style) instead of all spilling downward.
const n = filtered.length;
type Block = { sum: number; count: number; start: number };
const blocks: Block[] = [];
for (let i = 0; i < n; i++) {
// e_i = desired_i i·PILL_H is the target for the substituted q.
let cur: Block = { sum: desired[i] - i * PILL_H, count: 1, start: i };
while (blocks.length > 0) {
const prev = blocks[blocks.length - 1];
if (prev.sum / prev.count <= cur.sum / cur.count) break;
blocks.pop();
cur = { sum: prev.sum + cur.sum, count: prev.count + cur.count, start: prev.start };
}
blocks.push(cur);
}
const centers = new Array<number>(n);
for (const b of blocks) {
const mean = b.sum / b.count; // optimal q for the whole block
for (let i = b.start; i < b.start + b.count; i++) centers[i] = mean + i * PILL_H;
}
// Centres are non-decreasing, so centers[0] is the topmost. Shift the
// whole set down by any overflow above the band edge so the first label
// isn't clipped (preserves the ≥ PILL_H spacing).
const shift = n > 0 ? Math.max(0, TOP_PAD - (centers[0] - PILL_H / 2)) : 0;
const out: Placed[] = []; const out: Placed[] = [];
let prevY = -Infinity; for (let i = 0; i < n; i++) {
for (const s of filtered) { out.push({ spot: filtered[i], freqY: desired[i], labelY: centers[i] + shift - PILL_H / 2 });
const fy = TOP_PAD + (1 - (s.freq_khz - lo) / span) * innerH;
const ly = Math.max(fy, prevY + PILL_H);
out.push({ spot: s, freqY: fy, labelY: ly });
prevY = ly;
} }
const lastLabelBottom = out.length ? out[out.length - 1].labelY + PILL_H : 0; const lastLabelBottom = out.length ? out[out.length - 1].labelY + PILL_H : 0;
return { return {
+2
View File
@@ -284,6 +284,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
<F label="Clublog date"><Input value={draft.clublog_qso_upload_date ?? ''} onChange={(e) => set('clublog_qso_upload_date', e.target.value)} /></F> <F label="Clublog date"><Input value={draft.clublog_qso_upload_date ?? ''} onChange={(e) => set('clublog_qso_upload_date', e.target.value)} /></F>
<F label="HRDLog status" span={2}><Input value={draft.hrdlog_qso_upload_status ?? ''} onChange={(e) => set('hrdlog_qso_upload_status', e.target.value)} /></F> <F label="HRDLog status" span={2}><Input value={draft.hrdlog_qso_upload_status ?? ''} onChange={(e) => set('hrdlog_qso_upload_status', e.target.value)} /></F>
<F label="HRDLog date"><Input value={draft.hrdlog_qso_upload_date ?? ''} onChange={(e) => set('hrdlog_qso_upload_date', e.target.value)} /></F> <F label="HRDLog date"><Input value={draft.hrdlog_qso_upload_date ?? ''} onChange={(e) => set('hrdlog_qso_upload_date', e.target.value)} /></F>
<F label="QRZ.com status" span={2}><Input value={draft.qrzcom_qso_upload_status ?? ''} onChange={(e) => set('qrzcom_qso_upload_status', e.target.value)} /></F>
<F label="QRZ.com date"><Input value={draft.qrzcom_qso_upload_date ?? ''} onChange={(e) => set('qrzcom_qso_upload_date', e.target.value)} /></F>
</div> </div>
</TabsContent> </TabsContent>
@@ -140,6 +140,8 @@ const COL_CATALOG: ColEntry[] = [
{ group: 'Uploads', label: 'ClubLog upload status', colId: 'clublog_qso_upload_status', headerName: 'ClubLog status', field: 'clublog_qso_upload_status' as any, width: 110 }, { group: 'Uploads', label: 'ClubLog upload status', colId: 'clublog_qso_upload_status', headerName: 'ClubLog status', field: 'clublog_qso_upload_status' as any, width: 110 },
{ group: 'Uploads', label: 'HRDLog upload date', colId: 'hrdlog_qso_upload_date', headerName: 'HRDLog date', field: 'hrdlog_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'Uploads', label: 'HRDLog upload date', colId: 'hrdlog_qso_upload_date', headerName: 'HRDLog date', field: 'hrdlog_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: 'HRDLog upload status', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog status', field: 'hrdlog_qso_upload_status' as any, width: 110 }, { group: 'Uploads', label: 'HRDLog upload status', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog status', field: 'hrdlog_qso_upload_status' as any, width: 110 },
{ group: 'Uploads', label: 'QRZ.com upload date', colId: 'qrzcom_qso_upload_date', headerName: 'QRZ.com date', field: 'qrzcom_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: 'QRZ.com upload status', colId: 'qrzcom_qso_upload_status', headerName: 'QRZ.com status', field: 'qrzcom_qso_upload_status' as any, width: 110 },
// ── Contest ── // ── Contest ──
{ group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 }, { group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 },
+259 -6
View File
@@ -3,7 +3,7 @@ import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2, ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight, ChevronDown, ChevronRight,
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon, User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
Compass, Wifi, Construction, Compass, Wifi, Construction, UploadCloud,
} from 'lucide-react'; } from 'lucide-react';
import { import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider, GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
@@ -17,6 +17,7 @@ import {
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder, GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
GetQSLDefaults, SaveQSLDefaults, GetQSLDefaults, SaveQSLDefaults,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
ComputeStationInfo, ComputeStationInfo,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models'; import type { profile as profileModels } from '../../wailsjs/go/models';
@@ -167,6 +168,7 @@ type SectionId =
| 'profiles' | 'profiles'
| 'operating' | 'operating'
| 'confirmations' | 'confirmations'
| 'external-services'
| 'udp' | 'udp'
| 'lookup' | 'lookup'
| 'lists-bands' | 'lists-bands'
@@ -190,6 +192,7 @@ const TREE: TreeNode[] = [
{ kind: 'item', label: 'Profiles (portable, home, contest)', id: 'profiles' }, { kind: 'item', label: 'Profiles (portable, home, contest)', id: 'profiles' },
{ kind: 'item', label: 'Operating conditions (rigs & antennas)', id: 'operating' }, { kind: 'item', label: 'Operating conditions (rigs & antennas)', id: 'operating' },
{ kind: 'item', label: 'Confirmations (QSL / eQSL / LoTW defaults)', id: 'confirmations' }, { kind: 'item', label: 'Confirmations (QSL / eQSL / LoTW defaults)', id: 'confirmations' },
{ kind: 'item', label: 'External services (QRZ.com, Clublog, LoTW…)', id: 'external-services' },
], ],
}, },
{ {
@@ -221,6 +224,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
profiles: 'Profiles', profiles: 'Profiles',
operating: 'Operating conditions', operating: 'Operating conditions',
confirmations: 'Confirmations', confirmations: 'Confirmations',
'external-services': 'External services',
lookup: 'Callsign Lookup', lookup: 'Callsign Lookup',
'lists-bands': 'Bands', 'lists-bands': 'Bands',
'lists-modes': 'Modes & default RST', 'lists-modes': 'Modes & default RST',
@@ -368,15 +372,38 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
qsl_sent: string; qsl_rcvd: string; qsl_sent: string; qsl_rcvd: string;
lotw_sent: string; lotw_rcvd: string; lotw_sent: string; lotw_rcvd: string;
eqsl_sent: string; eqsl_rcvd: string; eqsl_sent: string; eqsl_rcvd: string;
clublog_status: string; hrdlog_status: string; clublog_status: string; hrdlog_status: string; qrzcom_status: string;
}; };
const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({ const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
qsl_sent: '', qsl_rcvd: '', qsl_sent: '', qsl_rcvd: '',
lotw_sent: '', lotw_rcvd: '', lotw_sent: '', lotw_rcvd: '',
eqsl_sent: '', eqsl_rcvd: '', eqsl_sent: '', eqsl_rcvd: '',
clublog_status: '', hrdlog_status: '', clublog_status: '', hrdlog_status: '', qrzcom_status: '',
}); });
// External services (logbook upload). One block per service; only QRZ is
// wired today. upload_mode is 'immediate' | 'delayed' (per-service).
type ExtServiceCfg = {
api_key: string; email: string; password: string; callsign: string;
force_station_callsign: string;
auto_upload: boolean; upload_mode: string;
};
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg };
const emptyExtCfg = (): ExtServiceCfg => ({
api_key: '', email: '', password: '', callsign: '',
force_station_callsign: '', auto_upload: false, upload_mode: 'immediate',
});
const [extSvc, setExtSvc] = useState<ExternalServices>({
qrz: emptyExtCfg(), clublog: emptyExtCfg(),
});
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [qrzTesting, setQrzTesting] = useState(false);
const [clublogTest, setClublogTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [clublogTesting, setClublogTesting] = useState(false);
// Active tab in the External Services panel — lifted here because
// PANELS[selected]() is called as a function, so panels can't hold hooks.
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'hamqth' | 'lotw'>('qrz');
const [backupCfg, setBackupCfg] = useState<mainModels.BackupSettings>({ const [backupCfg, setBackupCfg] = useState<mainModels.BackupSettings>({
enabled: false, folder: '', rotation: 5, zip: false, enabled: false, folder: '', rotation: 5, zip: false,
last_backup_at: '', default_folder: '', last_backup_at: '', default_folder: '',
@@ -450,9 +477,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const [l, ls, c, ap, r, b, qd] = await Promise.all([ const [l, ls, c, ap, r, b, qd, es] = await Promise.all([
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(), GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
GetRotatorSettings(), GetBackupSettings(), GetQSLDefaults(), GetRotatorSettings(), GetBackupSettings(), GetQSLDefaults(), GetExternalServices(),
]); ]);
setLookup(l); setLookup(l);
setActiveProfile(ap as Profile); setActiveProfile(ap as Profile);
@@ -463,6 +490,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
setRotator(r); setRotator(r);
setBackupCfg(b as any); setBackupCfg(b as any);
setQslDefaults(qd as any); setQslDefaults(qd as any);
setExtSvc(es as any);
} catch (e: any) { } catch (e: any) {
setErr(String(e?.message ?? e)); setErr(String(e?.message ?? e));
} finally { } finally {
@@ -582,6 +610,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
await SaveRotatorSettings(rotator as any); await SaveRotatorSettings(rotator as any);
await SaveBackupSettings(backupCfg as any); await SaveBackupSettings(backupCfg as any);
await SaveQSLDefaults(qslDefaults as any); await SaveQSLDefaults(qslDefaults as any);
await SaveExternalServices(extSvc as any);
await SetClusterAutoConnect(clusterAutoConnect); await SetClusterAutoConnect(clusterAutoConnect);
setMsg('Settings saved.'); setMsg('Settings saved.');
@@ -1555,7 +1584,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<div className="border-t border-border/60 pt-3 space-y-3"> <div className="border-t border-border/60 pt-3 space-y-3">
<div className="text-[11px] text-muted-foreground"> <div className="text-[11px] text-muted-foreground">
Upload status fields (Clublog / HRDLog) are stamped on every QSO; "N" is typical when you want OpsLog to track which QSOs still need to be uploaded. Upload status fields (Clublog / HRDLog / QRZ.com) are stamped on every QSO; "N" is typical when you want OpsLog to track which QSOs still need to be uploaded.
</div> </div>
{/* Clublog */} {/* Clublog */}
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end"> <div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
@@ -1575,6 +1604,15 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</div> </div>
<div /> <div />
</div> </div>
{/* QRZ.com */}
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
<Label className="text-sm font-medium pb-1.5">QRZ.com</Label>
<div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Upload status</Label>
{renderSelect('qrzcom_status', FULL_OPTIONS)}
</div>
<div />
</div>
</div> </div>
</div> </div>
</> </>
@@ -1727,12 +1765,227 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
); );
} }
function ExternalServicesPanel() {
const TABS: { k: typeof extSvcTab; label: string; ready?: boolean }[] = [
{ k: 'qrz', label: 'QRZ.COM', ready: true },
{ k: 'clublog', label: 'CLUBLOG', ready: true },
{ k: 'hrdlog', label: 'HRDLOG.NET' },
{ k: 'eqsl', label: 'EQSL' },
{ k: 'hamqth', label: 'HAMQTH' },
{ k: 'lotw', label: 'LOTW' },
];
const qrz = extSvc.qrz;
const setQrz = (patch: Partial<ExtServiceCfg>) =>
setExtSvc((s) => ({ ...s, qrz: { ...s.qrz, ...patch } }));
const clublog = extSvc.clublog;
const setClublog = (patch: Partial<ExtServiceCfg>) =>
setExtSvc((s) => ({ ...s, clublog: { ...s.clublog, ...patch } }));
async function testQrz() {
setQrzTesting(true);
setQrzTest(null);
try {
// Persist first so the backend test reads the key just typed.
await SaveExternalServices(extSvc as any);
const msg = await TestQRZUpload();
setQrzTest({ ok: true, msg });
} catch (e: any) {
setQrzTest({ ok: false, msg: String(e?.message ?? e) });
} finally {
setQrzTesting(false);
}
}
async function testClublog() {
setClublogTesting(true);
setClublogTest(null);
try {
await SaveExternalServices(extSvc as any);
const msg = await TestClublogUpload();
setClublogTest({ ok: true, msg });
} catch (e: any) {
setClublogTest({ ok: false, msg: String(e?.message ?? e) });
} finally {
setClublogTesting(false);
}
}
return (
<>
<SectionHeader
title="External services"
hint="Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 12 min delay so a mis-logged QSO can still be fixed first)."
/>
{/* Tab strip */}
<div className="flex flex-wrap gap-1 border-b border-border mb-4">
{TABS.map((t) => (
<button
key={t.k}
type="button"
onClick={() => setExtSvcTab(t.k)}
className={cn(
'px-3 py-1.5 text-xs font-semibold rounded-t-md border-x border-t -mb-px transition-colors',
extSvcTab === t.k
? 'bg-card border-border text-foreground'
: 'bg-muted/40 border-transparent text-muted-foreground hover:text-foreground',
)}
>
{t.label}
{!t.ready && <span className="ml-1 text-[9px] opacity-60">soon</span>}
</button>
))}
</div>
{extSvcTab === 'qrz' ? (
<div className="space-y-4 max-w-2xl">
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">API key</Label>
<Input
value={qrz.api_key}
onChange={(e) => setQrz({ api_key: e.target.value })}
placeholder="QRZ.com logbook API key (XXXX-XXXX-XXXX-XXXX)"
className="font-mono text-xs"
/>
<Label className="text-sm">Force station callsign</Label>
<Input
value={qrz.force_station_callsign}
onChange={(e) => setQrz({ force_station_callsign: e.target.value.toUpperCase() })}
placeholder="e.g. F4BPO optional"
className="font-mono text-xs"
/>
</div>
<div className="text-[11px] text-muted-foreground bg-amber-50 border border-amber-200 rounded-md p-2.5 leading-relaxed">
QRZ.com discards station calls that differ from the one registered on the logbook.
Setting your registered callsign here rewrites <span className="font-mono">STATION_CALLSIGN</span> on
upload, so a QSO logged with a <span className="font-mono">/P</span> or <span className="font-mono">/QRP</span> suffix
is still accepted. Note this also applies to QSOs made with a country prefix/suffix.
</div>
<div className="border-t border-border/60 pt-3 space-y-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={qrz.auto_upload}
onCheckedChange={(c) => setQrz({ auto_upload: !!c })}
/>
Automatic upload on new QSO
</label>
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">Upload timing</Label>
<Select
value={qrz.upload_mode || 'immediate'}
onValueChange={(v) => setQrz({ upload_mode: v })}
>
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="immediate">Immediate</SelectItem>
<SelectItem value="delayed">Delayed (12 min, lets you fix mistakes)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={testQrz} disabled={qrzTesting || !qrz.api_key}>
<UploadCloud className="size-3.5" /> {qrzTesting ? 'Testing…' : 'Test connection'}
</Button>
{qrzTest && (
<span className={cn('text-xs', qrzTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
{qrzTest.msg}
</span>
)}
</div>
</div>
</div>
) : extSvcTab === 'clublog' ? (
<div className="space-y-4 max-w-2xl">
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">Account email</Label>
<Input
type="email"
value={clublog.email}
onChange={(e) => setClublog({ email: e.target.value })}
placeholder="your Club Log account email"
className="text-xs"
/>
<Label className="text-sm">Password</Label>
<Input
type="password"
value={clublog.password}
onChange={(e) => setClublog({ password: e.target.value })}
placeholder="Club Log account password"
className="text-xs"
/>
<Label className="text-sm">Logbook callsign</Label>
<Input
value={clublog.callsign}
onChange={(e) => setClublog({ callsign: e.target.value.toUpperCase() })}
placeholder="defaults to the active profile's callsign"
className="font-mono text-xs"
/>
</div>
<div className="text-[11px] text-muted-foreground bg-amber-50 border border-amber-200 rounded-md p-2.5 leading-relaxed">
Club Log uploads each QSO in real time using your account email, password and the
logbook callsign — no API key needed for QSO upload.
</div>
<div className="border-t border-border/60 pt-3 space-y-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={clublog.auto_upload}
onCheckedChange={(c) => setClublog({ auto_upload: !!c })}
/>
Automatic upload on new QSO
</label>
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">Upload timing</Label>
<Select
value={clublog.upload_mode || 'immediate'}
onValueChange={(v) => setClublog({ upload_mode: v })}
>
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="immediate">Immediate</SelectItem>
<SelectItem value="delayed">Delayed (12 min, lets you fix mistakes)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={testClublog} disabled={clublogTesting}>
<UploadCloud className="size-3.5" /> {clublogTesting ? 'Testing' : 'Test connection'}
</Button>
{clublogTest && (
<span className={cn('text-xs', clublogTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
{clublogTest.msg}
</span>
)}
</div>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center text-muted-foreground gap-2 py-16">
<Construction className="size-10 opacity-30" />
<div className="text-sm font-semibold text-foreground/70">
{TABS.find((t) => t.k === extSvcTab)?.label} — coming soon
</div>
<div className="text-xs">This external service isn't wired up yet.</div>
</div>
)}
</>
);
}
// Map sections to their content + icon (for placeholder). // Map sections to their content + icon (for placeholder).
const PANELS: Record<SectionId, () => JSX.Element> = { const PANELS: Record<SectionId, () => JSX.Element> = {
station: StationPanel, station: StationPanel,
profiles: ProfilesPanel, profiles: ProfilesPanel,
operating: OperatingPanelWrapper, operating: OperatingPanelWrapper,
confirmations: ConfirmationsPanel, confirmations: ConfirmationsPanel,
'external-services': ExternalServicesPanel,
lookup: LookupPanel, lookup: LookupPanel,
'lists-bands': BandsPanel, 'lists-bands': BandsPanel,
'lists-modes': ModesPanel, 'lists-modes': ModesPanel,
+14
View File
@@ -59,6 +59,20 @@ export function inferSpotMode(comment: string, freqHz: number): string {
return ''; return '';
} }
// spotModeCategory buckets the fine-grained mode from inferSpotMode into
// the three families the cluster filter exposes: 'SSB' (phone: SSB/FM/AM),
// 'CW', and 'DATA' (every digital mode). Returns '' when the mode is
// unknown so callers can decide how to treat un-categorisable spots.
export function spotModeCategory(mode: string): 'SSB' | 'CW' | 'DATA' | '' {
const m = (mode || '').toUpperCase();
if (m === '') return '';
if (m === 'CW') return 'CW';
if (m === 'SSB' || m === 'USB' || m === 'LSB' || m === 'FM' || m === 'AM') return 'SSB';
// Everything else inferSpotMode can return (FT8/FT4/JS8/RTTY/PSK*/…/DATA)
// is a digital mode.
return 'DATA';
}
// spotStatusKey is the cache key for ClusterSpotStatuses results. Must be // spotStatusKey is the cache key for ClusterSpotStatuses results. Must be
// computed identically in the fetcher and every reader — including the // computed identically in the fetcher and every reader — including the
// band map and the spot table — so a CW spot's status doesn't get looked // band map and the spot table — so a CW spot's status doesn't get looked
+9
View File
@@ -6,6 +6,7 @@ import {profile} from '../models';
import {adif} from '../models'; import {adif} from '../models';
import {cat} from '../models'; import {cat} from '../models';
import {cluster} from '../models'; import {cluster} from '../models';
import {extsvc} 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';
@@ -62,6 +63,8 @@ export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
export function GetCtyDatInfo():Promise<main.CtyDatInfo>; export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
export function GetExternalServices():Promise<extsvc.ExternalServices>;
export function GetListsSettings():Promise<main.ListsSettings>; export function GetListsSettings():Promise<main.ListsSettings>;
export function GetLogFilePath():Promise<string>; export function GetLogFilePath():Promise<string>;
@@ -122,6 +125,8 @@ export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>; export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>;
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>; export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>; export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
@@ -152,8 +157,12 @@ export function SetCompactMode(arg1:boolean):Promise<void>;
export function SwitchCATRig(arg1:number):Promise<void>; export function SwitchCATRig(arg1:number):Promise<void>;
export function TestClublogUpload():Promise<string>;
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>; export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
export function TestQRZUpload():Promise<string>;
export function TestRotator(arg1:main.RotatorSettings):Promise<void>; export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
export function UpdateQSO(arg1:qso.QSO):Promise<void>; export function UpdateQSO(arg1:qso.QSO):Promise<void>;
+16
View File
@@ -106,6 +106,10 @@ export function GetCtyDatInfo() {
return window['go']['main']['App']['GetCtyDatInfo'](); return window['go']['main']['App']['GetCtyDatInfo']();
} }
export function GetExternalServices() {
return window['go']['main']['App']['GetExternalServices']();
}
export function GetListsSettings() { export function GetListsSettings() {
return window['go']['main']['App']['GetListsSettings'](); return window['go']['main']['App']['GetListsSettings']();
} }
@@ -226,6 +230,10 @@ export function SaveClusterServer(arg1) {
return window['go']['main']['App']['SaveClusterServer'](arg1); return window['go']['main']['App']['SaveClusterServer'](arg1);
} }
export function SaveExternalServices(arg1) {
return window['go']['main']['App']['SaveExternalServices'](arg1);
}
export function SaveListsSettings(arg1) { export function SaveListsSettings(arg1) {
return window['go']['main']['App']['SaveListsSettings'](arg1); return window['go']['main']['App']['SaveListsSettings'](arg1);
} }
@@ -286,10 +294,18 @@ export function SwitchCATRig(arg1) {
return window['go']['main']['App']['SwitchCATRig'](arg1); return window['go']['main']['App']['SwitchCATRig'](arg1);
} }
export function TestClublogUpload() {
return window['go']['main']['App']['TestClublogUpload']();
}
export function TestLookupProvider(arg1, arg2, arg3, arg4) { export function TestLookupProvider(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4); return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
} }
export function TestQRZUpload() {
return window['go']['main']['App']['TestQRZUpload']();
}
export function TestRotator(arg1) { export function TestRotator(arg1) {
return window['go']['main']['App']['TestRotator'](arg1); return window['go']['main']['App']['TestRotator'](arg1);
} }
+67
View File
@@ -183,6 +183,67 @@ export namespace cluster {
} }
export namespace extsvc {
export class ServiceConfig {
api_key: string;
email: string;
password: string;
callsign: string;
force_station_callsign: string;
auto_upload: boolean;
upload_mode: string;
static createFrom(source: any = {}) {
return new ServiceConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.api_key = source["api_key"];
this.email = source["email"];
this.password = source["password"];
this.callsign = source["callsign"];
this.force_station_callsign = source["force_station_callsign"];
this.auto_upload = source["auto_upload"];
this.upload_mode = source["upload_mode"];
}
}
export class ExternalServices {
qrz: ServiceConfig;
clublog: ServiceConfig;
static createFrom(source: any = {}) {
return new ExternalServices(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.qrz = this.convertValues(source["qrz"], ServiceConfig);
this.clublog = this.convertValues(source["clublog"], ServiceConfig);
}
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 lookup { export namespace lookup {
export class Result { export class Result {
@@ -403,6 +464,7 @@ export namespace main {
eqsl_rcvd: string; eqsl_rcvd: string;
clublog_status: string; clublog_status: string;
hrdlog_status: string; hrdlog_status: string;
qrzcom_status: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new QSLDefaults(source); return new QSLDefaults(source);
@@ -418,6 +480,7 @@ export namespace main {
this.eqsl_rcvd = source["eqsl_rcvd"]; this.eqsl_rcvd = source["eqsl_rcvd"];
this.clublog_status = source["clublog_status"]; this.clublog_status = source["clublog_status"];
this.hrdlog_status = source["hrdlog_status"]; this.hrdlog_status = source["hrdlog_status"];
this.qrzcom_status = source["qrzcom_status"];
} }
} }
export class RotatorSettings { export class RotatorSettings {
@@ -847,6 +910,8 @@ export namespace qso {
clublog_qso_upload_status?: string; clublog_qso_upload_status?: string;
hrdlog_qso_upload_date?: string; hrdlog_qso_upload_date?: string;
hrdlog_qso_upload_status?: string; hrdlog_qso_upload_status?: string;
qrzcom_qso_upload_date?: string;
qrzcom_qso_upload_status?: string;
contest_id?: string; contest_id?: string;
srx?: number; srx?: number;
stx?: number; stx?: number;
@@ -950,6 +1015,8 @@ export namespace qso {
this.clublog_qso_upload_status = source["clublog_qso_upload_status"]; this.clublog_qso_upload_status = source["clublog_qso_upload_status"];
this.hrdlog_qso_upload_date = source["hrdlog_qso_upload_date"]; this.hrdlog_qso_upload_date = source["hrdlog_qso_upload_date"];
this.hrdlog_qso_upload_status = source["hrdlog_qso_upload_status"]; this.hrdlog_qso_upload_status = source["hrdlog_qso_upload_status"];
this.qrzcom_qso_upload_date = source["qrzcom_qso_upload_date"];
this.qrzcom_qso_upload_status = source["qrzcom_qso_upload_status"];
this.contest_id = source["contest_id"]; this.contest_id = source["contest_id"];
this.srx = source["srx"]; this.srx = source["srx"];
this.stx = source["stx"]; this.stx = source["stx"];
+14
View File
@@ -77,6 +77,18 @@ func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
return count, err return count, err
} }
// SingleRecordADIF returns one QSO serialised as an ADIF record (fields
// terminated by <EOR>), with no document header. Used by the external-
// service uploaders (QRZ.com / Clublog / …) which want a bare record as
// the ADIF parameter of their HTTP API.
func SingleRecordADIF(q qso.QSO) string {
var b strings.Builder
bw := bufio.NewWriter(&b)
writeRecord(bw, q)
bw.Flush()
return b.String()
}
// writeRecord serialises one QSO as ADIF tags terminated by <EOR>. // writeRecord serialises one QSO as ADIF tags terminated by <EOR>.
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted" // Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
// mode (e.g. FT4 stored without a parent) is exported as the canonical // mode (e.g. FT4 stored without a parent) is exported as the canonical
@@ -155,6 +167,8 @@ func writeRecord(bw *bufio.Writer, q qso.QSO) {
writeField(bw, "CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus) writeField(bw, "CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus)
writeField(bw, "HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate) writeField(bw, "HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate)
writeField(bw, "HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus) writeField(bw, "HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
writeField(bw, "QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
writeField(bw, "QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
// --- Contest --- // --- Contest ---
writeField(bw, "CONTEST_ID", q.ContestID) writeField(bw, "CONTEST_ID", q.ContestID)
+3
View File
@@ -183,6 +183,7 @@ var adifPromoted = stringSet(
"eqsl_qsl_sent", "eqsl_qsl_rcvd", "eqsl_qslsdate", "eqsl_qslrdate", "eqsl_qsl_sent", "eqsl_qsl_rcvd", "eqsl_qslsdate", "eqsl_qslrdate",
"clublog_qso_upload_date", "clublog_qso_upload_status", "clublog_qso_upload_date", "clublog_qso_upload_status",
"hrdlog_qso_upload_date", "hrdlog_qso_upload_status", "hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
"qrzcom_qso_upload_date", "qrzcom_qso_upload_status",
// Contest // Contest
"contest_id", "srx", "stx", "srx_string", "stx_string", "contest_id", "srx", "stx", "srx_string", "stx_string",
"check", "precedence", "arrl_sect", "check", "precedence", "arrl_sect",
@@ -312,6 +313,8 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
q.ClublogUploadStatus = rec["clublog_qso_upload_status"] q.ClublogUploadStatus = rec["clublog_qso_upload_status"]
q.HRDLogUploadDate = rec["hrdlog_qso_upload_date"] q.HRDLogUploadDate = rec["hrdlog_qso_upload_date"]
q.HRDLogUploadStatus = rec["hrdlog_qso_upload_status"] q.HRDLogUploadStatus = rec["hrdlog_qso_upload_status"]
q.QRZComUploadDate = rec["qrzcom_qso_upload_date"]
q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
// Contest // Contest
q.ContestID = rec["contest_id"] q.ContestID = rec["contest_id"]
@@ -0,0 +1,6 @@
-- QRZ.com Logbook upload tracking. Like Clublog / HRDLog, QRZ.com is an
-- upload target: we stamp QRZCOM_QSO_UPLOAD_STATUS (and DATE) so OpsLog
-- can track which QSOs still need pushing and round-trip the standard
-- ADIF fields. Confirmations panel exposes the default status.
ALTER TABLE qso ADD COLUMN qrzcom_qso_upload_date TEXT;
ALTER TABLE qso ADD COLUMN qrzcom_qso_upload_status TEXT;
+25 -1
View File
@@ -146,7 +146,11 @@ var dxccByName = map[string]int{
"liechtenstein": 251, "liechtenstein": 251,
"austria": 206, "austria": 206,
"italy": 248, "italy": 248,
"sicily": 225, // Sicily (IT9) and African Italy (IG9/IH9) are cty.dat WAE splits, not
// DXCC entities — they count as Italy (248). Sardinia (IS0) IS its own
// DXCC entity (225) and keeps its number.
"sicily": 248,
"african italy": 248,
"sardinia": 225, "sardinia": 225,
"spain": 281, "spain": 281,
"portugal": 272, "portugal": 272,
@@ -318,3 +322,23 @@ func EntityDXCC(name string) int {
} }
return dxccByName[strings.ToLower(strings.TrimSpace(name))] return dxccByName[strings.ToLower(strings.TrimSpace(name))]
} }
// ctyEntityAliases maps cty.dat's non-DXCC pseudo-entities — the CQ-zone /
// WAE splits AD1C lists separately — onto the ARRL DXCC entity they belong
// to. cty.dat reports e.g. "Sicily" or "Sardinia" so contesters get the
// right zones, but for DXCC (and the COUNTRY field) they are Italy. Add an
// entry here for any other split that should report its parent entity.
var ctyEntityAliases = map[string]string{
"sicily": "Italy",
"african italy": "Italy",
// NB: Sardinia is intentionally absent — it's a real DXCC entity.
}
// CanonicalEntityName normalises a cty.dat entity name to its ARRL DXCC
// entity. Names that already are DXCC entities pass through unchanged.
func CanonicalEntityName(name string) string {
if c, ok := ctyEntityAliases[strings.ToLower(strings.TrimSpace(name))]; ok {
return c
}
return name
}
+14 -2
View File
@@ -181,10 +181,22 @@ func parseEntityHeader(line string) *Entity {
if len(parts) < 8 { if len(parts) < 8 {
return nil return nil
} }
name := strings.TrimSpace(parts[0])
primary := strings.TrimSpace(parts[7])
// cty.dat marks non-DXCC entities (WAE / contest-only zone splits such
// as Sicily *IT9 and African Italy *IG9) with a leading '*' on the
// primary prefix. Those report under their parent DXCC entity. True
// DXCC entities — including Sardinia (IS0) and Corsica (TK) — have no
// '*' and keep their own name. Per-prefix zones/lat-lon are preserved,
// so e.g. IG9 still resolves to CQ 33 / continent AF under "Italy".
if strings.HasPrefix(primary, "*") {
primary = strings.TrimPrefix(primary, "*")
name = CanonicalEntityName(name)
}
e := &Entity{ e := &Entity{
Name: strings.TrimSpace(parts[0]), Name: name,
Continent: strings.TrimSpace(parts[3]), Continent: strings.TrimSpace(parts[3]),
Primary: strings.TrimSpace(parts[7]), Primary: primary,
} }
e.CQZone, _ = strconv.Atoi(strings.TrimSpace(parts[1])) e.CQZone, _ = strconv.Atoi(strings.TrimSpace(parts[1]))
e.ITUZone, _ = strconv.Atoi(strings.TrimSpace(parts[2])) e.ITUZone, _ = strconv.Atoi(strings.TrimSpace(parts[2]))
+48
View File
@@ -54,6 +54,54 @@ func TestLookup(t *testing.T) {
} }
} }
// cty.dat marks non-DXCC entities (Sicily *IT9, African Italy *IG9) with a
// leading '*'; the parser must fold those into their parent DXCC entity
// "Italy" while leaving real DXCC entities — Sardinia (IS0), no '*' — alone.
func TestCanonicalEntityNames(t *testing.T) {
const cty = `Italy: 15: 28: EU: 42.82: -12.58: -1.0: I:
I,IK,IZ;
African Italy: 33: 37: AF: 35.67: -12.67: -1.0: *IG9:
IG9,IH9;
Sardinia: 15: 28: EU: 40.15: -9.27: -1.0: IS0:
IM0,IS,IW0U,IW0V;
Sicily: 15: 28: EU: 37.50: -14.00: -1.0: *IT9:
IT9,IW9;
`
db, err := Load(strings.NewReader(cty))
if err != nil {
t.Fatalf("load: %v", err)
}
cases := map[string]string{
"IW9EZO": "Italy", // Sicily (*IT9) → Italy
"IT9CLY": "Italy",
"IG9A": "Italy", // African Italy (*IG9) → Italy
"IK0ABC": "Italy",
"IS0XYZ": "Sardinia", // real DXCC entity — must stay Sardinia
"IM0ABC": "Sardinia",
}
for call, want := range cases {
m, ok := db.Lookup(call)
if !ok {
t.Errorf("%s: no match", call)
continue
}
if m.Entity.Name != want {
t.Errorf("%s: country = %q, want %q", call, m.Entity.Name, want)
}
}
// African Italy keeps its own zones/continent even though it reports
// Italy as the entity.
if m, _ := db.Lookup("IG9A"); m.CQZone != 33 || m.Continent != "AF" {
t.Errorf("IG9A: got CQ=%d cont=%s, want 33/AF", m.CQZone, m.Continent)
}
if EntityDXCC("Sicily") != 248 {
t.Errorf("EntityDXCC(Sicily) = %d, want 248 (Italy)", EntityDXCC("Sicily"))
}
if EntityDXCC("Sardinia") != 225 {
t.Errorf("EntityDXCC(Sardinia) = %d, want 225", EntityDXCC("Sardinia"))
}
}
func TestNormalize(t *testing.T) { func TestNormalize(t *testing.T) {
cases := map[string]string{ cases := map[string]string{
"F4BPO": "F4BPO", "F4BPO": "F4BPO",
+123
View File
@@ -0,0 +1,123 @@
package extsvc
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// clublogRealtimeURL is Club Log's real-time single-QSO upload endpoint.
// (Batch ADIF goes to putlogs.php; we push one record per logged QSO.)
const clublogRealtimeURL = "https://clublog.org/realtime.php"
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
// requires an api parameter that identifies the client software (not the
// user) — the same way Log4OM embeds its own key — so we ship it baked in
// rather than asking each user for one. It's an application identifier, not
// a user secret, but note it is visible in the source and the binary.
const clublogAppAPIKey = "5767f19333363a9ef432ee9cd4141fe76b8adf38"
// UploadClublog pushes one ADIF record to Club Log in real time. The user
// supplies the account email + password and the logbook callsign; the
// application API key is embedded (clublogAppAPIKey), so users never need
// one — same UX as Log4OM.
//
// Form params:
//
// email, password, callsign, adif, clientident, api
//
// Club Log replies with HTTP 200 on success; on failure it returns a 4xx/5xx
// status with a plain-text reason in the body.
func UploadClublog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
email := strings.TrimSpace(cfg.Email)
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
switch {
case email == "":
return UploadResult{}, fmt.Errorf("clublog: account email not set")
case cfg.Password == "":
return UploadResult{}, fmt.Errorf("clublog: password not set")
case call == "":
return UploadResult{}, fmt.Errorf("clublog: logbook callsign not set")
case strings.TrimSpace(adifRecord) == "":
return UploadResult{}, fmt.Errorf("clublog: empty adif record")
}
form := url.Values{}
form.Set("email", email)
form.Set("password", cfg.Password)
form.Set("callsign", call)
form.Set("adif", adifRecord)
form.Set("clientident", "OpsLog")
// Club Log requires the application API key. Use OpsLog's embedded key;
// a per-user override (cfg.APIKey) wins if one is ever configured.
api := strings.TrimSpace(cfg.APIKey)
if api == "" {
api = clublogAppAPIKey
}
form.Set("api", api)
res, err := clublogPost(ctx, client, clublogRealtimeURL, form)
if err != nil {
return UploadResult{}, err
}
return res, nil
}
// TestClublog validates the configured credentials by attempting a no-op
// style check. Club Log has no dedicated status endpoint, so we report the
// fields look complete; a real failure surfaces on the first upload.
func TestClublog(ctx context.Context, cfg ServiceConfig) (string, error) {
_ = ctx
switch {
case strings.TrimSpace(cfg.Email) == "":
return "", fmt.Errorf("clublog: account email not set")
case cfg.Password == "":
return "", fmt.Errorf("clublog: password not set")
case strings.TrimSpace(cfg.Callsign) == "":
return "", fmt.Errorf("clublog: logbook callsign not set")
}
return fmt.Sprintf("Ready — %s via %s", strings.ToUpper(strings.TrimSpace(cfg.Callsign)), strings.TrimSpace(cfg.Email)), nil
}
// clublogPost performs the form POST and maps the HTTP status to a result.
func clublogPost(ctx context.Context, client *http.Client, endpoint string, form url.Values) (UploadResult, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
msg := strings.TrimSpace(string(body))
switch {
case resp.StatusCode == http.StatusOK:
return UploadResult{OK: true, Message: msg}, nil
case isClublogDuplicate(resp.StatusCode, msg):
// Club Log rejects an exact duplicate; treat as already-logged.
return UploadResult{OK: true, Message: "already in logbook"}, nil
default:
if msg == "" {
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
return UploadResult{OK: false, Message: msg}, fmt.Errorf("clublog: upload failed: %s", msg)
}
}
// isClublogDuplicate recognises Club Log's "already have this QSO" rejection
// so repeated uploads stay idempotent.
func isClublogDuplicate(status int, msg string) bool {
m := strings.ToLower(msg)
return strings.Contains(m, "duplicate") || strings.Contains(m, "already")
}
+90
View File
@@ -0,0 +1,90 @@
// Package extsvc uploads logged QSOs to external logbook services
// (QRZ.com first; Clublog and LoTW to follow). Each service has its own
// credentials and an upload mode chosen per-service: "immediate" pushes as
// soon as the QSO is saved, "delayed" waits a random 12 minutes (like
// Log4OM) so a mistakenly-logged QSO can still be edited or removed before
// it leaves.
//
// The Manager is intentionally fire-and-forget: a failed or skipped upload
// just leaves the QSO's per-service upload-status column empty, and the
// (future) manual-upload window will let the user retry the backlog.
package extsvc
import (
"errors"
"strings"
)
// errFromResult turns a non-OK result with no transport error into one
// (defensive — uploaders normally return an error alongside !OK).
func errFromResult(r UploadResult) error {
if r.Message != "" {
return errors.New(r.Message)
}
return errors.New("upload rejected")
}
// Service identifies one external logbook.
type Service string
const (
ServiceQRZ Service = "qrz" // QRZ.com Logbook
ServiceClublog Service = "clublog" // Club Log real-time upload
// ServiceLoTW to come.
)
// UploadMode selects when an auto-upload fires after a QSO is saved.
type UploadMode string
const (
// ModeImmediate uploads as soon as the QSO is logged.
ModeImmediate UploadMode = "immediate"
// ModeDelayed waits a random 12 minutes before uploading.
ModeDelayed UploadMode = "delayed"
)
// ServiceConfig is the per-service user configuration. It's a superset of
// the credential shapes the different services need — each service reads
// only the fields it uses:
//
// QRZ.com → APIKey, ForceStationCallsign
// Club Log → Email, Password, Callsign, APIKey
//
// AutoUpload + UploadMode are common to all (timing is per-service, so the
// user can run e.g. Club Log immediate and QRZ delayed).
type ServiceConfig struct {
APIKey string `json:"api_key"`
Email string `json:"email"` // Club Log account email
Password string `json:"password"` // Club Log account password
Callsign string `json:"callsign"` // Club Log logbook (owner) callsign
ForceStationCallsign string `json:"force_station_callsign"` // QRZ
AutoUpload bool `json:"auto_upload"`
UploadMode UploadMode `json:"upload_mode"`
}
// normalised returns the config with whitespace trimmed and a valid upload
// mode (defaults to immediate).
func (c ServiceConfig) normalised() ServiceConfig {
c.APIKey = strings.TrimSpace(c.APIKey)
c.Email = strings.TrimSpace(c.Email)
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign))
if c.UploadMode != ModeDelayed {
c.UploadMode = ModeImmediate
}
return c
}
// ExternalServices bundles every service's config for the settings UI.
// LoTW fields will be added as that service lands.
type ExternalServices struct {
QRZ ServiceConfig `json:"qrz"`
Clublog ServiceConfig `json:"clublog"`
}
// UploadResult is the outcome of a single upload attempt.
type UploadResult struct {
OK bool // the service accepted (or already had) the QSO
LogID string // service-assigned record id, when provided
Message string // human-readable detail (reason on failure)
}
+160
View File
@@ -0,0 +1,160 @@
package extsvc
import (
"context"
"math/rand"
"net/http"
"sync"
"time"
)
// Deps are the host-app callbacks the Manager needs. Keeping them as
// function fields decouples extsvc from the qso/adif/settings packages and
// keeps the upload-scheduling logic testable.
type Deps struct {
Client *http.Client
// BuildADIF returns the ADIF record for a QSO id, with STATION_CALLSIGN
// overridden by forceCall when non-empty. ok=false means "skip silently"
// (row gone, missing required fields, …).
BuildADIF func(id int64, forceCall string) (record string, ok bool)
// MarkUploaded stamps the per-service upload status on the QSO row and
// notifies the UI. Called once, on success.
MarkUploaded func(svc Service, id int64, logID string)
// NotifyError surfaces a failed upload (logging + optional UI event).
NotifyError func(svc Service, id int64, err error)
// Logf is an optional diagnostic logger.
Logf func(format string, args ...any)
}
// Manager owns the external-service config snapshot and schedules uploads
// when a QSO is logged. Immediate uploads run in their own goroutine;
// delayed uploads use a timer with a random 12 minute fuse.
type Manager struct {
deps Deps
mu sync.Mutex
cfg ExternalServices
rnd *rand.Rand
}
func NewManager(deps Deps) *Manager {
if deps.Client == nil {
deps.Client = &http.Client{Timeout: 20 * time.Second}
}
return &Manager{
deps: deps,
// Seeded from the clock; the delay only needs to be unpredictable
// enough to spread bursts, not cryptographically random.
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
func (m *Manager) logf(format string, args ...any) {
if m.deps.Logf != nil {
m.deps.Logf(format, args...)
}
}
// SetConfig replaces the active config snapshot (called after the user
// saves the External Services settings).
func (m *Manager) SetConfig(cfg ExternalServices) {
m.mu.Lock()
defer m.mu.Unlock()
cfg.QRZ = cfg.QRZ.normalised()
m.cfg = cfg
}
// Config returns the current snapshot.
func (m *Manager) Config() ExternalServices {
m.mu.Lock()
defer m.mu.Unlock()
return m.cfg
}
// delaySeconds returns a random 60120s fuse for delayed uploads.
func (m *Manager) delaySeconds() time.Duration {
m.mu.Lock()
d := 60 + m.rnd.Intn(61) // [60, 120]
m.mu.Unlock()
return time.Duration(d) * time.Second
}
// OnQSOLogged is called after a QSO is inserted (manual entry or UDP
// auto-log). It fans out to every enabled, auto-upload service in the
// configured timing mode. Returns immediately.
func (m *Manager) OnQSOLogged(id int64) {
cfg := m.Config()
// QRZ.com
if qrz := cfg.QRZ; qrz.AutoUpload && qrz.APIKey != "" {
m.scheduleUpload(ServiceQRZ, id, qrz)
}
// Club Log — email + password + callsign are enough (no API key).
if cl := cfg.Clublog; cl.AutoUpload && cl.Email != "" && cl.Password != "" {
m.scheduleUpload(ServiceClublog, id, cl)
}
// LoTW will be added here.
}
// scheduleUpload either uploads now (immediate) or arms a timer (delayed).
func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
if cfg.UploadMode == ModeDelayed {
d := m.delaySeconds()
m.logf("extsvc: %s upload of QSO %d scheduled in %s", svc, id, d)
time.AfterFunc(d, func() { m.upload(svc, id, cfg) })
return
}
go m.upload(svc, id, cfg)
}
// upload performs the actual push. It builds a fresh, lifecycle-independent
// context so a delayed upload still completes even if it fires close to
// shutdown.
func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var res UploadResult
var err error
switch svc {
case ServiceQRZ:
// QRZ rewrites STATION_CALLSIGN to the registered call.
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return
}
res, err = UploadQRZ(ctx, m.deps.Client, cfg.APIKey, record)
case ServiceClublog:
// Club Log takes the logbook callsign as a separate param, so the
// ADIF keeps the QSO's own station call (no override).
record, ok := m.deps.BuildADIF(id, "")
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return
}
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
default:
return
}
if err != nil || !res.OK {
if err == nil {
err = errFromResult(res)
}
m.logf("extsvc: %s upload of QSO %d failed: %v", svc, id, err)
if m.deps.NotifyError != nil {
m.deps.NotifyError(svc, id, err)
}
return
}
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID)
if m.deps.MarkUploaded != nil {
m.deps.MarkUploaded(svc, id, res.LogID)
}
}
+157
View File
@@ -0,0 +1,157 @@
package extsvc
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// qrzAPIURL is the QRZ.com Logbook API endpoint. Note this is the LOGBOOK
// API (key from the logbook's settings page), NOT the XML lookup
// subscription used elsewhere for callsign data — they're different keys.
const qrzAPIURL = "https://logbook.qrz.com/api"
// UploadQRZ pushes one ADIF record to the QRZ.com logbook identified by
// apiKey. It returns OK when the QSO is inserted or already present
// (QRZ reports a duplicate as a FAIL with a "duplicate" reason, which we
// treat as success so retries are idempotent).
//
// API shape (form-encoded POST):
//
// KEY=<logbook key>&ACTION=INSERT&ADIF=<one record>&OPTION=
//
// Response is URL-encoded key/values, e.g.:
//
// STATUS=OK&LOGID=123456&COUNT=1&...
// STATUS=FAIL&REASON=Unable+to+add+QSO+...&...
// STATUS=AUTH&REASON=invalid+api+key&...
func UploadQRZ(ctx context.Context, client *http.Client, apiKey, adifRecord string) (UploadResult, error) {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return UploadResult{}, fmt.Errorf("qrz: api key not set")
}
if strings.TrimSpace(adifRecord) == "" {
return UploadResult{}, fmt.Errorf("qrz: empty adif record")
}
form := url.Values{}
form.Set("KEY", apiKey)
form.Set("ACTION", "INSERT")
// OPTION=REPLACE would overwrite an existing matching QSO; we leave it
// empty so QRZ rejects duplicates (which we map to OK below).
form.Set("ADIF", adifRecord)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, qrzAPIURL, strings.NewReader(form.Encode()))
if err != nil {
return UploadResult{}, fmt.Errorf("qrz: build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return UploadResult{}, fmt.Errorf("qrz: request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return UploadResult{}, fmt.Errorf("qrz: read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return UploadResult{}, fmt.Errorf("qrz: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return parseQRZResponse(string(body))
}
// TestQRZ checks a logbook API key with ACTION=STATUS and returns a short
// human-readable summary (callsign + QSO count) for the settings UI. An
// invalid key comes back as STATUS=AUTH → returned as an error.
func TestQRZ(ctx context.Context, client *http.Client, apiKey string) (string, error) {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return "", fmt.Errorf("qrz: api key not set")
}
form := url.Values{}
form.Set("KEY", apiKey)
form.Set("ACTION", "STATUS")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, qrzAPIURL, strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("qrz: build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("qrz: request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
vals, err := url.ParseQuery(strings.TrimSpace(string(body)))
if err != nil {
return "", fmt.Errorf("qrz: bad response: %w", err)
}
status := strings.ToUpper(strings.TrimSpace(vals.Get("STATUS")))
if status == "AUTH" || status == "FAIL" {
reason := strings.TrimSpace(vals.Get("REASON"))
if reason == "" {
reason = "invalid API key"
}
return "", fmt.Errorf("qrz: %s", reason)
}
call := strings.TrimSpace(vals.Get("CALLSIGN"))
count := strings.TrimSpace(vals.Get("COUNT"))
switch {
case call != "" && count != "":
return fmt.Sprintf("Connected — %s logbook, %s QSOs", call, count), nil
case call != "":
return fmt.Sprintf("Connected — %s logbook", call), nil
default:
return "Connected — key OK", nil
}
}
// parseQRZResponse decodes QRZ's "&"-joined, URL-encoded reply.
func parseQRZResponse(body string) (UploadResult, error) {
vals, err := url.ParseQuery(strings.TrimSpace(body))
if err != nil {
return UploadResult{}, fmt.Errorf("qrz: bad response %q: %w", body, err)
}
status := strings.ToUpper(strings.TrimSpace(vals.Get("STATUS")))
reason := strings.TrimSpace(vals.Get("REASON"))
logID := strings.TrimSpace(vals.Get("LOGID"))
switch status {
case "OK":
return UploadResult{OK: true, LogID: logID, Message: reason}, nil
case "FAIL":
// A duplicate is a benign failure — the QSO is in the logbook, so
// from our side the upload "succeeded". Detect it from the reason.
if isDuplicateReason(reason) {
return UploadResult{OK: true, LogID: logID, Message: "already in logbook"}, nil
}
return UploadResult{OK: false, Message: reason}, fmt.Errorf("qrz: upload failed: %s", reason)
case "AUTH":
return UploadResult{OK: false, Message: reason}, fmt.Errorf("qrz: auth error: %s", reason)
default:
return UploadResult{OK: false, Message: reason}, fmt.Errorf("qrz: unexpected status %q (%s)", status, reason)
}
}
// isDuplicateReason recognises the various phrasings QRZ uses when a QSO is
// already present.
func isDuplicateReason(reason string) bool {
r := strings.ToLower(reason)
return strings.Contains(r, "duplicate") ||
strings.Contains(r, "already") ||
strings.Contains(r, "unable to add") && strings.Contains(r, "exists")
}
+33
View File
@@ -0,0 +1,33 @@
package extsvc
import "testing"
func TestParseQRZResponse(t *testing.T) {
cases := []struct {
name string
body string
wantOK bool
wantErr bool
logID string
}{
{"insert ok", "STATUS=OK&LOGID=123456&COUNT=1", true, false, "123456"},
{"duplicate is ok", "STATUS=FAIL&REASON=Unable+to+add+QSO+duplicate", true, false, ""},
{"already present", "STATUS=FAIL&REASON=QSO+already+in+logbook", true, false, ""},
{"real failure", "STATUS=FAIL&REASON=Bad+ADIF", false, true, ""},
{"auth failure", "STATUS=AUTH&REASON=invalid+api+key", false, true, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
res, err := parseQRZResponse(c.body)
if (err != nil) != c.wantErr {
t.Fatalf("err = %v, wantErr %v", err, c.wantErr)
}
if res.OK != c.wantOK {
t.Errorf("OK = %v, want %v", res.OK, c.wantOK)
}
if c.logID != "" && res.LogID != c.logID {
t.Errorf("LogID = %q, want %q", res.LogID, c.logID)
}
})
}
}
+219
View File
@@ -0,0 +1,219 @@
package udp
import (
"bytes"
"encoding/xml"
"fmt"
"strconv"
"strings"
"time"
)
// N1MM Logger+ broadcasts each logged contact as a UTF-8 XML datagram.
// We care about the two that represent a completed QSO:
//
// <contactinfo> a freshly logged contact
// <contactreplace> an edited contact (same shape; we treat it as a log)
//
// Everything else N1MM emits on the same socket — <spot>, <RadioInfo>,
// <dynamicresults>, <AppInfo>, <contactdelete> — is ignored here (spots
// are a separate feature; deletes/status aren't auto-logged).
//
// N1MM frequencies are in tens of Hz (rxfreq 1402500 == 14.025 MHz), and
// the <band> tag is the band edge in MHz as a bare number ("14", "3.5").
// We derive the ADIF band from the frequency when we have it and fall back
// to the band tag otherwise.
//
// Rather than build a qso.QSO by hand we synthesise an ADIF record and feed
// it back through the same auto-log path WSJT-X uses (LogUDPLoggedADIF):
// that gets us lookup enrichment, DXCC stamping, the operating-conditions
// stamp and dedup for free.
// n1mmContact maps the subset of <contactinfo>/<contactreplace> fields we
// promote into the logbook. Unmapped tags are dropped; anything we keep but
// the ADIF importer doesn't promote lands in the QSO's Extras.
type n1mmContact struct {
Call string `xml:"call"`
Mode string `xml:"mode"`
Band string `xml:"band"` // band edge in MHz, e.g. "14"
RxFreq string `xml:"rxfreq"` // tens of Hz; string so empty decodes cleanly
TxFreq string `xml:"txfreq"`
Timestamp string `xml:"timestamp"` // "2006-01-02 15:04:05", UTC
MyCall string `xml:"mycall"`
Operator string `xml:"operator"`
Snt string `xml:"snt"`
SntNr string `xml:"sntnr"`
Rcv string `xml:"rcv"`
RcvNr string `xml:"rcvnr"`
Grid string `xml:"gridsquare"`
Name string `xml:"name"`
QTH string `xml:"qth"`
Comment string `xml:"comment"`
Power string `xml:"power"`
ContestName string `xml:"contestname"`
}
// ParseN1MM decodes one N1MM UDP datagram. It returns ok=false (with no
// error) for datagrams that aren't a loggable contact. For a contact it
// returns a synthesised ADIF record ready for the auto-log path.
func ParseN1MM(pkt []byte) (adifText string, ok bool, err error) {
dec := xml.NewDecoder(bytes.NewReader(pkt))
for {
tok, terr := dec.Token()
if terr != nil {
// EOF before any start element, or malformed XML.
return "", false, fmt.Errorf("n1mm: no element: %w", terr)
}
se, isStart := tok.(xml.StartElement)
if !isStart {
continue
}
switch se.Name.Local {
case "contactinfo", "contactreplace":
var c n1mmContact
if derr := dec.DecodeElement(&c, &se); derr != nil {
return "", false, fmt.Errorf("n1mm: decode %s: %w", se.Name.Local, derr)
}
return c.toADIF()
default:
// spot / RadioInfo / dynamicresults / contactdelete / etc.
return "", false, nil
}
}
}
// toADIF turns a parsed contact into an ADIF record string. Returns
// ok=false if the required call/mode/date fields are missing — better to
// skip silently than to hand the auto-log path an unloggable record.
func (c n1mmContact) toADIF() (string, bool, error) {
call := strings.ToUpper(strings.TrimSpace(c.Call))
mode := normaliseN1MMMode(c.Mode)
t, terr := parseN1MMTimestamp(c.Timestamp)
if call == "" || mode == "" || terr != nil {
return "", false, nil
}
var freqHz int64
if raw := strings.TrimSpace(c.RxFreq); raw != "" {
if tens, perr := strconv.ParseInt(raw, 10, 64); perr == nil {
freqHz = tens * 10
}
}
band := bandFromHz(freqHz)
if band == "" {
band = bandFromMHzTag(c.Band)
}
if band == "" {
// No band, no log — the importer requires it.
return "", false, nil
}
var b strings.Builder
writeADIFField(&b, "call", call)
writeADIFField(&b, "qso_date", t.Format("20060102"))
writeADIFField(&b, "time_on", t.Format("150405"))
writeADIFField(&b, "band", band)
writeADIFField(&b, "mode", mode)
if freqHz > 0 {
// MHz with kHz precision, ADIF style: "14.025000".
writeADIFField(&b, "freq", strconv.FormatFloat(float64(freqHz)/1e6, 'f', 6, 64))
}
writeADIFField(&b, "rst_sent", strings.TrimSpace(c.Snt))
writeADIFField(&b, "rst_rcvd", strings.TrimSpace(c.Rcv))
writeADIFField(&b, "gridsquare", strings.TrimSpace(c.Grid))
writeADIFField(&b, "name", strings.TrimSpace(c.Name))
writeADIFField(&b, "qth", strings.TrimSpace(c.QTH))
writeADIFField(&b, "comment", strings.TrimSpace(c.Comment))
writeADIFField(&b, "tx_pwr", strings.TrimSpace(c.Power))
writeADIFField(&b, "operator", strings.ToUpper(strings.TrimSpace(c.MyCall)))
writeADIFField(&b, "contest_id", strings.TrimSpace(c.ContestName))
writeADIFField(&b, "stx", strings.TrimSpace(c.SntNr))
writeADIFField(&b, "srx", strings.TrimSpace(c.RcvNr))
b.WriteString("<eor>\n")
return b.String(), true, nil
}
// writeADIFField appends a single "<name:len>value" field, skipping empties.
func writeADIFField(b *strings.Builder, name, value string) {
if value == "" {
return
}
fmt.Fprintf(b, "<%s:%d>%s", name, len(value), value)
}
// normaliseN1MMMode maps N1MM mode strings onto ADIF modes. N1MM reports
// the sideband (USB/LSB) where ADIF wants the parent mode SSB; everything
// else passes through upper-cased.
func normaliseN1MMMode(mode string) string {
m := strings.ToUpper(strings.TrimSpace(mode))
switch m {
case "USB", "LSB":
return "SSB"
default:
return m
}
}
// parseN1MMTimestamp parses N1MM's "2006-01-02 15:04:05" UTC timestamp.
func parseN1MMTimestamp(ts string) (time.Time, error) {
return time.Parse("2006-01-02 15:04:05", strings.TrimSpace(ts))
}
// n1mmBand is one entry in the band-plan table used to derive an ADIF band
// from a dial frequency.
type n1mmBand struct {
loHz, hiHz int64
name string
}
// bandPlan covers the HF/VHF/UHF allocations a logger is likely to see.
// Ranges are generous (band edges, not country sub-bands) so an out-of-band
// dial reading still maps to the nearest band.
var bandPlan = []n1mmBand{
{1_800_000, 2_000_000, "160m"},
{3_500_000, 4_000_000, "80m"},
{5_060_000, 5_450_000, "60m"},
{7_000_000, 7_300_000, "40m"},
{10_100_000, 10_150_000, "30m"},
{14_000_000, 14_350_000, "20m"},
{18_068_000, 18_168_000, "17m"},
{21_000_000, 21_450_000, "15m"},
{24_890_000, 24_990_000, "12m"},
{28_000_000, 29_700_000, "10m"},
{50_000_000, 54_000_000, "6m"},
{70_000_000, 71_000_000, "4m"},
{144_000_000, 148_000_000, "2m"},
{222_000_000, 225_000_000, "1.25m"},
{420_000_000, 450_000_000, "70cm"},
{902_000_000, 928_000_000, "33cm"},
{1_240_000_000, 1_300_000_000, "23cm"},
}
// bandFromHz returns the ADIF band token for a dial frequency, or "" when
// the frequency is zero or outside every known allocation.
func bandFromHz(hz int64) string {
if hz <= 0 {
return ""
}
for _, b := range bandPlan {
if hz >= b.loHz && hz <= b.hiHz {
return b.name
}
}
return ""
}
// bandFromMHzTag maps N1MM's bare-MHz <band> tag ("14", "3.5") onto an ADIF
// band by treating it as a frequency at the band's low edge.
func bandFromMHzTag(tag string) string {
tag = strings.TrimSpace(tag)
if tag == "" {
return ""
}
mhz, err := strconv.ParseFloat(tag, 64)
if err != nil {
return ""
}
// Nudge just inside the low edge so e.g. "14" lands in 20m.
return bandFromHz(int64(mhz*1_000_000) + 1)
}
+103
View File
@@ -0,0 +1,103 @@
package udp
import (
"strings"
"testing"
)
// A representative N1MM+ <contactinfo> datagram (trimmed to the fields we
// read). rxfreq 1402500 tens-of-Hz == 14.025 MHz → 20m.
const sampleContactInfo = `<?xml version="1.0" encoding="utf-8"?>
<contactinfo>
<contestname>DX</contestname>
<timestamp>2024-03-15 14:25:30</timestamp>
<mycall>K1ABC</mycall>
<band>14</band>
<rxfreq>1402500</rxfreq>
<txfreq>1402500</txfreq>
<operator>K1ABC</operator>
<mode>CW</mode>
<call>VE9AA</call>
<snt>599</snt>
<sntnr>1</sntnr>
<rcv>599</rcv>
<rcvnr>42</rcvnr>
<gridsquare>FN65</gridsquare>
<name>Mike</name>
<comment>tnx</comment>
<power>100</power>
</contactinfo>`
func TestParseN1MMContactInfo(t *testing.T) {
adif, ok, err := ParseN1MM([]byte(sampleContactInfo))
if err != nil {
t.Fatalf("ParseN1MM error: %v", err)
}
if !ok {
t.Fatal("expected a loggable contact, got ok=false")
}
want := map[string]string{
"<call:5>VE9AA": "callsign",
"<qso_date:8>20240315": "date",
"<time_on:6>142530": "time",
"<band:3>20m": "band",
"<mode:2>CW": "mode",
"<freq:9>14.025000": "freq",
"<rst_sent:3>599": "rst sent",
"<rst_rcvd:3>599": "rst rcvd",
"<gridsquare:4>FN65": "grid",
"<name:4>Mike": "name",
"<stx:1>1": "stx serial",
"<srx:2>42": "srx serial",
"<eor>": "terminator",
}
for sub, label := range want {
if !strings.Contains(adif, sub) {
t.Errorf("missing %s field %q in:\n%s", label, sub, adif)
}
}
}
func TestParseN1MMSSBMapping(t *testing.T) {
pkt := `<contactinfo><call>F4XYZ</call><mode>USB</mode><band>14</band><rxfreq>1420000</rxfreq><timestamp>2024-01-01 00:00:00</timestamp></contactinfo>`
adif, ok, err := ParseN1MM([]byte(pkt))
if err != nil || !ok {
t.Fatalf("ParseN1MM ok=%v err=%v", ok, err)
}
if !strings.Contains(adif, "<mode:3>SSB") {
t.Errorf("USB should map to SSB, got:\n%s", adif)
}
}
func TestParseN1MMIgnoresNonContacts(t *testing.T) {
for _, pkt := range []string{
`<RadioInfo><app>N1MM</app><freq>1402500</freq></RadioInfo>`,
`<spot><dxcall>VE9AA</dxcall><frequency>14025</frequency></spot>`,
`<contactdelete><call>VE9AA</call></contactdelete>`,
} {
_, ok, err := ParseN1MM([]byte(pkt))
if err != nil {
t.Errorf("unexpected error for %q: %v", pkt, err)
}
if ok {
t.Errorf("expected ok=false (ignored) for %q", pkt)
}
}
}
func TestBandFromHz(t *testing.T) {
cases := map[int64]string{
14_025_000: "20m",
7_100_000: "40m",
3_650_000: "80m",
28_400_000: "10m",
144_200_000: "2m",
0: "",
15_000_000: "", // between 20m and 17m → no band
}
for hz, want := range cases {
if got := bandFromHz(hz); got != want {
t.Errorf("bandFromHz(%d) = %q, want %q", hz, got, want)
}
}
}
+24 -5
View File
@@ -45,8 +45,7 @@ type Event struct {
DXGrid string // ServiceWSJT (Status) DXGrid string // ServiceWSJT (Status)
Mode string // ServiceWSJT (Status) Mode string // ServiceWSJT (Status)
FreqHz int64 // ServiceWSJT (Status) FreqHz int64 // ServiceWSJT (Status)
LoggedADIF string // ServiceWSJT (LoggedADIF) or ServiceADIF LoggedADIF string // ServiceWSJT (LoggedADIF), ServiceADIF or ServiceN1MM
RawText string // generic fallback (n1mm xml, etc.)
} }
// Server is a single inbound UDP listener. // Server is a single inbound UDP listener.
@@ -187,7 +186,17 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
ev.FreqHz = w.FreqHz ev.FreqHz = w.FreqHz
ev.LoggedADIF = w.LoggedADIF ev.LoggedADIF = w.LoggedADIF
case ServiceADIF: case ServiceADIF:
ev.LoggedADIF = string(pkt) // JTAlert / GridTracker forward a text ADIF record after a QSO is
// logged. Guard against keep-alive / non-ADIF chatter on the socket:
// only forward payloads that actually carry a callsign field and a
// record terminator.
text := string(pkt)
low := strings.ToLower(text)
if !strings.Contains(low, "<call:") || !strings.Contains(low, "<eor") {
applog.Printf("udp: [%s] ADIF payload ignored (no <call:>/<eor>)\n", s.cfg.Name)
return
}
ev.LoggedADIF = text
case ServiceRemoteCall: case ServiceRemoteCall:
// Common payload shapes seen in the wild: // Common payload shapes seen in the wild:
// "F4XYZ" (bare callsign) // "F4XYZ" (bare callsign)
@@ -217,12 +226,22 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
} }
ev.DXCall = strings.ToUpper(parts[len(parts)-1]) ev.DXCall = strings.ToUpper(parts[len(parts)-1])
case ServiceN1MM: case ServiceN1MM:
ev.RawText = string(pkt) adifText, ok, err := ParseN1MM(pkt)
if err != nil {
applog.Printf("udp: [%s] N1MM parse error: %v\n", s.cfg.Name, err)
return
}
if !ok {
applog.Printf("udp: [%s] N1MM datagram ignored (not a loggable contact)\n", s.cfg.Name)
return
}
applog.Printf("udp: [%s] N1MM contact decoded (%d bytes ADIF)\n", s.cfg.Name, len(adifText))
ev.LoggedADIF = adifText
default: default:
return return
} }
// Empty events are useless; skip. // Empty events are useless; skip.
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.RawText == "" { if ev.DXCall == "" && ev.LoggedADIF == "" {
return return
} }
select { select {
+10 -5
View File
@@ -88,16 +88,21 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
return Result{}, fmt.Errorf("empty callsign") return Result{}, fmt.Errorf("empty callsign")
} }
if r, ok := m.cache.Get(ctx, call); ok {
r.Source = "cache"
return r, nil
}
m.mu.RLock() m.mu.RLock()
providers := append([]Provider(nil), m.providers...) providers := append([]Provider(nil), m.providers...)
dxcc := m.dxcc dxcc := m.dxcc
m.mu.RUnlock() m.mu.RUnlock()
if r, ok := m.cache.Get(ctx, call); ok {
r.Source = "cache"
// Re-assert the authoritative DXCC fields (country/zones/continent)
// from cty.dat on every cache hit — cheap (in-memory) and lets a
// corrected entity mapping (e.g. Sicily → Italy) heal stale cached
// rows without waiting for the TTL to expire.
fillFromDXCC(&r, dxcc)
return r, nil
}
var lastErr error var lastErr error
for _, p := range providers { for _, p := range providers {
r, err := p.Lookup(ctx, call) r, err := p.Lookup(ctx, call)
+36
View File
@@ -76,6 +76,8 @@ type QSO struct {
ClublogUploadStatus string `json:"clublog_qso_upload_status,omitempty"` ClublogUploadStatus string `json:"clublog_qso_upload_status,omitempty"`
HRDLogUploadDate string `json:"hrdlog_qso_upload_date,omitempty"` HRDLogUploadDate string `json:"hrdlog_qso_upload_date,omitempty"`
HRDLogUploadStatus string `json:"hrdlog_qso_upload_status,omitempty"` HRDLogUploadStatus string `json:"hrdlog_qso_upload_status,omitempty"`
QRZComUploadDate string `json:"qrzcom_qso_upload_date,omitempty"`
QRZComUploadStatus string `json:"qrzcom_qso_upload_status,omitempty"`
// --- Contest --- // --- Contest ---
ContestID string `json:"contest_id,omitempty"` ContestID string `json:"contest_id,omitempty"`
@@ -164,6 +166,7 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
eqsl_sent, eqsl_rcvd, eqsl_sent_date, eqsl_rcvd_date, eqsl_sent, eqsl_rcvd, eqsl_sent_date, eqsl_rcvd_date,
clublog_qso_upload_date, clublog_qso_upload_status, clublog_qso_upload_date, clublog_qso_upload_status,
hrdlog_qso_upload_date, hrdlog_qso_upload_status, hrdlog_qso_upload_date, hrdlog_qso_upload_status,
qrzcom_qso_upload_date, qrzcom_qso_upload_status,
contest_id, srx, stx, srx_string, stx_string, check_field, precedence, arrl_sect, contest_id, srx, stx, srx_string, stx_string, check_field, precedence, arrl_sect,
prop_mode, sat_name, sat_mode, ant_az, ant_el, ant_path, prop_mode, sat_name, sat_mode, ant_az, ant_el, ant_path,
station_callsign, operator, my_grid, my_gridsquare_ext, my_country, my_state, my_cnty, my_iota, station_callsign, operator, my_grid, my_gridsquare_ext, my_country, my_state, my_cnty, my_iota,
@@ -215,6 +218,7 @@ func (q *QSO) args() []any {
q.EQSLSent, q.EQSLRcvd, q.EQSLSentDate, q.EQSLRcvdDate, q.EQSLSent, q.EQSLRcvd, q.EQSLSentDate, q.EQSLRcvdDate,
q.ClublogUploadDate, q.ClublogUploadStatus, q.ClublogUploadDate, q.ClublogUploadStatus,
q.HRDLogUploadDate, q.HRDLogUploadStatus, q.HRDLogUploadDate, q.HRDLogUploadStatus,
q.QRZComUploadDate, q.QRZComUploadStatus,
q.ContestID, q.SRX, q.STX, q.SRXString, q.STXString, q.Check, q.Precedence, q.ARRLSect, q.ContestID, q.SRX, q.STX, q.SRXString, q.STXString, q.Check, q.Precedence, q.ARRLSect,
q.PropMode, q.SatName, q.SatMode, q.AntAz, q.AntEl, q.AntPath, q.PropMode, q.SatName, q.SatMode, q.AntAz, q.AntEl, q.AntPath,
q.StationCallsign, q.Operator, q.MyGrid, q.MyGridExt, q.MyCountry, q.MyState, q.MyCounty, q.MyIOTA, q.StationCallsign, q.Operator, q.MyGrid, q.MyGridExt, q.MyCountry, q.MyState, q.MyCounty, q.MyIOTA,
@@ -319,6 +323,34 @@ func (r *Repo) GetByID(ctx context.Context, id int64) (QSO, error) {
return scanQSO(row) return scanQSO(row)
} }
// MarkQRZUploaded stamps QRZCOM_QSO_UPLOAD_STATUS=Y and the upload date on
// a QSO after a successful push to the QRZ.com logbook. date is an ADIF
// YYYYMMDD string. Only the two QRZ columns are touched — no full-row
// rewrite — so it's safe to call from the async upload path.
func (r *Repo) MarkQRZUploaded(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE qso SET qrzcom_qso_upload_status = 'Y', qrzcom_qso_upload_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
date, id)
if err != nil {
return fmt.Errorf("mark qrz uploaded %d: %w", id, err)
}
return nil
}
// MarkClublogUploaded stamps CLUBLOG_QSO_UPLOAD_STATUS=Y and the upload
// date after a successful Club Log push. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkClublogUploaded(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE qso SET clublog_qso_upload_status = 'Y', clublog_qso_upload_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
date, id)
if err != nil {
return fmt.Errorf("mark clublog uploaded %d: %w", id, err)
}
return nil
}
// Update overwrites all editable fields of an existing QSO. updated_at is bumped. // Update overwrites all editable fields of an existing QSO. updated_at is bumped.
func (r *Repo) Update(ctx context.Context, q QSO) error { func (r *Repo) Update(ctx context.Context, q QSO) error {
if q.ID == 0 { if q.ID == 0 {
@@ -1004,6 +1036,7 @@ func scanQSO(s scanner) (QSO, error) {
eqslSentDate, eqslRcvdDate sql.NullString eqslSentDate, eqslRcvdDate sql.NullString
clublogDate, clublogStatus sql.NullString clublogDate, clublogStatus sql.NullString
hrdlogDate, hrdlogStatus sql.NullString hrdlogDate, hrdlogStatus sql.NullString
qrzcomDate, qrzcomStatus sql.NullString
contestID sql.NullString contestID sql.NullString
srx, stx sql.NullInt64 srx, stx sql.NullInt64
srxStr, stxStr sql.NullString srxStr, stxStr sql.NullString
@@ -1035,6 +1068,7 @@ func scanQSO(s scanner) (QSO, error) {
&eqslSent, &eqslRcvd, &eqslSentDate, &eqslRcvdDate, &eqslSent, &eqslRcvd, &eqslSentDate, &eqslRcvdDate,
&clublogDate, &clublogStatus, &clublogDate, &clublogStatus,
&hrdlogDate, &hrdlogStatus, &hrdlogDate, &hrdlogStatus,
&qrzcomDate, &qrzcomStatus,
&contestID, &srx, &stx, &srxStr, &stxStr, &checkField, &precedence, &arrlSect, &contestID, &srx, &stx, &srxStr, &stxStr, &checkField, &precedence, &arrlSect,
&propMode, &satName, &satMode, &antAz, &antEl, &antPath, &propMode, &satName, &satMode, &antAz, &antEl, &antPath,
&stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA, &stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA,
@@ -1120,6 +1154,8 @@ func scanQSO(s scanner) (QSO, error) {
q.ClublogUploadStatus = clublogStatus.String q.ClublogUploadStatus = clublogStatus.String
q.HRDLogUploadDate = hrdlogDate.String q.HRDLogUploadDate = hrdlogDate.String
q.HRDLogUploadStatus = hrdlogStatus.String q.HRDLogUploadStatus = hrdlogStatus.String
q.QRZComUploadDate = qrzcomDate.String
q.QRZComUploadStatus = qrzcomStatus.String
q.ContestID = contestID.String q.ContestID = contestID.String
if srx.Valid { if srx.Valid {
v := int(srx.Int64) v := int(srx.Int64)