feat: Added Net control
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hamlog/internal/adif"
|
"hamlog/internal/adif"
|
||||||
|
"hamlog/internal/antgenius"
|
||||||
"hamlog/internal/applog"
|
"hamlog/internal/applog"
|
||||||
"hamlog/internal/audio"
|
"hamlog/internal/audio"
|
||||||
"hamlog/internal/award"
|
"hamlog/internal/award"
|
||||||
@@ -25,23 +26,23 @@ import (
|
|||||||
"hamlog/internal/backup"
|
"hamlog/internal/backup"
|
||||||
"hamlog/internal/cat"
|
"hamlog/internal/cat"
|
||||||
"hamlog/internal/clublog"
|
"hamlog/internal/clublog"
|
||||||
"hamlog/internal/cwdecode"
|
|
||||||
"hamlog/internal/cluster"
|
"hamlog/internal/cluster"
|
||||||
|
"hamlog/internal/cwdecode"
|
||||||
"hamlog/internal/db"
|
"hamlog/internal/db"
|
||||||
"hamlog/internal/dxcc"
|
"hamlog/internal/dxcc"
|
||||||
"hamlog/internal/email"
|
"hamlog/internal/email"
|
||||||
"hamlog/internal/extsvc"
|
"hamlog/internal/extsvc"
|
||||||
"hamlog/internal/integrations/udp"
|
"hamlog/internal/integrations/udp"
|
||||||
"hamlog/internal/lookup"
|
"hamlog/internal/lookup"
|
||||||
|
"hamlog/internal/netctl"
|
||||||
"hamlog/internal/operating"
|
"hamlog/internal/operating"
|
||||||
"hamlog/internal/pota"
|
"hamlog/internal/pota"
|
||||||
|
"hamlog/internal/powergenius"
|
||||||
"hamlog/internal/profile"
|
"hamlog/internal/profile"
|
||||||
"hamlog/internal/qslcard"
|
"hamlog/internal/qslcard"
|
||||||
"hamlog/internal/qso"
|
"hamlog/internal/qso"
|
||||||
"hamlog/internal/rotator/pst"
|
"hamlog/internal/rotator/pst"
|
||||||
"hamlog/internal/settings"
|
"hamlog/internal/settings"
|
||||||
"hamlog/internal/antgenius"
|
|
||||||
"hamlog/internal/powergenius"
|
|
||||||
"hamlog/internal/ultrabeam"
|
"hamlog/internal/ultrabeam"
|
||||||
"hamlog/internal/winkeyer"
|
"hamlog/internal/winkeyer"
|
||||||
|
|
||||||
@@ -399,6 +400,14 @@ type App struct {
|
|||||||
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
|
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
|
||||||
audioMgr *audio.Manager
|
audioMgr *audio.Manager
|
||||||
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
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
|
cwMu sync.Mutex // guards the CW decoder lifecycle
|
||||||
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
|
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
|
||||||
cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
|
cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
|
||||||
@@ -827,6 +836,13 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
a.qsoRec = audio.NewRecorder()
|
a.qsoRec = audio.NewRecorder()
|
||||||
a.startQSORecorderIfEnabled()
|
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.
|
// Ultrabeam antenna: connect in the background if enabled.
|
||||||
a.startUltrabeam()
|
a.startUltrabeam()
|
||||||
// Antenna Genius switch: connect in the background if enabled.
|
// 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
|
return filepath.Join(filepath.Dir(exe), "data"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// fileExists reports whether path exists and is a regular file.
|
// fileExists reports whether path exists and is a regular file.
|
||||||
func fileExists(path string) bool {
|
func fileExists(path string) bool {
|
||||||
fi, err := os.Stat(path)
|
fi, err := os.Stat(path)
|
||||||
@@ -4260,6 +4275,259 @@ func (a *App) QSOAudioCancel() {
|
|||||||
// RestartQSORecorder applies new audio settings to the running recorder.
|
// RestartQSORecorder applies new audio settings to the running recorder.
|
||||||
func (a *App) RestartQSORecorder() { a.startQSORecorderIfEnabled() }
|
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) ───────────────────────────────
|
// ── E-mail / SMTP (send QSO recordings) ───────────────────────────────
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
+30
-3
@@ -66,6 +66,7 @@ import { ShutdownProgress } from '@/components/ShutdownProgress';
|
|||||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||||
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
||||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||||
|
import { NetControlPanel } from '@/components/NetControlPanel';
|
||||||
import { BulkEditModal } from '@/components/BulkEditModal';
|
import { BulkEditModal } from '@/components/BulkEditModal';
|
||||||
import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover';
|
import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover';
|
||||||
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
||||||
@@ -695,6 +696,10 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
const chatShown = chatOpen && chatAvailable;
|
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 [dvkEnabled, setDvkEnabled] = useState(false);
|
||||||
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
||||||
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
||||||
@@ -1709,8 +1714,6 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
|
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
|
||||||
await AddQSO(payload);
|
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
|
resetEntry(); // clears the call AND the Worked-before matrix
|
||||||
callsignRef.current?.focus(); // return focus to the call field, wherever it was (e.g. Name)
|
callsignRef.current?.focus(); // return focus to the call field, wherever it was (e.g. Name)
|
||||||
await refresh();
|
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: 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: 'item', label: cwEnabled ? '✓ CW decoder (RX audio)' : 'CW decoder (RX audio)', action: 'tools.cwdecoder' },
|
||||||
{ type: 'separator' },
|
{ 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
|
// Maintenance — bumped here while we only have one entry. Will move
|
||||||
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
|
// 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 },
|
{ 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: [
|
{ name: 'help', label: 'Help', items: [
|
||||||
{ type: 'item', label: 'About OpsLog', action: 'help.about' },
|
{ 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) {
|
function handleMenu(action: string) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
@@ -2130,6 +2135,7 @@ export default function App() {
|
|||||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||||
case 'tools.cwdecoder': toggleCwDecoder(); 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.refreshCty': refreshCtyDat(); break;
|
||||||
case 'tools.downloadRefs': downloadRefs(); break;
|
case 'tools.downloadRefs': downloadRefs(); break;
|
||||||
case 'help.about': setShowAbout(true); break;
|
case 'help.about': setShowAbout(true); break;
|
||||||
@@ -3461,6 +3467,21 @@ export default function App() {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="awards">Awards</TabsTrigger>
|
<TabsTrigger value="awards">Awards</TabsTrigger>
|
||||||
<TabsTrigger value="bandmap">Band Map</TabsTrigger>
|
<TabsTrigger value="bandmap">Band Map</TabsTrigger>
|
||||||
|
{netEnabled && (
|
||||||
|
<TabsTrigger value="net" className="gap-1.5">
|
||||||
|
Net
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
aria-label="Close Net"
|
||||||
|
title="Close"
|
||||||
|
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
|
||||||
|
onPointerDown={(e) => { e.stopPropagation(); }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setNetEnabled(false); setActiveTab((t) => (t === 'net' ? 'recent' : t)); }}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
|
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
|
||||||
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>}
|
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom</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
|
||||||
@@ -3788,6 +3809,12 @@ export default function App() {
|
|||||||
{/* Band Map: several bands shown side-by-side (panadapter-style
|
{/* Band Map: several bands shown side-by-side (panadapter-style
|
||||||
strips). Pick bands with the chips; each strip is clickable to
|
strips). Pick bands with the chips; each strip is clickable to
|
||||||
tune the rig. */}
|
tune the rig. */}
|
||||||
|
{netEnabled && (
|
||||||
|
<TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||||
|
<NetControlPanel onLogged={refresh} rstChoices={rstOptions(mode, rstLists)} />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
<TabsContent value="bandmap" className="mt-0 flex flex-col min-h-0 flex-1">
|
<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">
|
<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>
|
<span className="text-xs text-muted-foreground mr-1">Bands:</span>
|
||||||
|
|||||||
@@ -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<Net[]>([]);
|
||||||
|
const [selId, setSelId] = useState<string>('');
|
||||||
|
const [openId, setOpenId] = useState<string>('');
|
||||||
|
const [roster, setRoster] = useState<Station[]>([]);
|
||||||
|
const [active, setActive] = useState<Active[]>([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Add/edit-contact dialog.
|
||||||
|
const [contactOpen, setContactOpen] = useState(false);
|
||||||
|
const [contact, setContact] = useState<Station>(emptyStation());
|
||||||
|
const [looking, setLooking] = useState(false);
|
||||||
|
|
||||||
|
const activeGrid = useRef<any>(null);
|
||||||
|
const rosterGrid = useRef<any>(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<Active>) {
|
||||||
|
// 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<Active>) {
|
||||||
|
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<ColDef<Active>[]>(() => [
|
||||||
|
{ 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<ColDef<Station>[]>(() => [
|
||||||
|
{ 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<ColDef>(() => ({ sortable: true, resizable: true, suppressMovable: false }), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-0 flex-1">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<Button variant="outline" size="sm" className="h-8" onClick={newNet}>
|
||||||
|
<Plus className="size-3.5" /> New NET
|
||||||
|
</Button>
|
||||||
|
<select
|
||||||
|
className="h-8 rounded-md border border-input bg-background px-2 text-sm min-w-[180px]"
|
||||||
|
value={selId}
|
||||||
|
disabled={isOpen}
|
||||||
|
onChange={(e) => setSelId(e.target.value)}
|
||||||
|
title={isOpen ? 'Close the NET to switch' : 'Select a NET'}
|
||||||
|
>
|
||||||
|
<option value="">— select a NET —</option>
|
||||||
|
{nets.map((n) => <option key={n.id} value={n.id}>{n.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<Button variant={isOpen ? 'destructive' : 'default'} size="sm" className="h-8" disabled={!selId} onClick={toggleOpen}>
|
||||||
|
<Radio className="size-3.5" /> {isOpen ? 'Close NET' : 'Open NET'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8" disabled={!selId || isOpen} onClick={renameNet}>Rename</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 text-rose-700" disabled={!selId || isOpen} onClick={deleteNet}>
|
||||||
|
<Trash2 className="size-3.5" /> Delete
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
{isOpen && <Badge className="bg-emerald-600 text-white tracking-wider">NET OPEN</Badge>}
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
On air: <strong className="text-foreground">{active.length}</strong> ·
|
||||||
|
Roster: <strong className="text-foreground">{roster.length}</strong>
|
||||||
|
</span>
|
||||||
|
{error && <Badge variant="destructive" className="max-w-[280px] truncate" title={error}>{error}</Badge>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
{/* ACTIVE USERS (left) */}
|
||||||
|
<div className="flex flex-col min-h-0 flex-1 border-r border-border/60">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-600 text-white text-[11px] font-semibold uppercase tracking-wider">
|
||||||
|
On air — active QSOs
|
||||||
|
<span className="ml-auto font-normal normal-case opacity-90">double-click callsign → log & end QSO</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||||
|
<div style={{ position: 'absolute', inset: 0 }}>
|
||||||
|
<AgGridReact<Active>
|
||||||
|
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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isOpen && active.length > 0 && (
|
||||||
|
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
||||||
|
onClick={() => { const r = activeGrid.current?.api?.getSelectedRows?.()?.[0] as Active; if (r) deactivate(r.callsign); }}>
|
||||||
|
<MinusCircle className="size-3.5" /> Log & end selected
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* NET USERS / roster (right) */}
|
||||||
|
<div className="flex flex-col min-h-0 w-[40%] max-w-[560px]">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-emerald-700 text-white text-[11px] font-semibold uppercase tracking-wider">
|
||||||
|
NET users — roster
|
||||||
|
<span className="ml-auto font-normal normal-case opacity-90">double-click → put on air</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||||
|
<div style={{ position: 'absolute', inset: 0 }}>
|
||||||
|
<AgGridReact<Station>
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" disabled={!selId} onClick={openAddContact}>
|
||||||
|
<UserPlus className="size-3.5" /> Add contact
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px] text-rose-700" disabled={!selId} onClick={removeSelectedRoster}>
|
||||||
|
<MinusCircle className="size-3.5" /> Remove
|
||||||
|
</Button>
|
||||||
|
{isOpen && (
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto"
|
||||||
|
onClick={() => { const r = rosterGrid.current?.api?.getSelectedRows?.()?.[0] as Station; if (r) activate(r.callsign); }}>
|
||||||
|
<PlusCircle className="size-3.5" /> Put selected on air
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add / edit contact dialog */}
|
||||||
|
<Dialog open={contactOpen} onOpenChange={setContactOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add contact to NET</DialogTitle>
|
||||||
|
<DialogDescription>Saved in this NET's roster (reused next time you open it).</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-3 px-5 py-2">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label className="text-[11px]">Callsign</Label>
|
||||||
|
<Input className="font-mono uppercase" value={contact.callsign ?? ''}
|
||||||
|
onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, callsign: e.target.value.toUpperCase() }))}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') lookupContact(); }} />
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={lookupContact} disabled={looking || !(contact.callsign ?? '').trim()}>
|
||||||
|
<Search className="size-3.5" /> {looking ? '…' : 'Search'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[11px]">Name</Label>
|
||||||
|
<Input value={contact.name ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, name: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[11px]">QTH</Label>
|
||||||
|
<Input value={contact.qth ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, qth: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[11px]">Country</Label>
|
||||||
|
<Input value={contact.country ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, country: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setContactOpen(false)}>Cancel</Button>
|
||||||
|
<Button size="sm" onClick={saveContact} disabled={!(contact.callsign ?? '').trim()}>
|
||||||
|
<PlusCircle className="size-3.5" /> Save in NET
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -283,7 +283,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
|
||||||
<Select value={service} onValueChange={setService}>
|
<Select value={service} onValueChange={setService}>
|
||||||
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
<SelectContent>{[...SERVICES].sort((a, b) => a.label.localeCompare(b.label)).map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{uploadCall && (
|
{uploadCall && (
|
||||||
|
|||||||
Vendored
+33
@@ -16,6 +16,7 @@ import {audio} from '../models';
|
|||||||
import {operating} from '../models';
|
import {operating} from '../models';
|
||||||
import {udp} from '../models';
|
import {udp} from '../models';
|
||||||
import {lookup} from '../models';
|
import {lookup} from '../models';
|
||||||
|
import {netctl} from '../models';
|
||||||
|
|
||||||
export function ADIFFields():Promise<Array<adif.FieldDef>>;
|
export function ADIFFields():Promise<Array<adif.FieldDef>>;
|
||||||
|
|
||||||
@@ -371,6 +372,38 @@ export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
|||||||
|
|
||||||
export function MoveDatabase(arg1:string):Promise<void>;
|
export function MoveDatabase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function NetActivate(arg1:string):Promise<main.netActiveEntry>;
|
||||||
|
|
||||||
|
export function NetActiveList():Promise<Array<main.netActiveEntry>>;
|
||||||
|
|
||||||
|
export function NetClose():Promise<void>;
|
||||||
|
|
||||||
|
export function NetCreate(arg1:string):Promise<netctl.Net>;
|
||||||
|
|
||||||
|
export function NetDeactivate(arg1:string):Promise<number>;
|
||||||
|
|
||||||
|
export function NetDelete(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function NetList():Promise<Array<netctl.Net>>;
|
||||||
|
|
||||||
|
export function NetLookup(arg1:string):Promise<netctl.Station>;
|
||||||
|
|
||||||
|
export function NetOpen(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function NetOpenID():Promise<string>;
|
||||||
|
|
||||||
|
export function NetRename(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function NetRoster(arg1:string):Promise<Array<netctl.Station>>;
|
||||||
|
|
||||||
|
export function NetRosterRemove(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function NetRosterUpsert(arg1:string,arg2:netctl.Station):Promise<void>;
|
||||||
|
|
||||||
|
export function NetSetDefaults(arg1:string,arg2:string,arg3:string,arg4:string):Promise<void>;
|
||||||
|
|
||||||
|
export function NetUpdateActive(arg1:main.netActiveEntry):Promise<void>;
|
||||||
|
|
||||||
export function OpenADIFFile():Promise<string>;
|
export function OpenADIFFile():Promise<string>;
|
||||||
|
|
||||||
export function OpenDatabase(arg1:string):Promise<void>;
|
export function OpenDatabase(arg1:string):Promise<void>;
|
||||||
|
|||||||
@@ -710,6 +710,70 @@ export function MoveDatabase(arg1) {
|
|||||||
return window['go']['main']['App']['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() {
|
export function OpenADIFFile() {
|
||||||
return window['go']['main']['App']['OpenADIFFile']();
|
return window['go']['main']['App']['OpenADIFFile']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2004,6 +2004,126 @@ export namespace main {
|
|||||||
return a;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user