feat: Added Net control
This commit is contained in:
@@ -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