Files
OpsLog/internal/profile/profile.go
T
rouggy ae7472a67d feat(station): MY_IOTA in the station profile
IOTA is an official ADIF field, and almost everything for it was already here:
the qso table has carried my_iota since the first migration, the insert and scan
handle it, and both ADIF import and export write it. The one missing link was
the station profile, so the reference had to be typed on every contact of an
island activation, or added afterwards in a bulk edit.

Migration 0025 adds the column; it joins my_sota_ref and my_pota_ref in the
profile, in the Station Information panel, and in the same stamp-if-empty block
that fills the other My* fields on a logged QSO.

Uppercased and trimmed on save. ADIF spells it EU-005, and an operator typing
"eu-005" would otherwise put a reference no award matcher recognises on every
QSO of an activation - the kind of mistake you only find months later.
2026-08-10 19:28:07 +02:00

351 lines
12 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"
"encoding/json"
"fmt"
"time"
)
// ProfileDB is a profile's logbook database target. Backend "" or "sqlite" =
// the local SQLite file; "mysql" = the shared MySQL server described by the
// rest. Switching the active profile switches the live logbook to this.
type ProfileDB struct {
Backend string `json:"backend"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
Database string `json:"database"`
// Path is a per-profile SQLite logbook FILE, distinct from the shared app
// database (opslog.db, which always holds settings + profiles). Empty =
// the shared database is used as the logbook (the historical default). Set =
// this profile's QSOs live in their own .db, so a visiting operator's contacts
// don't mix into yours and switching it never touches your config. Only used
// when Backend != "mysql".
Path string `json:"path,omitempty"`
}
// 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"`
OpName string `json:"op_name"` // operator's personal name (e.g. "Greg")
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"`
MyIOTA string `json:"my_iota"`
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"`
DB ProfileDB `json:"db"` // per-profile logbook target (empty backend = local SQLite)
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, op_name, owner_callsign, my_grid, my_country, my_state, my_cnty,
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref, my_iota,
my_rig, my_antenna, my_dxcc, my_cqz, my_ituz, my_lat, my_lon, tx_pwr,
is_active, sort_order, db_config, 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, op_name, owner_callsign, my_grid, my_country, my_state, my_cnty,
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref, my_iota,
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.OpName, p.OwnerCallsign, p.MyGrid, p.MyCountry, p.MyState, p.MyCounty,
p.MyStreet, p.MyCity, p.MyPostalCode, p.MySOTARef, p.MyPOTARef, p.MyIOTA,
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 = ?, op_name = ?, owner_callsign = ?, my_grid = ?, my_country = ?,
my_state = ?, my_cnty = ?, my_street = ?, my_city = ?, my_postal_code = ?,
my_sota_ref = ?, my_pota_ref = ?, my_iota = ?, 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.OpName, p.OwnerCallsign, p.MyGrid, p.MyCountry,
p.MyState, p.MyCounty, p.MyStreet, p.MyCity, p.MyPostalCode,
p.MySOTARef, p.MyPOTARef, p.MyIOTA, 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
}
// SetDB updates only a profile's logbook database target, leaving the rest of
// the profile untouched (the general Save deliberately doesn't write db_config).
func (r *Repo) SetDB(ctx context.Context, id int64, cfg ProfileDB) error {
b, err := json.Marshal(cfg)
if err != nil {
return err
}
now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
_, err = r.db.ExecContext(ctx,
`UPDATE station_profiles SET db_config = ?, updated_at = ? WHERE id = ?`,
string(b), now, 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
}
dbCfg := src.DB // Save deliberately doesn't write db_config — copy it after.
src.ID = 0
src.Name = newName
src.IsActive = false
src.SortOrder = 0
if err := r.Save(ctx, &src); err != nil {
return Profile{}, err
}
if dbCfg.Backend != "" {
if err := r.SetDB(ctx, src.ID, dbCfg); err != nil {
return Profile{}, err
}
src.DB = dbCfg
}
return src, nil
}
// ----- helpers -----
type scannable interface {
Scan(dest ...any) error
}
func scan(row scannable) (Profile, error) {
var p Profile
var (
callsign, operator, opName, ownerCall, myGrid, myCountry, myState, myCnty,
myStreet, myCity, myPostal, mySOTA, myPOTA, myIOTA,
myRig, myAntenna sql.NullString
myDXCC, myCQZ, myITUZ sql.NullInt64
myLat, myLon, txPwr sql.NullFloat64
isActive, sortOrder int
dbConfig sql.NullString
createdAt, updatedAt string
)
err := row.Scan(&p.ID, &p.Name, &callsign, &operator, &opName, &ownerCall, &myGrid, &myCountry, &myState, &myCnty,
&myStreet, &myCity, &myPostal, &mySOTA, &myPOTA, &myIOTA,
&myRig, &myAntenna, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon, &txPwr,
&isActive, &sortOrder, &dbConfig, &createdAt, &updatedAt)
if err != nil {
return p, err
}
if s := dbConfig.String; s != "" {
_ = json.Unmarshal([]byte(s), &p.DB)
}
p.Callsign = callsign.String
p.Operator = operator.String
p.OpName = opName.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.MyIOTA = myIOTA.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
}