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>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
// 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 …).
|
||||
// TODO: implementation.
|
||||
package profile
|
||||
@@ -0,0 +1,257 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// SettingsReader is the minimal slice of the settings store we need for
|
||||
// migrating legacy single-station settings into the first profile.
|
||||
type SettingsReader interface {
|
||||
GetMany(ctx context.Context, keys ...string) (map[string]string, error)
|
||||
}
|
||||
|
||||
// EnsureDefault makes sure at least one profile exists and one is active.
|
||||
//
|
||||
// First-run path: if the table is empty, build a "Default" profile from
|
||||
// the legacy `station.*` settings keys and mark it active — so the user's
|
||||
// existing config carries over invisibly. Returns the active profile.
|
||||
//
|
||||
// The legacy key names are passed in from the caller (app.go) so this
|
||||
// package doesn't need to import or duplicate them.
|
||||
func EnsureDefault(ctx context.Context, db *sql.DB, settings SettingsReader, legacyKeys LegacyStationKeys) (Profile, error) {
|
||||
repo := NewRepo(db)
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM station_profiles`).Scan(&count); err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
if count == 0 {
|
||||
seed, err := buildSeedFromSettings(ctx, settings, legacyKeys)
|
||||
if err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
seed.IsActive = true
|
||||
if err := repo.Save(ctx, &seed); err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
return seed, nil
|
||||
}
|
||||
// Profiles exist but none active (manual DB edit, deleted active row
|
||||
// outside the app, …) — promote the first one.
|
||||
if active, err := repo.Active(ctx); err == nil {
|
||||
return active, nil
|
||||
}
|
||||
var firstID int64
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT id FROM station_profiles ORDER BY sort_order ASC, id ASC LIMIT 1`).Scan(&firstID); err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
if err := repo.SetActive(ctx, firstID); err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
return repo.Get(ctx, firstID)
|
||||
}
|
||||
|
||||
// LegacyStationKeys names the pre-profiles settings keys used to seed
|
||||
// the first profile. Kept as a struct so adding/renaming a key doesn't
|
||||
// silently fall through to an empty default.
|
||||
type LegacyStationKeys struct {
|
||||
Callsign string
|
||||
Operator string
|
||||
MyGrid string
|
||||
Country string
|
||||
SOTA string
|
||||
POTA string
|
||||
}
|
||||
|
||||
func buildSeedFromSettings(ctx context.Context, settings SettingsReader, keys LegacyStationKeys) (Profile, error) {
|
||||
m, err := settings.GetMany(ctx,
|
||||
keys.Callsign, keys.Operator, keys.MyGrid, keys.Country, keys.SOTA, keys.POTA)
|
||||
if err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
return Profile{
|
||||
Name: "Default",
|
||||
Callsign: m[keys.Callsign],
|
||||
Operator: m[keys.Operator],
|
||||
MyGrid: m[keys.MyGrid],
|
||||
MyCountry: m[keys.Country],
|
||||
MySOTARef: m[keys.SOTA],
|
||||
MyPOTARef: m[keys.POTA],
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user