diff --git a/app.go b/app.go index c16018a..997b02c 100644 --- a/app.go +++ b/app.go @@ -18,6 +18,7 @@ import ( "time" "hamlog/internal/adif" + "hamlog/internal/antgenius" "hamlog/internal/applog" "hamlog/internal/audio" "hamlog/internal/award" @@ -25,23 +26,23 @@ import ( "hamlog/internal/backup" "hamlog/internal/cat" "hamlog/internal/clublog" - "hamlog/internal/cwdecode" "hamlog/internal/cluster" + "hamlog/internal/cwdecode" "hamlog/internal/db" "hamlog/internal/dxcc" "hamlog/internal/email" "hamlog/internal/extsvc" "hamlog/internal/integrations/udp" "hamlog/internal/lookup" + "hamlog/internal/netctl" "hamlog/internal/operating" "hamlog/internal/pota" + "hamlog/internal/powergenius" "hamlog/internal/profile" "hamlog/internal/qslcard" "hamlog/internal/qso" "hamlog/internal/rotator/pst" "hamlog/internal/settings" - "hamlog/internal/antgenius" - "hamlog/internal/powergenius" "hamlog/internal/ultrabeam" "hamlog/internal/winkeyer" @@ -223,8 +224,8 @@ const ( keyExtLoTWStationLoc = "extsvc.lotw.station_location" keyExtLoTWForceCall = "extsvc.lotw.force_station_callsign" // override STATION_CALLSIGN at sign time (e.g. F4BPO/P on the F4BPO cert) keyExtLoTWKeyPassword = "extsvc.lotw.key_password" - keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag" // legacy single flag (migrated to upload_flags) - keyExtLoTWUploadFlags = "extsvc.lotw.upload_flags" // CSV set of lotw_sent values to upload (e.g. "N,R") + keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag" // legacy single flag (migrated to upload_flags) + keyExtLoTWUploadFlags = "extsvc.lotw.upload_flags" // CSV set of lotw_sent values to upload (e.g. "N,R") keyExtLoTWWriteLog = "extsvc.lotw.write_log" keyExtLoTWAutoUpload = "extsvc.lotw.auto_upload" keyExtLoTWUploadMode = "extsvc.lotw.upload_mode" @@ -374,57 +375,65 @@ type LookupSettings struct { // App is the application context bound to the Wails runtime. type App struct { - ctx context.Context - db *sql.DB - qso *qso.Repo - settings *settings.Store - profiles *profile.Repo - lookup *lookup.Manager - cache *lookup.Cache - cat *cat.Manager - dxcc *dxcc.Manager - cluster *cluster.Manager - pota *pota.Cache - awardRefs *awardref.Repo - qslTemplates *qslcard.Repo - operating *operating.Repo - udp *udp.Manager - udpRepo *udp.Repo - extsvc *extsvc.Manager - winkeyer *winkeyer.Manager - clublog *clublog.Manager - ultrabeam *ultrabeam.Client // Ultrabeam antenna (TCP); nil when disabled - ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off - antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled - pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled - audioMgr *audio.Manager - qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) - cwMu sync.Mutex // guards the CW decoder lifecycle - cwStop chan struct{} // stops the CW decoder capture loop; nil when off - cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch) - cwPitchHz int // manual pitch override (0 = auto / follow Flex) - startupProfile string // --profile from the command line (activate at startup) - dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord) - dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends - pttMu sync.Mutex - udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check - pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted - pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle - pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission) - startupErr string // captured for surfacing to the frontend - dbPath string // active database file (may be a user-chosen location) - logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite) - dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup - dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite - catFlexSpots bool // push cluster spots to the FlexRadio panadapter - liveActMu sync.Mutex // guards the entry-strip activity reported for live status - liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off) - liveBand string - liveMode string - awardSnapMu sync.Mutex // guards the award QSO snapshot - awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations - awardSnapRev string // logbook revision the snapshot was built at ("" = none) - dataDir string // /data — holds config.json, logs, cty.dat + ctx context.Context + db *sql.DB + qso *qso.Repo + settings *settings.Store + profiles *profile.Repo + lookup *lookup.Manager + cache *lookup.Cache + cat *cat.Manager + dxcc *dxcc.Manager + cluster *cluster.Manager + pota *pota.Cache + awardRefs *awardref.Repo + qslTemplates *qslcard.Repo + operating *operating.Repo + udp *udp.Manager + udpRepo *udp.Repo + extsvc *extsvc.Manager + winkeyer *winkeyer.Manager + clublog *clublog.Manager + ultrabeam *ultrabeam.Client // Ultrabeam antenna (TCP); nil when disabled + ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off + antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled + pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled + audioMgr *audio.Manager + qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) + + // NET Control: persistent net definitions/rosters (global JSON) + the live + // session (in-memory only — active stations currently in QSO). + netStore *netctl.Store + netMu sync.Mutex + netOpenID string // id of the currently open net ("" = none) + netActive []*netActiveEntry // stations on the air right now, in check-in order + + cwMu sync.Mutex // guards the CW decoder lifecycle + cwStop chan struct{} // stops the CW decoder capture loop; nil when off + cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch) + cwPitchHz int // manual pitch override (0 = auto / follow Flex) + startupProfile string // --profile from the command line (activate at startup) + dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord) + dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends + pttMu sync.Mutex + udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check + pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted + pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle + pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission) + startupErr string // captured for surfacing to the frontend + dbPath string // active database file (may be a user-chosen location) + logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite) + dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup + dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite + catFlexSpots bool // push cluster spots to the FlexRadio panadapter + liveActMu sync.Mutex // guards the entry-strip activity reported for live status + liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off) + liveBand string + liveMode string + awardSnapMu sync.Mutex // guards the award QSO snapshot + awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations + awardSnapRev string // logbook revision the snapshot was built at ("" = none) + dataDir string // /data — holds config.json, logs, cty.dat // shuttingDown gates beforeClose re-entry: the first user attempt to // close fires shutdown tasks (backup, future LoTW upload, ...) while @@ -827,6 +836,13 @@ func (a *App) startup(ctx context.Context) { a.qsoRec = audio.NewRecorder() a.startQSORecorderIfEnabled() + // NET Control store (global JSON, shared across logbooks). + if ns, err := netctl.Open(filepath.Join(a.dataDir, "nets.json")); err != nil { + applog.Printf("netctl: open failed: %v", err) + } else { + a.netStore = ns + } + // Ultrabeam antenna: connect in the background if enabled. a.startUltrabeam() // Antenna Genius switch: connect in the background if enabled. @@ -1055,7 +1071,6 @@ func userDataDir() (string, error) { return filepath.Join(filepath.Dir(exe), "data"), nil } - // fileExists reports whether path exists and is a regular file. func fileExists(path string) bool { fi, err := os.Stat(path) @@ -1114,8 +1129,8 @@ func copyFileData(src, dst string) error { // (MySQL). The MySQL connection lives here — not in the settings table — for // the same reason: we need it to choose and open the backend at startup. type dbPointer struct { - DBPath string `json:"db_path"` - MySQL *MySQLSettings `json:"mysql,omitempty"` + DBPath string `json:"db_path"` + MySQL *MySQLSettings `json:"mysql,omitempty"` } func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") } @@ -1181,9 +1196,9 @@ type MySQLSettings struct { // the Settings UI can confirm the shared MySQL connection (or explain a // fallback to SQLite when the configured server was unreachable). type DBBackendStatus struct { - Active string `json:"active"` // "sqlite" | "mysql" - Fallback bool `json:"fallback"` // MySQL was enabled but failed, so we used SQLite - Error string `json:"error"` // the MySQL open error, when Fallback is true + Active string `json:"active"` // "sqlite" | "mysql" + Fallback bool `json:"fallback"` // MySQL was enabled but failed, so we used SQLite + Error string `json:"error"` // the MySQL open error, when Fallback is true } // GetDBBackendStatus returns the active backend and any MySQL fallback error. @@ -4260,6 +4275,259 @@ func (a *App) QSOAudioCancel() { // RestartQSORecorder applies new audio settings to the running recorder. func (a *App) RestartQSORecorder() { a.startQSORecorderIfEnabled() } +// ── NET Control ──────────────────────────────────────────────────────────── +// +// A NET is a named net with a station roster (persisted globally in nets.json). +// Opening a net starts an in-memory live session: stations moved "on the air" +// (NetActivate) accumulate a time_on; moving one back off (NetDeactivate) logs +// the QSO into the active logbook with live CAT freq/mode and removes it from +// the session. The session is RAM-only — closing the app mid-net drops any +// active stations that were never logged. + +// netActiveEntry is one station currently on the air in the open net. +type netActiveEntry struct { + Callsign string `json:"callsign"` + Name string `json:"name"` + QTH string `json:"qth"` + Country string `json:"country"` + RSTSent string `json:"rst_sent"` + RSTRcvd string `json:"rst_rcvd"` + Comment string `json:"comment"` + TimeOn time.Time `json:"time_on"` +} + +// NetList returns all nets (with rosters), ordered by name. +func (a *App) NetList() []netctl.Net { + if a.netStore == nil { + return nil + } + return a.netStore.List() +} + +// NetCreate adds a new named net (defaults 59/59). +func (a *App) NetCreate(name string) (netctl.Net, error) { + if a.netStore == nil { + return netctl.Net{}, fmt.Errorf("net store unavailable") + } + return a.netStore.Create(name) +} + +// NetRename renames a net. +func (a *App) NetRename(id, name string) error { + if a.netStore == nil { + return fmt.Errorf("net store unavailable") + } + return a.netStore.Rename(id, name) +} + +// NetSetDefaults updates a net's default report/comment values. +func (a *App) NetSetDefaults(id, rstSent, rstRcvd, comment string) error { + if a.netStore == nil { + return fmt.Errorf("net store unavailable") + } + return a.netStore.SetDefaults(id, rstSent, rstRcvd, comment) +} + +// NetDelete removes a net and its roster (closing it first if it's open). +func (a *App) NetDelete(id string) error { + if a.netStore == nil { + return fmt.Errorf("net store unavailable") + } + a.netMu.Lock() + if a.netOpenID == id { + a.netOpenID, a.netActive = "", nil + } + a.netMu.Unlock() + return a.netStore.Delete(id) +} + +// NetRoster returns a net's roster, sorted by callsign. +func (a *App) NetRoster(id string) ([]netctl.Station, error) { + if a.netStore == nil { + return nil, fmt.Errorf("net store unavailable") + } + return a.netStore.Roster(id) +} + +// NetRosterUpsert adds or updates a roster station. +func (a *App) NetRosterUpsert(id string, s netctl.Station) error { + if a.netStore == nil { + return fmt.Errorf("net store unavailable") + } + return a.netStore.RosterUpsert(id, s) +} + +// NetRosterRemove deletes a callsign from a net's roster. +func (a *App) NetRosterRemove(id, callsign string) error { + if a.netStore == nil { + return fmt.Errorf("net store unavailable") + } + return a.netStore.RosterRemove(id, callsign) +} + +// NetLookup resolves a callsign via the configured provider (QRZ) so the +// Add-contact dialog can pre-fill name/QTH/country/zones. Best-effort — returns +// just the callsign if no provider or no match. +func (a *App) NetLookup(callsign string) netctl.Station { + st := netctl.Station{Callsign: strings.ToUpper(strings.TrimSpace(callsign))} + if a.lookup == nil || st.Callsign == "" { + return st + } + if lr, err := a.lookup.Lookup(a.ctx, st.Callsign); err == nil { + st.Name, st.QTH, st.Country = lr.Name, lr.QTH, lr.Country + st.DXCC, st.CQ, st.ITU = lr.DXCC, lr.CQZ, lr.ITUZ + } + return st +} + +// NetOpen starts a live session for the given net (clears any prior session). +func (a *App) NetOpen(id string) error { + if a.netStore == nil { + return fmt.Errorf("net store unavailable") + } + if _, ok := a.netStore.Get(id); !ok { + return fmt.Errorf("net not found") + } + a.netMu.Lock() + a.netOpenID, a.netActive = id, nil + a.netMu.Unlock() + return nil +} + +// NetClose ends the live session (does NOT log remaining actives — the operator +// moves each station back to the roster side to log it before closing). +func (a *App) NetClose() { + a.netMu.Lock() + a.netOpenID, a.netActive = "", nil + a.netMu.Unlock() +} + +// NetOpenID returns the id of the currently open net ("" = none). +func (a *App) NetOpenID() string { + a.netMu.Lock() + defer a.netMu.Unlock() + return a.netOpenID +} + +// NetActiveList returns the stations currently on the air, in check-in order. +func (a *App) NetActiveList() []netActiveEntry { + a.netMu.Lock() + defer a.netMu.Unlock() + out := make([]netActiveEntry, len(a.netActive)) + for i, e := range a.netActive { + out[i] = *e + } + return out +} + +// NetActivate puts a station on the air (records time_on, seeds defaults from +// the net + roster). No-op if already active. The net must be open. +func (a *App) NetActivate(callsign string) (netActiveEntry, error) { + call := strings.ToUpper(strings.TrimSpace(callsign)) + if call == "" { + return netActiveEntry{}, fmt.Errorf("callsign required") + } + a.netMu.Lock() + defer a.netMu.Unlock() + if a.netOpenID == "" { + return netActiveEntry{}, fmt.Errorf("no net open") + } + for _, e := range a.netActive { + if e.Callsign == call { + return *e, nil // already on the air + } + } + e := &netActiveEntry{Callsign: call, TimeOn: time.Now().UTC()} + if net, ok := a.netStore.Get(a.netOpenID); ok { + e.RSTSent, e.RSTRcvd, e.Comment = net.DefaultRSTSent, net.DefaultRSTRcvd, net.DefaultComment + for _, st := range net.Stations { + if strings.EqualFold(st.Callsign, call) { + e.Name, e.QTH, e.Country = st.Name, st.QTH, st.Country + break + } + } + } + if e.RSTSent == "" { + e.RSTSent = "59" + } + if e.RSTRcvd == "" { + e.RSTRcvd = "59" + } + a.netActive = append(a.netActive, e) + return *e, nil +} + +// NetUpdateActive edits the live fields (report/QTH/name/comment) of a station +// already on the air. TimeOn is preserved. +func (a *App) NetUpdateActive(e netActiveEntry) error { + call := strings.ToUpper(strings.TrimSpace(e.Callsign)) + a.netMu.Lock() + defer a.netMu.Unlock() + for _, cur := range a.netActive { + if cur.Callsign == call { + cur.Name, cur.QTH, cur.Country = e.Name, e.QTH, e.Country + cur.RSTSent, cur.RSTRcvd, cur.Comment = e.RSTSent, e.RSTRcvd, e.Comment + return nil + } + } + return fmt.Errorf("station not active") +} + +// NetDeactivate ends a station's QSO: it logs the contact to the active logbook +// (live CAT freq/mode, time_on→now) and removes it from the session. Returns +// the new QSO id. +func (a *App) NetDeactivate(callsign string) (int64, error) { + call := strings.ToUpper(strings.TrimSpace(callsign)) + a.netMu.Lock() + var entry *netActiveEntry + idx := -1 + for i, e := range a.netActive { + if e.Callsign == call { + entry, idx = e, i + break + } + } + if entry == nil { + a.netMu.Unlock() + return 0, fmt.Errorf("station not active") + } + a.netActive = append(a.netActive[:idx], a.netActive[idx+1:]...) + a.netMu.Unlock() + + // Frequency/mode come live from the rig; fall back to the last UI-reported + // values when CAT is off. + var st cat.RigState + if a.cat != nil { + st = a.cat.State() + } + freq, band, mode := st.FreqHz, st.Band, st.Mode + if freq == 0 { + a.liveActMu.Lock() + freq, band, mode = a.liveFreqHz, a.liveBand, a.liveMode + a.liveActMu.Unlock() + } + if band == "" && freq > 0 { + band = bandForHz(freq) + } + q := qso.QSO{ + Callsign: call, + QSODate: entry.TimeOn, + QSODateOff: time.Now().UTC(), + Band: band, + Mode: mode, + RSTSent: entry.RSTSent, + RSTRcvd: entry.RSTRcvd, + Name: entry.Name, + QTH: entry.QTH, + Comment: entry.Comment, + } + if freq > 0 { + f := freq + q.FreqHz = &f + } + return a.AddQSO(q) +} + // ── E-mail / SMTP (send QSO recordings) ─────────────────────────────── const ( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6e792fa..0b06bcf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -66,6 +66,7 @@ import { ShutdownProgress } from '@/components/ShutdownProgress'; import { ClusterGrid } from '@/components/ClusterGrid'; import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot'; import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; +import { NetControlPanel } from '@/components/NetControlPanel'; import { BulkEditModal } from '@/components/BulkEditModal'; import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover'; import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel'; @@ -695,6 +696,10 @@ export default function App() { } const chatShown = chatOpen && chatAvailable; + // NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster). + const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1'); + useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]); + const [dvkEnabled, setDvkEnabled] = useState(false); const [dvkMsgs, setDvkMsgs] = useState([]); const [dvkStat, setDvkStat] = useState({ recording: false, playing: false, rec_slot: 0 }); @@ -1709,8 +1714,6 @@ export default function App() { }; applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current); await AddQSO(payload); - // Same green toast as a QSL upload, so the op gets visual confirmation. - showToast(`QSO logged — ${payload.callsign}${band ? ` · ${band}` : ''}${mode ? ` ${mode}` : ''}`); resetEntry(); // clears the call AND the Worked-before matrix callsignRef.current?.focus(); // return focus to the call field, wherever it was (e.g. Name) await refresh(); @@ -2104,6 +2107,8 @@ export default function App() { { type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' }, { type: 'item', label: cwEnabled ? '✓ CW decoder (RX audio)' : 'CW decoder (RX audio)', action: 'tools.cwdecoder' }, { type: 'separator' }, + { type: 'item', label: netEnabled ? '✓ NET Control' : 'NET Control', action: 'tools.net' }, + { type: 'separator' }, // Maintenance — bumped here while we only have one entry. Will move // to a Tools → Maintenance submenu once Clublog + LoTW refresh land. { type: 'item', label: ctyRefreshing ? 'Refreshing cty.dat…' : 'Refresh cty.dat', action: 'tools.refreshCty', disabled: ctyRefreshing }, @@ -2112,7 +2117,7 @@ export default function App() { { name: 'help', label: 'Help', items: [ { type: 'item', label: 'About OpsLog', action: 'help.about' }, ]}, - ], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled]); + ], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled]); function handleMenu(action: string) { switch (action) { @@ -2130,6 +2135,7 @@ export default function App() { case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break; case 'tools.dvk': setDvkEnabled((v) => !v); break; case 'tools.cwdecoder': toggleCwDecoder(); break; + case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break; case 'tools.refreshCty': refreshCtyDat(); break; case 'tools.downloadRefs': downloadRefs(); break; case 'help.about': setShowAbout(true); break; @@ -3461,6 +3467,21 @@ export default function App() { Awards Band Map + {netEnabled && ( + + Net + { e.stopPropagation(); }} + onClick={(e) => { e.stopPropagation(); setNetEnabled(false); setActiveTab((t) => (t === 'net' ? 'recent' : t)); }} + > + + + + )} {catState.backend === 'flex' && FlexRadio} {catState.backend === 'icom' && Icom} {/* Not a tab — QRZ blocks embedding, so this opens the call's @@ -3788,6 +3809,12 @@ export default function App() { {/* Band Map: several bands shown side-by-side (panadapter-style strips). Pick bands with the chips; each strip is clickable to tune the rig. */} + {netEnabled && ( + + + + )} +
Bands: diff --git a/frontend/src/components/NetControlPanel.tsx b/frontend/src/components/NetControlPanel.tsx new file mode 100644 index 0000000..67433c1 --- /dev/null +++ b/frontend/src/components/NetControlPanel.tsx @@ -0,0 +1,386 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + AllCommunityModule, ModuleRegistry, themeQuartz, + type ColDef, type RowDoubleClickedEvent, type CellValueChangedEvent, +} from 'ag-grid-community'; +import { AgGridReact } from 'ag-grid-react'; +import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus } from 'lucide-react'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { + NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID, + NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup, + NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, +} from '@/../wailsjs/go/main/App'; +import { netctl } from '@/../wailsjs/go/models'; + +ModuleRegistry.registerModules([AllCommunityModule]); + +const hamlogTheme = themeQuartz.withParams({ + fontFamily: 'inherit', + fontSize: 12.5, + backgroundColor: '#faf6ea', + foregroundColor: '#2a2419', + headerBackgroundColor: '#e8dfc9', + headerTextColor: '#5a4f3a', + headerFontWeight: 600, + oddRowBackgroundColor: '#f5efe0', + rowHoverColor: '#ecdcb4', + selectedRowBackgroundColor: '#f0d9a8', + borderColor: '#c8b994', + rowBorder: { color: '#d8c9a8', width: 1 }, + columnBorder: { color: '#d8c9a8', width: 1 }, + cellHorizontalPadding: 10, + rowHeight: 30, + headerHeight: 32, + spacing: 4, + accentColor: '#b8410c', + iconSize: 12, +}); + +type Net = netctl.Net; +type Station = netctl.Station; +type Active = { + callsign: string; name: string; qth: string; country: string; + rst_sent: string; rst_rcvd: string; comment: string; time_on: any; +}; + +function fmtTimeOn(s: any): string { + if (!s) return ''; + const d = new Date(s); + if (isNaN(d.getTime())) return ''; + const p = (n: number) => String(n).padStart(2, '0'); + return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}Z`; +} + +const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' }); + +export function NetControlPanel({ onLogged, rstChoices }: { onLogged?: () => void; rstChoices?: string[] }) { + const [nets, setNets] = useState([]); + const [selId, setSelId] = useState(''); + const [openId, setOpenId] = useState(''); + const [roster, setRoster] = useState([]); + const [active, setActive] = useState([]); + const [error, setError] = useState(''); + + // Add/edit-contact dialog. + const [contactOpen, setContactOpen] = useState(false); + const [contact, setContact] = useState(emptyStation()); + const [looking, setLooking] = useState(false); + + const activeGrid = useRef(null); + const rosterGrid = useRef(null); + + const isOpen = openId !== '' && openId === selId; + + const refreshNets = useCallback(async () => { + try { + const [list, oid] = await Promise.all([NetList(), NetOpenID()]); + const arr = (list ?? []) as Net[]; + setNets(arr); + setOpenId(oid ?? ''); + setSelId((cur) => cur || (oid ?? '') || (arr[0]?.id ?? '')); + } catch (e: any) { setError(String(e?.message ?? e)); } + }, []); + + const refreshRoster = useCallback(async (id: string) => { + if (!id) { setRoster([]); return; } + try { setRoster(((await NetRoster(id)) ?? []) as Station[]); } + catch (e: any) { setError(String(e?.message ?? e)); } + }, []); + + const refreshActive = useCallback(async () => { + try { setActive(((await NetActiveList()) ?? []) as Active[]); } + catch { /* ignore */ } + }, []); + + useEffect(() => { refreshNets(); }, [refreshNets]); + useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]); + useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]); + + // The roster side hides callsigns that are currently on the air (they live in + // the left grid until logged), mirroring Log4OM's two-list behaviour. + const activeCalls = useMemo(() => new Set(active.map((a) => a.callsign.toUpperCase())), [active]); + const rosterShown = useMemo( + () => roster.filter((s) => !activeCalls.has((s.callsign ?? '').toUpperCase())), + [roster, activeCalls], + ); + + async function newNet() { + const name = window.prompt('New NET name:'); + if (!name?.trim()) return; + try { const n = await NetCreate(name.trim()); await refreshNets(); setSelId(n.id); } + catch (e: any) { setError(String(e?.message ?? e)); } + } + async function renameNet() { + if (!selId) return; + const cur = nets.find((n) => n.id === selId); + const name = window.prompt('Rename NET:', cur?.name ?? ''); + if (!name?.trim()) return; + try { await NetRename(selId, name.trim()); await refreshNets(); } + catch (e: any) { setError(String(e?.message ?? e)); } + } + async function deleteNet() { + if (!selId) return; + const cur = nets.find((n) => n.id === selId); + if (!window.confirm(`Delete NET "${cur?.name}" and its roster? This cannot be undone.`)) return; + try { await NetDelete(selId); setSelId(''); await refreshNets(); } + catch (e: any) { setError(String(e?.message ?? e)); } + } + async function toggleOpen() { + try { + if (isOpen) { + if (active.length > 0 && + !window.confirm(`${active.length} station(s) still on the air will be dropped WITHOUT logging. Close anyway?`)) return; + await NetClose(); setOpenId(''); + } else { + await NetOpen(selId); setOpenId(selId); await refreshActive(); + } + } catch (e: any) { setError(String(e?.message ?? e)); } + } + + // Roster → active (start QSO). + async function activate(call: string) { + if (!isOpen || !call) return; + try { await NetActivate(call); await refreshActive(); } + catch (e: any) { setError(String(e?.message ?? e)); } + } + // Active → logged (end QSO, removed from session, written to the logbook). + async function deactivate(call: string) { + if (!call) return; + try { await NetDeactivate(call); await refreshActive(); onLogged?.(); } + catch (e: any) { setError(String(e?.message ?? e)); } + } + + function onActiveDblClick(e: RowDoubleClickedEvent) { + // Double-clicking a non-editable area logs the QSO; editable cells open the + // editor instead (ag-grid handles that before this fires only for blanks), + // so we gate on the column being the callsign. + if (e.data && (e as any).column?.getColId?.() === 'callsign') deactivate(e.data.callsign); + } + + async function onActiveCellChanged(e: CellValueChangedEvent) { + const d = e.data; + if (!d) return; + try { + await NetUpdateActive({ + callsign: d.callsign, name: d.name ?? '', qth: d.qth ?? '', country: d.country ?? '', + rst_sent: d.rst_sent ?? '', rst_rcvd: d.rst_rcvd ?? '', comment: d.comment ?? '', time_on: d.time_on, + } as any); + } catch (err: any) { setError(String(err?.message ?? err)); } + } + + // Add-contact dialog. + function openAddContact() { setContact(emptyStation()); setContactOpen(true); } + async function lookupContact() { + const call = (contact.callsign ?? '').trim(); + if (!call) return; + setLooking(true); + try { + const r = await NetLookup(call); + setContact((c) => netctl.Station.createFrom({ + ...c, callsign: (r.callsign || call).toUpperCase(), + name: r.name || c.name, qth: r.qth || c.qth, country: r.country || c.country, + dxcc: r.dxcc || c.dxcc, itu: r.itu || c.itu, cq: r.cq || c.cq, + })); + } catch (e: any) { setError(String(e?.message ?? e)); } + finally { setLooking(false); } + } + async function saveContact() { + const call = (contact.callsign ?? '').trim().toUpperCase(); + if (!call || !selId) return; + try { + await NetRosterUpsert(selId, netctl.Station.createFrom({ ...contact, callsign: call })); + setContactOpen(false); + await refreshRoster(selId); + } catch (e: any) { setError(String(e?.message ?? e)); } + } + async function removeSelectedRoster() { + const sel = (rosterGrid.current?.api?.getSelectedRows() ?? []) as Station[]; + if (sel.length === 0) return; + if (!window.confirm(`Remove ${sel.length} station(s) from this NET's roster?`)) return; + try { + for (const s of sel) await NetRosterRemove(selId, s.callsign); + await refreshRoster(selId); + } catch (e: any) { setError(String(e?.message ?? e)); } + } + + const activeCols = useMemo[]>(() => [ + { colId: 'callsign', headerName: 'Callsign', field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' }, + { headerName: 'Name', field: 'name', flex: 1, editable: true }, + { headerName: 'QTH', field: 'qth', flex: 1, editable: true }, + { headerName: 'Time on', valueGetter: (p) => fmtTimeOn(p.data?.time_on), width: 90, cellClass: 'font-mono text-[11px]' }, + { headerName: 'Country', field: 'country', width: 120 }, + { + headerName: 'RST S', field: 'rst_sent', width: 80, editable: true, cellClass: 'font-mono', + cellEditor: 'agSelectCellEditor', cellEditorParams: { values: rstChoices ?? [] }, + }, + { + headerName: 'RST R', field: 'rst_rcvd', width: 80, editable: true, cellClass: 'font-mono', + cellEditor: 'agSelectCellEditor', cellEditorParams: { values: rstChoices ?? [] }, + }, + { headerName: 'Comment', field: 'comment', flex: 1.5, editable: true }, + ], [rstChoices]); + + const rosterCols = useMemo[]>(() => [ + { headerName: 'Callsign', field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' }, + { headerName: 'Name', field: 'name', flex: 1 }, + { headerName: 'QTH', field: 'qth', flex: 1 }, + { headerName: 'Country', field: 'country', width: 130 }, + ], []); + + const defaultColDef = useMemo(() => ({ sortable: true, resizable: true, suppressMovable: false }), []); + + return ( +
+ {/* Toolbar */} +
+ + + + + + +
+ {isOpen && NET OPEN} + + On air: {active.length} · + Roster: {roster.length} + + {error && {error}} +
+ +
+ {/* ACTIVE USERS (left) */} +
+
+ On air — active QSOs + double-click callsign → log & end QSO +
+
+
+ + ref={activeGrid} + theme={hamlogTheme} + rowData={active} + columnDefs={activeCols} + defaultColDef={defaultColDef} + onRowDoubleClicked={onActiveDblClick} + onCellValueChanged={onActiveCellChanged} + animateRows={false} + getRowId={(p) => String((p.data as any).callsign)} + stopEditingWhenCellsLoseFocus + /> +
+
+ {isOpen && active.length > 0 && ( +
+ +
+ )} +
+ + {/* NET USERS / roster (right) */} +
+
+ NET users — roster + double-click → put on air +
+
+
+ + ref={rosterGrid} + theme={hamlogTheme} + rowData={rosterShown} + columnDefs={rosterCols} + defaultColDef={defaultColDef} + rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }} + onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)} + animateRows={false} + getRowId={(p) => String((p.data as any).callsign)} + /> +
+
+
+ + + {isOpen && ( + + )} +
+
+
+ + {/* Add / edit contact dialog */} + + + + Add contact to NET + Saved in this NET's roster (reused next time you open it). + +
+
+
+ + setContact((c) => netctl.Station.createFrom({ ...c, callsign: e.target.value.toUpperCase() }))} + onKeyDown={(e) => { if (e.key === 'Enter') lookupContact(); }} /> +
+ +
+
+ + setContact((c) => netctl.Station.createFrom({ ...c, name: e.target.value }))} /> +
+
+ + setContact((c) => netctl.Station.createFrom({ ...c, qth: e.target.value }))} /> +
+
+ + setContact((c) => netctl.Station.createFrom({ ...c, country: e.target.value }))} /> +
+
+ + + + +
+
+
+ ); +} diff --git a/frontend/src/components/QSLManagerModal.tsx b/frontend/src/components/QSLManagerModal.tsx index 936b6df..f80aebc 100644 --- a/frontend/src/components/QSLManagerModal.tsx +++ b/frontend/src/components/QSLManagerModal.tsx @@ -283,7 +283,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
{uploadCall && ( diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index d653947..8cfcf21 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -16,6 +16,7 @@ import {audio} from '../models'; import {operating} from '../models'; import {udp} from '../models'; import {lookup} from '../models'; +import {netctl} from '../models'; export function ADIFFields():Promise>; @@ -371,6 +372,38 @@ export function LookupCallsign(arg1:string):Promise; export function MoveDatabase(arg1:string):Promise; +export function NetActivate(arg1:string):Promise; + +export function NetActiveList():Promise>; + +export function NetClose():Promise; + +export function NetCreate(arg1:string):Promise; + +export function NetDeactivate(arg1:string):Promise; + +export function NetDelete(arg1:string):Promise; + +export function NetList():Promise>; + +export function NetLookup(arg1:string):Promise; + +export function NetOpen(arg1:string):Promise; + +export function NetOpenID():Promise; + +export function NetRename(arg1:string,arg2:string):Promise; + +export function NetRoster(arg1:string):Promise>; + +export function NetRosterRemove(arg1:string,arg2:string):Promise; + +export function NetRosterUpsert(arg1:string,arg2:netctl.Station):Promise; + +export function NetSetDefaults(arg1:string,arg2:string,arg3:string,arg4:string):Promise; + +export function NetUpdateActive(arg1:main.netActiveEntry):Promise; + export function OpenADIFFile():Promise; export function OpenDatabase(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 48d451d..50fff82 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -710,6 +710,70 @@ export function MoveDatabase(arg1) { return window['go']['main']['App']['MoveDatabase'](arg1); } +export function NetActivate(arg1) { + return window['go']['main']['App']['NetActivate'](arg1); +} + +export function NetActiveList() { + return window['go']['main']['App']['NetActiveList'](); +} + +export function NetClose() { + return window['go']['main']['App']['NetClose'](); +} + +export function NetCreate(arg1) { + return window['go']['main']['App']['NetCreate'](arg1); +} + +export function NetDeactivate(arg1) { + return window['go']['main']['App']['NetDeactivate'](arg1); +} + +export function NetDelete(arg1) { + return window['go']['main']['App']['NetDelete'](arg1); +} + +export function NetList() { + return window['go']['main']['App']['NetList'](); +} + +export function NetLookup(arg1) { + return window['go']['main']['App']['NetLookup'](arg1); +} + +export function NetOpen(arg1) { + return window['go']['main']['App']['NetOpen'](arg1); +} + +export function NetOpenID() { + return window['go']['main']['App']['NetOpenID'](); +} + +export function NetRename(arg1, arg2) { + return window['go']['main']['App']['NetRename'](arg1, arg2); +} + +export function NetRoster(arg1) { + return window['go']['main']['App']['NetRoster'](arg1); +} + +export function NetRosterRemove(arg1, arg2) { + return window['go']['main']['App']['NetRosterRemove'](arg1, arg2); +} + +export function NetRosterUpsert(arg1, arg2) { + return window['go']['main']['App']['NetRosterUpsert'](arg1, arg2); +} + +export function NetSetDefaults(arg1, arg2, arg3, arg4) { + return window['go']['main']['App']['NetSetDefaults'](arg1, arg2, arg3, arg4); +} + +export function NetUpdateActive(arg1) { + return window['go']['main']['App']['NetUpdateActive'](arg1); +} + export function OpenADIFFile() { return window['go']['main']['App']['OpenADIFFile'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 886f91d..ac4ef44 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -2004,6 +2004,126 @@ export namespace main { return a; } } + export class netActiveEntry { + callsign: string; + name: string; + qth: string; + country: string; + rst_sent: string; + rst_rcvd: string; + comment: string; + // Go type: time + time_on: any; + + static createFrom(source: any = {}) { + return new netActiveEntry(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.callsign = source["callsign"]; + this.name = source["name"]; + this.qth = source["qth"]; + this.country = source["country"]; + this.rst_sent = source["rst_sent"]; + this.rst_rcvd = source["rst_rcvd"]; + this.comment = source["comment"]; + this.time_on = this.convertValues(source["time_on"], null); + } + + 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 netctl { + + export class Station { + callsign: string; + name?: string; + qth?: string; + country?: string; + dxcc?: number; + itu?: number; + cq?: number; + groups?: string; + sig?: string; + sig_info?: string; + + static createFrom(source: any = {}) { + return new Station(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.callsign = source["callsign"]; + this.name = source["name"]; + this.qth = source["qth"]; + this.country = source["country"]; + this.dxcc = source["dxcc"]; + this.itu = source["itu"]; + this.cq = source["cq"]; + this.groups = source["groups"]; + this.sig = source["sig"]; + this.sig_info = source["sig_info"]; + } + } + export class Net { + id: string; + name: string; + default_rst_sent?: string; + default_rst_rcvd?: string; + default_comment?: string; + stations?: Station[]; + + static createFrom(source: any = {}) { + return new Net(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.name = source["name"]; + this.default_rst_sent = source["default_rst_sent"]; + this.default_rst_rcvd = source["default_rst_rcvd"]; + this.default_comment = source["default_comment"]; + this.stations = this.convertValues(source["stations"], Station); + } + + 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; + } + } } diff --git a/internal/netctl/netctl.go b/internal/netctl/netctl.go new file mode 100644 index 0000000..b990e15 --- /dev/null +++ b/internal/netctl/netctl.go @@ -0,0 +1,234 @@ +// Package netctl persists "NET" definitions and their station rosters for the +// NET Control feature (managing a directed net / round-table on a frequency). +// +// A NET is a named net (e.g. "French QSO", "QSO des Brasses") with a roster of +// stations that habitually check in. The roster grows over time as you add new +// callsigns. Storage is a single JSON file in the data dir — global/shared +// across all logbooks (a net like "French QSO" is reused whatever logbook is +// open). The QSOs themselves are logged into the active logbook by the caller; +// this package only owns the net definitions + rosters, not the live session. +package netctl + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +// Station is one roster entry: a station registered in a net. +type Station struct { + Callsign string `json:"callsign"` + Name string `json:"name,omitempty"` + QTH string `json:"qth,omitempty"` + Country string `json:"country,omitempty"` + DXCC int `json:"dxcc,omitempty"` + ITU int `json:"itu,omitempty"` + CQ int `json:"cq,omitempty"` + Groups string `json:"groups,omitempty"` + SIG string `json:"sig,omitempty"` + SIGInfo string `json:"sig_info,omitempty"` +} + +// Net is a named net with default report values and a station roster. +type Net struct { + ID string `json:"id"` + Name string `json:"name"` + DefaultRSTSent string `json:"default_rst_sent,omitempty"` + DefaultRSTRcvd string `json:"default_rst_rcvd,omitempty"` + DefaultComment string `json:"default_comment,omitempty"` + Stations []Station `json:"stations,omitempty"` +} + +// Store is the persistent collection of nets, backed by a JSON file. +type Store struct { + mu sync.Mutex + path string + nets []Net +} + +// Open loads the store from path (creating an empty one if the file is absent). +func Open(path string) (*Store, error) { + s := &Store{path: path} + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return s, nil + } + return nil, err + } + if len(b) > 0 { + if err := json.Unmarshal(b, &s.nets); err != nil { + // Corrupt file: start empty rather than failing the whole app. + s.nets = nil + } + } + return s, nil +} + +// save writes the current state to disk. Caller must hold s.mu. +func (s *Store) save() error { + b, err := json.MarshalIndent(s.nets, "", " ") + if err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, b, 0o644); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +func newID() string { return strconv.FormatInt(time.Now().UnixNano(), 36) } + +// List returns a copy of all nets (with rosters), ordered by name. +func (s *Store) List() []Net { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]Net, len(s.nets)) + copy(out, s.nets) + sort.Slice(out, func(i, j int) bool { + return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) + }) + return out +} + +// find returns the index of the net with id, or -1. Caller must hold s.mu. +func (s *Store) find(id string) int { + for i := range s.nets { + if s.nets[i].ID == id { + return i + } + } + return -1 +} + +// Create adds a new net with default reports of 59/59 and returns it. +func (s *Store) Create(name string) (Net, error) { + name = strings.TrimSpace(name) + if name == "" { + return Net{}, fmt.Errorf("net name required") + } + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.nets { + if strings.EqualFold(s.nets[i].Name, name) { + return Net{}, fmt.Errorf("a net named %q already exists", name) + } + } + n := Net{ID: newID(), Name: name, DefaultRSTSent: "59", DefaultRSTRcvd: "59"} + s.nets = append(s.nets, n) + return n, s.save() +} + +// Rename changes a net's name. +func (s *Store) Rename(id, name string) error { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("net name required") + } + s.mu.Lock() + defer s.mu.Unlock() + i := s.find(id) + if i < 0 { + return fmt.Errorf("net not found") + } + s.nets[i].Name = name + return s.save() +} + +// SetDefaults updates the per-net default report/comment values. +func (s *Store) SetDefaults(id, rstSent, rstRcvd, comment string) error { + s.mu.Lock() + defer s.mu.Unlock() + i := s.find(id) + if i < 0 { + return fmt.Errorf("net not found") + } + s.nets[i].DefaultRSTSent = rstSent + s.nets[i].DefaultRSTRcvd = rstRcvd + s.nets[i].DefaultComment = comment + return s.save() +} + +// Delete removes a net and its roster. +func (s *Store) Delete(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + i := s.find(id) + if i < 0 { + return fmt.Errorf("net not found") + } + s.nets = append(s.nets[:i], s.nets[i+1:]...) + return s.save() +} + +// Get returns a copy of one net by id. +func (s *Store) Get(id string) (Net, bool) { + s.mu.Lock() + defer s.mu.Unlock() + i := s.find(id) + if i < 0 { + return Net{}, false + } + return s.nets[i], true +} + +// Roster returns a net's stations, sorted by callsign. +func (s *Store) Roster(id string) ([]Station, error) { + s.mu.Lock() + defer s.mu.Unlock() + i := s.find(id) + if i < 0 { + return nil, fmt.Errorf("net not found") + } + out := make([]Station, len(s.nets[i].Stations)) + copy(out, s.nets[i].Stations) + sort.Slice(out, func(a, b int) bool { return out[a].Callsign < out[b].Callsign }) + return out, nil +} + +// RosterUpsert adds st to the net's roster, or updates it if the callsign is +// already present (matched case-insensitively; the callsign is stored upper). +func (s *Store) RosterUpsert(id string, st Station) error { + st.Callsign = strings.ToUpper(strings.TrimSpace(st.Callsign)) + if st.Callsign == "" { + return fmt.Errorf("callsign required") + } + s.mu.Lock() + defer s.mu.Unlock() + i := s.find(id) + if i < 0 { + return fmt.Errorf("net not found") + } + for j := range s.nets[i].Stations { + if strings.EqualFold(s.nets[i].Stations[j].Callsign, st.Callsign) { + s.nets[i].Stations[j] = st + return s.save() + } + } + s.nets[i].Stations = append(s.nets[i].Stations, st) + return s.save() +} + +// RosterRemove deletes a callsign from a net's roster. +func (s *Store) RosterRemove(id, callsign string) error { + callsign = strings.ToUpper(strings.TrimSpace(callsign)) + s.mu.Lock() + defer s.mu.Unlock() + i := s.find(id) + if i < 0 { + return fmt.Errorf("net not found") + } + for j := range s.nets[i].Stations { + if strings.EqualFold(s.nets[i].Stations[j].Callsign, callsign) { + s.nets[i].Stations = append(s.nets[i].Stations[:j], s.nets[i].Stations[j+1:]...) + return s.save() + } + } + return nil // not present → nothing to do +}