// 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 }