// Package operating manages the per-profile tree of stations (radios), // antennas, and the bands each antenna covers. The "default for a band" // flag drives the auto-fill of MY_RIG / MY_ANTENNA on each logged QSO. package operating import ( "context" "database/sql" "fmt" "strings" ) // Station is a radio / TRX line. The display Name is also what gets // written into the MY_RIG ADIF field on each QSO — no separate ADIF // value to maintain. TXPower (W) is per-rig so changing rig auto- // stamps the right power on logged QSOs. type Station struct { ID int64 `json:"id"` ProfileID int64 `json:"profile_id"` Name string `json:"name"` TXPower *float64 `json:"tx_pwr,omitempty"` SortOrder int `json:"sort_order"` Antennas []Antenna `json:"antennas,omitempty"` } // Antenna is one antenna attached to a station. The display Name doubles // as the MY_ANTENNA ADIF value. type Antenna struct { ID int64 `json:"id"` StationID int64 `json:"station_id"` Name string `json:"name"` SortOrder int `json:"sort_order"` Bands []AntennaBand `json:"bands"` } // AntennaBand pairs an antenna with one of the bands it covers, plus // whether it is the default for that band in this profile. type AntennaBand struct { Band string `json:"band"` IsDefault bool `json:"is_default"` } // BandDefault is the resolved tuple looked up at QSO save: which // station+antenna should pre-fill MY_RIG / MY_ANTENNA / TX_PWR for a // given band. Station and antenna names go straight into the ADIF // fields — there is no separate "ADIF value" anymore. type BandDefault struct { StationID int64 `json:"station_id"` StationName string `json:"station_name"` AntennaID int64 `json:"antenna_id"` AntennaName string `json:"antenna_name"` TXPower *float64 `json:"tx_pwr,omitempty"` } type Repo struct{ db *sql.DB } func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} } // ListTree returns every station for the profile with its nested antennas // and bands. One round-trip per level — three queries total regardless of // tree size, so the Settings panel stays snappy on big setups. func (r *Repo) ListTree(ctx context.Context, profileID int64) ([]Station, error) { rows, err := r.db.QueryContext(ctx, `SELECT id, profile_id, name, tx_pwr, sort_order FROM operating_stations WHERE profile_id = ? ORDER BY sort_order, id`, profileID) if err != nil { return nil, fmt.Errorf("list stations: %w", err) } defer rows.Close() var stations []Station stationByID := map[int64]int{} // id → index in stations slice for rows.Next() { var s Station var pwr sql.NullFloat64 if err := rows.Scan(&s.ID, &s.ProfileID, &s.Name, &pwr, &s.SortOrder); err != nil { return nil, err } if pwr.Valid { v := pwr.Float64 s.TXPower = &v } stationByID[s.ID] = len(stations) stations = append(stations, s) } if len(stations) == 0 { return stations, nil } // Build IN-clause placeholders for the second query. ids := make([]any, 0, len(stations)) placeholders := make([]string, 0, len(stations)) for _, s := range stations { ids = append(ids, s.ID) placeholders = append(placeholders, "?") } antRows, err := r.db.QueryContext(ctx, `SELECT id, station_id, name, sort_order FROM operating_antennas WHERE station_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY station_id, sort_order, id`, ids...) if err != nil { return nil, fmt.Errorf("list antennas: %w", err) } // Collect antennas into a flat map keyed by ID first — taking pointers // into a slice we later append to is unsafe (a re-allocation // invalidates older pointers, leaving the band loop writing to dead // memory). We assemble the per-station slices at the very end, once // everything is collected. antennasByID := map[int64]*Antenna{} antennaIDsByStation := map[int64][]int64{} for antRows.Next() { a := &Antenna{} if err := antRows.Scan(&a.ID, &a.StationID, &a.Name, &a.SortOrder); err != nil { antRows.Close() return nil, err } antennasByID[a.ID] = a antennaIDsByStation[a.StationID] = append(antennaIDsByStation[a.StationID], a.ID) } antRows.Close() if len(antennasByID) > 0 { antIDs := make([]any, 0, len(antennasByID)) antPlaceholders := make([]string, 0, len(antennasByID)) for id := range antennasByID { antIDs = append(antIDs, id) antPlaceholders = append(antPlaceholders, "?") } bandRows, err := r.db.QueryContext(ctx, `SELECT antenna_id, band, is_default FROM operating_antenna_bands WHERE antenna_id IN (`+strings.Join(antPlaceholders, ",")+`) ORDER BY band`, antIDs...) if err != nil { return nil, fmt.Errorf("list bands: %w", err) } for bandRows.Next() { var antID int64 var band string var isDefault int if err := bandRows.Scan(&antID, &band, &isDefault); err != nil { bandRows.Close() return nil, err } if a, ok := antennasByID[antID]; ok { a.Bands = append(a.Bands, AntennaBand{Band: band, IsDefault: isDefault != 0}) } } bandRows.Close() } // Now assemble each station's Antennas slice. By the time we do this // every antenna already has its full band list attached, so no // downstream pointer is left behind. for sIdx := range stations { for _, antID := range antennaIDsByStation[stations[sIdx].ID] { if a, ok := antennasByID[antID]; ok { stations[sIdx].Antennas = append(stations[sIdx].Antennas, *a) } } } return stations, nil } // SaveStation upserts a station. Returns the (possibly new) ID. func (r *Repo) SaveStation(ctx context.Context, s *Station) error { if strings.TrimSpace(s.Name) == "" { return fmt.Errorf("station name required") } var pwr any if s.TXPower != nil { pwr = *s.TXPower } if s.ID == 0 { res, err := r.db.ExecContext(ctx, `INSERT INTO operating_stations(profile_id, name, tx_pwr, sort_order) VALUES(?, ?, ?, ?)`, s.ProfileID, s.Name, pwr, s.SortOrder) if err != nil { return fmt.Errorf("insert station: %w", err) } id, _ := res.LastInsertId() s.ID = id return nil } _, err := r.db.ExecContext(ctx, `UPDATE operating_stations SET name = ?, tx_pwr = ?, sort_order = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, s.Name, pwr, s.SortOrder, s.ID) if err != nil { return fmt.Errorf("update station: %w", err) } return nil } // DeleteStation cascades to antennas and bands via FK ON DELETE CASCADE. func (r *Repo) DeleteStation(ctx context.Context, id int64) error { _, err := r.db.ExecContext(ctx, `DELETE FROM operating_stations WHERE id = ?`, id) return err } // SaveAntenna upserts an antenna and replaces its band list in one // transaction. `is_default` is enforced per profile: setting it on one // antenna clears any other antenna's default for the same band. func (r *Repo) SaveAntenna(ctx context.Context, a *Antenna) error { if strings.TrimSpace(a.Name) == "" { return fmt.Errorf("antenna name required") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() if a.ID == 0 { res, err := tx.ExecContext(ctx, `INSERT INTO operating_antennas(station_id, name, sort_order) VALUES(?, ?, ?)`, a.StationID, a.Name, a.SortOrder) if err != nil { return fmt.Errorf("insert antenna: %w", err) } id, _ := res.LastInsertId() a.ID = id } else { if _, err := tx.ExecContext(ctx, `UPDATE operating_antennas SET name = ?, sort_order = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, a.Name, a.SortOrder, a.ID); err != nil { return fmt.Errorf("update antenna: %w", err) } } // Look up profile_id for this antenna's station — needed for the // "single default per band per profile" constraint. var profileID int64 if err := tx.QueryRowContext(ctx, `SELECT s.profile_id FROM operating_stations s WHERE s.id = ?`, a.StationID).Scan(&profileID); err != nil { return fmt.Errorf("lookup profile id: %w", err) } // Replace band list wholesale — simpler than diffing, fine for the // small N (a typical antenna covers a handful of bands). if _, err := tx.ExecContext(ctx, `DELETE FROM operating_antenna_bands WHERE antenna_id = ?`, a.ID); err != nil { return fmt.Errorf("clear bands: %w", err) } for _, b := range a.Bands { band := strings.TrimSpace(strings.ToLower(b.Band)) if band == "" { continue } def := 0 if b.IsDefault { def = 1 } // Insert this antenna's band entry, then if it's a default // clear other antennas' default for the same band within // the same profile. if _, err := tx.ExecContext(ctx, `INSERT INTO operating_antenna_bands(antenna_id, band, is_default) VALUES(?, ?, ?)`, a.ID, band, def); err != nil { return fmt.Errorf("insert band: %w", err) } if def == 1 { if _, err := tx.ExecContext(ctx, ` UPDATE operating_antenna_bands SET is_default = 0 WHERE band = ? AND antenna_id != ? AND antenna_id IN ( SELECT oa.id FROM operating_antennas oa JOIN operating_stations os ON oa.station_id = os.id WHERE os.profile_id = ? )`, band, a.ID, profileID); err != nil { return fmt.Errorf("clear other defaults: %w", err) } } } return tx.Commit() } // DeleteAntenna cascades to bands via FK ON DELETE CASCADE. func (r *Repo) DeleteAntenna(ctx context.Context, id int64) error { _, err := r.db.ExecContext(ctx, `DELETE FROM operating_antennas WHERE id = ?`, id) return err } // BandDefault returns the (station, antenna) flagged default for the given // band in the given profile. Empty result when nothing matches — callers // should leave MY_RIG/MY_ANTENNA blank in that case. func (r *Repo) BandDefault(ctx context.Context, profileID int64, band string) (BandDefault, bool, error) { band = strings.TrimSpace(strings.ToLower(band)) if band == "" { return BandDefault{}, false, nil } row := r.db.QueryRowContext(ctx, ` SELECT s.id, s.name, s.tx_pwr, a.id, a.name FROM operating_antenna_bands ab JOIN operating_antennas a ON ab.antenna_id = a.id JOIN operating_stations s ON a.station_id = s.id WHERE s.profile_id = ? AND ab.band = ? AND ab.is_default = 1 LIMIT 1`, profileID, band) var ( d BandDefault pwr sql.NullFloat64 ) if err := row.Scan(&d.StationID, &d.StationName, &pwr, &d.AntennaID, &d.AntennaName); err != nil { if err == sql.ErrNoRows { return BandDefault{}, false, nil } return BandDefault{}, false, err } if pwr.Valid { v := pwr.Float64 d.TXPower = &v } return d, true, nil }