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"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -53,8 +54,11 @@ const (
keyStationSOTA = "station.my_sota_ref"
keyStationPOTA = "station.my_pota_ref"
keyListsBands = "lists.bands"
keyListsModes = "lists.modes"
keyListsBands = "lists.bands"
keyListsModes = "lists.modes"
keyListsRSTPhone = "lists.rst_phone"
keyListsRSTCW = "lists.rst_cw"
keyListsRSTDigital = "lists.rst_digital"
keyCATEnabled = "cat.enabled"
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
// entry form. Default values match common HF/VHF practice.
type ListsSettings struct {
Bands []string `json:"bands"`
Modes []ModePreset `json:"modes"`
Bands []string `json:"bands"`
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{
@@ -171,6 +178,49 @@ var defaultModes = []ModePreset{
{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
// 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.
@@ -214,7 +264,8 @@ type App struct {
udpRepo *udp.Repo
extsvc *extsvc.Manager
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
// close fires shutdown tasks (backup, future LoTW upload, ...) while
@@ -316,7 +367,7 @@ func (a *App) refreshOperatorGrid() {
// without making the lookup package import dxcc.
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 {
return
}
@@ -324,7 +375,7 @@ func (a dxccAdapter) Resolve(call string) (country, continent string, cqz, ituz
if !found || mm.Entity == nil {
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{} }
@@ -343,12 +394,29 @@ func (a *App) startup(ctx context.Context) {
fmt.Println("OpsLog:", a.startupErr)
return
}
a.dataDir = dataDir
a.dbPath = filepath.Join(dataDir, "opslog.db")
// One-shot rename for users coming from the HamLog era.
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)
usingDefault := true
// config.json (in the data dir) may point the database to a user-chosen
// location — e.g. another drive or a synced folder, so it survives a
// Windows reinstall. It lives OUTSIDE the DB since we must know the path
// 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 {
@@ -632,6 +700,122 @@ func userDataDir() (string, error) {
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.
// Called at startup and after the user saves new credentials.
//
@@ -721,6 +905,16 @@ type StationInfoComputed struct {
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
// callsign (via cty.dat) and grid (via Maidenhead → lat/lon). The
// 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)
return
}
// Snapshot what's already confirmed so we can flag each incoming
// confirmation as a NEW DXCC / band / slot.
sets, _ := a.qso.ConfirmedSlots(ctx)
// Snapshot award-valid confirmations (LoTW + paper QSL — the only two
// that count for ARRL awards) so each incoming one is flagged NEW.
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"})
var items []ConfirmationItem
perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
q, ok := adif.RecordToQSO(rec)
@@ -1715,12 +1909,148 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
if a.settings != nil {
_ = 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:
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
}
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
// from cty.dat (offline) — used when adding a not-found confirmation that
// 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 != "" {
_ = 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 {
out.Bands = append([]string(nil), defaultBands...)
}
if len(out.Modes) == 0 {
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
}
@@ -2469,7 +2817,23 @@ func (a *App) SaveListsSettings(l ListsSettings) error {
if err != nil {
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
@@ -2624,6 +2988,28 @@ func (a *App) rotatorClient() (*pst.Client, RotatorSettings, error) {
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
// elevation if the rotator is configured for it).
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")
}
// 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
// the UI on mount and to hydrate after a `cluster:state` event.
func (a *App) GetClusterStatus() []cluster.ServerStatus {
+389 -192
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
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';
import {
@@ -14,24 +14,25 @@ import {
SetCompactMode,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
RefreshCtyDat,
RotatorGoTo, RotatorStop,
RotatorGoTo, RotatorStop, GetRotatorHeading,
OpenExternalURL,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
ListClusterServers, ClusterSpotStatuses,
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
GetCATSettings,
OperatingDefaultForBand,
LogUDPLoggedADIF,
ListCountries,
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { EventsOn } from '../wailsjs/runtime/runtime';
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
import { Menubar, type Menu } from '@/components/Menubar';
import { QSLManagerModal } from '@/components/QSLManagerModal';
import { QSLManagerPanel } from '@/components/QSLManagerModal';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { SettingsModal } from '@/components/SettingsModal';
import { QSOEditModal } from '@/components/QSOEditModal';
import { BandSlotGrid } from '@/components/BandSlotGrid';
import { BandMap } from '@/components/BandMap';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { ShutdownProgress } from '@/components/ShutdownProgress';
@@ -39,6 +40,7 @@ import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -54,6 +56,7 @@ import {
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { pathBetween } from '@/lib/maidenhead';
import { flagURL } from '@/lib/flags';
type QSO = QSOForm;
type ImportResult = adifModels.ImportResult;
@@ -141,6 +144,38 @@ function shortCatError(err?: string): string {
if (e.includes('coinitialize')) return 'COM error';
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 {
if (!call) return '';
const c = call.trim().toUpperCase().split('/')[0];
@@ -159,6 +194,8 @@ export default function App() {
// === Entry ===
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,
// logged as ADIF QSO_DATE. End time = save click (logged as QSO_DATE_OFF).
const [qsoStartedAt, setQsoStartedAt] = useState<Date | null>(null);
@@ -228,6 +265,10 @@ export default function App() {
const [freqMhz, setFreqMhz] = useState('');
// RX freq for split — only set/shown when the rig is in split mode.
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 [rstRcvd, setRstRcvd] = useState('59');
const [grid, setGrid] = useState('');
@@ -248,6 +289,7 @@ export default function App() {
// CAT — receives live rig state via Wails events.
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
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
// in Preferences > Hardware > CAT interface.
@@ -360,11 +402,24 @@ export default function App() {
const [qsos, setQsos] = useState<QSO[]>([]);
const [total, setTotal] = useState<number>(0);
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 [filterCallsign, setFilterCallsign] = useState('');
const [filterBand, setFilterBand] = useState('');
const [filterMode, setFilterMode] = useState('');
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
// huge logs render OK once loaded, but a 25k+ logbook still takes a
// couple of seconds to round-trip from SQLite at launch. Defaulting
@@ -410,6 +465,8 @@ export default function App() {
retries?: number;
};
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 }[]>([]);
// Ring buffer — only keep the last N spots; cluster firehose can be heavy.
const [spots, setSpots] = useState<ClusterSpot[]>([]);
@@ -449,7 +506,6 @@ export default function App() {
// close so the next plain "Preferences" launch reverts to default.
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
const [showDeleteAll, setShowDeleteAll] = useState(false);
const [showQSLManager, setShowQSLManager] = useState(false);
const [deletingAll, setDeletingAll] = useState(false);
const [ctyRefreshing, setCtyRefreshing] = useState(false);
@@ -518,6 +574,44 @@ export default function App() {
return () => { offUploaded(); offDone(); if (t) window.clearTimeout(t); };
}, [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 () => {
try { setStation(await GetStationSettings()); } catch {}
}, []);
@@ -530,6 +624,7 @@ export default function App() {
const loadLists = useCallback(async () => {
try {
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.modes && l.modes.length) {
setModePresets(l.modes);
@@ -581,14 +676,14 @@ export default function App() {
if (!lk.freq && s.freq_hz && s.freq_hz > 0) {
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
}
// RX freq (split only): backend follows ADIF — freq_hz = TX,
// freq_rx_hz = RX. Only set when the rig is in split, otherwise the
// field would duplicate TX for no reason. The freq lock covers both.
// RX freq: backend follows ADIF — freq_hz = TX, freq_rx_hz = RX.
// In split we take the rig's real RX freq; otherwise RX mirrors TX
// (the user can still override it by hand). The freq lock covers both.
if (!lk.freq) {
if (s.split && s.freq_rx_hz && s.freq_rx_hz > 0) {
setRxFreqMhz((s.freq_rx_hz / 1_000_000).toFixed(5));
} else {
setRxFreqMhz('');
} else if (s.freq_hz && s.freq_hz > 0) {
setRxFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
}
}
if (!lk.band && s.band) setBand(s.band);
@@ -736,7 +831,7 @@ export default function App() {
callsign: callsign.trim().toUpperCase(),
qso_date: start.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,
grid: grid.trim().toUpperCase(),
name, qth, country, comment, notes: note,
@@ -981,9 +1076,8 @@ export default function App() {
{ type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' },
{ type: 'separator' },
{ type: 'item', label: 'Callsign lookup settings…', action: 'tools.lookup' },
{ type: 'item', label: 'DX Cluster', action: 'tools.cluster', disabled: true },
{ type: 'item', label: 'CAT control', action: 'tools.cat', disabled: true },
{ type: 'item', label: 'Rotator', action: 'tools.rotator', disabled: true },
{ type: 'item', label: 'CAT interface…', action: 'tools.cat' },
{ type: 'item', label: 'Rotator…', action: 'tools.rotator' },
{ type: 'separator' },
// Maintenance — bumped here while we only have one entry. Will move
// 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.delete': if (selectedId !== null) askDelete(selectedId); 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.cat': setSettingsSection('cat'); setShowSettings(true); break;
case 'tools.rotator': setSettingsSection('rotator'); setShowSettings(true); break;
case 'tools.refreshCty': refreshCtyDat(); break;
}
}
@@ -1097,9 +1193,20 @@ export default function App() {
{catState.split && (
<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" />
<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
both directly clickable, plus an always-visible Stop. The
old Shift/Ctrl shortcuts were not discoverable enough. */}
@@ -1151,51 +1258,6 @@ export default function App() {
</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 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>
)}
{/* 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 =====
Enter from any <input> inside the strip logs the QSO. Radix Selects
render as <button> elements and are ignored by this handler — they
keep their own keyboard behaviour. */}
<div className={cn(!compact && 'flex gap-2.5 items-stretch px-2.5 pt-2.5 shrink-0')}>
<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) => {
if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') {
e.preventDefault();
@@ -1264,9 +1337,12 @@ export default function App() {
} else if (e.key === 'Escape') {
e.preventDefault();
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">
<Label className="mb-1 flex items-center gap-2 h-3.5">
Callsign
@@ -1294,60 +1370,25 @@ export default function App() {
{!lookupBusy && !lookupResult && lookupError && (
<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>
<Input
ref={callsignRef}
className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card"
value={callsign}
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>
{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>
<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 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 className="flex flex-col w-24">
<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')}
/>
</div>
{/* Optional ID/location fields — hidden in compact mode. */}
{/* ── Row 2: Operator name + QTH + Grid + Country + zones (hidden in compact) ── */}
{!compact && <>
<div className="flex flex-col w-24"><Label className="mb-1 h-3.5">Grid</Label>
<Input value={grid} placeholder="JN05" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
</div>
<div className="flex flex-col flex-1 min-w-[100px]"><Label className="mb-1 h-3.5">Name</Label>
<div className="basis-full h-0" aria-hidden />
<div className="flex flex-col w-48"><Label className="mb-1 h-3.5">Name</Label>
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
</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'); }} />
</div>
<div className="flex flex-col flex-1 min-w-[100px]"><Label className="mb-1 h-3.5">Country</Label>
<Input value={country} onChange={(e) => { setCountry(e.target.value); markEdited('country'); }} />
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">Grid</Label>
<Input value={grid} placeholder="JN05" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
</div>
{/* Numeric DXCC metadata + short-path azimuth — surfaced in the
main strip per user request (LP + distances stay in F2). */}
<div className="flex flex-col w-16"><Label className="mb-1 h-3.5">DXCC #</Label>
<Input
type="number"
className="font-mono"
value={details.dxcc ?? ''}
onChange={(e) => updateDetails({ dxcc: e.target.value === '' ? undefined : parseInt(e.target.value, 10) || undefined })}
placeholder="—"
/>
<div className="flex flex-col w-40">
<Label className="mb-1 h-3.5 flex items-center gap-1.5">
Country
{flagURL(details.dxcc) && (
<img src={flagURL(details.dxcc)} alt="" className="h-3 rounded-[2px] border border-border/50 shadow-sm"
referrerPolicy="no-referrer" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
)}
</Label>
<Combobox value={country} options={countries} placeholder="Country" onChange={(v) => { setCountry(v); markEdited('country'); }} />
</div>
<div className="flex flex-col w-16"><Label className="mb-1 h-3.5">CQ</Label>
<Input
type="number"
className="font-mono"
value={details.cqz ?? ''}
onChange={(e) => updateDetails({ cqz: e.target.value === '' ? undefined : parseInt(e.target.value, 10) || undefined })}
placeholder="—"
/>
{/* DXCC # and Continent are derived from the callsign — read-only.
CQ/ITU stay editable but as plain text (no number spinners).
Kept compact (Log4OM-style) — just wide enough for their digits. */}
<div className="flex flex-col w-11"><Label className="mb-1 h-3.5">DXCC</Label>
<Input readOnly tabIndex={-1} className="font-mono bg-muted/40 cursor-default text-center px-1 text-xs"
value={details.dxcc ?? ''} placeholder="—" />
</div>
<div className="flex flex-col w-16"><Label className="mb-1 h-3.5">ITU</Label>
<Input
type="number"
className="font-mono"
value={details.ituz ?? ''}
onChange={(e) => updateDetails({ ituz: e.target.value === '' ? undefined : parseInt(e.target.value, 10) || undefined })}
placeholder="—"
/>
<div className="flex flex-col w-9"><Label className="mb-1 h-3.5">CQ</Label>
<Input inputMode="numeric" maxLength={2} className="font-mono text-center px-1 text-xs" value={details.cqz ?? ''} placeholder="—"
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); updateDetails({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} />
</div>
<div className="flex flex-col w-14"><Label className="mb-1 h-3.5">Cont</Label>
<Input
className="font-mono uppercase"
value={details.cont}
onChange={(e) => updateDetails({ cont: e.target.value.toUpperCase() })}
placeholder="—"
maxLength={2}
/>
<div className="flex flex-col w-9"><Label className="mb-1 h-3.5">ITU</Label>
<Input inputMode="numeric" maxLength={2} className="font-mono text-center px-1 text-xs" value={details.ituz ?? ''} placeholder="—"
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); updateDetails({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
</div>
<div className="flex flex-col w-9"><Label className="mb-1 h-3.5">Cont</Label>
<Input readOnly tabIndex={-1} className="font-mono uppercase bg-muted/40 cursor-default text-center px-1 text-xs"
value={details.cont} placeholder="—" />
</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>
<Input value={comment} onChange={(e) => setComment(e.target.value)} />
</div>
@@ -1468,60 +1550,74 @@ export default function App() {
<Input value={note} onChange={(e) => setNote(e.target.value)} />
</div>
)}
<div className="flex flex-col">
<div className="flex flex-col ml-auto">
<Label className="mb-1 h-3.5">&nbsp;</Label>
<Button onClick={save} disabled={saving} className="h-8">
<Send className="size-3.5" />
{saving ? '…' : 'Log QSO'}
</Button>
<div className="flex gap-2">
{/* Send DX spot — only when a cluster is connected. Pre-fills the
dialog from the current entry (or the last logged QSO). */}
{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>
</section>
{/* In compact mode the entry strip is the whole app — hide everything
else and let the user re-expand with the topbar toggle. */}
{compact ? null : <>
{/* ===== BAND/SLOT GRID ===== */}
{/* QRZ profile picture sits next to the matrix when the user has
opted in (Settings → Lookup → Show QRZ profile pictures). The
backend returns image_url="" when the toggle is off, so we
don't need to re-check the setting here. */}
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
<BandSlotGrid wb={wb} busy={wbBusy} currentBand={band} currentMode={mode} />
{/* Right of the entry (Log4OM-style): F1 Stats (matrix) + F2-F5 detail
tabs, then reserved free space. Hidden in compact mode. */}
{!compact && (
<div className="w-[560px] shrink-0 min-h-0 flex flex-col">
<DetailsPanel
callsign={callsign}
prefix={prefix}
operatorGrid={station.my_grid}
remoteGrid={grid}
details={details}
onChange={updateDetails}
wb={wb}
wbBusy={wbBusy}
band={band}
mode={mode}
/>
</div>
{lookupResult?.image_url && (
<a
href={lookupResult.image_url}
onClick={(e) => {
e.preventDefault();
OpenExternalURL(lookupResult.image_url!).catch((err) => setError(String(err?.message ?? err)));
}}
)}
{/* Reserved free space to the right — shows the QRZ profile photo large
so it's actually legible. Click opens the full-size image on QRZ. */}
{!compact && lookupResult?.image_url && (
<div className="flex-1 min-w-0 flex items-center">
<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"
className="block shrink-0 rounded border border-border overflow-hidden hover:border-primary/60 transition-colors"
>
<img
src={lookupResult.image_url}
alt={`${callsign} profile`}
className="block w-[160px] h-[120px] object-cover bg-muted/30"
alt="profile"
className="block max-h-[180px] max-w-full w-auto object-contain"
loading="lazy"
referrerPolicy="no-referrer"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
</a>
)}
</div>
{/* ===== F2-F5 DETAILS ===== */}
<DetailsPanel
callsign={callsign}
prefix={prefix}
operatorGrid={station.my_grid}
remoteGrid={grid}
details={details}
onChange={updateDetails}
/>
</button>
</div>
)}
</div>{/* /entry + aside row */}
{/* ===== 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]')}>
<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">
@@ -1540,6 +1636,21 @@ export default function App() {
</TabsTrigger>
<TabsTrigger value="awards">Awards</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>
<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} />
</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) => (
<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" />
@@ -1985,9 +2105,87 @@ export default function App() {
</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 && (
<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 && (
<SettingsModal
initialSection={settingsSection}
@@ -1995,7 +2193,6 @@ export default function App() {
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); }}
/>
)}
<QSLManagerModal open={showQSLManager} onClose={() => setShowQSLManager(false)} />
{deletingQSO && (
<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']],
};
// 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 } {
// pill = full pill background+text+border
// 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 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
// 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) {
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
// span, totalH grows so scrolling reveals them.
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
// 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);
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 filtered: Spot[] = [];
const inBand: Spot[] = [];
for (const s of spots) {
if (s.freq_khz < lo || s.freq_khz > hi) continue;
if (seen.has(s.dx_call)) continue;
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);
// 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 {
placed: out,
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).
// 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 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(() => {
const el = scrollerRef.current;
if (!el) return;
@@ -375,7 +456,8 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
{/* Pills absolutely positioned at their (anti-overlapped) Y */}
{placed.map((p, i) => {
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 mode = inferSpotMode(p.spot.comment ?? '', p.spot.freq_hz);
return (
@@ -389,7 +471,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
'hover:translate-x-0.5 hover:shadow',
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 */}
<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>
{/* 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">
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>
);
+24 -16
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import { ChevronUp, Construction } from 'lucide-react';
import { Construction } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
@@ -8,6 +8,7 @@ import {
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { pathBetween } from '@/lib/maidenhead';
import { BandSlotGrid } from '@/components/BandSlotGrid';
export interface DetailsState {
state: string;
@@ -45,9 +46,16 @@ interface Props {
remoteGrid: string; // entry-strip Grid value — destination
details: DetailsState;
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'];
@@ -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) {
const [open, setOpen] = useState<TabName | null>(null);
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode }: Props) {
const [open, setOpen] = useState<TabName>('stats');
// Bearing/distance from operator's home grid to the remote station.
// 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 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';
function setSatellite(on: boolean) {
@@ -94,6 +102,7 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
}
const tabs: { key: TabName; label: string }[] = [
{ key: 'stats', label: 'Stats (F1)' },
{ key: 'info', label: 'Info (F2)' },
{ key: 'awards', label: 'Awards (F3)' },
{ key: 'my', label: 'My (F4)' },
@@ -101,8 +110,8 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
];
return (
<section className="border-b border-border bg-card shrink-0">
<nav className="flex items-center gap-1 px-3 bg-muted/40 border-b border-border">
<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 shrink-0">
{tabs.map((t) => (
<button
key={t.key}
@@ -117,17 +126,15 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
{t.label}
</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>
<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' && (
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
<Field label="State / pref">
@@ -251,6 +258,7 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
</Field>
</div>
)}
</div>
</section>
);
}
+173 -204
View File
@@ -1,8 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { UploadCloud, DownloadCloud, Search, Loader2 } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
@@ -28,7 +25,6 @@ const SERVICES = [
{ v: 'lotw', label: 'LoTW' },
];
// Sent-status filter values. Empty string = blank/none.
const SENT_STATUSES = [
{ v: 'R', label: 'Requested' },
{ 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())}`;
}
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 [sent, setSent] = useState('R');
const [rows, setRows] = useState<UploadRow[]>([]);
@@ -55,22 +53,20 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
const [error, setError] = useState('');
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 [confirmations, setConfirmations] = useState<Confirmation[]>([]);
const [confFilter, setConfFilter] = useState('all'); // all | new | dxcc | band | slot
const [logOpen, setLogOpen] = useState(false);
const [logTitle, setLogTitle] = useState('');
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
const [showLog, setShowLog] = useState(false);
const [logLines, setLogLines] = useState<string[]>([]);
const [uploadDone, setUploadDone] = useState(false);
const [busy, setBusy] = useState(false);
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
useEffect(() => {
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
const offDone = EventsOn('qslmgr:done', (d: any) => {
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0}`]);
setUploadDone(true);
setBusy(false);
});
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
setConfirmations((list ?? []) as Confirmation[]);
@@ -81,16 +77,28 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
const selectedCount = selected.size;
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);
setError('');
try {
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
const list = (r ?? []) as UploadRow[];
setRows(list);
setSelected(new Set(list.map((x) => x.id))); // auto-select all found
setSelected(new Set(list.map((x) => x.id)));
setViewMode('upload');
setShowLog(false);
} catch (e: any) {
setError(String(e?.message ?? e));
setRows([]);
@@ -98,7 +106,7 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
} finally {
setSearching(false);
}
}
}, [service, sent]);
function toggle(id: number) {
setSelected((s) => {
@@ -114,209 +122,170 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
async function upload() {
const ids = rows.filter((r) => selected.has(r.id)).map((r) => r.id);
if (ids.length === 0) return;
setLogLines([]);
setUploadDone(false);
setLogAction('upload');
setLogTitle('Uploading to ' + serviceLabel);
setLogOpen(true);
try {
await UploadQSOsManual(service, ids);
} catch (e: any) {
setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]);
setUploadDone(true);
}
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
try { await UploadQSOsManual(service, ids); }
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
}
async function download() {
setLogLines([]);
setUploadDone(false);
setLogAction('download');
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);
}
setLogLines([]); setBusy(true); setLogAction('download'); setShowLog(true);
try { await DownloadConfirmations(service, addNotFound); }
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
}
function closeLog() {
setLogOpen(false);
// After an upload, refresh the search so uploaded QSOs drop out of the
// filter. After a download, leave the confirmations list on screen.
function viewResults() {
setShowLog(false);
if (logAction === 'upload') selectRequired();
}
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
return (
<>
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-[1000px] w-full max-h-[88vh] grid grid-rows-[auto_auto_1fr_auto] gap-0 p-0">
<DialogHeader className="px-4 pt-4">
<DialogTitle>QSL Manager</DialogTitle>
<DialogDescription className="sr-only">Upload logged QSOs to online logbooks.</DialogDescription>
</DialogHeader>
{/* Search toolbar */}
<div className="flex items-end gap-3 px-4 py-3 border-b border-border bg-muted/20">
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
<Select value={service} onValueChange={setService}>
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
<SelectContent>
{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label>
<Select value={sent} onValueChange={setSent}>
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
<SelectContent>
{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching}>
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
Select required
</Button>
<div className="flex-1" />
<span className="text-xs text-muted-foreground">
{viewMode === 'confirmations'
? `${confirmations.length} confirmation(s) received`
: `${rows.length} found · ${selectedCount} selected`}
</span>
<div className="flex flex-col min-h-0 flex-1">
{/* Search toolbar */}
<div className="flex items-end gap-3 px-3 py-2 border-b border-border bg-muted/20 shrink-0">
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
<Select value={service} onValueChange={setService}>
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label>
<Select value={sent} onValueChange={setSent}>
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
<SelectContent>{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
</Select>
</div>
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching || busy}>
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
Select required
</Button>
<div className="flex-1" />
{!showLog && viewMode === 'confirmations' && (
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Filter</label>
<Select value={confFilter} onValueChange={setConfFilter}>
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="new">New (any)</SelectItem>
<SelectItem value="dxcc">New DXCC</SelectItem>
<SelectItem value="band">New band</SelectItem>
<SelectItem value="slot">New slot</SelectItem>
</SelectContent>
</Select>
</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 */}
<div className="overflow-auto px-4 py-2 min-h-[200px]">
{error && <div className="text-xs text-rose-700 mb-2">{error}</div>}
{/* Content: log OR results grid */}
<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>}
{viewMode === 'confirmations' ? (
confirmations.length === 0 ? (
<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">
{showLog ? (
<div className="font-mono text-[11px] space-y-0.5 py-1">
{logLines.length === 0 ? (
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> starting</div>
) : 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>
<DialogFooter>
<Button size="sm" onClick={closeLog} disabled={!uploadDone}>
{uploadDone ? 'Close' : <><Loader2 className="size-3.5 animate-spin" /> Uploading</>}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
) : viewMode === 'confirmations' ? (
shownConfs.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">
{confirmations.length === 0 ? 'No new confirmations.' : 'No confirmations match this filter.'}
</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>
{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 { Trash2 } from 'lucide-react';
import { Trash2, Search, Loader2 } from 'lucide-react';
import { LookupCallsign } from '../../wailsjs/go/main/App';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
@@ -104,11 +105,47 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
const [extrasText, setExtrasText] = useState(stringifyExtras(draft.extras));
const [localErr, setLocalErr] = useState('');
const [saving, setSaving] = useState(false);
const [looking, setLooking] = useState(false);
function set<K extends keyof QSO>(key: K, value: QSO[K]) {
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() {
if (!draft.callsign?.trim()) { setLocalErr('Callsign required'); return; }
setSaving(true);
@@ -200,8 +237,15 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
<TabsContent value="basic" className="mt-0">
<div className="grid grid-cols-6 gap-3">
<F label="Callsign" span={6}>
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-11"
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
<div className="flex gap-2">
<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 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>
@@ -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 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="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>
</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: '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 confirmed', colId: 'qrzcom_qso_download_status', headerName: 'QRZ.com cfm', field: 'qrzcom_qso_download_status' as any, width: 100 },
// ── Contest ──
{ 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,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp,
GetQSLDefaults, SaveQSLDefaults,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
TestLoTWUpload, ListTQSLStationLocations,
@@ -141,6 +142,7 @@ type SectionId =
| 'lists-modes'
| 'cluster'
| 'backup'
| 'database'
| 'awards'
| 'cat'
| 'rotator'
@@ -171,6 +173,7 @@ const TREE: TreeNode[] = [
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
{ kind: 'item', label: 'UDP integrations (WSJT-X, JTDX, MSHV…)', id: 'udp' },
{ kind: 'item', label: 'Database backup', id: 'backup' },
{ kind: 'item', label: 'Database location', id: 'database' },
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
],
},
@@ -196,6 +199,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
'lists-modes': 'Modes & default RST',
cluster: 'DX Cluster',
backup: 'Database backup',
database: 'Database location',
udp: 'UDP integrations',
awards: 'Awards',
cat: 'CAT interface',
@@ -319,7 +323,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const [activeProfile, setActiveProfile] = useState<Profile | null>(null);
const updateActive = (patch: Partial<Profile>) =>
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
// exotic or experimental bands not listed).
const [bandDraft, setBandDraft] = useState('');
@@ -384,6 +390,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const [backupRunning, setBackupRunning] = useState(false);
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 [clusterAutoConnect, setClusterAutoConnectState] = useState(false);
const [clusterStatuses, setClusterStatuses] = useState<ClusterServerStatus[]>([]);
@@ -457,6 +466,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
setLookup(l);
setActiveProfile(ap as Profile);
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 reloadClusterServers();
setCatCfg(c);
@@ -464,6 +478,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
setBackupCfg(b as any);
setQslDefaults(qd as any);
setExtSvc(es as any);
try { setDbSettings(await GetDatabaseSettings() as any); } catch {}
try {
const locs: any = await ListTQSLStationLocations();
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(),
}))
.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) {
await SaveProfile({
@@ -1233,6 +1254,28 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</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).
const PANELS: Record<SectionId, () => JSX.Element> = {
station: StationPanel,
@@ -2134,6 +2245,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
cluster: ClusterPanel,
udp: UDPIntegrationsPanelWrapper,
backup: BackupPanel,
database: DatabasePanel,
awards: () => <ComingSoon id="awards" icon={Award} />,
cat: CATPanel,
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<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean }
>(({ className, children, hideClose, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean; hideOverlay?: boolean }
>(({ className, children, hideClose, hideOverlay, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
{!hideOverlay && <DialogOverlay />}
<DialogPrimitive.Content
ref={ref}
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 GetDatabaseSettings():Promise<main.DatabaseSettings>;
export function GetExternalServices():Promise<extsvc.ExternalServices>;
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 GetRotatorHeading():Promise<main.RotatorHeading>;
export function GetRotatorSettings():Promise<main.RotatorSettings>;
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 ListCountries():Promise<Array<string>>;
export function ListOperatingTree():Promise<Array<operating.Station>>;
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 MoveDatabase(arg1:string):Promise<void>;
export function OpenADIFFile():Promise<string>;
export function OpenDatabase(arg1:string):Promise<void>;
export function OpenExternalURL(arg1:string):Promise<void>;
export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>;
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 ReloadUDPIntegrations():Promise<Array<string>>;
export function ResetDatabaseToDefault():Promise<void>;
export function RotatorGoTo(arg1:number,arg2:number):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 SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
export function SetCATFrequency(arg1:number):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']();
}
export function GetDatabaseSettings() {
return window['go']['main']['App']['GetDatabaseSettings']();
}
export function GetExternalServices() {
return window['go']['main']['App']['GetExternalServices']();
}
@@ -138,6 +142,10 @@ export function GetQSO(arg1) {
return window['go']['main']['App']['GetQSO'](arg1);
}
export function GetRotatorHeading() {
return window['go']['main']['App']['GetRotatorHeading']();
}
export function GetRotatorSettings() {
return window['go']['main']['App']['GetRotatorSettings']();
}
@@ -158,6 +166,10 @@ export function ListClusterServers() {
return window['go']['main']['App']['ListClusterServers']();
}
export function ListCountries() {
return window['go']['main']['App']['ListCountries']();
}
export function ListOperatingTree() {
return window['go']['main']['App']['ListOperatingTree']();
}
@@ -186,10 +198,18 @@ export function LookupCallsign(arg1) {
return window['go']['main']['App']['LookupCallsign'](arg1);
}
export function MoveDatabase(arg1) {
return window['go']['main']['App']['MoveDatabase'](arg1);
}
export function OpenADIFFile() {
return window['go']['main']['App']['OpenADIFFile']();
}
export function OpenDatabase(arg1) {
return window['go']['main']['App']['OpenDatabase'](arg1);
}
export function OpenExternalURL(arg1) {
return window['go']['main']['App']['OpenExternalURL'](arg1);
}
@@ -202,6 +222,18 @@ export function 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() {
return window['go']['main']['App']['RefreshCtyDat']();
}
@@ -210,6 +242,10 @@ export function ReloadUDPIntegrations() {
return window['go']['main']['App']['ReloadUDPIntegrations']();
}
export function ResetDatabaseToDefault() {
return window['go']['main']['App']['ResetDatabaseToDefault']();
}
export function 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);
}
export function SendClusterSpot(arg1, arg2, arg3) {
return window['go']['main']['App']['SendClusterSpot'](arg1, arg2, arg3);
}
export function 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"];
}
}
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 {
name: string;
default_rst_sent?: string;
@@ -432,6 +448,9 @@ export namespace main {
export class ListsSettings {
bands: string[];
modes: ModePreset[];
rst_phone: string[];
rst_cw: string[];
rst_digital: string[];
static createFrom(source: any = {}) {
return new ListsSettings(source);
@@ -441,6 +460,9 @@ export namespace main {
if ('string' === typeof source) source = JSON.parse(source);
this.bands = source["bands"];
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 {
@@ -516,6 +538,24 @@ export namespace main {
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 {
enabled: boolean;
host: string;
@@ -955,6 +995,8 @@ export namespace qso {
hrdlog_qso_upload_status?: string;
qrzcom_qso_upload_date?: string;
qrzcom_qso_upload_status?: string;
qrzcom_qso_download_date?: string;
qrzcom_qso_download_status?: string;
contest_id?: string;
srx?: number;
stx?: number;
@@ -1060,6 +1102,8 @@ export namespace qso {
this.hrdlog_qso_upload_status = source["hrdlog_qso_upload_status"];
this.qrzcom_qso_upload_date = source["qrzcom_qso_upload_date"];
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.srx = source["srx"];
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, "QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
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 ---
writeField(bw, "CONTEST_ID", q.ContestID)
+3
View File
@@ -184,6 +184,7 @@ var adifPromoted = stringSet(
"clublog_qso_upload_date", "clublog_qso_upload_status",
"hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
"qrzcom_qso_upload_date", "qrzcom_qso_upload_status",
"qrzcom_qso_download_date", "qrzcom_qso_download_status",
// Contest
"contest_id", "srx", "stx", "srx_string", "stx_string",
"check", "precedence", "arrl_sect",
@@ -315,6 +316,8 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
q.HRDLogUploadStatus = rec["hrdlog_qso_upload_status"]
q.QRZComUploadDate = rec["qrzcom_qso_upload_date"]
q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
q.QRZComDownloadDate = rec["qrzcom_qso_download_date"]
q.QRZComDownloadStatus = rec["qrzcom_qso_download_status"]
// Contest
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"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
@@ -147,6 +149,30 @@ func (m *Manager) Lookup(callsign string) (Match, bool) {
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
// if nothing loaded).
func (m *Manager) Info() ctySource {
+75
View File
@@ -3,6 +3,7 @@ package extsvc
import (
"context"
"fmt"
"html"
"io"
"net/http"
"net/url"
@@ -70,6 +71,80 @@ func UploadQRZ(ctx context.Context, client *http.Client, apiKey, adifRecord stri
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
// human-readable summary (callsign + QSO count) for the settings UI. An
// 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
// `lookup` doesn't import the dxcc package directly.
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.
@@ -210,7 +210,7 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
if dxcc == nil {
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 {
return false
}
@@ -221,8 +221,15 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
if ituz != 0 { r.ITUZ = ituz; filled = true }
if lat != 0 && r.Lat == 0 { r.Lat = lat; 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).
if strings.ContainsRune(r.Callsign, '/') && r.DXCC != 0 {
// cty.dat is authoritative for the *operating* entity: it strips benign
// 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
filled = true
}
+46 -4
View File
@@ -78,6 +78,8 @@ type QSO struct {
HRDLogUploadStatus string `json:"hrdlog_qso_upload_status,omitempty"`
QRZComUploadDate string `json:"qrzcom_qso_upload_date,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 ---
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,
hrdlog_qso_upload_date, hrdlog_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,
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,
@@ -219,6 +222,7 @@ func (q *QSO) args() []any {
q.ClublogUploadDate, q.ClublogUploadStatus,
q.HRDLogUploadDate, q.HRDLogUploadStatus,
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.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,
@@ -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))
}
// ConfirmedSlots returns the set of confirmed DXCC/band/slot combos. A QSO
// counts as confirmed when any received flag (LoTW, paper, eQSL) is "Y".
func (r *Repo) ConfirmedSlots(ctx context.Context) (ConfirmedSets, error) {
// confirmedCols whitelists the received-status columns ConfirmedSlots may
// OR together (guards the dynamic SQL).
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{}}
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, `
SELECT COALESCE(dxcc,0), LOWER(COALESCE(band,'')), UPPER(COALESCE(mode,''))
FROM qso
WHERE lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y'`)
WHERE `+strings.Join(conds, " OR "))
if err != nil {
return sets, err
}
@@ -1127,6 +1152,19 @@ func (r *Repo) ConfirmedSlots(ctx context.Context) (ConfirmedSets, error) {
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
// after a LoTW confirmation download. date is an ADIF YYYYMMDD string.
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
hrdlogDate, hrdlogStatus sql.NullString
qrzcomDate, qrzcomStatus sql.NullString
qrzcomDlDate, qrzcomDlStatus sql.NullString
contestID sql.NullString
srx, stx sql.NullInt64
srxStr, stxStr sql.NullString
@@ -1205,6 +1244,7 @@ func scanQSO(s scanner) (QSO, error) {
&clublogDate, &clublogStatus,
&hrdlogDate, &hrdlogStatus,
&qrzcomDate, &qrzcomStatus,
&qrzcomDlDate, &qrzcomDlStatus,
&contestID, &srx, &stx, &srxStr, &stxStr, &checkField, &precedence, &arrlSect,
&propMode, &satName, &satMode, &antAz, &antEl, &antPath,
&stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA,
@@ -1292,6 +1332,8 @@ func scanQSO(s scanner) (QSO, error) {
q.HRDLogUploadStatus = hrdlogStatus.String
q.QRZComUploadDate = qrzcomDate.String
q.QRZComUploadStatus = qrzcomStatus.String
q.QRZComDownloadDate = qrzcomDlDate.String
q.QRZComDownloadStatus = qrzcomDlStatus.String
q.ContestID = contestID.String
if srx.Valid {
v := int(srx.Int64)
+54
View File
@@ -10,6 +10,7 @@ package pst
import (
"fmt"
"net"
"strconv"
"time"
)
@@ -53,6 +54,59 @@ func (c *Client) Park() error {
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 {
addr := fmt.Sprintf("%s:%d", c.Host, c.Port)
conn, err := net.DialTimeout("udp", addr, 2*time.Second)