diff --git a/app.go b/app.go index 22a3f83..112004b 100644 --- a/app.go +++ b/app.go @@ -576,7 +576,7 @@ func (a *App) startup(ctx context.Context) { a.dxcc = dxcc.NewManager(dataDir) a.lookup.SetDXCCResolver(dxccAdapter{m: a.dxcc}) 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) return } @@ -847,6 +847,19 @@ func (a *App) runBackupForShutdown() error { 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) { // If the user managed to skip beforeClose (force kill, OS shutdown, // crash recovery) we still try the backup here as a best-effort @@ -1503,13 +1516,13 @@ func (a *App) migrateAwardDefs() { changed = true } } - _ = a.settings.Set(a.ctx, keyAwardDefsFixed, defsFixVersion) + a.setSetting(keyAwardDefsFixed, defsFixVersion) } if !changed { return } 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)) } } @@ -1843,12 +1856,10 @@ func (a *App) SyncPOTAHunterLog(addMissing bool, onlyMyCall bool) (POTASyncResul case emptyBest >= 0: all[emptyBest].POTARef = e.Reference // stamp regardless of time skew toUpdate[emptyBest] = struct{}{} - res.Updated++ case nonEmptyBest >= 0 && nonEmptyDiff <= nferWindow: // n-fer: same physical QSO at another park. all[nonEmptyBest].POTARef += "," + e.Reference toUpdate[nonEmptyBest] = struct{}{} - res.Updated++ case len(byCall[pota.BaseCall(e.Worked)]) == 0 && addMissing: toAdd = append(toAdd, e) 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 { - _ = 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 { 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") 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) 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 } 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 } @@ -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 @@ -4448,7 +4465,7 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern wruntime.EventsEmit(a.ctx, "qslmgr:log", line) } } - ctx := context.Background() + ctx := a.ctx uploaded := 0 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}) } } - ctx := context.Background() + ctx := a.ctx matched, total, added := 0, 0, 0 // 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). 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: @@ -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 // resolving to empty and re-pulled everything. 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 len(snip) > 300 { @@ -5507,7 +5524,7 @@ func (a *App) RunBackupNow() (string, error) { if err != nil { 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 } @@ -5535,7 +5552,7 @@ func (a *App) maybeShutdownBackup() { fmt.Println("OpsLog: shutdown backup failed:", err) 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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ed43d56..fe00931 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -597,15 +597,15 @@ export default function App() { const [clusterSearch, setClusterSearch] = useState(''); // Hide spots already worked (exact call worked, or this band+mode slot done). const [clusterHideWorked, setClusterHideWorked] = useState(false); - const [showBandMap, setShowBandMap] = useState(false); - // Which side the band map docks to (persisted). Toggled from its header. - const [bandMapSide, setBandMapSide] = useState<'left' | 'right'>( - () => (localStorage.getItem('bandmap.side') === 'left' ? 'left' : 'right'), - ); - const toggleBandMapSide = useCallback(() => { - setBandMapSide((s) => { - const next = s === 'right' ? 'left' : 'right'; - writeUiPref('bandmap.side', next); + // Bands shown side-by-side in the Band Map tab (portable). + const [bandMapBands, setBandMapBands] = useState(() => { + try { const v = JSON.parse(localStorage.getItem('opslog.bandMapBands') || '[]'); return Array.isArray(v) ? v : []; } + catch { return []; } + }); + const toggleBandMapBand = useCallback((b: string) => { + setBandMapBands((cur) => { + const next = cur.includes(b) ? cur.filter((x) => x !== b) : [...cur, b]; + writeUiPref('opslog.bandMapBands', JSON.stringify(next)); return next; }); }, []); @@ -670,6 +670,10 @@ export default function App() { // tell whether an incoming DX call actually changed anything. const callsignValRef = useRef(''); 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 // 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); 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(() => { @@ -1030,23 +1050,28 @@ export default function App() { // We push the broadcast DX call into the entry field and auto-log any // ADIF record that arrives. useEffect(() => { - const unsubDX = EventsOn('udp:dx_call', (p: any) => { - const call = String(p?.call ?? '').trim(); - if (!call) return; - // Don't clobber what the user is currently typing — only update - // when the entry field is empty or matches a previous broadcast. - const changed = call.toUpperCase() !== callsignValRef.current.trim().toUpperCase(); + // Apply a UDP-broadcast callsign, but never clobber what the operator is + // typing: only update when the field is empty, already shows this call, or + // still shows the previous broadcast (i.e. the field content is ours, not + // a different call the user typed). Returns true if it actually changed. + const applyUdpCall = (raw: string): boolean => { + 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); - // External app jumped to a new station (DXHunter/WSJT/MSHV click): start a - // fresh recording for the new target instead of continuing the old take. - if (changed) restartRecordingForNewTarget(); + return true; + }; + 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 call = String(raw ?? '').trim(); - if (!call) return; - const changed = call.toUpperCase() !== callsignValRef.current.trim().toUpperCase(); - onCallsignInput(call); - if (changed) restartRecordingForNewTarget(); + if (applyUdpCall(raw)) restartRecordingForNewTarget(); }); const unsubProg = EventsOn('import:progress', (p: any) => { setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) }); @@ -2181,10 +2206,10 @@ export default function App() { )} + ); + })} + +
+ {bandMapBands.length === 0 ? ( +
+ Pick one or more bands above to show their band maps side by side. +
+ ) : bandMapBands.map((b) => ( +
+ s.band === b)} + spotStatus={spotStatus} + currentFreqHz={band === b && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0} + onSpotClick={handleSpotClick} + onClose={() => toggleBandMapBand(b)} + /> +
+ ))} +
+ - - {showBandMap && ( -
- 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)} - /> -
- )} } diff --git a/frontend/src/components/MainMap.tsx b/frontend/src/components/MainMap.tsx index b0fbbc0..8f933f0 100644 --- a/frontend/src/components/MainMap.tsx +++ b/frontend/src/components/MainMap.tsx @@ -1,7 +1,18 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; 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: // • 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(null); const locatorOverlay = useRef(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. useEffect(() => { 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); worldOverlay.current = L.layerGroup().addTo(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) { 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) { 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); - const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]); - // Include the arc so high-latitude curves aren't clipped. - pts.forEach((p) => bounds.extend(p as L.LatLngExpression)); - wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 }); - } else if (to) { + // Only re-frame the map when auto-zoom is on; otherwise keep the user's + // chosen (remembered) view so the beam heading stays visible. + if (autoZoom) { + const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]); + 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); - } else if (from) { + } else if (autoZoom && from) { 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); // 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); @@ -169,6 +195,25 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
+ {/* Auto-zoom toggle: on = frame QTH→DX each QSO; off = free pan/zoom + (remembered across restarts), so the beam heading stays visible. */} + {path && (
Dist {Math.round(path.distanceShort).toLocaleString()} km diff --git a/frontend/src/lib/uiPref.ts b/frontend/src/lib/uiPref.ts index c4fdf4d..6259fb5 100644 --- a/frontend/src/lib/uiPref.ts +++ b/frontend/src/lib/uiPref.ts @@ -18,6 +18,9 @@ const PORTABLE_KEYS = [ 'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map 'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time) '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: diff --git a/internal/backup/backup.go b/internal/backup/backup.go index cf9f643..32b77ba 100644 --- a/internal/backup/backup.go +++ b/internal/backup/backup.go @@ -36,13 +36,14 @@ func DefaultFolder(dataDir string) string { return filepath.Join(dataDir, "backups") } -// Run executes one backup pass: checkpoint WAL → copy the database file -// → optionally zip → rotate. Returns the path of the file that was -// written so the caller can surface it to the UI. +// Run executes one backup pass: VACUUM INTO a fresh snapshot → optionally +// zip → rotate. Returns the path of the file that was written so the caller +// can surface it to the UI. // -// dbConn is used to issue the WAL checkpoint so the on-disk file is -// self-consistent before we copy it. It's the same *sql.DB the app uses; -// SQLite tolerates concurrent reads during the copy. +// VACUUM INTO produces a transactionally-consistent copy in a single SQL +// statement (no torn-copy window while the app keeps writing), and compacts +// 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) { if dbConn == nil { 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 { 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") 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 if doZip { 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 } + _ = os.Remove(tmp) } else { dstPath = filepath.Join(folder, base+".db") - if err := copyFile(dbPath, dstPath); err != nil { - return "", err + _ = os.Remove(dstPath) + 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. -// The inner filename is just the base of the source so unzip restores -// "hamlog.db" wherever the user extracts. -func copyZipped(src, dst string) error { +// innerName is the entry name inside the zip (the live DB's base name) so +// unzip restores e.g. "opslog.db" wherever the user extracts. +func copyZipped(src, dst, innerName string) error { in, err := os.Open(src) if err != nil { return fmt.Errorf("open source: %w", err) @@ -119,7 +134,7 @@ func copyZipped(src, dst string) error { return fmt.Errorf("create dest: %w", err) } zw := zip.NewWriter(out) - w, err := zw.Create(filepath.Base(src)) + w, err := zw.Create(innerName) if err != nil { zw.Close() out.Close() diff --git a/internal/cluster/cluster.go b/internal/cluster/cluster.go index 519602c..2616a64 100644 --- a/internal/cluster/cluster.go +++ b/internal/cluster/cluster.go @@ -317,7 +317,7 @@ func (s *session) run() { // Returns the moment we marked the link "connected" (zero if dial failed) // and the error that ended the session (nil if stopCh). 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) if err != nil { return time.Time{}, fmt.Errorf("dial %s: %w", addr, err) diff --git a/internal/db/db.go b/internal/db/db.go index 81b1602..2b3011c 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -17,7 +17,11 @@ var migrationsFS embed.FS // Open opens (and creates if needed) the SQLite database at the given path, // enables performance PRAGMAs, and applies embedded migrations. 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) if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) diff --git a/internal/operating/operating.go b/internal/operating/operating.go index 234dc11..41a3318 100644 --- a/internal/operating/operating.go +++ b/internal/operating/operating.go @@ -84,6 +84,9 @@ func (r *Repo) ListTree(ctx context.Context, profileID int64) ([]Station, error) stationByID[s.ID] = len(stations) stations = append(stations, s) } + if err := rows.Err(); err != nil { + return nil, err + } if len(stations) == 0 { return stations, nil } diff --git a/internal/rotator/pst/pst.go b/internal/rotator/pst/pst.go index 749dbbb..76e4b5d 100644 --- a/internal/rotator/pst/pst.go +++ b/internal/rotator/pst/pst.go @@ -108,7 +108,7 @@ func parseAzimuth(s string) (int, bool) { } 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) if err != nil { return fmt.Errorf("dial PstRotator at %s: %w", addr, err)