feat: upload to external services clublog qrz
This commit is contained in:
@@ -19,6 +19,7 @@ import (
|
||||
"hamlog/internal/cat"
|
||||
"hamlog/internal/cluster"
|
||||
"hamlog/internal/db"
|
||||
"hamlog/internal/extsvc"
|
||||
"hamlog/internal/integrations/udp"
|
||||
"hamlog/internal/operating"
|
||||
"hamlog/internal/dxcc"
|
||||
@@ -83,6 +84,21 @@ const (
|
||||
keyQSLDefaultEQSLRcvd = "qsl.eqsl_rcvd"
|
||||
keyQSLDefaultClublogStatus = "qsl.clublog_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
|
||||
@@ -99,6 +115,7 @@ type QSLDefaults struct {
|
||||
EQSLRcvd string `json:"eqsl_rcvd"`
|
||||
ClublogStatus string `json:"clublog_status"`
|
||||
HRDLogStatus string `json:"hrdlog_status"`
|
||||
QRZComStatus string `json:"qrzcom_status"`
|
||||
}
|
||||
|
||||
// CATSettings is the user-tweakable rig-control configuration. Stored as
|
||||
@@ -184,6 +201,7 @@ type App struct {
|
||||
operating *operating.Repo
|
||||
udp *udp.Manager
|
||||
udpRepo *udp.Repo
|
||||
extsvc *extsvc.Manager
|
||||
startupErr string // captured for surfacing to the frontend
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -648,7 +677,11 @@ func (a *App) AddQSO(q qso.QSO) (int64, error) {
|
||||
a.applyStationDefaults(&q)
|
||||
a.applyDXCCNumber(&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
|
||||
@@ -1171,6 +1204,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
|
||||
keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd,
|
||||
keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd,
|
||||
keyQSLDefaultClublogStatus, keyQSLDefaultHRDLogStatus,
|
||||
keyQSLDefaultQRZComStatus,
|
||||
)
|
||||
if err != nil {
|
||||
return out, err
|
||||
@@ -1183,6 +1217,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
|
||||
out.EQSLRcvd = m[keyQSLDefaultEQSLRcvd]
|
||||
out.ClublogStatus = m[keyQSLDefaultClublogStatus]
|
||||
out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus]
|
||||
out.QRZComStatus = m[keyQSLDefaultQRZComStatus]
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -1201,6 +1236,7 @@ func (a *App) SaveQSLDefaults(d QSLDefaults) error {
|
||||
keyQSLDefaultEQSLRcvd: strings.ToUpper(strings.TrimSpace(d.EQSLRcvd)),
|
||||
keyQSLDefaultClublogStatus: strings.ToUpper(strings.TrimSpace(d.ClublogStatus)),
|
||||
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 {
|
||||
return err
|
||||
@@ -1229,6 +1265,163 @@ func (a *App) applyQSLDefaults(q *qso.QSO) {
|
||||
if q.EQSLRcvd == "" { q.EQSLRcvd = d.EQSLRcvd }
|
||||
if q.ClublogUploadStatus == "" { q.ClublogUploadStatus = d.ClublogStatus }
|
||||
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 ───────────────────────────────────────────────────
|
||||
@@ -1286,7 +1479,8 @@ func (a *App) ReloadUDPIntegrations() []string {
|
||||
|
||||
// LogUDPLoggedADIF takes an ADIF blob received over UDP and inserts the
|
||||
// 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) {
|
||||
if a.qso == nil {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
@@ -1379,6 +1573,9 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert qso: %w", err)
|
||||
}
|
||||
if a.extsvc != nil {
|
||||
a.extsvc.OnQSOLogged(id)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
|
||||
+36
-3
@@ -35,7 +35,7 @@ import { BandMap } from '@/components/BandMap';
|
||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
||||
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 { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
||||
|
||||
@@ -426,6 +426,10 @@ export default function App() {
|
||||
// already-worked). Otherwise only matching spots pass.
|
||||
type SpotStatusKey = 'new' | 'new-band' | 'new-slot' | 'worked';
|
||||
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 [showBandMap, setShowBandMap] = useState(false);
|
||||
type SortKey = 'time' | 'call' | 'freq' | 'band' | 'mode' | 'spotter' | 'source';
|
||||
@@ -1279,7 +1283,6 @@ export default function App() {
|
||||
className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card"
|
||||
value={callsign}
|
||||
onChange={(e) => onCallsignInput(e.target.value)}
|
||||
placeholder="F4XYZ"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-24">
|
||||
@@ -1443,7 +1446,7 @@ export default function App() {
|
||||
<Input value={comment} onChange={(e) => setComment(e.target.value)} />
|
||||
</div>
|
||||
{!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)} />
|
||||
</div>
|
||||
)}
|
||||
@@ -1777,6 +1780,32 @@ export default function App() {
|
||||
</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" />
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||
<Checkbox checked={clusterGroup} onCheckedChange={(c) => setClusterGroup(!!c)} />
|
||||
@@ -1808,6 +1837,10 @@ export default function App() {
|
||||
const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz);
|
||||
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) {
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
const st = spotStatus[k]?.status || '';
|
||||
|
||||
@@ -160,13 +160,47 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
filtered.push(s);
|
||||
}
|
||||
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[] = [];
|
||||
let prevY = -Infinity;
|
||||
for (const s of filtered) {
|
||||
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;
|
||||
for (let i = 0; i < n; i++) {
|
||||
out.push({ spot: filtered[i], freqY: desired[i], labelY: centers[i] + shift - PILL_H / 2 });
|
||||
}
|
||||
const lastLabelBottom = out.length ? out[out.length - 1].labelY + PILL_H : 0;
|
||||
return {
|
||||
|
||||
@@ -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="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="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>
|
||||
</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: '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: '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 ──
|
||||
{ group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 },
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||
ChevronDown, ChevronRight,
|
||||
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
||||
Compass, Wifi, Construction,
|
||||
Compass, Wifi, Construction, UploadCloud,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||
GetQSLDefaults, SaveQSLDefaults,
|
||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
||||
ComputeStationInfo,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||
@@ -167,6 +168,7 @@ type SectionId =
|
||||
| 'profiles'
|
||||
| 'operating'
|
||||
| 'confirmations'
|
||||
| 'external-services'
|
||||
| 'udp'
|
||||
| 'lookup'
|
||||
| 'lists-bands'
|
||||
@@ -190,6 +192,7 @@ const TREE: TreeNode[] = [
|
||||
{ kind: 'item', label: 'Profiles (portable, home, contest)', id: 'profiles' },
|
||||
{ kind: 'item', label: 'Operating conditions (rigs & antennas)', id: 'operating' },
|
||||
{ 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',
|
||||
operating: 'Operating conditions',
|
||||
confirmations: 'Confirmations',
|
||||
'external-services': 'External services',
|
||||
lookup: 'Callsign Lookup',
|
||||
'lists-bands': 'Bands',
|
||||
'lists-modes': 'Modes & default RST',
|
||||
@@ -368,15 +372,38 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
qsl_sent: string; qsl_rcvd: string;
|
||||
lotw_sent: string; lotw_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>({
|
||||
qsl_sent: '', qsl_rcvd: '',
|
||||
lotw_sent: '', lotw_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>({
|
||||
enabled: false, folder: '', rotation: 5, zip: false,
|
||||
last_backup_at: '', default_folder: '',
|
||||
@@ -450,9 +477,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
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(),
|
||||
GetRotatorSettings(), GetBackupSettings(), GetQSLDefaults(),
|
||||
GetRotatorSettings(), GetBackupSettings(), GetQSLDefaults(), GetExternalServices(),
|
||||
]);
|
||||
setLookup(l);
|
||||
setActiveProfile(ap as Profile);
|
||||
@@ -463,6 +490,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
setRotator(r);
|
||||
setBackupCfg(b as any);
|
||||
setQslDefaults(qd as any);
|
||||
setExtSvc(es as any);
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
} finally {
|
||||
@@ -582,6 +610,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
await SaveRotatorSettings(rotator as any);
|
||||
await SaveBackupSettings(backupCfg as any);
|
||||
await SaveQSLDefaults(qslDefaults as any);
|
||||
await SaveExternalServices(extSvc as any);
|
||||
await SetClusterAutoConnect(clusterAutoConnect);
|
||||
|
||||
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="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>
|
||||
{/* Clublog */}
|
||||
<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>
|
||||
{/* 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>
|
||||
</>
|
||||
@@ -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 1–2 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 (1–2 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 (1–2 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).
|
||||
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||||
station: StationPanel,
|
||||
profiles: ProfilesPanel,
|
||||
operating: OperatingPanelWrapper,
|
||||
confirmations: ConfirmationsPanel,
|
||||
'external-services': ExternalServicesPanel,
|
||||
lookup: LookupPanel,
|
||||
'lists-bands': BandsPanel,
|
||||
'lists-modes': ModesPanel,
|
||||
|
||||
@@ -59,6 +59,20 @@ export function inferSpotMode(comment: string, freqHz: number): string {
|
||||
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
|
||||
// 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
|
||||
|
||||
Vendored
+9
@@ -6,6 +6,7 @@ import {profile} from '../models';
|
||||
import {adif} from '../models';
|
||||
import {cat} from '../models';
|
||||
import {cluster} from '../models';
|
||||
import {extsvc} from '../models';
|
||||
import {operating} from '../models';
|
||||
import {udp} 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 GetExternalServices():Promise<extsvc.ExternalServices>;
|
||||
|
||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||
|
||||
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 SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>;
|
||||
|
||||
export function SaveListsSettings(arg1:main.ListsSettings):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 TestClublogUpload():Promise<string>;
|
||||
|
||||
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 UpdateQSO(arg1:qso.QSO):Promise<void>;
|
||||
|
||||
@@ -106,6 +106,10 @@ export function GetCtyDatInfo() {
|
||||
return window['go']['main']['App']['GetCtyDatInfo']();
|
||||
}
|
||||
|
||||
export function GetExternalServices() {
|
||||
return window['go']['main']['App']['GetExternalServices']();
|
||||
}
|
||||
|
||||
export function GetListsSettings() {
|
||||
return window['go']['main']['App']['GetListsSettings']();
|
||||
}
|
||||
@@ -226,6 +230,10 @@ export function SaveClusterServer(arg1) {
|
||||
return window['go']['main']['App']['SaveClusterServer'](arg1);
|
||||
}
|
||||
|
||||
export function SaveExternalServices(arg1) {
|
||||
return window['go']['main']['App']['SaveExternalServices'](arg1);
|
||||
}
|
||||
|
||||
export function SaveListsSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveListsSettings'](arg1);
|
||||
}
|
||||
@@ -286,10 +294,18 @@ export function 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) {
|
||||
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function TestQRZUpload() {
|
||||
return window['go']['main']['App']['TestQRZUpload']();
|
||||
}
|
||||
|
||||
export function TestRotator(arg1) {
|
||||
return window['go']['main']['App']['TestRotator'](arg1);
|
||||
}
|
||||
|
||||
@@ -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 class Result {
|
||||
@@ -403,6 +464,7 @@ export namespace main {
|
||||
eqsl_rcvd: string;
|
||||
clublog_status: string;
|
||||
hrdlog_status: string;
|
||||
qrzcom_status: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new QSLDefaults(source);
|
||||
@@ -418,6 +480,7 @@ export namespace main {
|
||||
this.eqsl_rcvd = source["eqsl_rcvd"];
|
||||
this.clublog_status = source["clublog_status"];
|
||||
this.hrdlog_status = source["hrdlog_status"];
|
||||
this.qrzcom_status = source["qrzcom_status"];
|
||||
}
|
||||
}
|
||||
export class RotatorSettings {
|
||||
@@ -847,6 +910,8 @@ export namespace qso {
|
||||
clublog_qso_upload_status?: string;
|
||||
hrdlog_qso_upload_date?: string;
|
||||
hrdlog_qso_upload_status?: string;
|
||||
qrzcom_qso_upload_date?: string;
|
||||
qrzcom_qso_upload_status?: string;
|
||||
contest_id?: string;
|
||||
srx?: number;
|
||||
stx?: number;
|
||||
@@ -950,6 +1015,8 @@ export namespace qso {
|
||||
this.clublog_qso_upload_status = source["clublog_qso_upload_status"];
|
||||
this.hrdlog_qso_upload_date = source["hrdlog_qso_upload_date"];
|
||||
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.srx = source["srx"];
|
||||
this.stx = source["stx"];
|
||||
|
||||
@@ -77,6 +77,18 @@ func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
|
||||
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>.
|
||||
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
||||
// 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, "HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate)
|
||||
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 ---
|
||||
writeField(bw, "CONTEST_ID", q.ContestID)
|
||||
|
||||
@@ -183,6 +183,7 @@ var adifPromoted = stringSet(
|
||||
"eqsl_qsl_sent", "eqsl_qsl_rcvd", "eqsl_qslsdate", "eqsl_qslrdate",
|
||||
"clublog_qso_upload_date", "clublog_qso_upload_status",
|
||||
"hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
|
||||
"qrzcom_qso_upload_date", "qrzcom_qso_upload_status",
|
||||
// Contest
|
||||
"contest_id", "srx", "stx", "srx_string", "stx_string",
|
||||
"check", "precedence", "arrl_sect",
|
||||
@@ -312,6 +313,8 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
||||
q.ClublogUploadStatus = rec["clublog_qso_upload_status"]
|
||||
q.HRDLogUploadDate = rec["hrdlog_qso_upload_date"]
|
||||
q.HRDLogUploadStatus = rec["hrdlog_qso_upload_status"]
|
||||
q.QRZComUploadDate = rec["qrzcom_qso_upload_date"]
|
||||
q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
|
||||
|
||||
// Contest
|
||||
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;
|
||||
@@ -146,7 +146,11 @@ var dxccByName = map[string]int{
|
||||
"liechtenstein": 251,
|
||||
"austria": 206,
|
||||
"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,
|
||||
"spain": 281,
|
||||
"portugal": 272,
|
||||
@@ -318,3 +322,23 @@ func EntityDXCC(name string) int {
|
||||
}
|
||||
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
@@ -181,10 +181,22 @@ func parseEntityHeader(line string) *Entity {
|
||||
if len(parts) < 8 {
|
||||
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{
|
||||
Name: strings.TrimSpace(parts[0]),
|
||||
Name: name,
|
||||
Continent: strings.TrimSpace(parts[3]),
|
||||
Primary: strings.TrimSpace(parts[7]),
|
||||
Primary: primary,
|
||||
}
|
||||
e.CQZone, _ = strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
e.ITUZone, _ = strconv.Atoi(strings.TrimSpace(parts[2]))
|
||||
|
||||
@@ -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) {
|
||||
cases := map[string]string{
|
||||
"F4BPO": "F4BPO",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 1–2 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 1–2 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)
|
||||
}
|
||||
@@ -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 1–2 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 60–120s 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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,8 +45,7 @@ type Event struct {
|
||||
DXGrid string // ServiceWSJT (Status)
|
||||
Mode string // ServiceWSJT (Status)
|
||||
FreqHz int64 // ServiceWSJT (Status)
|
||||
LoggedADIF string // ServiceWSJT (LoggedADIF) or ServiceADIF
|
||||
RawText string // generic fallback (n1mm xml, etc.)
|
||||
LoggedADIF string // ServiceWSJT (LoggedADIF), ServiceADIF or ServiceN1MM
|
||||
}
|
||||
|
||||
// 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.LoggedADIF = w.LoggedADIF
|
||||
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:
|
||||
// Common payload shapes seen in the wild:
|
||||
// "F4XYZ" (bare callsign)
|
||||
@@ -217,12 +226,22 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
}
|
||||
ev.DXCall = strings.ToUpper(parts[len(parts)-1])
|
||||
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:
|
||||
return
|
||||
}
|
||||
// Empty events are useless; skip.
|
||||
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.RawText == "" {
|
||||
if ev.DXCall == "" && ev.LoggedADIF == "" {
|
||||
return
|
||||
}
|
||||
select {
|
||||
|
||||
@@ -88,16 +88,21 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
||||
return Result{}, fmt.Errorf("empty callsign")
|
||||
}
|
||||
|
||||
if r, ok := m.cache.Get(ctx, call); ok {
|
||||
r.Source = "cache"
|
||||
return r, nil
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
providers := append([]Provider(nil), m.providers...)
|
||||
dxcc := m.dxcc
|
||||
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
|
||||
for _, p := range providers {
|
||||
r, err := p.Lookup(ctx, call)
|
||||
|
||||
@@ -76,6 +76,8 @@ type QSO struct {
|
||||
ClublogUploadStatus string `json:"clublog_qso_upload_status,omitempty"`
|
||||
HRDLogUploadDate string `json:"hrdlog_qso_upload_date,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 ---
|
||||
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,
|
||||
clublog_qso_upload_date, clublog_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,
|
||||
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,
|
||||
@@ -215,6 +218,7 @@ func (q *QSO) args() []any {
|
||||
q.EQSLSent, q.EQSLRcvd, q.EQSLSentDate, q.EQSLRcvdDate,
|
||||
q.ClublogUploadDate, q.ClublogUploadStatus,
|
||||
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.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,
|
||||
@@ -319,6 +323,34 @@ func (r *Repo) GetByID(ctx context.Context, id int64) (QSO, error) {
|
||||
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.
|
||||
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
||||
if q.ID == 0 {
|
||||
@@ -1004,6 +1036,7 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
eqslSentDate, eqslRcvdDate sql.NullString
|
||||
clublogDate, clublogStatus sql.NullString
|
||||
hrdlogDate, hrdlogStatus sql.NullString
|
||||
qrzcomDate, qrzcomStatus sql.NullString
|
||||
contestID sql.NullString
|
||||
srx, stx sql.NullInt64
|
||||
srxStr, stxStr sql.NullString
|
||||
@@ -1035,6 +1068,7 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
&eqslSent, &eqslRcvd, &eqslSentDate, &eqslRcvdDate,
|
||||
&clublogDate, &clublogStatus,
|
||||
&hrdlogDate, &hrdlogStatus,
|
||||
&qrzcomDate, &qrzcomStatus,
|
||||
&contestID, &srx, &stx, &srxStr, &stxStr, &checkField, &precedence, &arrlSect,
|
||||
&propMode, &satName, &satMode, &antAz, &antEl, &antPath,
|
||||
&stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA,
|
||||
@@ -1120,6 +1154,8 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
q.ClublogUploadStatus = clublogStatus.String
|
||||
q.HRDLogUploadDate = hrdlogDate.String
|
||||
q.HRDLogUploadStatus = hrdlogStatus.String
|
||||
q.QRZComUploadDate = qrzcomDate.String
|
||||
q.QRZComUploadStatus = qrzcomStatus.String
|
||||
q.ContestID = contestID.String
|
||||
if srx.Valid {
|
||||
v := int(srx.Int64)
|
||||
|
||||
Reference in New Issue
Block a user