up
This commit is contained in:
@@ -576,7 +576,7 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
a.dxcc = dxcc.NewManager(dataDir)
|
a.dxcc = dxcc.NewManager(dataDir)
|
||||||
a.lookup.SetDXCCResolver(dxccAdapter{m: a.dxcc})
|
a.lookup.SetDXCCResolver(dxccAdapter{m: a.dxcc})
|
||||||
go func() {
|
go func() {
|
||||||
if err := a.dxcc.EnsureLoaded(context.Background()); err != nil {
|
if err := a.dxcc.EnsureLoaded(a.ctx); err != nil {
|
||||||
fmt.Println("OpsLog: cty.dat unavailable —", err)
|
fmt.Println("OpsLog: cty.dat unavailable —", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -847,6 +847,19 @@ func (a *App) runBackupForShutdown() error {
|
|||||||
return a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339))
|
return a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setSetting persists a key/value and logs (rather than silently swallows) a
|
||||||
|
// failure — used for non-critical settings writes where the caller can't
|
||||||
|
// surface the error but a lost write would still mislead (stale timestamps,
|
||||||
|
// seed markers…).
|
||||||
|
func (a *App) setSetting(key, val string) {
|
||||||
|
if a.settings == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.settings.Set(a.ctx, key, val); err != nil {
|
||||||
|
applog.Printf("settings: set %q failed: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) shutdown(ctx context.Context) {
|
func (a *App) shutdown(ctx context.Context) {
|
||||||
// If the user managed to skip beforeClose (force kill, OS shutdown,
|
// If the user managed to skip beforeClose (force kill, OS shutdown,
|
||||||
// crash recovery) we still try the backup here as a best-effort
|
// crash recovery) we still try the backup here as a best-effort
|
||||||
@@ -1503,13 +1516,13 @@ func (a *App) migrateAwardDefs() {
|
|||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = a.settings.Set(a.ctx, keyAwardDefsFixed, defsFixVersion)
|
a.setSetting(keyAwardDefsFixed, defsFixVersion)
|
||||||
}
|
}
|
||||||
if !changed {
|
if !changed {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if b, err := json.Marshal(migrated); err == nil {
|
if b, err := json.Marshal(migrated); err == nil {
|
||||||
_ = a.settings.Set(a.ctx, keyAwardDefs, string(b))
|
a.setSetting(keyAwardDefs, string(b))
|
||||||
applog.Printf("awards: migrated/fixed %d definitions", len(migrated))
|
applog.Printf("awards: migrated/fixed %d definitions", len(migrated))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1843,12 +1856,10 @@ func (a *App) SyncPOTAHunterLog(addMissing bool, onlyMyCall bool) (POTASyncResul
|
|||||||
case emptyBest >= 0:
|
case emptyBest >= 0:
|
||||||
all[emptyBest].POTARef = e.Reference // stamp regardless of time skew
|
all[emptyBest].POTARef = e.Reference // stamp regardless of time skew
|
||||||
toUpdate[emptyBest] = struct{}{}
|
toUpdate[emptyBest] = struct{}{}
|
||||||
res.Updated++
|
|
||||||
case nonEmptyBest >= 0 && nonEmptyDiff <= nferWindow:
|
case nonEmptyBest >= 0 && nonEmptyDiff <= nferWindow:
|
||||||
// n-fer: same physical QSO at another park.
|
// n-fer: same physical QSO at another park.
|
||||||
all[nonEmptyBest].POTARef += "," + e.Reference
|
all[nonEmptyBest].POTARef += "," + e.Reference
|
||||||
toUpdate[nonEmptyBest] = struct{}{}
|
toUpdate[nonEmptyBest] = struct{}{}
|
||||||
res.Updated++
|
|
||||||
case len(byCall[pota.BaseCall(e.Worked)]) == 0 && addMissing:
|
case len(byCall[pota.BaseCall(e.Worked)]) == 0 && addMissing:
|
||||||
toAdd = append(toAdd, e)
|
toAdd = append(toAdd, e)
|
||||||
default:
|
default:
|
||||||
@@ -1857,8 +1868,14 @@ func (a *App) SyncPOTAHunterLog(addMissing bool, onlyMyCall bool) (POTASyncResul
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Count only QSOs actually written, and log failures — so the report
|
||||||
|
// reflects reality (a DB lock / constraint no longer inflates "updated").
|
||||||
for i := range toUpdate {
|
for i := range toUpdate {
|
||||||
_ = a.qso.Update(a.ctx, all[i])
|
if err := a.qso.Update(a.ctx, all[i]); err != nil {
|
||||||
|
applog.Printf("pota: update QSO %s failed: %v", all[i].Callsign, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res.Updated++
|
||||||
}
|
}
|
||||||
if len(toAdd) > 0 {
|
if len(toAdd) > 0 {
|
||||||
res.Added = a.insertPOTAQSOs(toAdd)
|
res.Added = a.insertPOTAQSOs(toAdd)
|
||||||
@@ -2330,7 +2347,7 @@ func (a *App) UpdateAwardReferenceList(code string) (AwardRefMeta, error) {
|
|||||||
}
|
}
|
||||||
now := time.Now().Format("2006-01-02 15:04")
|
now := time.Now().Format("2006-01-02 15:04")
|
||||||
if a.settings != nil {
|
if a.settings != nil {
|
||||||
_ = a.settings.Set(a.ctx, keyAwardRefsUpdated+strings.ToUpper(code), now)
|
a.setSetting(keyAwardRefsUpdated+strings.ToUpper(code), now)
|
||||||
}
|
}
|
||||||
applog.Printf("award-refs: %s updated — %d references", strings.ToUpper(code), n)
|
applog.Printf("award-refs: %s updated — %d references", strings.ToUpper(code), n)
|
||||||
return AwardRefMeta{Code: strings.ToUpper(code), Count: n, UpdatedAt: now, CanUpdate: true}, nil
|
return AwardRefMeta{Code: strings.ToUpper(code), Count: n, UpdatedAt: now, CanUpdate: true}, nil
|
||||||
@@ -2380,7 +2397,7 @@ func (a *App) ReplaceAwardReferences(code string, refs []awardref.Ref) (int, err
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if a.settings != nil {
|
if a.settings != nil {
|
||||||
_ = a.settings.Set(a.ctx, keyAwardRefsUpdated+strings.ToUpper(code), time.Now().Format("2006-01-02 15:04"))
|
a.setSetting(keyAwardRefsUpdated+strings.ToUpper(code), time.Now().Format("2006-01-02 15:04"))
|
||||||
}
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
@@ -2592,7 +2609,7 @@ func (a *App) seedBuiltinReferences() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = a.settings.Set(a.ctx, keyAwardRefsSeeded, builtinRefsVersion)
|
a.setSetting(keyAwardRefsSeeded, builtinRefsVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportAwardReferencesText parses pasted lines or CSV into references and
|
// ImportAwardReferencesText parses pasted lines or CSV into references and
|
||||||
@@ -4448,7 +4465,7 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
|||||||
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
|
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := a.ctx
|
||||||
uploaded := 0
|
uploaded := 0
|
||||||
|
|
||||||
if svc == extsvc.ServiceLoTW {
|
if svc == extsvc.ServiceLoTW {
|
||||||
@@ -4555,7 +4572,7 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": matched, "total": total})
|
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": matched, "total": total})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := a.ctx
|
||||||
matched, total, added := 0, 0, 0
|
matched, total, added := 0, 0, 0
|
||||||
|
|
||||||
// resolveSince turns the UI's request into a concrete date (or ""):
|
// resolveSince turns the UI's request into a concrete date (or ""):
|
||||||
@@ -4674,7 +4691,7 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
}
|
}
|
||||||
// Remember today so the next pull is incremental (per active profile).
|
// Remember today so the next pull is incremental (per active profile).
|
||||||
if a.settings != nil {
|
if a.settings != nil {
|
||||||
_ = a.settings.Set(ctx, a.profileScope()+keyExtLoTWLastDownload, time.Now().UTC().Format("2006-01-02"))
|
a.setSetting(a.profileScope()+keyExtLoTWLastDownload, time.Now().UTC().Format("2006-01-02"))
|
||||||
}
|
}
|
||||||
|
|
||||||
case extsvc.ServiceQRZ:
|
case extsvc.ServiceQRZ:
|
||||||
@@ -4702,7 +4719,7 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
// late meant the date was never saved, so "since last download" kept
|
// late meant the date was never saved, so "since last download" kept
|
||||||
// resolving to empty and re-pulled everything.
|
// resolving to empty and re-pulled everything.
|
||||||
if a.settings != nil {
|
if a.settings != nil {
|
||||||
_ = a.settings.Set(ctx, a.profileScope()+keyExtQRZLastDownload, time.Now().UTC().Format("2006-01-02"))
|
a.setSetting(a.profileScope()+keyExtQRZLastDownload, time.Now().UTC().Format("2006-01-02"))
|
||||||
}
|
}
|
||||||
if snip := strings.TrimSpace(adifText); snip != "" {
|
if snip := strings.TrimSpace(adifText); snip != "" {
|
||||||
if len(snip) > 300 {
|
if len(snip) > 300 {
|
||||||
@@ -5507,7 +5524,7 @@ func (a *App) RunBackupNow() (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return path, err
|
return path, err
|
||||||
}
|
}
|
||||||
_ = a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339))
|
a.setSetting(keyBackupLast, time.Now().UTC().Format(time.RFC3339))
|
||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5535,7 +5552,7 @@ func (a *App) maybeShutdownBackup() {
|
|||||||
fmt.Println("OpsLog: shutdown backup failed:", err)
|
fmt.Println("OpsLog: shutdown backup failed:", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339))
|
a.setSetting(keyBackupLast, time.Now().UTC().Format(time.RFC3339))
|
||||||
}
|
}
|
||||||
|
|
||||||
// PickBackupFolder opens a native directory picker so the user can browse
|
// PickBackupFolder opens a native directory picker so the user can browse
|
||||||
|
|||||||
+92
-76
@@ -597,15 +597,15 @@ export default function App() {
|
|||||||
const [clusterSearch, setClusterSearch] = useState('');
|
const [clusterSearch, setClusterSearch] = useState('');
|
||||||
// Hide spots already worked (exact call worked, or this band+mode slot done).
|
// Hide spots already worked (exact call worked, or this band+mode slot done).
|
||||||
const [clusterHideWorked, setClusterHideWorked] = useState(false);
|
const [clusterHideWorked, setClusterHideWorked] = useState(false);
|
||||||
const [showBandMap, setShowBandMap] = useState(false);
|
// Bands shown side-by-side in the Band Map tab (portable).
|
||||||
// Which side the band map docks to (persisted). Toggled from its header.
|
const [bandMapBands, setBandMapBands] = useState<string[]>(() => {
|
||||||
const [bandMapSide, setBandMapSide] = useState<'left' | 'right'>(
|
try { const v = JSON.parse(localStorage.getItem('opslog.bandMapBands') || '[]'); return Array.isArray(v) ? v : []; }
|
||||||
() => (localStorage.getItem('bandmap.side') === 'left' ? 'left' : 'right'),
|
catch { return []; }
|
||||||
);
|
});
|
||||||
const toggleBandMapSide = useCallback(() => {
|
const toggleBandMapBand = useCallback((b: string) => {
|
||||||
setBandMapSide((s) => {
|
setBandMapBands((cur) => {
|
||||||
const next = s === 'right' ? 'left' : 'right';
|
const next = cur.includes(b) ? cur.filter((x) => x !== b) : [...cur, b];
|
||||||
writeUiPref('bandmap.side', next);
|
writeUiPref('opslog.bandMapBands', JSON.stringify(next));
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -670,6 +670,10 @@ export default function App() {
|
|||||||
// tell whether an incoming DX call actually changed anything.
|
// tell whether an incoming DX call actually changed anything.
|
||||||
const callsignValRef = useRef('');
|
const callsignValRef = useRef('');
|
||||||
useEffect(() => { callsignValRef.current = callsign; }, [callsign]);
|
useEffect(() => { callsignValRef.current = callsign; }, [callsign]);
|
||||||
|
// Last callsign broadcast over UDP (WSJT-X/JTDX/MSHV/DXHunter). Lets us tell
|
||||||
|
// "the field still shows the previous broadcast" (safe to update) from "the
|
||||||
|
// user has typed a different call" (must not clobber).
|
||||||
|
const lastUdpCallRef = useRef('');
|
||||||
|
|
||||||
// When the entered callsign turns out to be worked-before, jump to the
|
// When the entered callsign turns out to be worked-before, jump to the
|
||||||
// Worked-before tab so the history is front-and-centre. Only once per call,
|
// Worked-before tab so the history is front-and-centre. Only once per call,
|
||||||
@@ -901,6 +905,22 @@ export default function App() {
|
|||||||
setRstSent(p?.default_rst_sent || fallback);
|
setRstSent(p?.default_rst_sent || fallback);
|
||||||
setRstRcvd(p?.default_rst_rcvd || fallback);
|
setRstRcvd(p?.default_rst_rcvd || fallback);
|
||||||
}
|
}
|
||||||
|
// Clicking a spot (cluster grid or any band map): tune the rig, set the mode,
|
||||||
|
// fill the call, pre-fill POTA, (re)start the recording. Shared so every spot
|
||||||
|
// source behaves identically.
|
||||||
|
function handleSpotClick(s: any) {
|
||||||
|
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
|
||||||
|
if (catState.connected) {
|
||||||
|
tuneRigCAT(s.freq_hz, m);
|
||||||
|
} else {
|
||||||
|
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
|
||||||
|
if (s.band) setBand(s.band);
|
||||||
|
}
|
||||||
|
if (m) applyModeFromSpot(m);
|
||||||
|
onCallsignInput(s.dx_call);
|
||||||
|
applySpotPOTA((s as any).pota_ref);
|
||||||
|
if (s.dx_call?.trim()) restartRecordingForNewTarget();
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => { refresh(); }, [refresh]);
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1030,23 +1050,28 @@ export default function App() {
|
|||||||
// We push the broadcast DX call into the entry field and auto-log any
|
// We push the broadcast DX call into the entry field and auto-log any
|
||||||
// ADIF record that arrives.
|
// ADIF record that arrives.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubDX = EventsOn('udp:dx_call', (p: any) => {
|
// Apply a UDP-broadcast callsign, but never clobber what the operator is
|
||||||
const call = String(p?.call ?? '').trim();
|
// typing: only update when the field is empty, already shows this call, or
|
||||||
if (!call) return;
|
// still shows the previous broadcast (i.e. the field content is ours, not
|
||||||
// Don't clobber what the user is currently typing — only update
|
// a different call the user typed). Returns true if it actually changed.
|
||||||
// when the entry field is empty or matches a previous broadcast.
|
const applyUdpCall = (raw: string): boolean => {
|
||||||
const changed = call.toUpperCase() !== callsignValRef.current.trim().toUpperCase();
|
const call = String(raw ?? '').trim();
|
||||||
|
if (!call) return false;
|
||||||
|
const upper = call.toUpperCase();
|
||||||
|
const current = callsignValRef.current.trim().toUpperCase();
|
||||||
|
const prev = lastUdpCallRef.current;
|
||||||
|
lastUdpCallRef.current = upper; // remember this broadcast either way
|
||||||
|
if (current === upper) return false; // already shown → no-op
|
||||||
|
if (current !== '' && current !== prev) return false; // user typed a different call → leave it
|
||||||
onCallsignInput(call);
|
onCallsignInput(call);
|
||||||
// External app jumped to a new station (DXHunter/WSJT/MSHV click): start a
|
return true;
|
||||||
// fresh recording for the new target instead of continuing the old take.
|
};
|
||||||
if (changed) restartRecordingForNewTarget();
|
const unsubDX = EventsOn('udp:dx_call', (p: any) => {
|
||||||
|
// External app moved to a new station → fresh recording for the new target.
|
||||||
|
if (applyUdpCall(p?.call)) restartRecordingForNewTarget();
|
||||||
});
|
});
|
||||||
const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
|
const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
|
||||||
const call = String(raw ?? '').trim();
|
if (applyUdpCall(raw)) restartRecordingForNewTarget();
|
||||||
if (!call) return;
|
|
||||||
const changed = call.toUpperCase() !== callsignValRef.current.trim().toUpperCase();
|
|
||||||
onCallsignInput(call);
|
|
||||||
if (changed) restartRecordingForNewTarget();
|
|
||||||
});
|
});
|
||||||
const unsubProg = EventsOn('import:progress', (p: any) => {
|
const unsubProg = EventsOn('import:progress', (p: any) => {
|
||||||
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
|
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
|
||||||
@@ -2181,10 +2206,10 @@ export default function App() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant={showBandMap ? 'default' : 'outline'}
|
variant={activeTab === 'bandmap' ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowBandMap((v) => !v)}
|
onClick={() => setActiveTab('bandmap')}
|
||||||
title="Toggle band map (visible across all tabs)"
|
title="Open the Band Map tab (several bands side by side)"
|
||||||
className="h-8"
|
className="h-8"
|
||||||
>
|
>
|
||||||
Band map
|
Band map
|
||||||
@@ -2443,10 +2468,9 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>{/* /entry + aside row */}
|
</div>{/* /entry + aside row */}
|
||||||
|
|
||||||
{/* ===== LOWER: tabs+table | call history | (optional) band map ===== */}
|
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
|
||||||
{compact ? null : <>
|
{compact ? null : <>
|
||||||
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
|
<div className="grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)] grid-cols-[1fr]">
|
||||||
showBandMap ? (bandMapSide === 'left' ? 'grid-cols-[260px_1fr]' : 'grid-cols-[1fr_260px]') : 'grid-cols-[1fr]')}>
|
|
||||||
<section className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
|
<section className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0 flex-1">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0 flex-1">
|
||||||
<TabsList className="px-3 shrink-0">
|
<TabsList className="px-3 shrink-0">
|
||||||
@@ -2463,6 +2487,7 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="awards">Awards</TabsTrigger>
|
<TabsTrigger value="awards">Awards</TabsTrigger>
|
||||||
|
<TabsTrigger value="bandmap">Band Map</TabsTrigger>
|
||||||
{/* Not a tab — QRZ blocks embedding, so this opens the call's
|
{/* Not a tab — QRZ blocks embedding, so this opens the call's
|
||||||
QRZ.com page in the system browser. Styled like a trigger. */}
|
QRZ.com page in the system browser. Styled like a trigger. */}
|
||||||
<button
|
<button
|
||||||
@@ -2740,26 +2765,7 @@ export default function App() {
|
|||||||
<ClusterGrid
|
<ClusterGrid
|
||||||
rows={rendered as any}
|
rows={rendered as any}
|
||||||
spotStatus={spotStatus}
|
spotStatus={spotStatus}
|
||||||
onSpotClick={(s) => {
|
onSpotClick={handleSpotClick}
|
||||||
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
|
|
||||||
if (catState.connected) {
|
|
||||||
tuneRigCAT(s.freq_hz, m);
|
|
||||||
} else {
|
|
||||||
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
|
|
||||||
if (s.band) setBand(s.band);
|
|
||||||
}
|
|
||||||
// Set mode + flip the RST default (599↔59) for the new
|
|
||||||
// target — a plain setMode skipped the RST preset.
|
|
||||||
if (m) applyModeFromSpot(m);
|
|
||||||
onCallsignInput(s.dx_call);
|
|
||||||
// A POTA spot carries the park ref — pre-fill the POTA
|
|
||||||
// award reference (like the State→RAC auto-match) so it's
|
|
||||||
// logged without re-typing. n-fer refs (comma-separated)
|
|
||||||
// become one POTA@ entry each.
|
|
||||||
applySpotPOTA((s as any).pota_ref);
|
|
||||||
// New target from a clicked spot → fresh recording + reset timer.
|
|
||||||
if (s.dx_call.trim()) restartRecordingForNewTarget();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -2952,35 +2958,45 @@ export default function App() {
|
|||||||
<TabsContent value="awards" className="flex-1 min-h-0 p-0">
|
<TabsContent value="awards" className="flex-1 min-h-0 p-0">
|
||||||
<AwardsPanel onEditQSO={openEdit} />
|
<AwardsPanel onEditQSO={openEdit} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Band Map: several bands shown side-by-side (panadapter-style
|
||||||
|
strips). Pick bands with the chips; each strip is clickable to
|
||||||
|
tune the rig. */}
|
||||||
|
<TabsContent value="bandmap" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-border/60 shrink-0 flex-wrap">
|
||||||
|
<span className="text-xs text-muted-foreground mr-1">Bands:</span>
|
||||||
|
{bands.map((b) => {
|
||||||
|
const on = bandMapBands.includes(b);
|
||||||
|
return (
|
||||||
|
<button key={b} type="button" onClick={() => toggleBandMapBand(b)}
|
||||||
|
className={cn('px-2 py-0.5 rounded-full border text-[11px] font-medium transition-colors',
|
||||||
|
on ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||||
|
{b}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 flex gap-2 p-2 overflow-x-auto">
|
||||||
|
{bandMapBands.length === 0 ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Pick one or more bands above to show their band maps side by side.
|
||||||
|
</div>
|
||||||
|
) : bandMapBands.map((b) => (
|
||||||
|
<div key={b} className="w-[260px] shrink-0 min-h-0 border border-border rounded-lg overflow-hidden flex flex-col">
|
||||||
|
<BandMap
|
||||||
|
band={b}
|
||||||
|
spots={spots.filter((s) => s.band === b)}
|
||||||
|
spotStatus={spotStatus}
|
||||||
|
currentFreqHz={band === b && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
|
||||||
|
onSpotClick={handleSpotClick}
|
||||||
|
onClose={() => toggleBandMapBand(b)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{showBandMap && (
|
|
||||||
<div className={cn('bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden', bandMapSide === 'left' && 'order-first')}>
|
|
||||||
<BandMap
|
|
||||||
side={bandMapSide}
|
|
||||||
onToggleSide={toggleBandMapSide}
|
|
||||||
band={band}
|
|
||||||
spots={spots.filter((s) => s.band === band)}
|
|
||||||
spotStatus={spotStatus}
|
|
||||||
currentFreqHz={freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
|
|
||||||
onSpotClick={(s) => {
|
|
||||||
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
|
|
||||||
if (catState.connected) {
|
|
||||||
tuneRigCAT(s.freq_hz, m);
|
|
||||||
} else {
|
|
||||||
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
|
|
||||||
if (s.band) setBand(s.band);
|
|
||||||
}
|
|
||||||
if (m) applyModeFromSpot(m);
|
|
||||||
onCallsignInput(s.dx_call);
|
|
||||||
applySpotPOTA((s as any).pota_ref);
|
|
||||||
if (s.dx_call.trim()) restartRecordingForNewTarget();
|
|
||||||
}}
|
|
||||||
onClose={() => setShowBandMap(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>}
|
</>}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
|
// Persisted free-pan view of the world map (when auto-zoom is off).
|
||||||
|
function loadMapView(): { lat: number; lon: number; zoom: number } | null {
|
||||||
|
try { const v = JSON.parse(localStorage.getItem('opslog.mapView') || 'null'); return v && typeof v.zoom === 'number' ? v : null; }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
function saveMapView(m: L.Map) {
|
||||||
|
const c = m.getCenter();
|
||||||
|
writeUiPref('opslog.mapView', JSON.stringify({ lat: c.lat, lon: c.lng, zoom: m.getZoom() }));
|
||||||
|
}
|
||||||
|
|
||||||
// MainMap — Log4OM-style dual map for the Main tab:
|
// MainMap — Log4OM-style dual map for the Main tab:
|
||||||
// • Left: a world map with the great-circle path drawn from the operator to
|
// • Left: a world map with the great-circle path drawn from the operator to
|
||||||
@@ -60,6 +71,13 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
|
|||||||
const worldOverlay = useRef<L.LayerGroup | null>(null);
|
const worldOverlay = useRef<L.LayerGroup | null>(null);
|
||||||
const locatorOverlay = useRef<L.LayerGroup | null>(null);
|
const locatorOverlay = useRef<L.LayerGroup | null>(null);
|
||||||
|
|
||||||
|
// Auto-zoom the world map to fit QTH→DX on each QSO. When off, the operator
|
||||||
|
// pans/zooms freely (e.g. a whole-world view) and the view is remembered
|
||||||
|
// across restarts. Default on.
|
||||||
|
const [autoZoom, setAutoZoom] = useState(() => localStorage.getItem('opslog.mapAutoZoomDX') !== '0');
|
||||||
|
const autoZoomRef = useRef(autoZoom);
|
||||||
|
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
|
||||||
|
|
||||||
// One-time map creation.
|
// One-time map creation.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (worldRef.current && !worldMap.current) {
|
if (worldRef.current && !worldMap.current) {
|
||||||
@@ -68,6 +86,11 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
|
|||||||
L.tileLayer(CARTO_LIGHT, { attribution: CARTO_ATTR, subdomains: 'abcd', maxZoom: 19 }).addTo(m);
|
L.tileLayer(CARTO_LIGHT, { attribution: CARTO_ATTR, subdomains: 'abcd', maxZoom: 19 }).addTo(m);
|
||||||
worldOverlay.current = L.layerGroup().addTo(m);
|
worldOverlay.current = L.layerGroup().addTo(m);
|
||||||
worldMap.current = m;
|
worldMap.current = m;
|
||||||
|
// Restore the saved free-pan view when not auto-zooming.
|
||||||
|
const sv = loadMapView();
|
||||||
|
if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom);
|
||||||
|
// Remember the view as the user pans/zooms (only meaningful when free).
|
||||||
|
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
|
||||||
}
|
}
|
||||||
if (locatorRef.current && !locatorMap.current) {
|
if (locatorRef.current && !locatorMap.current) {
|
||||||
const m = L.map(locatorRef.current, { zoomControl: true, attributionControl: true })
|
const m = L.map(locatorRef.current, { zoomControl: true, attributionControl: true })
|
||||||
@@ -135,13 +158,16 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
|
|||||||
if (from && to) {
|
if (from && to) {
|
||||||
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 96);
|
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 96);
|
||||||
L.polyline(pts as L.LatLngExpression[], { color: '#dc2626', weight: 2, opacity: 0.9 }).addTo(wo);
|
L.polyline(pts as L.LatLngExpression[], { color: '#dc2626', weight: 2, opacity: 0.9 }).addTo(wo);
|
||||||
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
// Only re-frame the map when auto-zoom is on; otherwise keep the user's
|
||||||
// Include the arc so high-latitude curves aren't clipped.
|
// chosen (remembered) view so the beam heading stays visible.
|
||||||
pts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
if (autoZoom) {
|
||||||
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
||||||
} else if (to) {
|
pts.forEach((p) => bounds.extend(p as L.LatLngExpression)); // include the arc
|
||||||
|
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
||||||
|
}
|
||||||
|
} else if (autoZoom && to) {
|
||||||
wm.setView([to.lat, to.lon], 3);
|
wm.setView([to.lat, to.lon], 3);
|
||||||
} else if (from) {
|
} else if (autoZoom && from) {
|
||||||
wm.setView([from.lat, from.lon], 3);
|
wm.setView([from.lat, from.lon], 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +186,7 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
|
|||||||
}
|
}
|
||||||
setTimeout(() => { wm.invalidateSize(); lm.invalidateSize(); }, 0);
|
setTimeout(() => { wm.invalidateSize(); lm.invalidateSize(); }, 0);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth]);
|
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, autoZoom]);
|
||||||
|
|
||||||
const path = pathBetween(fromGrid, toGrid);
|
const path = pathBetween(fromGrid, toGrid);
|
||||||
|
|
||||||
@@ -169,6 +195,25 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
|
|||||||
<div className="grid grid-cols-2 gap-2 flex-1 min-h-0">
|
<div className="grid grid-cols-2 gap-2 flex-1 min-h-0">
|
||||||
<div className="relative isolate rounded-lg overflow-hidden border border-border">
|
<div className="relative isolate rounded-lg overflow-hidden border border-border">
|
||||||
<div ref={worldRef} className="absolute inset-0" />
|
<div ref={worldRef} className="absolute inset-0" />
|
||||||
|
{/* Auto-zoom toggle: on = frame QTH→DX each QSO; off = free pan/zoom
|
||||||
|
(remembered across restarts), so the beam heading stays visible. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const v = !autoZoom;
|
||||||
|
setAutoZoom(v);
|
||||||
|
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
|
||||||
|
const m = worldMap.current;
|
||||||
|
if (!v && m) saveMapView(m); // entering free mode → remember current view
|
||||||
|
// (turning it on re-frames via the redraw effect, which depends on autoZoom)
|
||||||
|
}}
|
||||||
|
title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'}
|
||||||
|
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
|
||||||
|
autoZoom ? 'bg-primary text-primary-foreground border-primary' : 'bg-card/90 text-muted-foreground border-border hover:bg-card'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Zoom DX
|
||||||
|
</button>
|
||||||
{path && (
|
{path && (
|
||||||
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
||||||
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
|
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
||||||
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
||||||
'opslog.catModeBeforeFreq', // send CAT mode before frequency (older rigs)
|
'opslog.catModeBeforeFreq', // send CAT mode before frequency (older rigs)
|
||||||
|
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
|
||||||
|
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
||||||
|
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
||||||
];
|
];
|
||||||
|
|
||||||
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
||||||
|
|||||||
+33
-18
@@ -36,13 +36,14 @@ func DefaultFolder(dataDir string) string {
|
|||||||
return filepath.Join(dataDir, "backups")
|
return filepath.Join(dataDir, "backups")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run executes one backup pass: checkpoint WAL → copy the database file
|
// Run executes one backup pass: VACUUM INTO a fresh snapshot → optionally
|
||||||
// → optionally zip → rotate. Returns the path of the file that was
|
// zip → rotate. Returns the path of the file that was written so the caller
|
||||||
// written so the caller can surface it to the UI.
|
// can surface it to the UI.
|
||||||
//
|
//
|
||||||
// dbConn is used to issue the WAL checkpoint so the on-disk file is
|
// VACUUM INTO produces a transactionally-consistent copy in a single SQL
|
||||||
// self-consistent before we copy it. It's the same *sql.DB the app uses;
|
// statement (no torn-copy window while the app keeps writing), and compacts
|
||||||
// SQLite tolerates concurrent reads during the copy.
|
// the destination as a bonus. It replaces the old "checkpoint + raw io.Copy",
|
||||||
|
// which could capture a half-written page during a concurrent write.
|
||||||
func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation int, doZip bool) (string, error) {
|
func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation int, doZip bool) (string, error) {
|
||||||
if dbConn == nil {
|
if dbConn == nil {
|
||||||
return "", fmt.Errorf("nil db connection")
|
return "", fmt.Errorf("nil db connection")
|
||||||
@@ -56,24 +57,38 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
|
|||||||
if err := os.MkdirAll(folder, 0o755); err != nil {
|
if err := os.MkdirAll(folder, 0o755); err != nil {
|
||||||
return "", fmt.Errorf("create backup folder: %w", err)
|
return "", fmt.Errorf("create backup folder: %w", err)
|
||||||
}
|
}
|
||||||
// Flush WAL into the main file so a raw copy is a complete database.
|
|
||||||
// TRUNCATE removes the -wal file's contents after checkpointing.
|
|
||||||
if _, err := dbConn.ExecContext(ctx, `PRAGMA wal_checkpoint(TRUNCATE);`); err != nil {
|
|
||||||
return "", fmt.Errorf("wal_checkpoint: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stamp := time.Now().Format("2006-01-02")
|
stamp := time.Now().Format("2006-01-02")
|
||||||
base := fmt.Sprintf("opslog-%s", stamp)
|
base := fmt.Sprintf("opslog-%s", stamp)
|
||||||
|
|
||||||
|
// VACUUM INTO requires a non-existent target → use a temp file, then
|
||||||
|
// move/zip it into place.
|
||||||
|
tmp := filepath.Join(folder, base+".vacuum.tmp")
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
if _, err := dbConn.ExecContext(ctx, `VACUUM INTO ?;`, tmp); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", fmt.Errorf("vacuum into: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var dstPath string
|
var dstPath string
|
||||||
if doZip {
|
if doZip {
|
||||||
dstPath = filepath.Join(folder, base+".db.zip")
|
dstPath = filepath.Join(folder, base+".db.zip")
|
||||||
if err := copyZipped(dbPath, dstPath); err != nil {
|
// Inner name = the live DB's base so unzip restores e.g. "opslog.db".
|
||||||
|
if err := copyZipped(tmp, dstPath, filepath.Base(dbPath)); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
_ = os.Remove(tmp)
|
||||||
} else {
|
} else {
|
||||||
dstPath = filepath.Join(folder, base+".db")
|
dstPath = filepath.Join(folder, base+".db")
|
||||||
if err := copyFile(dbPath, dstPath); err != nil {
|
_ = os.Remove(dstPath)
|
||||||
return "", err
|
if err := os.Rename(tmp, dstPath); err != nil {
|
||||||
|
// Rename can fail across filesystems — fall back to a copy.
|
||||||
|
if cerr := copyFile(tmp, dstPath); cerr != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", cerr
|
||||||
|
}
|
||||||
|
_ = os.Remove(tmp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,9 +120,9 @@ func copyFile(src, dst string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// copyZipped writes a single-entry deflate zip containing the database.
|
// copyZipped writes a single-entry deflate zip containing the database.
|
||||||
// The inner filename is just the base of the source so unzip restores
|
// innerName is the entry name inside the zip (the live DB's base name) so
|
||||||
// "hamlog.db" wherever the user extracts.
|
// unzip restores e.g. "opslog.db" wherever the user extracts.
|
||||||
func copyZipped(src, dst string) error {
|
func copyZipped(src, dst, innerName string) error {
|
||||||
in, err := os.Open(src)
|
in, err := os.Open(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("open source: %w", err)
|
return fmt.Errorf("open source: %w", err)
|
||||||
@@ -119,7 +134,7 @@ func copyZipped(src, dst string) error {
|
|||||||
return fmt.Errorf("create dest: %w", err)
|
return fmt.Errorf("create dest: %w", err)
|
||||||
}
|
}
|
||||||
zw := zip.NewWriter(out)
|
zw := zip.NewWriter(out)
|
||||||
w, err := zw.Create(filepath.Base(src))
|
w, err := zw.Create(innerName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
zw.Close()
|
zw.Close()
|
||||||
out.Close()
|
out.Close()
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ func (s *session) run() {
|
|||||||
// Returns the moment we marked the link "connected" (zero if dial failed)
|
// Returns the moment we marked the link "connected" (zero if dial failed)
|
||||||
// and the error that ended the session (nil if stopCh).
|
// and the error that ended the session (nil if stopCh).
|
||||||
func (s *session) runOnce() (time.Time, error) {
|
func (s *session) runOnce() (time.Time, error) {
|
||||||
addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
|
addr := net.JoinHostPort(s.cfg.Host, fmt.Sprintf("%d", s.cfg.Port)) // IPv6-safe
|
||||||
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return time.Time{}, fmt.Errorf("dial %s: %w", addr, err)
|
return time.Time{}, fmt.Errorf("dial %s: %w", addr, err)
|
||||||
|
|||||||
+5
-1
@@ -17,7 +17,11 @@ var migrationsFS embed.FS
|
|||||||
// Open opens (and creates if needed) the SQLite database at the given path,
|
// Open opens (and creates if needed) the SQLite database at the given path,
|
||||||
// enables performance PRAGMAs, and applies embedded migrations.
|
// enables performance PRAGMAs, and applies embedded migrations.
|
||||||
func Open(path string) (*sql.DB, error) {
|
func Open(path string) (*sql.DB, error) {
|
||||||
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(on)&_pragma=synchronous(normal)&_pragma=busy_timeout(5000)", path)
|
// Escape only the two characters a path could contain that the DSN would
|
||||||
|
// otherwise read as its query/fragment delimiters. Windows separators
|
||||||
|
// (\\ and the drive ':') are left intact — url.PathEscape would mangle them.
|
||||||
|
safePath := strings.NewReplacer("?", "%3F", "#", "%23").Replace(path)
|
||||||
|
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(on)&_pragma=synchronous(normal)&_pragma=busy_timeout(5000)", safePath)
|
||||||
conn, err := sql.Open("sqlite", dsn)
|
conn, err := sql.Open("sqlite", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ func (r *Repo) ListTree(ctx context.Context, profileID int64) ([]Station, error)
|
|||||||
stationByID[s.ID] = len(stations)
|
stationByID[s.ID] = len(stations)
|
||||||
stations = append(stations, s)
|
stations = append(stations, s)
|
||||||
}
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if len(stations) == 0 {
|
if len(stations) == 0 {
|
||||||
return stations, nil
|
return stations, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ func parseAzimuth(s string) (int, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) send(payload string) error {
|
func (c *Client) send(payload string) error {
|
||||||
addr := fmt.Sprintf("%s:%d", c.Host, c.Port)
|
addr := net.JoinHostPort(c.Host, fmt.Sprintf("%d", c.Port)) // IPv6-safe
|
||||||
conn, err := net.DialTimeout("udp", addr, 2*time.Second)
|
conn, err := net.DialTimeout("udp", addr, 2*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("dial PstRotator at %s: %w", addr, err)
|
return fmt.Errorf("dial PstRotator at %s: %w", addr, err)
|
||||||
|
|||||||
Reference in New Issue
Block a user