7ace2cc602
Backend (Go 1.25 / Wails v2): - QSO storage on SQLite (modernc) with embedded migrations (0001..0005) - Streaming ADIF import (batch insert) + WorkedBefore per callsign and DXCC - Callsign lookup with QRZ.com + HamQTH providers (primary/failsafe routing) and SQLite-backed TTL cache - DXCC resolver from cty.dat (auto-download, longest-prefix-match) - Multi-profile operator identities (home/portable/SOTA/contest) — every QSO stamps MY_* from the active profile - CAT control via OmniRig COM on a single OS-locked goroutine, with bidirectional sync (freq/mode/band/split/VFOs) and Rig1/Rig2 hot-swap - Settings store (key/value), CAT debug log at %APPDATA%/HamLog/cat.log Frontend (React 18 + TypeScript + Tailwind v4 + shadcn-style): - Single-row entry strip with CAT-aware band/mode/freq, RST, Start/End UTC, per-field locks (band/mode/freq/start/end) for backdated QSOs - Topbar: live freq (MHz.kHz.Hz dotted), live UTC, band/mode/SPLIT badges, CAT pill with rig selector and clickable Azimuth pill (rotor TODO) - Settings tree: Profiles (Log4OM-style manager), Station Information (edits the active profile), unified Callsign Lookup with Test buttons, Bands/Modes lists, CAT - Worked-before matrix (band × mode × class) with new-DXCC highlighting - ADIF import from menu + Maintenance > Refresh cty.dat Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
258 lines
8.2 KiB
Go
258 lines
8.2 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"`
|
|
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
|
|
}
|