feat: status bar added

This commit is contained in:
2026-05-30 01:35:50 +02:00
parent 8f1ad126ac
commit 806b39970b
24 changed files with 1933 additions and 451 deletions
+1
View File
@@ -0,0 +1 @@
{"sessionId":"9e3eadf5-7e35-4848-8cf9-515589d63e73","pid":5360,"acquiredAt":1780094656528}
+424 -16
View File
@@ -9,6 +9,7 @@ import (
"math" "math"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -53,8 +54,11 @@ const (
keyStationSOTA = "station.my_sota_ref" keyStationSOTA = "station.my_sota_ref"
keyStationPOTA = "station.my_pota_ref" keyStationPOTA = "station.my_pota_ref"
keyListsBands = "lists.bands" keyListsBands = "lists.bands"
keyListsModes = "lists.modes" keyListsModes = "lists.modes"
keyListsRSTPhone = "lists.rst_phone"
keyListsRSTCW = "lists.rst_cw"
keyListsRSTDigital = "lists.rst_digital"
keyCATEnabled = "cat.enabled" keyCATEnabled = "cat.enabled"
keyCATBackend = "cat.backend" // "omnirig" (only one for now) keyCATBackend = "cat.backend" // "omnirig" (only one for now)
@@ -151,8 +155,11 @@ type ModePreset struct {
// ListsSettings holds the user-customisable dropdown lists used by the // ListsSettings holds the user-customisable dropdown lists used by the
// entry form. Default values match common HF/VHF practice. // entry form. Default values match common HF/VHF practice.
type ListsSettings struct { type ListsSettings struct {
Bands []string `json:"bands"` Bands []string `json:"bands"`
Modes []ModePreset `json:"modes"` Modes []ModePreset `json:"modes"`
RSTPhone []string `json:"rst_phone"` // RS reports for phone modes
RSTCW []string `json:"rst_cw"` // RST reports for CW/RTTY/PSK
RSTDigital []string `json:"rst_digital"` // dB reports for FT8/FT4/JT…
} }
var defaultBands = []string{ var defaultBands = []string{
@@ -171,6 +178,49 @@ var defaultModes = []ModePreset{
{Name: "DIGITALVOICE", DefaultRSTSent: "59", DefaultRSTRcvd: "59"}, {Name: "DIGITALVOICE", DefaultRSTSent: "59", DefaultRSTRcvd: "59"},
} }
// Default RST report lists, editable in Settings → Modes. Phone carries the
// over-S9 reports (59+10…59+60) plus the full RS grid; CW the full RST grid;
// digital the dB reports +30…-30.
var defaultRSTPhone = buildPhoneRST()
var defaultRSTCW = buildCWRST()
var defaultRSTDigital = buildDigitalRST()
func buildPhoneRST() []string {
out := []string{"59+60", "59+50", "59+40", "59+30", "59+20", "59+10"}
for r := 5; r >= 1; r-- {
for s := 9; s >= 1; s-- {
out = append(out, fmt.Sprintf("%d%d", r, s))
}
}
return out
}
func buildCWRST() []string {
var out []string
for r := 5; r >= 1; r-- {
for s := 9; s >= 1; s-- {
for t := 9; t >= 1; t-- {
out = append(out, fmt.Sprintf("%d%d%d", r, s, t))
}
}
}
return out
}
func buildDigitalRST() []string {
var out []string
for db := 30; db >= -30; db-- {
sign := "+"
if db < 0 {
sign = "-"
}
n := db
if n < 0 {
n = -n
}
out = append(out, fmt.Sprintf("%s%02d", sign, n))
}
return out
}
// StationSettings holds the active operator profile. Used to stamp every // StationSettings holds the active operator profile. Used to stamp every
// new QSO so we don't ask the user to retype it for each contact. // new QSO so we don't ask the user to retype it for each contact.
// Multi-profile support (portable / SOTA …) will layer on top of this. // Multi-profile support (portable / SOTA …) will layer on top of this.
@@ -214,7 +264,8 @@ type App struct {
udpRepo *udp.Repo udpRepo *udp.Repo
extsvc *extsvc.Manager extsvc *extsvc.Manager
startupErr string // captured for surfacing to the frontend startupErr string // captured for surfacing to the frontend
dbPath string dbPath string // active database file (may be a user-chosen location)
dataDir string // %APPDATA%/OpsLog — holds config.json, logs, cty.dat
// shuttingDown gates beforeClose re-entry: the first user attempt to // shuttingDown gates beforeClose re-entry: the first user attempt to
// close fires shutdown tasks (backup, future LoTW upload, ...) while // close fires shutdown tasks (backup, future LoTW upload, ...) while
@@ -316,7 +367,7 @@ func (a *App) refreshOperatorGrid() {
// without making the lookup package import dxcc. // without making the lookup package import dxcc.
type dxccAdapter struct{ m *dxcc.Manager } type dxccAdapter struct{ m *dxcc.Manager }
func (a dxccAdapter) Resolve(call string) (country, continent string, cqz, ituz int, lat, lon float64, ok bool) { func (a dxccAdapter) Resolve(call string) (dxccNum int, country, continent string, cqz, ituz int, lat, lon float64, ok bool) {
if a.m == nil { if a.m == nil {
return return
} }
@@ -324,7 +375,7 @@ func (a dxccAdapter) Resolve(call string) (country, continent string, cqz, ituz
if !found || mm.Entity == nil { if !found || mm.Entity == nil {
return return
} }
return mm.Entity.Name, mm.Continent, mm.CQZone, mm.ITUZone, mm.Lat, mm.Lon, true return dxcc.EntityDXCC(mm.Entity.Name), mm.Entity.Name, mm.Continent, mm.CQZone, mm.ITUZone, mm.Lat, mm.Lon, true
} }
func NewApp() *App { return &App{} } func NewApp() *App { return &App{} }
@@ -343,12 +394,29 @@ func (a *App) startup(ctx context.Context) {
fmt.Println("OpsLog:", a.startupErr) fmt.Println("OpsLog:", a.startupErr)
return return
} }
a.dataDir = dataDir
a.dbPath = filepath.Join(dataDir, "opslog.db") a.dbPath = filepath.Join(dataDir, "opslog.db")
// One-shot rename for users coming from the HamLog era. usingDefault := true
if _, err := os.Stat(a.dbPath); os.IsNotExist(err) { // config.json (in the data dir) may point the database to a user-chosen
oldDB := filepath.Join(dataDir, "hamlog.db") // location — e.g. another drive or a synced folder, so it survives a
if _, err := os.Stat(oldDB); err == nil { // Windows reinstall. It lives OUTSIDE the DB since we must know the path
_ = os.Rename(oldDB, a.dbPath) // before opening it.
if custom := readDBPointer(dataDir); custom != "" {
a.dbPath = custom
usingDefault = false
}
if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil {
a.startupErr = "cannot create db folder: " + err.Error()
fmt.Println("OpsLog:", a.startupErr)
return
}
// One-shot rename for users coming from the HamLog era (default location only).
if usingDefault {
if _, err := os.Stat(a.dbPath); os.IsNotExist(err) {
oldDB := filepath.Join(dataDir, "hamlog.db")
if _, err := os.Stat(oldDB); err == nil {
_ = os.Rename(oldDB, a.dbPath)
}
} }
} }
if _, err := applog.Init(dataDir); err != nil { if _, err := applog.Init(dataDir); err != nil {
@@ -632,6 +700,122 @@ func userDataDir() (string, error) {
return newDir, nil return newDir, nil
} }
// ── Database location (config.json pointer) ────────────────────────────
// dbPointer is the tiny bootstrap config stored in the data dir. It must
// live outside the database because we read it to decide which DB to open.
type dbPointer struct {
DBPath string `json:"db_path"`
}
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
// readDBPointer returns the user-chosen DB path, or "" for the default.
func readDBPointer(dataDir string) string {
b, err := os.ReadFile(dbPointerPath(dataDir))
if err != nil {
return ""
}
var c dbPointer
if json.Unmarshal(b, &c) != nil {
return ""
}
return strings.TrimSpace(c.DBPath)
}
// writeDBPointer persists the chosen DB path ("" resets to default).
func writeDBPointer(dataDir, path string) error {
b, _ := json.MarshalIndent(dbPointer{DBPath: strings.TrimSpace(path)}, "", " ")
return os.WriteFile(dbPointerPath(dataDir), b, 0o644)
}
// DatabaseSettings describes the active database file for the Settings UI.
type DatabaseSettings struct {
Path string `json:"path"`
DefaultPath string `json:"default_path"`
IsCustom bool `json:"is_custom"`
}
// GetDatabaseSettings returns where the active database lives.
func (a *App) GetDatabaseSettings() DatabaseSettings {
def := filepath.Join(a.dataDir, "opslog.db")
return DatabaseSettings{Path: a.dbPath, DefaultPath: def, IsCustom: a.dbPath != def}
}
// PickOpenDatabase opens a file dialog to choose an existing .db file.
func (a *App) PickOpenDatabase() (string, error) {
if a.ctx == nil {
return "", fmt.Errorf("no app context")
}
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "Open an OpsLog database",
DefaultDirectory: filepath.Dir(a.dbPath),
Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}},
})
}
// PickSaveDatabase opens a save dialog to choose where to put a copy.
func (a *App) PickSaveDatabase() (string, error) {
if a.ctx == nil {
return "", fmt.Errorf("no app context")
}
return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
Title: "Save the OpsLog database to…",
DefaultFilename: "opslog.db",
Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}},
})
}
// OpenDatabase points OpsLog at an existing database file. Takes effect on
// the next launch.
func (a *App) OpenDatabase(path string) error {
path = strings.TrimSpace(path)
if path == "" {
return fmt.Errorf("no path given")
}
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("database file not found: %w", err)
}
return writeDBPointer(a.dataDir, path)
}
// MoveDatabase writes a clean copy of the current database to dest (which
// must not exist yet) and switches OpsLog to it on the next launch. Uses
// VACUUM INTO so the copy is consistent even with an open WAL.
func (a *App) MoveDatabase(dest string) error {
dest = strings.TrimSpace(dest)
if dest == "" {
return fmt.Errorf("no destination given")
}
if _, err := os.Stat(dest); err == nil {
return fmt.Errorf("a file already exists at %s — pick a new name", dest)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return fmt.Errorf("create folder: %w", err)
}
if a.db == nil {
return fmt.Errorf("database not open")
}
// VACUUM INTO takes a string literal; escape single quotes in the path.
safe := strings.ReplaceAll(dest, "'", "''")
if _, err := a.db.ExecContext(a.ctx, "VACUUM INTO '"+safe+"'"); err != nil {
return fmt.Errorf("copy database: %w", err)
}
return writeDBPointer(a.dataDir, dest)
}
// ResetDatabaseToDefault clears the custom location (back to the data dir).
func (a *App) ResetDatabaseToDefault() error {
return writeDBPointer(a.dataDir, "")
}
// QuitApp closes OpsLog (used to apply a database change on next launch).
func (a *App) QuitApp() {
if a.ctx != nil {
wruntime.Quit(a.ctx)
}
}
// reloadLookupProviders rebuilds the lookup chain from current settings. // reloadLookupProviders rebuilds the lookup chain from current settings.
// Called at startup and after the user saves new credentials. // Called at startup and after the user saves new credentials.
// //
@@ -721,6 +905,16 @@ type StationInfoComputed struct {
Lon float64 `json:"lon"` Lon float64 `json:"lon"`
} }
// ListCountries returns the DXCC entity names for the Country picker, so the
// user selects from a fixed list instead of typing (avoids typos). Empty
// until cty.dat has loaded.
func (a *App) ListCountries() []string {
if a.dxcc == nil {
return nil
}
return a.dxcc.EntityNames()
}
// ComputeStationInfo resolves a station's structured metadata from the // ComputeStationInfo resolves a station's structured metadata from the
// callsign (via cty.dat) and grid (via Maidenhead → lat/lon). The // callsign (via cty.dat) and grid (via Maidenhead → lat/lon). The
// frontend calls this whenever Callsign or Grid changes in the Station // frontend calls this whenever Callsign or Grid changes in the Station
@@ -1646,9 +1840,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
done(matched, total) done(matched, total)
return return
} }
// Snapshot what's already confirmed so we can flag each incoming // Snapshot award-valid confirmations (LoTW + paper QSL — the only two
// confirmation as a NEW DXCC / band / slot. // that count for ARRL awards) so each incoming one is flagged NEW.
sets, _ := a.qso.ConfirmedSlots(ctx) sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"})
var items []ConfirmationItem var items []ConfirmationItem
perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error { perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
q, ok := adif.RecordToQSO(rec) q, ok := adif.RecordToQSO(rec)
@@ -1715,12 +1909,148 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
if a.settings != nil { if a.settings != nil {
_ = a.settings.Set(ctx, keyExtLoTWLastDownload, time.Now().UTC().Format("2006-01-02")) _ = a.settings.Set(ctx, keyExtLoTWLastDownload, time.Now().UTC().Format("2006-01-02"))
} }
case extsvc.ServiceQRZ:
emit("Fetching QRZ.com logbook…")
fr, err := extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, "ALL")
if err != nil {
emit("Fetch failed: " + err.Error())
done(matched, total)
return
}
adifText := fr.ADIF
emit(fmt.Sprintf("QRZ RESULT=%s COUNT=%s, ADIF %d bytes", fr.Result, fr.Count, len(adifText)))
if snip := strings.TrimSpace(adifText); snip != "" {
if len(snip) > 300 {
snip = snip[:300]
}
emit("ADIF head: " + snip)
}
keyIDs, _ := a.qso.DedupeKeyIDs(ctx)
// QRZ confirmations are QRZ-specific (not award-valid), so NEW is
// judged only against other QRZ confirmations.
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"qrzcom_qso_download_status"})
// Ids already QRZ-confirmed locally → "ALREADY CONFIRMED" vs "UPDATED",
// without a per-record DB read.
alreadyQrz := map[int64]bool{}
if rs, e := a.db.QueryContext(ctx, `SELECT id FROM qso WHERE qrzcom_qso_download_status = 'Y'`); e == nil {
for rs.Next() {
var id int64
if rs.Scan(&id) == nil {
alreadyQrz[id] = true
}
}
rs.Close()
}
var items []ConfirmationItem
parsed := 0
allKeys := map[string]bool{} // union of field names seen, for diagnostics
// QRZ FETCH returns headerless ADIF (no <EOH>); prepend one so the
// parser treats the stream as records.
perr := adif.Parse(strings.NewReader("<EOH>\n"+adifText), func(rec adif.Record) error {
parsed++
for k := range rec {
allKeys[k] = true
}
if !qrzRecordConfirmed(rec) {
return nil
}
q, ok := adif.RecordToQSO(rec)
if !ok {
return nil
}
total++
date := rec["qrzcom_qso_download_date"]
if date == "" {
date = time.Now().UTC().Format("20060102")
}
a.enrichContactedFromCty(&q)
line := fmt.Sprintf("Callsign: %s Date: %s Band: %s Mode: %s",
q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04"), q.Band, q.Mode)
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
id, found := keyIDs[key]
switch {
case found:
if alreadyQrz[id] {
emit(line + " ### ALREADY CONFIRMED ###")
} else if e := a.qso.MarkQRZConfirmed(ctx, id, date); e == nil {
alreadyQrz[id] = true
matched++
emit(line + " ### UPDATED ###")
}
case addNotFound:
q.QRZComUploadStatus = "Y"
q.QRZComDownloadStatus = "Y"
q.QRZComDownloadDate = date
if newID, e := a.qso.Add(ctx, q); e == nil {
keyIDs[key] = newID
added++
emit(line + " ### ADDED ###")
}
default:
emit(line + " ### NOT IN LOG ###")
}
// Result row + NEW flags.
dxccNum := 0
if q.DXCC != nil {
dxccNum = *q.DXCC
}
it := ConfirmationItem{
Callsign: q.Callsign,
QSODate: q.QSODate.UTC().Format(time.RFC3339),
Band: q.Band, Mode: q.Mode, Country: q.Country,
}
if dxccNum != 0 {
it.NewDXCC = !sets.DXCC[dxccNum]
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)]
sets.DXCC[dxccNum] = true
sets.Band[qso.BandKey(dxccNum, q.Band)] = true
sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true
}
items = append(items, it)
return nil
})
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items)
}
if perr != nil {
emit("Parse error: " + perr.Error())
}
// Diagnostic: the union of every field name QRZ returned, so we can
// pin the confirmation marker against real data.
keys := make([]string, 0, len(allKeys))
for k := range allKeys {
keys = append(keys, k)
}
sort.Strings(keys)
emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", ")))
emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total))
default: default:
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc)) emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
} }
done(matched+added, total) done(matched+added, total)
} }
// qrzRecordConfirmed reports whether a QRZ FETCH ADIF record represents a
// confirmed QSO. QRZ's confirmation marker isn't clearly documented, so we
// accept the likely candidates; the download's one-time field dump lets us
// pin the exact field against real data and tighten this if needed.
func qrzRecordConfirmed(rec adif.Record) bool {
if strings.EqualFold(rec["qsl_rcvd"], "Y") {
return true
}
if strings.EqualFold(rec["qrzcom_qso_download_status"], "Y") {
return true
}
switch strings.ToUpper(strings.TrimSpace(rec["app_qrzlog_status"])) {
case "C", "Y":
return true
}
return false
}
// enrichContactedFromCty fills a QSO's contacted-station country/DXCC/zones // enrichContactedFromCty fills a QSO's contacted-station country/DXCC/zones
// from cty.dat (offline) — used when adding a not-found confirmation that // from cty.dat (offline) — used when adding a not-found confirmation that
// only carries call/band/mode/date. // only carries call/band/mode/date.
@@ -2444,12 +2774,30 @@ func (a *App) GetListsSettings() (ListsSettings, error) {
if raw, _ := a.settings.Get(a.ctx, keyListsModes); raw != "" { if raw, _ := a.settings.Get(a.ctx, keyListsModes); raw != "" {
_ = json.Unmarshal([]byte(raw), &out.Modes) _ = json.Unmarshal([]byte(raw), &out.Modes)
} }
if raw, _ := a.settings.Get(a.ctx, keyListsRSTPhone); raw != "" {
_ = json.Unmarshal([]byte(raw), &out.RSTPhone)
}
if raw, _ := a.settings.Get(a.ctx, keyListsRSTCW); raw != "" {
_ = json.Unmarshal([]byte(raw), &out.RSTCW)
}
if raw, _ := a.settings.Get(a.ctx, keyListsRSTDigital); raw != "" {
_ = json.Unmarshal([]byte(raw), &out.RSTDigital)
}
if len(out.Bands) == 0 { if len(out.Bands) == 0 {
out.Bands = append([]string(nil), defaultBands...) out.Bands = append([]string(nil), defaultBands...)
} }
if len(out.Modes) == 0 { if len(out.Modes) == 0 {
out.Modes = append([]ModePreset(nil), defaultModes...) out.Modes = append([]ModePreset(nil), defaultModes...)
} }
if len(out.RSTPhone) == 0 {
out.RSTPhone = append([]string(nil), defaultRSTPhone...)
}
if len(out.RSTCW) == 0 {
out.RSTCW = append([]string(nil), defaultRSTCW...)
}
if len(out.RSTDigital) == 0 {
out.RSTDigital = append([]string(nil), defaultRSTDigital...)
}
return out, nil return out, nil
} }
@@ -2469,7 +2817,23 @@ func (a *App) SaveListsSettings(l ListsSettings) error {
if err != nil { if err != nil {
return err return err
} }
return a.settings.Set(a.ctx, keyListsModes, string(m)) if err := a.settings.Set(a.ctx, keyListsModes, string(m)); err != nil {
return err
}
for k, v := range map[string][]string{
keyListsRSTPhone: l.RSTPhone,
keyListsRSTCW: l.RSTCW,
keyListsRSTDigital: l.RSTDigital,
} {
b, err := json.Marshal(v)
if err != nil {
return err
}
if err := a.settings.Set(a.ctx, k, string(b)); err != nil {
return err
}
}
return nil
} }
// SaveStationSettings updates only the six "basic" fields on the active // SaveStationSettings updates only the six "basic" fields on the active
@@ -2624,6 +2988,28 @@ func (a *App) rotatorClient() (*pst.Client, RotatorSettings, error) {
return pst.New(s.Host, s.Port), s, nil return pst.New(s.Host, s.Port), s, nil
} }
// RotatorHeading is the live antenna heading for the status bar.
type RotatorHeading struct {
Enabled bool `json:"enabled"`
OK bool `json:"ok"`
Azimuth int `json:"azimuth"`
Raw string `json:"raw"`
}
// GetRotatorHeading queries PstRotator for the current azimuth. Returns
// Enabled=false when the rotator isn't configured. Polled by the status bar.
func (a *App) GetRotatorHeading() RotatorHeading {
s, err := a.GetRotatorSettings()
if err != nil || !s.Enabled {
return RotatorHeading{Enabled: false}
}
az, raw, herr := pst.New(s.Host, s.Port).Heading()
if herr != nil {
return RotatorHeading{Enabled: true, OK: false, Raw: raw}
}
return RotatorHeading{Enabled: true, OK: true, Azimuth: az, Raw: raw}
}
// RotatorGoTo points the antenna at the given azimuth (and optional // RotatorGoTo points the antenna at the given azimuth (and optional
// elevation if the rotator is configured for it). // elevation if the rotator is configured for it).
func (a *App) RotatorGoTo(az int, el int) error { func (a *App) RotatorGoTo(az int, el int) error {
@@ -2891,6 +3277,28 @@ func (a *App) SendClusterCommand(cmd string) error {
return fmt.Errorf("no enabled cluster server to send to") return fmt.Errorf("no enabled cluster server to send to")
} }
// SendClusterSpot announces a DX spot on the **master** cluster (first
// enabled server). Format is the universal DXSpider/AR-Cluster command
// `DX <freq_khz> <call> <comment>`. The frequency is taken in kHz; call is
// upper-cased; comment is optional (commonly the mode, e.g. "CW").
func (a *App) SendClusterSpot(call string, freqKHz float64, comment string) error {
call = strings.ToUpper(strings.TrimSpace(call))
if call == "" {
return fmt.Errorf("callsign required")
}
if freqKHz <= 0 {
return fmt.Errorf("invalid frequency")
}
// Trim a trailing ".0" so integer kHz stay clean (14205 not 14205.0),
// but keep sub-kHz precision when present (e.g. 10138.7).
freqStr := strconv.FormatFloat(freqKHz, 'f', -1, 64)
cmd := fmt.Sprintf("DX %s %s", freqStr, call)
if c := strings.TrimSpace(comment); c != "" {
cmd += " " + c
}
return a.SendClusterCommand(cmd)
}
// GetClusterStatus returns a snapshot of every active session. Used by // GetClusterStatus returns a snapshot of every active session. Used by
// the UI on mount and to hydrate after a `cluster:state` event. // the UI on mount and to hydrate after a `cluster:state` event.
func (a *App) GetClusterStatus() []cluster.ServerStatus { func (a *App) GetClusterStatus() []cluster.ServerStatus {
+389 -192
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
AlertCircle, Antenna, CheckCircle2, Clock, Compass, ExternalLink, Hash, Loader2, Lock, AlertCircle, Antenna, CheckCircle2, Clock, Compass, ExternalLink, Hash, Loader2, Lock,
Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Send, Settings, Square, Trash2, Unlock, X, Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, Square, Trash2, Unlock, X,
} from 'lucide-react'; } from 'lucide-react';
import { import {
@@ -14,24 +14,25 @@ import {
SetCompactMode, SetCompactMode,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
RefreshCtyDat, RefreshCtyDat,
RotatorGoTo, RotatorStop, RotatorGoTo, RotatorStop, GetRotatorHeading,
OpenExternalURL, OpenExternalURL,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
ListClusterServers, ClusterSpotStatuses, ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
GetCATSettings, GetCATSettings,
OperatingDefaultForBand, OperatingDefaultForBand,
LogUDPLoggedADIF, LogUDPLoggedADIF,
ListCountries,
} from '../wailsjs/go/main/App'; } from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { EventsOn } 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 { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
import { Menubar, type Menu } from '@/components/Menubar'; import { Menubar, type Menu } from '@/components/Menubar';
import { QSLManagerModal } from '@/components/QSLManagerModal'; import { QSLManagerPanel } from '@/components/QSLManagerModal';
import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ConfirmDialog } from '@/components/ConfirmDialog';
import { SettingsModal } from '@/components/SettingsModal'; import { SettingsModal } from '@/components/SettingsModal';
import { QSOEditModal } from '@/components/QSOEditModal'; import { QSOEditModal } from '@/components/QSOEditModal';
import { BandSlotGrid } from '@/components/BandSlotGrid';
import { BandMap } from '@/components/BandMap'; import { BandMap } from '@/components/BandMap';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { ShutdownProgress } from '@/components/ShutdownProgress'; import { ShutdownProgress } from '@/components/ShutdownProgress';
@@ -39,6 +40,7 @@ import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot'; import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel'; import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -54,6 +56,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { pathBetween } from '@/lib/maidenhead'; import { pathBetween } from '@/lib/maidenhead';
import { flagURL } from '@/lib/flags';
type QSO = QSOForm; type QSO = QSOForm;
type ImportResult = adifModels.ImportResult; type ImportResult = adifModels.ImportResult;
@@ -141,6 +144,38 @@ function shortCatError(err?: string): string {
if (e.includes('coinitialize')) return 'COM error'; if (e.includes('coinitialize')) return 'COM error';
return err.length > 24 ? err.slice(0, 22) + '…' : err; return err.length > 24 ? err.slice(0, 22) + '…' : err;
} }
// bandForMHz maps a dial frequency (MHz) to its ADIF band, or '' if outside
// every known allocation (used to auto-fill the band when the freq changes).
function bandForMHz(mhz: number): string {
if (!mhz || isNaN(mhz)) return '';
const plan: [number, number, string][] = [
[1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'],
[10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'],
[24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'],
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [1240, 1300, '23cm'],
];
for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b;
return '';
}
// rstCategory buckets a mode into the report family used for its RST list.
type RSTLists = { phone: string[]; cw: string[]; digital: string[] };
function rstCategory(mode: string): keyof RSTLists {
const m = (mode || '').toUpperCase();
const digital = ['FT8', 'FT4', 'JT65', 'JT9', 'JS8', 'Q65', 'MSK144', 'FST4', 'FST4W', 'MFSK', 'OLIVIA', 'JT4', 'WSPR'];
if (digital.includes(m)) return 'digital';
if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw';
return 'phone';
}
// rstOptions returns the valid report choices for a mode from the user's
// editable lists (Settings → Modes), with a tiny fallback before they load.
function rstOptions(mode: string, lists: RSTLists): string[] {
const cat = rstCategory(mode);
const l = lists[cat];
if (l && l.length) return l;
return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20'];
}
function computePrefix(call: string): string { function computePrefix(call: string): string {
if (!call) return ''; if (!call) return '';
const c = call.trim().toUpperCase().split('/')[0]; const c = call.trim().toUpperCase().split('/')[0];
@@ -159,6 +194,8 @@ export default function App() {
// === Entry === // === Entry ===
const [callsign, setCallsign] = useState(''); const [callsign, setCallsign] = useState('');
// Ref to the callsign input so ESC can snap focus back to it.
const callsignRef = useRef<HTMLInputElement>(null);
// QSO start time — frozen when the operator starts typing the callsign, // QSO start time — frozen when the operator starts typing the callsign,
// logged as ADIF QSO_DATE. End time = save click (logged as QSO_DATE_OFF). // logged as ADIF QSO_DATE. End time = save click (logged as QSO_DATE_OFF).
const [qsoStartedAt, setQsoStartedAt] = useState<Date | null>(null); const [qsoStartedAt, setQsoStartedAt] = useState<Date | null>(null);
@@ -228,6 +265,10 @@ export default function App() {
const [freqMhz, setFreqMhz] = useState(''); const [freqMhz, setFreqMhz] = useState('');
// RX freq for split — only set/shown when the rig is in split mode. // RX freq for split — only set/shown when the rig is in split mode.
const [rxFreqMhz, setRxFreqMhz] = useState(''); const [rxFreqMhz, setRxFreqMhz] = useState('');
// RX band — follows the TX band by default; only differs for cross-band work.
const [bandRx, setBandRx] = useState('20m');
const [countries, setCountries] = useState<string[]>([]);
const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
const [rstSent, setRstSent] = useState('59'); const [rstSent, setRstSent] = useState('59');
const [rstRcvd, setRstRcvd] = useState('59'); const [rstRcvd, setRstRcvd] = useState('59');
const [grid, setGrid] = useState(''); const [grid, setGrid] = useState('');
@@ -248,6 +289,7 @@ export default function App() {
// CAT — receives live rig state via Wails events. // CAT — receives live rig state via Wails events.
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any); const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig // Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default // can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
// in Preferences > Hardware > CAT interface. // in Preferences > Hardware > CAT interface.
@@ -360,11 +402,24 @@ export default function App() {
const [qsos, setQsos] = useState<QSO[]>([]); const [qsos, setQsos] = useState<QSO[]>([]);
const [total, setTotal] = useState<number>(0); const [total, setTotal] = useState<number>(0);
const [error, setError] = useState(''); const [error, setError] = useState('');
// Transient success toast (bottom-right, auto-dismiss). Used for things
// like "spot sent" where a blocking error banner would be overkill.
const [toast, setToast] = useState('');
const showToast = useCallback((msg: string) => {
setToast(msg);
window.setTimeout(() => setToast((t) => (t === msg ? '' : t)), 3500);
}, []);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [filterCallsign, setFilterCallsign] = useState(''); const [filterCallsign, setFilterCallsign] = useState('');
const [filterBand, setFilterBand] = useState(''); const [filterBand, setFilterBand] = useState('');
const [filterMode, setFilterMode] = useState(''); const [filterMode, setFilterMode] = useState('');
const [activeTab, setActiveTab] = useState('recent'); const [activeTab, setActiveTab] = useState('recent');
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
const [qslTabOpen, setQslTabOpen] = useState(false);
function closeQslTab() {
setQslTabOpen(false);
setActiveTab((t) => (t === 'qsl' ? 'recent' : t));
}
// Recent QSOs row cap, persisted. With AG Grid's virtual scroller // Recent QSOs row cap, persisted. With AG Grid's virtual scroller
// huge logs render OK once loaded, but a 25k+ logbook still takes a // huge logs render OK once loaded, but a 25k+ logbook still takes a
// couple of seconds to round-trip from SQLite at launch. Defaulting // couple of seconds to round-trip from SQLite at launch. Defaulting
@@ -410,6 +465,8 @@ export default function App() {
retries?: number; retries?: number;
}; };
const [clusterServerStatuses, setClusterServerStatuses] = useState<ServerStatus[]>([]); const [clusterServerStatuses, setClusterServerStatuses] = useState<ServerStatus[]>([]);
// "Send DX spot" dialog (Log4OM-style) — only reachable when a cluster is up.
const [showSpotModal, setShowSpotModal] = useState(false);
const [clusterServers, setClusterServers] = useState<{ id: number; name: string; enabled: boolean; sort_order: number }[]>([]); const [clusterServers, setClusterServers] = useState<{ id: number; name: string; enabled: boolean; sort_order: number }[]>([]);
// Ring buffer — only keep the last N spots; cluster firehose can be heavy. // Ring buffer — only keep the last N spots; cluster firehose can be heavy.
const [spots, setSpots] = useState<ClusterSpot[]>([]); const [spots, setSpots] = useState<ClusterSpot[]>([]);
@@ -449,7 +506,6 @@ export default function App() {
// close so the next plain "Preferences" launch reverts to default. // close so the next plain "Preferences" launch reverts to default.
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined); const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
const [showDeleteAll, setShowDeleteAll] = useState(false); const [showDeleteAll, setShowDeleteAll] = useState(false);
const [showQSLManager, setShowQSLManager] = useState(false);
const [deletingAll, setDeletingAll] = useState(false); const [deletingAll, setDeletingAll] = useState(false);
const [ctyRefreshing, setCtyRefreshing] = useState(false); const [ctyRefreshing, setCtyRefreshing] = useState(false);
@@ -518,6 +574,44 @@ export default function App() {
return () => { offUploaded(); offDone(); if (t) window.clearTimeout(t); }; return () => { offUploaded(); offDone(); if (t) window.clearTimeout(t); };
}, [refresh]); }, [refresh]);
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
// rotator is disabled (the backend just reads settings and returns).
useEffect(() => {
let alive = true;
const tick = async () => {
try { const h: any = await GetRotatorHeading(); if (alive) setRotatorHeading(h); } catch {}
};
tick();
const id = window.setInterval(tick, 3000);
return () => { alive = false; window.clearInterval(id); };
}, []);
// RX band auto-follows the TX band (only differs for cross-band work).
useEffect(() => { setBandRx(band); }, [band]);
// RX freq mirrors TX freq on every TX change (unless the rig is in split,
// where the RX freq is genuinely different). It stays editable by hand:
// a manual RX edit sticks until the next TX-freq change re-syncs it.
useEffect(() => {
if (!catState.split) setRxFreqMhz(freqMhz);
}, [freqMhz, catState.split]);
// Load the DXCC country list for the Country picker. cty.dat loads a few
// seconds after startup, so retry until it's available.
useEffect(() => {
let tries = 0;
let timer = 0;
const load = async () => {
try {
const c = await ListCountries();
if (c && c.length) { setCountries(c); return; }
} catch {}
if (tries++ < 15) timer = window.setTimeout(load, 2000);
};
load();
return () => { if (timer) window.clearTimeout(timer); };
}, []);
const loadStation = useCallback(async () => { const loadStation = useCallback(async () => {
try { setStation(await GetStationSettings()); } catch {} try { setStation(await GetStationSettings()); } catch {}
}, []); }, []);
@@ -530,6 +624,7 @@ export default function App() {
const loadLists = useCallback(async () => { const loadLists = useCallback(async () => {
try { try {
const l: ListsSettings = await GetListsSettings(); const l: ListsSettings = await GetListsSettings();
setRstLists({ phone: (l as any).rst_phone ?? [], cw: (l as any).rst_cw ?? [], digital: (l as any).rst_digital ?? [] });
if (l.bands && l.bands.length) setBands(l.bands); if (l.bands && l.bands.length) setBands(l.bands);
if (l.modes && l.modes.length) { if (l.modes && l.modes.length) {
setModePresets(l.modes); setModePresets(l.modes);
@@ -581,14 +676,14 @@ export default function App() {
if (!lk.freq && s.freq_hz && s.freq_hz > 0) { if (!lk.freq && s.freq_hz && s.freq_hz > 0) {
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5)); setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
} }
// RX freq (split only): backend follows ADIF — freq_hz = TX, // RX freq: backend follows ADIF — freq_hz = TX, freq_rx_hz = RX.
// freq_rx_hz = RX. Only set when the rig is in split, otherwise the // In split we take the rig's real RX freq; otherwise RX mirrors TX
// field would duplicate TX for no reason. The freq lock covers both. // (the user can still override it by hand). The freq lock covers both.
if (!lk.freq) { if (!lk.freq) {
if (s.split && s.freq_rx_hz && s.freq_rx_hz > 0) { if (s.split && s.freq_rx_hz && s.freq_rx_hz > 0) {
setRxFreqMhz((s.freq_rx_hz / 1_000_000).toFixed(5)); setRxFreqMhz((s.freq_rx_hz / 1_000_000).toFixed(5));
} else { } else if (s.freq_hz && s.freq_hz > 0) {
setRxFreqMhz(''); setRxFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
} }
} }
if (!lk.band && s.band) setBand(s.band); if (!lk.band && s.band) setBand(s.band);
@@ -736,7 +831,7 @@ export default function App() {
callsign: callsign.trim().toUpperCase(), callsign: callsign.trim().toUpperCase(),
qso_date: start.toISOString(), qso_date: start.toISOString(),
qso_date_off: end.toISOString(), qso_date_off: end.toISOString(),
band, mode, freq_hz: freqHz, freq_rx_hz: rxFreqHz, band, band_rx: bandRx, mode, freq_hz: freqHz, freq_rx_hz: rxFreqHz,
rst_sent: rstSent, rst_rcvd: rstRcvd, rst_sent: rstSent, rst_rcvd: rstRcvd,
grid: grid.trim().toUpperCase(), grid: grid.trim().toUpperCase(),
name, qth, country, comment, notes: note, name, qth, country, comment, notes: note,
@@ -981,9 +1076,8 @@ export default function App() {
{ type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' }, { type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' },
{ type: 'separator' }, { type: 'separator' },
{ type: 'item', label: 'Callsign lookup settings…', action: 'tools.lookup' }, { type: 'item', label: 'Callsign lookup settings…', action: 'tools.lookup' },
{ type: 'item', label: 'DX Cluster', action: 'tools.cluster', disabled: true }, { type: 'item', label: 'CAT interface…', action: 'tools.cat' },
{ type: 'item', label: 'CAT control', action: 'tools.cat', disabled: true }, { type: 'item', label: 'Rotator…', action: 'tools.rotator' },
{ type: 'item', label: 'Rotator', action: 'tools.rotator', disabled: true },
{ type: 'separator' }, { type: 'separator' },
// Maintenance — bumped here while we only have one entry. Will move // Maintenance — bumped here while we only have one entry. Will move
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land. // to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
@@ -1004,8 +1098,10 @@ export default function App() {
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break; case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
case 'edit.delete': if (selectedId !== null) askDelete(selectedId); break; case 'edit.delete': if (selectedId !== null) askDelete(selectedId); break;
case 'edit.prefs': setShowSettings(true); break; case 'edit.prefs': setShowSettings(true); break;
case 'tools.qslmanager': setShowQSLManager(true); break; case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
case 'tools.lookup': setSettingsSection('lookup'); setShowSettings(true); break; case 'tools.lookup': setSettingsSection('lookup'); setShowSettings(true); break;
case 'tools.cat': setSettingsSection('cat'); setShowSettings(true); break;
case 'tools.rotator': setSettingsSection('rotator'); setShowSettings(true); break;
case 'tools.refreshCty': refreshCtyDat(); break; case 'tools.refreshCty': refreshCtyDat(); break;
} }
} }
@@ -1097,9 +1193,20 @@ export default function App() {
{catState.split && ( {catState.split && (
<Badge className="bg-amber-100 text-amber-800 border-amber-300 font-mono text-[10px] py-0" variant="outline">SPLIT</Badge> <Badge className="bg-amber-100 text-amber-800 border-amber-300 font-mono text-[10px] py-0" variant="outline">SPLIT</Badge>
)} )}
{/* Band & mode removed here — shown in the QSO entry strip below. */}
{catState.enabled && catState.backend === 'omnirig' && (
<div className="inline-flex rounded-md border border-border overflow-hidden text-[11px] font-sans font-semibold">
{[1, 2].map((n) => {
const active = (catState.rig_num || 1) === n;
return (
<button key={n} type="button" onClick={() => SwitchCATRig(n).catch(() => {})}
className={cn('px-2 py-0.5 transition-colors', active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}
title={`Use OmniRig Rig ${n}`}>R{n}</button>
);
})}
</div>
)}
<div className="w-px h-4 bg-border mx-2" /> <div className="w-px h-4 bg-border mx-2" />
<Badge variant="accent" className="font-mono">{band}</Badge>
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 font-mono" variant="outline">{mode}</Badge>
{/* Bearing controls — three separate buttons so SP and LP are {/* Bearing controls — three separate buttons so SP and LP are
both directly clickable, plus an always-visible Stop. The both directly clickable, plus an always-visible Stop. The
old Shift/Ctrl shortcuts were not discoverable enough. */} old Shift/Ctrl shortcuts were not discoverable enough. */}
@@ -1151,51 +1258,6 @@ export default function App() {
</div> </div>
); );
})()} })()}
{catState.enabled && (
<>
<span
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold font-sans ml-2 border',
catState.connected
? 'bg-emerald-100 text-emerald-800 border-emerald-300'
: 'bg-rose-100 text-rose-800 border-rose-300',
)}
title={
catState.connected
? `CAT: ${catState.rig || catState.backend || 'connected'}`
: catState.error || 'CAT offline'
}
>
<RadioTower className={cn('size-3', catState.connected && 'animate-pulse')} />
{catState.connected
? (catState.rig || 'CAT')
: (shortCatError(catState.error) || 'CAT off')}
</span>
{catState.backend === 'omnirig' && (
<div className="inline-flex rounded-md border border-border overflow-hidden text-[10px] font-sans font-semibold">
{[1, 2].map((n) => {
const active = (catState.rig_num || 1) === n;
return (
<button
key={n}
type="button"
onClick={() => SwitchCATRig(n).catch(() => {})}
className={cn(
'px-1.5 py-0.5 transition-colors',
active
? 'bg-primary text-primary-foreground'
: 'bg-card text-muted-foreground hover:bg-muted',
)}
title={`Use OmniRig Rig ${n}`}
>
R{n}
</button>
);
})}
</div>
)}
</>
)}
</div> </div>
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60"> <div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
@@ -1251,12 +1313,23 @@ export default function App() {
</div> </div>
)} )}
{/* Transient success toast (bottom-right). */}
{toast && (
<div className="fixed bottom-4 right-4 z-[100] flex items-center gap-2 rounded-lg border border-emerald-300 bg-emerald-50 text-emerald-800 px-3.5 py-2 text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2">
<Satellite className="size-4 shrink-0" />
<span>{toast}</span>
<button className="ml-1 text-emerald-600 hover:text-emerald-800" onClick={() => setToast('')}><X className="size-3.5" /></button>
</div>
)}
{/* ===== ENTRY STRIP ===== {/* ===== ENTRY STRIP =====
Enter from any <input> inside the strip logs the QSO. Radix Selects Enter from any <input> inside the strip logs the QSO. Radix Selects
render as <button> elements and are ignored by this handler — they render as <button> elements and are ignored by this handler — they
keep their own keyboard behaviour. */} keep their own keyboard behaviour. */}
<div className={cn(!compact && 'flex gap-2.5 items-stretch px-2.5 pt-2.5 shrink-0')}>
<section <section
className="flex gap-2 items-end flex-wrap px-3 py-2 bg-card border-b border-border shadow-sm shrink-0" className={cn('flex gap-2 items-end flex-wrap content-start px-3 py-2 bg-card shadow-sm border-border',
compact ? 'border-b shrink-0' : 'flex-1 min-w-[560px] max-w-[920px] border rounded-lg')}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') { if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') {
e.preventDefault(); e.preventDefault();
@@ -1264,9 +1337,12 @@ export default function App() {
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
resetEntry(); resetEntry();
// Snap focus back to the callsign field, ready for the next QSO.
callsignRef.current?.focus();
} }
}} }}
> >
{/* ── Row 1: Callsign + RST ── */}
<div className="flex flex-col w-40"> <div className="flex flex-col w-40">
<Label className="mb-1 flex items-center gap-2 h-3.5"> <Label className="mb-1 flex items-center gap-2 h-3.5">
Callsign Callsign
@@ -1294,60 +1370,25 @@ export default function App() {
{!lookupBusy && !lookupResult && lookupError && ( {!lookupBusy && !lookupResult && lookupError && (
<Badge variant="destructive" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider">{lookupError}</Badge> <Badge variant="destructive" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider">{lookupError}</Badge>
)} )}
{/* Contacted entity flag (from its DXCC number). */}
{flagURL(details.dxcc) && (
<img src={flagURL(details.dxcc)} alt="" title={country}
className="h-3.5 ml-auto rounded-[2px] border border-border/50 shadow-sm"
referrerPolicy="no-referrer" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
)}
</Label> </Label>
<Input <Input
ref={callsignRef}
className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card" className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card"
value={callsign} value={callsign}
onChange={(e) => onCallsignInput(e.target.value)} onChange={(e) => onCallsignInput(e.target.value)}
/>
</div>
<div className="flex flex-col w-24">
<Label className="mb-1 h-3.5 flex items-center gap-1">Band <LockBtn k="band" title="band" /></Label>
<Select value={band} onValueChange={onBandUserChange}>
<SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
<SelectContent>{bands.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1">Mode <LockBtn k="mode" title="mode" /></Label>
<Select value={mode} onValueChange={onModeUserChange}>
<SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
<SelectContent>{modes.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1">
{catState.split ? 'TX Freq (MHz)' : 'Freq (MHz)'} <LockBtn k="freq" title="frequency" />
</Label>
<Input
tabIndex={-1}
className="font-mono"
value={freqFocused ? freqMhz : (freqMhz ? fmtFreqDots(freqMhz) : '')}
placeholder="14.250"
onFocus={() => setFreqFocused(true)}
onBlur={() => setFreqFocused(false)}
onChange={(e) => { setFreqMhz(e.target.value); noteManualEdit(); }}
/> />
</div> </div>
{catState.split && (
<div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 text-rose-600">RX Freq (MHz)</Label>
<Input
tabIndex={-1}
value={freqFocused ? rxFreqMhz : (rxFreqMhz ? fmtFreqDots(rxFreqMhz) : '')}
placeholder="14.255"
onFocus={() => setFreqFocused(true)}
onBlur={() => setFreqFocused(false)}
onChange={(e) => { setRxFreqMhz(e.target.value); noteManualEdit(); }}
className="font-mono bg-rose-50/40 border-rose-200 focus:bg-card"
/>
</div>
)}
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST tx</Label> <div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST tx</Label>
<Input value={rstSent} onChange={(e) => { setRstSent(e.target.value); rstUserEditedRef.current = true; }} /> <Combobox value={rstSent} options={rstOptions(mode, rstLists)} onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
</div> </div>
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST rx</Label> <div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST rx</Label>
<Input value={rstRcvd} onChange={(e) => { setRstRcvd(e.target.value); rstUserEditedRef.current = true; }} /> <Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
</div> </div>
<div className="flex flex-col w-24"> <div className="flex flex-col w-24">
<Label className="mb-1 h-3.5 flex items-center gap-1 text-emerald-700"> <Label className="mb-1 h-3.5 flex items-center gap-1 text-emerald-700">
@@ -1405,61 +1446,102 @@ export default function App() {
className={cn('font-mono', locks.end ? '' : 'bg-muted/40 cursor-default')} className={cn('font-mono', locks.end ? '' : 'bg-muted/40 cursor-default')}
/> />
</div> </div>
{/* Optional ID/location fields — hidden in compact mode. */}
{/* ── Row 2: Operator name + QTH + Grid + Country + zones (hidden in compact) ── */}
{!compact && <> {!compact && <>
<div className="flex flex-col w-24"><Label className="mb-1 h-3.5">Grid</Label> <div className="basis-full h-0" aria-hidden />
<Input value={grid} placeholder="JN05" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} /> <div className="flex flex-col w-48"><Label className="mb-1 h-3.5">Name</Label>
</div>
<div className="flex flex-col flex-1 min-w-[100px]"><Label className="mb-1 h-3.5">Name</Label>
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} /> <Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
</div> </div>
<div className="flex flex-col flex-1 min-w-[100px]"><Label className="mb-1 h-3.5">QTH</Label> <div className="flex flex-col w-36"><Label className="mb-1 h-3.5">QTH</Label>
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} /> <Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
</div> </div>
<div className="flex flex-col flex-1 min-w-[100px]"><Label className="mb-1 h-3.5">Country</Label> <div className="flex flex-col w-20"><Label className="mb-1 h-3.5">Grid</Label>
<Input value={country} onChange={(e) => { setCountry(e.target.value); markEdited('country'); }} /> <Input value={grid} placeholder="JN05" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
</div> </div>
{/* Numeric DXCC metadata + short-path azimuth — surfaced in the <div className="flex flex-col w-40">
main strip per user request (LP + distances stay in F2). */} <Label className="mb-1 h-3.5 flex items-center gap-1.5">
<div className="flex flex-col w-16"><Label className="mb-1 h-3.5">DXCC #</Label> Country
<Input {flagURL(details.dxcc) && (
type="number" <img src={flagURL(details.dxcc)} alt="" className="h-3 rounded-[2px] border border-border/50 shadow-sm"
className="font-mono" referrerPolicy="no-referrer" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
value={details.dxcc ?? ''} )}
onChange={(e) => updateDetails({ dxcc: e.target.value === '' ? undefined : parseInt(e.target.value, 10) || undefined })} </Label>
placeholder="—" <Combobox value={country} options={countries} placeholder="Country" onChange={(v) => { setCountry(v); markEdited('country'); }} />
/>
</div> </div>
<div className="flex flex-col w-16"><Label className="mb-1 h-3.5">CQ</Label> {/* DXCC # and Continent are derived from the callsign — read-only.
<Input CQ/ITU stay editable but as plain text (no number spinners).
type="number" Kept compact (Log4OM-style) — just wide enough for their digits. */}
className="font-mono" <div className="flex flex-col w-11"><Label className="mb-1 h-3.5">DXCC</Label>
value={details.cqz ?? ''} <Input readOnly tabIndex={-1} className="font-mono bg-muted/40 cursor-default text-center px-1 text-xs"
onChange={(e) => updateDetails({ cqz: e.target.value === '' ? undefined : parseInt(e.target.value, 10) || undefined })} value={details.dxcc ?? ''} placeholder="—" />
placeholder="—"
/>
</div> </div>
<div className="flex flex-col w-16"><Label className="mb-1 h-3.5">ITU</Label> <div className="flex flex-col w-9"><Label className="mb-1 h-3.5">CQ</Label>
<Input <Input inputMode="numeric" maxLength={2} className="font-mono text-center px-1 text-xs" value={details.cqz ?? ''} placeholder="—"
type="number" onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); updateDetails({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} />
className="font-mono"
value={details.ituz ?? ''}
onChange={(e) => updateDetails({ ituz: e.target.value === '' ? undefined : parseInt(e.target.value, 10) || undefined })}
placeholder="—"
/>
</div> </div>
<div className="flex flex-col w-14"><Label className="mb-1 h-3.5">Cont</Label> <div className="flex flex-col w-9"><Label className="mb-1 h-3.5">ITU</Label>
<Input <Input inputMode="numeric" maxLength={2} className="font-mono text-center px-1 text-xs" value={details.ituz ?? ''} placeholder="—"
className="font-mono uppercase" onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); updateDetails({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
value={details.cont} </div>
onChange={(e) => updateDetails({ cont: e.target.value.toUpperCase() })} <div className="flex flex-col w-9"><Label className="mb-1 h-3.5">Cont</Label>
placeholder="—" <Input readOnly tabIndex={-1} className="font-mono uppercase bg-muted/40 cursor-default text-center px-1 text-xs"
maxLength={2} value={details.cont} placeholder="—" />
/>
</div> </div>
</>} </>}
{/* Comment stays visible in compact mode — handy for quick contest/
portable annotations alongside the basic frequency info. */} {/* ── Row 3: Freq + Band + Mode + Band RX + RX Freq ── */}
<div className="basis-full h-0" aria-hidden />
<div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1">
{catState.split ? 'TX Freq (MHz)' : 'Freq (MHz)'} <LockBtn k="freq" title="frequency" />
</Label>
<Input
tabIndex={-1}
className="font-mono"
value={freqFocused ? freqMhz : (freqMhz ? fmtFreqDots(freqMhz) : '')}
placeholder="14.250"
onFocus={() => setFreqFocused(true)}
onBlur={() => setFreqFocused(false)}
onChange={(e) => { setFreqMhz(e.target.value); noteManualEdit(); const b = bandForMHz(parseFloat(e.target.value)); if (b) setBand(b); }}
/>
</div>
<div className="flex flex-col w-24">
<Label className="mb-1 h-3.5 flex items-center gap-1">Band <LockBtn k="band" title="band" /></Label>
<Select value={band} onValueChange={onBandUserChange}>
<SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
<SelectContent>{bands.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1">Mode <LockBtn k="mode" title="mode" /></Label>
<Select value={mode} onValueChange={onModeUserChange}>
<SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
<SelectContent>{modes.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex flex-col w-24">
<Label className="mb-1 h-3.5">Band RX</Label>
<Select value={bandRx} onValueChange={setBandRx}>
<SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
<SelectContent>{bands.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex flex-col w-28">
<Label className={cn('mb-1 h-3.5', catState.split && 'text-rose-600')}>RX Freq (MHz)</Label>
<Input
tabIndex={-1}
value={freqFocused ? rxFreqMhz : (rxFreqMhz ? fmtFreqDots(rxFreqMhz) : '')}
placeholder="14.255"
onFocus={() => setFreqFocused(true)}
onBlur={() => setFreqFocused(false)}
onChange={(e) => { setRxFreqMhz(e.target.value); noteManualEdit(); const rb = bandForMHz(parseFloat(e.target.value)); if (rb) setBandRx(rb); }}
className={cn('font-mono', catState.split && 'bg-rose-50/40 border-rose-200 focus:bg-card')}
/>
</div>
{/* ── Row 4: Comment + Note ── */}
<div className="basis-full h-0" aria-hidden />
<div className="flex flex-col flex-1 min-w-[120px]"><Label className="mb-1 h-3.5">Comment</Label> <div className="flex flex-col flex-1 min-w-[120px]"><Label className="mb-1 h-3.5">Comment</Label>
<Input value={comment} onChange={(e) => setComment(e.target.value)} /> <Input value={comment} onChange={(e) => setComment(e.target.value)} />
</div> </div>
@@ -1468,60 +1550,74 @@ export default function App() {
<Input value={note} onChange={(e) => setNote(e.target.value)} /> <Input value={note} onChange={(e) => setNote(e.target.value)} />
</div> </div>
)} )}
<div className="flex flex-col"> <div className="flex flex-col ml-auto">
<Label className="mb-1 h-3.5">&nbsp;</Label> <Label className="mb-1 h-3.5">&nbsp;</Label>
<Button onClick={save} disabled={saving} className="h-8"> <div className="flex gap-2">
<Send className="size-3.5" /> {/* Send DX spot — only when a cluster is connected. Pre-fills the
{saving ? '…' : 'Log QSO'} dialog from the current entry (or the last logged QSO). */}
</Button> {clusterServerStatuses.some((s) => s.state === 'connected') && (
<Button
type="button"
variant="outline"
onClick={() => setShowSpotModal(true)}
className="h-8"
title="Send a DX spot to the master cluster"
>
<Satellite className="size-3.5" />
Spot
</Button>
)}
<Button onClick={save} disabled={saving} className="h-8">
<Send className="size-3.5" />
{saving ? '…' : 'Log QSO'}
</Button>
</div>
</div> </div>
</section> </section>
{/* In compact mode the entry strip is the whole app — hide everything {/* Right of the entry (Log4OM-style): F1 Stats (matrix) + F2-F5 detail
else and let the user re-expand with the topbar toggle. */} tabs, then reserved free space. Hidden in compact mode. */}
{compact ? null : <> {!compact && (
{/* ===== BAND/SLOT GRID ===== */} <div className="w-[560px] shrink-0 min-h-0 flex flex-col">
{/* QRZ profile picture sits next to the matrix when the user has <DetailsPanel
opted in (Settings → Lookup → Show QRZ profile pictures). The callsign={callsign}
backend returns image_url="" when the toggle is off, so we prefix={prefix}
don't need to re-check the setting here. */} operatorGrid={station.my_grid}
<div className="flex items-start gap-3"> remoteGrid={grid}
<div className="flex-1 min-w-0"> details={details}
<BandSlotGrid wb={wb} busy={wbBusy} currentBand={band} currentMode={mode} /> onChange={updateDetails}
wb={wb}
wbBusy={wbBusy}
band={band}
mode={mode}
/>
</div> </div>
{lookupResult?.image_url && ( )}
<a {/* Reserved free space to the right — shows the QRZ profile photo large
href={lookupResult.image_url} so it's actually legible. Click opens the full-size image on QRZ. */}
onClick={(e) => { {!compact && lookupResult?.image_url && (
e.preventDefault(); <div className="flex-1 min-w-0 flex items-center">
OpenExternalURL(lookupResult.image_url!).catch((err) => setError(String(err?.message ?? err))); <button
}} type="button"
onClick={() => lookupResult.image_url && OpenExternalURL(lookupResult.image_url).catch((err) => setError(String(err?.message ?? err)))}
className="rounded-lg border border-border overflow-hidden hover:border-primary/60 transition-colors bg-muted/20"
title="Open full-size on QRZ.com" title="Open full-size on QRZ.com"
className="block shrink-0 rounded border border-border overflow-hidden hover:border-primary/60 transition-colors"
> >
<img <img
src={lookupResult.image_url} src={lookupResult.image_url}
alt={`${callsign} profile`} alt="profile"
className="block w-[160px] h-[120px] object-cover bg-muted/30" className="block max-h-[180px] max-w-full w-auto object-contain"
loading="lazy" loading="lazy"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/> />
</a> </button>
)} </div>
</div> )}
</div>{/* /entry + aside row */}
{/* ===== F2-F5 DETAILS ===== */}
<DetailsPanel
callsign={callsign}
prefix={prefix}
operatorGrid={station.my_grid}
remoteGrid={grid}
details={details}
onChange={updateDetails}
/>
{/* ===== LOWER: tabs+table | call history | (optional) band map ===== */} {/* ===== LOWER: tabs+table | call history | (optional) band map ===== */}
{compact ? null : <>
<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]')}> <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"> <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 flex-1"> <Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0 flex-1">
@@ -1540,6 +1636,21 @@ export default function App() {
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="awards">Awards</TabsTrigger> <TabsTrigger value="awards">Awards</TabsTrigger>
<TabsTrigger value="propagation">Propagation</TabsTrigger> <TabsTrigger value="propagation">Propagation</TabsTrigger>
{qslTabOpen && (
<TabsTrigger value="qsl" className="gap-1.5">
QSL Manager
<span
role="button"
aria-label="Close QSL Manager"
title="Close"
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
onPointerDown={(e) => { e.stopPropagation(); }}
onClick={(e) => { e.stopPropagation(); closeQslTab(); }}
>
<X className="size-3" />
</span>
</TabsTrigger>
)}
</TabsList> </TabsList>
<TabsContent value="recent" className="mt-0 flex flex-col min-h-0 flex-1"> <TabsContent value="recent" className="mt-0 flex flex-col min-h-0 flex-1">
@@ -1949,6 +2060,15 @@ export default function App() {
<WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} /> <WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} />
</TabsContent> </TabsContent>
{/* Opened on demand from Tools → QSL Manager; closable via the
tab's × . forceMount keeps its state (and a running download
updating) while you work on other tabs. */}
{qslTabOpen && (
<TabsContent value="qsl" forceMount className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
<QSLManagerPanel />
</TabsContent>
)}
{(['main','awards','propagation'] as const).map((t) => ( {(['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"> <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" /> <Hash className="size-10 opacity-30" />
@@ -1985,9 +2105,87 @@ export default function App() {
</div> </div>
</>} </>}
{/* ===== STATUS BAR ===== */}
{!compact && (() => {
const clusterUp = clusterServerStatuses.some((s) => s.state === 'connected');
const catUp = catState.enabled && catState.connected;
const Chip = ({ on, label, title, onClick, disabled }: { on: boolean; label: React.ReactNode; title?: string; onClick?: () => void; disabled?: boolean }) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={cn(
'inline-flex items-center gap-1.5 px-2 h-5 rounded border text-[11px] transition-colors',
disabled ? 'opacity-50 cursor-default border-transparent'
: 'border-border hover:bg-muted cursor-pointer',
)}
>
<span className={cn('size-2 rounded-full', on ? 'bg-emerald-500' : 'bg-muted-foreground/40')} />
{label}
</button>
);
return (
<footer className="flex items-center gap-2 px-3 h-7 bg-card border-t border-border shrink-0">
<span className="text-[11px] text-muted-foreground">
QSO count <strong className="text-foreground font-mono">{total.toLocaleString('en-US')}</strong>
</span>
<div className="w-px h-4 bg-border mx-1" />
<Chip on={clusterUp} label="Cluster" title={clusterUp ? 'Cluster connected' : 'Cluster offline'} onClick={() => setActiveTab('cluster')} />
<Chip
on={catUp}
label={<span className="inline-flex items-center gap-1"><RadioTower className="size-3" />{catUp ? (catState.rig || 'CAT') : (catState.enabled ? (shortCatError(catState.error) || 'CAT off') : 'CAT')}</span>}
title={catUp ? `CAT: ${catState.rig || catState.backend || 'connected'}` : (catState.error || 'CAT')}
onClick={() => { setSettingsSection('cat'); setShowSettings(true); }}
/>
<Chip
on={rotatorHeading.enabled && rotatorHeading.ok}
label={rotatorHeading.enabled && rotatorHeading.ok ? `Rotator ${rotatorHeading.azimuth}°` : 'Rotator'}
title={rotatorHeading.enabled ? (rotatorHeading.ok ? `Antenna heading ${rotatorHeading.azimuth}°` : 'Rotator: no position') : 'Rotator disabled'}
disabled={!rotatorHeading.enabled}
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
/>
<div className="flex-1" />
</footer>
);
})()}
{editingQSO && ( {editingQSO && (
<QSOEditModal qso={editingQSO} onSave={onModalSave} onDelete={onModalDelete} onClose={() => setEditingQSO(null)} /> <QSOEditModal qso={editingQSO} onSave={onModalSave} onDelete={onModalDelete} onClose={() => setEditingQSO(null)} />
)} )}
<SendSpotModal
open={showSpotModal}
onClose={() => setShowSpotModal(false)}
// Callsign: the QSO-entry call, else the last logged QSO.
defaultCall={callsign.trim() || qsos[0]?.callsign || ''}
// Freq: the entry TX freq (kHz), else the last logged QSO's.
defaultFreqKHz={
parseFloat(freqMhz) > 0
? Math.round(parseFloat(freqMhz) * 1000 * 10) / 10
: (qsos[0]?.freq_hz ? Math.round((qsos[0].freq_hz / 1000) * 10) / 10 : 0)
}
defaultMode={mode || qsos[0]?.mode || ''}
targetName={
clusterServers
.filter((s) => s.enabled)
.sort((a, b) => a.sort_order - b.sort_order)[0]?.name
}
recent={qsos.slice(0, 8).map((q): RecentSpotQSO => ({
callsign: q.callsign,
freqKHz: q.freq_hz ? Math.round((q.freq_hz / 1000) * 10) / 10 : 0,
mode: q.mode ?? '',
band: q.band,
}))}
onSend={async (call, freqKHz, comment) => {
await SendClusterSpot(call, freqKHz, comment);
const target = clusterServers
.filter((s) => s.enabled)
.sort((a, b) => a.sort_order - b.sort_order)[0]?.name;
showToast(`Spot ${call} sent${target ? ` on ${target}` : ''}`);
}}
/>
{showSettings && ( {showSettings && (
<SettingsModal <SettingsModal
initialSection={settingsSection} initialSection={settingsSection}
@@ -1995,7 +2193,6 @@ export default function App() {
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); }} onSaved={() => { loadStation(); loadLists(); loadCATCfg(); }}
/> />
)} )}
<QSLManagerModal open={showQSLManager} onClose={() => setShowQSLManager(false)} />
{deletingQSO && ( {deletingQSO && (
<ConfirmDialog <ConfirmDialog
+97 -7
View File
@@ -65,6 +65,28 @@ const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
'6m': [[50000, 50100, 'fill-emerald-50'], [50100, 50500, 'fill-amber-50']], '6m': [[50000, 50100, 'fill-emerald-50'], [50100, 50500, 'fill-amber-50']],
}; };
// Small coloured dot + label used in the band-map legend strip.
function LegendDot({ cls, label }: { cls: string; label: string }) {
return (
<span className="inline-flex items-center gap-1">
<span className={cn('size-2 rounded-full', cls)} />
{label}
</span>
);
}
// Human-readable label for a spot status — used in the pill hover tooltip
// so the operator can see WHY a spot is coloured the way it is.
function statusLabel(s: string): string {
switch (s) {
case 'new': return 'NEW DXCC (entity never worked)';
case 'new-band': return 'NEW BAND (entity not worked on this band)';
case 'new-slot': return 'NEW SLOT (mode not worked on this band)';
case 'worked': return 'Worked (this band + mode already in log)';
default: return 'Entity not resolved';
}
}
function statusStyle(s: string): { pill: string; bar: string; line: string; dot: string } { function statusStyle(s: string): { pill: string; bar: string; line: string; dot: string } {
// pill = full pill background+text+border // pill = full pill background+text+border
// bar = thick left accent inside the pill // bar = thick left accent inside the pill
@@ -114,6 +136,13 @@ const PILL_GAP = 32; // px between scale border and first pill (room for leade
const LABEL_W = 200; const LABEL_W = 200;
const TOP_PAD = 14; // px of breathing room above/below the band edges so const TOP_PAD = 14; // px of breathing room above/below the band edges so
const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0 const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
// Max FT-mode (FT8/FT4/…) pills drawn at once. These pile up on a single
// watering-hole frequency and otherwise spawn hundreds of spots that fan out
// and cover the whole map. ONLY the FT family is capped — CW, SSB and other
// digital spots are always shown in full. When more than this FT spots are in
// band we keep the most useful (new entities first, worked last; ties broken
// by closeness to the rig freq).
const MAX_VISIBLE_SPOTS = 30;
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose }: Props) { export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose }: Props) {
const range = BAND_RANGES[band]; const range = BAND_RANGES[band];
@@ -146,19 +175,53 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
// larger). When more labels stack than fit in the band's natural pixel // larger). When more labels stack than fit in the band's natural pixel
// span, totalH grows so scrolling reveals them. // span, totalH grows so scrolling reveals them.
type Placed = { spot: Spot; freqY: number; labelY: number }; type Placed = { spot: Spot; freqY: number; labelY: number };
const { placed, totalH } = useMemo<{ placed: Placed[]; totalH: number }>(() => { const { placed, totalH, hidden } = useMemo<{ placed: Placed[]; totalH: number; hidden: number }>(() => {
// innerH is the band's stretched pixel span; total adds top+bottom // innerH is the band's stretched pixel span; total adds top+bottom
// padding so the edge freq labels aren't clipped at y=0 / y=H. // padding so the edge freq labels aren't clipped at y=0 / y=H.
const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz); const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz);
if (!range) return { placed: [], totalH: innerH + TOP_PAD + BOT_PAD }; if (!range) return { placed: [], totalH: innerH + TOP_PAD + BOT_PAD, hidden: 0 };
const seen = new Set<string>(); const seen = new Set<string>();
const filtered: Spot[] = []; const inBand: Spot[] = [];
for (const s of spots) { for (const s of spots) {
if (s.freq_khz < lo || s.freq_khz > hi) continue; if (s.freq_khz < lo || s.freq_khz > hi) continue;
if (seen.has(s.dx_call)) continue; if (seen.has(s.dx_call)) continue;
seen.add(s.dx_call); seen.add(s.dx_call);
filtered.push(s); inBand.push(s);
} }
// Only the FT family (FT8/FT4/…) is capped — it's what floods a single
// watering-hole frequency. Everything else (CW, SSB, RTTY, PSK, …) is
// always shown in full.
const isFlood = (s: Spot) => /^FT/.test(inferSpotMode(s.comment ?? '', s.freq_hz));
const ftSpots = inBand.filter(isFlood);
const otherSpots = inBand.filter((s) => !isFlood(s));
// Rank an FT spot by usefulness (new entity → unworked → worked); ties
// break by proximity to the rig frequency. Keep the top MAX_VISIBLE_SPOTS.
const rank = (s: Spot) => {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
switch (spotStatus[k]?.status ?? '') {
case 'new': return 0;
case 'new-band': return 1;
case 'new-slot': return 2;
case 'worked': return 4;
default: return 3;
}
};
let keptFt = ftSpots;
let hiddenCount = 0;
if (ftSpots.length > MAX_VISIBLE_SPOTS) {
const rigK = currentFreqHz ? currentFreqHz / 1000 : (lo + hi) / 2;
keptFt = [...ftSpots]
.sort((a, b) => {
const r = rank(a) - rank(b);
if (r !== 0) return r;
return Math.abs(a.freq_khz - rigK) - Math.abs(b.freq_khz - rigK);
})
.slice(0, MAX_VISIBLE_SPOTS);
hiddenCount = ftSpots.length - keptFt.length;
}
const filtered = [...otherSpots, ...keptFt];
filtered.sort((a, b) => b.freq_khz - a.freq_khz); filtered.sort((a, b) => b.freq_khz - a.freq_khz);
// Desired pill-CENTRE Y for each spot = its true frequency's Y. // Desired pill-CENTRE Y for each spot = its true frequency's Y.
@@ -206,14 +269,32 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
return { return {
placed: out, placed: out,
totalH: Math.max(innerH + TOP_PAD + BOT_PAD, lastLabelBottom + BOT_PAD), totalH: Math.max(innerH + TOP_PAD + BOT_PAD, lastLabelBottom + BOT_PAD),
hidden: hiddenCount,
}; };
}, [spots, range, lo, hi, span, pxPerKHz, containerH]); }, [spots, range, lo, hi, span, pxPerKHz, containerH, spotStatus, currentFreqHz]);
// freqToY for elements rendered outside the memo (ticks, rig pointer). // freqToY for elements rendered outside the memo (ticks, rig pointer).
// Must mirror the same offset so the rig triangle sits on the right kHz. // Must mirror the same offset so the rig triangle sits on the right kHz.
const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz); const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz);
const freqToY = (kHz: number) => TOP_PAD + (1 - (kHz - lo) / span) * innerH; const freqToY = (kHz: number) => TOP_PAD + (1 - (kHz - lo) / span) * innerH;
// Auto-centre on the rig frequency when the map opens or the band changes
// (once per band, so it doesn't fight the user's manual scrolling). Waits
// for the scroller height to be measured and a valid in-band rig freq.
const centeredForRef = useRef<string>('');
useEffect(() => {
if (!range || containerH <= 0 || currentFreqHz <= 0) return;
const kHz = currentFreqHz / 1000;
if (kHz < lo || kHz > hi) return;
if (centeredForRef.current === band) return;
const el = scrollerRef.current;
if (!el) return;
centeredForRef.current = band;
el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2);
// freqToY is recomputed each render; intentionally excluded from deps.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [band, containerH, currentFreqHz, range, lo, hi]);
useEffect(() => { useEffect(() => {
const el = scrollerRef.current; const el = scrollerRef.current;
if (!el) return; if (!el) return;
@@ -375,7 +456,8 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
{/* Pills absolutely positioned at their (anti-overlapped) Y */} {/* Pills absolutely positioned at their (anti-overlapped) Y */}
{placed.map((p, i) => { {placed.map((p, i) => {
const k = spotStatusKey(p.spot.dx_call, p.spot.band ?? '', p.spot.comment ?? '', p.spot.freq_hz); const k = spotStatusKey(p.spot.dx_call, p.spot.band ?? '', p.spot.comment ?? '', p.spot.freq_hz);
const st = spotStatus[k]?.status ?? ''; const entry = spotStatus[k];
const st = entry?.status ?? '';
const style = statusStyle(st); const style = statusStyle(st);
const mode = inferSpotMode(p.spot.comment ?? '', p.spot.freq_hz); const mode = inferSpotMode(p.spot.comment ?? '', p.spot.freq_hz);
return ( return (
@@ -389,7 +471,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
'hover:translate-x-0.5 hover:shadow', 'hover:translate-x-0.5 hover:shadow',
style.pill, style.pill,
)} )}
title={`${p.spot.dx_call} · ${p.spot.freq_khz.toFixed(1)} kHz${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`} title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st)}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
> >
{/* Status accent strip on the left */} {/* Status accent strip on the left */}
<span className={cn('w-1 shrink-0', style.bar)} aria-hidden /> <span className={cn('w-1 shrink-0', style.bar)} aria-hidden />
@@ -406,8 +488,16 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
})} })}
</div> </div>
</div> </div>
{/* Colour legend — what each pill colour means. */}
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
<LegendDot cls="bg-rose-400" label="New DXCC" />
<LegendDot cls="bg-amber-400" label="New band" />
<LegendDot cls="bg-yellow-300" label="New slot (mode)" />
<LegendDot cls="bg-muted-foreground/30" label="Worked" />
</div>
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0"> <div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
scroll · ctrl+wheel = zoom · = jump to rig scroll · ctrl+wheel = zoom · = jump to rig
{hidden > 0 && <span className="text-amber-600"> · {hidden} FT spot{hidden > 1 ? 's' : ''} hidden (top {MAX_VISIBLE_SPOTS} shown)</span>}
</div> </div>
</div> </div>
); );
+24 -16
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { ChevronUp, Construction } from 'lucide-react'; import { Construction } from 'lucide-react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
@@ -8,6 +8,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { pathBetween } from '@/lib/maidenhead'; import { pathBetween } from '@/lib/maidenhead';
import { BandSlotGrid } from '@/components/BandSlotGrid';
export interface DetailsState { export interface DetailsState {
state: string; state: string;
@@ -45,9 +46,16 @@ interface Props {
remoteGrid: string; // entry-strip Grid value — destination remoteGrid: string; // entry-strip Grid value — destination
details: DetailsState; details: DetailsState;
onChange: (patch: Partial<DetailsState>) => void; onChange: (patch: Partial<DetailsState>) => void;
// Stats (F1) tab content: the worked-before matrix + optional QRZ image.
wb?: any;
wbBusy?: boolean;
band: string;
mode: string;
imageUrl?: string;
onOpenImage?: () => void;
} }
type TabName = 'info' | 'awards' | 'my' | 'extended'; type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
const PROP_MODES = ['NONE','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR']; const PROP_MODES = ['NONE','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
@@ -67,8 +75,8 @@ function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3
); );
} }
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange }: Props) { export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode }: Props) {
const [open, setOpen] = useState<TabName | null>(null); const [open, setOpen] = useState<TabName>('stats');
// Bearing/distance from operator's home grid to the remote station. // Bearing/distance from operator's home grid to the remote station.
// Recomputed only when either grid actually changes. // Recomputed only when either grid actually changes.
@@ -79,7 +87,7 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
const fmtDeg = (n: number) => `${Math.round(n)}°`; const fmtDeg = (n: number) => `${Math.round(n)}°`;
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`; const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
function toggle(t: TabName) { setOpen((prev) => (prev === t ? null : t)); } function toggle(t: TabName) { setOpen(t); }
const satelliteMode = !!details.sat_name || !!details.sat_mode || details.prop_mode === 'SAT'; const satelliteMode = !!details.sat_name || !!details.sat_mode || details.prop_mode === 'SAT';
function setSatellite(on: boolean) { function setSatellite(on: boolean) {
@@ -94,6 +102,7 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
} }
const tabs: { key: TabName; label: string }[] = [ const tabs: { key: TabName; label: string }[] = [
{ key: 'stats', label: 'Stats (F1)' },
{ key: 'info', label: 'Info (F2)' }, { key: 'info', label: 'Info (F2)' },
{ key: 'awards', label: 'Awards (F3)' }, { key: 'awards', label: 'Awards (F3)' },
{ key: 'my', label: 'My (F4)' }, { key: 'my', label: 'My (F4)' },
@@ -101,8 +110,8 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
]; ];
return ( return (
<section className="border-b border-border bg-card shrink-0"> <section className="border border-border rounded-lg bg-card flex flex-col flex-1 min-h-0 overflow-hidden">
<nav className="flex items-center gap-1 px-3 bg-muted/40 border-b border-border"> <nav className="flex items-center gap-1 px-3 bg-muted/40 border-b border-border shrink-0">
{tabs.map((t) => ( {tabs.map((t) => (
<button <button
key={t.key} key={t.key}
@@ -117,17 +126,15 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
{t.label} {t.label}
</button> </button>
))} ))}
{open && (
<button
onClick={() => setOpen(null)}
className="ml-auto text-muted-foreground hover:text-foreground p-1.5"
title="Close"
>
<ChevronUp className="size-3.5" />
</button>
)}
</nav> </nav>
<div className="overflow-y-auto min-h-0">
{open === 'stats' && (
<div className="px-3 py-2.5">
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} />
</div>
)}
{open === 'info' && ( {open === 'info' && (
<div className="grid grid-cols-6 gap-2 px-3 py-2.5"> <div className="grid grid-cols-6 gap-2 px-3 py-2.5">
<Field label="State / pref"> <Field label="State / pref">
@@ -251,6 +258,7 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
</Field> </Field>
</div> </div>
)} )}
</div>
</section> </section>
); );
} }
+173 -204
View File
@@ -1,8 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { UploadCloud, DownloadCloud, Search, Loader2 } from 'lucide-react'; import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { import {
@@ -28,7 +25,6 @@ const SERVICES = [
{ v: 'lotw', label: 'LoTW' }, { v: 'lotw', label: 'LoTW' },
]; ];
// Sent-status filter values. Empty string = blank/none.
const SENT_STATUSES = [ const SENT_STATUSES = [
{ v: 'R', label: 'Requested' }, { v: 'R', label: 'Requested' },
{ v: 'N', label: 'No' }, { v: 'N', label: 'No' },
@@ -46,7 +42,9 @@ function fmtDate(iso: string): string {
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`; return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
} }
export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) { // QSL Manager as an in-app tab panel: upload logged QSOs to online logbooks
// and download confirmations, while the rest of the app stays usable.
export function QSLManagerPanel() {
const [service, setService] = useState('lotw'); const [service, setService] = useState('lotw');
const [sent, setSent] = useState('R'); const [sent, setSent] = useState('R');
const [rows, setRows] = useState<UploadRow[]>([]); const [rows, setRows] = useState<UploadRow[]>([]);
@@ -55,22 +53,20 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
const [error, setError] = useState(''); const [error, setError] = useState('');
const [addNotFound, setAddNotFound] = useState(false); const [addNotFound, setAddNotFound] = useState(false);
// 'upload' shows the Select-required search results; 'confirmations' shows
// the rows returned by a Download.
const [viewMode, setViewMode] = useState<'upload' | 'confirmations'>('upload'); const [viewMode, setViewMode] = useState<'upload' | 'confirmations'>('upload');
const [confirmations, setConfirmations] = useState<Confirmation[]>([]); const [confirmations, setConfirmations] = useState<Confirmation[]>([]);
const [confFilter, setConfFilter] = useState('all'); // all | new | dxcc | band | slot
const [logOpen, setLogOpen] = useState(false); const [showLog, setShowLog] = useState(false);
const [logTitle, setLogTitle] = useState('');
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
const [logLines, setLogLines] = useState<string[]>([]); const [logLines, setLogLines] = useState<string[]>([]);
const [uploadDone, setUploadDone] = useState(false); const [busy, setBusy] = useState(false);
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
useEffect(() => { useEffect(() => {
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line])); const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
const offDone = EventsOn('qslmgr:done', (d: any) => { const offDone = EventsOn('qslmgr:done', (d: any) => {
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0}`]); setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0}`]);
setUploadDone(true); setBusy(false);
}); });
const offConf = EventsOn('qslmgr:confirmations', (list: any) => { const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
setConfirmations((list ?? []) as Confirmation[]); setConfirmations((list ?? []) as Confirmation[]);
@@ -81,16 +77,28 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
const selectedCount = selected.size; const selectedCount = selected.size;
const allSelected = rows.length > 0 && selected.size === rows.length; const allSelected = rows.length > 0 && selected.size === rows.length;
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
async function selectRequired() { const shownConfs = useMemo(() => confirmations.filter((c) => {
switch (confFilter) {
case 'new': return c.new_dxcc || c.new_band || c.new_slot;
case 'dxcc': return c.new_dxcc;
case 'band': return c.new_band;
case 'slot': return c.new_slot;
default: return true;
}
}), [confirmations, confFilter]);
const selectRequired = useCallback(async () => {
setSearching(true); setSearching(true);
setError(''); setError('');
try { try {
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent); const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
const list = (r ?? []) as UploadRow[]; const list = (r ?? []) as UploadRow[];
setRows(list); setRows(list);
setSelected(new Set(list.map((x) => x.id))); // auto-select all found setSelected(new Set(list.map((x) => x.id)));
setViewMode('upload'); setViewMode('upload');
setShowLog(false);
} catch (e: any) { } catch (e: any) {
setError(String(e?.message ?? e)); setError(String(e?.message ?? e));
setRows([]); setRows([]);
@@ -98,7 +106,7 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
} finally { } finally {
setSearching(false); setSearching(false);
} }
} }, [service, sent]);
function toggle(id: number) { function toggle(id: number) {
setSelected((s) => { setSelected((s) => {
@@ -114,209 +122,170 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
async function upload() { async function upload() {
const ids = rows.filter((r) => selected.has(r.id)).map((r) => r.id); const ids = rows.filter((r) => selected.has(r.id)).map((r) => r.id);
if (ids.length === 0) return; if (ids.length === 0) return;
setLogLines([]); setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
setUploadDone(false); try { await UploadQSOsManual(service, ids); }
setLogAction('upload'); catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
setLogTitle('Uploading to ' + serviceLabel);
setLogOpen(true);
try {
await UploadQSOsManual(service, ids);
} catch (e: any) {
setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]);
setUploadDone(true);
}
} }
async function download() { async function download() {
setLogLines([]); setLogLines([]); setBusy(true); setLogAction('download'); setShowLog(true);
setUploadDone(false); try { await DownloadConfirmations(service, addNotFound); }
setLogAction('download'); catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
setLogTitle('Downloading confirmations from ' + serviceLabel);
setLogOpen(true);
try {
await DownloadConfirmations(service, addNotFound);
} catch (e: any) {
setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]);
setUploadDone(true);
}
} }
function closeLog() { function viewResults() {
setLogOpen(false); setShowLog(false);
// After an upload, refresh the search so uploaded QSOs drop out of the
// filter. After a download, leave the confirmations list on screen.
if (logAction === 'upload') selectRequired(); if (logAction === 'upload') selectRequired();
} }
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
return ( return (
<> <div className="flex flex-col min-h-0 flex-1">
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}> {/* Search toolbar */}
<DialogContent className="max-w-[1000px] w-full max-h-[88vh] grid grid-rows-[auto_auto_1fr_auto] gap-0 p-0"> <div className="flex items-end gap-3 px-3 py-2 border-b border-border bg-muted/20 shrink-0">
<DialogHeader className="px-4 pt-4"> <div className="flex flex-col gap-1">
<DialogTitle>QSL Manager</DialogTitle> <label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
<DialogDescription className="sr-only">Upload logged QSOs to online logbooks.</DialogDescription> <Select value={service} onValueChange={setService}>
</DialogHeader> <SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
{/* Search toolbar */} </Select>
<div className="flex items-end gap-3 px-4 py-3 border-b border-border bg-muted/20"> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label> <label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label>
<Select value={service} onValueChange={setService}> <Select value={sent} onValueChange={setSent}>
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)} </Select>
</SelectContent> </div>
</Select> <Button size="sm" className="h-8" onClick={selectRequired} disabled={searching || busy}>
</div> {searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
<div className="flex flex-col gap-1"> Select required
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label> </Button>
<Select value={sent} onValueChange={setSent}> <div className="flex-1" />
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger> {!showLog && viewMode === 'confirmations' && (
<SelectContent> <div className="flex flex-col gap-1">
{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)} <label className="text-[10px] uppercase tracking-wider text-muted-foreground">Filter</label>
</SelectContent> <Select value={confFilter} onValueChange={setConfFilter}>
</Select> <SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
</div> <SelectContent>
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching}> <SelectItem value="all">All</SelectItem>
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />} <SelectItem value="new">New (any)</SelectItem>
Select required <SelectItem value="dxcc">New DXCC</SelectItem>
</Button> <SelectItem value="band">New band</SelectItem>
<div className="flex-1" /> <SelectItem value="slot">New slot</SelectItem>
<span className="text-xs text-muted-foreground"> </SelectContent>
{viewMode === 'confirmations' </Select>
? `${confirmations.length} confirmation(s) received`
: `${rows.length} found · ${selectedCount} selected`}
</span>
</div> </div>
)}
{logLines.length > 0 && (
<Button variant="ghost" size="sm" className="h-8" onClick={() => (showLog ? viewResults() : setShowLog(true))}>
{showLog ? <><ListChecks className="size-3.5" /> Results</> : <><ScrollText className="size-3.5" /> Log</>}
</Button>
)}
<span className="text-xs text-muted-foreground">
{viewMode === 'confirmations'
? `${shownConfs.length} / ${confirmations.length} confirmation(s)`
: `${rows.length} found · ${selectedCount} selected`}
</span>
</div>
{/* Results grid */} {/* Content: log OR results grid */}
<div className="overflow-auto px-4 py-2 min-h-[200px]"> <div className="flex-1 overflow-auto px-3 py-2 min-h-0">
{error && <div className="text-xs text-rose-700 mb-2">{error}</div>} {error && <div className="text-xs text-rose-700 mb-2">{error}</div>}
{viewMode === 'confirmations' ? ( {showLog ? (
confirmations.length === 0 ? ( <div className="font-mono text-[11px] space-y-0.5 py-1">
<div className="text-sm text-muted-foreground py-10 text-center">No new confirmations.</div>
) : (
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 px-2">Date UTC</th>
<th className="py-1.5 px-2">Callsign</th>
<th className="py-1.5 px-2">Band</th>
<th className="py-1.5 px-2">Mode</th>
<th className="py-1.5 px-2">Country</th>
<th className="py-1.5 px-2">New?</th>
</tr>
</thead>
<tbody>
{confirmations.map((c, i) => (
<tr key={i} className="border-b border-border/40">
<td className="py-1 px-2 font-mono">{fmtDate(c.qso_date)}</td>
<td className="py-1 px-2 font-mono font-bold">{c.callsign}</td>
<td className="py-1 px-2">{c.band}</td>
<td className="py-1 px-2">{c.mode}</td>
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
<td className="py-1 px-2">
{c.new_dxcc ? (
<span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-rose-100 text-rose-800 border border-rose-300">NEW DXCC</span>
) : c.new_band ? (
<span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-amber-100 text-amber-800 border border-amber-300">NEW BAND</span>
) : c.new_slot ? (
<span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-yellow-100 text-yellow-800 border border-yellow-300">NEW SLOT</span>
) : (
<span className="text-muted-foreground/50"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
)
) : rows.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">
Pick a service + sent status, then Select required.
</div>
) : (
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 px-2 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
<th className="py-1.5 px-2">Date UTC</th>
<th className="py-1.5 px-2">Callsign</th>
<th className="py-1.5 px-2">Band</th>
<th className="py-1.5 px-2">Mode</th>
<th className="py-1.5 px-2">Country</th>
<th className="py-1.5 px-2">Sent</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr
key={r.id}
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', selected.has(r.id) && 'bg-primary/5')}
onClick={() => toggle(r.id)}
>
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selected.has(r.id)} onCheckedChange={() => toggle(r.id)} />
</td>
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
<td className="py-1 px-2">{r.band}</td>
<td className="py-1 px-2">{r.mode}</td>
<td className="py-1 px-2 text-muted-foreground">{r.country}</td>
<td className="py-1 px-2 font-mono">{r.status || '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<DialogFooter className="px-4 py-3 border-t border-border sm:justify-between">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={download} title="Fetch confirmations from the service and update received status">
<DownloadCloud className="size-3.5" />
Download confirmations
</Button>
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title="Insert confirmed QSOs that aren't in your log yet">
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
Add not-found
</label>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onClose}>Close</Button>
<Button size="sm" onClick={upload} disabled={selectedCount === 0}>
<UploadCloud className="size-3.5" />
Upload {selectedCount} to {serviceLabel}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Upload progress / log window */}
<Dialog open={logOpen} onOpenChange={(o) => { if (!o && uploadDone) closeLog(); }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{logTitle || 'Working…'}</DialogTitle>
<DialogDescription className="sr-only">Progress log.</DialogDescription>
</DialogHeader>
<div className="max-h-[50vh] overflow-auto rounded-md border border-border bg-muted/30 p-2.5 font-mono text-[11px] space-y-0.5">
{logLines.length === 0 ? ( {logLines.length === 0 ? (
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> starting</div> <div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> starting</div>
) : logLines.map((l, i) => ( ) : logLines.map((l, i) => (
<div key={i} className={cn(l.includes('FAILED') || l.includes('failed') ? 'text-rose-700' : l.includes('OK') ? 'text-emerald-700' : 'text-foreground')}>{l}</div> <div key={i} className={cn(
/FAIL|failed|error/i.test(l) ? 'text-rose-700'
: /\bOK\b|UPDATED|ADDED|uploaded/i.test(l) ? 'text-emerald-700'
: 'text-foreground/90')}>{l}</div>
))} ))}
{busy && <div className="text-muted-foreground flex items-center gap-2 pt-1"><Loader2 className="size-3 animate-spin" /> working</div>}
</div> </div>
<DialogFooter> ) : viewMode === 'confirmations' ? (
<Button size="sm" onClick={closeLog} disabled={!uploadDone}> shownConfs.length === 0 ? (
{uploadDone ? 'Close' : <><Loader2 className="size-3.5 animate-spin" /> Uploading</>} <div className="text-sm text-muted-foreground py-10 text-center">
</Button> {confirmations.length === 0 ? 'No new confirmations.' : 'No confirmations match this filter.'}
</DialogFooter> </div>
</DialogContent> ) : (
</Dialog> <table className="w-full text-xs border-collapse">
</> <thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
<th className="py-1.5 px-2">Country</th><th className="py-1.5 px-2">New?</th>
</tr>
</thead>
<tbody>
{shownConfs.map((c, i) => (
<tr key={i} className="border-b border-border/40">
<td className="py-1 px-2 font-mono">{fmtDate(c.qso_date)}</td>
<td className="py-1 px-2 font-mono font-bold">{c.callsign}</td>
<td className="py-1 px-2">{c.band}</td>
<td className="py-1 px-2">{c.mode}</td>
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
<td className="py-1 px-2">
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-rose-100 text-rose-800 border border-rose-300">NEW DXCC</span>
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-amber-100 text-amber-800 border border-amber-300">NEW BAND</span>
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-yellow-100 text-yellow-800 border border-yellow-300">NEW SLOT</span>
: <span className="text-muted-foreground/50"></span>}
</td>
</tr>
))}
</tbody>
</table>
)
) : rows.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then Select required.</div>
) : (
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 px-2 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
<th className="py-1.5 px-2">Country</th><th className="py-1.5 px-2">Sent</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', selected.has(r.id) && 'bg-primary/5')}
onClick={() => toggle(r.id)}>
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selected.has(r.id)} onCheckedChange={() => toggle(r.id)} />
</td>
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
<td className="py-1 px-2">{r.band}</td>
<td className="py-1 px-2">{r.mode}</td>
<td className="py-1 px-2 text-muted-foreground">{r.country}</td>
<td className="py-1 px-2 font-mono">{r.status || '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Action bar */}
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={download} disabled={busy}
title="Fetch confirmations from the service and update received status">
<DownloadCloud className="size-3.5" /> Download confirmations
</Button>
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title="Insert confirmed QSOs that aren't in your log yet">
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
Add not-found
</label>
</div>
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
<UploadCloud className="size-3.5" /> Upload {selectedCount} to {serviceLabel}
</Button>
</div>
</div>
); );
} }
+50 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Trash2 } from 'lucide-react'; import { Trash2, Search, Loader2 } from 'lucide-react';
import { LookupCallsign } from '../../wailsjs/go/main/App';
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
@@ -104,11 +105,47 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
const [extrasText, setExtrasText] = useState(stringifyExtras(draft.extras)); const [extrasText, setExtrasText] = useState(stringifyExtras(draft.extras));
const [localErr, setLocalErr] = useState(''); const [localErr, setLocalErr] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [looking, setLooking] = useState(false);
function set<K extends keyof QSO>(key: K, value: QSO[K]) { function set<K extends keyof QSO>(key: K, value: QSO[K]) {
setDraft((d) => ({ ...d, [key]: value })); setDraft((d) => ({ ...d, [key]: value }));
} }
// Re-run a callsign lookup (QRZ/HamQTH + cty.dat) and merge the result into
// the draft — handy after correcting the callsign. Only overwrites the
// lookup-derived fields; leaves call/band/mode/RST/dates alone.
async function fetchLookup() {
const call = (draft.callsign ?? '').trim().toUpperCase();
if (!call) { setLocalErr('Callsign required'); return; }
setLooking(true);
setLocalErr('');
try {
const r: any = await LookupCallsign(call);
setDraft((d) => ({
...d,
name: r.name ?? d.name,
qth: r.qth ?? d.qth,
address: r.address ?? (d as any).address,
email: r.email ?? (d as any).email,
country: r.country ?? d.country,
grid: r.grid ?? d.grid,
state: r.state ?? d.state,
cnty: r.cnty ?? d.cnty,
cont: r.cont ?? d.cont,
qsl_via: r.qsl_via ?? d.qsl_via,
dxcc: r.dxcc || d.dxcc,
cqz: r.cqz || d.cqz,
ituz: r.ituz || d.ituz,
lat: r.lat || d.lat,
lon: r.lon || d.lon,
}));
} catch (e: any) {
setLocalErr('Lookup: ' + String(e?.message ?? e));
} finally {
setLooking(false);
}
}
function save() { function save() {
if (!draft.callsign?.trim()) { setLocalErr('Callsign required'); return; } if (!draft.callsign?.trim()) { setLocalErr('Callsign required'); return; }
setSaving(true); setSaving(true);
@@ -200,8 +237,15 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
<TabsContent value="basic" className="mt-0"> <TabsContent value="basic" className="mt-0">
<div className="grid grid-cols-6 gap-3"> <div className="grid grid-cols-6 gap-3">
<F label="Callsign" span={6}> <F label="Callsign" span={6}>
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-11" <div className="flex gap-2">
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} /> <Input className="font-mono text-lg font-bold tracking-wider uppercase h-11 flex-1"
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
<Button type="button" variant="outline" className="h-11" onClick={fetchLookup} disabled={looking}
title="Look up this callsign (QRZ.com / HamQTH) and refresh name, QTH, country, grid, zones…">
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />}
Fetch
</Button>
</div>
</F> </F>
<F label="Start (UTC)" span={3}><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></F> <F label="Start (UTC)" span={3}><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></F>
<F label="End (UTC)" span={3}><Input type="datetime-local" value={dateOff} onChange={(e) => setDateOff(e.target.value)} /></F> <F label="End (UTC)" span={3}><Input type="datetime-local" value={dateOff} onChange={(e) => setDateOff(e.target.value)} /></F>
@@ -232,6 +276,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
<F label="RST sent"><Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} /></F> <F label="RST sent"><Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} /></F>
<F label="RST rcvd"><Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} /></F> <F label="RST rcvd"><Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} /></F>
<F label="TX power (W)"><Input type="number" value={draft.tx_pwr ?? ''} onChange={(e) => set('tx_pwr', numOrUndef(e.target.value) as any)} /></F> <F label="TX power (W)"><Input type="number" value={draft.tx_pwr ?? ''} onChange={(e) => set('tx_pwr', numOrUndef(e.target.value) as any)} /></F>
<F label="Name" span={3}><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></F>
<F label="Country" span={2}><Input value={draft.country ?? ''} onChange={(e) => set('country', e.target.value)} /></F>
<F label="Grid"><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} /></F>
</div> </div>
</TabsContent> </TabsContent>
@@ -142,6 +142,7 @@ const COL_CATALOG: ColEntry[] = [
{ group: 'Uploads', label: 'HRDLog upload status', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog status', field: 'hrdlog_qso_upload_status' as any, width: 110 }, { group: 'Uploads', label: 'HRDLog upload status', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog status', field: 'hrdlog_qso_upload_status' as any, width: 110 },
{ group: 'Uploads', label: 'QRZ.com upload date', colId: 'qrzcom_qso_upload_date', headerName: 'QRZ.com date', field: 'qrzcom_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'Uploads', label: 'QRZ.com upload date', colId: 'qrzcom_qso_upload_date', headerName: 'QRZ.com date', field: 'qrzcom_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: 'QRZ.com upload status', colId: 'qrzcom_qso_upload_status', headerName: 'QRZ.com status', field: 'qrzcom_qso_upload_status' as any, width: 110 }, { group: 'Uploads', label: 'QRZ.com upload status', colId: 'qrzcom_qso_upload_status', headerName: 'QRZ.com status', field: 'qrzcom_qso_upload_status' as any, width: 110 },
{ group: 'Uploads', label: 'QRZ.com confirmed', colId: 'qrzcom_qso_download_status', headerName: 'QRZ.com cfm', field: 'qrzcom_qso_download_status' as any, width: 100 },
// ── Contest ── // ── Contest ──
{ group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 }, { group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 },
+167
View File
@@ -0,0 +1,167 @@
import { useEffect, useRef, useState } from 'react';
import { Satellite, Loader2 } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
export interface RecentSpotQSO {
callsign: string;
freqKHz: number;
mode: string;
band?: string;
}
interface Props {
open: boolean;
onClose: () => void;
// Pre-fill values: callsign from the QSO entry (or last logged), the
// current TX freq in kHz, and the current mode (goes into the comment).
defaultCall: string;
defaultFreqKHz: number;
defaultMode: string;
// Master cluster name, shown so the user knows where the spot goes.
targetName?: string;
recent: RecentSpotQSO[];
onSend: (call: string, freqKHz: number, comment: string) => Promise<void>;
}
// SendSpotModal — Log4OM-style "Send Spot" window. Announces a DX spot on
// the master cluster: callsign + frequency (kHz) + a free message (defaults
// to the mode). A "Latest QSOs" list lets the operator one-click a recent
// contact into the form.
export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defaultMode, targetName, recent, onSend }: Props) {
const [call, setCall] = useState('');
const [freqKHz, setFreqKHz] = useState('');
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [ok, setOk] = useState(false);
const callRef = useRef<HTMLInputElement>(null);
// (Re)initialise the form each time the dialog opens.
useEffect(() => {
if (!open) return;
setCall((defaultCall || '').toUpperCase());
setFreqKHz(defaultFreqKHz > 0 ? trimKHz(defaultFreqKHz) : '');
setMessage(defaultMode || '');
setError('');
setOk(false);
// Focus the freq if the call is already known, else the call.
setTimeout(() => callRef.current?.focus(), 50);
}, [open, defaultCall, defaultFreqKHz, defaultMode]);
async function send() {
const c = call.trim().toUpperCase();
const f = parseFloat(freqKHz);
if (!c) { setError('Callsign required'); return; }
if (!f || f <= 0) { setError('Frequency (kHz) required'); return; }
setBusy(true);
setError('');
try {
await onSend(c, f, message.trim());
setOk(true);
// Brief success flash, then close.
setTimeout(() => { setOk(false); onClose(); }, 700);
} catch (e: any) {
setError(String(e?.message ?? e));
} finally {
setBusy(false);
}
}
function pick(q: RecentSpotQSO) {
setCall(q.callsign.toUpperCase());
if (q.freqKHz > 0) setFreqKHz(trimKHz(q.freqKHz));
if (q.mode) setMessage(q.mode);
setError('');
}
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Satellite className="size-4 text-primary" /> Send DX Spot
</DialogTitle>
</DialogHeader>
<div className="px-5 py-3 space-y-3">
<div className="flex gap-3">
<div className="flex flex-col flex-1">
<Label className="mb-1">Callsign</Label>
<Input
ref={callRef}
className="font-mono uppercase font-bold"
value={call}
onChange={(e) => setCall(e.target.value.toUpperCase())}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
placeholder="DX call"
/>
</div>
<div className="flex flex-col w-32">
<Label className="mb-1">Frequency (kHz)</Label>
<Input
className="font-mono"
value={freqKHz}
onChange={(e) => setFreqKHz(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
placeholder="14205"
/>
</div>
</div>
<div className="flex flex-col">
<Label className="mb-1">Message</Label>
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
placeholder="e.g. CW · TNX QSO"
/>
</div>
{recent.length > 0 && (
<div>
<Label className="mb-1 block">Latest QSOs</Label>
<div className="max-h-40 overflow-y-auto rounded-md border border-border divide-y divide-border/60">
{recent.map((q, i) => (
<button
key={`${q.callsign}-${i}`}
type="button"
onClick={() => pick(q)}
className="flex w-full items-center gap-2 px-2 py-1 text-left text-xs hover:bg-accent/40"
>
<span className="font-mono font-bold w-24 truncate">{q.callsign}</span>
<span className="font-mono text-muted-foreground w-20 text-right">{q.freqKHz > 0 ? trimKHz(q.freqKHz) : '—'}</span>
<span className="text-muted-foreground">{q.mode || ''}</span>
</button>
))}
</div>
</div>
)}
{error && <div className="text-xs text-rose-600">{error}</div>}
</div>
<DialogFooter>
<span className="text-[11px] text-muted-foreground mr-auto self-center">
{ok ? 'Spot sent ✓' : targetName ? `${targetName}` : 'Master cluster'}
</span>
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
<Button onClick={send} disabled={busy}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Satellite className="size-3.5" />}
{busy ? 'Sending…' : 'Send spot'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// trimKHz formats a kHz value without a trailing ".0" (14205) but keeps
// sub-kHz precision when present (10138.7).
function trimKHz(khz: number): string {
return String(Math.round(khz * 10) / 10).replace(/\.0$/, '');
}
+114 -2
View File
@@ -16,6 +16,7 @@ import {
ConnectClusterServer, DisconnectClusterServer, ConnectClusterServer, DisconnectClusterServer,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder, GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp,
GetQSLDefaults, SaveQSLDefaults, GetQSLDefaults, SaveQSLDefaults,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
TestLoTWUpload, ListTQSLStationLocations, TestLoTWUpload, ListTQSLStationLocations,
@@ -141,6 +142,7 @@ type SectionId =
| 'lists-modes' | 'lists-modes'
| 'cluster' | 'cluster'
| 'backup' | 'backup'
| 'database'
| 'awards' | 'awards'
| 'cat' | 'cat'
| 'rotator' | 'rotator'
@@ -171,6 +173,7 @@ const TREE: TreeNode[] = [
{ kind: 'item', label: 'DX Cluster', id: 'cluster' }, { kind: 'item', label: 'DX Cluster', id: 'cluster' },
{ kind: 'item', label: 'UDP integrations (WSJT-X, JTDX, MSHV…)', id: 'udp' }, { kind: 'item', label: 'UDP integrations (WSJT-X, JTDX, MSHV…)', id: 'udp' },
{ kind: 'item', label: 'Database backup', id: 'backup' }, { kind: 'item', label: 'Database backup', id: 'backup' },
{ kind: 'item', label: 'Database location', id: 'database' },
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true }, { kind: 'item', label: 'Awards', id: 'awards', disabled: true },
], ],
}, },
@@ -196,6 +199,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
'lists-modes': 'Modes & default RST', 'lists-modes': 'Modes & default RST',
cluster: 'DX Cluster', cluster: 'DX Cluster',
backup: 'Database backup', backup: 'Database backup',
database: 'Database location',
udp: 'UDP integrations', udp: 'UDP integrations',
awards: 'Awards', awards: 'Awards',
cat: 'CAT interface', cat: 'CAT interface',
@@ -319,7 +323,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const [activeProfile, setActiveProfile] = useState<Profile | null>(null); const [activeProfile, setActiveProfile] = useState<Profile | null>(null);
const updateActive = (patch: Partial<Profile>) => const updateActive = (patch: Partial<Profile>) =>
setActiveProfile((p) => (p ? { ...p, ...patch } : p)); setActiveProfile((p) => (p ? { ...p, ...patch } : p));
const [lists, setLists] = useState<ListsSettings>({ bands: [], modes: [] }); const [lists, setLists] = useState<ListsSettings>({ bands: [], modes: [], rst_phone: [], rst_cw: [], rst_digital: [] });
// RST report lists edited as free text (one/space-separated values).
const [rstText, setRstText] = useState({ phone: '', cw: '', digital: '' });
// Custom band drafts (catalog covers ADIF spec but the user may have // Custom band drafts (catalog covers ADIF spec but the user may have
// exotic or experimental bands not listed). // exotic or experimental bands not listed).
const [bandDraft, setBandDraft] = useState(''); const [bandDraft, setBandDraft] = useState('');
@@ -384,6 +390,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const [backupRunning, setBackupRunning] = useState(false); const [backupRunning, setBackupRunning] = useState(false);
const [backupResult, setBackupResult] = useState<{ ok: boolean; msg: string } | null>(null); const [backupResult, setBackupResult] = useState<{ ok: boolean; msg: string } | null>(null);
const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean }>({ path: '', default_path: '', is_custom: false });
const [dbMsg, setDbMsg] = useState('');
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]); const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
const [clusterAutoConnect, setClusterAutoConnectState] = useState(false); const [clusterAutoConnect, setClusterAutoConnectState] = useState(false);
const [clusterStatuses, setClusterStatuses] = useState<ClusterServerStatus[]>([]); const [clusterStatuses, setClusterStatuses] = useState<ClusterServerStatus[]>([]);
@@ -457,6 +466,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
setLookup(l); setLookup(l);
setActiveProfile(ap as Profile); setActiveProfile(ap as Profile);
setLists(ls); setLists(ls);
setRstText({
phone: ((ls as any).rst_phone ?? []).join(' '),
cw: ((ls as any).rst_cw ?? []).join(' '),
digital: ((ls as any).rst_digital ?? []).join(' '),
});
await reloadProfiles(); await reloadProfiles();
await reloadClusterServers(); await reloadClusterServers();
setCatCfg(c); setCatCfg(c);
@@ -464,6 +478,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
setBackupCfg(b as any); setBackupCfg(b as any);
setQslDefaults(qd as any); setQslDefaults(qd as any);
setExtSvc(es as any); setExtSvc(es as any);
try { setDbSettings(await GetDatabaseSettings() as any); } catch {}
try { try {
const locs: any = await ListTQSLStationLocations(); const locs: any = await ListTQSLStationLocations();
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean)); setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
@@ -600,7 +615,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
default_rst_rcvd: (m.default_rst_rcvd ?? '').trim(), default_rst_rcvd: (m.default_rst_rcvd ?? '').trim(),
})) }))
.filter((m) => m.name !== ''); .filter((m) => m.name !== '');
await SaveListsSettings({ bands, modes } as any); const splitList = (s: string) => s.split(/[\s,]+/).map((x) => x.trim()).filter(Boolean);
await SaveListsSettings({
bands, modes,
rst_phone: splitList(rstText.phone),
rst_cw: splitList(rstText.cw),
rst_digital: splitList(rstText.digital),
} as any);
if (activeProfile) { if (activeProfile) {
await SaveProfile({ await SaveProfile({
@@ -1233,6 +1254,28 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</div> </div>
</div> </div>
</div> </div>
{/* RST report lists — the dropdown choices in the entry form. */}
<div className="mt-6 max-w-4xl">
<div className="text-sm font-semibold mb-1">RST report lists</div>
<div className="text-[11px] text-muted-foreground mb-2">
The choices offered in the entry form's RST dropdowns, per mode family. One value per line (or space-separated). The first one is the top of the list.
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label className="text-xs">Phone (SSB/AM/FM)</Label>
<Textarea rows={8} className="font-mono text-xs" value={rstText.phone} onChange={(e) => setRstText((s) => ({ ...s, phone: e.target.value }))} />
</div>
<div className="space-y-1">
<Label className="text-xs">CW / RTTY / PSK</Label>
<Textarea rows={8} className="font-mono text-xs" value={rstText.cw} onChange={(e) => setRstText((s) => ({ ...s, cw: e.target.value }))} />
</div>
<div className="space-y-1">
<Label className="text-xs">Digital (FT8/FT4/JT) dB</Label>
<Textarea rows={8} className="font-mono text-xs" value={rstText.digital} onChange={(e) => setRstText((s) => ({ ...s, digital: e.target.value }))} />
</div>
</div>
</div>
</> </>
); );
} }
@@ -2121,6 +2164,74 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
); );
} }
function DatabasePanel() {
async function refreshDb() { try { setDbSettings(await GetDatabaseSettings() as any); } catch {} }
async function openExisting() {
try {
const p = await PickOpenDatabase();
if (!p) return;
await OpenDatabase(p);
await refreshDb();
setDbMsg(`Database set to:\n${p}\nRestart OpsLog to apply.`);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function saveCopy() {
try {
const p = await PickSaveDatabase();
if (!p) return;
await MoveDatabase(p);
await refreshDb();
setDbMsg(`A copy was saved to:\n${p}\nand selected. Restart OpsLog to apply.`);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function resetDefault() {
try {
await ResetDatabaseToDefault();
await refreshDb();
setDbMsg('Database reset to the default location. Restart OpsLog to apply.');
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
return (
<>
<SectionHeader
title="Database location"
hint="Keep your log database wherever you like — another drive or a synced folder (Seafile, Dropbox…) — so it survives a Windows reinstall. Everything (QSOs, settings, lookup cache) lives in this one file."
/>
<div className="space-y-4 max-w-2xl">
<div className="space-y-1">
<Label>Current database</Label>
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
{dbSettings.path || '—'}
{dbSettings.is_custom
? <span className="ml-2 text-[10px] text-emerald-700">(custom location)</span>
: <span className="ml-2 text-[10px] text-muted-foreground">(default)</span>}
</div>
<div className="text-[10px] text-muted-foreground">Default: <span className="font-mono">{dbSettings.default_path}</span></div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={openExisting}>Open existing database</Button>
<Button variant="outline" size="sm" onClick={saveCopy}>Save a copy &amp; switch to it</Button>
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>Reset to default</Button>}
</div>
<div className="text-[11px] text-muted-foreground bg-amber-50 border border-amber-200 rounded-md p-2.5 leading-relaxed">
<strong>Open existing</strong> points OpsLog at a database file you already have (e.g. after reinstalling Windows).{' '}
<strong>Save a copy</strong> writes the current database to a new place and switches to it.{' '}
A database change takes effect on the next launch.
</div>
{dbMsg && (
<div className="text-xs bg-emerald-50 border border-emerald-300 text-emerald-800 rounded-md px-3 py-2 flex items-center justify-between gap-3 whitespace-pre-line">
<span>{dbMsg}</span>
<Button size="sm" variant="destructive" className="shrink-0" onClick={() => QuitApp()}>Quit now</Button>
</div>
)}
</div>
</>
);
}
// Map sections to their content + icon (for placeholder). // Map sections to their content + icon (for placeholder).
const PANELS: Record<SectionId, () => JSX.Element> = { const PANELS: Record<SectionId, () => JSX.Element> = {
station: StationPanel, station: StationPanel,
@@ -2134,6 +2245,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
cluster: ClusterPanel, cluster: ClusterPanel,
udp: UDPIntegrationsPanelWrapper, udp: UDPIntegrationsPanelWrapper,
backup: BackupPanel, backup: BackupPanel,
database: DatabasePanel,
awards: () => <ComingSoon id="awards" icon={Award} />, awards: () => <ComingSoon id="awards" icon={Award} />,
cat: CATPanel, cat: CATPanel,
rotator: RotatorPanel, rotator: RotatorPanel,
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useRef, useState } from 'react';
import { Input } from './input';
import { cn } from '@/lib/utils';
// Searchable combobox: type to filter, click/Enter to pick. On blur it commits
// only an exact (case-insensitive) match — otherwise it reverts, so the field
// can't hold a typo'd value that isn't in the list.
export function Combobox({
value, onChange, options, placeholder, className, allowFreeText = false,
}: {
value: string;
onChange: (v: string) => void;
options: string[];
placeholder?: string;
className?: string;
allowFreeText?: boolean;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function onDoc(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, []);
const filtered = open
? options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60)
: [];
function commit(v: string) {
onChange(v);
setQuery(v);
setOpen(false);
}
function onBlur() {
// Defer so a click on an option registers first.
setTimeout(() => {
setOpen(false);
const exact = options.find((o) => o.toLowerCase() === query.trim().toLowerCase());
if (exact) { onChange(exact); setQuery(exact); }
else if (allowFreeText) { onChange(query.trim()); }
else { setQuery(value); } // revert typo
}, 120);
}
return (
<div ref={ref} className={cn('relative', className)}>
<Input
value={open ? query : value}
placeholder={placeholder}
// Focus selects the text so a keystroke replaces it — but does NOT
// open the list (so tabbing in doesn't pop the dropdown).
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onBlur={onBlur}
onKeyDown={(e) => {
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
else if (e.key === 'Enter' && open && filtered.length > 0) { e.preventDefault(); commit(filtered[0]); }
else if (e.key === 'Escape') { setQuery(value); setOpen(false); }
// Tab: just let it move on; onBlur commits/closes. Options are
// tabIndex=-1 so a single Tab leaves the field.
}}
/>
{open && filtered.length > 0 && (
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-card shadow-lg text-xs">
{filtered.map((o) => (
<button
key={o}
type="button"
tabIndex={-1}
className="block w-full text-left px-2 py-1 hover:bg-accent/40"
onMouseDown={(e) => { e.preventDefault(); commit(o); }}
>
{o}
</button>
))}
</div>
)}
</div>
);
}
+3 -3
View File
@@ -25,10 +25,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean } React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean; hideOverlay?: boolean }
>(({ className, children, hideClose, ...props }, ref) => ( >(({ className, children, hideClose, hideOverlay, ...props }, ref) => (
<DialogPortal> <DialogPortal>
<DialogOverlay /> {!hideOverlay && <DialogOverlay />}
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
+78
View File
@@ -0,0 +1,78 @@
// Maps ADIF DXCC entity numbers to ISO-3166 alpha-2 codes (flagcdn slugs),
// so we can show the contacted entity's flag in the UI.
//
// Why not emoji flags? Windows does NOT render regional-indicator flag
// emoji (it shows the two letters instead), and this is a Windows app — so
// we use flagcdn.com PNGs keyed by ISO code instead.
//
// Numbers are taken verbatim from internal/dxcc/adif_numbers.go so they're
// guaranteed correct. Coverage is the commonly-worked entities; an unknown
// DXCC number simply yields no flag (graceful — better than a wrong one).
//
// French overseas territories (Corsica, Guadeloupe, Martinique, Réunion,
// New Caledonia, French Polynesia…) are mapped to "fr": they fly the French
// tricolore and several of their ISO codes aren't served by flagcdn.
const DXCC_ISO: Record<number, string> = {
// North America
1: 'ca', 291: 'us', 6: 'us', 110: 'us', 50: 'mx',
202: 'pr', 285: 'vi', 91: 'vg', 69: 'ky', 82: 'jm', 60: 'bs', 64: 'bm',
78: 'ht', 72: 'do', 70: 'cu', 62: 'bb', 90: 'tt', 77: 'gd', 97: 'lc',
98: 'vc', 95: 'dm', 96: 'ms', 249: 'kn', 94: 'ag', 89: 'tc',
79: 'fr', 84: 'fr', 63: 'fr',
// Central America
66: 'bz', 80: 'hn', 74: 'sv', 86: 'ni', 308: 'cr', 88: 'pa', 76: 'gt',
// South America
116: 'co', 120: 'ec', 71: 'ec', 136: 'pe', 104: 'bo', 112: 'cl',
100: 'ar', 144: 'uy', 132: 'py', 108: 'br', 148: 've', 129: 'gy',
140: 'sr', 216: 'co',
// Western Europe
227: 'fr', 214: 'fr', 230: 'de', 209: 'be', 263: 'nl', 254: 'lu',
287: 'ch', 251: 'li', 206: 'at', 248: 'it', 225: 'it', 281: 'es',
272: 'pt', 203: 'ad', 278: 'sm', 295: 'va', 260: 'mc',
// British Isles
223: 'gb-eng', 279: 'gb-sct', 294: 'gb-wls', 265: 'gb-nir', 245: 'ie',
114: 'im', 122: 'je', 106: 'gg',
// Central / Eastern Europe
269: 'pl', 503: 'cz', 504: 'sk', 239: 'hu', 275: 'ro', 212: 'bg',
296: 'rs', 514: 'me', 499: 'si', 497: 'hr', 501: 'ba', 502: 'mk',
522: 'xk', 7: 'al',
// Greece / Mediterranean
236: 'gr', 45: 'gr', 40: 'gr', 180: 'gr', 215: 'cy', 257: 'mt', 390: 'tr',
// Nordic / Baltic
221: 'dk', 222: 'fo', 237: 'gl', 284: 'se', 266: 'no', 224: 'fi',
5: 'ax', 242: 'is', 52: 'ee', 145: 'lv', 146: 'lt',
// Eastern Europe / Caucasus / Russia
27: 'by', 288: 'ua', 179: 'md', 75: 'ge', 14: 'am', 18: 'az',
54: 'ru', 15: 'ru', 126: 'ru',
// Middle East
336: 'il', 342: 'jo', 354: 'lb', 384: 'sy', 378: 'sa', 391: 'ae',
304: 'bh', 348: 'kw', 376: 'qa', 370: 'om', 492: 'ye', 330: 'ir', 333: 'iq',
// North Africa
478: 'eg', 436: 'ly', 400: 'dz', 446: 'ma', 302: 'eh', 474: 'tn',
// Sub-Saharan Africa
462: 'za', 464: 'na', 402: 'bw', 452: 'zw', 482: 'zm', 181: 'mz',
438: 'mg', 165: 'mu', 453: 'fr', 379: 'sc', 430: 'ke', 470: 'tz',
286: 'ug', 53: 'et', 51: 'er', 466: 'sd', 521: 'ss', 450: 'ng',
424: 'gh', 406: 'cm', 456: 'sn', 434: 'lr', 458: 'sl', 416: 'bj',
483: 'tg', 428: 'ci', 442: 'ml', 187: 'ne', 410: 'td', 32: 'cv',
420: 'ga', 444: 'mr', 382: 'dj', 454: 'rw', 107: 'gn', 39: 'km', 169: 'fr',
// Asia
339: 'jp', 137: 'kr', 344: 'kp', 318: 'cn', 324: 'in', 372: 'pk',
315: 'lk', 369: 'np', 305: 'bd', 306: 'bt', 309: 'mm', 299: 'my',
46: 'my', 381: 'sg', 327: 'id', 375: 'ph', 345: 'bn', 312: 'kh',
143: 'la', 387: 'th', 293: 'vn', 130: 'kz', 292: 'uz', 280: 'tm',
262: 'tj', 135: 'kg', 3: 'af', 159: 'mv', 363: 'mn', 321: 'hk',
386: 'tw', 511: 'tl',
// Oceania
150: 'au', 163: 'pg', 185: 'sb', 158: 'vu', 176: 'fj', 190: 'ws',
170: 'nz', 162: 'fr', 175: 'fr', 160: 'to', 282: 'tv', 301: 'ki',
188: 'nu', 168: 'mh', 189: 'nf', 147: 'au',
};
// flagURL returns a flagcdn PNG URL for the given DXCC entity number, or ''
// when we don't have a mapping. Height ~20px by default (retina-friendly).
export function flagURL(dxcc?: number): string {
if (!dxcc) return '';
const iso = DXCC_ISO[dxcc];
return iso ? `https://flagcdn.com/h20/${iso}.png` : '';
}
+20
View File
@@ -67,6 +67,8 @@ export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
export function GetCtyDatInfo():Promise<main.CtyDatInfo>; export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
export function GetExternalServices():Promise<extsvc.ExternalServices>; export function GetExternalServices():Promise<extsvc.ExternalServices>;
export function GetListsSettings():Promise<main.ListsSettings>; export function GetListsSettings():Promise<main.ListsSettings>;
@@ -79,6 +81,8 @@ export function GetQSLDefaults():Promise<main.QSLDefaults>;
export function GetQSO(arg1:number):Promise<qso.QSO>; export function GetQSO(arg1:number):Promise<qso.QSO>;
export function GetRotatorHeading():Promise<main.RotatorHeading>;
export function GetRotatorSettings():Promise<main.RotatorSettings>; export function GetRotatorSettings():Promise<main.RotatorSettings>;
export function GetStartupStatus():Promise<main.StartupStatus>; export function GetStartupStatus():Promise<main.StartupStatus>;
@@ -89,6 +93,8 @@ export function ImportADIF(arg1:string,arg2:boolean):Promise<adif.ImportResult>;
export function ListClusterServers():Promise<Array<cluster.ServerConfig>>; export function ListClusterServers():Promise<Array<cluster.ServerConfig>>;
export function ListCountries():Promise<Array<string>>;
export function ListOperatingTree():Promise<Array<operating.Station>>; export function ListOperatingTree():Promise<Array<operating.Station>>;
export function ListProfiles():Promise<Array<profile.Profile>>; export function ListProfiles():Promise<Array<profile.Profile>>;
@@ -103,18 +109,30 @@ export function LogUDPLoggedADIF(arg1:string):Promise<number>;
export function LookupCallsign(arg1:string):Promise<lookup.Result>; export function LookupCallsign(arg1:string):Promise<lookup.Result>;
export function MoveDatabase(arg1:string):Promise<void>;
export function OpenADIFFile():Promise<string>; export function OpenADIFFile():Promise<string>;
export function OpenDatabase(arg1:string):Promise<void>;
export function OpenExternalURL(arg1:string):Promise<void>; export function OpenExternalURL(arg1:string):Promise<void>;
export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>; export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>;
export function PickBackupFolder():Promise<string>; export function PickBackupFolder():Promise<string>;
export function PickOpenDatabase():Promise<string>;
export function PickSaveDatabase():Promise<string>;
export function QuitApp():Promise<void>;
export function RefreshCtyDat():Promise<main.CtyDatInfo>; export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function ReloadUDPIntegrations():Promise<Array<string>>; export function ReloadUDPIntegrations():Promise<Array<string>>;
export function ResetDatabaseToDefault():Promise<void>;
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>; export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
export function RotatorPark():Promise<void>; export function RotatorPark():Promise<void>;
@@ -153,6 +171,8 @@ export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
export function SendClusterCommand(arg1:string):Promise<void>; export function SendClusterCommand(arg1:string):Promise<void>;
export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
export function SetCATFrequency(arg1:number):Promise<void>; export function SetCATFrequency(arg1:number):Promise<void>;
export function SetCATMode(arg1:string):Promise<void>; export function SetCATMode(arg1:string):Promise<void>;
+40
View File
@@ -114,6 +114,10 @@ export function GetCtyDatInfo() {
return window['go']['main']['App']['GetCtyDatInfo'](); return window['go']['main']['App']['GetCtyDatInfo']();
} }
export function GetDatabaseSettings() {
return window['go']['main']['App']['GetDatabaseSettings']();
}
export function GetExternalServices() { export function GetExternalServices() {
return window['go']['main']['App']['GetExternalServices'](); return window['go']['main']['App']['GetExternalServices']();
} }
@@ -138,6 +142,10 @@ export function GetQSO(arg1) {
return window['go']['main']['App']['GetQSO'](arg1); return window['go']['main']['App']['GetQSO'](arg1);
} }
export function GetRotatorHeading() {
return window['go']['main']['App']['GetRotatorHeading']();
}
export function GetRotatorSettings() { export function GetRotatorSettings() {
return window['go']['main']['App']['GetRotatorSettings'](); return window['go']['main']['App']['GetRotatorSettings']();
} }
@@ -158,6 +166,10 @@ export function ListClusterServers() {
return window['go']['main']['App']['ListClusterServers'](); return window['go']['main']['App']['ListClusterServers']();
} }
export function ListCountries() {
return window['go']['main']['App']['ListCountries']();
}
export function ListOperatingTree() { export function ListOperatingTree() {
return window['go']['main']['App']['ListOperatingTree'](); return window['go']['main']['App']['ListOperatingTree']();
} }
@@ -186,10 +198,18 @@ export function LookupCallsign(arg1) {
return window['go']['main']['App']['LookupCallsign'](arg1); return window['go']['main']['App']['LookupCallsign'](arg1);
} }
export function MoveDatabase(arg1) {
return window['go']['main']['App']['MoveDatabase'](arg1);
}
export function OpenADIFFile() { export function OpenADIFFile() {
return window['go']['main']['App']['OpenADIFFile'](); return window['go']['main']['App']['OpenADIFFile']();
} }
export function OpenDatabase(arg1) {
return window['go']['main']['App']['OpenDatabase'](arg1);
}
export function OpenExternalURL(arg1) { export function OpenExternalURL(arg1) {
return window['go']['main']['App']['OpenExternalURL'](arg1); return window['go']['main']['App']['OpenExternalURL'](arg1);
} }
@@ -202,6 +222,18 @@ export function PickBackupFolder() {
return window['go']['main']['App']['PickBackupFolder'](); return window['go']['main']['App']['PickBackupFolder']();
} }
export function PickOpenDatabase() {
return window['go']['main']['App']['PickOpenDatabase']();
}
export function PickSaveDatabase() {
return window['go']['main']['App']['PickSaveDatabase']();
}
export function QuitApp() {
return window['go']['main']['App']['QuitApp']();
}
export function RefreshCtyDat() { export function RefreshCtyDat() {
return window['go']['main']['App']['RefreshCtyDat'](); return window['go']['main']['App']['RefreshCtyDat']();
} }
@@ -210,6 +242,10 @@ export function ReloadUDPIntegrations() {
return window['go']['main']['App']['ReloadUDPIntegrations'](); return window['go']['main']['App']['ReloadUDPIntegrations']();
} }
export function ResetDatabaseToDefault() {
return window['go']['main']['App']['ResetDatabaseToDefault']();
}
export function RotatorGoTo(arg1, arg2) { export function RotatorGoTo(arg1, arg2) {
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2); return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
} }
@@ -286,6 +322,10 @@ export function SendClusterCommand(arg1) {
return window['go']['main']['App']['SendClusterCommand'](arg1); return window['go']['main']['App']['SendClusterCommand'](arg1);
} }
export function SendClusterSpot(arg1, arg2, arg3) {
return window['go']['main']['App']['SendClusterSpot'](arg1, arg2, arg3);
}
export function SetCATFrequency(arg1) { export function SetCATFrequency(arg1) {
return window['go']['main']['App']['SetCATFrequency'](arg1); return window['go']['main']['App']['SetCATFrequency'](arg1);
} }
+44
View File
@@ -413,6 +413,22 @@ export namespace main {
this.file_mod_time = source["file_mod_time"]; this.file_mod_time = source["file_mod_time"];
} }
} }
export class DatabaseSettings {
path: string;
default_path: string;
is_custom: boolean;
static createFrom(source: any = {}) {
return new DatabaseSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.path = source["path"];
this.default_path = source["default_path"];
this.is_custom = source["is_custom"];
}
}
export class ModePreset { export class ModePreset {
name: string; name: string;
default_rst_sent?: string; default_rst_sent?: string;
@@ -432,6 +448,9 @@ export namespace main {
export class ListsSettings { export class ListsSettings {
bands: string[]; bands: string[];
modes: ModePreset[]; modes: ModePreset[];
rst_phone: string[];
rst_cw: string[];
rst_digital: string[];
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new ListsSettings(source); return new ListsSettings(source);
@@ -441,6 +460,9 @@ export namespace main {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.bands = source["bands"]; this.bands = source["bands"];
this.modes = this.convertValues(source["modes"], ModePreset); this.modes = this.convertValues(source["modes"], ModePreset);
this.rst_phone = source["rst_phone"];
this.rst_cw = source["rst_cw"];
this.rst_digital = source["rst_digital"];
} }
convertValues(a: any, classs: any, asMap: boolean = false): any { convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -516,6 +538,24 @@ export namespace main {
this.qrzcom_status = source["qrzcom_status"]; this.qrzcom_status = source["qrzcom_status"];
} }
} }
export class RotatorHeading {
enabled: boolean;
ok: boolean;
azimuth: number;
raw: string;
static createFrom(source: any = {}) {
return new RotatorHeading(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.ok = source["ok"];
this.azimuth = source["azimuth"];
this.raw = source["raw"];
}
}
export class RotatorSettings { export class RotatorSettings {
enabled: boolean; enabled: boolean;
host: string; host: string;
@@ -955,6 +995,8 @@ export namespace qso {
hrdlog_qso_upload_status?: string; hrdlog_qso_upload_status?: string;
qrzcom_qso_upload_date?: string; qrzcom_qso_upload_date?: string;
qrzcom_qso_upload_status?: string; qrzcom_qso_upload_status?: string;
qrzcom_qso_download_date?: string;
qrzcom_qso_download_status?: string;
contest_id?: string; contest_id?: string;
srx?: number; srx?: number;
stx?: number; stx?: number;
@@ -1060,6 +1102,8 @@ export namespace qso {
this.hrdlog_qso_upload_status = source["hrdlog_qso_upload_status"]; this.hrdlog_qso_upload_status = source["hrdlog_qso_upload_status"];
this.qrzcom_qso_upload_date = source["qrzcom_qso_upload_date"]; this.qrzcom_qso_upload_date = source["qrzcom_qso_upload_date"];
this.qrzcom_qso_upload_status = source["qrzcom_qso_upload_status"]; this.qrzcom_qso_upload_status = source["qrzcom_qso_upload_status"];
this.qrzcom_qso_download_date = source["qrzcom_qso_download_date"];
this.qrzcom_qso_download_status = source["qrzcom_qso_download_status"];
this.contest_id = source["contest_id"]; this.contest_id = source["contest_id"];
this.srx = source["srx"]; this.srx = source["srx"];
this.stx = source["stx"]; this.stx = source["stx"];
+2
View File
@@ -169,6 +169,8 @@ func writeRecord(bw *bufio.Writer, q qso.QSO) {
writeField(bw, "HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus) writeField(bw, "HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
writeField(bw, "QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate) writeField(bw, "QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
writeField(bw, "QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus) writeField(bw, "QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
writeField(bw, "QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
writeField(bw, "QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
// --- Contest --- // --- Contest ---
writeField(bw, "CONTEST_ID", q.ContestID) writeField(bw, "CONTEST_ID", q.ContestID)
+3
View File
@@ -184,6 +184,7 @@ var adifPromoted = stringSet(
"clublog_qso_upload_date", "clublog_qso_upload_status", "clublog_qso_upload_date", "clublog_qso_upload_status",
"hrdlog_qso_upload_date", "hrdlog_qso_upload_status", "hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
"qrzcom_qso_upload_date", "qrzcom_qso_upload_status", "qrzcom_qso_upload_date", "qrzcom_qso_upload_status",
"qrzcom_qso_download_date", "qrzcom_qso_download_status",
// Contest // Contest
"contest_id", "srx", "stx", "srx_string", "stx_string", "contest_id", "srx", "stx", "srx_string", "stx_string",
"check", "precedence", "arrl_sect", "check", "precedence", "arrl_sect",
@@ -315,6 +316,8 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
q.HRDLogUploadStatus = rec["hrdlog_qso_upload_status"] q.HRDLogUploadStatus = rec["hrdlog_qso_upload_status"]
q.QRZComUploadDate = rec["qrzcom_qso_upload_date"] q.QRZComUploadDate = rec["qrzcom_qso_upload_date"]
q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"] q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
q.QRZComDownloadDate = rec["qrzcom_qso_download_date"]
q.QRZComDownloadStatus = rec["qrzcom_qso_download_status"]
// Contest // Contest
q.ContestID = rec["contest_id"] q.ContestID = rec["contest_id"]
@@ -0,0 +1,5 @@
-- QRZ.com confirmation (download) tracking. Mirrors the upload columns from
-- 0014: QRZCOM_QSO_DOWNLOAD_STATUS = 'Y' when QRZ reports the QSO as
-- confirmed (matched by the other op), with the date it was pulled.
ALTER TABLE qso ADD COLUMN qrzcom_qso_download_date TEXT;
ALTER TABLE qso ADD COLUMN qrzcom_qso_download_status TEXT;
+26
View File
@@ -7,6 +7,8 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -147,6 +149,30 @@ func (m *Manager) Lookup(callsign string) (Match, bool) {
return db.Lookup(callsign) return db.Lookup(callsign)
} }
// EntityNames returns the sorted, de-duplicated DXCC entity names from the
// loaded cty.dat — the canonical list for a "Country" picker. Empty until
// cty.dat has loaded.
func (m *Manager) EntityNames() []string {
m.mu.RLock()
db := m.db
m.mu.RUnlock()
if db == nil {
return nil
}
seen := map[string]bool{}
var out []string
for _, e := range db.Entities() {
n := strings.TrimSpace(e.Name)
if n == "" || seen[n] {
continue
}
seen[n] = true
out = append(out, n)
}
sort.Strings(out)
return out
}
// Info returns metadata about the currently-loaded cty.dat (or zero value // Info returns metadata about the currently-loaded cty.dat (or zero value
// if nothing loaded). // if nothing loaded).
func (m *Manager) Info() ctySource { func (m *Manager) Info() ctySource {
+75
View File
@@ -3,6 +3,7 @@ package extsvc
import ( import (
"context" "context"
"fmt" "fmt"
"html"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
@@ -70,6 +71,80 @@ func UploadQRZ(ctx context.Context, client *http.Client, apiKey, adifRecord stri
return parseQRZResponse(string(body)) return parseQRZResponse(string(body))
} }
// QRZFetchResult is the parsed outcome of a QRZ FETCH.
type QRZFetchResult struct {
ADIF string // raw ADIF document
Result string // RESULT field (OK / FAIL / AUTH)
Count string // COUNT field reported by QRZ
}
// FetchQRZ pulls logbook records as ADIF via the QRZ FETCH action. option is
// the QRZ OPTION string (e.g. "ALL"). The ADIF document is returned in the
// response's ADIF field.
func FetchQRZ(ctx context.Context, client *http.Client, apiKey, option string) (QRZFetchResult, error) {
var out QRZFetchResult
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return out, fmt.Errorf("qrz: api key not set")
}
form := url.Values{}
form.Set("KEY", apiKey)
form.Set("ACTION", "FETCH")
if option != "" {
form.Set("OPTION", option)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, qrzAPIURL, strings.NewReader(form.Encode()))
if err != nil {
return out, fmt.Errorf("qrz: build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if client == nil {
client = &http.Client{Timeout: 120 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return out, fmt.Errorf("qrz: request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024*1024))
if err != nil {
return out, fmt.Errorf("qrz: read response: %w", err)
}
// The response is "RESULT=OK&COUNT=N&ADIF=<adif>". The ADIF blob can
// contain '&' and ';', so we can't url.ParseQuery the whole body (Go
// caps the number of params). Split off the ADIF value manually and
// only query-parse the small status header.
full := string(body)
head, adifPart := full, ""
if i := strings.Index(full, "ADIF="); i >= 0 {
head = full[:i]
adifPart = full[i+len("ADIF="):]
}
vals, _ := url.ParseQuery(strings.TrimRight(head, "&"))
out.Result = strings.ToUpper(strings.TrimSpace(vals.Get("RESULT")))
out.Count = strings.TrimSpace(vals.Get("COUNT"))
if out.Result == "AUTH" || out.Result == "FAIL" {
reason := strings.TrimSpace(vals.Get("REASON"))
if reason == "" {
reason = "fetch rejected"
}
return out, fmt.Errorf("qrz: %s", reason)
}
// The ADIF value may be url-encoded (%3C) and/or HTML-entity-encoded
// (QRZ returns &lt; &gt; &amp;). Decode both so the ADIF parser sees
// real '<' / '>' tags.
if strings.Contains(adifPart, "%3C") || strings.Contains(adifPart, "%3c") {
if dec, derr := url.QueryUnescape(adifPart); derr == nil {
adifPart = dec
}
}
if strings.Contains(adifPart, "&lt;") || strings.Contains(adifPart, "&gt;") || strings.Contains(adifPart, "&amp;") {
adifPart = html.UnescapeString(adifPart)
}
out.ADIF = adifPart
return out, nil
}
// TestQRZ checks a logbook API key with ACTION=STATUS and returns a short // TestQRZ checks a logbook API key with ACTION=STATUS and returns a short
// human-readable summary (callsign + QSO count) for the settings UI. An // human-readable summary (callsign + QSO count) for the settings UI. An
// invalid key comes back as STATUS=AUTH → returned as an error. // invalid key comes back as STATUS=AUTH → returned as an error.
+11 -4
View File
@@ -50,7 +50,7 @@ type Provider interface {
// don't (or when no provider returned anything). Decoupled via interface so // don't (or when no provider returned anything). Decoupled via interface so
// `lookup` doesn't import the dxcc package directly. // `lookup` doesn't import the dxcc package directly.
type DXCCResolver interface { type DXCCResolver interface {
Resolve(callsign string) (country, continent string, cqz, ituz int, lat, lon float64, ok bool) Resolve(callsign string) (dxccNum int, country, continent string, cqz, ituz int, lat, lon float64, ok bool)
} }
// Manager composes a cache with one or more providers. // Manager composes a cache with one or more providers.
@@ -210,7 +210,7 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
if dxcc == nil { if dxcc == nil {
return false return false
} }
country, cont, cqz, ituz, lat, lon, ok := dxcc.Resolve(r.Callsign) dxccNum, country, cont, cqz, ituz, lat, lon, ok := dxcc.Resolve(r.Callsign)
if !ok { if !ok {
return false return false
} }
@@ -221,8 +221,15 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
if ituz != 0 { r.ITUZ = ituz; filled = true } if ituz != 0 { r.ITUZ = ituz; filled = true }
if lat != 0 && r.Lat == 0 { r.Lat = lat; filled = true } if lat != 0 && r.Lat == 0 { r.Lat = lat; filled = true }
if lon != 0 && r.Lon == 0 { r.Lon = lon; filled = true } if lon != 0 && r.Lon == 0 { r.Lon = lon; filled = true }
// Slashed call → drop QRZ's DXCC# (it's the home call's). // cty.dat is authoritative for the *operating* entity: it strips benign
if strings.ContainsRune(r.Callsign, '/') && r.DXCC != 0 { // suffixes (/P /M /MM /QRP /A …) and honours real prefixes (DL/F4NIE).
// Use its DXCC# when known — this overrides the provider's home-call
// value AND fixes portable calls like F4BPO/P (same entity, must keep
// France's 227). Only when cty.dat can't map a slashed call do we drop
// the provider's number rather than mislabel.
if dxccNum != 0 {
if r.DXCC != dxccNum { r.DXCC = dxccNum; filled = true }
} else if strings.ContainsRune(r.Callsign, '/') && r.DXCC != 0 {
r.DXCC = 0 r.DXCC = 0
filled = true filled = true
} }
+46 -4
View File
@@ -78,6 +78,8 @@ type QSO struct {
HRDLogUploadStatus string `json:"hrdlog_qso_upload_status,omitempty"` HRDLogUploadStatus string `json:"hrdlog_qso_upload_status,omitempty"`
QRZComUploadDate string `json:"qrzcom_qso_upload_date,omitempty"` QRZComUploadDate string `json:"qrzcom_qso_upload_date,omitempty"`
QRZComUploadStatus string `json:"qrzcom_qso_upload_status,omitempty"` QRZComUploadStatus string `json:"qrzcom_qso_upload_status,omitempty"`
QRZComDownloadDate string `json:"qrzcom_qso_download_date,omitempty"`
QRZComDownloadStatus string `json:"qrzcom_qso_download_status,omitempty"`
// --- Contest --- // --- Contest ---
ContestID string `json:"contest_id,omitempty"` ContestID string `json:"contest_id,omitempty"`
@@ -167,6 +169,7 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
clublog_qso_upload_date, clublog_qso_upload_status, clublog_qso_upload_date, clublog_qso_upload_status,
hrdlog_qso_upload_date, hrdlog_qso_upload_status, hrdlog_qso_upload_date, hrdlog_qso_upload_status,
qrzcom_qso_upload_date, qrzcom_qso_upload_status, qrzcom_qso_upload_date, qrzcom_qso_upload_status,
qrzcom_qso_download_date, qrzcom_qso_download_status,
contest_id, srx, stx, srx_string, stx_string, check_field, precedence, arrl_sect, contest_id, srx, stx, srx_string, stx_string, check_field, precedence, arrl_sect,
prop_mode, sat_name, sat_mode, ant_az, ant_el, ant_path, prop_mode, sat_name, sat_mode, ant_az, ant_el, ant_path,
station_callsign, operator, my_grid, my_gridsquare_ext, my_country, my_state, my_cnty, my_iota, station_callsign, operator, my_grid, my_gridsquare_ext, my_country, my_state, my_cnty, my_iota,
@@ -219,6 +222,7 @@ func (q *QSO) args() []any {
q.ClublogUploadDate, q.ClublogUploadStatus, q.ClublogUploadDate, q.ClublogUploadStatus,
q.HRDLogUploadDate, q.HRDLogUploadStatus, q.HRDLogUploadDate, q.HRDLogUploadStatus,
q.QRZComUploadDate, q.QRZComUploadStatus, q.QRZComUploadDate, q.QRZComUploadStatus,
q.QRZComDownloadDate, q.QRZComDownloadStatus,
q.ContestID, q.SRX, q.STX, q.SRXString, q.STXString, q.Check, q.Precedence, q.ARRLSect, q.ContestID, q.SRX, q.STX, q.SRXString, q.STXString, q.Check, q.Precedence, q.ARRLSect,
q.PropMode, q.SatName, q.SatMode, q.AntAz, q.AntEl, q.AntPath, q.PropMode, q.SatName, q.SatMode, q.AntAz, q.AntEl, q.AntPath,
q.StationCallsign, q.Operator, q.MyGrid, q.MyGridExt, q.MyCountry, q.MyState, q.MyCounty, q.MyIOTA, q.StationCallsign, q.Operator, q.MyGrid, q.MyGridExt, q.MyCountry, q.MyState, q.MyCounty, q.MyIOTA,
@@ -1099,14 +1103,35 @@ func SlotKey(dxcc int, band, mode string) string {
return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), strings.ToUpper(mode)) return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), strings.ToUpper(mode))
} }
// ConfirmedSlots returns the set of confirmed DXCC/band/slot combos. A QSO // confirmedCols whitelists the received-status columns ConfirmedSlots may
// counts as confirmed when any received flag (LoTW, paper, eQSL) is "Y". // OR together (guards the dynamic SQL).
func (r *Repo) ConfirmedSlots(ctx context.Context) (ConfirmedSets, error) { var confirmedCols = map[string]bool{
"lotw_rcvd": true,
"qsl_rcvd": true,
"eqsl_rcvd": true,
"qrzcom_qso_download_status": true,
}
// ConfirmedSlots returns the set of confirmed DXCC/band/slot combos, counting
// only the given received-status columns as "confirmed". This lets the caller
// scope award-relevant confirmations per service — e.g. LoTW download uses
// {lotw_rcvd, qsl_rcvd} (the award-valid sources), QRZ uses
// {qrzcom_qso_download_status}.
func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets, error) {
sets := ConfirmedSets{DXCC: map[int]bool{}, Band: map[string]bool{}, Slot: map[string]bool{}} sets := ConfirmedSets{DXCC: map[int]bool{}, Band: map[string]bool{}, Slot: map[string]bool{}}
var conds []string
for _, c := range cols {
if confirmedCols[c] {
conds = append(conds, c+" = 'Y'")
}
}
if len(conds) == 0 {
return sets, nil
}
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT COALESCE(dxcc,0), LOWER(COALESCE(band,'')), UPPER(COALESCE(mode,'')) SELECT COALESCE(dxcc,0), LOWER(COALESCE(band,'')), UPPER(COALESCE(mode,''))
FROM qso FROM qso
WHERE lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y'`) WHERE `+strings.Join(conds, " OR "))
if err != nil { if err != nil {
return sets, err return sets, err
} }
@@ -1127,6 +1152,19 @@ func (r *Repo) ConfirmedSlots(ctx context.Context) (ConfirmedSets, error) {
return sets, rows.Err() return sets, rows.Err()
} }
// MarkQRZConfirmed stamps QRZCOM_QSO_DOWNLOAD_STATUS=Y and the date on a QSO
// confirmed via a QRZ.com download. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE qso SET qrzcom_qso_download_status = 'Y', qrzcom_qso_download_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
date, id)
if err != nil {
return fmt.Errorf("mark qrz confirmed %d: %w", id, err)
}
return nil
}
// MarkLoTWConfirmed stamps LOTW_QSL_RCVD=Y and the received date on a QSO // MarkLoTWConfirmed stamps LOTW_QSL_RCVD=Y and the received date on a QSO
// after a LoTW confirmation download. date is an ADIF YYYYMMDD string. // after a LoTW confirmation download. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkLoTWConfirmed(ctx context.Context, id int64, date string) error { func (r *Repo) MarkLoTWConfirmed(ctx context.Context, id int64, date string) error {
@@ -1173,6 +1211,7 @@ func scanQSO(s scanner) (QSO, error) {
clublogDate, clublogStatus sql.NullString clublogDate, clublogStatus sql.NullString
hrdlogDate, hrdlogStatus sql.NullString hrdlogDate, hrdlogStatus sql.NullString
qrzcomDate, qrzcomStatus sql.NullString qrzcomDate, qrzcomStatus sql.NullString
qrzcomDlDate, qrzcomDlStatus sql.NullString
contestID sql.NullString contestID sql.NullString
srx, stx sql.NullInt64 srx, stx sql.NullInt64
srxStr, stxStr sql.NullString srxStr, stxStr sql.NullString
@@ -1205,6 +1244,7 @@ func scanQSO(s scanner) (QSO, error) {
&clublogDate, &clublogStatus, &clublogDate, &clublogStatus,
&hrdlogDate, &hrdlogStatus, &hrdlogDate, &hrdlogStatus,
&qrzcomDate, &qrzcomStatus, &qrzcomDate, &qrzcomStatus,
&qrzcomDlDate, &qrzcomDlStatus,
&contestID, &srx, &stx, &srxStr, &stxStr, &checkField, &precedence, &arrlSect, &contestID, &srx, &stx, &srxStr, &stxStr, &checkField, &precedence, &arrlSect,
&propMode, &satName, &satMode, &antAz, &antEl, &antPath, &propMode, &satName, &satMode, &antAz, &antEl, &antPath,
&stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA, &stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA,
@@ -1292,6 +1332,8 @@ func scanQSO(s scanner) (QSO, error) {
q.HRDLogUploadStatus = hrdlogStatus.String q.HRDLogUploadStatus = hrdlogStatus.String
q.QRZComUploadDate = qrzcomDate.String q.QRZComUploadDate = qrzcomDate.String
q.QRZComUploadStatus = qrzcomStatus.String q.QRZComUploadStatus = qrzcomStatus.String
q.QRZComDownloadDate = qrzcomDlDate.String
q.QRZComDownloadStatus = qrzcomDlStatus.String
q.ContestID = contestID.String q.ContestID = contestID.String
if srx.Valid { if srx.Valid {
v := int(srx.Int64) v := int(srx.Int64)
+54
View File
@@ -10,6 +10,7 @@ package pst
import ( import (
"fmt" "fmt"
"net" "net"
"strconv"
"time" "time"
) )
@@ -53,6 +54,59 @@ func (c *Client) Park() error {
return c.send("<PST><PARK>1</PARK></PST>") return c.send("<PST><PARK>1</PARK></PST>")
} }
// Heading queries PstRotator for the current azimuth. PstRotator's protocol:
// send "<PST>AZ?</PST>" to the command port, and it reports the azimuth back
// on UDP port+1. So we bind a listener on port+1 first, send the query, then
// read the reply. Returns the raw reply too, for diagnostics. err is non-nil
// on timeout (no reply) or an unparseable response.
func (c *Client) Heading() (az int, raw string, err error) {
// Listen on port+1 where PstRotator sends its position report.
pc, err := net.ListenPacket("udp4", fmt.Sprintf(":%d", c.Port+1))
if err != nil {
return 0, "", fmt.Errorf("listen :%d for PstRotator reply: %w", c.Port+1, err)
}
defer pc.Close()
if err := c.send("<PST>AZ?</PST>"); err != nil {
return 0, "", fmt.Errorf("query PstRotator: %w", err)
}
_ = pc.SetReadDeadline(time.Now().Add(1500 * time.Millisecond))
buf := make([]byte, 512)
n, _, rerr := pc.ReadFrom(buf)
if rerr != nil {
return 0, "", fmt.Errorf("no reply on :%d: %w", c.Port+1, rerr)
}
raw = string(buf[:n])
a, ok := parseAzimuth(raw)
if !ok {
return 0, raw, fmt.Errorf("no azimuth in reply %q", raw)
}
return a, raw, nil
}
// parseAzimuth extracts the first integer found in a PstRotator reply
// ("AZ:123", "123", "<PST><AZIMUTH>123</AZIMUTH></PST>", …) and normalises
// it to [0,360).
func parseAzimuth(s string) (int, bool) {
i := 0
for i < len(s) && (s[i] < '0' || s[i] > '9') {
i++
}
if i >= len(s) {
return 0, false
}
j := i
for j < len(s) && s[j] >= '0' && s[j] <= '9' {
j++
}
n, err := strconv.Atoi(s[i:j])
if err != nil {
return 0, false
}
return ((n % 360) + 360) % 360, true
}
func (c *Client) send(payload string) error { func (c *Client) send(payload string) error {
addr := fmt.Sprintf("%s:%d", c.Host, c.Port) addr := fmt.Sprintf("%s:%d", c.Host, c.Port)
conn, err := net.DialTimeout("udp", addr, 2*time.Second) conn, err := net.DialTimeout("udp", addr, 2*time.Second)