// Package profile manages operator profiles: transmit callsign, locator, // DXCC, operator, antenna/CAT config, etc. Lets the user switch quickly // between several identities (home / portable / SOTA …). // // The active profile stamps every new QSO's MY_* fields (MY_GRIDSQUARE, // MY_SOTA_REF, MY_RIG…). Future versions will also attach per-profile // credentials (LoTW / Clublog / QRZ.com) so each callsign can export to // its own account. package profile import ( "context" "database/sql" "fmt" "time" ) // Profile is one operating configuration. A user typically keeps a few: // "Home", "Portable", "SOTA Pic du Midi", "/MM cruise"… type Profile struct { ID int64 `json:"id"` Name string `json:"name"` Callsign string `json:"callsign"` Operator string `json:"operator"` MyGrid string `json:"my_grid"` MyCountry string `json:"my_country"` MyState string `json:"my_state"` MyCounty string `json:"my_cnty"` MyStreet string `json:"my_street"` MyCity string `json:"my_city"` MyPostalCode string `json:"my_postal_code"` MySOTARef string `json:"my_sota_ref"` MyPOTARef string `json:"my_pota_ref"` MyRig string `json:"my_rig"` MyAntenna string `json:"my_antenna"` TxPower *float64 `json:"tx_pwr,omitempty"` IsActive bool `json:"is_active"` SortOrder int `json:"sort_order"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // Repo is a SQLite-backed profile store. All ops take a context so the // HTTP/Wails frontend can cancel them on tab close. type Repo struct{ db *sql.DB } func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} } const selectCols = `id, name, callsign, operator, my_grid, my_country, my_state, my_cnty, my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref, my_rig, my_antenna, tx_pwr, is_active, sort_order, created_at, updated_at` // List returns every profile, active first then by sort_order/id. func (r *Repo) List(ctx context.Context) ([]Profile, error) { rows, err := r.db.QueryContext(ctx, `SELECT `+selectCols+` FROM station_profiles ORDER BY is_active DESC, sort_order ASC, id ASC`) if err != nil { return nil, err } defer rows.Close() var out []Profile for rows.Next() { p, err := scan(rows) if err != nil { return nil, err } out = append(out, p) } return out, rows.Err() } // Get returns one profile by ID, or sql.ErrNoRows if missing. func (r *Repo) Get(ctx context.Context, id int64) (Profile, error) { row := r.db.QueryRowContext(ctx, `SELECT `+selectCols+` FROM station_profiles WHERE id = ?`, id) return scan(row) } // Active returns the currently-active profile, or sql.ErrNoRows if none. func (r *Repo) Active(ctx context.Context) (Profile, error) { row := r.db.QueryRowContext(ctx, `SELECT `+selectCols+` FROM station_profiles WHERE is_active = 1 LIMIT 1`) return scan(row) } // Save upserts a profile. p.ID == 0 means "create". Updates touch // updated_at; is_active is preserved separately via SetActive. func (r *Repo) Save(ctx context.Context, p *Profile) error { if p.Name == "" { return fmt.Errorf("profile name required") } now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") if p.ID == 0 { res, err := r.db.ExecContext(ctx, ` INSERT INTO station_profiles (name, callsign, operator, my_grid, my_country, my_state, my_cnty, my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref, my_rig, my_antenna, tx_pwr, is_active, sort_order, created_at, updated_at) VALUES(?,?,?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?,?,?)`, p.Name, p.Callsign, p.Operator, p.MyGrid, p.MyCountry, p.MyState, p.MyCounty, p.MyStreet, p.MyCity, p.MyPostalCode, p.MySOTARef, p.MyPOTARef, p.MyRig, p.MyAntenna, nullableFloat(p.TxPower), boolInt(p.IsActive), p.SortOrder, now, now) if err != nil { return err } id, _ := res.LastInsertId() p.ID = id return nil } _, err := r.db.ExecContext(ctx, ` UPDATE station_profiles SET name = ?, callsign = ?, operator = ?, my_grid = ?, my_country = ?, my_state = ?, my_cnty = ?, my_street = ?, my_city = ?, my_postal_code = ?, my_sota_ref = ?, my_pota_ref = ?, my_rig = ?, my_antenna = ?, tx_pwr = ?, sort_order = ?, updated_at = ? WHERE id = ?`, p.Name, p.Callsign, p.Operator, p.MyGrid, p.MyCountry, p.MyState, p.MyCounty, p.MyStreet, p.MyCity, p.MyPostalCode, p.MySOTARef, p.MyPOTARef, p.MyRig, p.MyAntenna, nullableFloat(p.TxPower), p.SortOrder, now, p.ID) return err } // SetActive atomically switches the active profile. Clears the flag on all // rows first to keep the "only one active" invariant from the schema doc. func (r *Repo) SetActive(ctx context.Context, id int64) error { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() if _, err := tx.ExecContext(ctx, `UPDATE station_profiles SET is_active = 0`); err != nil { return err } res, err := tx.ExecContext(ctx, `UPDATE station_profiles SET is_active = 1 WHERE id = ?`, id) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return sql.ErrNoRows } return tx.Commit() } // Delete removes a profile. Refuses to delete the last remaining profile // (we always want at least one so QSO stamping doesn't crash). If the // deleted one was active, the first remaining profile becomes active. func (r *Repo) Delete(ctx context.Context, id int64) error { var count int if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM station_profiles`).Scan(&count); err != nil { return err } if count <= 1 { return fmt.Errorf("cannot delete the last remaining profile") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() var wasActive int if err := tx.QueryRowContext(ctx, `SELECT is_active FROM station_profiles WHERE id = ?`, id).Scan(&wasActive); err != nil { return err } if _, err := tx.ExecContext(ctx, `DELETE FROM station_profiles WHERE id = ?`, id); err != nil { return err } if wasActive == 1 { // Promote the first remaining profile. var newID int64 if err := tx.QueryRowContext(ctx, `SELECT id FROM station_profiles ORDER BY sort_order ASC, id ASC LIMIT 1`).Scan(&newID); err != nil { return err } if _, err := tx.ExecContext(ctx, `UPDATE station_profiles SET is_active = 1 WHERE id = ?`, newID); err != nil { return err } } return tx.Commit() } // Duplicate clones a profile under a new name (caller supplies it). The // copy is created inactive — switching is an explicit user action. func (r *Repo) Duplicate(ctx context.Context, srcID int64, newName string) (Profile, error) { src, err := r.Get(ctx, srcID) if err != nil { return Profile{}, err } src.ID = 0 src.Name = newName src.IsActive = false src.SortOrder = 0 if err := r.Save(ctx, &src); err != nil { return Profile{}, err } return src, nil } // ----- helpers ----- type scannable interface { Scan(dest ...any) error } func scan(row scannable) (Profile, error) { var p Profile var ( callsign, operator, myGrid, myCountry, myState, myCnty, myStreet, myCity, myPostal, mySOTA, myPOTA, myRig, myAntenna sql.NullString txPwr sql.NullFloat64 isActive, sortOrder int createdAt, updatedAt string ) err := row.Scan(&p.ID, &p.Name, &callsign, &operator, &myGrid, &myCountry, &myState, &myCnty, &myStreet, &myCity, &myPostal, &mySOTA, &myPOTA, &myRig, &myAntenna, &txPwr, &isActive, &sortOrder, &createdAt, &updatedAt) if err != nil { return p, err } p.Callsign = callsign.String p.Operator = operator.String p.MyGrid = myGrid.String p.MyCountry = myCountry.String p.MyState = myState.String p.MyCounty = myCnty.String p.MyStreet = myStreet.String p.MyCity = myCity.String p.MyPostalCode = myPostal.String p.MySOTARef = mySOTA.String p.MyPOTARef = myPOTA.String p.MyRig = myRig.String p.MyAntenna = myAntenna.String if txPwr.Valid { v := txPwr.Float64 p.TxPower = &v } p.IsActive = isActive == 1 p.SortOrder = sortOrder p.CreatedAt, _ = time.Parse("2006-01-02T15:04:05.000Z", createdAt) p.UpdatedAt, _ = time.Parse("2006-01-02T15:04:05.000Z", updatedAt) return p, nil } func nullableFloat(p *float64) any { if p == nil { return nil } return *p } func boolInt(b bool) int { if b { return 1 } return 0 }