rigs completed
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -13,9 +14,11 @@ import (
|
||||
"time"
|
||||
|
||||
"hamlog/internal/adif"
|
||||
"hamlog/internal/backup"
|
||||
"hamlog/internal/cat"
|
||||
"hamlog/internal/cluster"
|
||||
"hamlog/internal/db"
|
||||
"hamlog/internal/operating"
|
||||
"hamlog/internal/dxcc"
|
||||
"hamlog/internal/lookup"
|
||||
"hamlog/internal/profile"
|
||||
@@ -63,6 +66,12 @@ const (
|
||||
keyRotatorHasElevation = "rotator.has_elevation"
|
||||
|
||||
keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start
|
||||
|
||||
keyBackupEnabled = "backup.enabled"
|
||||
keyBackupFolder = "backup.folder"
|
||||
keyBackupRotation = "backup.rotation"
|
||||
keyBackupZip = "backup.zip"
|
||||
keyBackupLast = "backup.last_at"
|
||||
)
|
||||
|
||||
// CATSettings is the user-tweakable rig-control configuration. Stored as
|
||||
@@ -145,8 +154,104 @@ type App struct {
|
||||
cat *cat.Manager
|
||||
dxcc *dxcc.Manager
|
||||
cluster *cluster.Manager
|
||||
operating *operating.Repo
|
||||
startupErr string // captured for surfacing to the frontend
|
||||
dbPath string
|
||||
|
||||
// shuttingDown gates beforeClose re-entry: the first user attempt to
|
||||
// close fires shutdown tasks (backup, future LoTW upload, ...) while
|
||||
// blocking the window close; the subsequent programmatic Quit() call
|
||||
// must be allowed through.
|
||||
shuttingDown bool
|
||||
|
||||
// Cached operator location used to compute distance/bearing for
|
||||
// cluster spots. Refreshed on profile activation; zero means
|
||||
// "unknown" and we skip the per-spot computation.
|
||||
opLat float64
|
||||
opLon float64
|
||||
opSet bool
|
||||
}
|
||||
|
||||
// gridToLatLon parses a Maidenhead locator (4 or 6 chars) and returns the
|
||||
// centre lat/lon in degrees. Returns ok=false on malformed input.
|
||||
func gridToLatLon(grid string) (lat, lon float64, ok bool) {
|
||||
g := strings.ToUpper(strings.TrimSpace(grid))
|
||||
if len(g) < 4 {
|
||||
return 0, 0, false
|
||||
}
|
||||
A := g[0] - 'A'
|
||||
B := g[1] - 'A'
|
||||
C := g[2] - '0'
|
||||
D := g[3] - '0'
|
||||
if A > 17 || B > 17 || C > 9 || D > 9 {
|
||||
return 0, 0, false
|
||||
}
|
||||
lon = -180 + float64(A)*20 + float64(C)*2
|
||||
lat = -90 + float64(B)*10 + float64(D)*1
|
||||
if len(g) >= 6 {
|
||||
E := g[4] - 'A'
|
||||
F := g[5] - 'A'
|
||||
if E <= 23 && F <= 23 {
|
||||
lon += float64(E)*(5.0/60.0) + 2.5/60.0
|
||||
lat += float64(F)*(2.5/60.0) + 1.25/60.0
|
||||
return lat, lon, true
|
||||
}
|
||||
}
|
||||
// 4-char locator: aim at the centre of the square.
|
||||
lon += 1
|
||||
lat += 0.5
|
||||
return lat, lon, true
|
||||
}
|
||||
|
||||
// haversineKm returns the great-circle distance between two lat/lon pairs
|
||||
// in kilometres. Standard Haversine, mean Earth radius 6371 km.
|
||||
func haversineKm(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
const R = 6371.0
|
||||
rad := math.Pi / 180.0
|
||||
dLat := (lat2 - lat1) * rad
|
||||
dLon := (lon2 - lon1) * rad
|
||||
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||||
math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||
return R * c
|
||||
}
|
||||
|
||||
// initialBearingDeg returns the initial great-circle bearing (azimuth) in
|
||||
// degrees [0, 360) from (lat1, lon1) towards (lat2, lon2). This is the
|
||||
// "short path" heading.
|
||||
func initialBearingDeg(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
rad := math.Pi / 180.0
|
||||
dLon := (lon2 - lon1) * rad
|
||||
y := math.Sin(dLon) * math.Cos(lat2*rad)
|
||||
x := math.Cos(lat1*rad)*math.Sin(lat2*rad) -
|
||||
math.Sin(lat1*rad)*math.Cos(lat2*rad)*math.Cos(dLon)
|
||||
deg := math.Atan2(y, x) / rad
|
||||
if deg < 0 {
|
||||
deg += 360
|
||||
}
|
||||
return deg
|
||||
}
|
||||
|
||||
// refreshOperatorGrid reloads the active profile and caches its grid as
|
||||
// lat/lon. Called at startup and after profile activation so the cluster
|
||||
// onSpot callback can compute distance/bearing without hitting the DB
|
||||
// per spot.
|
||||
func (a *App) refreshOperatorGrid() {
|
||||
a.opSet = false
|
||||
if a.profiles == nil || a.ctx == nil {
|
||||
return
|
||||
}
|
||||
p, err := a.profiles.Active(a.ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
lat, lon, ok := gridToLatLon(p.MyGrid)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a.opLat = lat
|
||||
a.opLon = lon
|
||||
a.opSet = true
|
||||
}
|
||||
|
||||
// dxccAdapter bridges *dxcc.Manager to the lookup.DXCCResolver interface
|
||||
@@ -191,6 +296,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
a.qso = qso.NewRepo(conn)
|
||||
a.settings = settings.NewStore(conn)
|
||||
a.profiles = profile.NewRepo(conn)
|
||||
a.operating = operating.NewRepo(conn)
|
||||
// On first run, copy the legacy single-station settings into a
|
||||
// "Default" profile so the user's existing config carries over without
|
||||
// any manual step. Subsequent runs just confirm an active profile.
|
||||
@@ -238,6 +344,14 @@ func (a *App) startup(ctx context.Context) {
|
||||
if m, ok := a.dxcc.Lookup(s.DXCall); ok && m.Entity != nil {
|
||||
s.Country = m.Entity.Name
|
||||
s.Continent = m.Continent
|
||||
s.CQZone = m.CQZone
|
||||
s.ITUZone = m.ITUZone
|
||||
if a.opSet && (m.Lat != 0 || m.Lon != 0) {
|
||||
s.DistanceKm = int(haversineKm(a.opLat, a.opLon, m.Lat, m.Lon) + 0.5)
|
||||
sp := initialBearingDeg(a.opLat, a.opLon, m.Lat, m.Lon)
|
||||
s.ShortPath = int(sp + 0.5)
|
||||
s.LongPath = (s.ShortPath + 180) % 360
|
||||
}
|
||||
}
|
||||
}
|
||||
if a.ctx != nil {
|
||||
@@ -250,6 +364,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
}
|
||||
},
|
||||
)
|
||||
a.refreshOperatorGrid()
|
||||
if cs, _ := a.clusterAutoConnect(); cs {
|
||||
a.startAllEnabledClusters()
|
||||
}
|
||||
@@ -275,7 +390,118 @@ func (a *App) GetStartupStatus() StartupStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// beforeClose intercepts the window-close event so we can run shutdown
|
||||
// tasks (backup, future LoTW upload, ...) while showing a progress modal
|
||||
// to the user. Returns true the first time to block the close; the
|
||||
// goroutine eventually calls wruntime.Quit() which re-enters this method
|
||||
// with shuttingDown=true and we let the close proceed.
|
||||
func (a *App) beforeClose(ctx context.Context) bool {
|
||||
if a.shuttingDown {
|
||||
return false
|
||||
}
|
||||
a.shuttingDown = true
|
||||
|
||||
steps := a.plannedShutdownSteps()
|
||||
if len(steps) == 0 {
|
||||
// Nothing to do — exit immediately, no need to flash a modal.
|
||||
return false
|
||||
}
|
||||
go a.runShutdownTasks(ctx, steps)
|
||||
return true
|
||||
}
|
||||
|
||||
// shutdownStep is emitted to the frontend so the progress modal can
|
||||
// render the task list and update each row's state as work progresses.
|
||||
type shutdownStep struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Status string `json:"status"` // "pending" | "running" | "done" | "error"
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// plannedShutdownSteps returns the tasks that will actually run, so the
|
||||
// UI knows the full checklist up front. Right now that's just the backup
|
||||
// (when enabled and not yet done today); LoTW upload, eQSL upload, etc.
|
||||
// will append to this list as they land.
|
||||
func (a *App) plannedShutdownSteps() []shutdownStep {
|
||||
var out []shutdownStep
|
||||
if s, err := a.GetBackupSettings(); err == nil && s.Enabled {
|
||||
folder := s.Folder
|
||||
if folder == "" {
|
||||
folder = s.DefaultFolder
|
||||
}
|
||||
if !backup.HasBackupToday(folder) {
|
||||
out = append(out, shutdownStep{ID: "backup", Label: "Backing up database", Status: "pending"})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) emitShutdownEvent(name string, payload any) {
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, name, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// runShutdownTasks executes every planned shutdown task in order,
|
||||
// emitting progress events at each transition so the frontend modal
|
||||
// stays in sync. Errors don't abort the sequence — we still want to
|
||||
// give later steps a chance and ultimately close the app.
|
||||
func (a *App) runShutdownTasks(ctx context.Context, steps []shutdownStep) {
|
||||
a.emitShutdownEvent("shutdown:start", steps)
|
||||
for i := range steps {
|
||||
steps[i].Status = "running"
|
||||
a.emitShutdownEvent("shutdown:update", steps)
|
||||
var err error
|
||||
switch steps[i].ID {
|
||||
case "backup":
|
||||
err = a.runBackupForShutdown()
|
||||
}
|
||||
if err != nil {
|
||||
steps[i].Status = "error"
|
||||
steps[i].Detail = err.Error()
|
||||
} else {
|
||||
steps[i].Status = "done"
|
||||
}
|
||||
a.emitShutdownEvent("shutdown:update", steps)
|
||||
}
|
||||
a.emitShutdownEvent("shutdown:done", steps)
|
||||
// Give the UI a moment to show the "done" state before we yank the
|
||||
// window away. 600ms feels purposeful without being annoying.
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
wruntime.Quit(ctx)
|
||||
}
|
||||
|
||||
// runBackupForShutdown is the same logic as maybeShutdownBackup but
|
||||
// returns an error so the shutdown sequence can mark the step as failed.
|
||||
func (a *App) runBackupForShutdown() error {
|
||||
if a.settings == nil || a.db == nil {
|
||||
return fmt.Errorf("db not ready")
|
||||
}
|
||||
s, err := a.GetBackupSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
folder := s.Folder
|
||||
if folder == "" {
|
||||
folder = s.DefaultFolder
|
||||
}
|
||||
if backup.HasBackupToday(folder) {
|
||||
return nil
|
||||
}
|
||||
if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
func (a *App) shutdown(ctx context.Context) {
|
||||
// If the user managed to skip beforeClose (force kill, OS shutdown,
|
||||
// crash recovery) we still try the backup here as a best-effort
|
||||
// safety net. HasBackupToday makes a double-run a no-op.
|
||||
if !a.shuttingDown {
|
||||
a.maybeShutdownBackup()
|
||||
}
|
||||
if a.db != nil {
|
||||
_ = a.db.Close()
|
||||
}
|
||||
@@ -763,6 +989,238 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Operating conditions ───────────────────────────────────────────────
|
||||
|
||||
// ListOperatingTree returns the stations/antennas/bands tree for the
|
||||
// active profile. The UI renders the Settings tree from this.
|
||||
func (a *App) ListOperatingTree() ([]operating.Station, error) {
|
||||
if a.operating == nil || a.profiles == nil {
|
||||
return nil, fmt.Errorf("db not initialized")
|
||||
}
|
||||
p, err := a.profiles.Active(a.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.operating.ListTree(a.ctx, p.ID)
|
||||
}
|
||||
|
||||
// SaveOperatingStation upserts a station. profile_id is set from the
|
||||
// active profile if zero so the frontend doesn't have to know about it.
|
||||
func (a *App) SaveOperatingStation(s operating.Station) (operating.Station, error) {
|
||||
if a.operating == nil || a.profiles == nil {
|
||||
return s, fmt.Errorf("db not initialized")
|
||||
}
|
||||
if s.ProfileID == 0 {
|
||||
p, err := a.profiles.Active(a.ctx)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.ProfileID = p.ID
|
||||
}
|
||||
if err := a.operating.SaveStation(a.ctx, &s); err != nil {
|
||||
return s, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// DeleteOperatingStation cascades to antennas + bands.
|
||||
func (a *App) DeleteOperatingStation(id int64) error {
|
||||
if a.operating == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.operating.DeleteStation(a.ctx, id)
|
||||
}
|
||||
|
||||
// SaveOperatingAntenna upserts an antenna and replaces its band list.
|
||||
// Setting is_default on a band clears the flag from any other antenna
|
||||
// on the same band within this profile.
|
||||
func (a *App) SaveOperatingAntenna(ant operating.Antenna) (operating.Antenna, error) {
|
||||
if a.operating == nil {
|
||||
return ant, fmt.Errorf("db not initialized")
|
||||
}
|
||||
if err := a.operating.SaveAntenna(a.ctx, &ant); err != nil {
|
||||
return ant, err
|
||||
}
|
||||
return ant, nil
|
||||
}
|
||||
|
||||
// DeleteOperatingAntenna cascades to bands.
|
||||
func (a *App) DeleteOperatingAntenna(id int64) error {
|
||||
if a.operating == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.operating.DeleteAntenna(a.ctx, id)
|
||||
}
|
||||
|
||||
// OperatingDefaultForBand returns the (station, antenna) flagged default
|
||||
// for `band` in the active profile. Used by the entry strip to auto-fill
|
||||
// MY_RIG and MY_ANTENNA when the user picks a band.
|
||||
func (a *App) OperatingDefaultForBand(band string) (operating.BandDefault, error) {
|
||||
if a.operating == nil || a.profiles == nil {
|
||||
return operating.BandDefault{}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
p, err := a.profiles.Active(a.ctx)
|
||||
if err != nil {
|
||||
return operating.BandDefault{}, err
|
||||
}
|
||||
d, _, err := a.operating.BandDefault(a.ctx, p.ID, band)
|
||||
return d, err
|
||||
}
|
||||
|
||||
// ── Backup ──────────────────────────────────────────────────────────────
|
||||
|
||||
// BackupSettings is the user-tweakable database backup configuration.
|
||||
type BackupSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Folder string `json:"folder"`
|
||||
Rotation int `json:"rotation"`
|
||||
Zip bool `json:"zip"`
|
||||
LastBackupAt string `json:"last_backup_at"`
|
||||
DefaultFolder string `json:"default_folder"` // computed, read-only — shown as a hint
|
||||
}
|
||||
|
||||
// GetBackupSettings returns stored backup config with safe defaults.
|
||||
func (a *App) GetBackupSettings() (BackupSettings, error) {
|
||||
out := BackupSettings{
|
||||
Rotation: 5,
|
||||
DefaultFolder: backup.DefaultFolder(filepath.Dir(a.dbPath)),
|
||||
}
|
||||
if a.settings == nil {
|
||||
return out, nil
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx,
|
||||
keyBackupEnabled, keyBackupFolder, keyBackupRotation, keyBackupZip, keyBackupLast)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Enabled = m[keyBackupEnabled] == "1"
|
||||
out.Folder = m[keyBackupFolder]
|
||||
if n, _ := strconv.Atoi(m[keyBackupRotation]); n > 0 {
|
||||
out.Rotation = n
|
||||
}
|
||||
out.Zip = m[keyBackupZip] == "1"
|
||||
out.LastBackupAt = m[keyBackupLast]
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveBackupSettings persists backup config (no immediate backup —
|
||||
// trigger it explicitly with RunBackupNow).
|
||||
func (a *App) SaveBackupSettings(s BackupSettings) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if s.Rotation <= 0 {
|
||||
s.Rotation = 5
|
||||
}
|
||||
enabled := "0"
|
||||
if s.Enabled {
|
||||
enabled = "1"
|
||||
}
|
||||
doZip := "0"
|
||||
if s.Zip {
|
||||
doZip = "1"
|
||||
}
|
||||
for k, v := range map[string]string{
|
||||
keyBackupEnabled: enabled,
|
||||
keyBackupFolder: strings.TrimSpace(s.Folder),
|
||||
keyBackupRotation: strconv.Itoa(s.Rotation),
|
||||
keyBackupZip: doZip,
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunBackupNow forces an immediate backup using the persisted settings.
|
||||
// Returns the destination path of the file that was written.
|
||||
func (a *App) RunBackupNow() (string, error) {
|
||||
s, err := a.GetBackupSettings()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
folder := s.Folder
|
||||
if folder == "" {
|
||||
folder = s.DefaultFolder
|
||||
}
|
||||
path, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip)
|
||||
if err != nil {
|
||||
return path, err
|
||||
}
|
||||
_ = a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339))
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// maybeShutdownBackup runs a backup at shutdown if the user enabled it
|
||||
// and no backup for today already exists. Running at shutdown (not at
|
||||
// startup) means the snapshot includes the QSOs the user just logged
|
||||
// this session — exactly what we want to protect. Errors are printed
|
||||
// but never block the close.
|
||||
func (a *App) maybeShutdownBackup() {
|
||||
if a.settings == nil || a.db == nil {
|
||||
return
|
||||
}
|
||||
s, err := a.GetBackupSettings()
|
||||
if err != nil || !s.Enabled {
|
||||
return
|
||||
}
|
||||
folder := s.Folder
|
||||
if folder == "" {
|
||||
folder = s.DefaultFolder
|
||||
}
|
||||
if backup.HasBackupToday(folder) {
|
||||
return
|
||||
}
|
||||
if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil {
|
||||
fmt.Println("HamLog: shutdown backup failed:", err)
|
||||
return
|
||||
}
|
||||
_ = a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// PickBackupFolder opens a native directory picker so the user can browse
|
||||
// to a backup target rather than typing the path. Returns the absolute
|
||||
// path (or empty string if the dialog was cancelled).
|
||||
//
|
||||
// Windows' shell dialog refuses to open when DefaultDirectory points at
|
||||
// a path that doesn't exist yet (typical for our default backups folder
|
||||
// on first launch). We walk up the path until we find an existing
|
||||
// ancestor and use that as the dialog's starting point.
|
||||
func (a *App) PickBackupFolder() (string, error) {
|
||||
if a.ctx == nil {
|
||||
return "", fmt.Errorf("no app context")
|
||||
}
|
||||
current, _ := a.GetBackupSettings()
|
||||
defaultDir := current.Folder
|
||||
if defaultDir == "" {
|
||||
defaultDir = current.DefaultFolder
|
||||
}
|
||||
defaultDir = firstExistingAncestor(defaultDir)
|
||||
return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||
Title: "Pick a folder for HamLog backups",
|
||||
DefaultDirectory: defaultDir,
|
||||
})
|
||||
}
|
||||
|
||||
// firstExistingAncestor returns p if it exists, otherwise the closest
|
||||
// parent directory that does. Returns "" if nothing valid is found (the
|
||||
// dialog then opens at the OS default location).
|
||||
func firstExistingAncestor(p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
for p != "" {
|
||||
if st, err := os.Stat(p); err == nil && st.IsDir() {
|
||||
return p
|
||||
}
|
||||
parent := filepath.Dir(p)
|
||||
if parent == p {
|
||||
break
|
||||
}
|
||||
p = parent
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetCATState returns the current snapshot from the CAT manager. Used by the
|
||||
// frontend on mount before any cat:state event has been emitted.
|
||||
func (a *App) GetCATState() cat.RigState {
|
||||
@@ -998,6 +1456,7 @@ func (a *App) SaveProfile(p profile.Profile) (profile.Profile, error) {
|
||||
if err := a.profiles.Save(a.ctx, &p); err != nil {
|
||||
return profile.Profile{}, err
|
||||
}
|
||||
a.refreshOperatorGrid()
|
||||
return p, nil
|
||||
}
|
||||
|
||||
@@ -1016,7 +1475,11 @@ func (a *App) ActivateProfile(id int64) error {
|
||||
if a.profiles == nil {
|
||||
return fmt.Errorf("profiles not initialized")
|
||||
}
|
||||
return a.profiles.SetActive(a.ctx, id)
|
||||
if err := a.profiles.SetActive(a.ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
a.refreshOperatorGrid()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DuplicateProfile clones an existing profile under newName. Useful when
|
||||
@@ -1396,6 +1859,10 @@ type SpotStatus struct {
|
||||
Country string `json:"country,omitempty"`
|
||||
Continent string `json:"continent,omitempty"`
|
||||
Status string `json:"status"`
|
||||
// WorkedCall is true when this exact callsign exists in the log
|
||||
// (any band, any mode). Drives the per-call text highlight, in
|
||||
// addition to the entity-level Status (NEW / NEW BAND / …).
|
||||
WorkedCall bool `json:"worked_call"`
|
||||
}
|
||||
|
||||
// ClusterSpotStatuses takes a batch of spots and returns slot status for
|
||||
@@ -1428,12 +1895,19 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
// Per-call worked set — separate from the entity check so we can flag
|
||||
// "I've already QSO'd this exact station" even when the band/mode
|
||||
// makes the entity check say "new-band" or "new-slot".
|
||||
workedCalls, _ := a.qso.WorkedCallsigns(a.ctx)
|
||||
for i, q := range spots {
|
||||
out[i] = SpotStatus{
|
||||
Call: q.Call,
|
||||
Band: strings.ToLower(q.Band),
|
||||
Mode: strings.ToUpper(q.Mode),
|
||||
}
|
||||
if _, ok := workedCalls[strings.ToUpper(q.Call)]; ok {
|
||||
out[i].WorkedCall = true
|
||||
}
|
||||
if a.dxcc == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
Generated
+57
@@ -19,6 +19,8 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"ag-grid-community": "^35.3.0",
|
||||
"ag-grid-react": "^35.3.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
@@ -2641,6 +2643,35 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ag-charts-types": {
|
||||
"version": "13.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-13.3.0.tgz",
|
||||
"integrity": "sha512-UMoAn908LC4ZIJSNfUckSBEFa79Mi1vFRA8qIRx+NusEuuFgXDioCZx4MxM7O3rDXlxTWH9DvQmcDjh7vyd89w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ag-grid-community": {
|
||||
"version": "35.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-35.3.0.tgz",
|
||||
"integrity": "sha512-c9WQWB88J965IjBC/GPUX30aAZix10o6oYT86DWipcxgLZTIQlLSilJJEr1bno/245rPEAIMjhoU1gp9VIfURg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ag-charts-types": "13.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ag-grid-react": {
|
||||
"version": "35.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-35.3.0.tgz",
|
||||
"integrity": "sha512-3c6YEFGQGNZxEi1PdK0b+WhKkKRJ7KxuYzsG4UmISyax5/J7N93f8B1TZK1pq+AgzPhdk/++vjZe3KhFdF3tog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ag-grid-community": "35.3.0",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||
@@ -3302,6 +3333,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -3351,6 +3391,17 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
@@ -3376,6 +3427,12 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"ag-grid-community": "^35.3.0",
|
||||
"ag-grid-react": "^35.3.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
|
||||
@@ -1 +1 @@
|
||||
58f02c99f9fceb8f5aeae2c8b90fd325
|
||||
687705a933fcf09f20bdb5083955a417
|
||||
+109
-213
@@ -19,8 +19,9 @@ import {
|
||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
||||
ListClusterServers, ClusterSpotStatuses,
|
||||
GetCATSettings,
|
||||
OperatingDefaultForBand,
|
||||
} from '../wailsjs/go/main/App';
|
||||
import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime';
|
||||
import { EventsOn } from '../wailsjs/runtime/runtime';
|
||||
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
||||
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
|
||||
@@ -30,8 +31,11 @@ import { SettingsModal } from '@/components/SettingsModal';
|
||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
||||
import { BandMap } from '@/components/BandMap';
|
||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||
import { CallHistoryPanel } from '@/components/CallHistoryPanel';
|
||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -327,6 +331,25 @@ export default function App() {
|
||||
const updateDetails = useCallback((patch: Partial<DetailsState>) => {
|
||||
setDetails((d) => ({ ...d, ...patch }));
|
||||
}, []);
|
||||
// Auto-fill MY_RIG / MY_ANTENNA from the operating conditions tree
|
||||
// whenever the band changes. The backend resolves the "default antenna
|
||||
// for this band" within the active profile and returns the (rig,
|
||||
// antenna) tuple. Empty result → we DO clear the fields so leftover
|
||||
// values from a previous band don't get logged against the wrong gear.
|
||||
useEffect(() => {
|
||||
if (!band) return;
|
||||
let cancelled = false;
|
||||
OperatingDefaultForBand(band).then((d) => {
|
||||
if (cancelled) return;
|
||||
setDetails((cur) => ({
|
||||
...cur,
|
||||
my_rig: d?.station_name || '',
|
||||
my_antenna: d?.antenna_name || '',
|
||||
tx_pwr: d?.tx_pwr ?? cur.tx_pwr,
|
||||
}));
|
||||
}).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [band]);
|
||||
const prefix = useMemo(() => computePrefix(callsign), [callsign]);
|
||||
// Bearing/distance from operator's home grid to the remote station —
|
||||
// shown live in the entry strip (SP azimuth) and Info tab (LP + dist).
|
||||
@@ -340,6 +363,16 @@ export default function App() {
|
||||
const [filterBand, setFilterBand] = useState('');
|
||||
const [filterMode, setFilterMode] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('recent');
|
||||
// Recent QSOs row cap, persisted. With AG Grid's virtual scroller
|
||||
// huge logs render OK once loaded, but a 25k+ logbook still takes a
|
||||
// couple of seconds to round-trip from SQLite at launch. Defaulting
|
||||
// to 500 keeps the first paint instant; the user can bump to "All"
|
||||
// when they actually want to search history.
|
||||
const [qsoLimit, setQsoLimit] = useState<number>(() => {
|
||||
const raw = Number(localStorage.getItem('hamlog.qsoLimit') ?? '500');
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 500;
|
||||
});
|
||||
useEffect(() => { localStorage.setItem('hamlog.qsoLimit', String(qsoLimit)); }, [qsoLimit]);
|
||||
|
||||
// === DX Cluster live state ===
|
||||
type ClusterSpot = {
|
||||
@@ -355,6 +388,11 @@ export default function App() {
|
||||
time_utc?: string;
|
||||
country?: string;
|
||||
continent?: string;
|
||||
cqz?: number;
|
||||
ituz?: number;
|
||||
distance_km?: number;
|
||||
sp_deg?: number;
|
||||
lp_deg?: number;
|
||||
received_at: string;
|
||||
raw: string;
|
||||
};
|
||||
@@ -394,7 +432,7 @@ export default function App() {
|
||||
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
|
||||
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
|
||||
// different slots don't share the same colour.
|
||||
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string }>>({});
|
||||
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean }>>({});
|
||||
|
||||
// === Modals ===
|
||||
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
|
||||
@@ -451,7 +489,7 @@ export default function App() {
|
||||
try {
|
||||
const list = await ListQSO({
|
||||
callsign: filterCallsign, band: filterBand, mode: filterMode,
|
||||
limit: 500, offset: 0,
|
||||
limit: qsoLimit, offset: 0,
|
||||
} as any);
|
||||
const n = await CountQSO();
|
||||
setQsos(list);
|
||||
@@ -460,7 +498,7 @@ export default function App() {
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
}
|
||||
}, [filterCallsign, filterBand, filterMode]);
|
||||
}, [filterCallsign, filterBand, filterMode, qsoLimit]);
|
||||
|
||||
const loadStation = useCallback(async () => {
|
||||
try { setStation(await GetStationSettings()); } catch {}
|
||||
@@ -517,7 +555,7 @@ export default function App() {
|
||||
// CAT live updates. Push freq/band/mode into the entry strip when the rig
|
||||
// moves, unless the user just typed something (1.5s grace window).
|
||||
useEffect(() => {
|
||||
EventsOn('cat:state', (s: CATState) => {
|
||||
const unsub = EventsOn('cat:state', (s: CATState) => {
|
||||
setCatState(s);
|
||||
if (!s?.connected) return;
|
||||
if (Date.now() < catFreezeUntilRef.current) return;
|
||||
@@ -554,7 +592,7 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
});
|
||||
return () => { EventsOff('cat:state'); };
|
||||
return () => { unsub?.(); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -574,7 +612,7 @@ export default function App() {
|
||||
// cluster:state fires on connect/disconnect/save/delete — refresh
|
||||
// the saved-server list too so the source dropdown stays in sync
|
||||
// when the user adds, deletes or toggles a row in Settings.
|
||||
EventsOn('cluster:state', async (sts: ServerStatus[]) => {
|
||||
const unsubState = EventsOn('cluster:state', async (sts: ServerStatus[]) => {
|
||||
setClusterServerStatuses(sts ?? []);
|
||||
try {
|
||||
const list = await ListClusterServers();
|
||||
@@ -589,13 +627,13 @@ export default function App() {
|
||||
const activeIds = new Set((sts ?? []).map((s) => s.server_id));
|
||||
setSpots((arr) => arr.filter((sp) => activeIds.has(sp.source_id)));
|
||||
});
|
||||
EventsOn('cluster:spot', (sp: ClusterSpot) => {
|
||||
const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
|
||||
setSpots((arr) => {
|
||||
const next = [sp, ...arr];
|
||||
return next.length > SPOTS_CAP ? next.slice(0, SPOTS_CAP) : next;
|
||||
});
|
||||
});
|
||||
return () => { EventsOff('cluster:state'); EventsOff('cluster:spot'); };
|
||||
return () => { unsubState?.(); unsubSpot?.(); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -623,7 +661,12 @@ export default function App() {
|
||||
const next = { ...prev };
|
||||
for (const r of res) {
|
||||
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
|
||||
next[k] = { status: r.status ?? '', country: r.country, continent: (r as any).continent };
|
||||
next[k] = {
|
||||
status: r.status ?? '',
|
||||
country: r.country,
|
||||
continent: (r as any).continent,
|
||||
worked_call: !!(r as any).worked_call,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
@@ -955,6 +998,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-background">
|
||||
<ShutdownProgress />
|
||||
{/* ===== TOPBAR ===== */}
|
||||
{compact ? (
|
||||
// Minimal compact topbar — brand + freq + toggle. Saves vertical space
|
||||
@@ -1127,10 +1171,6 @@ export default function App() {
|
||||
<Settings className="size-3.5" /> Set station
|
||||
</Button>
|
||||
)}
|
||||
<div className="text-right">
|
||||
<div className="font-bold text-[15px] leading-none">{total.toLocaleString('en-US')}</div>
|
||||
<div className="text-[10px] text-muted-foreground uppercase tracking-wider">Total QSOs</div>
|
||||
</div>
|
||||
<Button
|
||||
variant={showBandMap ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
@@ -1431,9 +1471,9 @@ export default function App() {
|
||||
/>
|
||||
|
||||
{/* ===== LOWER: tabs+table | call history | (optional) band map ===== */}
|
||||
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0', showBandMap ? 'grid-cols-[1fr_360px_260px]' : 'grid-cols-[1fr_360px]')}>
|
||||
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]', showBandMap ? 'grid-cols-[1fr_260px]' : 'grid-cols-[1fr]')}>
|
||||
<section className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0 flex-1">
|
||||
<TabsList className="px-3 shrink-0">
|
||||
<TabsTrigger value="main">Main</TabsTrigger>
|
||||
<TabsTrigger value="recent">
|
||||
@@ -1441,6 +1481,12 @@ export default function App() {
|
||||
<Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-[10px]">{qsos.length}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="cluster">Cluster</TabsTrigger>
|
||||
<TabsTrigger value="worked">
|
||||
Worked before
|
||||
{wb && wb.count > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-[10px]">{wb.count}</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="awards">Awards</TabsTrigger>
|
||||
<TabsTrigger value="propagation">Propagation</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -1515,67 +1561,45 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full border-collapse text-[12.5px]">
|
||||
<thead>
|
||||
<tr>
|
||||
{['Date UTC','Callsign','Band','Mode','MHz','RST sent','RST rcvd','Name','QTH','Country','Grid','Station','Comment',''].map((h, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className={cn(
|
||||
'sticky top-0 z-10 bg-stone-200 px-2.5 py-2 text-left font-semibold text-muted-foreground text-[11px] uppercase tracking-wide border-b border-border whitespace-nowrap',
|
||||
h === 'MHz' && 'text-right',
|
||||
i === 13 && 'w-0',
|
||||
<RecentQSOsGrid
|
||||
rows={qsos as any}
|
||||
total={total}
|
||||
onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
onRowSelected={(id) => setSelectedId(id)}
|
||||
/>
|
||||
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
|
||||
<span>
|
||||
Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
|
||||
<span className="font-semibold text-foreground">{total}</span>
|
||||
{filterCallsign || filterBand || filterMode ? ' (filtered)' : ''}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{qsos.length >= qsoLimit && qsos.length < total && (
|
||||
<span className="text-amber-700">Limit reached — raise Max to see more.</span>
|
||||
)}
|
||||
>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{qsos.length === 0 ? (
|
||||
<tr><td colSpan={14} className="text-center py-10 text-muted-foreground italic">No QSO yet. Log your first contact above.</td></tr>
|
||||
) : qsos.map((q, i) => (
|
||||
<tr
|
||||
key={q.id}
|
||||
className={cn(
|
||||
'cursor-pointer hover:bg-stone-100 transition-colors',
|
||||
i % 2 === 1 && 'bg-stone-50/60',
|
||||
selectedId === q.id && '!bg-accent',
|
||||
)}
|
||||
onClick={() => setSelectedId(q.id)}
|
||||
onDoubleClick={() => openEdit(q.id)}
|
||||
<Label className="text-[11px] text-muted-foreground">Max</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={100}
|
||||
className="w-24 h-7 font-mono text-xs"
|
||||
value={qsoLimit}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
if (Number.isFinite(n) && n > 0) setQsoLimit(Math.floor(n));
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-[11px]"
|
||||
onClick={() => setQsoLimit(Math.max(total, 1))}
|
||||
title="Load the entire log"
|
||||
disabled={total === 0}
|
||||
>
|
||||
<td className="px-2.5 py-1.5 font-mono whitespace-nowrap border-b border-border/40">{fmtDateUTC(q.qso_date)}</td>
|
||||
<td className="px-2.5 py-1.5 font-mono font-semibold text-primary whitespace-nowrap border-b border-border/40">{q.callsign}</td>
|
||||
<td className="px-2.5 py-1.5 whitespace-nowrap border-b border-border/40">
|
||||
<span className="inline-block px-2 py-0.5 rounded font-mono text-[11px] font-semibold bg-accent text-accent-foreground">{q.band}</span>
|
||||
</td>
|
||||
<td className="px-2.5 py-1.5 whitespace-nowrap border-b border-border/40">
|
||||
<span className="inline-block px-2 py-0.5 rounded font-mono text-[11px] font-semibold bg-emerald-100 text-emerald-700">{q.mode}</span>
|
||||
</td>
|
||||
<td className="px-2.5 py-1.5 font-mono text-right whitespace-nowrap border-b border-border/40">{q.freq_hz ? fmtFreqDots(fmtFreq(q.freq_hz)) : ''}</td>
|
||||
<td className="px-2.5 py-1.5 font-mono whitespace-nowrap border-b border-border/40">{q.rst_sent ?? ''}</td>
|
||||
<td className="px-2.5 py-1.5 font-mono whitespace-nowrap border-b border-border/40">{q.rst_rcvd ?? ''}</td>
|
||||
<td className="px-2.5 py-1.5 whitespace-nowrap border-b border-border/40">{q.name ?? ''}</td>
|
||||
<td className="px-2.5 py-1.5 whitespace-nowrap border-b border-border/40">{q.qth ?? ''}</td>
|
||||
<td className="px-2.5 py-1.5 whitespace-nowrap border-b border-border/40">{q.country ?? ''}</td>
|
||||
<td className="px-2.5 py-1.5 font-mono whitespace-nowrap border-b border-border/40">{q.grid ?? ''}</td>
|
||||
<td className="px-2.5 py-1.5 font-mono whitespace-nowrap border-b border-border/40">{q.station_callsign ?? ''}</td>
|
||||
<td className="px-2.5 py-1.5 text-muted-foreground whitespace-nowrap border-b border-border/40 max-w-[200px] overflow-hidden text-ellipsis">{q.comment ?? ''}</td>
|
||||
<td className="px-1.5 py-0.5 text-right whitespace-nowrap border-b border-border/40 opacity-0 group-hover:opacity-100">
|
||||
<Button size="icon" variant="ghost" className="size-6 mx-0.5"
|
||||
onClick={(e) => { e.stopPropagation(); openEdit(q.id); }}>
|
||||
<Pencil className="size-3" />
|
||||
All ({total})
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-6 mx-0.5 hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={(e) => { e.stopPropagation(); askDelete(q.id); }}>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -1737,7 +1761,6 @@ export default function App() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{(() => {
|
||||
// Apply every filter. `bandsActive` is the band set the
|
||||
// user clicked, OR the entry's locked band when Lock band
|
||||
@@ -1753,9 +1776,6 @@ export default function App() {
|
||||
if (search && !s.dx_call.includes(search)) return false;
|
||||
if (clusterLockMode) {
|
||||
const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz);
|
||||
// Treat empty inferred mode as wildcard so we don't
|
||||
// hide perfectly good spots just because the comment
|
||||
// was ambiguous.
|
||||
if (spotMode && mode && spotMode !== mode) return false;
|
||||
}
|
||||
if (clusterStatusFilter.size > 0) {
|
||||
@@ -1775,23 +1795,9 @@ export default function App() {
|
||||
}
|
||||
rendered = Array.from(seen.values());
|
||||
}
|
||||
// Apply sort. Time defaults to descending (newest first).
|
||||
const dir = clusterSort.dir === 'asc' ? 1 : -1;
|
||||
const cmp = (a: any, b: any) => (a < b ? -dir : a > b ? dir : 0);
|
||||
rendered = [...rendered].sort((a, b) => {
|
||||
switch (clusterSort.key) {
|
||||
case 'time': return cmp(a.received_at, b.received_at);
|
||||
case 'call': return cmp(a.dx_call, b.dx_call);
|
||||
case 'freq': return cmp(a.freq_khz, b.freq_khz);
|
||||
case 'band': return cmp(a.band ?? '', b.band ?? '');
|
||||
case 'mode': return cmp(inferSpotMode(a.comment ?? '', a.freq_hz), inferSpotMode(b.comment ?? '', b.freq_hz));
|
||||
case 'spotter': return cmp(cleanSpotter(a.spotter), cleanSpotter(b.spotter));
|
||||
case 'source': return cmp(a.source_name, b.source_name);
|
||||
}
|
||||
});
|
||||
if (rendered.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
|
||||
<Hash className="size-10 opacity-30" />
|
||||
<div className="text-sm font-semibold text-foreground/70">
|
||||
{clusterServerStatuses.some((s) => s.state === 'connected') ? 'Waiting for spots…' : 'No active connection'}
|
||||
@@ -1804,84 +1810,11 @@ export default function App() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const headers: { key: SortKey | null; label: string; align?: 'right' }[] = [
|
||||
{ key: 'time', label: 'Time' },
|
||||
{ key: 'call', label: 'Call' },
|
||||
{ key: 'freq', label: 'Freq', align: 'right' },
|
||||
{ key: 'band', label: 'Band' },
|
||||
{ key: 'mode', label: 'Mode' },
|
||||
{ key: null, label: 'Country' },
|
||||
{ key: null, label: 'Cont' },
|
||||
{ key: 'spotter', label: 'Spotter' },
|
||||
{ key: 'source', label: 'Source' },
|
||||
{ key: null, label: 'Loc' },
|
||||
{ key: null, label: 'Comment' },
|
||||
];
|
||||
const toggleSort = (k: SortKey) => setClusterSort((s) =>
|
||||
s.key === k
|
||||
? { key: k, dir: s.dir === 'asc' ? 'desc' : 'asc' }
|
||||
: { key: k, dir: k === 'time' || k === 'freq' ? 'desc' : 'asc' });
|
||||
// Log4OM-style per-cell highlight: the badge that matches
|
||||
// the "what's new" gets coloured instead of the whole row.
|
||||
// CALL = new entity, BAND = new band for entity, MODE = new
|
||||
// mode for that band (NEW SLOT — Log4OM doesn't show this
|
||||
// but the user wants it).
|
||||
const cellHL = (s: ClusterSpot) => {
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
return spotStatus[k]?.status ?? '';
|
||||
};
|
||||
return (
|
||||
<table className="w-full border-collapse text-[12.5px]">
|
||||
<thead>
|
||||
<tr>
|
||||
{headers.map((h, i) => {
|
||||
const sortable = h.key !== null;
|
||||
const active = sortable && clusterSort.key === h.key;
|
||||
return (
|
||||
<th
|
||||
key={i}
|
||||
onClick={sortable ? () => toggleSort(h.key as SortKey) : undefined}
|
||||
className={cn(
|
||||
'px-2.5 py-1.5 font-semibold text-[10px] uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border sticky top-0',
|
||||
h.align === 'right' ? 'text-right' : 'text-left',
|
||||
sortable && 'cursor-pointer select-none hover:text-foreground',
|
||||
active && 'text-primary',
|
||||
)}
|
||||
>
|
||||
{h.label}
|
||||
{active && (
|
||||
<span className="ml-1 inline-block text-[9px]">
|
||||
{clusterSort.dir === 'asc' ? '▲' : '▼'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rendered.map((s, i) => {
|
||||
const hl = cellHL(s);
|
||||
const callCls = hl === 'new'
|
||||
? 'bg-rose-100 text-rose-800 hover:bg-rose-200 border border-rose-300'
|
||||
: 'text-primary';
|
||||
const bandCls = hl === 'new-band'
|
||||
? 'bg-amber-200 text-amber-900 border-amber-500 hover:bg-amber-200'
|
||||
: '';
|
||||
const modeMode = inferSpotMode(s.comment ?? '', s.freq_hz);
|
||||
const modeCls = hl === 'new-slot'
|
||||
? 'bg-yellow-200 text-yellow-900 border-yellow-500 hover:bg-yellow-200'
|
||||
: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-100';
|
||||
return (
|
||||
<tr
|
||||
key={`${s.received_at}-${s.dx_call}-${i}`}
|
||||
className="cursor-pointer hover:bg-accent/30"
|
||||
onClick={() => {
|
||||
// Mode comes from the spot itself (comment text
|
||||
// first, band plan fallback). Sending it to CAT
|
||||
// matters because skipping it leaves the rig
|
||||
// on whatever it had — typically DIGU after a
|
||||
// previous FT8 contact, which breaks a SSB click.
|
||||
<ClusterGrid
|
||||
rows={rendered as any}
|
||||
spotStatus={spotStatus}
|
||||
onSpotClick={(s) => {
|
||||
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
|
||||
if (catState.connected) {
|
||||
SetCATFrequency(s.freq_hz).catch(() => {});
|
||||
@@ -1893,49 +1826,9 @@ export default function App() {
|
||||
}
|
||||
onCallsignInput(s.dx_call);
|
||||
}}
|
||||
title={s.raw}
|
||||
>
|
||||
<td
|
||||
className="px-2.5 py-1.5 font-mono text-muted-foreground whitespace-nowrap border-b border-border/40"
|
||||
title={s.repeats && s.repeats > 1 ? `Seen ${s.repeats}× across active clusters` : undefined}
|
||||
>{s.time_utc || ''}</td>
|
||||
<td className="px-2.5 py-1.5 whitespace-nowrap border-b border-border/40">
|
||||
<span
|
||||
className={cn('font-mono font-bold inline-block px-1 rounded', callCls)}
|
||||
title={hl === 'new' ? `NEW DXCC: ${spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]?.country ?? ''}` : undefined}
|
||||
>
|
||||
{s.dx_call}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2.5 py-1.5 font-mono text-right whitespace-nowrap border-b border-border/40">{s.freq_khz.toFixed(1)}</td>
|
||||
<td className="px-2.5 py-1.5 border-b border-border/40">
|
||||
{bandCls
|
||||
? <Badge className={cn('font-mono text-[10px] py-0', bandCls)} variant="outline" title="NEW BAND for this entity">{s.band || '—'}</Badge>
|
||||
: <Badge variant="accent" className="font-mono text-[10px] py-0">{s.band || '—'}</Badge>}
|
||||
</td>
|
||||
<td className="px-2.5 py-1.5 border-b border-border/40">
|
||||
{!modeMode
|
||||
? <span className="text-muted-foreground text-[10px]">—</span>
|
||||
: <Badge className={cn('font-mono text-[10px] py-0', modeCls)} variant="outline" title={hl === 'new-slot' ? 'NEW SLOT (mode not yet worked on this band)' : undefined}>{modeMode}</Badge>}
|
||||
</td>
|
||||
<td className="px-2.5 py-1.5 text-[12px] text-muted-foreground whitespace-nowrap border-b border-border/40">
|
||||
{s.country ?? spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]?.country ?? ''}
|
||||
</td>
|
||||
<td className="px-2.5 py-1.5 font-mono text-muted-foreground text-[10px] whitespace-nowrap border-b border-border/40">
|
||||
{s.continent ?? spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]?.continent ?? ''}
|
||||
</td>
|
||||
<td className="px-2.5 py-1.5 font-mono text-muted-foreground whitespace-nowrap border-b border-border/40">{cleanSpotter(s.spotter)}</td>
|
||||
<td className="px-2.5 py-1.5 font-mono text-muted-foreground/60 text-[10px] whitespace-nowrap border-b border-border/40">{s.source_name}</td>
|
||||
<td className="px-2.5 py-1.5 font-mono text-muted-foreground whitespace-nowrap border-b border-border/40">{s.locator || ''}</td>
|
||||
<td className="px-2.5 py-1.5 text-muted-foreground border-b border-border/40">{s.comment}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Command input — sends to the master server. */}
|
||||
<div className="flex items-center gap-2 p-2.5 border-t border-border/60 shrink-0">
|
||||
@@ -1971,6 +1864,10 @@ export default function App() {
|
||||
now in the topbar, visible on every tab. */}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||
<WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} />
|
||||
</TabsContent>
|
||||
|
||||
{(['main','awards','propagation'] as const).map((t) => (
|
||||
<TabsContent key={t} value={t} className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
|
||||
<Hash className="size-10 opacity-30" />
|
||||
@@ -1981,7 +1878,6 @@ export default function App() {
|
||||
</Tabs>
|
||||
</section>
|
||||
|
||||
<CallHistoryPanel wb={wb} busy={wbBusy} currentCall={callsign} />
|
||||
{showBandMap && (
|
||||
<div className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
|
||||
<BandMap
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||||
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
|
||||
} from 'ag-grid-community';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Columns3 } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
const hamlogTheme = themeQuartz.withParams({
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12.5,
|
||||
backgroundColor: '#faf6ea',
|
||||
foregroundColor: '#2a2419',
|
||||
headerBackgroundColor: '#e8dfc9',
|
||||
headerTextColor: '#5a4f3a',
|
||||
headerFontWeight: 600,
|
||||
oddRowBackgroundColor: '#f5efe0',
|
||||
rowHoverColor: '#ecdcb4',
|
||||
selectedRowBackgroundColor: '#f0d9a8',
|
||||
borderColor: '#c8b994',
|
||||
rowBorder: { color: '#d8c9a8', width: 1 },
|
||||
columnBorder: { color: '#d8c9a8', width: 1 },
|
||||
cellHorizontalPadding: 10,
|
||||
rowHeight: 30,
|
||||
headerHeight: 32,
|
||||
spacing: 4,
|
||||
accentColor: '#b8410c',
|
||||
iconSize: 12,
|
||||
});
|
||||
|
||||
export type ClusterSpot = {
|
||||
source_id: number;
|
||||
source_name: string;
|
||||
spotter: string;
|
||||
dx_call: string;
|
||||
freq_khz: number;
|
||||
freq_hz: number;
|
||||
band?: string;
|
||||
comment?: string;
|
||||
locator?: string;
|
||||
time_utc?: string;
|
||||
country?: string;
|
||||
continent?: string;
|
||||
cqz?: number;
|
||||
ituz?: number;
|
||||
distance_km?: number;
|
||||
sp_deg?: number;
|
||||
lp_deg?: number;
|
||||
received_at: string;
|
||||
raw: string;
|
||||
repeats?: number;
|
||||
};
|
||||
|
||||
export type SpotStatusEntry = {
|
||||
status?: string;
|
||||
country?: string;
|
||||
continent?: string;
|
||||
worked_call?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
rows: ClusterSpot[];
|
||||
spotStatus: Record<string, SpotStatusEntry>;
|
||||
onSpotClick?: (s: ClusterSpot) => void;
|
||||
};
|
||||
|
||||
const COL_STATE_KEY = 'hamlog.clusterColState.v1';
|
||||
|
||||
// Extracts the prefix from a callsign — drops portable suffixes (/P, /MM
|
||||
// etc.), keeps a slashed prefix (HB0/DL2SBY → HB0), and trims the trailing
|
||||
// digits after the last letter group (DL2SBY → DL2).
|
||||
function fmtPfx(call: string): string {
|
||||
if (!call) return '';
|
||||
const c = call.trim().toUpperCase();
|
||||
const base = c.includes('/') ? c.split('/')[0] : c;
|
||||
// If "base" is a callsign rather than a bare prefix (like DL2SBY), cut
|
||||
// at the last digit to get DL2.
|
||||
let lastDigit = -1;
|
||||
for (let i = 0; i < base.length; i++) {
|
||||
if (base[i] >= '0' && base[i] <= '9') lastDigit = i;
|
||||
}
|
||||
return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
|
||||
}
|
||||
|
||||
// Renders an ISO timestamp (RFC3339 with nanoseconds) as a compact UTC
|
||||
// "YYYY-MM-DD HH:MM:SS" string — matches the rest of the app's date style.
|
||||
function fmtDateTimeUTC(s: any): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
if (isNaN(d.getTime())) return String(s);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
|
||||
}
|
||||
|
||||
type ColEntry = ColDef<ClusterSpot> & { group: string; label: string; defaultVisible?: boolean };
|
||||
|
||||
const COL_CATALOG: ColEntry[] = [
|
||||
{
|
||||
group: 'Spot', label: 'Time', colId: 'time',
|
||||
headerName: 'Time', field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
sort: 'desc',
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Call', colId: 'call',
|
||||
headerName: 'Call', field: 'dx_call' as any, width: 120,
|
||||
defaultVisible: true,
|
||||
cellRenderer: (p: any) => {
|
||||
if (!p.value) return '';
|
||||
const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[
|
||||
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
|
||||
];
|
||||
const isNew = status?.status === 'new';
|
||||
const workedCall = !!status?.worked_call;
|
||||
const style: any = {
|
||||
display: 'inline-block', padding: '1px 6px', borderRadius: 4,
|
||||
fontFamily: 'ui-monospace, monospace', fontWeight: 700, fontSize: 12,
|
||||
};
|
||||
if (isNew) {
|
||||
style.backgroundColor = '#ffe4e6';
|
||||
style.color = '#9f1239';
|
||||
style.border = '1px solid #fda4af';
|
||||
} else if (workedCall) {
|
||||
style.color = '#0369a1';
|
||||
} else {
|
||||
style.color = '#b8410c';
|
||||
}
|
||||
return <span style={style} title={isNew ? `NEW DXCC: ${status?.country ?? ''}` : workedCall ? 'Already worked this call' : undefined}>{p.value}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Freq', colId: 'freq',
|
||||
headerName: 'Freq', field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
valueFormatter: (p) => typeof p.value === 'number' ? p.value.toFixed(1) : '',
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Band', colId: 'band',
|
||||
headerName: 'Band', field: 'band' as any, width: 75,
|
||||
defaultVisible: true,
|
||||
cellClass: 'flex items-center',
|
||||
cellRenderer: (p: any) => {
|
||||
const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[
|
||||
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
|
||||
];
|
||||
const newBand = status?.status === 'new-band';
|
||||
const bg = newBand ? '#fde68a' : '#f0d9a8';
|
||||
const fg = newBand ? '#92400e' : '#7a4a14';
|
||||
return p.value
|
||||
? <span
|
||||
style={{
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
||||
backgroundColor: bg, color: fg, lineHeight: '16px',
|
||||
border: newBand ? '1px solid #f59e0b' : undefined,
|
||||
}}
|
||||
title={newBand ? 'NEW BAND for this entity' : undefined}
|
||||
>{p.value}</span>
|
||||
: '';
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Mode', colId: 'mode',
|
||||
headerName: 'Mode', colSpan: undefined, width: 80,
|
||||
defaultVisible: true,
|
||||
cellClass: 'flex items-center',
|
||||
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
||||
cellRenderer: (p: any) => {
|
||||
const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[
|
||||
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
|
||||
];
|
||||
const newSlot = status?.status === 'new-slot';
|
||||
const bg = newSlot ? '#fef08a' : '#d1fae5';
|
||||
const fg = newSlot ? '#854d0e' : '#047857';
|
||||
return p.value
|
||||
? <span
|
||||
style={{
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
||||
backgroundColor: bg, color: fg, lineHeight: '16px',
|
||||
border: newSlot ? '1px solid #eab308' : undefined,
|
||||
}}
|
||||
title={newSlot ? 'NEW SLOT (mode not yet worked on this band)' : undefined}
|
||||
>{p.value}</span>
|
||||
: <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Pfx', colId: 'pfx',
|
||||
headerName: 'Pfx', width: 60, cellClass: 'font-mono',
|
||||
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'CQ Zone', colId: 'cqz',
|
||||
headerName: 'CQZ', field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'ITU Zone', colId: 'ituz',
|
||||
headerName: 'ITU', field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'Distance (km)', colId: 'distance_km',
|
||||
headerName: 'Dist km', field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'Short path (°)', colId: 'sp_deg',
|
||||
headerName: 'SP°', field: 'sp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'Long path (°)', colId: 'lp_deg',
|
||||
headerName: 'LP°', field: 'lp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Country', colId: 'country',
|
||||
headerName: 'Country', width: 140,
|
||||
defaultVisible: true,
|
||||
valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[
|
||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||
]?.country ?? '',
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Continent', colId: 'continent',
|
||||
headerName: 'Cont', width: 60, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[
|
||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||
]?.continent ?? '',
|
||||
cellStyle: { color: '#7a6b50', fontSize: 10 },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Spotter', colId: 'spotter',
|
||||
headerName: 'Spotter', field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Source', colId: 'source',
|
||||
headerName: 'Source', field: 'source_name' as any, width: 100,
|
||||
defaultVisible: true,
|
||||
cellStyle: { color: '#9a8870', fontSize: 10 },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Locator', colId: 'locator',
|
||||
headerName: 'Loc', field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Comment', colId: 'comment',
|
||||
headerName: 'Comment', field: 'comment' as any, flex: 1, minWidth: 160,
|
||||
defaultVisible: true,
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Received at', colId: 'received_at',
|
||||
headerName: 'Received UTC', field: 'received_at' as any, width: 160, cellClass: 'font-mono',
|
||||
valueFormatter: (p) => fmtDateTimeUTC(p.value),
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Raw', colId: 'raw',
|
||||
headerName: 'Raw', field: 'raw' as any, width: 300, cellClass: 'font-mono',
|
||||
},
|
||||
];
|
||||
|
||||
const GROUP_ORDER = ['Spot', 'Geo'];
|
||||
|
||||
export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => COL_CATALOG.map((c) => {
|
||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||
return { ...rest, hide: !defaultVisible };
|
||||
}), []);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||
}), []);
|
||||
|
||||
// Pass spotStatus through AG Grid's context so cell renderers can look up
|
||||
// per-cell highlight without re-rendering the whole grid when the map
|
||||
// updates. We refresh cells whose values depend on it after each prop
|
||||
// change below.
|
||||
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
|
||||
|
||||
function onGridReady(e: GridReadyEvent) {
|
||||
try {
|
||||
const raw = localStorage.getItem(COL_STATE_KEY);
|
||||
if (raw) {
|
||||
const state = JSON.parse(raw) as ColumnState[];
|
||||
if (Array.isArray(state)) e.api.applyColumnState({ state, applyOrder: true });
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const saveColumnState = useCallback(() => {
|
||||
try {
|
||||
const state = gridRef.current?.api?.getColumnState();
|
||||
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
function handleRowClicked(e: RowClickedEvent<ClusterSpot>) {
|
||||
if (e.data && onSpotClick) onSpotClick(e.data);
|
||||
}
|
||||
|
||||
function isColVisible(colId: string): boolean {
|
||||
const col = gridRef.current?.api?.getColumn(colId);
|
||||
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
|
||||
}
|
||||
function setColVisible(colId: string, visible: boolean) {
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
api.setColumnsVisible([colId], visible);
|
||||
saveColumnState();
|
||||
}
|
||||
function showAll(group?: string) {
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!);
|
||||
api.setColumnsVisible(ids, true);
|
||||
saveColumnState();
|
||||
}
|
||||
function hideAll(group?: string) {
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!);
|
||||
api.setColumnsVisible(ids, false);
|
||||
saveColumnState();
|
||||
}
|
||||
function resetDefaults() {
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
const visible = COL_CATALOG.filter((c) => c.defaultVisible).map((c) => c.colId!);
|
||||
const hidden = COL_CATALOG.filter((c) => !c.defaultVisible).map((c) => c.colId!);
|
||||
api.setColumnsVisible(visible, true);
|
||||
api.setColumnsVisible(hidden, false);
|
||||
saveColumnState();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
||||
<Columns3 className="size-3.5" /> Columns
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<AgGridReact<ClusterSpot>
|
||||
ref={gridRef}
|
||||
theme={hamlogTheme}
|
||||
rowData={rows}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColDef}
|
||||
context={context}
|
||||
onGridReady={onGridReady}
|
||||
onColumnResized={saveColumnState}
|
||||
onColumnMoved={saveColumnState}
|
||||
onColumnPinned={saveColumnState}
|
||||
onColumnVisible={saveColumnState}
|
||||
onSortChanged={saveColumnState}
|
||||
onRowClicked={handleRowClicked}
|
||||
animateRows={false}
|
||||
suppressCellFocus
|
||||
getRowId={(p) => `${(p.data as any).received_at}-${(p.data as any).dx_call}-${(p.data as any).source_id}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cluster columns</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick the columns you want visible in the Cluster table.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[60vh] overflow-y-auto py-2">
|
||||
{GROUP_ORDER.map((group) => {
|
||||
const cols = COL_CATALOG.filter((c) => c.group === group);
|
||||
if (cols.length === 0) return null;
|
||||
return (
|
||||
<div key={group} className="rounded-md border border-border p-2.5 mb-2">
|
||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>none</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{cols.map((c) => (
|
||||
<label key={c.colId} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
|
||||
<Checkbox
|
||||
checked={isColVisible(c.colId!)}
|
||||
onCheckedChange={(v) => setColVisible(c.colId!, !!v)}
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -31,12 +31,27 @@ export function Menubar({ menus, onAction }: Props) {
|
||||
key={menu.name}
|
||||
open={openMenu === menu.name}
|
||||
onOpenChange={(o) => setOpenMenu(o ? menu.name : null)}
|
||||
modal={false}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
onMouseEnter={() => {
|
||||
// Only switch on hover if a menu is already open.
|
||||
if (openMenu !== null && openMenu !== menu.name) setOpenMenu(menu.name);
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
// Desktop-menubar behaviour: when another menu is already
|
||||
// open, a click on a different trigger should switch to it
|
||||
// in one click. Without this Radix consumes the click to
|
||||
// close the current menu first, requiring a second click
|
||||
// to open the new one. We pre-empt by setting open state
|
||||
// synchronously and stopping the event from reaching the
|
||||
// default Radix toggle.
|
||||
if (openMenu !== null && openMenu !== menu.name) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpenMenu(menu.name);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'px-3 text-sm rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
openMenu === menu.name && 'bg-muted text-primary',
|
||||
@@ -44,7 +59,17 @@ export function Menubar({ menus, onAction }: Props) {
|
||||
>
|
||||
{menu.label}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={4} className="min-w-[240px]">
|
||||
<DropdownMenuContent
|
||||
align="start" sideOffset={4} className="min-w-[240px]"
|
||||
onCloseAutoFocus={(e) => {
|
||||
// Radix re-focuses the trigger after close. Combined with our
|
||||
// focus-visible:ring style this leaves an orange outline around
|
||||
// the previously-clicked menu — looks like a stuck "selected"
|
||||
// state. We swallow the auto-focus and let the next interaction
|
||||
// decide where focus belongs.
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{menu.items.map((item, i) =>
|
||||
item.type === 'separator' ? (
|
||||
<DropdownMenuSeparator key={i} />
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Antenna as AntennaIcon, Radio, Plus, Trash2, Star,
|
||||
ChevronRight, ChevronDown, Edit2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
ListOperatingTree, SaveOperatingStation, DeleteOperatingStation,
|
||||
SaveOperatingAntenna, DeleteOperatingAntenna,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Band = { band: string; is_default: boolean };
|
||||
type Antenna = {
|
||||
id: number;
|
||||
station_id: number;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
bands: Band[];
|
||||
};
|
||||
type Station = {
|
||||
id: number;
|
||||
profile_id: number;
|
||||
name: string;
|
||||
tx_pwr?: number;
|
||||
sort_order: number;
|
||||
antennas?: Antenna[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
/** ADIF bands available to toggle, in display order (from ListsSettings). */
|
||||
bands: string[];
|
||||
/** External error sink — parent shows it next to the Save button. */
|
||||
onError: (msg: string) => void;
|
||||
};
|
||||
|
||||
export function OperatingPanel({ bands, onError }: Props) {
|
||||
const [tree, setTree] = useState<Station[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// expanded keeps which stations show their antennas; everything open by
|
||||
// default so the user sees the full setup at a glance.
|
||||
const [expanded, setExpanded] = useState<Set<number>>(new Set());
|
||||
// editingId tracks the row currently in edit mode. Use a string namespace
|
||||
// to keep station ids and antenna ids in the same Set.
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const t = await ListOperatingTree();
|
||||
const list = (t ?? []) as Station[];
|
||||
setTree(list);
|
||||
setExpanded((prev) => {
|
||||
if (prev.size > 0) return prev;
|
||||
return new Set(list.map((s) => s.id));
|
||||
});
|
||||
} catch (e: any) {
|
||||
onError(String(e?.message ?? e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [onError]);
|
||||
|
||||
useEffect(() => { void reload(); }, [reload]);
|
||||
|
||||
function toggleExpanded(id: number) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function addStation() {
|
||||
try {
|
||||
const created = await SaveOperatingStation({
|
||||
id: 0, profile_id: 0, name: 'New rig', sort_order: tree.length,
|
||||
} as any);
|
||||
const c = created as Station;
|
||||
setTree((prev) => [...prev, { ...c, antennas: [] }]);
|
||||
setExpanded((prev) => new Set(prev).add(c.id));
|
||||
setEditing(`station:${c.id}`);
|
||||
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function updateStation(s: Station) {
|
||||
try {
|
||||
const saved = await SaveOperatingStation(s as any) as Station;
|
||||
setTree((prev) => prev.map((x) => x.id === s.id ? { ...x, ...saved } : x));
|
||||
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function removeStation(id: number) {
|
||||
if (!confirm('Delete this rig and all its antennas?')) return;
|
||||
try {
|
||||
await DeleteOperatingStation(id);
|
||||
setTree((prev) => prev.filter((s) => s.id !== id));
|
||||
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
async function addAntenna(stationId: number) {
|
||||
try {
|
||||
const created = await SaveOperatingAntenna({
|
||||
id: 0, station_id: stationId, name: 'New antenna', sort_order: 0, bands: [],
|
||||
} as any) as Antenna;
|
||||
setTree((prev) => prev.map((s) =>
|
||||
s.id === stationId
|
||||
? { ...s, antennas: [...(s.antennas ?? []), created] }
|
||||
: s
|
||||
));
|
||||
setEditing(`antenna:${created.id}`);
|
||||
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function updateAntenna(a: Antenna) {
|
||||
try {
|
||||
const saved = await SaveOperatingAntenna(a as any) as Antenna;
|
||||
setTree((prev) => prev.map((s) => s.id === a.station_id
|
||||
? {
|
||||
...s,
|
||||
antennas: (s.antennas ?? []).map((x) => x.id === a.id ? saved : x),
|
||||
}
|
||||
: {
|
||||
// The save may have cleared is_default on antennas of OTHER
|
||||
// stations (one default per band per profile). Refresh those
|
||||
// by reloading the tree wholesale.
|
||||
...s,
|
||||
}
|
||||
));
|
||||
// Reload to pick up cross-station default flips.
|
||||
void reload();
|
||||
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function removeAntenna(stationId: number, antId: number) {
|
||||
if (!confirm('Delete this antenna?')) return;
|
||||
try {
|
||||
await DeleteOperatingAntenna(antId);
|
||||
setTree((prev) => prev.map((s) =>
|
||||
s.id === stationId
|
||||
? { ...s, antennas: (s.antennas ?? []).filter((a) => a.id !== antId) }
|
||||
: s
|
||||
));
|
||||
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-xs text-muted-foreground italic">Loading…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
||||
Define your rigs (stations) and the antennas connected to each one.
|
||||
For every antenna, tick the bands it covers. <Star className="inline size-3 text-amber-500 fill-current align-text-bottom" /> marks
|
||||
the default antenna for that band — when you change the band in the
|
||||
entry strip, the matching rig + antenna auto-fill the MY_RIG and
|
||||
MY_ANTENNA ADIF fields. Only one antenna can be the default per
|
||||
band; setting one clears the previous default.
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={addStation}>
|
||||
<Plus className="size-3.5" /> Add rig
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tree.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border/70 px-4 py-8 text-center text-xs text-muted-foreground italic">
|
||||
No rig configured yet. Click "Add rig" to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tree.map((station) => (
|
||||
<StationRow
|
||||
key={station.id}
|
||||
station={station}
|
||||
bands={bands}
|
||||
expanded={expanded.has(station.id)}
|
||||
editing={editing}
|
||||
setEditing={setEditing}
|
||||
onToggleExpanded={() => toggleExpanded(station.id)}
|
||||
onUpdate={updateStation}
|
||||
onDelete={() => removeStation(station.id)}
|
||||
onAddAntenna={() => addAntenna(station.id)}
|
||||
onUpdateAntenna={updateAntenna}
|
||||
onDeleteAntenna={(antId) => removeAntenna(station.id, antId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Station row ────────────────────────────────────────────────────────
|
||||
|
||||
type StationRowProps = {
|
||||
station: Station;
|
||||
bands: string[];
|
||||
expanded: boolean;
|
||||
editing: string | null;
|
||||
setEditing: (id: string | null) => void;
|
||||
onToggleExpanded: () => void;
|
||||
onUpdate: (s: Station) => void;
|
||||
onDelete: () => void;
|
||||
onAddAntenna: () => void;
|
||||
onUpdateAntenna: (a: Antenna) => void;
|
||||
onDeleteAntenna: (antId: number) => void;
|
||||
};
|
||||
|
||||
function StationRow({
|
||||
station, bands, expanded, editing, setEditing,
|
||||
onToggleExpanded, onUpdate, onDelete, onAddAntenna,
|
||||
onUpdateAntenna, onDeleteAntenna,
|
||||
}: StationRowProps) {
|
||||
const editKey = `station:${station.id}`;
|
||||
const isEditing = editing === editKey;
|
||||
const [draft, setDraft] = useState({
|
||||
name: station.name,
|
||||
tx_pwr: station.tx_pwr != null ? String(station.tx_pwr) : '',
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!isEditing) setDraft({
|
||||
name: station.name,
|
||||
tx_pwr: station.tx_pwr != null ? String(station.tx_pwr) : '',
|
||||
});
|
||||
}, [isEditing, station.name, station.tx_pwr]);
|
||||
|
||||
function commit() {
|
||||
const pwrNum = draft.tx_pwr.trim() === '' ? undefined : parseFloat(draft.tx_pwr);
|
||||
onUpdate({
|
||||
...station,
|
||||
name: draft.name.trim() || station.name,
|
||||
tx_pwr: Number.isFinite(pwrNum as number) ? (pwrNum as number) : undefined,
|
||||
});
|
||||
setEditing(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-card">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-border/60 bg-muted/30">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleExpanded}
|
||||
className="p-0.5 hover:bg-accent/40 rounded"
|
||||
title={expanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{expanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
|
||||
</button>
|
||||
<Radio className="size-4 text-muted-foreground" />
|
||||
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-7 text-sm flex-1"
|
||||
placeholder="Rig name (also stamped as MY_RIG)"
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }}
|
||||
/>
|
||||
<Label className="text-[11px] text-muted-foreground">Power (W)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
className="h-7 text-sm w-20 font-mono"
|
||||
placeholder="100"
|
||||
value={draft.tx_pwr}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, tx_pwr: e.target.value }))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }}
|
||||
/>
|
||||
<Button size="sm" onClick={commit}>Save</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-semibold text-sm">{station.name}</span>
|
||||
{station.tx_pwr != null && (
|
||||
<span className="text-[11px] text-muted-foreground font-mono">{station.tx_pwr} W</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => setEditing(editKey)} title="Edit">
|
||||
<Edit2 className="size-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-6 text-xs" onClick={onAddAntenna}>
|
||||
<Plus className="size-3" /> Antenna
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={onDelete} title="Delete rig">
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="p-2 space-y-2">
|
||||
{(station.antennas ?? []).length === 0 ? (
|
||||
<div className="text-[11px] text-muted-foreground italic pl-6">
|
||||
No antenna yet — click "Antenna" above to add one.
|
||||
</div>
|
||||
) : (
|
||||
(station.antennas ?? []).map((a) => (
|
||||
<AntennaRow
|
||||
key={a.id}
|
||||
antenna={a}
|
||||
bands={bands}
|
||||
editing={editing}
|
||||
setEditing={setEditing}
|
||||
onUpdate={onUpdateAntenna}
|
||||
onDelete={() => onDeleteAntenna(a.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Antenna row ────────────────────────────────────────────────────────
|
||||
|
||||
type AntennaRowProps = {
|
||||
antenna: Antenna;
|
||||
bands: string[];
|
||||
editing: string | null;
|
||||
setEditing: (id: string | null) => void;
|
||||
onUpdate: (a: Antenna) => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }: AntennaRowProps) {
|
||||
const editKey = `antenna:${antenna.id}`;
|
||||
const isEditing = editing === editKey;
|
||||
const [draft, setDraft] = useState({ name: antenna.name });
|
||||
useEffect(() => {
|
||||
if (!isEditing) setDraft({ name: antenna.name });
|
||||
}, [isEditing, antenna.name]);
|
||||
|
||||
const enabledBands = new Map<string, Band>(
|
||||
(antenna.bands ?? []).map((b) => [b.band, b])
|
||||
);
|
||||
|
||||
function commitNames() {
|
||||
onUpdate({
|
||||
...antenna,
|
||||
name: draft.name.trim() || antenna.name,
|
||||
bands: antenna.bands ?? [],
|
||||
});
|
||||
setEditing(null);
|
||||
}
|
||||
|
||||
function toggleBand(band: string, on: boolean) {
|
||||
let next = [...(antenna.bands ?? [])];
|
||||
if (on) {
|
||||
if (!next.find((b) => b.band === band)) {
|
||||
next.push({ band, is_default: false });
|
||||
}
|
||||
} else {
|
||||
next = next.filter((b) => b.band !== band);
|
||||
}
|
||||
onUpdate({ ...antenna, bands: next });
|
||||
}
|
||||
|
||||
function setDefault(band: string, isDefault: boolean) {
|
||||
const next = (antenna.bands ?? []).map((b) =>
|
||||
b.band === band ? { ...b, is_default: isDefault } : b
|
||||
);
|
||||
onUpdate({ ...antenna, bands: next });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded border border-border/70 bg-muted/10">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<AntennaIcon className="size-3.5 text-muted-foreground ml-3" />
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-7 text-sm flex-1"
|
||||
placeholder="Antenna name (also stamped as MY_ANTENNA)"
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commitNames(); if (e.key === 'Escape') setEditing(null); }}
|
||||
/>
|
||||
<Button size="sm" onClick={commitNames}>Save</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm font-medium">{antenna.name}</span>
|
||||
<div className="flex-1" />
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => setEditing(editKey)}>
|
||||
<Edit2 className="size-3" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={onDelete}>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-2 pl-8 flex flex-wrap gap-1.5">
|
||||
{bands.length === 0 ? (
|
||||
<span className="text-[10px] text-muted-foreground italic">No band configured in Settings → Bands.</span>
|
||||
) : bands.map((band) => {
|
||||
const entry = enabledBands.get(band);
|
||||
const enabled = !!entry;
|
||||
const isDefault = !!entry?.is_default;
|
||||
return (
|
||||
<div
|
||||
key={band}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-1 rounded text-[11px] font-mono border transition-colors',
|
||||
isDefault
|
||||
? 'border-amber-400 bg-amber-50 shadow-sm'
|
||||
: enabled
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: 'border-border/50 bg-muted/30 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={enabled}
|
||||
onCheckedChange={(c) => toggleBand(band, !!c)}
|
||||
className="size-3"
|
||||
/>
|
||||
<span className={isDefault ? 'font-semibold' : undefined}>{band}</span>
|
||||
{enabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDefault(band, !isDefault)}
|
||||
className={cn(
|
||||
'flex items-center gap-0.5 ml-1 px-1.5 py-0.5 rounded transition-colors',
|
||||
isDefault
|
||||
? 'bg-amber-400 text-white'
|
||||
: 'border border-dashed border-muted-foreground/40 text-muted-foreground hover:border-amber-500 hover:text-amber-700',
|
||||
)}
|
||||
title={isDefault ? 'Default antenna for this band — click to unset' : 'Click to make this antenna the default for this band'}
|
||||
>
|
||||
<Star className={cn('size-3', isDefault && 'fill-current')} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider">
|
||||
{isDefault ? 'Default' : 'Set'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||
} from 'ag-grid-community';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Columns3 } from 'lucide-react';
|
||||
import type { QSOForm } from '@/types';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
// Register every Community feature once. v32+ requires explicit registration;
|
||||
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
||||
// virtual-scroll — everything we want out of the box for a logbook table.
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
// Custom Quartz theme tuned to match HamLog's warm palette.
|
||||
const hamlogTheme = themeQuartz.withParams({
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12.5,
|
||||
backgroundColor: '#faf6ea',
|
||||
foregroundColor: '#2a2419',
|
||||
headerBackgroundColor: '#e8dfc9',
|
||||
headerTextColor: '#5a4f3a',
|
||||
headerFontWeight: 600,
|
||||
oddRowBackgroundColor: '#f5efe0',
|
||||
rowHoverColor: '#ecdcb4',
|
||||
selectedRowBackgroundColor: '#f0d9a8',
|
||||
borderColor: '#c8b994',
|
||||
rowBorder: { color: '#d8c9a8', width: 1 },
|
||||
columnBorder: { color: '#d8c9a8', width: 1 },
|
||||
cellHorizontalPadding: 10,
|
||||
rowHeight: 32,
|
||||
headerHeight: 34,
|
||||
spacing: 4,
|
||||
accentColor: '#b8410c',
|
||||
iconSize: 12,
|
||||
});
|
||||
|
||||
const badgeCellClass = 'flex items-center';
|
||||
|
||||
type Props = {
|
||||
rows: QSOForm[];
|
||||
total: number;
|
||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||
onRowSelected?: (id: number | null) => void;
|
||||
};
|
||||
|
||||
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
|
||||
|
||||
function fmtMhzDots(hz?: number): string {
|
||||
if (!hz) return '';
|
||||
const mhz = (hz / 1_000_000).toFixed(6);
|
||||
const [i, f] = mhz.split('.');
|
||||
return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`;
|
||||
}
|
||||
|
||||
function fmtDateUTC(s: any): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
if (isNaN(d.getTime())) return s;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||
}
|
||||
function fmtDateOnly(s: any): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
if (isNaN(d.getTime())) return s;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
const bandPill = (p: any) => p.value
|
||||
? <span style={{
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
||||
backgroundColor: '#f0d9a8', color: '#7a4a14', lineHeight: '16px',
|
||||
}}>{p.value}</span>
|
||||
: '';
|
||||
const modePill = (p: any) => p.value
|
||||
? <span style={{
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
||||
backgroundColor: '#d1fae5', color: '#047857', lineHeight: '16px',
|
||||
}}>{p.value}</span>
|
||||
: '';
|
||||
|
||||
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
|
||||
// = shown out of the box; anything else stays hidden until the user toggles
|
||||
// it in the Columns dialog.
|
||||
type ColEntry = ColDef<QSOForm> & { group: string; label: string; defaultVisible?: boolean };
|
||||
|
||||
const COL_CATALOG: ColEntry[] = [
|
||||
// ── QSO basics ──
|
||||
{ group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Date Off', colId: 'qso_date_off', headerName: 'Date off', field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
{ group: 'QSO', label: 'Callsign', colId: 'callsign', headerName: 'Callsign', field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', cellStyle: { color: '#b8410c' }, defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, cellClass: badgeCellClass, cellRenderer: bandPill, defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Band RX', colId: 'band_rx', headerName: 'Band RX', field: 'band_rx' as any, width: 75, cellClass: badgeCellClass, cellRenderer: bandPill },
|
||||
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: badgeCellClass, cellRenderer: modePill, defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Submode', colId: 'submode', headerName: 'Submode', field: 'submode' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'QSO', label: 'MHz (TX)', colId: 'freq_hz', headerName: 'MHz', field: 'freq_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
||||
{ group: 'QSO', label: 'MHz (RX)', colId: 'freq_rx_hz', headerName: 'MHz RX', field: 'freq_rx_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
|
||||
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'TX Power', colId: 'tx_pwr', headerName: 'TX Power', field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
|
||||
// ── Contacted station ──
|
||||
{ group: 'Contacted', label: 'Name', colId: 'name', headerName: 'Name', field: 'name' as any, width: 170, defaultVisible: true },
|
||||
{ group: 'Contacted', label: 'QTH', colId: 'qth', headerName: 'QTH', field: 'qth' as any, width: 200, defaultVisible: true },
|
||||
{ group: 'Contacted', label: 'Address', colId: 'address', headerName: 'Address', field: 'address' as any, width: 200 },
|
||||
{ group: 'Contacted', label: 'Country', colId: 'country', headerName: 'Country', field: 'country' as any, width: 150, defaultVisible: true },
|
||||
{ group: 'Contacted', label: 'State', colId: 'state', headerName: 'State', field: 'state' as any, width: 80 },
|
||||
{ group: 'Contacted', label: 'County', colId: 'cnty', headerName: 'County', field: 'cnty' as any, width: 130 },
|
||||
{ group: 'Contacted', label: 'Continent',colId: 'cont', headerName: 'Cont', field: 'cont' as any, width: 60 },
|
||||
{ group: 'Contacted', label: 'Grid', colId: 'grid', headerName: 'Grid', field: 'grid' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'Contacted', label: 'Grid Ext', colId: 'gridsquare_ext', headerName: 'GridExt', field: 'gridsquare_ext' as any, width: 85, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'VUCC grids',colId: 'vucc_grids', headerName: 'VUCC', field: 'vucc_grids' as any, width: 130, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'DXCC #', colId: 'dxcc', headerName: 'DXCC #', field: 'dxcc' as any, width: 70, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'CQZ', colId: 'cqz', headerName: 'CQZ', field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'ITU', colId: 'ituz', headerName: 'ITU', field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'IOTA', colId: 'iota', headerName: 'IOTA', field: 'iota' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'SOTA ref', colId: 'sota_ref', headerName: 'SOTA', field: 'sota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'POTA ref', colId: 'pota_ref', headerName: 'POTA', field: 'pota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'Age', colId: 'age', headerName: 'Age', field: 'age' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contacted', label: 'Lat', colId: 'lat', headerName: 'Lat', field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'Lon', colId: 'lon', headerName: 'Lon', field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'Email', colId: 'email', headerName: 'Email', field: 'email' as any, width: 180 },
|
||||
{ group: 'Contacted', label: 'Web', colId: 'web', headerName: 'Web', field: 'web' as any, width: 180 },
|
||||
|
||||
// ── QSL ──
|
||||
{ group: 'QSL', label: 'QSL sent', colId: 'qsl_sent', headerName: 'QSL sent', field: 'qsl_sent' as any, width: 80 },
|
||||
{ group: 'QSL', label: 'QSL rcvd', colId: 'qsl_rcvd', headerName: 'QSL rcvd', field: 'qsl_rcvd' as any, width: 80 },
|
||||
{ group: 'QSL', label: 'QSL sent date',colId: 'qsl_sent_date', headerName: 'QSL S date', field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: 'QSL rcvd date',colId: 'qsl_rcvd_date', headerName: 'QSL R date', field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: 'QSL via', colId: 'qsl_via', headerName: 'QSL via', field: 'qsl_via' as any, width: 130 },
|
||||
{ group: 'QSL', label: 'QSL msg', colId: 'qsl_msg', headerName: 'QSL msg', field: 'qsl_msg' as any, width: 200 },
|
||||
{ group: 'QSL', label: 'QSL msg rcvd', colId: 'qslmsg_rcvd', headerName: 'QSL msg rcvd', field: 'qslmsg_rcvd' as any, width: 200 },
|
||||
|
||||
// ── LoTW ──
|
||||
{ group: 'LoTW', label: 'LoTW sent', colId: 'lotw_sent', headerName: 'LoTW sent', field: 'lotw_sent' as any, width: 80 },
|
||||
{ group: 'LoTW', label: 'LoTW rcvd', colId: 'lotw_rcvd', headerName: 'LoTW rcvd', field: 'lotw_rcvd' as any, width: 80 },
|
||||
{ group: 'LoTW', label: 'LoTW sent date', colId: 'lotw_sent_date', headerName: 'LoTW S date', field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'LoTW', label: 'LoTW rcvd date', colId: 'lotw_rcvd_date', headerName: 'LoTW R date', field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
|
||||
// ── eQSL ──
|
||||
{ group: 'eQSL', label: 'eQSL sent', colId: 'eqsl_sent', headerName: 'eQSL sent', field: 'eqsl_sent' as any, width: 80 },
|
||||
{ group: 'eQSL', label: 'eQSL rcvd', colId: 'eqsl_rcvd', headerName: 'eQSL rcvd', field: 'eqsl_rcvd' as any, width: 80 },
|
||||
{ group: 'eQSL', label: 'eQSL sent date', colId: 'eqsl_sent_date', headerName: 'eQSL S date', field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
|
||||
// ── Uploads ──
|
||||
{ group: 'Uploads', label: 'ClubLog upload date', colId: 'clublog_qso_upload_date', headerName: 'ClubLog date', field: 'clublog_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'ClubLog upload status', colId: 'clublog_qso_upload_status', headerName: 'ClubLog status', field: 'clublog_qso_upload_status' as any, width: 110 },
|
||||
{ group: 'Uploads', label: 'HRDLog upload date', colId: 'hrdlog_qso_upload_date', headerName: 'HRDLog date', field: 'hrdlog_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'HRDLog upload status', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog status', field: 'hrdlog_qso_upload_status' as any, width: 110 },
|
||||
|
||||
// ── Contest ──
|
||||
{ group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 },
|
||||
{ group: 'Contest', label: 'SRX', colId: 'srx', headerName: 'SRX', field: 'srx' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contest', label: 'STX', colId: 'stx', headerName: 'STX', field: 'stx' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contest', label: 'SRX string', colId: 'srx_string', headerName: 'SRX str', field: 'srx_string' as any, width: 100 },
|
||||
{ group: 'Contest', label: 'STX string', colId: 'stx_string', headerName: 'STX str', field: 'stx_string' as any, width: 100 },
|
||||
{ group: 'Contest', label: 'Check', colId: 'check', headerName: 'Check', field: 'check' as any, width: 70 },
|
||||
{ group: 'Contest', label: 'Precedence', colId: 'precedence', headerName: 'Precedence', field: 'precedence' as any, width: 90 },
|
||||
{ group: 'Contest', label: 'ARRL section',colId: 'arrl_sect', headerName: 'ARRL sect', field: 'arrl_sect' as any, width: 90 },
|
||||
|
||||
// ── Propagation / antenna ──
|
||||
{ group: 'Propagation', label: 'Prop mode', colId: 'prop_mode', headerName: 'Prop', field: 'prop_mode' as any, width: 80 },
|
||||
{ group: 'Propagation', label: 'Sat name', colId: 'sat_name', headerName: 'Sat', field: 'sat_name' as any, width: 110 },
|
||||
{ group: 'Propagation', label: 'Sat mode', colId: 'sat_mode', headerName: 'Sat mode', field: 'sat_mode' as any, width: 80 },
|
||||
{ group: 'Propagation', label: 'Ant az', colId: 'ant_az', headerName: 'Az', field: 'ant_az' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'Propagation', label: 'Ant el', colId: 'ant_el', headerName: 'El', field: 'ant_el' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'Propagation', label: 'Ant path', colId: 'ant_path', headerName: 'Path', field: 'ant_path' as any, width: 70 },
|
||||
{ group: 'Propagation', label: 'Rig', colId: 'rig', headerName: 'Rig', field: 'rig' as any, width: 120 },
|
||||
{ group: 'Propagation', label: 'Antenna', colId: 'ant', headerName: 'Antenna', field: 'ant' as any, width: 140 },
|
||||
|
||||
// ── My station (operator side) ──
|
||||
{ group: 'My station', label: 'Station call', colId: 'station_callsign', headerName: 'Station', field: 'station_callsign' as any, width: 100, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'My station', label: 'Operator', colId: 'operator', headerName: 'Operator',field: 'operator' as any, width: 100, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My grid', colId: 'my_grid', headerName: 'My grid', field: 'my_grid' as any, width: 85, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My country', colId: 'my_country', headerName: 'My ctry', field: 'my_country' as any, width: 130 },
|
||||
{ group: 'My station', label: 'My state', colId: 'my_state', headerName: 'My state',field: 'my_state' as any, width: 80 },
|
||||
{ group: 'My station', label: 'My county', colId: 'my_cnty', headerName: 'My cnty', field: 'my_cnty' as any, width: 110 },
|
||||
{ group: 'My station', label: 'My IOTA', colId: 'my_iota', headerName: 'My IOTA', field: 'my_iota' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My SOTA', colId: 'my_sota_ref', headerName: 'My SOTA', field: 'my_sota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My POTA', colId: 'my_pota_ref', headerName: 'My POTA', field: 'my_pota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My DXCC', colId: 'my_dxcc', headerName: 'My DXCC#',field: 'my_dxcc' as any, width: 80, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My CQ zone', colId: 'my_cq_zone', headerName: 'My CQZ', field: 'my_cq_zone' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My ITU zone', colId: 'my_itu_zone', headerName: 'My ITU', field: 'my_itu_zone' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My lat', colId: 'my_lat', headerName: 'My lat', field: 'my_lat' as any, width: 90, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My lon', colId: 'my_lon', headerName: 'My lon', field: 'my_lon' as any, width: 90, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My street', colId: 'my_street', headerName: 'Street', field: 'my_street' as any, width: 160 },
|
||||
{ group: 'My station', label: 'My city', colId: 'my_city', headerName: 'City', field: 'my_city' as any, width: 130 },
|
||||
{ group: 'My station', label: 'My ZIP', colId: 'my_postal_code', headerName: 'ZIP', field: 'my_postal_code' as any, width: 80 },
|
||||
{ group: 'My station', label: 'My rig', colId: 'my_rig', headerName: 'My rig', field: 'my_rig' as any, width: 130 },
|
||||
{ group: 'My station', label: 'My antenna', colId: 'my_antenna', headerName: 'My ant', field: 'my_antenna' as any, width: 130 },
|
||||
|
||||
// ── Misc ──
|
||||
{ group: 'Misc', label: 'Comment', colId: 'comment', headerName: 'Comment', field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
|
||||
{ group: 'Misc', label: 'Notes', colId: 'notes', headerName: 'Notes', field: 'notes' as any, width: 240 },
|
||||
{ group: 'Misc', label: 'Created', colId: 'created_at', headerName: 'Created at', field: 'created_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
{ group: 'Misc', label: 'Updated', colId: 'updated_at', headerName: 'Updated at', field: 'updated_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
];
|
||||
|
||||
const GROUP_ORDER = [
|
||||
'QSO', 'Contacted', 'QSL', 'LoTW', 'eQSL', 'Uploads',
|
||||
'Contest', 'Propagation', 'My station', 'Misc',
|
||||
];
|
||||
|
||||
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Props) {
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
// Compute initial column defs: all columns defined, but those not marked
|
||||
// defaultVisible start hidden. The user's saved state (loaded onGridReady)
|
||||
// overrides this so a previously toggled column wins.
|
||||
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => COL_CATALOG.map((c) => {
|
||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||
return { ...rest, hide: !defaultVisible };
|
||||
}), []);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
filter: true,
|
||||
suppressMovable: false,
|
||||
}), []);
|
||||
|
||||
function onGridReady(e: GridReadyEvent) {
|
||||
try {
|
||||
const raw = localStorage.getItem(COL_STATE_KEY);
|
||||
if (raw) {
|
||||
const state = JSON.parse(raw) as ColumnState[];
|
||||
if (Array.isArray(state)) {
|
||||
e.api.applyColumnState({ state, applyOrder: true });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const saveColumnState = useCallback(() => {
|
||||
try {
|
||||
const state = gridRef.current?.api?.getColumnState();
|
||||
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||
}
|
||||
function onSelectionChanged() {
|
||||
const sel = gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined;
|
||||
onRowSelected?.(sel && sel[0] ? (sel[0].id as number) : null);
|
||||
}
|
||||
|
||||
// ── Column picker (visibility) ──
|
||||
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
|
||||
// state for "which columns are visible" — AG Grid's column state is the
|
||||
// source of truth, and saveColumnState persists it.
|
||||
function isColVisible(colId: string): boolean {
|
||||
const col = gridRef.current?.api?.getColumn(colId);
|
||||
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
|
||||
}
|
||||
function setColVisible(colId: string, visible: boolean) {
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
api.setColumnsVisible([colId], visible);
|
||||
saveColumnState();
|
||||
}
|
||||
function showAll(group?: string) {
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!);
|
||||
api.setColumnsVisible(ids, true);
|
||||
saveColumnState();
|
||||
}
|
||||
function hideAll(group?: string) {
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!);
|
||||
api.setColumnsVisible(ids, false);
|
||||
saveColumnState();
|
||||
}
|
||||
function resetDefaults() {
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
const visible = COL_CATALOG.filter((c) => c.defaultVisible).map((c) => c.colId!);
|
||||
const hidden = COL_CATALOG.filter((c) => !c.defaultVisible).map((c) => c.colId!);
|
||||
api.setColumnsVisible(visible, true);
|
||||
api.setColumnsVisible(hidden, false);
|
||||
saveColumnState();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
||||
<Columns3 className="size-3.5" /> Columns
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<AgGridReact<QSOForm>
|
||||
ref={gridRef}
|
||||
theme={hamlogTheme}
|
||||
rowData={rows}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColDef}
|
||||
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
|
||||
onGridReady={onGridReady}
|
||||
onColumnResized={saveColumnState}
|
||||
onColumnMoved={saveColumnState}
|
||||
onColumnPinned={saveColumnState}
|
||||
onColumnVisible={saveColumnState}
|
||||
onSortChanged={saveColumnState}
|
||||
onRowDoubleClicked={handleRowDoubleClicked}
|
||||
onSelectionChanged={onSelectionChanged}
|
||||
animateRows={false}
|
||||
suppressCellFocus
|
||||
getRowId={(p) => String((p.data as any).id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Columns</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick the columns you want visible in the Recent QSOs table.
|
||||
Your selection is saved.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto py-2">
|
||||
{GROUP_ORDER.map((group) => {
|
||||
const cols = COL_CATALOG.filter((c) => c.group === group);
|
||||
if (cols.length === 0) return null;
|
||||
return (
|
||||
<div key={group} className="rounded-md border border-border p-2.5">
|
||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>none</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{cols.map((c) => (
|
||||
<label key={c.colId} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
|
||||
<Checkbox
|
||||
checked={isColVisible(c.colId!)}
|
||||
onCheckedChange={(v) => setColVisible(c.colId!, !!v)}
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ArrowDown, ArrowUp, Copy, Plus, Star, StarOff, Trash2,
|
||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||
ChevronDown, ChevronRight,
|
||||
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
||||
Compass, Wifi, Construction,
|
||||
@@ -15,11 +15,12 @@ import {
|
||||
GetClusterAutoConnect, SetClusterAutoConnect,
|
||||
ConnectClusterServer, DisconnectClusterServer,
|
||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
import type { main as mainModels, cluster as clusterModels } from '../../wailsjs/go/models';
|
||||
import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { OperatingPanel } from '@/components/OperatingPanel';
|
||||
|
||||
type LookupSettings = LookupSettingsForm;
|
||||
type StationSettings = StationSettingsForm;
|
||||
@@ -44,6 +46,55 @@ type ClusterServer = Omit<clusterModels.ServerConfig, 'convertValues'>;
|
||||
type ClusterServerStatus = Omit<clusterModels.ServerStatus, 'convertValues'>;
|
||||
type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
||||
|
||||
// Catalog of all standard ADIF bands, in natural frequency order. The user
|
||||
// picks a subset on the right; everything else in the UI (entry strip,
|
||||
// band-slot grid, band-map switcher) iterates that subset.
|
||||
const BAND_CATALOG = [
|
||||
'2190m','630m','560m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m',
|
||||
'8m','6m','5m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm',
|
||||
'6mm','4mm','2.5mm','2mm','1mm',
|
||||
];
|
||||
|
||||
// Catalog of common ADIF modes with sensible RST defaults. When the user
|
||||
// picks one on the right, the RSTs are pre-filled but stay editable.
|
||||
const MODE_CATALOG: Array<{ name: string; sent: string; rcvd: string }> = [
|
||||
{ name: 'SSB', sent: '59', rcvd: '59' },
|
||||
{ name: 'CW', sent: '599', rcvd: '599' },
|
||||
{ name: 'AM', sent: '59', rcvd: '59' },
|
||||
{ name: 'FM', sent: '59', rcvd: '59' },
|
||||
{ name: 'DIGITALVOICE', sent: '59', rcvd: '59' },
|
||||
{ name: 'FT8', sent: '-10', rcvd: '-10' },
|
||||
{ name: 'FT4', sent: '-10', rcvd: '-10' },
|
||||
{ name: 'JS8', sent: '-10', rcvd: '-10' },
|
||||
{ name: 'MSK144', sent: '+00', rcvd: '+00' },
|
||||
{ name: 'JT65', sent: '-15', rcvd: '-15' },
|
||||
{ name: 'JT9', sent: '-15', rcvd: '-15' },
|
||||
{ name: 'Q65', sent: '-15', rcvd: '-15' },
|
||||
{ name: 'FST4', sent: '-15', rcvd: '-15' },
|
||||
{ name: 'FST4W', sent: '-15', rcvd: '-15' },
|
||||
{ name: 'WSPR', sent: '-20', rcvd: '-20' },
|
||||
{ name: 'RTTY', sent: '599', rcvd: '599' },
|
||||
{ name: 'PSK31', sent: '599', rcvd: '599' },
|
||||
{ name: 'PSK63', sent: '599', rcvd: '599' },
|
||||
{ name: 'PSK125', sent: '599', rcvd: '599' },
|
||||
{ name: 'OLIVIA', sent: '599', rcvd: '599' },
|
||||
{ name: 'CONTESTI', sent: '599', rcvd: '599' },
|
||||
{ name: 'MFSK', sent: '599', rcvd: '599' },
|
||||
{ name: 'THROB', sent: '599', rcvd: '599' },
|
||||
{ name: 'HELL', sent: '599', rcvd: '599' },
|
||||
{ name: 'PACKET', sent: '599', rcvd: '599' },
|
||||
{ name: 'PACTOR', sent: '599', rcvd: '599' },
|
||||
{ name: 'VARA', sent: '599', rcvd: '599' },
|
||||
{ name: 'VARA HF', sent: '599', rcvd: '599' },
|
||||
{ name: 'ARDOP', sent: '599', rcvd: '599' },
|
||||
{ name: 'ATV', sent: '59', rcvd: '59' },
|
||||
{ name: 'SSTV', sent: '59', rcvd: '59' },
|
||||
{ name: 'C4FM', sent: '59', rcvd: '59' },
|
||||
{ name: 'DSTAR', sent: '59', rcvd: '59' },
|
||||
{ name: 'DMR', sent: '59', rcvd: '59' },
|
||||
{ name: 'FUSION', sent: '59', rcvd: '59' },
|
||||
];
|
||||
|
||||
const emptyProfile = (): Profile => ({
|
||||
id: 0,
|
||||
name: '',
|
||||
@@ -72,6 +123,7 @@ interface Props {
|
||||
type SectionId =
|
||||
| 'station'
|
||||
| 'profiles'
|
||||
| 'operating'
|
||||
| 'lookup'
|
||||
| 'lists-bands'
|
||||
| 'lists-modes'
|
||||
@@ -92,6 +144,7 @@ const TREE: TreeNode[] = [
|
||||
kind: 'group', label: 'User Configuration', icon: User, defaultOpen: true, children: [
|
||||
{ kind: 'item', label: 'Station Information', id: 'station' },
|
||||
{ kind: 'item', label: 'Profiles (portable, home, contest)', id: 'profiles' },
|
||||
{ kind: 'item', label: 'Operating conditions (rigs & antennas)', id: 'operating' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -102,7 +155,7 @@ const TREE: TreeNode[] = [
|
||||
{ kind: 'item', label: 'Modes & default RST', id: 'lists-modes' },
|
||||
]},
|
||||
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
|
||||
{ kind: 'item', label: 'Backup / Export', id: 'backup', disabled: true },
|
||||
{ kind: 'item', label: 'Database backup', id: 'backup' },
|
||||
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
|
||||
],
|
||||
},
|
||||
@@ -120,11 +173,12 @@ const TREE: TreeNode[] = [
|
||||
const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
||||
station: 'Station Information',
|
||||
profiles: 'Profiles',
|
||||
operating: 'Operating conditions',
|
||||
lookup: 'Callsign Lookup',
|
||||
'lists-bands': 'Bands',
|
||||
'lists-modes': 'Modes & default RST',
|
||||
cluster: 'DX Cluster',
|
||||
backup: 'Backup / Export',
|
||||
backup: 'Database backup',
|
||||
awards: 'Awards',
|
||||
cat: 'CAT interface',
|
||||
rotator: 'Rotator',
|
||||
@@ -248,7 +302,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
const updateActive = (patch: Partial<Profile>) =>
|
||||
setActiveProfile((p) => (p ? { ...p, ...patch } : p));
|
||||
const [lists, setLists] = useState<ListsSettings>({ bands: [], modes: [] });
|
||||
const [bandsText, setBandsText] = useState('');
|
||||
// Custom band drafts (catalog covers ADIF spec but the user may have
|
||||
// exotic or experimental bands not listed).
|
||||
const [bandDraft, setBandDraft] = useState('');
|
||||
const [modeDraft, setModeDraft] = useState('');
|
||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, poll_ms: 250, delay_ms: 0,
|
||||
digital_default: 'FT8',
|
||||
@@ -259,6 +316,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
const [rotatorTesting, setRotatorTesting] = useState(false);
|
||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
const [backupCfg, setBackupCfg] = useState<mainModels.BackupSettings>({
|
||||
enabled: false, folder: '', rotation: 5, zip: false,
|
||||
last_backup_at: '', default_folder: '',
|
||||
} as any);
|
||||
const [backupRunning, setBackupRunning] = useState(false);
|
||||
const [backupResult, setBackupResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
|
||||
const [clusterAutoConnect, setClusterAutoConnectState] = useState(false);
|
||||
const [clusterStatuses, setClusterStatuses] = useState<ClusterServerStatus[]>([]);
|
||||
@@ -281,14 +345,14 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
// click Connect/Disconnect inside the modal and see the pills change
|
||||
// without saving + reopening.
|
||||
useEffect(() => {
|
||||
EventsOn('cluster:state', async (st: any) => {
|
||||
const unsub = EventsOn('cluster:state', async (st: any) => {
|
||||
setClusterStatuses((st ?? []) as ClusterServerStatus[]);
|
||||
try {
|
||||
const list = await ListClusterServers();
|
||||
setClusterServers((list ?? []) as ClusterServer[]);
|
||||
} catch {}
|
||||
});
|
||||
return () => { EventsOff('cluster:state'); };
|
||||
return () => { unsub?.(); };
|
||||
}, []);
|
||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||
// State for ProfilesPanel — lifted here because PANELS[selected]() calls
|
||||
@@ -325,18 +389,18 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [l, ls, c, ap, r] = await Promise.all([
|
||||
const [l, ls, c, ap, r, b] = await Promise.all([
|
||||
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
|
||||
GetRotatorSettings(),
|
||||
GetRotatorSettings(), GetBackupSettings(),
|
||||
]);
|
||||
setLookup(l);
|
||||
setActiveProfile(ap as Profile);
|
||||
setLists(ls);
|
||||
await reloadProfiles();
|
||||
await reloadClusterServers();
|
||||
setBandsText((ls.bands ?? []).join('\n'));
|
||||
setCatCfg(c);
|
||||
setRotator(r);
|
||||
setBackupCfg(b as any);
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
} finally {
|
||||
@@ -345,12 +409,59 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// ── Band selection helpers (dual-list shuttle) ──────────────────────────
|
||||
function addBand(tag: string) {
|
||||
const b = tag.trim().toLowerCase();
|
||||
if (!b) return;
|
||||
setLists((l) => {
|
||||
if ((l.bands ?? []).includes(b)) return l;
|
||||
return { ...l, bands: [...(l.bands ?? []), b] };
|
||||
});
|
||||
}
|
||||
function removeBand(i: number) {
|
||||
setLists((l) => {
|
||||
const next = [...(l.bands ?? [])];
|
||||
next.splice(i, 1);
|
||||
return { ...l, bands: next };
|
||||
});
|
||||
}
|
||||
function moveBand(i: number, dir: -1 | 1) {
|
||||
setLists((l) => {
|
||||
const next = [...(l.bands ?? [])];
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= next.length) return l;
|
||||
[next[i], next[j]] = [next[j], next[i]];
|
||||
return { ...l, bands: next };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mode helpers ────────────────────────────────────────────────────────
|
||||
function addMode() {
|
||||
setLists((l) => ({
|
||||
...l,
|
||||
modes: [...(l.modes ?? []), { name: '', default_rst_sent: '59', default_rst_rcvd: '59' }],
|
||||
}));
|
||||
}
|
||||
function addModeFromCatalog(m: { name: string; sent: string; rcvd: string }) {
|
||||
setLists((l) => {
|
||||
if ((l.modes ?? []).some((x) => (x.name ?? '').toUpperCase() === m.name)) return l;
|
||||
return {
|
||||
...l,
|
||||
modes: [...(l.modes ?? []), { name: m.name, default_rst_sent: m.sent, default_rst_rcvd: m.rcvd }],
|
||||
};
|
||||
});
|
||||
}
|
||||
function addCustomMode(name: string) {
|
||||
const n = name.trim().toUpperCase();
|
||||
if (!n) return;
|
||||
setLists((l) => {
|
||||
if ((l.modes ?? []).some((x) => (x.name ?? '').toUpperCase() === n)) return l;
|
||||
return {
|
||||
...l,
|
||||
modes: [...(l.modes ?? []), { name: n, default_rst_sent: '59', default_rst_rcvd: '59' }],
|
||||
};
|
||||
});
|
||||
}
|
||||
function removeMode(i: number) {
|
||||
setLists((l) => {
|
||||
const next = [...(l.modes ?? [])];
|
||||
@@ -378,11 +489,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
async function save() {
|
||||
setSaving(true); setErr(''); setMsg('');
|
||||
try {
|
||||
// Bands: dedup, lowercase, trim.
|
||||
// Bands: dedup, lowercase, trim. Order = user's drag order.
|
||||
const seen = new Set<string>();
|
||||
const bands: string[] = [];
|
||||
for (const line of bandsText.split('\n')) {
|
||||
const b = line.trim().toLowerCase();
|
||||
for (const raw of lists.bands ?? []) {
|
||||
const b = (raw ?? '').trim().toLowerCase();
|
||||
if (b && !seen.has(b)) { seen.add(b); bands.push(b); }
|
||||
}
|
||||
const modes = (lists.modes ?? [])
|
||||
@@ -407,6 +518,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
await SaveLookupSettings(lookup as any);
|
||||
await SaveCATSettings(catCfg as any);
|
||||
await SaveRotatorSettings(rotator as any);
|
||||
await SaveBackupSettings(backupCfg as any);
|
||||
await SetClusterAutoConnect(clusterAutoConnect);
|
||||
|
||||
setMsg('Settings saved.');
|
||||
@@ -506,23 +618,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
<Label>POTA ref</Label>
|
||||
<Input className="font-mono uppercase" value={p.my_pota_ref ?? ''} onChange={(e) => updateActive({ my_pota_ref: e.target.value })} placeholder="FF-1234" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Rig</Label>
|
||||
<Input value={p.my_rig ?? ''} onChange={(e) => updateActive({ my_rig: e.target.value })} placeholder="Flex 8600" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Antenna</Label>
|
||||
<Input value={p.my_antenna ?? ''} onChange={(e) => updateActive({ my_antenna: e.target.value })} placeholder="Ultrabeam UB40" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>TX power (W)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={p.tx_pwr ?? ''}
|
||||
onChange={(e) => updateActive({ tx_pwr: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) })}
|
||||
placeholder="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -796,41 +891,192 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
}
|
||||
|
||||
function BandsPanel() {
|
||||
const selected = lists.bands ?? [];
|
||||
const selectedSet = new Set(selected.map((b) => (b ?? '').toLowerCase()));
|
||||
const available = BAND_CATALOG.filter((b) => !selectedSet.has(b.toLowerCase()));
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="Bands" hint="One ADIF band per line (e.g. 20m, 2m, 70cm). Order = display order in the entry form and the band-slot grid." />
|
||||
<Textarea
|
||||
className="font-mono min-h-[260px] max-w-md"
|
||||
value={bandsText}
|
||||
onChange={(e) => setBandsText(e.target.value)}
|
||||
<SectionHeader
|
||||
title="Bands"
|
||||
hint="Pick the bands you actually use. The entry strip, the band-slot grid and the band-map switcher only show what's on the right. Order on the right = display order."
|
||||
/>
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] gap-3 max-w-3xl">
|
||||
{/* Left: available catalog */}
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<div className="px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
|
||||
Available
|
||||
</div>
|
||||
<div className="max-h-[320px] overflow-y-auto divide-y divide-border">
|
||||
{available.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground italic">All catalog bands selected.</div>
|
||||
) : (
|
||||
available.map((b) => (
|
||||
<button
|
||||
key={b}
|
||||
type="button"
|
||||
onDoubleClick={() => addBand(b)}
|
||||
onClick={() => addBand(b)}
|
||||
className="w-full text-left px-3 py-1.5 text-sm font-mono hover:bg-accent/40 transition-colors"
|
||||
>
|
||||
{b}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 px-2 py-1.5 border-t border-border bg-muted/40">
|
||||
<Input
|
||||
value={bandDraft}
|
||||
onChange={(e) => setBandDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
addBand(bandDraft);
|
||||
setBandDraft('');
|
||||
}
|
||||
}}
|
||||
placeholder="Custom band (e.g. 4m)"
|
||||
className="font-mono h-7 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7"
|
||||
onClick={() => { addBand(bandDraft); setBandDraft(''); }}
|
||||
disabled={!bandDraft.trim()}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: shuttle hint */}
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<ArrowRight className="size-4" />
|
||||
<ArrowLeft className="size-4" />
|
||||
</div>
|
||||
|
||||
{/* Right: selected */}
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<div className="px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground flex items-center justify-between">
|
||||
<span>Selected ({selected.length})</span>
|
||||
</div>
|
||||
<div className="max-h-[360px] overflow-y-auto divide-y divide-border">
|
||||
{selected.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground italic">
|
||||
No band selected — pick from the left.
|
||||
</div>
|
||||
) : (
|
||||
selected.map((b, i) => (
|
||||
<div key={`${b}-${i}`} className="grid grid-cols-[auto_1fr_auto] items-center gap-1 px-2 py-1">
|
||||
<div className="flex gap-0.5">
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveBand(i, -1)} disabled={i === 0}>
|
||||
<ArrowUp className="size-3" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveBand(i, 1)} disabled={i === selected.length - 1}>
|
||||
<ArrowDown className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="font-mono text-sm">{b}</span>
|
||||
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={() => removeBand(i)}>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModesPanel() {
|
||||
const selected = lists.modes ?? [];
|
||||
const selectedSet = new Set(selected.map((m) => (m.name ?? '').toUpperCase()));
|
||||
const available = MODE_CATALOG.filter((m) => !selectedSet.has(m.name));
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="Modes & default RST"
|
||||
hint="When you pick a mode in the entry form, RST sent/rcvd auto-fill with these defaults (unless you've typed something)."
|
||||
hint="Pick the modes you actually use on the right. Anywhere the UI shows a mode picker, it iterates the right column. When you select a mode in the entry form, RST sent/rcvd auto-fill with the defaults below (unless you've typed something)."
|
||||
/>
|
||||
<div className="rounded-md border border-border overflow-hidden max-w-2xl">
|
||||
<div className="grid grid-cols-[auto_1fr_1fr_1fr_auto] gap-2 px-3 py-2 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
|
||||
<span className="w-12">Order</span>
|
||||
<span>Mode (ADIF)</span>
|
||||
<span>RST sent</span>
|
||||
<span>RST rcvd</span>
|
||||
<span className="w-8"></span>
|
||||
<div className="grid grid-cols-[1fr_auto_1.5fr] gap-3 max-w-4xl">
|
||||
{/* Left: available catalog */}
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<div className="px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
|
||||
Available
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{(lists.modes ?? []).map((m, i) => (
|
||||
<div key={i} className="grid grid-cols-[auto_1fr_1fr_1fr_auto] gap-2 px-3 py-1.5 items-center">
|
||||
<div className="max-h-[360px] overflow-y-auto divide-y divide-border">
|
||||
{available.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground italic">All catalog modes selected.</div>
|
||||
) : (
|
||||
available.map((m) => (
|
||||
<button
|
||||
key={m.name}
|
||||
type="button"
|
||||
onDoubleClick={() => addModeFromCatalog(m)}
|
||||
onClick={() => addModeFromCatalog(m)}
|
||||
className="w-full text-left px-3 py-1.5 text-sm font-mono hover:bg-accent/40 transition-colors flex items-center justify-between gap-2"
|
||||
title={`Default RST: ${m.sent} / ${m.rcvd}`}
|
||||
>
|
||||
<span>{m.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{m.sent}/{m.rcvd}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 px-2 py-1.5 border-t border-border bg-muted/40">
|
||||
<Input
|
||||
value={modeDraft}
|
||||
onChange={(e) => setModeDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
addCustomMode(modeDraft);
|
||||
setModeDraft('');
|
||||
}
|
||||
}}
|
||||
placeholder="Custom mode"
|
||||
className="font-mono uppercase h-7 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7"
|
||||
onClick={() => { addCustomMode(modeDraft); setModeDraft(''); }}
|
||||
disabled={!modeDraft.trim()}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: shuttle hint */}
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<ArrowRight className="size-4" />
|
||||
<ArrowLeft className="size-4" />
|
||||
</div>
|
||||
|
||||
{/* Right: selected with editable RST */}
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<div className="grid grid-cols-[auto_1fr_72px_72px_auto] gap-2 px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
|
||||
<span className="w-12">Order</span>
|
||||
<span>Mode</span>
|
||||
<span>RST snt</span>
|
||||
<span>RST rcv</span>
|
||||
<span className="w-6"></span>
|
||||
</div>
|
||||
<div className="max-h-[360px] overflow-y-auto divide-y divide-border">
|
||||
{selected.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground italic">
|
||||
No mode selected — pick from the left.
|
||||
</div>
|
||||
) : (
|
||||
selected.map((m, i) => (
|
||||
<div key={i} className="grid grid-cols-[auto_1fr_72px_72px_auto] gap-2 px-3 py-1 items-center">
|
||||
<div className="flex gap-0.5 w-12">
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, -1)} disabled={i === 0}>
|
||||
<ArrowUp className="size-3" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, 1)} disabled={i === (lists.modes?.length ?? 0) - 1}>
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, 1)} disabled={i === selected.length - 1}>
|
||||
<ArrowDown className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -841,12 +1087,16 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={addMode} className="mt-3">
|
||||
<Plus className="size-3.5" /> Add mode
|
||||
<div className="px-3 py-1.5 border-t border-border bg-muted/40">
|
||||
<Button variant="ghost" size="sm" onClick={addMode} className="h-6 text-xs">
|
||||
<Plus className="size-3" /> Add blank row
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1152,15 +1402,150 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function OperatingPanelWrapper() {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="Operating conditions"
|
||||
hint="Define your rigs and the antennas you use on each band. The entry strip will auto-fill MY_RIG and MY_ANTENNA based on the default antenna for the band you're operating on."
|
||||
/>
|
||||
<OperatingPanel
|
||||
bands={lists.bands ?? []}
|
||||
onError={(m) => setErr(m)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BackupPanel() {
|
||||
const fmtLast = (iso: string) => {
|
||||
if (!iso) return 'never';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())} UTC`;
|
||||
};
|
||||
const effectiveFolder = (backupCfg.folder || backupCfg.default_folder || '').replace(/\\/g, '/');
|
||||
async function backupNow() {
|
||||
setBackupRunning(true); setBackupResult(null);
|
||||
try {
|
||||
// Save current draft first so the backup runs with the values
|
||||
// the user just typed (folder, rotation, zip) — otherwise the
|
||||
// backend would use stale persisted config.
|
||||
await SaveBackupSettings(backupCfg as any);
|
||||
const path = await RunBackupNow();
|
||||
setBackupResult({ ok: true, msg: 'Backup written to ' + path });
|
||||
const refreshed = await GetBackupSettings();
|
||||
setBackupCfg(refreshed as any);
|
||||
} catch (e: any) {
|
||||
setBackupResult({ ok: false, msg: String(e?.message ?? e) });
|
||||
} finally { setBackupRunning(false); }
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="Database backup"
|
||||
hint="HamLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones."
|
||||
/>
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={!!backupCfg.enabled}
|
||||
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, enabled: !!c }))}
|
||||
/>
|
||||
<span>Automatic backup when closing HamLog (max once per day)</span>
|
||||
</label>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Backup folder</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="font-mono text-xs flex-1"
|
||||
placeholder={backupCfg.default_folder || 'leave empty for default'}
|
||||
value={backupCfg.folder ?? ''}
|
||||
onChange={(e) => setBackupCfg((b) => ({ ...b, folder: e.target.value }))}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const p = await PickBackupFolder();
|
||||
if (p) setBackupCfg((b) => ({ ...b, folder: p }));
|
||||
} catch (e: any) {
|
||||
setBackupResult({ ok: false, msg: String(e?.message ?? e) });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Browse…
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{backupCfg.folder
|
||||
? <>Effective folder: <span className="font-mono">{effectiveFolder}</span></>
|
||||
: <>If empty, HamLog uses the default: <span className="font-mono">{(backupCfg.default_folder ?? '').replace(/\\/g, '/')}</span></>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Rotation (copies to keep)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
className="w-24 font-mono text-xs"
|
||||
value={backupCfg.rotation || 5}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
if (Number.isFinite(n) && n > 0) setBackupCfg((b) => ({ ...b, rotation: Math.floor(n) }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer pb-2">
|
||||
<Checkbox
|
||||
checked={!!backupCfg.zip}
|
||||
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, zip: !!c }))}
|
||||
/>
|
||||
<span>ZIP backup (smaller file)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3 flex items-center gap-3">
|
||||
<Button size="sm" onClick={backupNow} disabled={backupRunning}>
|
||||
{backupRunning ? 'Backing up…' : 'Backup now'}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Last backup: <strong className="text-foreground">{fmtLast(backupCfg.last_backup_at)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{backupResult && (
|
||||
<div className={cn(
|
||||
'text-xs px-3 py-2 rounded-md border',
|
||||
backupResult.ok
|
||||
? 'bg-emerald-50 border-emerald-300 text-emerald-800'
|
||||
: 'bg-rose-50 border-rose-300 text-rose-800',
|
||||
)}>
|
||||
{backupResult.msg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Map sections to their content + icon (for placeholder).
|
||||
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||||
station: StationPanel,
|
||||
profiles: ProfilesPanel,
|
||||
operating: OperatingPanelWrapper,
|
||||
lookup: LookupPanel,
|
||||
'lists-bands': BandsPanel,
|
||||
'lists-modes': ModesPanel,
|
||||
cluster: ClusterPanel,
|
||||
backup: () => <ComingSoon id="backup" icon={Database} />,
|
||||
backup: BackupPanel,
|
||||
awards: () => <ComingSoon id="awards" icon={Award} />,
|
||||
cat: CATPanel,
|
||||
rotator: RotatorPanel,
|
||||
@@ -1170,7 +1555,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-[960px] w-full max-h-[90vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogContent className="max-w-[1180px] w-full max-h-[90vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Preferences</DialogTitle>
|
||||
<DialogDescription className="sr-only">Configure HamLog modules — station, lookup, hardware…</DialogDescription>
|
||||
@@ -1179,7 +1564,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
{loading ? (
|
||||
<div className="p-6 text-muted-foreground">Loading…</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[260px_1fr] min-h-0 overflow-hidden">
|
||||
<div className="grid grid-cols-[320px_1fr] min-h-0 overflow-hidden">
|
||||
{/* Left sidebar tree */}
|
||||
<aside className="border-r border-border bg-muted/30 overflow-y-auto p-2">
|
||||
<Tree selected={selected} onSelect={setSelected} />
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
|
||||
type Step = {
|
||||
id: string;
|
||||
label: string;
|
||||
status: 'pending' | 'running' | 'done' | 'error';
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
// ShutdownProgress is a full-screen overlay that appears while HamLog is
|
||||
// running its close-time tasks (backup, future LoTW upload, ...). It
|
||||
// listens for `shutdown:start` / `shutdown:update` / `shutdown:done`
|
||||
// events from the backend and renders a checklist that updates as each
|
||||
// task completes. The backend triggers wruntime.Quit() once everything
|
||||
// is finished, so this component never has to dismiss itself.
|
||||
export function ShutdownProgress() {
|
||||
const [steps, setSteps] = useState<Step[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const u1 = EventsOn('shutdown:start', (s: Step[]) => setSteps(s ?? []));
|
||||
const u2 = EventsOn('shutdown:update', (s: Step[]) => setSteps(s ?? []));
|
||||
const u3 = EventsOn('shutdown:done', (s: Step[]) => setSteps(s ?? []));
|
||||
return () => { u1?.(); u2?.(); u3?.(); };
|
||||
}, []);
|
||||
|
||||
if (!steps) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-card border border-border rounded-lg shadow-xl p-6 min-w-[360px] max-w-[480px]">
|
||||
<div className="text-sm font-semibold mb-3 text-foreground">Closing HamLog…</div>
|
||||
<div className="space-y-2">
|
||||
{steps.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground italic">Nothing to do, exiting.</div>
|
||||
) : steps.map((s) => (
|
||||
<div key={s.id} className="flex items-start gap-2 text-sm">
|
||||
<div className="mt-0.5 w-4 flex items-center justify-center">
|
||||
{s.status === 'done' && <CheckCircle2 className="size-4 text-emerald-600" />}
|
||||
{s.status === 'running' && <Loader2 className="size-4 animate-spin text-primary" />}
|
||||
{s.status === 'error' && <XCircle className="size-4 text-rose-600" />}
|
||||
{s.status === 'pending' && <span className="size-2 rounded-full bg-muted-foreground/40" />}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className={
|
||||
s.status === 'done' ? 'text-foreground'
|
||||
: s.status === 'error' ? 'text-rose-700 font-medium'
|
||||
: s.status === 'pending' ? 'text-muted-foreground'
|
||||
: 'text-foreground font-medium'
|
||||
}>
|
||||
{s.label}
|
||||
</div>
|
||||
{s.detail && (
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5 font-mono break-all">
|
||||
{s.detail}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||||
type ColDef, type ColumnState, type GridReadyEvent,
|
||||
} from 'ag-grid-community';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Columns3, Star } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { WorkedBeforeView } from '@/types';
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
const hamlogTheme = themeQuartz.withParams({
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12.5,
|
||||
backgroundColor: '#faf6ea',
|
||||
foregroundColor: '#2a2419',
|
||||
headerBackgroundColor: '#e8dfc9',
|
||||
headerTextColor: '#5a4f3a',
|
||||
headerFontWeight: 600,
|
||||
oddRowBackgroundColor: '#f5efe0',
|
||||
rowHoverColor: '#ecdcb4',
|
||||
selectedRowBackgroundColor: '#f0d9a8',
|
||||
borderColor: '#c8b994',
|
||||
rowBorder: { color: '#d8c9a8', width: 1 },
|
||||
columnBorder: { color: '#d8c9a8', width: 1 },
|
||||
cellHorizontalPadding: 10,
|
||||
rowHeight: 30,
|
||||
headerHeight: 32,
|
||||
spacing: 4,
|
||||
accentColor: '#b8410c',
|
||||
iconSize: 12,
|
||||
});
|
||||
|
||||
type WorkedEntry = NonNullable<WorkedBeforeView['entries']>[number];
|
||||
|
||||
type Props = {
|
||||
wb: WorkedBeforeView | null;
|
||||
busy: boolean;
|
||||
currentCall: string;
|
||||
};
|
||||
|
||||
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v1';
|
||||
|
||||
function fmtDateTime(s: any): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||
}
|
||||
function fmtDate(s: any): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
const bandPill = (p: any) => p.value
|
||||
? <span style={{
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
||||
backgroundColor: '#f0d9a8', color: '#7a4a14', lineHeight: '16px',
|
||||
}}>{p.value}</span>
|
||||
: '';
|
||||
const modePill = (p: any) => p.value
|
||||
? <span style={{
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
||||
backgroundColor: '#d1fae5', color: '#047857', lineHeight: '16px',
|
||||
}}>{p.value}</span>
|
||||
: '';
|
||||
|
||||
// Single Y/N flag column renderer: green dot for "Y", grey dash otherwise.
|
||||
const flagRenderer = (p: any) => {
|
||||
if (p.value === 'Y') {
|
||||
return <span style={{
|
||||
display: 'inline-block', width: 16, height: 16, borderRadius: 4,
|
||||
backgroundColor: '#10b981', color: 'white', textAlign: 'center',
|
||||
fontSize: 10, fontWeight: 700, lineHeight: '16px',
|
||||
}}>Y</span>;
|
||||
}
|
||||
return <span style={{ color: '#a8a29e' }}>—</span>;
|
||||
};
|
||||
|
||||
type ColEntry = ColDef<WorkedEntry> & { group: string; label: string; defaultVisible?: boolean };
|
||||
|
||||
const COL_CATALOG: ColEntry[] = [
|
||||
{ group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateTime(p.value), sort: 'desc', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, cellClass: 'flex items-center', cellRenderer: bandPill, defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'flex items-center', cellRenderer: modePill, defaultVisible: true },
|
||||
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 90, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 90, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSL', label: 'QSL sent', colId: 'qsl_sent', headerName: 'QSL S', field: 'qsl_sent' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
|
||||
{ group: 'QSL', label: 'QSL rcvd', colId: 'qsl_rcvd', headerName: 'QSL R', field: 'qsl_rcvd' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
|
||||
{ group: 'LoTW', label: 'LoTW sent', colId: 'lotw_sent', headerName: 'LoTW S', field: 'lotw_sent' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
|
||||
{ group: 'LoTW', label: 'LoTW rcvd', colId: 'lotw_rcvd', headerName: 'LoTW R', field: 'lotw_rcvd' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
|
||||
];
|
||||
|
||||
const GROUP_ORDER = ['QSO', 'QSL', 'LoTW'];
|
||||
|
||||
export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const hasCall = currentCall.trim() !== '';
|
||||
const count = wb?.count ?? 0;
|
||||
const entries = wb?.entries ?? [];
|
||||
|
||||
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => COL_CATALOG.map((c) => {
|
||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||
return { ...rest, hide: !defaultVisible };
|
||||
}), []);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||
}), []);
|
||||
|
||||
function onGridReady(e: GridReadyEvent) {
|
||||
try {
|
||||
const raw = localStorage.getItem(COL_STATE_KEY);
|
||||
if (raw) {
|
||||
const state = JSON.parse(raw) as ColumnState[];
|
||||
if (Array.isArray(state)) e.api.applyColumnState({ state, applyOrder: true });
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const saveColumnState = useCallback(() => {
|
||||
try {
|
||||
const state = gridRef.current?.api?.getColumnState();
|
||||
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
function isColVisible(colId: string): boolean {
|
||||
const col = gridRef.current?.api?.getColumn(colId);
|
||||
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
|
||||
}
|
||||
function setColVisible(colId: string, visible: boolean) {
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
api.setColumnsVisible([colId], visible);
|
||||
saveColumnState();
|
||||
}
|
||||
function showAll(group?: string) {
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!);
|
||||
api.setColumnsVisible(ids, true);
|
||||
saveColumnState();
|
||||
}
|
||||
function hideAll(group?: string) {
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!);
|
||||
api.setColumnsVisible(ids, false);
|
||||
saveColumnState();
|
||||
}
|
||||
function resetDefaults() {
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
const visible = COL_CATALOG.filter((c) => c.defaultVisible).map((c) => c.colId!);
|
||||
const hidden = COL_CATALOG.filter((c) => !c.defaultVisible).map((c) => c.colId!);
|
||||
api.setColumnsVisible(visible, true);
|
||||
api.setColumnsVisible(hidden, false);
|
||||
saveColumnState();
|
||||
}
|
||||
|
||||
// Empty / loading / no-call states.
|
||||
if (!hasCall) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||||
Type a callsign in the entry strip to see prior contacts.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (busy && count === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground italic">
|
||||
checking…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (count === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||||
<Star className="size-8 text-primary fill-current" />
|
||||
<div className="text-2xl font-bold text-primary tracking-wider">NEW</div>
|
||||
<div>No prior QSO with <span className="font-mono font-semibold text-foreground">{currentCall.toUpperCase()}</span>.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3 px-3 py-1.5 border-b border-border/60 bg-muted/30">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Worked before</span>
|
||||
<span className="font-mono text-sm font-bold text-primary tracking-wider">{currentCall.toUpperCase()}</span>
|
||||
<Badge variant="accent" className="font-mono text-[11px] tracking-wider">{count}×</Badge>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
First: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
|
||||
Last: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
||||
</div>
|
||||
{wb?.dxcc_name && (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
DXCC: <strong className="text-foreground font-semibold">{wb.dxcc_name}</strong>
|
||||
{typeof wb.dxcc_count === 'number' && wb.dxcc_count > 0 && (
|
||||
<span> · {wb.dxcc_count} entity QSOs</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
||||
<Columns3 className="size-3.5" /> Columns
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<AgGridReact<WorkedEntry>
|
||||
ref={gridRef}
|
||||
theme={hamlogTheme}
|
||||
rowData={entries}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColDef}
|
||||
onGridReady={onGridReady}
|
||||
onColumnResized={saveColumnState}
|
||||
onColumnMoved={saveColumnState}
|
||||
onColumnPinned={saveColumnState}
|
||||
onColumnVisible={saveColumnState}
|
||||
onSortChanged={saveColumnState}
|
||||
animateRows={false}
|
||||
suppressCellFocus
|
||||
getRowId={(p) => String((p.data as any).id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{count > entries.length && (
|
||||
<div className="text-center py-1 text-[11px] italic text-muted-foreground border-t border-border/60 bg-muted/30">
|
||||
+ {count - entries.length} older QSOs (not shown — capped for performance)
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Worked-before columns</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick the columns you want visible in the Worked-before table.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[60vh] overflow-y-auto py-2">
|
||||
{GROUP_ORDER.map((group) => {
|
||||
const cols = COL_CATALOG.filter((c) => c.group === group);
|
||||
if (cols.length === 0) return null;
|
||||
return (
|
||||
<div key={group} className="rounded-md border border-border p-2.5 mb-2">
|
||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>none</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{cols.map((c) => (
|
||||
<label key={c.colId} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
|
||||
<Checkbox
|
||||
checked={isColVisible(c.colId!)}
|
||||
onCheckedChange={(v) => setColVisible(c.colId!, !!v)}
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Vendored
+21
@@ -6,6 +6,7 @@ import {profile} from '../models';
|
||||
import {adif} from '../models';
|
||||
import {cat} from '../models';
|
||||
import {cluster} from '../models';
|
||||
import {operating} from '../models';
|
||||
import {lookup} from '../models';
|
||||
|
||||
export function ActivateProfile(arg1:number):Promise<void>;
|
||||
@@ -26,6 +27,10 @@ export function DeleteAllQSO():Promise<number>;
|
||||
|
||||
export function DeleteClusterServer(arg1:number):Promise<void>;
|
||||
|
||||
export function DeleteOperatingAntenna(arg1:number):Promise<void>;
|
||||
|
||||
export function DeleteOperatingStation(arg1:number):Promise<void>;
|
||||
|
||||
export function DeleteProfile(arg1:number):Promise<void>;
|
||||
|
||||
export function DeleteQSO(arg1:number):Promise<void>;
|
||||
@@ -40,6 +45,8 @@ export function ExportADIF(arg1:string):Promise<adif.ExportResult>;
|
||||
|
||||
export function GetActiveProfile():Promise<profile.Profile>;
|
||||
|
||||
export function GetBackupSettings():Promise<main.BackupSettings>;
|
||||
|
||||
export function GetCATSettings():Promise<main.CATSettings>;
|
||||
|
||||
export function GetCATState():Promise<cat.RigState>;
|
||||
@@ -66,6 +73,8 @@ export function ImportADIF(arg1:string,arg2:boolean):Promise<adif.ImportResult>;
|
||||
|
||||
export function ListClusterServers():Promise<Array<cluster.ServerConfig>>;
|
||||
|
||||
export function ListOperatingTree():Promise<Array<operating.Station>>;
|
||||
|
||||
export function ListProfiles():Promise<Array<profile.Profile>>;
|
||||
|
||||
export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>;
|
||||
@@ -76,6 +85,10 @@ export function OpenADIFFile():Promise<string>;
|
||||
|
||||
export function OpenExternalURL(arg1:string):Promise<void>;
|
||||
|
||||
export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>;
|
||||
|
||||
export function PickBackupFolder():Promise<string>;
|
||||
|
||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||
|
||||
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
||||
@@ -84,8 +97,12 @@ export function RotatorPark():Promise<void>;
|
||||
|
||||
export function RotatorStop():Promise<void>;
|
||||
|
||||
export function RunBackupNow():Promise<string>;
|
||||
|
||||
export function SaveADIFFile():Promise<string>;
|
||||
|
||||
export function SaveBackupSettings(arg1:main.BackupSettings):Promise<void>;
|
||||
|
||||
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
||||
|
||||
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
|
||||
@@ -94,6 +111,10 @@ export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
||||
|
||||
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
||||
|
||||
export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.Antenna>;
|
||||
|
||||
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
|
||||
|
||||
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
||||
|
||||
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
||||
|
||||
@@ -38,6 +38,14 @@ export function DeleteClusterServer(arg1) {
|
||||
return window['go']['main']['App']['DeleteClusterServer'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteOperatingAntenna(arg1) {
|
||||
return window['go']['main']['App']['DeleteOperatingAntenna'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteOperatingStation(arg1) {
|
||||
return window['go']['main']['App']['DeleteOperatingStation'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteProfile(arg1) {
|
||||
return window['go']['main']['App']['DeleteProfile'](arg1);
|
||||
}
|
||||
@@ -66,6 +74,10 @@ export function GetActiveProfile() {
|
||||
return window['go']['main']['App']['GetActiveProfile']();
|
||||
}
|
||||
|
||||
export function GetBackupSettings() {
|
||||
return window['go']['main']['App']['GetBackupSettings']();
|
||||
}
|
||||
|
||||
export function GetCATSettings() {
|
||||
return window['go']['main']['App']['GetCATSettings']();
|
||||
}
|
||||
@@ -118,6 +130,10 @@ export function ListClusterServers() {
|
||||
return window['go']['main']['App']['ListClusterServers']();
|
||||
}
|
||||
|
||||
export function ListOperatingTree() {
|
||||
return window['go']['main']['App']['ListOperatingTree']();
|
||||
}
|
||||
|
||||
export function ListProfiles() {
|
||||
return window['go']['main']['App']['ListProfiles']();
|
||||
}
|
||||
@@ -138,6 +154,14 @@ export function OpenExternalURL(arg1) {
|
||||
return window['go']['main']['App']['OpenExternalURL'](arg1);
|
||||
}
|
||||
|
||||
export function OperatingDefaultForBand(arg1) {
|
||||
return window['go']['main']['App']['OperatingDefaultForBand'](arg1);
|
||||
}
|
||||
|
||||
export function PickBackupFolder() {
|
||||
return window['go']['main']['App']['PickBackupFolder']();
|
||||
}
|
||||
|
||||
export function RefreshCtyDat() {
|
||||
return window['go']['main']['App']['RefreshCtyDat']();
|
||||
}
|
||||
@@ -154,10 +178,18 @@ export function RotatorStop() {
|
||||
return window['go']['main']['App']['RotatorStop']();
|
||||
}
|
||||
|
||||
export function RunBackupNow() {
|
||||
return window['go']['main']['App']['RunBackupNow']();
|
||||
}
|
||||
|
||||
export function SaveADIFFile() {
|
||||
return window['go']['main']['App']['SaveADIFFile']();
|
||||
}
|
||||
|
||||
export function SaveBackupSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveBackupSettings'](arg1);
|
||||
}
|
||||
|
||||
export function SaveCATSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveCATSettings'](arg1);
|
||||
}
|
||||
@@ -174,6 +206,14 @@ export function SaveLookupSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveLookupSettings'](arg1);
|
||||
}
|
||||
|
||||
export function SaveOperatingAntenna(arg1) {
|
||||
return window['go']['main']['App']['SaveOperatingAntenna'](arg1);
|
||||
}
|
||||
|
||||
export function SaveOperatingStation(arg1) {
|
||||
return window['go']['main']['App']['SaveOperatingStation'](arg1);
|
||||
}
|
||||
|
||||
export function SaveProfile(arg1) {
|
||||
return window['go']['main']['App']['SaveProfile'](arg1);
|
||||
}
|
||||
|
||||
@@ -257,6 +257,28 @@ export namespace lookup {
|
||||
|
||||
export namespace main {
|
||||
|
||||
export class BackupSettings {
|
||||
enabled: boolean;
|
||||
folder: string;
|
||||
rotation: number;
|
||||
zip: boolean;
|
||||
last_backup_at: string;
|
||||
default_folder: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new BackupSettings(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.folder = source["folder"];
|
||||
this.rotation = source["rotation"];
|
||||
this.zip = source["zip"];
|
||||
this.last_backup_at = source["last_backup_at"];
|
||||
this.default_folder = source["default_folder"];
|
||||
}
|
||||
}
|
||||
export class CATSettings {
|
||||
enabled: boolean;
|
||||
backend: string;
|
||||
@@ -413,6 +435,7 @@ export namespace main {
|
||||
country?: string;
|
||||
continent?: string;
|
||||
status: string;
|
||||
worked_call: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SpotStatus(source);
|
||||
@@ -426,6 +449,7 @@ export namespace main {
|
||||
this.country = source["country"];
|
||||
this.continent = source["continent"];
|
||||
this.status = source["status"];
|
||||
this.worked_call = source["worked_call"];
|
||||
}
|
||||
}
|
||||
export class StartupStatus {
|
||||
@@ -469,6 +493,124 @@ export namespace main {
|
||||
|
||||
}
|
||||
|
||||
export namespace operating {
|
||||
|
||||
export class AntennaBand {
|
||||
band: string;
|
||||
is_default: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AntennaBand(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.band = source["band"];
|
||||
this.is_default = source["is_default"];
|
||||
}
|
||||
}
|
||||
export class Antenna {
|
||||
id: number;
|
||||
station_id: number;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
bands: AntennaBand[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Antenna(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.station_id = source["station_id"];
|
||||
this.name = source["name"];
|
||||
this.sort_order = source["sort_order"];
|
||||
this.bands = this.convertValues(source["bands"], AntennaBand);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
export class BandDefault {
|
||||
station_id: number;
|
||||
station_name: string;
|
||||
antenna_id: number;
|
||||
antenna_name: string;
|
||||
tx_pwr?: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new BandDefault(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.station_id = source["station_id"];
|
||||
this.station_name = source["station_name"];
|
||||
this.antenna_id = source["antenna_id"];
|
||||
this.antenna_name = source["antenna_name"];
|
||||
this.tx_pwr = source["tx_pwr"];
|
||||
}
|
||||
}
|
||||
export class Station {
|
||||
id: number;
|
||||
profile_id: number;
|
||||
name: string;
|
||||
tx_pwr?: number;
|
||||
sort_order: number;
|
||||
antennas?: Antenna[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Station(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.profile_id = source["profile_id"];
|
||||
this.name = source["name"];
|
||||
this.tx_pwr = source["tx_pwr"];
|
||||
this.sort_order = source["sort_order"];
|
||||
this.antennas = this.convertValues(source["antennas"], Antenna);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export namespace profile {
|
||||
|
||||
export class Profile {
|
||||
|
||||
@@ -5,6 +5,7 @@ go 1.25.0
|
||||
require (
|
||||
github.com/go-ole/go-ole v1.3.0
|
||||
github.com/wailsapp/wails/v2 v2.11.0
|
||||
golang.org/x/text v0.22.0
|
||||
modernc.org/sqlite v1.50.1
|
||||
)
|
||||
|
||||
@@ -37,7 +38,6 @@ require (
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
+50
-6
@@ -1,12 +1,17 @@
|
||||
package adif
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/text/encoding/charmap"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
@@ -33,23 +38,62 @@ type Importer struct {
|
||||
SkipDuplicates bool // when true, records matching an existing or earlier-in-file QSO are skipped; otherwise all are inserted
|
||||
}
|
||||
|
||||
// ImportFile opens the file at path and imports it into the repo.
|
||||
// ImportFile reads the file at path and imports it into the repo. The
|
||||
// whole file is loaded into memory so we can do a definitive UTF-8 check
|
||||
// before parsing — peeking a buffered window misses non-ASCII bytes that
|
||||
// only appear past the header (typical when the ADIF header is pure ASCII
|
||||
// but record fields like NAME/QTH have accented chars in Windows-1252).
|
||||
func (im *Importer) ImportFile(ctx context.Context, path string) (ImportResult, error) {
|
||||
f, err := os.Open(path)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ImportResult{}, fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
return im.Import(ctx, f)
|
||||
// Strip UTF-8 BOM if present so the parser sees clean data.
|
||||
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
|
||||
return im.importBytes(ctx, data)
|
||||
}
|
||||
|
||||
// Import streams the ADI content from r into the repo.
|
||||
// pickValueDecoder returns the per-field byte-to-string decoder to use.
|
||||
// If the file is valid UTF-8 we keep the bytes as-is; otherwise we assume
|
||||
// Windows-1252 (de-facto encoding of MixW, Log4OM, HRD and most legacy
|
||||
// Western-European loggers). Decoding has to happen on each field's bytes
|
||||
// individually, NOT by wrapping the reader, because ADIF declares field
|
||||
// lengths in source-encoding bytes — e.g. "<QTH:7>YAOUNDÉ" is 7 bytes in
|
||||
// Windows-1252 (É is one byte 0xC9). Pre-decoding to UTF-8 would make É
|
||||
// two bytes, and the parser reading 7 bytes after the tag would chop the
|
||||
// É in half → "YAOUND" + an orphan 0xC3 byte → "YAOUND�" after JSON.
|
||||
func pickValueDecoder(data []byte) func([]byte) string {
|
||||
if utf8.Valid(data) {
|
||||
return nil // identity
|
||||
}
|
||||
dec := charmap.Windows1252.NewDecoder()
|
||||
return func(b []byte) string {
|
||||
out, err := dec.Bytes(b)
|
||||
if err != nil {
|
||||
return string(b)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
}
|
||||
|
||||
// Import streams the ADI content from r into the repo. Assumes UTF-8;
|
||||
// callers that may receive other encodings should go through ImportFile.
|
||||
func (im *Importer) Import(ctx context.Context, r interface {
|
||||
Read(p []byte) (int, error)
|
||||
}) (ImportResult, error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return ImportResult{}, fmt.Errorf("read input: %w", err)
|
||||
}
|
||||
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
|
||||
return im.importBytes(ctx, data)
|
||||
}
|
||||
|
||||
func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult, error) {
|
||||
if im.BatchSize <= 0 {
|
||||
im.BatchSize = 500
|
||||
}
|
||||
decode := pickValueDecoder(data)
|
||||
res := ImportResult{}
|
||||
batch := make([]qso.QSO, 0, im.BatchSize)
|
||||
|
||||
@@ -73,7 +117,7 @@ func (im *Importer) Import(ctx context.Context, r interface {
|
||||
return err
|
||||
}
|
||||
|
||||
err = Parse(r, func(rec Record) error {
|
||||
err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error {
|
||||
res.Total++
|
||||
q, ok := recordToQSO(rec)
|
||||
if !ok {
|
||||
|
||||
@@ -25,6 +25,21 @@ type Record map[string]string
|
||||
// Returning a non-nil error from fn stops parsing and is propagated.
|
||||
// The header (text before <EOH>) is silently discarded.
|
||||
func Parse(r io.Reader, fn func(Record) error) error {
|
||||
return parseWith(r, nil, fn)
|
||||
}
|
||||
|
||||
// ParseWithDecoder is like Parse but applies decodeValue to each field's
|
||||
// raw bytes before storing as a string. ADIF field lengths are byte
|
||||
// counts in the file's native encoding, so decoding MUST happen after
|
||||
// reading exactly N bytes — wrapping the reader in a decoder would shift
|
||||
// byte boundaries and chop multibyte chars in half (e.g. "<QTH:7>YAOUNDÉ"
|
||||
// in Windows-1252 is 7 bytes; after upfront decoding it'd be 8 bytes of
|
||||
// UTF-8 and the parser would only read the first 7, splitting É).
|
||||
func ParseWithDecoder(r io.Reader, decodeValue func([]byte) string, fn func(Record) error) error {
|
||||
return parseWith(r, decodeValue, fn)
|
||||
}
|
||||
|
||||
func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) error) error {
|
||||
br := bufio.NewReaderSize(r, 64*1024)
|
||||
|
||||
rec := Record{}
|
||||
@@ -69,11 +84,15 @@ func Parse(r io.Reader, fn func(Record) error) error {
|
||||
return fmt.Errorf("read field %s: %w", name, err)
|
||||
}
|
||||
if headerDone && name != "" {
|
||||
if decodeValue != nil {
|
||||
rec[name] = decodeValue(val)
|
||||
} else {
|
||||
rec[name] = string(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseSpec splits "callsign:5", "callsign:5:S" or "eor" into name and length.
|
||||
// name is lowercased; length is 0 for control tags or when missing.
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// Package backup creates rotating copies of the SQLite logbook so a single
|
||||
// disk failure or accidental delete doesn't wipe the user's QSO history.
|
||||
// The strategy is intentionally simple: a daily snapshot of the .db file
|
||||
// (optionally zipped), keeping the last N. SQLite's atomic-commit format
|
||||
// lets us copy the raw file safely as long as we copy after a checkpoint.
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Settings holds the user-tweakable backup configuration. Stored as flat
|
||||
// key/value pairs in the settings table (so we don't need a schema change
|
||||
// every time we add a field).
|
||||
type Settings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Folder string `json:"folder"` // empty → DefaultFolder
|
||||
Rotation int `json:"rotation"` // how many backups to keep; 0/neg = 5
|
||||
Zip bool `json:"zip"` // compress with deflate
|
||||
LastBackupAt string `json:"last_backup_at"`// RFC3339; empty if never
|
||||
}
|
||||
|
||||
// DefaultFolder returns the folder used when Settings.Folder is empty.
|
||||
// It sits next to the database file (in <appdata>/hamlog/backups) which
|
||||
// keeps it portable when the user moves the data directory.
|
||||
func DefaultFolder(dataDir string) string {
|
||||
return filepath.Join(dataDir, "backups")
|
||||
}
|
||||
|
||||
// Run executes one backup pass: checkpoint WAL → copy the database file
|
||||
// → optionally zip → rotate. Returns the path of the file that was
|
||||
// written so the caller can surface it to the UI.
|
||||
//
|
||||
// dbConn is used to issue the WAL checkpoint so the on-disk file is
|
||||
// self-consistent before we copy it. It's the same *sql.DB the app uses;
|
||||
// SQLite tolerates concurrent reads during the copy.
|
||||
func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation int, doZip bool) (string, error) {
|
||||
if dbConn == nil {
|
||||
return "", fmt.Errorf("nil db connection")
|
||||
}
|
||||
if rotation <= 0 {
|
||||
rotation = 5
|
||||
}
|
||||
if folder == "" {
|
||||
return "", fmt.Errorf("backup folder not set")
|
||||
}
|
||||
if err := os.MkdirAll(folder, 0o755); err != nil {
|
||||
return "", fmt.Errorf("create backup folder: %w", err)
|
||||
}
|
||||
// Flush WAL into the main file so a raw copy is a complete database.
|
||||
// TRUNCATE removes the -wal file's contents after checkpointing.
|
||||
if _, err := dbConn.ExecContext(ctx, `PRAGMA wal_checkpoint(TRUNCATE);`); err != nil {
|
||||
return "", fmt.Errorf("wal_checkpoint: %w", err)
|
||||
}
|
||||
|
||||
stamp := time.Now().Format("2006-01-02")
|
||||
base := fmt.Sprintf("hamlog-%s", stamp)
|
||||
var dstPath string
|
||||
if doZip {
|
||||
dstPath = filepath.Join(folder, base+".db.zip")
|
||||
if err := copyZipped(dbPath, dstPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
dstPath = filepath.Join(folder, base+".db")
|
||||
if err := copyFile(dbPath, dstPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if err := rotate(folder, rotation); err != nil {
|
||||
// Rotation errors are non-fatal — the backup itself succeeded.
|
||||
return dstPath, fmt.Errorf("rotate: %w (backup OK at %s)", err, dstPath)
|
||||
}
|
||||
return dstPath, nil
|
||||
}
|
||||
|
||||
// copyFile performs a plain file copy. We don't use os.Rename because
|
||||
// the source is the live database; we want a fresh standalone file.
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open source: %w", err)
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create dest: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
out.Close()
|
||||
os.Remove(dst)
|
||||
return fmt.Errorf("copy: %w", err)
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
// copyZipped writes a single-entry deflate zip containing the database.
|
||||
// The inner filename is just the base of the source so unzip restores
|
||||
// "hamlog.db" wherever the user extracts.
|
||||
func copyZipped(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open source: %w", err)
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create dest: %w", err)
|
||||
}
|
||||
zw := zip.NewWriter(out)
|
||||
w, err := zw.Create(filepath.Base(src))
|
||||
if err != nil {
|
||||
zw.Close()
|
||||
out.Close()
|
||||
os.Remove(dst)
|
||||
return fmt.Errorf("zip entry: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(w, in); err != nil {
|
||||
zw.Close()
|
||||
out.Close()
|
||||
os.Remove(dst)
|
||||
return fmt.Errorf("zip write: %w", err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
out.Close()
|
||||
os.Remove(dst)
|
||||
return fmt.Errorf("zip close: %w", err)
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
// rotate keeps the most recent `keep` backups in folder and deletes the
|
||||
// rest. Only files matching the hamlog-*.db / hamlog-*.db.zip pattern
|
||||
// are touched — never user files that happen to live in the same folder.
|
||||
func rotate(folder string, keep int) error {
|
||||
entries, err := os.ReadDir(folder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type item struct {
|
||||
path string
|
||||
mod time.Time
|
||||
}
|
||||
matches := make([]item, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !strings.HasPrefix(name, "hamlog-") {
|
||||
continue
|
||||
}
|
||||
if !(strings.HasSuffix(name, ".db") || strings.HasSuffix(name, ".db.zip")) {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, item{path: filepath.Join(folder, name), mod: info.ModTime()})
|
||||
}
|
||||
if len(matches) <= keep {
|
||||
return nil
|
||||
}
|
||||
sort.Slice(matches, func(i, j int) bool { return matches[i].mod.After(matches[j].mod) })
|
||||
var firstErr error
|
||||
for _, m := range matches[keep:] {
|
||||
if err := os.Remove(m.path); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// HasBackupToday returns true if a backup for today's date already exists
|
||||
// in folder. Used by the startup auto-backup to skip when the user has
|
||||
// already restarted the app once today.
|
||||
func HasBackupToday(folder string) bool {
|
||||
if folder == "" {
|
||||
return false
|
||||
}
|
||||
stamp := time.Now().Format("2006-01-02")
|
||||
for _, ext := range []string{".db", ".db.zip"} {
|
||||
if _, err := os.Stat(filepath.Join(folder, "hamlog-"+stamp+ext)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -52,6 +52,11 @@ type Spot struct {
|
||||
TimeUTC string `json:"time_utc,omitempty"`
|
||||
Country string `json:"country,omitempty"` // DXCC entity name (cty.dat)
|
||||
Continent string `json:"continent,omitempty"` // 2-letter continent
|
||||
CQZone int `json:"cqz,omitempty"` // DXCC entity CQ zone
|
||||
ITUZone int `json:"ituz,omitempty"` // DXCC entity ITU zone
|
||||
DistanceKm int `json:"distance_km,omitempty"` // great-circle km from operator's grid
|
||||
ShortPath int `json:"sp_deg,omitempty"` // azimuth (deg) short path from operator
|
||||
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
Raw string `json:"raw"`
|
||||
}
|
||||
@@ -168,14 +173,11 @@ func (m *Manager) StopServer(id int64) {
|
||||
if ok {
|
||||
delete(m.sessions, id)
|
||||
}
|
||||
remaining := len(m.sessions)
|
||||
m.mu.Unlock()
|
||||
fmt.Printf("cluster.StopServer id=%d found=%v remaining=%d\n", id, ok, remaining)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.stop()
|
||||
fmt.Printf("cluster.StopServer id=%d stopped successfully\n", id)
|
||||
m.emitStatus()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Operating conditions: per-profile tree of stations (radios) → antennas →
|
||||
-- bands. Used to auto-populate MY_RIG / MY_ANTENNA on each logged QSO
|
||||
-- based on the current band, so the operator doesn't have to retype them.
|
||||
--
|
||||
-- Tree shape:
|
||||
-- profile
|
||||
-- └── station (one row per radio: name + ADIF MY_RIG value)
|
||||
-- └── antenna (one row per antenna for that radio: name + ADIF MY_ANTENNA)
|
||||
-- └── band (band tags the antenna covers; one may be flagged default)
|
||||
--
|
||||
-- "Default for a band" is a per-profile flag: when the user picks 20m in the
|
||||
-- entry strip and an antenna on 20m is marked default, MY_RIG and MY_ANTENNA
|
||||
-- auto-fill from that antenna and its parent station. At most one antenna
|
||||
-- can be the default for any given (profile, band) — enforced by a partial
|
||||
-- unique index below.
|
||||
|
||||
CREATE TABLE operating_stations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
profile_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL, -- display name, e.g. "Flex 8600"
|
||||
adif_rig TEXT NOT NULL DEFAULT '', -- value written to MY_RIG ADIF field
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
FOREIGN KEY (profile_id) REFERENCES station_profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_operating_stations_profile ON operating_stations(profile_id, sort_order);
|
||||
|
||||
CREATE TABLE operating_antennas (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
station_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL, -- e.g. "UB640 VL2.3"
|
||||
adif_ant TEXT NOT NULL DEFAULT '', -- value written to MY_ANTENNA ADIF field
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
FOREIGN KEY (station_id) REFERENCES operating_stations(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_operating_antennas_station ON operating_antennas(station_id, sort_order);
|
||||
|
||||
-- The bands an antenna covers. Composite PK = one row per (antenna, band).
|
||||
-- is_default = the entry-form autofill picks this row when the user sets band.
|
||||
CREATE TABLE operating_antenna_bands (
|
||||
antenna_id INTEGER NOT NULL,
|
||||
band TEXT NOT NULL, -- ADIF lowercase, e.g. "20m"
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (antenna_id, band),
|
||||
FOREIGN KEY (antenna_id) REFERENCES operating_antennas(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_operating_bands_band ON operating_antenna_bands(band, is_default);
|
||||
@@ -0,0 +1,42 @@
|
||||
-- Repair the operating_* tables: 0008 referenced a non-existent `profiles`
|
||||
-- table (the real table is `station_profiles`), so the FK validation
|
||||
-- failed on every insert with "no such table: main.profiles". Dropping
|
||||
-- and recreating is safe here because no operating data could have been
|
||||
-- inserted (every attempt errored out).
|
||||
|
||||
DROP TABLE IF EXISTS operating_antenna_bands;
|
||||
DROP TABLE IF EXISTS operating_antennas;
|
||||
DROP TABLE IF EXISTS operating_stations;
|
||||
|
||||
CREATE TABLE operating_stations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
profile_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
adif_rig TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
FOREIGN KEY (profile_id) REFERENCES station_profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_operating_stations_profile ON operating_stations(profile_id, sort_order);
|
||||
|
||||
CREATE TABLE operating_antennas (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
station_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
adif_ant TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
FOREIGN KEY (station_id) REFERENCES operating_stations(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_operating_antennas_station ON operating_antennas(station_id, sort_order);
|
||||
|
||||
CREATE TABLE operating_antenna_bands (
|
||||
antenna_id INTEGER NOT NULL,
|
||||
band TEXT NOT NULL,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (antenna_id, band),
|
||||
FOREIGN KEY (antenna_id) REFERENCES operating_antennas(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_operating_bands_band ON operating_antenna_bands(band, is_default);
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Simplify the operating tree: drop the separate ADIF-value columns (we
|
||||
-- now use the display name as the ADIF MY_RIG / MY_ANTENNA value — one
|
||||
-- field per row, no duplication) and add per-rig TX power so the entry
|
||||
-- strip can stamp TX_PWR alongside MY_RIG when the band changes.
|
||||
|
||||
-- SQLite can't DROP COLUMN safely on every version we support, so we
|
||||
-- recreate the tables. operating_antenna_bands is left untouched — its
|
||||
-- schema didn't change — but the FK on operating_antennas needs to be
|
||||
-- rewired since the table is recreated.
|
||||
|
||||
DROP TABLE IF EXISTS operating_antenna_bands;
|
||||
|
||||
CREATE TABLE operating_stations_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
profile_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
tx_pwr REAL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
FOREIGN KEY (profile_id) REFERENCES station_profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO operating_stations_new (id, profile_id, name, sort_order, created_at, updated_at)
|
||||
SELECT id, profile_id, name, sort_order, created_at, updated_at FROM operating_stations;
|
||||
DROP TABLE operating_stations;
|
||||
ALTER TABLE operating_stations_new RENAME TO operating_stations;
|
||||
CREATE INDEX idx_operating_stations_profile ON operating_stations(profile_id, sort_order);
|
||||
|
||||
CREATE TABLE operating_antennas_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
station_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
FOREIGN KEY (station_id) REFERENCES operating_stations(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO operating_antennas_new (id, station_id, name, sort_order, created_at, updated_at)
|
||||
SELECT id, station_id, name, sort_order, created_at, updated_at FROM operating_antennas;
|
||||
DROP TABLE operating_antennas;
|
||||
ALTER TABLE operating_antennas_new RENAME TO operating_antennas;
|
||||
CREATE INDEX idx_operating_antennas_station ON operating_antennas(station_id, sort_order);
|
||||
|
||||
CREATE TABLE operating_antenna_bands (
|
||||
antenna_id INTEGER NOT NULL,
|
||||
band TEXT NOT NULL,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (antenna_id, band),
|
||||
FOREIGN KEY (antenna_id) REFERENCES operating_antennas(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_operating_bands_band ON operating_antenna_bands(band, is_default);
|
||||
@@ -0,0 +1,324 @@
|
||||
// Package operating manages the per-profile tree of stations (radios),
|
||||
// antennas, and the bands each antenna covers. The "default for a band"
|
||||
// flag drives the auto-fill of MY_RIG / MY_ANTENNA on each logged QSO.
|
||||
package operating
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Station is a radio / TRX line. The display Name is also what gets
|
||||
// written into the MY_RIG ADIF field on each QSO — no separate ADIF
|
||||
// value to maintain. TXPower (W) is per-rig so changing rig auto-
|
||||
// stamps the right power on logged QSOs.
|
||||
type Station struct {
|
||||
ID int64 `json:"id"`
|
||||
ProfileID int64 `json:"profile_id"`
|
||||
Name string `json:"name"`
|
||||
TXPower *float64 `json:"tx_pwr,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Antennas []Antenna `json:"antennas,omitempty"`
|
||||
}
|
||||
|
||||
// Antenna is one antenna attached to a station. The display Name doubles
|
||||
// as the MY_ANTENNA ADIF value.
|
||||
type Antenna struct {
|
||||
ID int64 `json:"id"`
|
||||
StationID int64 `json:"station_id"`
|
||||
Name string `json:"name"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Bands []AntennaBand `json:"bands"`
|
||||
}
|
||||
|
||||
// AntennaBand pairs an antenna with one of the bands it covers, plus
|
||||
// whether it is the default for that band in this profile.
|
||||
type AntennaBand struct {
|
||||
Band string `json:"band"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
|
||||
// BandDefault is the resolved tuple looked up at QSO save: which
|
||||
// station+antenna should pre-fill MY_RIG / MY_ANTENNA / TX_PWR for a
|
||||
// given band. Station and antenna names go straight into the ADIF
|
||||
// fields — there is no separate "ADIF value" anymore.
|
||||
type BandDefault struct {
|
||||
StationID int64 `json:"station_id"`
|
||||
StationName string `json:"station_name"`
|
||||
AntennaID int64 `json:"antenna_id"`
|
||||
AntennaName string `json:"antenna_name"`
|
||||
TXPower *float64 `json:"tx_pwr,omitempty"`
|
||||
}
|
||||
|
||||
type Repo struct{ db *sql.DB }
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
|
||||
|
||||
// ListTree returns every station for the profile with its nested antennas
|
||||
// and bands. One round-trip per level — three queries total regardless of
|
||||
// tree size, so the Settings panel stays snappy on big setups.
|
||||
func (r *Repo) ListTree(ctx context.Context, profileID int64) ([]Station, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, profile_id, name, tx_pwr, sort_order
|
||||
FROM operating_stations
|
||||
WHERE profile_id = ?
|
||||
ORDER BY sort_order, id`, profileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list stations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var stations []Station
|
||||
stationByID := map[int64]int{} // id → index in stations slice
|
||||
for rows.Next() {
|
||||
var s Station
|
||||
var pwr sql.NullFloat64
|
||||
if err := rows.Scan(&s.ID, &s.ProfileID, &s.Name, &pwr, &s.SortOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pwr.Valid {
|
||||
v := pwr.Float64
|
||||
s.TXPower = &v
|
||||
}
|
||||
stationByID[s.ID] = len(stations)
|
||||
stations = append(stations, s)
|
||||
}
|
||||
if len(stations) == 0 {
|
||||
return stations, nil
|
||||
}
|
||||
|
||||
// Build IN-clause placeholders for the second query.
|
||||
ids := make([]any, 0, len(stations))
|
||||
placeholders := make([]string, 0, len(stations))
|
||||
for _, s := range stations {
|
||||
ids = append(ids, s.ID)
|
||||
placeholders = append(placeholders, "?")
|
||||
}
|
||||
antRows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, station_id, name, sort_order
|
||||
FROM operating_antennas
|
||||
WHERE station_id IN (`+strings.Join(placeholders, ",")+`)
|
||||
ORDER BY station_id, sort_order, id`, ids...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list antennas: %w", err)
|
||||
}
|
||||
// Collect antennas into a flat map keyed by ID first — taking pointers
|
||||
// into a slice we later append to is unsafe (a re-allocation
|
||||
// invalidates older pointers, leaving the band loop writing to dead
|
||||
// memory). We assemble the per-station slices at the very end, once
|
||||
// everything is collected.
|
||||
antennasByID := map[int64]*Antenna{}
|
||||
antennaIDsByStation := map[int64][]int64{}
|
||||
for antRows.Next() {
|
||||
a := &Antenna{}
|
||||
if err := antRows.Scan(&a.ID, &a.StationID, &a.Name, &a.SortOrder); err != nil {
|
||||
antRows.Close()
|
||||
return nil, err
|
||||
}
|
||||
antennasByID[a.ID] = a
|
||||
antennaIDsByStation[a.StationID] = append(antennaIDsByStation[a.StationID], a.ID)
|
||||
}
|
||||
antRows.Close()
|
||||
|
||||
if len(antennasByID) > 0 {
|
||||
antIDs := make([]any, 0, len(antennasByID))
|
||||
antPlaceholders := make([]string, 0, len(antennasByID))
|
||||
for id := range antennasByID {
|
||||
antIDs = append(antIDs, id)
|
||||
antPlaceholders = append(antPlaceholders, "?")
|
||||
}
|
||||
bandRows, err := r.db.QueryContext(ctx,
|
||||
`SELECT antenna_id, band, is_default
|
||||
FROM operating_antenna_bands
|
||||
WHERE antenna_id IN (`+strings.Join(antPlaceholders, ",")+`)
|
||||
ORDER BY band`, antIDs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list bands: %w", err)
|
||||
}
|
||||
for bandRows.Next() {
|
||||
var antID int64
|
||||
var band string
|
||||
var isDefault int
|
||||
if err := bandRows.Scan(&antID, &band, &isDefault); err != nil {
|
||||
bandRows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if a, ok := antennasByID[antID]; ok {
|
||||
a.Bands = append(a.Bands, AntennaBand{Band: band, IsDefault: isDefault != 0})
|
||||
}
|
||||
}
|
||||
bandRows.Close()
|
||||
}
|
||||
|
||||
// Now assemble each station's Antennas slice. By the time we do this
|
||||
// every antenna already has its full band list attached, so no
|
||||
// downstream pointer is left behind.
|
||||
for sIdx := range stations {
|
||||
for _, antID := range antennaIDsByStation[stations[sIdx].ID] {
|
||||
if a, ok := antennasByID[antID]; ok {
|
||||
stations[sIdx].Antennas = append(stations[sIdx].Antennas, *a)
|
||||
}
|
||||
}
|
||||
}
|
||||
return stations, nil
|
||||
}
|
||||
|
||||
// SaveStation upserts a station. Returns the (possibly new) ID.
|
||||
func (r *Repo) SaveStation(ctx context.Context, s *Station) error {
|
||||
if strings.TrimSpace(s.Name) == "" {
|
||||
return fmt.Errorf("station name required")
|
||||
}
|
||||
var pwr any
|
||||
if s.TXPower != nil {
|
||||
pwr = *s.TXPower
|
||||
}
|
||||
if s.ID == 0 {
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO operating_stations(profile_id, name, tx_pwr, sort_order)
|
||||
VALUES(?, ?, ?, ?)`, s.ProfileID, s.Name, pwr, s.SortOrder)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert station: %w", err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
s.ID = id
|
||||
return nil
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE operating_stations
|
||||
SET name = ?, tx_pwr = ?, sort_order = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||
WHERE id = ?`, s.Name, pwr, s.SortOrder, s.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update station: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteStation cascades to antennas and bands via FK ON DELETE CASCADE.
|
||||
func (r *Repo) DeleteStation(ctx context.Context, id int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `DELETE FROM operating_stations WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveAntenna upserts an antenna and replaces its band list in one
|
||||
// transaction. `is_default` is enforced per profile: setting it on one
|
||||
// antenna clears any other antenna's default for the same band.
|
||||
func (r *Repo) SaveAntenna(ctx context.Context, a *Antenna) error {
|
||||
if strings.TrimSpace(a.Name) == "" {
|
||||
return fmt.Errorf("antenna name required")
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if a.ID == 0 {
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO operating_antennas(station_id, name, sort_order)
|
||||
VALUES(?, ?, ?)`, a.StationID, a.Name, a.SortOrder)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert antenna: %w", err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
a.ID = id
|
||||
} else {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE operating_antennas
|
||||
SET name = ?, sort_order = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||
WHERE id = ?`, a.Name, a.SortOrder, a.ID); err != nil {
|
||||
return fmt.Errorf("update antenna: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Look up profile_id for this antenna's station — needed for the
|
||||
// "single default per band per profile" constraint.
|
||||
var profileID int64
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT s.profile_id FROM operating_stations s WHERE s.id = ?`,
|
||||
a.StationID).Scan(&profileID); err != nil {
|
||||
return fmt.Errorf("lookup profile id: %w", err)
|
||||
}
|
||||
|
||||
// Replace band list wholesale — simpler than diffing, fine for the
|
||||
// small N (a typical antenna covers a handful of bands).
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM operating_antenna_bands WHERE antenna_id = ?`, a.ID); err != nil {
|
||||
return fmt.Errorf("clear bands: %w", err)
|
||||
}
|
||||
for _, b := range a.Bands {
|
||||
band := strings.TrimSpace(strings.ToLower(b.Band))
|
||||
if band == "" {
|
||||
continue
|
||||
}
|
||||
def := 0
|
||||
if b.IsDefault {
|
||||
def = 1
|
||||
}
|
||||
// Insert this antenna's band entry, then if it's a default
|
||||
// clear other antennas' default for the same band within
|
||||
// the same profile.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO operating_antenna_bands(antenna_id, band, is_default)
|
||||
VALUES(?, ?, ?)`, a.ID, band, def); err != nil {
|
||||
return fmt.Errorf("insert band: %w", err)
|
||||
}
|
||||
if def == 1 {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE operating_antenna_bands
|
||||
SET is_default = 0
|
||||
WHERE band = ?
|
||||
AND antenna_id != ?
|
||||
AND antenna_id IN (
|
||||
SELECT oa.id FROM operating_antennas oa
|
||||
JOIN operating_stations os ON oa.station_id = os.id
|
||||
WHERE os.profile_id = ?
|
||||
)`, band, a.ID, profileID); err != nil {
|
||||
return fmt.Errorf("clear other defaults: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DeleteAntenna cascades to bands via FK ON DELETE CASCADE.
|
||||
func (r *Repo) DeleteAntenna(ctx context.Context, id int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `DELETE FROM operating_antennas WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// BandDefault returns the (station, antenna) flagged default for the given
|
||||
// band in the given profile. Empty result when nothing matches — callers
|
||||
// should leave MY_RIG/MY_ANTENNA blank in that case.
|
||||
func (r *Repo) BandDefault(ctx context.Context, profileID int64, band string) (BandDefault, bool, error) {
|
||||
band = strings.TrimSpace(strings.ToLower(band))
|
||||
if band == "" {
|
||||
return BandDefault{}, false, nil
|
||||
}
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT s.id, s.name, s.tx_pwr,
|
||||
a.id, a.name
|
||||
FROM operating_antenna_bands ab
|
||||
JOIN operating_antennas a ON ab.antenna_id = a.id
|
||||
JOIN operating_stations s ON a.station_id = s.id
|
||||
WHERE s.profile_id = ? AND ab.band = ? AND ab.is_default = 1
|
||||
LIMIT 1`, profileID, band)
|
||||
var (
|
||||
d BandDefault
|
||||
pwr sql.NullFloat64
|
||||
)
|
||||
if err := row.Scan(&d.StationID, &d.StationName, &pwr,
|
||||
&d.AntennaID, &d.AntennaName); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return BandDefault{}, false, nil
|
||||
}
|
||||
return BandDefault{}, false, err
|
||||
}
|
||||
if pwr.Valid {
|
||||
v := pwr.Float64
|
||||
d.TXPower = &v
|
||||
}
|
||||
return d, true, nil
|
||||
}
|
||||
+60
-14
@@ -257,6 +257,7 @@ func decodeExtras(s string) map[string]string {
|
||||
|
||||
// Add inserts a QSO and returns its ID.
|
||||
func (r *Repo) Add(ctx context.Context, q QSO) (int64, error) {
|
||||
q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign))
|
||||
if q.Callsign == "" {
|
||||
return 0, fmt.Errorf("empty callsign")
|
||||
}
|
||||
@@ -293,6 +294,7 @@ func (r *Repo) AddBatch(ctx context.Context, qsos []QSO) (int64, error) {
|
||||
|
||||
var inserted int64
|
||||
for _, q := range qsos {
|
||||
q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign))
|
||||
if q.Callsign == "" {
|
||||
continue
|
||||
}
|
||||
@@ -322,6 +324,7 @@ func (r *Repo) Update(ctx context.Context, q QSO) error {
|
||||
if q.ID == 0 {
|
||||
return fmt.Errorf("missing id")
|
||||
}
|
||||
q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign))
|
||||
if q.Callsign == "" {
|
||||
return fmt.Errorf("empty callsign")
|
||||
}
|
||||
@@ -412,8 +415,9 @@ func (r *Repo) List(ctx context.Context, f ListFilter) ([]QSO, error) {
|
||||
q := `SELECT ` + selectCols + ` FROM qso WHERE 1=1`
|
||||
args := []any{}
|
||||
if f.Callsign != "" {
|
||||
// Contains-match so a search for "XYZ" finds F4XYZ, F4XYZ/P, etc.
|
||||
q += " AND callsign LIKE ?"
|
||||
args = append(args, f.Callsign+"%")
|
||||
args = append(args, "%"+f.Callsign+"%")
|
||||
}
|
||||
if f.Band != "" {
|
||||
q += " AND band = ?"
|
||||
@@ -428,8 +432,12 @@ func (r *Repo) List(ctx context.Context, f ListFilter) ([]QSO, error) {
|
||||
args = append(args, f.StationCallsign)
|
||||
}
|
||||
q += " ORDER BY qso_date DESC, id DESC"
|
||||
if f.Limit <= 0 || f.Limit > 1000 {
|
||||
f.Limit = 200
|
||||
if f.Limit <= 0 {
|
||||
f.Limit = 500
|
||||
}
|
||||
// Hard upper bound: 1M is enough to fit any realistic personal log.
|
||||
if f.Limit > 1_000_000 {
|
||||
f.Limit = 1_000_000
|
||||
}
|
||||
q += " LIMIT ? OFFSET ?"
|
||||
args = append(args, f.Limit, f.Offset)
|
||||
@@ -549,14 +557,14 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
||||
|
||||
// ---- Per-callsign stats ----
|
||||
if err := r.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM qso WHERE callsign = ?`, wb.Callsign).Scan(&wb.Count); err != nil {
|
||||
`SELECT COUNT(*) FROM qso WHERE upper(trim(callsign)) = ?`, wb.Callsign).Scan(&wb.Count); err != nil {
|
||||
return wb, fmt.Errorf("count worked: %w", err)
|
||||
}
|
||||
if wb.Count > 0 {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, qso_date, band, mode, rst_sent, rst_rcvd,
|
||||
qsl_sent, qsl_rcvd, lotw_sent, lotw_rcvd
|
||||
FROM qso WHERE callsign = ?
|
||||
FROM qso WHERE upper(trim(callsign)) = ?
|
||||
ORDER BY qso_date DESC, id DESC
|
||||
LIMIT ?`, wb.Callsign, maxWorkedEntries)
|
||||
if err != nil {
|
||||
@@ -569,14 +577,17 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
||||
var (
|
||||
e WorkedEntry
|
||||
dateStr string
|
||||
band, mode sql.NullString
|
||||
rstS, rstR, qslS, qslR, lotwS, lotwR sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&e.ID, &dateStr, &e.Band, &e.Mode,
|
||||
if err := rows.Scan(&e.ID, &dateStr, &band, &mode,
|
||||
&rstS, &rstR, &qslS, &qslR, &lotwS, &lotwR); err != nil {
|
||||
rows.Close()
|
||||
return wb, fmt.Errorf("scan worked: %w", err)
|
||||
}
|
||||
e.QSODate = parseTimeLoose(dateStr)
|
||||
e.Band = band.String
|
||||
e.Mode = mode.String
|
||||
e.RSTSent = rstS.String
|
||||
e.RSTRcvd = rstR.String
|
||||
e.QSLSent = qslS.String
|
||||
@@ -584,9 +595,15 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
||||
e.LOTWSent = lotwS.String
|
||||
e.LOTWRcvd = lotwR.String
|
||||
wb.Entries = append(wb.Entries, e)
|
||||
if e.Band != "" {
|
||||
bandsSet[e.Band] = struct{}{}
|
||||
}
|
||||
if e.Mode != "" {
|
||||
modesSet[e.Mode] = struct{}{}
|
||||
}
|
||||
if e.Band != "" && e.Mode != "" {
|
||||
bmSet[e.Band+"|"+e.Mode] = BandMode{Band: e.Band, Mode: e.Mode}
|
||||
}
|
||||
if wb.Last.IsZero() {
|
||||
wb.Last = e.QSODate
|
||||
}
|
||||
@@ -597,7 +614,7 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
||||
if wb.Count > maxWorkedEntries {
|
||||
var firstStr sql.NullString
|
||||
_ = r.db.QueryRowContext(ctx,
|
||||
`SELECT MIN(qso_date) FROM qso WHERE callsign = ?`, wb.Callsign).Scan(&firstStr)
|
||||
`SELECT MIN(qso_date) FROM qso WHERE upper(trim(callsign)) = ?`, wb.Callsign).Scan(&firstStr)
|
||||
if firstStr.Valid {
|
||||
wb.First = parseTimeLoose(firstStr.String)
|
||||
}
|
||||
@@ -622,7 +639,7 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
||||
var d sql.NullInt64
|
||||
_ = r.db.QueryRowContext(ctx, `
|
||||
SELECT dxcc FROM qso
|
||||
WHERE callsign = ? AND dxcc IS NOT NULL
|
||||
WHERE upper(trim(callsign)) = ? AND dxcc IS NOT NULL
|
||||
ORDER BY qso_date DESC LIMIT 1`, wb.Callsign).Scan(&d)
|
||||
if d.Valid {
|
||||
dxcc = int(d.Int64)
|
||||
@@ -656,15 +673,17 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
||||
}
|
||||
|
||||
if err := r.collectDistinct(ctx, &wb.DXCCBands,
|
||||
`SELECT DISTINCT band FROM qso WHERE dxcc = ?`, dxcc); err != nil {
|
||||
`SELECT DISTINCT band FROM qso WHERE dxcc = ? AND band IS NOT NULL AND band != ''`, dxcc); err != nil {
|
||||
return wb, err
|
||||
}
|
||||
if err := r.collectDistinct(ctx, &wb.DXCCModes,
|
||||
`SELECT DISTINCT mode FROM qso WHERE dxcc = ?`, dxcc); err != nil {
|
||||
`SELECT DISTINCT mode FROM qso WHERE dxcc = ? AND mode IS NOT NULL AND mode != ''`, dxcc); err != nil {
|
||||
return wb, err
|
||||
}
|
||||
bmRows, err := r.db.QueryContext(ctx,
|
||||
`SELECT DISTINCT band, mode FROM qso WHERE dxcc = ?`, dxcc)
|
||||
`SELECT DISTINCT band, mode FROM qso WHERE dxcc = ?
|
||||
AND band IS NOT NULL AND band != ''
|
||||
AND mode IS NOT NULL AND mode != ''`, dxcc)
|
||||
if err != nil {
|
||||
return wb, fmt.Errorf("dxcc band_modes: %w", err)
|
||||
}
|
||||
@@ -684,15 +703,21 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
||||
// One pass over every distinct (band, mode) in the DXCC, aggregating
|
||||
// "did this call work it?" and "was anything confirmed?" via MAX.
|
||||
// Status precedence: call_c > call_w > dxcc_c > dxcc_w.
|
||||
// Filter NULL/empty band+mode rows — they'd create a NULL group key
|
||||
// that Scan into *string can't handle and would error out the whole
|
||||
// WorkedBefore call, blanking the matrix in the UI.
|
||||
statusRows, err := r.db.QueryContext(ctx, `
|
||||
SELECT band, mode,
|
||||
MAX(CASE WHEN callsign = ? THEN 1 ELSE 0 END),
|
||||
MAX(CASE WHEN callsign = ?
|
||||
MAX(CASE WHEN upper(trim(callsign)) = ? THEN 1 ELSE 0 END),
|
||||
MAX(CASE WHEN upper(trim(callsign)) = ?
|
||||
AND (lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y')
|
||||
THEN 1 ELSE 0 END),
|
||||
MAX(CASE WHEN lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y'
|
||||
THEN 1 ELSE 0 END)
|
||||
FROM qso WHERE dxcc = ?
|
||||
FROM qso
|
||||
WHERE dxcc = ?
|
||||
AND band IS NOT NULL AND band != ''
|
||||
AND mode IS NOT NULL AND mode != ''
|
||||
GROUP BY band, mode`, wb.Callsign, wb.Callsign, dxcc)
|
||||
if err != nil {
|
||||
return wb, fmt.Errorf("band status: %w", err)
|
||||
@@ -886,6 +911,27 @@ func (r *Repo) EntitySlotMap(ctx context.Context, resolveEntity func(callsign st
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// WorkedCallsigns returns the set of every callsign ever logged (uppercased).
|
||||
// One pass, used by the cluster spot colouring to flag "already worked this
|
||||
// exact call" regardless of band/mode — Log4OM/RUMlogNG-style call highlight.
|
||||
func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT DISTINCT upper(callsign) FROM qso WHERE callsign != ''`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]struct{}, 1024)
|
||||
for rows.Next() {
|
||||
var c string
|
||||
if err := rows.Scan(&c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[c] = struct{}{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Count returns the total number of QSOs in the database.
|
||||
func (r *Repo) Count(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
|
||||
@@ -22,11 +22,13 @@ func main() {
|
||||
Height: 900,
|
||||
MinWidth: 1100,
|
||||
MinHeight: 700,
|
||||
WindowStartState: options.Maximised,
|
||||
AssetServer: &assetserver.Options{
|
||||
Assets: assets,
|
||||
},
|
||||
BackgroundColour: &options.RGBA{R: 250, G: 250, B: 249, A: 1},
|
||||
OnStartup: app.startup,
|
||||
OnBeforeClose: app.beforeClose,
|
||||
OnShutdown: app.shutdown,
|
||||
Bind: []interface{}{
|
||||
app,
|
||||
|
||||
Reference in New Issue
Block a user