Files

300 lines
9.5 KiB
Go

// 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"`
OwnerCallsign string `json:"owner_callsign"`
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"`
MyDXCC *int `json:"my_dxcc,omitempty"`
MyCQZone *int `json:"my_cqz,omitempty"`
MyITUZone *int `json:"my_ituz,omitempty"`
MyLat *float64 `json:"my_lat,omitempty"`
MyLon *float64 `json:"my_lon,omitempty"`
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, owner_callsign, 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, my_dxcc, my_cqz, my_ituz, my_lat, my_lon, 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, owner_callsign, 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, my_dxcc, my_cqz, my_ituz, my_lat, my_lon, tx_pwr,
is_active, sort_order, created_at, updated_at)
VALUES(?,?,?,?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?,?,?,?, ?,?,?,?)`,
p.Name, p.Callsign, p.Operator, p.OwnerCallsign, p.MyGrid, p.MyCountry, p.MyState, p.MyCounty,
p.MyStreet, p.MyCity, p.MyPostalCode, p.MySOTARef, p.MyPOTARef,
p.MyRig, p.MyAntenna, nullableInt(p.MyDXCC), nullableInt(p.MyCQZone), nullableInt(p.MyITUZone),
nullableFloat(p.MyLat), nullableFloat(p.MyLon), 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 = ?, owner_callsign = ?, 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 = ?,
my_dxcc = ?, my_cqz = ?, my_ituz = ?, my_lat = ?, my_lon = ?, tx_pwr = ?,
sort_order = ?, updated_at = ?
WHERE id = ?`,
p.Name, p.Callsign, p.Operator, p.OwnerCallsign, p.MyGrid, p.MyCountry,
p.MyState, p.MyCounty, p.MyStreet, p.MyCity, p.MyPostalCode,
p.MySOTARef, p.MyPOTARef, p.MyRig, p.MyAntenna,
nullableInt(p.MyDXCC), nullableInt(p.MyCQZone), nullableInt(p.MyITUZone),
nullableFloat(p.MyLat), nullableFloat(p.MyLon), 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, ownerCall, myGrid, myCountry, myState, myCnty,
myStreet, myCity, myPostal, mySOTA, myPOTA,
myRig, myAntenna sql.NullString
myDXCC, myCQZ, myITUZ sql.NullInt64
myLat, myLon, txPwr sql.NullFloat64
isActive, sortOrder int
createdAt, updatedAt string
)
err := row.Scan(&p.ID, &p.Name, &callsign, &operator, &ownerCall, &myGrid, &myCountry, &myState, &myCnty,
&myStreet, &myCity, &myPostal, &mySOTA, &myPOTA,
&myRig, &myAntenna, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon, &txPwr,
&isActive, &sortOrder, &createdAt, &updatedAt)
if err != nil {
return p, err
}
p.Callsign = callsign.String
p.Operator = operator.String
p.OwnerCallsign = ownerCall.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 myDXCC.Valid {
v := int(myDXCC.Int64)
p.MyDXCC = &v
}
if myCQZ.Valid {
v := int(myCQZ.Int64)
p.MyCQZone = &v
}
if myITUZ.Valid {
v := int(myITUZ.Int64)
p.MyITUZone = &v
}
if myLat.Valid {
v := myLat.Float64
p.MyLat = &v
}
if myLon.Valid {
v := myLon.Float64
p.MyLon = &v
}
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 nullableInt(p *int) any {
if p == nil {
return nil
}
return *p
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}