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.
351 lines
12 KiB
Go
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
|
|
}
|