Files
OpsLog/internal/profile/profile.go
T
rouggy 7ace2cc602 Initial codebase: Go + Wails amateur radio logbook
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>
2026-05-26 00:16:45 +02:00

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
}