feat: Winkeyer
This commit is contained in:
@@ -28,6 +28,7 @@ import (
|
|||||||
"hamlog/internal/profile"
|
"hamlog/internal/profile"
|
||||||
"hamlog/internal/qso"
|
"hamlog/internal/qso"
|
||||||
"hamlog/internal/rotator/pst"
|
"hamlog/internal/rotator/pst"
|
||||||
|
"hamlog/internal/winkeyer"
|
||||||
"hamlog/internal/settings"
|
"hamlog/internal/settings"
|
||||||
|
|
||||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
@@ -72,6 +73,27 @@ const (
|
|||||||
keyRotatorPort = "rotator.port"
|
keyRotatorPort = "rotator.port"
|
||||||
keyRotatorHasElevation = "rotator.has_elevation"
|
keyRotatorHasElevation = "rotator.has_elevation"
|
||||||
|
|
||||||
|
// WinKeyer CW keyer (serial) — Hardware → CW Keyer.
|
||||||
|
keyWKEnabled = "winkeyer.enabled"
|
||||||
|
keyWKPort = "winkeyer.port"
|
||||||
|
keyWKBaud = "winkeyer.baud"
|
||||||
|
keyWKWPM = "winkeyer.wpm"
|
||||||
|
keyWKWeight = "winkeyer.weight"
|
||||||
|
keyWKLeadIn = "winkeyer.lead_in_ms"
|
||||||
|
keyWKTail = "winkeyer.tail_ms"
|
||||||
|
keyWKRatio = "winkeyer.ratio"
|
||||||
|
keyWKFarnsworth = "winkeyer.farnsworth"
|
||||||
|
keyWKSidetone = "winkeyer.sidetone_hz"
|
||||||
|
keyWKMode = "winkeyer.mode"
|
||||||
|
keyWKSwap = "winkeyer.swap"
|
||||||
|
keyWKAutoSpace = "winkeyer.autospace"
|
||||||
|
keyWKUsePTT = "winkeyer.use_ptt"
|
||||||
|
keyWKSerialEcho = "winkeyer.serial_echo"
|
||||||
|
keyWKMacros = "winkeyer.macros" // JSON array of {label,text}
|
||||||
|
keyWKEngine = "winkeyer.engine" // "winkeyer" | "tci"
|
||||||
|
keyWKEscClears = "winkeyer.esc_clears_call" // ESC also clears the callsign
|
||||||
|
keyWKSendOnType = "winkeyer.send_on_type" // key characters live as typed
|
||||||
|
|
||||||
keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start
|
keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start
|
||||||
|
|
||||||
keyBackupEnabled = "backup.enabled"
|
keyBackupEnabled = "backup.enabled"
|
||||||
@@ -89,6 +111,7 @@ const (
|
|||||||
keyQSLDefaultClublogStatus = "qsl.clublog_status"
|
keyQSLDefaultClublogStatus = "qsl.clublog_status"
|
||||||
keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status"
|
keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status"
|
||||||
keyQSLDefaultQRZComStatus = "qsl.qrzcom_status"
|
keyQSLDefaultQRZComStatus = "qsl.qrzcom_status"
|
||||||
|
keyQSLDefaultQRZComCfm = "qsl.qrzcom_confirmed"
|
||||||
|
|
||||||
// External services (logbook upload). QRZ.com first; Clublog / LoTW
|
// External services (logbook upload). QRZ.com first; Clublog / LoTW
|
||||||
// will add their own keys under the same extsvc.* prefix.
|
// will add their own keys under the same extsvc.* prefix.
|
||||||
@@ -106,6 +129,7 @@ const (
|
|||||||
|
|
||||||
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
|
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
|
||||||
keyExtLoTWStationLoc = "extsvc.lotw.station_location"
|
keyExtLoTWStationLoc = "extsvc.lotw.station_location"
|
||||||
|
keyExtLoTWForceCall = "extsvc.lotw.force_station_callsign" // override STATION_CALLSIGN at sign time (e.g. F4BPO/P on the F4BPO cert)
|
||||||
keyExtLoTWKeyPassword = "extsvc.lotw.key_password"
|
keyExtLoTWKeyPassword = "extsvc.lotw.key_password"
|
||||||
keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag"
|
keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag"
|
||||||
keyExtLoTWWriteLog = "extsvc.lotw.write_log"
|
keyExtLoTWWriteLog = "extsvc.lotw.write_log"
|
||||||
@@ -131,6 +155,7 @@ type QSLDefaults struct {
|
|||||||
ClublogStatus string `json:"clublog_status"`
|
ClublogStatus string `json:"clublog_status"`
|
||||||
HRDLogStatus string `json:"hrdlog_status"`
|
HRDLogStatus string `json:"hrdlog_status"`
|
||||||
QRZComStatus string `json:"qrzcom_status"`
|
QRZComStatus string `json:"qrzcom_status"`
|
||||||
|
QRZComCfm string `json:"qrzcom_confirmed"` // QRZ.com download/confirmed status
|
||||||
}
|
}
|
||||||
|
|
||||||
// CATSettings is the user-tweakable rig-control configuration. Stored as
|
// CATSettings is the user-tweakable rig-control configuration. Stored as
|
||||||
@@ -263,6 +288,7 @@ type App struct {
|
|||||||
udp *udp.Manager
|
udp *udp.Manager
|
||||||
udpRepo *udp.Repo
|
udpRepo *udp.Repo
|
||||||
extsvc *extsvc.Manager
|
extsvc *extsvc.Manager
|
||||||
|
winkeyer *winkeyer.Manager
|
||||||
startupErr string // captured for surfacing to the frontend
|
startupErr string // captured for surfacing to the frontend
|
||||||
dbPath string // active database file (may be a user-chosen location)
|
dbPath string // active database file (may be a user-chosen location)
|
||||||
dataDir string // %APPDATA%/OpsLog — holds config.json, logs, cty.dat
|
dataDir string // %APPDATA%/OpsLog — holds config.json, logs, cty.dat
|
||||||
@@ -526,6 +552,20 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
})
|
})
|
||||||
a.extsvc.SetConfig(a.loadExternalServices())
|
a.extsvc.SetConfig(a.loadExternalServices())
|
||||||
|
|
||||||
|
// WinKeyer CW keyer (serial). Created idle; the UI connects on demand.
|
||||||
|
a.winkeyer = winkeyer.NewManager(
|
||||||
|
func(s winkeyer.Status) {
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "winkeyer:status", s)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
func(ch string) {
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "winkeyer:echo", ch)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
fmt.Println("OpsLog: db ready at", a.dbPath)
|
fmt.Println("OpsLog: db ready at", a.dbPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,6 +714,9 @@ func (a *App) shutdown(ctx context.Context) {
|
|||||||
if a.udp != nil {
|
if a.udp != nil {
|
||||||
a.udp.StopAll()
|
a.udp.StopAll()
|
||||||
}
|
}
|
||||||
|
if a.winkeyer != nil {
|
||||||
|
a.winkeyer.Disconnect()
|
||||||
|
}
|
||||||
if a.db != nil {
|
if a.db != nil {
|
||||||
_ = a.db.Close()
|
_ = a.db.Close()
|
||||||
}
|
}
|
||||||
@@ -804,11 +847,51 @@ func (a *App) MoveDatabase(dest string) error {
|
|||||||
return writeDBPointer(a.dataDir, dest)
|
return writeDBPointer(a.dataDir, dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateDatabase creates a fresh, empty logbook at dest (schema migrated) and
|
||||||
|
// points OpsLog at it for the next launch. dest must not already exist.
|
||||||
|
func (a *App) CreateDatabase(dest string) error {
|
||||||
|
dest = strings.TrimSpace(dest)
|
||||||
|
if dest == "" {
|
||||||
|
return fmt.Errorf("no path 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)
|
||||||
|
}
|
||||||
|
// db.Open creates the file and runs every migration → ready-to-use schema.
|
||||||
|
conn, err := db.Open(dest)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create database: %w", err)
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
return writeDBPointer(a.dataDir, dest)
|
||||||
|
}
|
||||||
|
|
||||||
// ResetDatabaseToDefault clears the custom location (back to the data dir).
|
// ResetDatabaseToDefault clears the custom location (back to the data dir).
|
||||||
func (a *App) ResetDatabaseToDefault() error {
|
func (a *App) ResetDatabaseToDefault() error {
|
||||||
return writeDBPointer(a.dataDir, "")
|
return writeDBPointer(a.dataDir, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUIPref / SetUIPref persist portable UI preferences (grid column layout,
|
||||||
|
// widths, sort…) in the DB settings table under a "ui." namespace, so they
|
||||||
|
// travel with the logbook and survive a reinstall — unlike the WebView's
|
||||||
|
// localStorage. Values are opaque JSON blobs owned by the frontend.
|
||||||
|
func (a *App) GetUIPref(key string) (string, error) {
|
||||||
|
if a.settings == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return a.settings.Get(a.ctx, "ui."+key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetUIPref(key, value string) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
return a.settings.Set(a.ctx, "ui."+key, value)
|
||||||
|
}
|
||||||
|
|
||||||
// QuitApp closes OpsLog (used to apply a database change on next launch).
|
// QuitApp closes OpsLog (used to apply a database change on next launch).
|
||||||
func (a *App) QuitApp() {
|
func (a *App) QuitApp() {
|
||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
@@ -1134,9 +1217,11 @@ func (a *App) WorkedBefore(callsign string, dxccHint int) (qso.WorkedBefore, err
|
|||||||
// Min size must be reduced BEFORE resizing down, otherwise the OS clamps to
|
// Min size must be reduced BEFORE resizing down, otherwise the OS clamps to
|
||||||
// the previous (larger) min — and increased BEFORE resizing up.
|
// the previous (larger) min — and increased BEFORE resizing up.
|
||||||
const (
|
const (
|
||||||
compactW, compactH = 980, 140
|
compactW, compactH = 1240, 158
|
||||||
normalW, normalH = 1400, 900
|
normalW, normalH = 1400, 900
|
||||||
normalMinW, normalMinH = 1100, 700
|
normalMinW, normalMinH = 1100, 700
|
||||||
|
// Large enough to never constrain a maximised window on big displays.
|
||||||
|
maxW, maxH = 8000, 6000
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *App) SetCompactMode(on bool) {
|
func (a *App) SetCompactMode(on bool) {
|
||||||
@@ -1144,11 +1229,17 @@ func (a *App) SetCompactMode(on bool) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if on {
|
if on {
|
||||||
|
// Lock the window to the compact size by pinning min == max. Without
|
||||||
|
// the max pin, dragging the frameless window (esp. across monitors /
|
||||||
|
// DPI boundaries) makes Windows snap it back to a large size.
|
||||||
wruntime.WindowSetMinSize(a.ctx, compactW, compactH)
|
wruntime.WindowSetMinSize(a.ctx, compactW, compactH)
|
||||||
|
wruntime.WindowSetMaxSize(a.ctx, compactW, compactH)
|
||||||
wruntime.WindowSetSize(a.ctx, compactW, compactH)
|
wruntime.WindowSetSize(a.ctx, compactW, compactH)
|
||||||
wruntime.WindowSetAlwaysOnTop(a.ctx, true)
|
wruntime.WindowSetAlwaysOnTop(a.ctx, true)
|
||||||
} else {
|
} else {
|
||||||
wruntime.WindowSetAlwaysOnTop(a.ctx, false)
|
wruntime.WindowSetAlwaysOnTop(a.ctx, false)
|
||||||
|
// Release the lock first (raise the max) before growing back.
|
||||||
|
wruntime.WindowSetMaxSize(a.ctx, maxW, maxH)
|
||||||
wruntime.WindowSetMinSize(a.ctx, normalMinW, normalMinH)
|
wruntime.WindowSetMinSize(a.ctx, normalMinW, normalMinH)
|
||||||
wruntime.WindowSetSize(a.ctx, normalW, normalH)
|
wruntime.WindowSetSize(a.ctx, normalW, normalH)
|
||||||
}
|
}
|
||||||
@@ -1175,14 +1266,44 @@ func (a *App) OpenADIFFile() (string, error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ImportADIF(path string, skipDuplicates bool) (adif.ImportResult, error) {
|
// ImportADIF imports an ADIF file. dupMode controls how records matching an
|
||||||
|
// existing QSO (same call + UTC-minute + band + mode) are handled:
|
||||||
|
// - "skip" : leave the existing QSO untouched (default, safe)
|
||||||
|
// - "update" : merge the file's non-empty fields onto the existing QSO —
|
||||||
|
// refreshes QSL/confirmation statuses when re-syncing from
|
||||||
|
// Log4OM / LoTW without clobbering fields the file omits
|
||||||
|
// - "all" : insert every record, duplicates included
|
||||||
|
//
|
||||||
|
// applyCty, when true, recomputes country / continent / DXCC / CQ / ITU from
|
||||||
|
// cty.dat for every record, overriding what the file carries — corrects the
|
||||||
|
// wrong COUNTRY that contest software often exports (e.g. RG2Y as Asiatic
|
||||||
|
// Russia). Everything else in the ADIF is still preserved verbatim.
|
||||||
|
func (a *App) ImportADIF(path string, dupMode string, applyCty bool) (adif.ImportResult, error) {
|
||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
return adif.ImportResult{}, fmt.Errorf("db not initialized")
|
return adif.ImportResult{}, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return adif.ImportResult{}, fmt.Errorf("empty path")
|
return adif.ImportResult{}, fmt.Errorf("empty path")
|
||||||
}
|
}
|
||||||
im := &adif.Importer{Repo: a.qso, SkipDuplicates: skipDuplicates}
|
// Import preserves the ADIF verbatim — NO station / confirmation defaults
|
||||||
|
// are applied. Defaults are for NEW QSOs only (manual entry + UDP auto-log);
|
||||||
|
// stamping them on a historical import would, e.g., flag old QSOs as
|
||||||
|
// "LoTW requested" and try to re-upload them.
|
||||||
|
im := &adif.Importer{Repo: a.qso}
|
||||||
|
switch dupMode {
|
||||||
|
case "update":
|
||||||
|
im.UpdateDuplicates = true
|
||||||
|
case "all":
|
||||||
|
// insert everything
|
||||||
|
default: // "skip"
|
||||||
|
im.SkipDuplicates = true
|
||||||
|
}
|
||||||
|
if applyCty {
|
||||||
|
im.Enrich = func(q *qso.QSO) { a.enrichContactedFromCtyForce(q) }
|
||||||
|
}
|
||||||
|
im.OnProgress = func(processed, total int) {
|
||||||
|
wruntime.EventsEmit(a.ctx, "import:progress", map[string]int{"processed": processed, "total": total})
|
||||||
|
}
|
||||||
return im.ImportFile(a.ctx, path)
|
return im.ImportFile(a.ctx, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1202,14 +1323,16 @@ func (a *App) SaveADIFFile() (string, error) {
|
|||||||
|
|
||||||
// ExportADIF writes every QSO to the given file path in ADIF 3.1 format.
|
// ExportADIF writes every QSO to the given file path in ADIF 3.1 format.
|
||||||
// Streams from DB so memory stays flat even with 100k+ records.
|
// Streams from DB so memory stays flat even with 100k+ records.
|
||||||
func (a *App) ExportADIF(path string) (adif.ExportResult, error) {
|
// includeAppFields=false → portable standard ADIF (for other loggers);
|
||||||
|
// true → full export keeping OpsLog/app-specific APP_* fields (round-trip).
|
||||||
|
func (a *App) ExportADIF(path string, includeAppFields bool) (adif.ExportResult, error) {
|
||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
return adif.ExportResult{}, fmt.Errorf("db not initialized")
|
return adif.ExportResult{}, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return adif.ExportResult{}, fmt.Errorf("empty path")
|
return adif.ExportResult{}, fmt.Errorf("empty path")
|
||||||
}
|
}
|
||||||
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1"}
|
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields}
|
||||||
return ex.ExportFile(a.ctx, path)
|
return ex.ExportFile(a.ctx, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1441,12 +1564,16 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
|
|||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
m, err := a.settings.GetMany(a.ctx,
|
prefix := ""
|
||||||
|
if a.profileHasGroup(markerQSL) {
|
||||||
|
prefix = a.profileScope()
|
||||||
|
}
|
||||||
|
m, err := a.getManyScoped(prefix,
|
||||||
keyQSLDefaultQSLSent, keyQSLDefaultQSLRcvd,
|
keyQSLDefaultQSLSent, keyQSLDefaultQSLRcvd,
|
||||||
keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd,
|
keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd,
|
||||||
keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd,
|
keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd,
|
||||||
keyQSLDefaultClublogStatus, keyQSLDefaultHRDLogStatus,
|
keyQSLDefaultClublogStatus, keyQSLDefaultHRDLogStatus,
|
||||||
keyQSLDefaultQRZComStatus,
|
keyQSLDefaultQRZComStatus, keyQSLDefaultQRZComCfm,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
@@ -1460,6 +1587,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
|
|||||||
out.ClublogStatus = m[keyQSLDefaultClublogStatus]
|
out.ClublogStatus = m[keyQSLDefaultClublogStatus]
|
||||||
out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus]
|
out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus]
|
||||||
out.QRZComStatus = m[keyQSLDefaultQRZComStatus]
|
out.QRZComStatus = m[keyQSLDefaultQRZComStatus]
|
||||||
|
out.QRZComCfm = m[keyQSLDefaultQRZComCfm]
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1469,6 +1597,7 @@ func (a *App) SaveQSLDefaults(d QSLDefaults) error {
|
|||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return fmt.Errorf("db not initialized")
|
return fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
|
scope := a.profileScope()
|
||||||
for k, v := range map[string]string{
|
for k, v := range map[string]string{
|
||||||
keyQSLDefaultQSLSent: strings.ToUpper(strings.TrimSpace(d.QSLSent)),
|
keyQSLDefaultQSLSent: strings.ToUpper(strings.TrimSpace(d.QSLSent)),
|
||||||
keyQSLDefaultQSLRcvd: strings.ToUpper(strings.TrimSpace(d.QSLRcvd)),
|
keyQSLDefaultQSLRcvd: strings.ToUpper(strings.TrimSpace(d.QSLRcvd)),
|
||||||
@@ -1479,11 +1608,15 @@ func (a *App) SaveQSLDefaults(d QSLDefaults) error {
|
|||||||
keyQSLDefaultClublogStatus: strings.ToUpper(strings.TrimSpace(d.ClublogStatus)),
|
keyQSLDefaultClublogStatus: strings.ToUpper(strings.TrimSpace(d.ClublogStatus)),
|
||||||
keyQSLDefaultHRDLogStatus: strings.ToUpper(strings.TrimSpace(d.HRDLogStatus)),
|
keyQSLDefaultHRDLogStatus: strings.ToUpper(strings.TrimSpace(d.HRDLogStatus)),
|
||||||
keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)),
|
keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)),
|
||||||
|
keyQSLDefaultQRZComCfm: strings.ToUpper(strings.TrimSpace(d.QRZComCfm)),
|
||||||
} {
|
} {
|
||||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := a.settings.Set(a.ctx, scope+markerQSL, "1"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1508,21 +1641,75 @@ func (a *App) applyQSLDefaults(q *qso.QSO) {
|
|||||||
if q.ClublogUploadStatus == "" { q.ClublogUploadStatus = d.ClublogStatus }
|
if q.ClublogUploadStatus == "" { q.ClublogUploadStatus = d.ClublogStatus }
|
||||||
if q.HRDLogUploadStatus == "" { q.HRDLogUploadStatus = d.HRDLogStatus }
|
if q.HRDLogUploadStatus == "" { q.HRDLogUploadStatus = d.HRDLogStatus }
|
||||||
if q.QRZComUploadStatus == "" { q.QRZComUploadStatus = d.QRZComStatus }
|
if q.QRZComUploadStatus == "" { q.QRZComUploadStatus = d.QRZComStatus }
|
||||||
|
if q.QRZComDownloadStatus == "" { q.QRZComDownloadStatus = d.QRZComCfm }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── External services (logbook upload) ─────────────────────────────────
|
// ── External services (logbook upload) ─────────────────────────────────
|
||||||
|
|
||||||
// loadExternalServices reads the configured external-service settings.
|
// loadExternalServices reads the configured external-service settings.
|
||||||
|
// ── Per-profile settings scoping ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// External Services and QSL Confirmations are scoped to the active profile
|
||||||
|
// so each operating identity (e.g. F4BPO vs TM2Q) uploads to its own
|
||||||
|
// accounts. They live under a "p<profileID>." key prefix. A per-group marker
|
||||||
|
// key records that a profile has saved its own copy; until then we
|
||||||
|
// transparently read the legacy un-prefixed (global) keys as the default —
|
||||||
|
// a lossless migration for logs created before profiles carried settings.
|
||||||
|
const (
|
||||||
|
markerExtsvc = "extsvc._set"
|
||||||
|
markerQSL = "qsl._set"
|
||||||
|
)
|
||||||
|
|
||||||
|
// profileScope returns the active profile's settings-key prefix ("p<id>.").
|
||||||
|
func (a *App) profileScope() string {
|
||||||
|
if a.profiles != nil {
|
||||||
|
if p, err := a.profiles.Active(a.ctx); err == nil && p.ID > 0 {
|
||||||
|
return fmt.Sprintf("p%d.", p.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "p0."
|
||||||
|
}
|
||||||
|
|
||||||
|
// profileHasGroup reports whether the active profile has saved its own copy
|
||||||
|
// of a settings group (identified by its marker key).
|
||||||
|
func (a *App) profileHasGroup(marker string) bool {
|
||||||
|
if a.settings == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
v, _ := a.settings.Get(a.ctx, a.profileScope()+marker)
|
||||||
|
return v == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// getManyScoped fetches base keys with the given prefix, returning a map
|
||||||
|
// keyed by the BASE key (so callers index with the plain constant).
|
||||||
|
func (a *App) getManyScoped(prefix string, keys ...string) (map[string]string, error) {
|
||||||
|
out := make(map[string]string, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
v, err := a.settings.Get(a.ctx, prefix+k)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) loadExternalServices() extsvc.ExternalServices {
|
func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||||||
var out extsvc.ExternalServices
|
var out extsvc.ExternalServices
|
||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
m, err := a.settings.GetMany(a.ctx,
|
// Read the active profile's scoped keys once it has saved them; otherwise
|
||||||
|
// fall back to the legacy global keys as the shared default.
|
||||||
|
prefix := ""
|
||||||
|
if a.profileHasGroup(markerExtsvc) {
|
||||||
|
prefix = a.profileScope()
|
||||||
|
}
|
||||||
|
m, err := a.getManyScoped(prefix,
|
||||||
keyExtQRZAPIKey, keyExtQRZForceCall, keyExtQRZAutoUpload, keyExtQRZUploadMode,
|
keyExtQRZAPIKey, keyExtQRZForceCall, keyExtQRZAutoUpload, keyExtQRZUploadMode,
|
||||||
keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign,
|
keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign,
|
||||||
keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode,
|
keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode,
|
||||||
keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWKeyPassword,
|
keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWForceCall, keyExtLoTWKeyPassword,
|
||||||
keyExtLoTWUploadFlag, keyExtLoTWWriteLog,
|
keyExtLoTWUploadFlag, keyExtLoTWWriteLog,
|
||||||
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
|
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
|
||||||
keyExtLoTWUsername, keyExtLoTWWebPassword)
|
keyExtLoTWUsername, keyExtLoTWWebPassword)
|
||||||
@@ -1551,9 +1738,10 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.LoTW = extsvc.ServiceConfig{
|
out.LoTW = extsvc.ServiceConfig{
|
||||||
TQSLPath: m[keyExtLoTWTQSLPath],
|
TQSLPath: m[keyExtLoTWTQSLPath],
|
||||||
StationLocation: m[keyExtLoTWStationLoc],
|
StationLocation: m[keyExtLoTWStationLoc],
|
||||||
KeyPassword: m[keyExtLoTWKeyPassword],
|
ForceStationCallsign: m[keyExtLoTWForceCall],
|
||||||
|
KeyPassword: m[keyExtLoTWKeyPassword],
|
||||||
UploadFlag: m[keyExtLoTWUploadFlag],
|
UploadFlag: m[keyExtLoTWUploadFlag],
|
||||||
WriteLog: m[keyExtLoTWWriteLog] == "1",
|
WriteLog: m[keyExtLoTWWriteLog] == "1",
|
||||||
Username: m[keyExtLoTWUsername],
|
Username: m[keyExtLoTWUsername],
|
||||||
@@ -1612,6 +1800,7 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
|||||||
if cfg.LoTW.WriteLog {
|
if cfg.LoTW.WriteLog {
|
||||||
ltWriteLog = "1"
|
ltWriteLog = "1"
|
||||||
}
|
}
|
||||||
|
scope := a.profileScope() // write under the active profile's prefix
|
||||||
for k, v := range map[string]string{
|
for k, v := range map[string]string{
|
||||||
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
|
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
|
||||||
keyExtQRZForceCall: strings.ToUpper(strings.TrimSpace(cfg.QRZ.ForceStationCallsign)),
|
keyExtQRZForceCall: strings.ToUpper(strings.TrimSpace(cfg.QRZ.ForceStationCallsign)),
|
||||||
@@ -1627,6 +1816,7 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
|||||||
|
|
||||||
keyExtLoTWTQSLPath: strings.TrimSpace(cfg.LoTW.TQSLPath),
|
keyExtLoTWTQSLPath: strings.TrimSpace(cfg.LoTW.TQSLPath),
|
||||||
keyExtLoTWStationLoc: strings.TrimSpace(cfg.LoTW.StationLocation),
|
keyExtLoTWStationLoc: strings.TrimSpace(cfg.LoTW.StationLocation),
|
||||||
|
keyExtLoTWForceCall: strings.ToUpper(strings.TrimSpace(cfg.LoTW.ForceStationCallsign)),
|
||||||
keyExtLoTWKeyPassword: cfg.LoTW.KeyPassword,
|
keyExtLoTWKeyPassword: cfg.LoTW.KeyPassword,
|
||||||
keyExtLoTWUploadFlag: ltFlag,
|
keyExtLoTWUploadFlag: ltFlag,
|
||||||
keyExtLoTWWriteLog: ltWriteLog,
|
keyExtLoTWWriteLog: ltWriteLog,
|
||||||
@@ -1635,10 +1825,15 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
|||||||
keyExtLoTWUsername: strings.TrimSpace(cfg.LoTW.Username),
|
keyExtLoTWUsername: strings.TrimSpace(cfg.LoTW.Username),
|
||||||
keyExtLoTWWebPassword: cfg.LoTW.Password,
|
keyExtLoTWWebPassword: cfg.LoTW.Password,
|
||||||
} {
|
} {
|
||||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Mark this profile as having its own External Services config (so future
|
||||||
|
// loads read the scoped keys instead of falling back to the global ones).
|
||||||
|
if err := a.settings.Set(a.ctx, scope+markerExtsvc, "1"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if a.extsvc != nil {
|
if a.extsvc != nil {
|
||||||
a.extsvc.SetConfig(a.loadExternalServices())
|
a.extsvc.SetConfig(a.loadExternalServices())
|
||||||
}
|
}
|
||||||
@@ -1714,7 +1909,7 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
|||||||
emit(fmt.Sprintf("Signing %d QSO(s) with TQSL…", len(ids)))
|
emit(fmt.Sprintf("Signing %d QSO(s) with TQSL…", len(ids)))
|
||||||
var recs []string
|
var recs []string
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if rec, ok := a.buildUploadADIF(id, ""); ok {
|
if rec, ok := a.buildUploadADIF(id, cfg.LoTW.ForceStationCallsign); ok {
|
||||||
recs = append(recs, rec)
|
recs = append(recs, rec)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1819,9 +2014,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
case extsvc.ServiceLoTW:
|
case extsvc.ServiceLoTW:
|
||||||
since := ""
|
since := ""
|
||||||
if a.settings != nil {
|
if a.settings != nil {
|
||||||
if m, e := a.settings.GetMany(ctx, keyExtLoTWLastDownload); e == nil {
|
// Scoped to the active profile — each identity tracks its own
|
||||||
since = m[keyExtLoTWLastDownload]
|
// LoTW account's last incremental-download date.
|
||||||
}
|
since, _ = a.settings.Get(ctx, a.profileScope()+keyExtLoTWLastDownload)
|
||||||
}
|
}
|
||||||
if since != "" {
|
if since != "" {
|
||||||
emit("Downloading LoTW confirmations received since " + since + "…")
|
emit("Downloading LoTW confirmations received since " + since + "…")
|
||||||
@@ -1905,9 +2100,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
} else {
|
} else {
|
||||||
emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total))
|
emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total))
|
||||||
}
|
}
|
||||||
// Remember today so the next pull is incremental.
|
// Remember today so the next pull is incremental (per active profile).
|
||||||
if a.settings != nil {
|
if a.settings != nil {
|
||||||
_ = a.settings.Set(ctx, keyExtLoTWLastDownload, time.Now().UTC().Format("2006-01-02"))
|
_ = a.settings.Set(ctx, a.profileScope()+keyExtLoTWLastDownload, time.Now().UTC().Format("2006-01-02"))
|
||||||
}
|
}
|
||||||
|
|
||||||
case extsvc.ServiceQRZ:
|
case extsvc.ServiceQRZ:
|
||||||
@@ -2083,6 +2278,123 @@ func (a *App) enrichContactedFromCty(q *qso.QSO) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enrichContactedFromCtyForce OVERWRITES the contacted-station country,
|
||||||
|
// continent, DXCC number and CQ/ITU zones from cty.dat. Unlike
|
||||||
|
// enrichContactedFromCty (which only fills blanks), this corrects values
|
||||||
|
// that are present-but-wrong — the case where contest software exports a
|
||||||
|
// bad COUNTRY/DXCC (e.g. RG2Y tagged "Asiatic Russia" instead of European).
|
||||||
|
// Returns true if cty.dat had a match.
|
||||||
|
func (a *App) enrichContactedFromCtyForce(q *qso.QSO) bool {
|
||||||
|
if a.dxcc == nil || q.Callsign == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
m, ok := a.dxcc.Lookup(q.Callsign)
|
||||||
|
if !ok || m.Entity == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
q.Country = m.Entity.Name
|
||||||
|
q.Continent = m.Continent
|
||||||
|
if n := dxcc.EntityDXCC(m.Entity.Name); n != 0 {
|
||||||
|
q.DXCC = &n
|
||||||
|
}
|
||||||
|
if m.CQZone != 0 {
|
||||||
|
v := m.CQZone
|
||||||
|
q.CQZ = &v
|
||||||
|
}
|
||||||
|
if m.ITUZone != 0 {
|
||||||
|
v := m.ITUZone
|
||||||
|
q.ITUZ = &v
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateQSOsFromCty recomputes country / continent / DXCC / CQ / ITU from
|
||||||
|
// cty.dat for the given QSO ids and saves them. Used by the grid's
|
||||||
|
// right-click "Update from cty.dat" on a multi-selection. Returns how many
|
||||||
|
// rows were actually changed.
|
||||||
|
func (a *App) UpdateQSOsFromCty(ids []int64) (int, error) {
|
||||||
|
if a.qso == nil {
|
||||||
|
return 0, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
changed := 0
|
||||||
|
for _, id := range ids {
|
||||||
|
q, err := a.qso.GetByID(a.ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
before := fmt.Sprint(q.Country, q.Continent, q.DXCC, q.CQZ, q.ITUZ)
|
||||||
|
if !a.enrichContactedFromCtyForce(&q) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fmt.Sprint(q.Country, q.Continent, q.DXCC, q.CQZ, q.ITUZ) == before {
|
||||||
|
continue // no change
|
||||||
|
}
|
||||||
|
if err := a.qso.Update(a.ctx, q); err == nil {
|
||||||
|
changed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateQSOsFromQRZ re-queries the callsign database (QRZ.com / HamQTH per
|
||||||
|
// the configured providers) for each QSO id and overwrites the geographic
|
||||||
|
// + entity fields (country, continent, DXCC, zones, grid, state, county)
|
||||||
|
// plus name/QTH when the provider returns them. Used by the grid's
|
||||||
|
// right-click "Update from QRZ.com". Returns how many rows were saved.
|
||||||
|
func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) {
|
||||||
|
if a.qso == nil || a.lookup == nil {
|
||||||
|
return 0, fmt.Errorf("not initialized")
|
||||||
|
}
|
||||||
|
changed := 0
|
||||||
|
for _, id := range ids {
|
||||||
|
q, err := a.qso.GetByID(a.ctx, id)
|
||||||
|
if err != nil || q.Callsign == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r, err := a.lookup.Lookup(a.ctx, q.Callsign)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.Country != "" {
|
||||||
|
q.Country = r.Country
|
||||||
|
}
|
||||||
|
if r.Continent != "" {
|
||||||
|
q.Continent = r.Continent
|
||||||
|
}
|
||||||
|
if r.DXCC != 0 {
|
||||||
|
n := r.DXCC
|
||||||
|
q.DXCC = &n
|
||||||
|
}
|
||||||
|
if r.CQZ != 0 {
|
||||||
|
v := r.CQZ
|
||||||
|
q.CQZ = &v
|
||||||
|
}
|
||||||
|
if r.ITUZ != 0 {
|
||||||
|
v := r.ITUZ
|
||||||
|
q.ITUZ = &v
|
||||||
|
}
|
||||||
|
if r.Grid != "" {
|
||||||
|
q.Grid = strings.ToUpper(r.Grid)
|
||||||
|
}
|
||||||
|
if r.State != "" {
|
||||||
|
q.State = r.State
|
||||||
|
}
|
||||||
|
if r.County != "" {
|
||||||
|
q.County = r.County
|
||||||
|
}
|
||||||
|
if r.Name != "" {
|
||||||
|
q.Name = r.Name
|
||||||
|
}
|
||||||
|
if r.QTH != "" {
|
||||||
|
q.QTH = r.QTH
|
||||||
|
}
|
||||||
|
if err := a.qso.Update(a.ctx, q); err == nil {
|
||||||
|
changed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListTQSLStationLocations returns the Station Locations defined in TQSL,
|
// ListTQSLStationLocations returns the Station Locations defined in TQSL,
|
||||||
// for the LoTW settings dropdown.
|
// for the LoTW settings dropdown.
|
||||||
func (a *App) ListTQSLStationLocations() ([]extsvc.StationLocation, error) {
|
func (a *App) ListTQSLStationLocations() ([]extsvc.StationLocation, error) {
|
||||||
@@ -2251,8 +2563,12 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
|||||||
// Pull the first record out of the payload. WSJT-X / JTDX / MSHV
|
// Pull the first record out of the payload. WSJT-X / JTDX / MSHV
|
||||||
// always send a single QSO per UDP packet (no header) but we tolerate
|
// always send a single QSO per UDP packet (no header) but we tolerate
|
||||||
// either form via adif.Parse.
|
// either form via adif.Parse.
|
||||||
|
// Pick the field decoder for this payload's encoding (UTF-8 as-is, else
|
||||||
|
// Windows-1252) so accented NAME/QTH from Log4OM/JTAlert aren't mangled.
|
||||||
|
// In UTF-8 mode the parser also repairs character-count field lengths.
|
||||||
|
decode := adif.ValueDecoderFor([]byte(adifText))
|
||||||
var record adif.Record
|
var record adif.Record
|
||||||
err := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
|
err := adif.ParseWithDecoder(strings.NewReader(adifText), decode, func(rec adif.Record) error {
|
||||||
if record == nil {
|
if record == nil {
|
||||||
record = rec
|
record = rec
|
||||||
}
|
}
|
||||||
@@ -2264,7 +2580,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
|||||||
if record == nil {
|
if record == nil {
|
||||||
// Some senders skip the <EOH> header; try treating the whole
|
// Some senders skip the <EOH> header; try treating the whole
|
||||||
// payload as a single record by prepending a fake header.
|
// payload as a single record by prepending a fake header.
|
||||||
err := adif.Parse(strings.NewReader("<EOH>"+adifText), func(rec adif.Record) error {
|
err := adif.ParseWithDecoder(strings.NewReader("<EOH>"+adifText), decode, func(rec adif.Record) error {
|
||||||
if record == nil {
|
if record == nil {
|
||||||
record = rec
|
record = rec
|
||||||
}
|
}
|
||||||
@@ -2315,6 +2631,13 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Active-profile station stamp ──
|
||||||
|
// Same as the manual AddQSO path: fill the operator's MY_* fields
|
||||||
|
// (station callsign, grid, country, zones, and the profile's default
|
||||||
|
// MY_RIG / MY_ANTENNA) from the active profile. Without this a UDP /
|
||||||
|
// WSJT-X auto-logged QSO carried none of the operator's own data.
|
||||||
|
a.applyStationDefaults(&q)
|
||||||
|
|
||||||
// ── DXCC# + QSL defaults ──
|
// ── DXCC# + QSL defaults ──
|
||||||
// applyDXCCNumber stamps the contacted-station DXCC# from the
|
// applyDXCCNumber stamps the contacted-station DXCC# from the
|
||||||
// entity-name table; QSL defaults are applied last so explicit ADIF
|
// entity-name table; QSL defaults are applied last so explicit ADIF
|
||||||
@@ -2905,6 +3228,15 @@ func (a *App) ActivateProfile(id int64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
a.refreshOperatorGrid()
|
a.refreshOperatorGrid()
|
||||||
|
// Per-profile config follows the active identity: reload the external-
|
||||||
|
// services manager so uploads now use this profile's accounts, and tell
|
||||||
|
// the frontend to refresh its settings panels.
|
||||||
|
if a.extsvc != nil {
|
||||||
|
a.extsvc.SetConfig(a.loadExternalServices())
|
||||||
|
}
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "profile:changed", id)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3060,6 +3392,206 @@ func boolStr(b bool) string {
|
|||||||
return "0"
|
return "0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- WinKeyer (CW keyer) bindings ---
|
||||||
|
|
||||||
|
// WKMacro is one CW message slot (F1…): a short label + the macro text, which
|
||||||
|
// may contain <VARIABLE> tokens resolved by the frontend before sending.
|
||||||
|
type WKMacro struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WinkeyerSettings is the Hardware → CW Keyer panel shape. It embeds the
|
||||||
|
// engine Config (keying parameters) plus the enable flag and message macros.
|
||||||
|
type WinkeyerSettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
winkeyer.Config
|
||||||
|
Engine string `json:"engine"` // keyer backend: "winkeyer" | "tci"
|
||||||
|
EscClearsCall bool `json:"esc_clears_call"` // ESC also resets the callsign
|
||||||
|
SendOnType bool `json:"send_on_type"` // key chars live as typed
|
||||||
|
Macros []WKMacro `json:"macros"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSerialPorts returns the available COM ports for the keyer dropdown.
|
||||||
|
func (a *App) ListSerialPorts() ([]string, error) {
|
||||||
|
return winkeyer.ListPorts()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWinkeyerSettings returns the persisted keyer config (with sane defaults).
|
||||||
|
func (a *App) GetWinkeyerSettings() (WinkeyerSettings, error) {
|
||||||
|
out := WinkeyerSettings{
|
||||||
|
Config: winkeyer.Config{
|
||||||
|
Baud: 1200, WPM: 25, Weight: 50, LeadInMs: 10, TailMs: 50,
|
||||||
|
Ratio: 50, Sidetone: 600, Mode: winkeyer.ModeIambicB, AutoSpace: true,
|
||||||
|
SerialEcho: true, // so the panel shows text as it's transmitted
|
||||||
|
},
|
||||||
|
Engine: "winkeyer",
|
||||||
|
EscClearsCall: true,
|
||||||
|
Macros: defaultWKMacros(),
|
||||||
|
}
|
||||||
|
if a.settings == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
m, err := a.settings.GetMany(a.ctx,
|
||||||
|
keyWKEnabled, keyWKPort, keyWKBaud, keyWKWPM, keyWKWeight, keyWKLeadIn,
|
||||||
|
keyWKTail, keyWKRatio, keyWKFarnsworth, keyWKSidetone, keyWKMode,
|
||||||
|
keyWKSwap, keyWKAutoSpace, keyWKUsePTT, keyWKSerialEcho, keyWKMacros,
|
||||||
|
keyWKEngine, keyWKEscClears, keyWKSendOnType)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if v := m[keyWKEngine]; v != "" {
|
||||||
|
out.Engine = v
|
||||||
|
}
|
||||||
|
if v := m[keyWKEscClears]; v != "" {
|
||||||
|
out.EscClearsCall = v == "1"
|
||||||
|
}
|
||||||
|
out.SendOnType = m[keyWKSendOnType] == "1"
|
||||||
|
out.Enabled = m[keyWKEnabled] == "1"
|
||||||
|
if v := m[keyWKPort]; v != "" {
|
||||||
|
out.Port = v
|
||||||
|
}
|
||||||
|
atoiInto(m[keyWKBaud], &out.Baud)
|
||||||
|
atoiInto(m[keyWKWPM], &out.WPM)
|
||||||
|
atoiInto(m[keyWKWeight], &out.Weight)
|
||||||
|
atoiInto(m[keyWKLeadIn], &out.LeadInMs)
|
||||||
|
atoiInto(m[keyWKTail], &out.TailMs)
|
||||||
|
atoiInto(m[keyWKRatio], &out.Ratio)
|
||||||
|
atoiInto(m[keyWKFarnsworth], &out.Farnsworth)
|
||||||
|
atoiInto(m[keyWKSidetone], &out.Sidetone)
|
||||||
|
if v := m[keyWKMode]; v != "" {
|
||||||
|
out.Mode = winkeyer.Mode(v)
|
||||||
|
}
|
||||||
|
out.Swap = m[keyWKSwap] == "1"
|
||||||
|
if v := m[keyWKAutoSpace]; v != "" {
|
||||||
|
out.AutoSpace = v == "1"
|
||||||
|
}
|
||||||
|
out.UsePTT = m[keyWKUsePTT] == "1"
|
||||||
|
out.SerialEcho = m[keyWKSerialEcho] == "1"
|
||||||
|
if v := m[keyWKMacros]; v != "" {
|
||||||
|
var mac []WKMacro
|
||||||
|
if json.Unmarshal([]byte(v), &mac) == nil && len(mac) > 0 {
|
||||||
|
out.Macros = mac
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveWinkeyerSettings persists the keyer config; if a link is open and the
|
||||||
|
// keying params changed, the caller can reconnect to apply them.
|
||||||
|
func (a *App) SaveWinkeyerSettings(s WinkeyerSettings) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
macJSON, _ := json.Marshal(s.Macros)
|
||||||
|
for k, v := range map[string]string{
|
||||||
|
keyWKEnabled: boolStr(s.Enabled),
|
||||||
|
keyWKPort: strings.TrimSpace(s.Port),
|
||||||
|
keyWKBaud: strconv.Itoa(s.Baud),
|
||||||
|
keyWKWPM: strconv.Itoa(s.WPM),
|
||||||
|
keyWKWeight: strconv.Itoa(s.Weight),
|
||||||
|
keyWKLeadIn: strconv.Itoa(s.LeadInMs),
|
||||||
|
keyWKTail: strconv.Itoa(s.TailMs),
|
||||||
|
keyWKRatio: strconv.Itoa(s.Ratio),
|
||||||
|
keyWKFarnsworth: strconv.Itoa(s.Farnsworth),
|
||||||
|
keyWKSidetone: strconv.Itoa(s.Sidetone),
|
||||||
|
keyWKMode: string(s.Mode),
|
||||||
|
keyWKSwap: boolStr(s.Swap),
|
||||||
|
keyWKAutoSpace: boolStr(s.AutoSpace),
|
||||||
|
keyWKUsePTT: boolStr(s.UsePTT),
|
||||||
|
keyWKSerialEcho: boolStr(s.SerialEcho),
|
||||||
|
keyWKMacros: string(macJSON),
|
||||||
|
keyWKEngine: strings.TrimSpace(s.Engine),
|
||||||
|
keyWKEscClears: boolStr(s.EscClearsCall),
|
||||||
|
keyWKSendOnType: boolStr(s.SendOnType),
|
||||||
|
} {
|
||||||
|
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WinkeyerConnect opens the serial link using the saved config.
|
||||||
|
func (a *App) WinkeyerConnect() error {
|
||||||
|
if a.winkeyer == nil {
|
||||||
|
return fmt.Errorf("winkeyer not initialized")
|
||||||
|
}
|
||||||
|
s, err := a.GetWinkeyerSettings()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.winkeyer.Connect(s.Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WinkeyerDisconnect closes the serial link.
|
||||||
|
func (a *App) WinkeyerDisconnect() error {
|
||||||
|
if a.winkeyer != nil {
|
||||||
|
a.winkeyer.Disconnect()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WinkeyerSend keys the (already variable-resolved) text as Morse.
|
||||||
|
func (a *App) WinkeyerSend(text string) error {
|
||||||
|
if a.winkeyer == nil {
|
||||||
|
return fmt.Errorf("winkeyer not initialized")
|
||||||
|
}
|
||||||
|
return a.winkeyer.Send(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WinkeyerStop aborts the current message immediately.
|
||||||
|
func (a *App) WinkeyerStop() error {
|
||||||
|
if a.winkeyer == nil {
|
||||||
|
return fmt.Errorf("winkeyer not initialized")
|
||||||
|
}
|
||||||
|
return a.winkeyer.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WinkeyerBackspace removes the last not-yet-keyed character (send-on-type).
|
||||||
|
func (a *App) WinkeyerBackspace() error {
|
||||||
|
if a.winkeyer == nil {
|
||||||
|
return fmt.Errorf("winkeyer not initialized")
|
||||||
|
}
|
||||||
|
return a.winkeyer.Backspace()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WinkeyerSetSpeed changes the keying speed (WPM) live.
|
||||||
|
func (a *App) WinkeyerSetSpeed(wpm int) error {
|
||||||
|
if a.winkeyer == nil {
|
||||||
|
return fmt.Errorf("winkeyer not initialized")
|
||||||
|
}
|
||||||
|
return a.winkeyer.SetSpeed(wpm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWinkeyerStatus returns the current link status (used on mount).
|
||||||
|
func (a *App) GetWinkeyerStatus() winkeyer.Status {
|
||||||
|
if a.winkeyer == nil {
|
||||||
|
return winkeyer.Status{}
|
||||||
|
}
|
||||||
|
return a.winkeyer.Snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultWKMacros mirrors the classic F-key set (CQ / answer / reports / 73).
|
||||||
|
func defaultWKMacros() []WKMacro {
|
||||||
|
return []WKMacro{
|
||||||
|
{Label: "CQ", Text: "CQ CQ DE <MY_CALL> <MY_CALL> K"},
|
||||||
|
{Label: "His call", Text: "<CALL> "},
|
||||||
|
{Label: "Report", Text: "<CALL> UR <STX> <STX> = "},
|
||||||
|
{Label: "Answer", Text: "<CALL> DE <MY_CALL> TU UR <RST_R> = "},
|
||||||
|
{Label: "Name/QTH", Text: "NAME <MY_NAME> QTH <MY_QTH> = "},
|
||||||
|
{Label: "73", Text: "<CALL> TU 73 DE <MY_CALL> "},
|
||||||
|
{Label: "QRL?", Text: "QRL? "},
|
||||||
|
{Label: "AGN", Text: "AGN "},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func atoiInto(s string, dst *int) {
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
|
||||||
|
*dst = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- DX Cluster bindings (multi-server) ---
|
// --- DX Cluster bindings (multi-server) ---
|
||||||
|
|
||||||
// resolveClusterLogin returns the login callsign for a server: explicit
|
// resolveClusterLogin returns the login callsign for a server: explicit
|
||||||
|
|||||||
+752
-273
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Minus, Plus, Crosshair, X } from 'lucide-react';
|
import { Minus, Plus, Crosshair, X } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { spotStatusKey, inferSpotMode } from '@/lib/spot';
|
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
||||||
|
|
||||||
// BandMap — vertical spectrum panel inspired by Log4OM.
|
// BandMap — vertical spectrum panel inspired by Log4OM.
|
||||||
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
|
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
|
||||||
@@ -136,12 +136,12 @@ const PILL_GAP = 32; // px between scale border and first pill (room for leade
|
|||||||
const LABEL_W = 200;
|
const LABEL_W = 200;
|
||||||
const TOP_PAD = 14; // px of breathing room above/below the band edges so
|
const TOP_PAD = 14; // px of breathing room above/below the band edges so
|
||||||
const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
||||||
// Max FT-mode (FT8/FT4/…) pills drawn at once. These pile up on a single
|
// Max DATA-class (digital: FT8/FT4/JS8/RTTY/PSK/…) pills drawn at once.
|
||||||
// watering-hole frequency and otherwise spawn hundreds of spots that fan out
|
// These pile up on the watering-hole frequencies and otherwise spawn
|
||||||
// and cover the whole map. ONLY the FT family is capped — CW, SSB and other
|
// hundreds of spots that fan out and cover the whole map. ONLY digital is
|
||||||
// digital spots are always shown in full. When more than this FT spots are in
|
// capped — CW and SSB are always shown in full. When more than this digital
|
||||||
// band we keep the most useful (new entities first, worked last; ties broken
|
// spots are in band we keep the most useful (new entities first, worked
|
||||||
// by closeness to the rig freq).
|
// last; ties broken by closeness to the rig freq).
|
||||||
const MAX_VISIBLE_SPOTS = 30;
|
const MAX_VISIBLE_SPOTS = 30;
|
||||||
|
|
||||||
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose }: Props) {
|
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose }: Props) {
|
||||||
@@ -189,14 +189,16 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
inBand.push(s);
|
inBand.push(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the FT family (FT8/FT4/…) is capped — it's what floods a single
|
// Only DATA-class spots (every digital mode — FT8/FT4/JS8/RTTY/PSK/…)
|
||||||
// watering-hole frequency. Everything else (CW, SSB, RTTY, PSK, …) is
|
// are capped — they're what floods the watering-hole frequencies. We key
|
||||||
// always shown in full.
|
// off the mode CATEGORY (not a literal "FT8" string) because many FT8
|
||||||
const isFlood = (s: Spot) => /^FT/.test(inferSpotMode(s.comment ?? '', s.freq_hz));
|
// spots carry no mode word and the band-plan fallback labels them the
|
||||||
|
// generic "DATA" rather than "FT8". CW and SSB are always shown in full.
|
||||||
|
const isFlood = (s: Spot) => spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz)) === 'DATA';
|
||||||
const ftSpots = inBand.filter(isFlood);
|
const ftSpots = inBand.filter(isFlood);
|
||||||
const otherSpots = inBand.filter((s) => !isFlood(s));
|
const otherSpots = inBand.filter((s) => !isFlood(s));
|
||||||
|
|
||||||
// Rank an FT spot by usefulness (new entity → unworked → worked); ties
|
// Rank a DATA spot by usefulness (new entity → unworked → worked); ties
|
||||||
// break by proximity to the rig frequency. Keep the top MAX_VISIBLE_SPOTS.
|
// break by proximity to the rig frequency. Keep the top MAX_VISIBLE_SPOTS.
|
||||||
const rank = (s: Spot) => {
|
const rank = (s: Spot) => {
|
||||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||||
@@ -497,7 +499,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
||||||
scroll · ctrl+wheel = zoom · ◎ = jump to rig
|
scroll · ctrl+wheel = zoom · ◎ = jump to rig
|
||||||
{hidden > 0 && <span className="text-amber-600"> · {hidden} FT spot{hidden > 1 ? 's' : ''} hidden (top {MAX_VISIBLE_SPOTS} shown)</span>}
|
{hidden > 0 && <span className="text-amber-600"> · {hidden} data spot{hidden > 1 ? 's' : ''} hidden (top {MAX_VISIBLE_SPOTS} shown)</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,6 +46,17 @@ const STATUS_CLASSES: Record<string, string> = {
|
|||||||
dxcc_w: 'bg-indigo-300 hover:bg-indigo-200',
|
dxcc_w: 'bg-indigo-300 hover:bg-indigo-200',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Legend entries, in the same colour order as the cells. swatch = the
|
||||||
|
// background class (or a special ring marker for the current-entry cell).
|
||||||
|
const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [
|
||||||
|
{ swatch: 'bg-emerald-700', label: 'Call confirmed' },
|
||||||
|
{ swatch: 'bg-emerald-300', label: 'Call worked' },
|
||||||
|
{ swatch: 'bg-indigo-800', label: 'Entity confirmed' },
|
||||||
|
{ swatch: 'bg-indigo-300', label: 'Entity worked' },
|
||||||
|
{ swatch: 'bg-stone-200', label: 'Not worked' },
|
||||||
|
{ swatch: 'bg-stone-200', ring: true, label: 'Current entry' },
|
||||||
|
];
|
||||||
|
|
||||||
function cellTitle(band: string, cls: string, status: string, current: boolean): string {
|
function cellTitle(band: string, cls: string, status: string, current: boolean): string {
|
||||||
const desc =
|
const desc =
|
||||||
status === 'call_c' ? 'This callsign confirmed' :
|
status === 'call_c' ? 'This callsign confirmed' :
|
||||||
@@ -75,8 +86,8 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-4 px-3 py-2 bg-card border-b border-border flex-wrap shrink-0',
|
'flex items-center gap-4 px-3 py-2 flex-wrap shrink-0',
|
||||||
newOne && 'bg-gradient-to-br from-amber-100 to-amber-200 border-amber-300',
|
newOne && 'bg-gradient-to-br from-amber-100 to-amber-200 rounded-lg',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-[220px]">
|
<div className="flex items-center gap-2 min-w-[220px]">
|
||||||
@@ -120,6 +131,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
<table className="border-separate" style={{ borderSpacing: 3 }}>
|
<table className="border-separate" style={{ borderSpacing: 3 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -170,6 +182,23 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{/* Colour legend — sits in the spare room under the matrix. */}
|
||||||
|
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap pl-[26px]">
|
||||||
|
{LEGEND.map((l) => (
|
||||||
|
<span key={l.label} className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-block size-3 rounded shrink-0',
|
||||||
|
l.swatch,
|
||||||
|
l.ring && 'ring-2 ring-amber-500 ring-inset',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{l.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import {
|
|||||||
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
|
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Columns3 } from 'lucide-react';
|
import { Columns3, FilterX } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||||
|
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
@@ -304,19 +305,18 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
|
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
function onGridReady(e: GridReadyEvent) {
|
||||||
try {
|
const local = loadLocal(COL_STATE_KEY);
|
||||||
const raw = localStorage.getItem(COL_STATE_KEY);
|
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
if (raw) {
|
loadRemote(COL_STATE_KEY).then((remote) => {
|
||||||
const state = JSON.parse(raw) as ColumnState[];
|
if (remote && !local) {
|
||||||
if (Array.isArray(state)) e.api.applyColumnState({ state, applyOrder: true });
|
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||||
|
seedLocal(COL_STATE_KEY, remote);
|
||||||
}
|
}
|
||||||
} catch {}
|
});
|
||||||
}
|
}
|
||||||
const saveColumnState = useCallback(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
try {
|
const state = gridRef.current?.api?.getColumnState();
|
||||||
const state = gridRef.current?.api?.getColumnState();
|
if (state) saveState(COL_STATE_KEY, state);
|
||||||
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
|
|
||||||
} catch {}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function handleRowClicked(e: RowClickedEvent<ClusterSpot>) {
|
function handleRowClicked(e: RowClickedEvent<ClusterSpot>) {
|
||||||
@@ -360,6 +360,10 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||||
|
title="Clear all column filters">
|
||||||
|
<FilterX className="size-3.5" /> Clear filters
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
||||||
<Columns3 className="size-3.5" /> Columns
|
<Columns3 className="size-3.5" /> Columns
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -53,9 +53,15 @@ interface Props {
|
|||||||
mode: string;
|
mode: string;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
onOpenImage?: () => void;
|
onOpenImage?: () => void;
|
||||||
|
// Optional controlled active tab (so the app can switch it via keyboard).
|
||||||
|
tab?: TabName;
|
||||||
|
onTab?: (t: TabName) => void;
|
||||||
|
// When the WinKeyer is active, F1-F12 fire macros, so the tab shortcut is
|
||||||
|
// shown as Ctrl+F1…F5 instead of F1…F5.
|
||||||
|
keyerActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
||||||
|
|
||||||
const PROP_MODES = ['NONE','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
const PROP_MODES = ['NONE','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
||||||
|
|
||||||
@@ -75,8 +81,9 @@ function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode }: Props) {
|
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, tab, onTab, keyerActive }: Props) {
|
||||||
const [open, setOpen] = useState<TabName>('stats');
|
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||||
|
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||||
|
|
||||||
// Bearing/distance from operator's home grid to the remote station.
|
// Bearing/distance from operator's home grid to the remote station.
|
||||||
// Recomputed only when either grid actually changes.
|
// Recomputed only when either grid actually changes.
|
||||||
@@ -87,7 +94,8 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
|||||||
const fmtDeg = (n: number) => `${Math.round(n)}°`;
|
const fmtDeg = (n: number) => `${Math.round(n)}°`;
|
||||||
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
|
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
|
||||||
|
|
||||||
function toggle(t: TabName) { setOpen(t); }
|
function toggle(t: TabName) { onTab ? onTab(t) : setInternalOpen(t); }
|
||||||
|
const fk = keyerActive ? 'Ctrl+F' : 'F';
|
||||||
|
|
||||||
const satelliteMode = !!details.sat_name || !!details.sat_mode || details.prop_mode === 'SAT';
|
const satelliteMode = !!details.sat_name || !!details.sat_mode || details.prop_mode === 'SAT';
|
||||||
function setSatellite(on: boolean) {
|
function setSatellite(on: boolean) {
|
||||||
@@ -102,15 +110,15 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabs: { key: TabName; label: string }[] = [
|
const tabs: { key: TabName; label: string }[] = [
|
||||||
{ key: 'stats', label: 'Stats (F1)' },
|
{ key: 'stats', label: `Stats (${fk}1)` },
|
||||||
{ key: 'info', label: 'Info (F2)' },
|
{ key: 'info', label: `Info (${fk}2)` },
|
||||||
{ key: 'awards', label: 'Awards (F3)' },
|
{ key: 'awards', label: `Awards (${fk}3)` },
|
||||||
{ key: 'my', label: 'My (F4)' },
|
{ key: 'my', label: `My (${fk}4)` },
|
||||||
{ key: 'extended', label: 'Extended (F5)' },
|
{ key: 'extended', label: `Extended (${fk}5)` },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="border border-border rounded-lg bg-card flex flex-col flex-1 min-h-0 overflow-hidden">
|
<section className="bg-card shadow-sm border border-border rounded-lg 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">
|
<nav className="flex items-center gap-1 px-3 bg-muted/40 border-b border-border shrink-0">
|
||||||
{tabs.map((t) => (
|
{tabs.map((t) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Globe2, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
menu: QSOMenuState;
|
||||||
|
onClose: () => void;
|
||||||
|
onUpdateFromCty: (ids: number[]) => void;
|
||||||
|
onUpdateFromQRZ: (ids: number[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
||||||
|
// menu is an Enterprise feature, so this is a plain floating menu driven by
|
||||||
|
// onCellContextMenu. Closes on any outside click, scroll or Escape.
|
||||||
|
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ }: Props) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menu) return;
|
||||||
|
const close = () => onClose();
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('mousedown', close);
|
||||||
|
window.addEventListener('scroll', close, true);
|
||||||
|
window.addEventListener('resize', close);
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousedown', close);
|
||||||
|
window.removeEventListener('scroll', close, true);
|
||||||
|
window.removeEventListener('resize', close);
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [menu, onClose]);
|
||||||
|
|
||||||
|
if (!menu) return null;
|
||||||
|
const n = menu.ids.length;
|
||||||
|
// Keep the menu on-screen near the cursor.
|
||||||
|
const x = Math.min(menu.x, window.innerWidth - 248);
|
||||||
|
const y = Math.min(menu.y, window.innerHeight - 110);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed z-[200] min-w-[240px] rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
|
||||||
|
style={{ left: x, top: y }}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
{n} QSO{n > 1 ? 's' : ''} selected
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onUpdateFromCty(menu.ids); onClose(); }}
|
||||||
|
>
|
||||||
|
<Globe2 className="size-4 text-primary" />
|
||||||
|
<span>Fix country & zones from cty.dat</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onUpdateFromQRZ(menu.ids); onClose(); }}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4 text-sky-600" />
|
||||||
|
<span>Update from QRZ.com</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,11 +13,24 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import {
|
import {
|
||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { flagURL } from '@/lib/flags';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
|
|
||||||
type QSO = QSOForm;
|
type QSO = QSOForm;
|
||||||
|
|
||||||
|
// Quick prefix from a callsign (drops portable suffixes, keeps a slashed
|
||||||
|
// prefix). Read-only display, mirrors Log4OM's PFX box.
|
||||||
|
function pfxOf(call: string): string {
|
||||||
|
const c = (call || '').trim().toUpperCase();
|
||||||
|
if (!c) return '';
|
||||||
|
const base = c.includes('/') ? c.split('/')[0] : c;
|
||||||
|
let lastDigit = -1;
|
||||||
|
for (let i = 0; i < base.length; i++) if (base[i] >= '0' && base[i] <= '9') lastDigit = i;
|
||||||
|
return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
|
||||||
|
}
|
||||||
|
|
||||||
const BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm'];
|
const BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm'];
|
||||||
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
|
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
|
||||||
const QSL_STATUSES = [
|
const QSL_STATUSES = [
|
||||||
@@ -29,6 +42,35 @@ const QSL_STATUSES = [
|
|||||||
];
|
];
|
||||||
const PROP_MODES = ['_','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
const PROP_MODES = ['_','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
||||||
|
|
||||||
|
// Confirmation channels — each maps to its QSO sent/received status, dates and
|
||||||
|
// (paper-only) via fields. Drives the "Manage Confirmation" editor and the
|
||||||
|
// live status grid (Log4OM style).
|
||||||
|
type ConfDef = {
|
||||||
|
key: string; label: string;
|
||||||
|
sent?: keyof QSOForm; rcvd?: keyof QSOForm;
|
||||||
|
sentDate?: keyof QSOForm; rcvdDate?: keyof QSOForm;
|
||||||
|
via?: keyof QSOForm;
|
||||||
|
};
|
||||||
|
const CONFIRMATIONS: ConfDef[] = [
|
||||||
|
{ key: 'QSL', label: 'QSL (paper)', sent: 'qsl_sent', rcvd: 'qsl_rcvd', sentDate: 'qsl_sent_date', rcvdDate: 'qsl_rcvd_date', via: 'qsl_via' },
|
||||||
|
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
|
||||||
|
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
|
||||||
|
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
|
||||||
|
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any },
|
||||||
|
{ key: 'HRDLOG', label: 'HRDLog', sent: 'hrdlog_qso_upload_status' as any, sentDate: 'hrdlog_qso_upload_date' as any },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Colour-coded status cell for the confirmation grid.
|
||||||
|
function StatusCell({ value }: { value?: string }) {
|
||||||
|
const v = (value || '').toUpperCase();
|
||||||
|
const label = v === 'Y' ? 'Yes' : v === 'R' ? 'Requested' : v === 'I' ? 'Ignore' : v === 'M' ? 'Modified' : 'No';
|
||||||
|
const cls = v === 'Y' ? 'bg-emerald-600 text-white'
|
||||||
|
: v === 'R' ? 'bg-orange-400 text-white'
|
||||||
|
: v === 'I' ? 'bg-stone-400 text-white'
|
||||||
|
: 'bg-amber-400 text-amber-950';
|
||||||
|
return <span className={cn('block text-center text-[11px] font-semibold rounded px-1 py-0.5', cls)}>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
qso: QSO;
|
qso: QSO;
|
||||||
onSave: (q: QSO) => void;
|
onSave: (q: QSO) => void;
|
||||||
@@ -98,10 +140,20 @@ function QslSelect({ value, onChange }: { value?: string; onChange: (v: string)
|
|||||||
|
|
||||||
export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
||||||
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
|
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
|
||||||
const [freqMhz, setFreqMhz] = useState(draft.freq_hz ? String(draft.freq_hz / 1_000_000) : '');
|
// Frequencies are edited as kHz + Hz (Log4OM style) and recombined on save.
|
||||||
const [freqRxMhz, setFreqRxMhz] = useState(draft.freq_rx_hz ? String(draft.freq_rx_hz / 1_000_000) : '');
|
const splitHz = (hz?: number) => hz
|
||||||
|
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
||||||
|
: { khz: '', hz: '' };
|
||||||
|
const f0 = splitHz(draft.freq_hz);
|
||||||
|
const fr0 = splitHz(draft.freq_rx_hz);
|
||||||
|
const [freqKHz, setFreqKHz] = useState(f0.khz);
|
||||||
|
const [freqHz, setFreqHz] = useState(f0.hz);
|
||||||
|
const [freqRxKHz, setFreqRxKHz] = useState(fr0.khz);
|
||||||
|
const [freqRxHz, setFreqRxHz] = useState(fr0.hz);
|
||||||
const [dateOn, setDateOn] = useState(toLocalISO(draft.qso_date));
|
const [dateOn, setDateOn] = useState(toLocalISO(draft.qso_date));
|
||||||
const [dateOff, setDateOff] = useState(toLocalISO(draft.qso_date_off));
|
const [dateOff, setDateOff] = useState(toLocalISO(draft.qso_date_off));
|
||||||
|
const [endEnabled, setEndEnabled] = useState(!!draft.qso_date_off);
|
||||||
|
const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel
|
||||||
const [extrasText, setExtrasText] = useState(stringifyExtras(draft.extras));
|
const [extrasText, setExtrasText] = useState(stringifyExtras(draft.extras));
|
||||||
const [localErr, setLocalErr] = useState('');
|
const [localErr, setLocalErr] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -166,9 +218,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
|||||||
my_sota_ref: (draft.my_sota_ref ?? '').trim().toUpperCase(),
|
my_sota_ref: (draft.my_sota_ref ?? '').trim().toUpperCase(),
|
||||||
my_pota_ref: (draft.my_pota_ref ?? '').trim().toUpperCase(),
|
my_pota_ref: (draft.my_pota_ref ?? '').trim().toUpperCase(),
|
||||||
qso_date: parseLocalISO(dateOn) ?? new Date().toISOString(),
|
qso_date: parseLocalISO(dateOn) ?? new Date().toISOString(),
|
||||||
qso_date_off: parseLocalISO(dateOff) ?? undefined,
|
qso_date_off: endEnabled ? (parseLocalISO(dateOff) ?? undefined) : undefined,
|
||||||
freq_hz: freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined,
|
freq_hz: freqKHz.trim() ? parseInt(freqKHz, 10) * 1000 + (parseInt(freqHz, 10) || 0) : undefined,
|
||||||
freq_rx_hz: freqRxMhz.trim() ? Math.round(parseFloat(freqRxMhz) * 1_000_000) : undefined,
|
freq_rx_hz: freqRxKHz.trim() ? parseInt(freqRxKHz, 10) * 1000 + (parseInt(freqRxHz, 10) || 0) : undefined,
|
||||||
dxcc: intOrUndef(draft.dxcc),
|
dxcc: intOrUndef(draft.dxcc),
|
||||||
cqz: intOrUndef(draft.cqz),
|
cqz: intOrUndef(draft.cqz),
|
||||||
ituz: intOrUndef(draft.ituz),
|
ituz: intOrUndef(draft.ituz),
|
||||||
@@ -210,14 +262,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
|||||||
<DialogDescription className="sr-only">Edit fields for QSO #{draft.id}</DialogDescription>
|
<DialogDescription className="sr-only">Edit fields for QSO #{draft.id}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Tabs defaultValue="basic" className="flex flex-col overflow-hidden min-h-0">
|
<Tabs defaultValue="qsoinfo" className="flex flex-col overflow-hidden min-h-0">
|
||||||
<TabsList className="px-3 overflow-x-auto">
|
<TabsList className="px-3 overflow-x-auto">
|
||||||
<TabsTrigger value="basic">Basic</TabsTrigger>
|
<TabsTrigger value="qsoinfo">QSO Info</TabsTrigger>
|
||||||
<TabsTrigger value="contacted">Contacted</TabsTrigger>
|
<TabsTrigger value="contact">Contact's details</TabsTrigger>
|
||||||
<TabsTrigger value="qsl">QSL</TabsTrigger>
|
<TabsTrigger value="qsl">QSL Info</TabsTrigger>
|
||||||
<TabsTrigger value="contest">Contest</TabsTrigger>
|
<TabsTrigger value="contest">Contest</TabsTrigger>
|
||||||
<TabsTrigger value="sat">Sat / Prop</TabsTrigger>
|
<TabsTrigger value="sat">Sat / Prop</TabsTrigger>
|
||||||
<TabsTrigger value="mystation">My station</TabsTrigger>
|
<TabsTrigger value="mystation">My Station</TabsTrigger>
|
||||||
<TabsTrigger value="notes">Notes</TabsTrigger>
|
<TabsTrigger value="notes">Notes</TabsTrigger>
|
||||||
<TabsTrigger value="extras">
|
<TabsTrigger value="extras">
|
||||||
Extras
|
Extras
|
||||||
@@ -234,106 +286,177 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-y-auto px-5 py-4 flex-1">
|
<div className="overflow-y-auto px-5 py-4 flex-1">
|
||||||
<TabsContent value="basic" className="mt-0">
|
<TabsContent value="qsoinfo" className="mt-0">
|
||||||
<div className="grid grid-cols-6 gap-3">
|
{/* Top: Callsign + RST + Fetch */}
|
||||||
<F label="Callsign" span={6}>
|
<div className="flex items-end gap-2 mb-3">
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-11 flex-1"
|
<Label>Callsign</Label>
|
||||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-10"
|
||||||
<Button type="button" variant="outline" className="h-11" onClick={fetchLookup} disabled={looking}
|
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||||
title="Look up this callsign (QRZ.com / HamQTH) and refresh name, QTH, country, grid, zones…">
|
</div>
|
||||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />}
|
<div className="flex flex-col w-20"><Label>S</Label>
|
||||||
Fetch
|
<Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} className="font-mono" /></div>
|
||||||
</Button>
|
<div className="flex flex-col w-20"><Label>R</Label>
|
||||||
|
<Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} className="font-mono" /></div>
|
||||||
|
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
||||||
|
title="Look up this callsign (QRZ.com / HamQTH) and refresh name, country, grid, zones…">
|
||||||
|
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} Fetch
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
||||||
|
{/* ── Left column ── */}
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<div><Label>Name</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="w-20 shrink-0">Band</Label>
|
||||||
|
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
|
||||||
|
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</F>
|
<div className="flex items-center gap-2">
|
||||||
<F label="Start (UTC)" span={3}><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></F>
|
<Label className="w-20 shrink-0">RX Band</Label>
|
||||||
<F label="End (UTC)" span={3}><Input type="datetime-local" value={dateOff} onChange={(e) => setDateOff(e.target.value)} /></F>
|
<Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}>
|
||||||
<F label="Band">
|
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||||
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
|
<SelectContent><SelectItem value="_">—</SelectItem>{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
</Select>
|
||||||
<SelectContent>{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
</div>
|
||||||
</Select>
|
<div className="flex items-center gap-2">
|
||||||
</F>
|
<Label className="w-20 shrink-0">Mode</Label>
|
||||||
<F label="Mode">
|
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
|
||||||
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
|
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectContent>{MODES.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
||||||
<SelectContent>{MODES.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
</Select>
|
||||||
</Select>
|
</div>
|
||||||
</F>
|
<div className="flex items-center gap-2">
|
||||||
<F label="Submode"><Input value={draft.submode ?? ''} onChange={(e) => set('submode', e.target.value)} /></F>
|
<Label className="w-20 shrink-0">Country</Label>
|
||||||
<F label="Band RX">
|
<Input value={draft.country ?? ''} onChange={(e) => set('country', e.target.value)} className="flex-1" />
|
||||||
<Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}>
|
</div>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<div className="flex items-center gap-2">
|
||||||
<SelectContent>
|
<Label className="w-20 shrink-0">ITU</Label>
|
||||||
<SelectItem value="_">—</SelectItem>
|
<Input type="number" value={draft.ituz ?? ''} onChange={(e) => set('ituz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
|
||||||
{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}
|
<Label>CQ</Label>
|
||||||
</SelectContent>
|
<Input type="number" value={draft.cqz ?? ''} onChange={(e) => set('cqz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
|
||||||
</Select>
|
<Input type="number" value={draft.dxcc ?? ''} onChange={(e) => set('dxcc', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center bg-muted/40" title="DXCC entity #" />
|
||||||
</F>
|
{flagURL(draft.dxcc) && <img src={flagURL(draft.dxcc)} alt="" className="h-4 rounded-[2px] border border-border/50" referrerPolicy="no-referrer" />}
|
||||||
<F label="Freq (MHz)"><Input value={freqMhz} onChange={(e) => setFreqMhz(e.target.value)} /></F>
|
</div>
|
||||||
<F label="Freq RX (MHz)"><Input value={freqRxMhz} onChange={(e) => setFreqRxMhz(e.target.value)} /></F>
|
<div className="flex items-center gap-2">
|
||||||
<F label="RST sent"><Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} /></F>
|
<Label className="w-20 shrink-0">Freq</Label>
|
||||||
<F label="RST rcvd"><Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} /></F>
|
<Input value={freqKHz} onChange={(e) => setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
||||||
<F label="TX power (W)"><Input type="number" value={draft.tx_pwr ?? ''} onChange={(e) => set('tx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
<Input value={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||||
<F label="Name" span={3}><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></F>
|
</div>
|
||||||
<F label="Country" span={2}><Input value={draft.country ?? ''} onChange={(e) => set('country', e.target.value)} /></F>
|
<div className="flex items-center gap-2">
|
||||||
<F label="Grid"><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} /></F>
|
<Label className="w-20 shrink-0">RX Freq</Label>
|
||||||
|
<Input value={freqRxKHz} onChange={(e) => setFreqRxKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
||||||
|
<Input value={freqRxHz} onChange={(e) => setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Right column ── */}
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<div><Label>QSO Start (UTC)</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div>
|
||||||
|
<div>
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Checkbox checked={endEnabled} onCheckedChange={(c) => setEndEnabled(!!c)} /> QSO End (UTC)
|
||||||
|
</Label>
|
||||||
|
<Input type="datetime-local" value={dateOff} disabled={!endEnabled} onChange={(e) => setDateOff(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex flex-col flex-1"><Label>Grid</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
|
||||||
|
<div className="flex flex-col w-24"><Label>PFX</Label><Input readOnly value={pfxOf(draft.callsign ?? '')} className="font-mono bg-muted/40" /></div>
|
||||||
|
</div>
|
||||||
|
<div><Label>Comment</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></div>
|
||||||
|
<div><Label>Note</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></div>
|
||||||
|
<div><Label>Contest</Label><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex flex-col flex-1"><Label>Sent</Label><Input value={draft.stx_string ?? (draft.stx != null ? String(draft.stx) : '')} onChange={(e) => set('stx_string', e.target.value)} /></div>
|
||||||
|
<div className="flex flex-col flex-1"><Label>Received</Label><Input value={draft.srx_string ?? (draft.srx != null ? String(draft.srx) : '')} onChange={(e) => set('srx_string', e.target.value)} /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="contacted" className="mt-0">
|
<TabsContent value="contact" className="mt-0">
|
||||||
<div className="grid grid-cols-6 gap-3">
|
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
||||||
<F label="Name" span={3}><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></F>
|
{/* Left column */}
|
||||||
<F label="QTH" span={3}><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></F>
|
<div className="flex flex-col gap-2.5">
|
||||||
<F label="Address" span={6}><Input value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></F>
|
<div><Label>County</Label><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></div>
|
||||||
<F label="Email" span={3}><Input value={(draft as any).email ?? ''} onChange={(e) => (set as any)('email', e.target.value)} /></F>
|
<div><Label>State</Label><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></div>
|
||||||
<F label="Web" span={3}><Input value={draft.web ?? ''} onChange={(e) => set('web', e.target.value)} /></F>
|
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></div>
|
||||||
<F label="Country" span={2}><Input value={draft.country ?? ''} onChange={(e) => set('country', e.target.value)} /></F>
|
<div><Label>Address</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
|
||||||
<F label="DXCC"><Input type="number" value={draft.dxcc ?? ''} onChange={(e) => set('dxcc', intOrUndef(e.target.value) as any)} /></F>
|
</div>
|
||||||
<F label="Cont"><Input value={draft.cont ?? ''} onChange={(e) => set('cont', e.target.value)} /></F>
|
{/* Right column */}
|
||||||
<F label="CQ zone"><Input type="number" value={draft.cqz ?? ''} onChange={(e) => set('cqz', intOrUndef(e.target.value) as any)} /></F>
|
<div className="flex flex-col gap-2.5">
|
||||||
<F label="ITU zone"><Input type="number" value={draft.ituz ?? ''} onChange={(e) => set('ituz', intOrUndef(e.target.value) as any)} /></F>
|
<div><Label>E-mail address</Label><Input value={(draft as any).email ?? ''} onChange={(e) => (set as any)('email', e.target.value)} /></div>
|
||||||
<F label="State"><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></F>
|
<div className="flex items-end gap-2">
|
||||||
<F label="County"><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></F>
|
<div className="flex flex-col flex-1"><Label>Lat</Label><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
||||||
<F label="Grid"><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} /></F>
|
<div className="flex flex-col flex-1"><Label>Lon</Label><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
||||||
<F label="Grid ext"><Input value={draft.gridsquare_ext ?? ''} onChange={(e) => set('gridsquare_ext', e.target.value)} /></F>
|
</div>
|
||||||
<F label="VUCC grids" span={2}><Input value={draft.vucc_grids ?? ''} onChange={(e) => set('vucc_grids', e.target.value)} /></F>
|
<div><Label>QSL Msg</Label><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></div>
|
||||||
<F label="IOTA"><Input value={draft.iota ?? ''} onChange={(e) => set('iota', e.target.value)} /></F>
|
<div><Label>QSL Via</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></div>
|
||||||
<F label="SOTA ref"><Input value={draft.sota_ref ?? ''} onChange={(e) => set('sota_ref', e.target.value)} /></F>
|
</div>
|
||||||
<F label="POTA ref"><Input value={draft.pota_ref ?? ''} onChange={(e) => set('pota_ref', e.target.value)} /></F>
|
|
||||||
<F label="Age"><Input type="number" value={draft.age ?? ''} onChange={(e) => set('age', intOrUndef(e.target.value) as any)} /></F>
|
|
||||||
<F label="Latitude"><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} /></F>
|
|
||||||
<F label="Longitude"><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} /></F>
|
|
||||||
<F label="Rig (contacted)" span={3}><Input value={draft.rig ?? ''} onChange={(e) => set('rig', e.target.value)} /></F>
|
|
||||||
<F label="Antenna (contacted)" span={3}><Input value={draft.ant ?? ''} onChange={(e) => set('ant', e.target.value)} /></F>
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="qsl" className="mt-0">
|
<TabsContent value="qsl" className="mt-0">
|
||||||
<div className="grid grid-cols-6 gap-3">
|
{(() => {
|
||||||
<F label="QSL sent"><QslSelect value={draft.qsl_sent ?? ''} onChange={(v) => set('qsl_sent', v)} /></F>
|
const def = CONFIRMATIONS.find((c) => c.key === confSel) ?? CONFIRMATIONS[0];
|
||||||
<F label="QSL rcvd"><QslSelect value={draft.qsl_rcvd ?? ''} onChange={(v) => set('qsl_rcvd', v)} /></F>
|
const val = (k?: keyof QSOForm) => (k ? ((draft as any)[k] ?? '') : '');
|
||||||
<F label="QSL sent date"><Input value={draft.qsl_sent_date ?? ''} placeholder="YYYYMMDD" onChange={(e) => set('qsl_sent_date', e.target.value)} /></F>
|
const put = (k: keyof QSOForm | undefined, v: any) => { if (k) (set as any)(k, v); };
|
||||||
<F label="QSL rcvd date"><Input value={draft.qsl_rcvd_date ?? ''} placeholder="YYYYMMDD" onChange={(e) => set('qsl_rcvd_date', e.target.value)} /></F>
|
return (
|
||||||
<F label="QSL via" span={3}><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></F>
|
<div className="flex gap-6">
|
||||||
<F label="QSL message" span={3}><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></F>
|
{/* Left: edit one confirmation channel at a time */}
|
||||||
<F label="QSL message rcvd" span={6}><Input value={draft.qslmsg_rcvd ?? ''} onChange={(e) => set('qslmsg_rcvd', e.target.value)} /></F>
|
<div className="flex-1 max-w-sm space-y-3">
|
||||||
<F label="LoTW sent"><QslSelect value={draft.lotw_sent ?? ''} onChange={(v) => set('lotw_sent', v)} /></F>
|
<div>
|
||||||
<F label="LoTW rcvd"><QslSelect value={draft.lotw_rcvd ?? ''} onChange={(v) => set('lotw_rcvd', v)} /></F>
|
<Label>Manage Confirmation</Label>
|
||||||
<F label="LoTW sent date"><Input value={draft.lotw_sent_date ?? ''} onChange={(e) => set('lotw_sent_date', e.target.value)} /></F>
|
<Select value={confSel} onValueChange={setConfSel}>
|
||||||
<F label="LoTW rcvd date"><Input value={draft.lotw_rcvd_date ?? ''} onChange={(e) => set('lotw_rcvd_date', e.target.value)} /></F>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<F label="eQSL sent"><QslSelect value={draft.eqsl_sent ?? ''} onChange={(v) => set('eqsl_sent', v)} /></F>
|
<SelectContent>{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{c.label}</SelectItem>)}</SelectContent>
|
||||||
<F label="eQSL rcvd"><QslSelect value={draft.eqsl_rcvd ?? ''} onChange={(v) => set('eqsl_rcvd', v)} /></F>
|
</Select>
|
||||||
<F label="eQSL sent date"><Input value={draft.eqsl_sent_date ?? ''} onChange={(e) => set('eqsl_sent_date', e.target.value)} /></F>
|
</div>
|
||||||
<F label="eQSL rcvd date"><Input value={draft.eqsl_rcvd_date ?? ''} onChange={(e) => set('eqsl_rcvd_date', e.target.value)} /></F>
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<F label="Clublog status" span={2}><Input value={draft.clublog_qso_upload_status ?? ''} onChange={(e) => set('clublog_qso_upload_status', e.target.value)} /></F>
|
<div><Label>Sent</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
|
||||||
<F label="Clublog date"><Input value={draft.clublog_qso_upload_date ?? ''} onChange={(e) => set('clublog_qso_upload_date', e.target.value)} /></F>
|
<div><Label>Received</Label>
|
||||||
<F label="HRDLog status" span={2}><Input value={draft.hrdlog_qso_upload_status ?? ''} onChange={(e) => set('hrdlog_qso_upload_status', e.target.value)} /></F>
|
{def.rcvd
|
||||||
<F label="HRDLog date"><Input value={draft.hrdlog_qso_upload_date ?? ''} onChange={(e) => set('hrdlog_qso_upload_date', e.target.value)} /></F>
|
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
|
||||||
<F label="QRZ.com status" span={2}><Input value={draft.qrzcom_qso_upload_status ?? ''} onChange={(e) => set('qrzcom_qso_upload_status', e.target.value)} /></F>
|
: <Input disabled value="—" />}
|
||||||
<F label="QRZ.com date"><Input value={draft.qrzcom_qso_upload_date ?? ''} onChange={(e) => set('qrzcom_qso_upload_date', e.target.value)} /></F>
|
</div>
|
||||||
</div>
|
<div><Label>Date sent</Label><Input value={val(def.sentDate)} placeholder="YYYYMMDD" onChange={(e) => put(def.sentDate, e.target.value)} className="font-mono" /></div>
|
||||||
|
<div><Label>Date received</Label><Input value={val(def.rcvdDate)} placeholder="YYYYMMDD" disabled={!def.rcvdDate} onChange={(e) => put(def.rcvdDate, e.target.value)} className="font-mono" /></div>
|
||||||
|
{def.via && (
|
||||||
|
<div className="col-span-2"><Label>Via</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder="BUREAU / DIRECT / manager…" /></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Pick a channel, edit it — the table on the right updates live. Everything is written when you click <strong>Save changes</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: live status grid for every channel */}
|
||||||
|
<div className="w-72 shrink-0">
|
||||||
|
<table className="w-full border-separate" style={{ borderSpacing: 4 }}>
|
||||||
|
<thead>
|
||||||
|
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
<th className="text-left font-semibold">Type</th>
|
||||||
|
<th className="font-semibold">Sent</th>
|
||||||
|
<th className="font-semibold">Received</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{CONFIRMATIONS.map((c) => (
|
||||||
|
<tr key={c.key} className={cn('text-xs', c.key === confSel && 'bg-accent/40')}>
|
||||||
|
<td className="font-medium pr-2 py-0.5">{c.label}</td>
|
||||||
|
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
|
||||||
|
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground">—</span>}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="contest" className="mt-0">
|
<TabsContent value="contest" className="mt-0">
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import {
|
|||||||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Columns3 } from 'lucide-react';
|
import { Columns3, FilterX } from 'lucide-react';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
|
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||||
|
|
||||||
// Register every Community feature once. v32+ requires explicit registration;
|
// Register every Community feature once. v32+ requires explicit registration;
|
||||||
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
||||||
@@ -45,6 +47,8 @@ type Props = {
|
|||||||
total: number;
|
total: number;
|
||||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||||
onRowSelected?: (id: number | null) => void;
|
onRowSelected?: (id: number | null) => void;
|
||||||
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
|
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
|
||||||
@@ -74,9 +78,11 @@ function fmtDateOnly(s: any): string {
|
|||||||
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
|
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
|
||||||
// = shown out of the box; anything else stays hidden until the user toggles
|
// = shown out of the box; anything else stays hidden until the user toggles
|
||||||
// it in the Columns dialog.
|
// it in the Columns dialog.
|
||||||
type ColEntry = ColDef<QSOForm> & { group: string; label: string; defaultVisible?: boolean };
|
export type ColEntry = ColDef<QSOForm> & { group: string; label: string; defaultVisible?: boolean };
|
||||||
|
|
||||||
const COL_CATALOG: ColEntry[] = [
|
// Shared so the Worked-before grid (which now also shows full QSO records)
|
||||||
|
// can offer the exact same column choices without duplicating the catalog.
|
||||||
|
export const COL_CATALOG: ColEntry[] = [
|
||||||
// ── QSO basics ──
|
// ── QSO basics ──
|
||||||
{ group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
{ group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
||||||
{ group: 'QSO', label: 'Date Off', colId: 'qso_date_off', headerName: 'Date off', field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
{ group: 'QSO', label: 'Date Off', colId: 'qso_date_off', headerName: 'Date off', field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||||
@@ -190,14 +196,33 @@ const COL_CATALOG: ColEntry[] = [
|
|||||||
{ group: 'Misc', label: 'Updated', colId: 'updated_at', headerName: 'Updated at', field: 'updated_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
|
{ group: 'Misc', label: 'Updated', colId: 'updated_at', headerName: 'Updated at', field: 'updated_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||||
];
|
];
|
||||||
|
|
||||||
const GROUP_ORDER = [
|
export const GROUP_ORDER = [
|
||||||
'QSO', 'Contacted', 'QSL', 'LoTW', 'eQSL', 'Uploads',
|
'QSO', 'Contacted', 'QSL', 'LoTW', 'eQSL', 'Uploads',
|
||||||
'Contest', 'Propagation', 'My station', 'Misc',
|
'Contest', 'Propagation', 'My station', 'Misc',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Props) {
|
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ }: Props) {
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
|
|
||||||
|
// Right-click: if the clicked row isn't already part of the selection,
|
||||||
|
// select just it; then open the bulk-action menu on the whole selection.
|
||||||
|
function onCellContextMenu(e: any) {
|
||||||
|
const ev = e.event as MouseEvent | undefined;
|
||||||
|
ev?.preventDefault();
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
if (!api) return;
|
||||||
|
if (e.node && !e.node.isSelected()) {
|
||||||
|
api.deselectAll();
|
||||||
|
e.node.setSelected(true);
|
||||||
|
}
|
||||||
|
const ids = (api.getSelectedRows() as QSOForm[])
|
||||||
|
.map((r) => r.id as number)
|
||||||
|
.filter((n) => !!n);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
setMenu({ x: ev?.clientX ?? 0, y: ev?.clientY ?? 0, ids });
|
||||||
|
}
|
||||||
|
|
||||||
// Compute initial column defs: all columns defined, but those not marked
|
// Compute initial column defs: all columns defined, but those not marked
|
||||||
// defaultVisible start hidden. The user's saved state (loaded onGridReady)
|
// defaultVisible start hidden. The user's saved state (loaded onGridReady)
|
||||||
@@ -215,21 +240,20 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
|
|||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
function onGridReady(e: GridReadyEvent) {
|
||||||
try {
|
const local = loadLocal(COL_STATE_KEY);
|
||||||
const raw = localStorage.getItem(COL_STATE_KEY);
|
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
if (raw) {
|
// Fall back to the portable DB copy when the local cache is empty
|
||||||
const state = JSON.parse(raw) as ColumnState[];
|
// (fresh machine / after a reinstall), then re-seed the cache.
|
||||||
if (Array.isArray(state)) {
|
loadRemote(COL_STATE_KEY).then((remote) => {
|
||||||
e.api.applyColumnState({ state, applyOrder: true });
|
if (remote && !local) {
|
||||||
}
|
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||||
|
seedLocal(COL_STATE_KEY, remote);
|
||||||
}
|
}
|
||||||
} catch {}
|
});
|
||||||
}
|
}
|
||||||
const saveColumnState = useCallback(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
try {
|
const state = gridRef.current?.api?.getColumnState();
|
||||||
const state = gridRef.current?.api?.getColumnState();
|
if (state) saveState(COL_STATE_KEY, state);
|
||||||
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
|
|
||||||
} catch {}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
||||||
@@ -281,6 +305,10 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||||
|
title="Clear all column filters">
|
||||||
|
<FilterX className="size-3.5" /> Clear filters
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
||||||
<Columns3 className="size-3.5" /> Columns
|
<Columns3 className="size-3.5" /> Columns
|
||||||
</Button>
|
</Button>
|
||||||
@@ -293,7 +321,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
|
|||||||
rowData={rows}
|
rowData={rows}
|
||||||
columnDefs={columnDefs}
|
columnDefs={columnDefs}
|
||||||
defaultColDef={defaultColDef}
|
defaultColDef={defaultColDef}
|
||||||
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
|
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||||
onGridReady={onGridReady}
|
onGridReady={onGridReady}
|
||||||
onColumnResized={saveColumnState}
|
onColumnResized={saveColumnState}
|
||||||
onColumnMoved={saveColumnState}
|
onColumnMoved={saveColumnState}
|
||||||
@@ -302,6 +330,8 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
|
|||||||
onSortChanged={saveColumnState}
|
onSortChanged={saveColumnState}
|
||||||
onRowDoubleClicked={handleRowDoubleClicked}
|
onRowDoubleClicked={handleRowDoubleClicked}
|
||||||
onSelectionChanged={onSelectionChanged}
|
onSelectionChanged={onSelectionChanged}
|
||||||
|
onCellContextMenu={onCellContextMenu}
|
||||||
|
preventDefaultOnContextMenu
|
||||||
animateRows={false}
|
animateRows={false}
|
||||||
suppressCellFocus
|
suppressCellFocus
|
||||||
getRowId={(p) => String((p.data as any).id)}
|
getRowId={(p) => String((p.data as any).id)}
|
||||||
@@ -309,6 +339,13 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<QSOContextMenu
|
||||||
|
menu={menu}
|
||||||
|
onClose={() => setMenu(null)}
|
||||||
|
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
||||||
|
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||||
<DialogContent className="max-w-3xl">
|
<DialogContent className="max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -318,7 +355,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
|
|||||||
Your selection is saved.
|
Your selection is saved.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto py-2">
|
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||||||
{GROUP_ORDER.map((group) => {
|
{GROUP_ORDER.map((group) => {
|
||||||
const cols = COL_CATALOG.filter((c) => c.group === group);
|
const cols = COL_CATALOG.filter((c) => c.group === group);
|
||||||
if (cols.length === 0) return null;
|
if (cols.length === 0) return null;
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import {
|
|||||||
GetCATSettings, SaveCATSettings,
|
GetCATSettings, SaveCATSettings,
|
||||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
||||||
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||||
ListClusterServers, SaveClusterServer, DeleteClusterServer,
|
ListClusterServers, SaveClusterServer, DeleteClusterServer,
|
||||||
GetClusterAutoConnect, SetClusterAutoConnect,
|
GetClusterAutoConnect, SetClusterAutoConnect,
|
||||||
ConnectClusterServer, DisconnectClusterServer,
|
ConnectClusterServer, DisconnectClusterServer,
|
||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||||
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp,
|
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
|
||||||
GetQSLDefaults, SaveQSLDefaults,
|
GetQSLDefaults, SaveQSLDefaults,
|
||||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
||||||
TestLoTWUpload, ListTQSLStationLocations,
|
TestLoTWUpload, ListTQSLStationLocations,
|
||||||
@@ -146,6 +147,7 @@ type SectionId =
|
|||||||
| 'awards'
|
| 'awards'
|
||||||
| 'cat'
|
| 'cat'
|
||||||
| 'rotator'
|
| 'rotator'
|
||||||
|
| 'winkeyer'
|
||||||
| 'antenna'
|
| 'antenna'
|
||||||
| 'audio';
|
| 'audio';
|
||||||
|
|
||||||
@@ -172,8 +174,7 @@ const TREE: TreeNode[] = [
|
|||||||
]},
|
]},
|
||||||
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
|
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
|
||||||
{ kind: 'item', label: 'UDP integrations (WSJT-X, JTDX, MSHV…)', id: 'udp' },
|
{ kind: 'item', label: 'UDP integrations (WSJT-X, JTDX, MSHV…)', id: 'udp' },
|
||||||
{ kind: 'item', label: 'Database backup', id: 'backup' },
|
{ kind: 'item', label: 'Database', id: 'database' },
|
||||||
{ kind: 'item', label: 'Database location', id: 'database' },
|
|
||||||
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
|
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -181,6 +182,7 @@ const TREE: TreeNode[] = [
|
|||||||
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [
|
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [
|
||||||
{ kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' },
|
{ kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' },
|
||||||
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' },
|
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' },
|
||||||
|
{ kind: 'item', label: 'CW Keyer (WinKeyer)', id: 'winkeyer' },
|
||||||
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true },
|
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true },
|
||||||
{ kind: 'item', label: 'Audio devices', id: 'audio', disabled: true },
|
{ kind: 'item', label: 'Audio devices', id: 'audio', disabled: true },
|
||||||
],
|
],
|
||||||
@@ -199,11 +201,12 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
|||||||
'lists-modes': 'Modes & default RST',
|
'lists-modes': 'Modes & default RST',
|
||||||
cluster: 'DX Cluster',
|
cluster: 'DX Cluster',
|
||||||
backup: 'Database backup',
|
backup: 'Database backup',
|
||||||
database: 'Database location',
|
database: 'Database',
|
||||||
udp: 'UDP integrations',
|
udp: 'UDP integrations',
|
||||||
awards: 'Awards',
|
awards: 'Awards',
|
||||||
cat: 'CAT interface',
|
cat: 'CAT interface',
|
||||||
rotator: 'Rotator',
|
rotator: 'Rotator',
|
||||||
|
winkeyer: 'CW Keyer (WinKeyer)',
|
||||||
antenna: 'Antenna',
|
antenna: 'Antenna',
|
||||||
audio: 'Audio devices',
|
audio: 'Audio devices',
|
||||||
};
|
};
|
||||||
@@ -284,6 +287,21 @@ function SectionHeader({ title, hint }: { title: string; hint?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProfileScopeNote flags that the panel's settings are saved per-profile, so
|
||||||
|
// the user knows which operating identity (F4BPO / TM2Q / …) they're editing.
|
||||||
|
function ProfileScopeNote({ profile }: { profile?: { name?: string; callsign?: string } }) {
|
||||||
|
return (
|
||||||
|
<div className="-mt-2 mb-4">
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-md bg-primary/10 text-primary text-xs px-2.5 py-1">
|
||||||
|
<User className="size-3.5" />
|
||||||
|
Saved for profile <strong className="font-semibold">{profile?.name || '—'}</strong>
|
||||||
|
{profile?.callsign ? <span className="font-mono opacity-80">({profile.callsign})</span> : null}
|
||||||
|
— switch profiles to edit another identity.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
||||||
const label = SECTION_LABELS[id] ?? id;
|
const label = SECTION_LABELS[id] ?? id;
|
||||||
const IconCmp = Icon ?? Construction;
|
const IconCmp = Icon ?? Construction;
|
||||||
@@ -340,17 +358,37 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
const [rotatorTesting, setRotatorTesting] = useState(false);
|
const [rotatorTesting, setRotatorTesting] = useState(false);
|
||||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
|
// WinKeyer CW keyer settings + macro editor.
|
||||||
|
type WKMac = { label: string; text: string };
|
||||||
|
type WKSettings = {
|
||||||
|
enabled: boolean; engine: string; esc_clears_call: boolean;
|
||||||
|
port: string; baud: number; wpm: number; weight: number;
|
||||||
|
lead_in_ms: number; tail_ms: number; ratio: number; farnsworth: number;
|
||||||
|
sidetone_hz: number; mode: string; swap: boolean; autospace: boolean;
|
||||||
|
use_ptt: boolean; serial_echo: boolean; macros: WKMac[];
|
||||||
|
};
|
||||||
|
const [wk, setWk] = useState<WKSettings>({
|
||||||
|
enabled: false, engine: 'winkeyer', esc_clears_call: true,
|
||||||
|
port: '', baud: 1200, wpm: 25, weight: 50, lead_in_ms: 10,
|
||||||
|
tail_ms: 50, ratio: 50, farnsworth: 0, sidetone_hz: 600, mode: 'iambic_b',
|
||||||
|
swap: false, autospace: true, use_ptt: false, serial_echo: true, macros: [],
|
||||||
|
});
|
||||||
|
const [wkPorts, setWkPorts] = useState<string[]>([]);
|
||||||
|
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
|
||||||
|
|
||||||
type QSLDefaults = {
|
type QSLDefaults = {
|
||||||
qsl_sent: string; qsl_rcvd: string;
|
qsl_sent: string; qsl_rcvd: string;
|
||||||
lotw_sent: string; lotw_rcvd: string;
|
lotw_sent: string; lotw_rcvd: string;
|
||||||
eqsl_sent: string; eqsl_rcvd: string;
|
eqsl_sent: string; eqsl_rcvd: string;
|
||||||
clublog_status: string; hrdlog_status: string; qrzcom_status: string;
|
clublog_status: string; hrdlog_status: string; qrzcom_status: string;
|
||||||
|
qrzcom_confirmed: string;
|
||||||
};
|
};
|
||||||
const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
|
const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
|
||||||
qsl_sent: '', qsl_rcvd: '',
|
qsl_sent: '', qsl_rcvd: '',
|
||||||
lotw_sent: '', lotw_rcvd: '',
|
lotw_sent: '', lotw_rcvd: '',
|
||||||
eqsl_sent: '', eqsl_rcvd: '',
|
eqsl_sent: '', eqsl_rcvd: '',
|
||||||
clublog_status: '', hrdlog_status: '', qrzcom_status: '',
|
clublog_status: '', hrdlog_status: '', qrzcom_status: '',
|
||||||
|
qrzcom_confirmed: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// External services (logbook upload). One block per service; only QRZ is
|
// External services (logbook upload). One block per service; only QRZ is
|
||||||
@@ -483,6 +521,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
const locs: any = await ListTQSLStationLocations();
|
const locs: any = await ListTQSLStationLocations();
|
||||||
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
|
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
|
||||||
} catch { /* TQSL not installed — leave the dropdown empty */ }
|
} catch { /* TQSL not installed — leave the dropdown empty */ }
|
||||||
|
try { setWk(await GetWinkeyerSettings() as any); } catch {}
|
||||||
|
try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setErr(String(e?.message ?? e));
|
setErr(String(e?.message ?? e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -636,6 +676,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
await SaveLookupSettings(lookup as any);
|
await SaveLookupSettings(lookup as any);
|
||||||
await SaveCATSettings(catCfg as any);
|
await SaveCATSettings(catCfg as any);
|
||||||
await SaveRotatorSettings(rotator as any);
|
await SaveRotatorSettings(rotator as any);
|
||||||
|
await SaveWinkeyerSettings(wk as any);
|
||||||
await SaveBackupSettings(backupCfg as any);
|
await SaveBackupSettings(backupCfg as any);
|
||||||
await SaveQSLDefaults(qslDefaults as any);
|
await SaveQSLDefaults(qslDefaults as any);
|
||||||
await SaveExternalServices(extSvc as any);
|
await SaveExternalServices(extSvc as any);
|
||||||
@@ -788,7 +829,17 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
|
|
||||||
async function profileActivate() {
|
async function profileActivate() {
|
||||||
if (!currentProfile) return;
|
if (!currentProfile) return;
|
||||||
try { await ActivateProfile(currentProfile.id as number); await reloadProfiles(); onSaved(); }
|
try {
|
||||||
|
await ActivateProfile(currentProfile.id as number);
|
||||||
|
await reloadProfiles();
|
||||||
|
// Per-profile settings follow the active identity — reload the panels
|
||||||
|
// that are now scoped to the newly-active profile.
|
||||||
|
const [ap, qd, es] = await Promise.all([GetActiveProfile(), GetQSLDefaults(), GetExternalServices()]);
|
||||||
|
setActiveProfile(ap as Profile);
|
||||||
|
setQslDefaults(qd as any);
|
||||||
|
setExtSvc(es as any);
|
||||||
|
onSaved();
|
||||||
|
}
|
||||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
async function profileRemove() {
|
async function profileRemove() {
|
||||||
@@ -1436,6 +1487,153 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WinkeyerPanel() {
|
||||||
|
const setMacro = (i: number, patch: Partial<WKMac>) => setWk((s) => {
|
||||||
|
const macros = [...s.macros];
|
||||||
|
while (macros.length <= i) macros.push({ label: '', text: '' });
|
||||||
|
macros[i] = { ...macros[i], ...patch };
|
||||||
|
return { ...s, macros };
|
||||||
|
});
|
||||||
|
const num = (v: string, d: number) => { const n = parseInt(v, 10); return isNaN(n) ? d : n; };
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
title="CW Keyer (WinKeyer)"
|
||||||
|
hint="Drive a K1EL WinKeyer (WK1/2/3) over a serial port to send Morse from OpsLog. Enable it, pick the COM port and keying parameters, then connect from the keyer panel (Tools → WinKeyer CW keyer)."
|
||||||
|
/>
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={wk.enabled} onCheckedChange={(c) => setWkField({ enabled: !!c })} />
|
||||||
|
Enable CW keyer (shows the keyer panel)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-3 items-end">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Keyer engine</Label>
|
||||||
|
<Select value={wk.engine} onValueChange={(v) => setWkField({ engine: v })}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="winkeyer">WinKeyer (serial)</SelectItem>
|
||||||
|
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer col-span-3 pb-1.5">
|
||||||
|
<Checkbox checked={wk.esc_clears_call} onCheckedChange={(c) => setWkField({ esc_clears_call: !!c })} />
|
||||||
|
ESC clears the callsign too (otherwise ESC only stops transmission)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>Serial port</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={wk.port || '_'} onValueChange={(v) => setWkField({ port: v === '_' ? '' : v })}>
|
||||||
|
<SelectTrigger className="h-8 flex-1"><SelectValue placeholder="— COM port —" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||||
|
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 px-2" title="Reload ports"
|
||||||
|
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
|
<ArrowDown className="size-3.5 rotate-90" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Baud</Label>
|
||||||
|
<Select value={String(wk.baud)} onValueChange={(v) => setWkField({ baud: num(v, 1200) })}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[1200, 9600].map((b) => <SelectItem key={b} value={String(b)}>{b}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Speed (WPM)</Label>
|
||||||
|
<Input type="number" min={5} max={99} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Weight</Label>
|
||||||
|
<Input type="number" min={10} max={90} value={wk.weight} onChange={(e) => setWkField({ weight: num(e.target.value, 50) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Lead-in (ms)</Label>
|
||||||
|
<Input type="number" min={0} value={wk.lead_in_ms} onChange={(e) => setWkField({ lead_in_ms: num(e.target.value, 10) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Tail (ms)</Label>
|
||||||
|
<Input type="number" min={0} value={wk.tail_ms} onChange={(e) => setWkField({ tail_ms: num(e.target.value, 50) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Ratio (33-66)</Label>
|
||||||
|
<Input type="number" min={33} max={66} value={wk.ratio} onChange={(e) => setWkField({ ratio: num(e.target.value, 50) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Farnsworth</Label>
|
||||||
|
<Input type="number" min={0} max={99} value={wk.farnsworth} onChange={(e) => setWkField({ farnsworth: num(e.target.value, 0) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Sidetone (Hz)</Label>
|
||||||
|
<Input type="number" min={0} value={wk.sidetone_hz} onChange={(e) => setWkField({ sidetone_hz: num(e.target.value, 600) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>Paddle mode</Label>
|
||||||
|
<Select value={wk.mode} onValueChange={(v) => setWkField({ mode: v })}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="iambic_b">Iambic B</SelectItem>
|
||||||
|
<SelectItem value="iambic_a">Iambic A</SelectItem>
|
||||||
|
<SelectItem value="ultimatic">Ultimatic</SelectItem>
|
||||||
|
<SelectItem value="bug">Bug</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={wk.swap} onCheckedChange={(c) => setWkField({ swap: !!c })} /> Swap paddles
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={wk.autospace} onCheckedChange={(c) => setWkField({ autospace: !!c })} /> Auto-space
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={wk.use_ptt} onCheckedChange={(c) => setWkField({ use_ptt: !!c })} /> Key PTT
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> Serial echo
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Macro editor */}
|
||||||
|
<div className="border-t border-border/60 pt-3">
|
||||||
|
<Label className="text-sm font-medium">CW message macros (F1…)</Label>
|
||||||
|
<p className="text-[11px] text-muted-foreground mb-2">
|
||||||
|
Use variables: <span className="font-mono"><MY_CALL> <CALL> <STX> <STRX> <MY_NAME> <HIS_NAME> <MY_QTH> <GRID> <CONT_TX> <n></span> (cut numbers: 9→N, 0→T). <span className="font-mono">*</span>=my call, <span className="font-mono">!</span>=his call.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5 max-h-[34vh] overflow-y-auto pr-1">
|
||||||
|
{Array.from({ length: 12 }).map((_, i) => {
|
||||||
|
const m = wk.macros[i] ?? { label: '', text: '' };
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-[10px] text-primary font-semibold w-6 shrink-0">F{i + 1}</span>
|
||||||
|
<Input className="h-8 w-28 shrink-0 text-xs" placeholder="Label" value={m.label} onChange={(e) => setMacro(i, { label: e.target.value })} />
|
||||||
|
<Input className="h-8 flex-1 font-mono text-xs" placeholder="CQ CQ DE <MY_CALL> K" value={m.text} onChange={(e) => setMacro(i, { text: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function statusForServer(id: number): ClusterServerStatus | undefined {
|
function statusForServer(id: number): ClusterServerStatus | undefined {
|
||||||
return clusterStatuses.find((s) => (s.server_id as number) === id);
|
return clusterStatuses.find((s) => (s.server_id as number) === id);
|
||||||
}
|
}
|
||||||
@@ -1623,6 +1821,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
title="Confirmations"
|
title="Confirmations"
|
||||||
hint="Default QSL / eQSL / LoTW / upload status applied to every QSO you log — manually or via UDP auto-log from WSJT-X / JTDX / MSHV. Leave a field blank to keep the QSO column empty."
|
hint="Default QSL / eQSL / LoTW / upload status applied to every QSO you log — manually or via UDP auto-log from WSJT-X / JTDX / MSHV. Leave a field blank to keep the QSO column empty."
|
||||||
/>
|
/>
|
||||||
|
<ProfileScopeNote profile={activeProfileObj} />
|
||||||
<div className="space-y-3 max-w-2xl">
|
<div className="space-y-3 max-w-2xl">
|
||||||
{/* Paper QSL */}
|
{/* Paper QSL */}
|
||||||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||||
@@ -1690,7 +1889,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Upload status</Label>
|
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Upload status</Label>
|
||||||
{renderSelect('qrzcom_status', FULL_OPTIONS)}
|
{renderSelect('qrzcom_status', FULL_OPTIONS)}
|
||||||
</div>
|
</div>
|
||||||
<div />
|
<div>
|
||||||
|
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Confirmed</Label>
|
||||||
|
{renderSelect('qrzcom_confirmed', FULL_OPTIONS)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1923,6 +2125,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
hint="Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first)."
|
hint="Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first)."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ProfileScopeNote profile={activeProfileObj} />
|
||||||
|
|
||||||
{/* Tab strip */}
|
{/* Tab strip */}
|
||||||
<div className="flex flex-wrap gap-1 border-b border-border mb-4">
|
<div className="flex flex-wrap gap-1 border-b border-border mb-4">
|
||||||
{TABS.map((t) => (
|
{TABS.map((t) => (
|
||||||
@@ -2100,6 +2304,19 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<ArrowDown className="size-3.5 rotate-90" />
|
<ArrowDown className="size-3.5 rotate-90" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<Label className="text-sm">Force station callsign</Label>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
value={lotw.force_station_callsign}
|
||||||
|
onChange={(e) => setLotw({ force_station_callsign: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="e.g. F4BPO/P — leave blank to use the QSO's own call"
|
||||||
|
className="font-mono uppercase w-64"
|
||||||
|
/>
|
||||||
|
<div className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
Overrides STATION_CALLSIGN at sign time so one certificate can sign several calls
|
||||||
|
(F4BPO, F4BPO/P, TM2Q). Pick the matching Station Location above.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Label className="text-sm">Key password</Label>
|
<Label className="text-sm">Key password</Label>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -2184,6 +2401,15 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
setDbMsg(`A copy was saved to:\n${p}\nand selected. Restart OpsLog to apply.`);
|
setDbMsg(`A copy was saved to:\n${p}\nand selected. Restart OpsLog to apply.`);
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
async function createNew() {
|
||||||
|
try {
|
||||||
|
const p = await PickSaveDatabase();
|
||||||
|
if (!p) return;
|
||||||
|
await CreateDatabase(p);
|
||||||
|
await refreshDb();
|
||||||
|
setDbMsg(`New empty logbook created at:\n${p}\nand selected. Restart OpsLog to apply.`);
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
async function resetDefault() {
|
async function resetDefault() {
|
||||||
try {
|
try {
|
||||||
await ResetDatabaseToDefault();
|
await ResetDatabaseToDefault();
|
||||||
@@ -2194,8 +2420,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Database location"
|
title="Database"
|
||||||
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."
|
hint="Your whole log (QSOs, settings, lookup cache) lives in one SQLite file. Keep it wherever you like — another drive or a synced folder (Seafile, Dropbox…) — and back it up automatically."
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-4 max-w-2xl">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -2210,15 +2436,17 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={openExisting}>Open existing database…</Button>
|
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> New database…</Button>
|
||||||
<Button variant="outline" size="sm" onClick={saveCopy}>Save a copy & switch to it…</Button>
|
<Button variant="outline" size="sm" onClick={openExisting}>Open existing…</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={saveCopy}>Save a copy & switch…</Button>
|
||||||
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>Reset to default</Button>}
|
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>Reset to default</Button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-[11px] text-muted-foreground bg-amber-50 border border-amber-200 rounded-md p-2.5 leading-relaxed">
|
<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>New</strong> creates a fresh empty logbook and switches to it (handy for a separate contest/SOTA log).{' '}
|
||||||
<strong>Save a copy</strong> writes the current database to a new place and switches to it.{' '}
|
<strong>Open existing</strong> points OpsLog at a file you already have.{' '}
|
||||||
A database change takes effect on the next launch.
|
<strong>Save a copy</strong> clones the current database elsewhere and switches to it.{' '}
|
||||||
|
Any database change takes effect on the next launch.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dbMsg && (
|
{dbMsg && (
|
||||||
@@ -2228,6 +2456,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Backup settings, merged into this Database section. */}
|
||||||
|
<div className="border-t border-border/60 mt-6 pt-5">
|
||||||
|
{BackupPanel()}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2249,6 +2482,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
awards: () => <ComingSoon id="awards" icon={Award} />,
|
awards: () => <ComingSoon id="awards" icon={Award} />,
|
||||||
cat: CATPanel,
|
cat: CATPanel,
|
||||||
rotator: RotatorPanel,
|
rotator: RotatorPanel,
|
||||||
|
winkeyer: WinkeyerPanel,
|
||||||
antenna: () => <ComingSoon id="antenna" icon={AntennaIcon} />,
|
antenna: () => <ComingSoon id="antenna" icon={AntennaIcon} />,
|
||||||
audio: () => <ComingSoon id="audio" icon={Server} />,
|
audio: () => <ComingSoon id="audio" icon={Server} />,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Radio, Square, Send, Plug, Power, RefreshCw, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface WKMacro { label: string; text: string }
|
||||||
|
export interface WKStatus {
|
||||||
|
connected: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
wpm: number;
|
||||||
|
version: number;
|
||||||
|
port: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
status: WKStatus;
|
||||||
|
ports: string[];
|
||||||
|
port: string;
|
||||||
|
wpm: number;
|
||||||
|
macros: WKMacro[];
|
||||||
|
sent: string; // text echoed back by the keyer as it transmits
|
||||||
|
onSelectPort: (p: string) => void;
|
||||||
|
onRefreshPorts: () => void;
|
||||||
|
onConnect: () => void;
|
||||||
|
onDisconnect: () => void;
|
||||||
|
onSetSpeed: (wpm: number) => void;
|
||||||
|
onSend: (text: string) => void; // raw text (App resolves variables)
|
||||||
|
onSendMacro: (index: number) => void; // App resolves the macro + sends
|
||||||
|
onStop: () => void;
|
||||||
|
onClose: () => void; // disable the keyer (hide the panel)
|
||||||
|
sendOnType: boolean;
|
||||||
|
onToggleSendOnType: (on: boolean) => void;
|
||||||
|
onSendRaw: (chars: string) => void; // key typed chars as-is (no variables)
|
||||||
|
onBackspace: () => void; // remove last not-yet-keyed char
|
||||||
|
}
|
||||||
|
|
||||||
|
// WinkeyerPanel — Log4OM-style CW keyer operating window. Lives in the
|
||||||
|
// reserved space to the right of the F1-F5 tabs. Sends Morse via the WinKeyer
|
||||||
|
// hardware: free-text CW, one-click macros (F1…), live speed, and abort.
|
||||||
|
export function WinkeyerPanel({
|
||||||
|
status, ports, port, wpm, macros, sent,
|
||||||
|
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
|
||||||
|
onSend, onSendMacro, onStop, onClose,
|
||||||
|
sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
|
||||||
|
}: Props) {
|
||||||
|
const [cwText, setCwText] = useState('');
|
||||||
|
const [speed, setSpeed] = useState(wpm);
|
||||||
|
|
||||||
|
// Keep the local speed slider in sync when the device/config changes it.
|
||||||
|
useEffect(() => { setSpeed(status.connected ? status.wpm || wpm : wpm); }, [status.wpm, status.connected, wpm]);
|
||||||
|
|
||||||
|
const connected = status.connected;
|
||||||
|
|
||||||
|
function sendText() {
|
||||||
|
const t = cwText.trim();
|
||||||
|
if (t && !sendOnType) onSend(t); // in send-on-type the text already went out
|
||||||
|
setCwText('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// In "send on type" mode, key each newly-typed char immediately, and send a
|
||||||
|
// WinKeyer backspace for each deleted char (removes it from the buffer if it
|
||||||
|
// hasn't been keyed yet). Only end-of-string edits are mirrored live.
|
||||||
|
function onCwChange(v: string) {
|
||||||
|
if (sendOnType && connected) {
|
||||||
|
const old = cwText;
|
||||||
|
if (v.length > old.length && v.startsWith(old)) {
|
||||||
|
onSendRaw(v.slice(old.length));
|
||||||
|
} else if (v.length < old.length && old.startsWith(v)) {
|
||||||
|
for (let i = 0; i < old.length - v.length; i++) onBackspace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCwText(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-2 h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
|
||||||
|
{/* Header / connection bar */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
|
||||||
|
<Radio className="size-4 text-primary shrink-0" />
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">WinKeyer</span>
|
||||||
|
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500') : 'bg-muted-foreground/40')}
|
||||||
|
title={connected ? (status.busy ? 'Sending…' : `Connected (v${status.version})`) : 'Disconnected'} />
|
||||||
|
<div className="flex-1" />
|
||||||
|
{!connected ? (
|
||||||
|
<>
|
||||||
|
<Select value={port || '_'} onValueChange={(v) => onSelectPort(v === '_' ? '' : v)}>
|
||||||
|
<SelectTrigger className="h-7 w-28 text-xs"><SelectValue placeholder="COM port" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ports.length === 0 && <SelectItem value="_" disabled>No ports</SelectItem>}
|
||||||
|
{ports.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 px-1.5" title="Refresh ports" onClick={onRefreshPorts}>
|
||||||
|
<RefreshCw className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" className="h-7" onClick={onConnect} disabled={!port}>
|
||||||
|
<Plug className="size-3.5" /> Connect
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" size="sm" className="h-7" onClick={onDisconnect}>
|
||||||
|
<Power className="size-3.5" /> Disconnect
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 px-1.5" title="Hide / disable WinKeyer" onClick={onClose}>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 px-3 pb-2 min-h-0 overflow-y-auto">
|
||||||
|
{/* Speed */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-xs w-12 shrink-0">Speed</Label>
|
||||||
|
<input
|
||||||
|
type="range" min={5} max={50} value={speed}
|
||||||
|
onChange={(e) => setSpeed(parseInt(e.target.value, 10))}
|
||||||
|
onMouseUp={() => onSetSpeed(speed)}
|
||||||
|
onTouchEnd={() => onSetSpeed(speed)}
|
||||||
|
disabled={!connected}
|
||||||
|
className="flex-1 accent-primary"
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-sm font-bold w-14 text-right">{speed} wpm</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live transmitted text (echoed by the keyer as it sends). */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-xs w-12 shrink-0">TX</Label>
|
||||||
|
<div className={cn(
|
||||||
|
'flex-1 min-w-0 h-8 rounded-md border border-border bg-muted/30 px-2.5 flex items-center font-mono text-sm tracking-wide truncate',
|
||||||
|
status.busy ? 'text-emerald-700' : 'text-muted-foreground',
|
||||||
|
)}>
|
||||||
|
{sent || <span className="opacity-50">—</span>}
|
||||||
|
{status.busy && <span className="ml-0.5 animate-pulse">▌</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CW text */}
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
|
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
|
||||||
|
CW text
|
||||||
|
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
|
||||||
|
title="Key each character live as you type (backspace removes un-sent chars)">
|
||||||
|
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
||||||
|
onChange={(e) => onToggleSendOnType(e.target.checked)} />
|
||||||
|
send on type
|
||||||
|
</label>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={cwText}
|
||||||
|
onChange={(e) => onCwChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendText(); } }}
|
||||||
|
placeholder={sendOnType ? 'Type — sent live…' : 'Type and press Enter to send…'}
|
||||||
|
disabled={!connected}
|
||||||
|
className="font-mono uppercase"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" className="h-8" onClick={sendText} disabled={!connected}>
|
||||||
|
<Send className="size-3.5" /> {sendOnType ? 'Clear' : 'Send'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" className="h-8" onClick={onStop} disabled={!connected} title="Abort (clear keyer buffer)">
|
||||||
|
<Square className="size-3.5" /> Stop
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Macro buttons F1… */}
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
{macros.map((m, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSendMacro(i)}
|
||||||
|
disabled={!connected}
|
||||||
|
title={m.text}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-start rounded-md border border-border px-2 py-1 text-left transition-colors',
|
||||||
|
connected ? 'hover:border-primary/60 hover:bg-accent/40' : 'opacity-50 cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] font-mono text-primary font-semibold">F{i + 1}</span>
|
||||||
|
<span className="text-xs font-medium truncate w-full">{m.label || `Macro ${i + 1}`}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{status.error && <div className="text-[11px] text-rose-600">{status.error}</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||||||
type ColDef, type ColumnState, type GridReadyEvent,
|
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Columns3, Star } from 'lucide-react';
|
import { Columns3, FilterX, Star } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import type { WorkedBeforeView } from '@/types';
|
import type { WorkedBeforeView, QSOForm } from '@/types';
|
||||||
|
import { COL_CATALOG, GROUP_ORDER } from './RecentQSOsGrid';
|
||||||
|
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||||
|
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
@@ -37,23 +40,19 @@ const hamlogTheme = themeQuartz.withParams({
|
|||||||
iconSize: 12,
|
iconSize: 12,
|
||||||
});
|
});
|
||||||
|
|
||||||
type WorkedEntry = NonNullable<WorkedBeforeView['entries']>[number];
|
type WorkedEntry = QSOForm; // entries are now full QSO records
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
wb: WorkedBeforeView | null;
|
wb: WorkedBeforeView | null;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
currentCall: string;
|
currentCall: string;
|
||||||
|
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||||
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v1';
|
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v2';
|
||||||
|
|
||||||
function fmtDateTime(s: any): string {
|
|
||||||
if (!s) return '';
|
|
||||||
const d = new Date(s);
|
|
||||||
if (isNaN(d.getTime())) return '';
|
|
||||||
const p = (n: number) => String(n).padStart(2, '0');
|
|
||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
|
||||||
}
|
|
||||||
function fmtDate(s: any): string {
|
function fmtDate(s: any): string {
|
||||||
if (!s) return '';
|
if (!s) return '';
|
||||||
const d = new Date(s);
|
const d = new Date(s);
|
||||||
@@ -62,52 +61,29 @@ function fmtDate(s: any): string {
|
|||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bandPill = (p: any) => p.value
|
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ }: Props) {
|
||||||
? <span style={{
|
|
||||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
|
||||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
|
||||||
backgroundColor: '#f0d9a8', color: '#7a4a14', lineHeight: '16px',
|
|
||||||
}}>{p.value}</span>
|
|
||||||
: '';
|
|
||||||
const modePill = (p: any) => p.value
|
|
||||||
? <span style={{
|
|
||||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
|
||||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
|
||||||
backgroundColor: '#d1fae5', color: '#047857', lineHeight: '16px',
|
|
||||||
}}>{p.value}</span>
|
|
||||||
: '';
|
|
||||||
|
|
||||||
// Single Y/N flag column renderer: green dot for "Y", grey dash otherwise.
|
|
||||||
const flagRenderer = (p: any) => {
|
|
||||||
if (p.value === 'Y') {
|
|
||||||
return <span style={{
|
|
||||||
display: 'inline-block', width: 16, height: 16, borderRadius: 4,
|
|
||||||
backgroundColor: '#10b981', color: 'white', textAlign: 'center',
|
|
||||||
fontSize: 10, fontWeight: 700, lineHeight: '16px',
|
|
||||||
}}>Y</span>;
|
|
||||||
}
|
|
||||||
return <span style={{ color: '#a8a29e' }}>—</span>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ColEntry = ColDef<WorkedEntry> & { group: string; label: string; defaultVisible?: boolean };
|
|
||||||
|
|
||||||
const COL_CATALOG: ColEntry[] = [
|
|
||||||
{ group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateTime(p.value), sort: 'desc', defaultVisible: true },
|
|
||||||
{ group: 'QSO', label: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, cellClass: 'flex items-center', cellRenderer: bandPill, defaultVisible: true },
|
|
||||||
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'flex items-center', cellRenderer: modePill, defaultVisible: true },
|
|
||||||
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 90, cellClass: 'font-mono', defaultVisible: true },
|
|
||||||
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 90, cellClass: 'font-mono', defaultVisible: true },
|
|
||||||
{ group: 'QSL', label: 'QSL sent', colId: 'qsl_sent', headerName: 'QSL S', field: 'qsl_sent' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
|
|
||||||
{ group: 'QSL', label: 'QSL rcvd', colId: 'qsl_rcvd', headerName: 'QSL R', field: 'qsl_rcvd' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
|
|
||||||
{ group: 'LoTW', label: 'LoTW sent', colId: 'lotw_sent', headerName: 'LoTW S', field: 'lotw_sent' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
|
|
||||||
{ group: 'LoTW', label: 'LoTW rcvd', colId: 'lotw_rcvd', headerName: 'LoTW R', field: 'lotw_rcvd' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const GROUP_ORDER = ['QSO', 'QSL', 'LoTW'];
|
|
||||||
|
|
||||||
export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
|
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
|
|
||||||
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
||||||
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
|
}
|
||||||
|
function onCellContextMenu(e: any) {
|
||||||
|
const ev = e.event as MouseEvent | undefined;
|
||||||
|
ev?.preventDefault();
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
if (!api) return;
|
||||||
|
if (e.node && !e.node.isSelected()) {
|
||||||
|
api.deselectAll();
|
||||||
|
e.node.setSelected(true);
|
||||||
|
}
|
||||||
|
const ids = (api.getSelectedRows() as WorkedEntry[])
|
||||||
|
.map((r) => r.id as number)
|
||||||
|
.filter((n) => !!n);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
setMenu({ x: ev?.clientX ?? 0, y: ev?.clientY ?? 0, ids });
|
||||||
|
}
|
||||||
|
|
||||||
const hasCall = currentCall.trim() !== '';
|
const hasCall = currentCall.trim() !== '';
|
||||||
const count = wb?.count ?? 0;
|
const count = wb?.count ?? 0;
|
||||||
@@ -123,19 +99,18 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
|
|||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
function onGridReady(e: GridReadyEvent) {
|
||||||
try {
|
const local = loadLocal(COL_STATE_KEY);
|
||||||
const raw = localStorage.getItem(COL_STATE_KEY);
|
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
if (raw) {
|
loadRemote(COL_STATE_KEY).then((remote) => {
|
||||||
const state = JSON.parse(raw) as ColumnState[];
|
if (remote && !local) {
|
||||||
if (Array.isArray(state)) e.api.applyColumnState({ state, applyOrder: true });
|
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||||
|
seedLocal(COL_STATE_KEY, remote);
|
||||||
}
|
}
|
||||||
} catch {}
|
});
|
||||||
}
|
}
|
||||||
const saveColumnState = useCallback(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
try {
|
const state = gridRef.current?.api?.getColumnState();
|
||||||
const state = gridRef.current?.api?.getColumnState();
|
if (state) saveState(COL_STATE_KEY, state);
|
||||||
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
|
|
||||||
} catch {}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function isColVisible(colId: string): boolean {
|
function isColVisible(colId: string): boolean {
|
||||||
@@ -218,6 +193,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||||
|
title="Clear all column filters">
|
||||||
|
<FilterX className="size-3.5" /> Clear filters
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
||||||
<Columns3 className="size-3.5" /> Columns
|
<Columns3 className="size-3.5" /> Columns
|
||||||
</Button>
|
</Button>
|
||||||
@@ -237,6 +216,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
|
|||||||
onColumnPinned={saveColumnState}
|
onColumnPinned={saveColumnState}
|
||||||
onColumnVisible={saveColumnState}
|
onColumnVisible={saveColumnState}
|
||||||
onSortChanged={saveColumnState}
|
onSortChanged={saveColumnState}
|
||||||
|
onRowDoubleClicked={handleRowDoubleClicked}
|
||||||
|
onCellContextMenu={onCellContextMenu}
|
||||||
|
preventDefaultOnContextMenu
|
||||||
|
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||||
animateRows={false}
|
animateRows={false}
|
||||||
suppressCellFocus
|
suppressCellFocus
|
||||||
getRowId={(p) => String((p.data as any).id)}
|
getRowId={(p) => String((p.data as any).id)}
|
||||||
@@ -244,6 +227,13 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<QSOContextMenu
|
||||||
|
menu={menu}
|
||||||
|
onClose={() => setMenu(null)}
|
||||||
|
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
||||||
|
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
||||||
|
/>
|
||||||
|
|
||||||
{count > entries.length && (
|
{count > entries.length && (
|
||||||
<div className="text-center py-1 text-[11px] italic text-muted-foreground border-t border-border/60 bg-muted/30">
|
<div className="text-center py-1 text-[11px] italic text-muted-foreground border-t border-border/60 bg-muted/30">
|
||||||
+ {count - entries.length} older QSOs (not shown — capped for performance)
|
+ {count - entries.length} older QSOs (not shown — capped for performance)
|
||||||
@@ -251,19 +241,19 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Worked-before columns</DialogTitle>
|
<DialogTitle>Worked-before columns</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Pick the columns you want visible in the Worked-before table.
|
Pick the columns you want visible in the Worked-before table.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="max-h-[60vh] overflow-y-auto py-2">
|
<div className="grid grid-cols-2 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||||||
{GROUP_ORDER.map((group) => {
|
{GROUP_ORDER.map((group) => {
|
||||||
const cols = COL_CATALOG.filter((c) => c.group === group);
|
const cols = COL_CATALOG.filter((c) => c.group === group);
|
||||||
if (cols.length === 0) return null;
|
if (cols.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div key={group} className="rounded-md border border-border p-2.5 mb-2">
|
<div key={group} className="rounded-md border border-border p-2.5">
|
||||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Portable grid-column preferences (visibility / order / width / sort).
|
||||||
|
//
|
||||||
|
// Stored in the DB settings table (so they travel with the logbook and
|
||||||
|
// survive a reinstall) AND mirrored to the WebView localStorage as a fast,
|
||||||
|
// flicker-free cache. On a fresh machine localStorage is empty, so we fall
|
||||||
|
// back to the DB copy and re-seed the cache.
|
||||||
|
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// loadLocal reads the cached column state synchronously (used in onGridReady
|
||||||
|
// to apply instantly, before the async DB round-trip).
|
||||||
|
export function loadLocal(key: string): any[] | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
const v = raw ? JSON.parse(raw) : null;
|
||||||
|
return Array.isArray(v) ? v : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadRemote pulls the portable copy from the DB (null if none / unset).
|
||||||
|
export async function loadRemote(key: string): Promise<any[] | null> {
|
||||||
|
try {
|
||||||
|
const v = await GetUIPref(key);
|
||||||
|
const parsed = v ? JSON.parse(v) : null;
|
||||||
|
return Array.isArray(parsed) ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveState write-throughs to both the cache and the DB (fire-and-forget).
|
||||||
|
export function saveState(key: string, state: any[]) {
|
||||||
|
const json = JSON.stringify(state);
|
||||||
|
try { localStorage.setItem(key, json); } catch { /* quota / private mode */ }
|
||||||
|
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedLocal writes a value into the cache without touching the DB (used after
|
||||||
|
// hydrating the cache from the DB on a fresh machine).
|
||||||
|
export function seedLocal(key: string, state: any[]) {
|
||||||
|
try { localStorage.setItem(key, JSON.stringify(state)); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
Vendored
+33
-2
@@ -7,6 +7,7 @@ import {adif} from '../models';
|
|||||||
import {cat} from '../models';
|
import {cat} from '../models';
|
||||||
import {cluster} from '../models';
|
import {cluster} from '../models';
|
||||||
import {extsvc} from '../models';
|
import {extsvc} from '../models';
|
||||||
|
import {winkeyer} from '../models';
|
||||||
import {operating} from '../models';
|
import {operating} from '../models';
|
||||||
import {udp} from '../models';
|
import {udp} from '../models';
|
||||||
import {lookup} from '../models';
|
import {lookup} from '../models';
|
||||||
@@ -27,6 +28,8 @@ export function ConnectClusterServer(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function CountQSO():Promise<number>;
|
export function CountQSO():Promise<number>;
|
||||||
|
|
||||||
|
export function CreateDatabase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function DeleteAllQSO():Promise<number>;
|
export function DeleteAllQSO():Promise<number>;
|
||||||
|
|
||||||
export function DeleteClusterServer(arg1:number):Promise<void>;
|
export function DeleteClusterServer(arg1:number):Promise<void>;
|
||||||
@@ -49,7 +52,7 @@ export function DownloadConfirmations(arg1:string,arg2:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
||||||
|
|
||||||
export function ExportADIF(arg1:string):Promise<adif.ExportResult>;
|
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>;
|
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>;
|
||||||
|
|
||||||
@@ -89,7 +92,13 @@ export function GetStartupStatus():Promise<main.StartupStatus>;
|
|||||||
|
|
||||||
export function GetStationSettings():Promise<main.StationSettings>;
|
export function GetStationSettings():Promise<main.StationSettings>;
|
||||||
|
|
||||||
export function ImportADIF(arg1:string,arg2:boolean):Promise<adif.ImportResult>;
|
export function GetUIPref(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
||||||
|
|
||||||
|
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||||
|
|
||||||
|
export function ImportADIF(arg1:string,arg2:string,arg3:boolean):Promise<adif.ImportResult>;
|
||||||
|
|
||||||
export function ListClusterServers():Promise<Array<cluster.ServerConfig>>;
|
export function ListClusterServers():Promise<Array<cluster.ServerConfig>>;
|
||||||
|
|
||||||
@@ -101,6 +110,8 @@ export function ListProfiles():Promise<Array<profile.Profile>>;
|
|||||||
|
|
||||||
export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>;
|
export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
|
export function ListSerialPorts():Promise<Array<string>>;
|
||||||
|
|
||||||
export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>>;
|
export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>>;
|
||||||
|
|
||||||
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
||||||
@@ -169,6 +180,8 @@ export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
|||||||
|
|
||||||
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
||||||
|
|
||||||
|
export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
|
||||||
|
|
||||||
export function SendClusterCommand(arg1:string):Promise<void>;
|
export function SendClusterCommand(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
|
export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
|
||||||
@@ -181,6 +194,8 @@ export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function SetCompactMode(arg1:boolean):Promise<void>;
|
export function SetCompactMode(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function TestClublogUpload():Promise<string>;
|
export function TestClublogUpload():Promise<string>;
|
||||||
@@ -195,6 +210,22 @@ export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
|||||||
|
|
||||||
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
|
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
|
||||||
|
|
||||||
|
export function UpdateQSOsFromCty(arg1:Array<number>):Promise<number>;
|
||||||
|
|
||||||
|
export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>;
|
||||||
|
|
||||||
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
||||||
|
|
||||||
|
export function WinkeyerBackspace():Promise<void>;
|
||||||
|
|
||||||
|
export function WinkeyerConnect():Promise<void>;
|
||||||
|
|
||||||
|
export function WinkeyerDisconnect():Promise<void>;
|
||||||
|
|
||||||
|
export function WinkeyerSend(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function WinkeyerSetSpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function WinkeyerStop():Promise<void>;
|
||||||
|
|
||||||
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export function CountQSO() {
|
|||||||
return window['go']['main']['App']['CountQSO']();
|
return window['go']['main']['App']['CountQSO']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CreateDatabase(arg1) {
|
||||||
|
return window['go']['main']['App']['CreateDatabase'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function DeleteAllQSO() {
|
export function DeleteAllQSO() {
|
||||||
return window['go']['main']['App']['DeleteAllQSO']();
|
return window['go']['main']['App']['DeleteAllQSO']();
|
||||||
}
|
}
|
||||||
@@ -78,8 +82,8 @@ export function DuplicateProfile(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
|
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExportADIF(arg1) {
|
export function ExportADIF(arg1, arg2) {
|
||||||
return window['go']['main']['App']['ExportADIF'](arg1);
|
return window['go']['main']['App']['ExportADIF'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FindQSOsForUpload(arg1, arg2) {
|
export function FindQSOsForUpload(arg1, arg2) {
|
||||||
@@ -158,8 +162,20 @@ export function GetStationSettings() {
|
|||||||
return window['go']['main']['App']['GetStationSettings']();
|
return window['go']['main']['App']['GetStationSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImportADIF(arg1, arg2) {
|
export function GetUIPref(arg1) {
|
||||||
return window['go']['main']['App']['ImportADIF'](arg1, arg2);
|
return window['go']['main']['App']['GetUIPref'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetWinkeyerSettings() {
|
||||||
|
return window['go']['main']['App']['GetWinkeyerSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetWinkeyerStatus() {
|
||||||
|
return window['go']['main']['App']['GetWinkeyerStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportADIF(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListClusterServers() {
|
export function ListClusterServers() {
|
||||||
@@ -182,6 +198,10 @@ export function ListQSO(arg1) {
|
|||||||
return window['go']['main']['App']['ListQSO'](arg1);
|
return window['go']['main']['App']['ListQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListSerialPorts() {
|
||||||
|
return window['go']['main']['App']['ListSerialPorts']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ListTQSLStationLocations() {
|
export function ListTQSLStationLocations() {
|
||||||
return window['go']['main']['App']['ListTQSLStationLocations']();
|
return window['go']['main']['App']['ListTQSLStationLocations']();
|
||||||
}
|
}
|
||||||
@@ -318,6 +338,10 @@ export function SaveUDPIntegration(arg1) {
|
|||||||
return window['go']['main']['App']['SaveUDPIntegration'](arg1);
|
return window['go']['main']['App']['SaveUDPIntegration'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveWinkeyerSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveWinkeyerSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SendClusterCommand(arg1) {
|
export function SendClusterCommand(arg1) {
|
||||||
return window['go']['main']['App']['SendClusterCommand'](arg1);
|
return window['go']['main']['App']['SendClusterCommand'](arg1);
|
||||||
}
|
}
|
||||||
@@ -342,6 +366,10 @@ export function SetCompactMode(arg1) {
|
|||||||
return window['go']['main']['App']['SetCompactMode'](arg1);
|
return window['go']['main']['App']['SetCompactMode'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetUIPref(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['SetUIPref'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function SwitchCATRig(arg1) {
|
export function SwitchCATRig(arg1) {
|
||||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||||
}
|
}
|
||||||
@@ -370,10 +398,42 @@ export function UpdateQSO(arg1) {
|
|||||||
return window['go']['main']['App']['UpdateQSO'](arg1);
|
return window['go']['main']['App']['UpdateQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function UpdateQSOsFromCty(arg1) {
|
||||||
|
return window['go']['main']['App']['UpdateQSOsFromCty'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpdateQSOsFromQRZ(arg1) {
|
||||||
|
return window['go']['main']['App']['UpdateQSOsFromQRZ'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function UploadQSOsManual(arg1, arg2) {
|
export function UploadQSOsManual(arg1, arg2) {
|
||||||
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
|
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function WinkeyerBackspace() {
|
||||||
|
return window['go']['main']['App']['WinkeyerBackspace']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WinkeyerConnect() {
|
||||||
|
return window['go']['main']['App']['WinkeyerConnect']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WinkeyerDisconnect() {
|
||||||
|
return window['go']['main']['App']['WinkeyerDisconnect']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WinkeyerSend(arg1) {
|
||||||
|
return window['go']['main']['App']['WinkeyerSend'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WinkeyerSetSpeed(arg1) {
|
||||||
|
return window['go']['main']['App']['WinkeyerSetSpeed'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WinkeyerStop() {
|
||||||
|
return window['go']['main']['App']['WinkeyerStop']();
|
||||||
|
}
|
||||||
|
|
||||||
export function WorkedBefore(arg1, arg2) {
|
export function WorkedBefore(arg1, arg2) {
|
||||||
return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
|
return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
+113
-51
@@ -19,6 +19,7 @@ export namespace adif {
|
|||||||
export class ImportResult {
|
export class ImportResult {
|
||||||
total: number;
|
total: number;
|
||||||
imported: number;
|
imported: number;
|
||||||
|
updated: number;
|
||||||
skipped: number;
|
skipped: number;
|
||||||
duplicates: number;
|
duplicates: number;
|
||||||
duplicate_samples: string[];
|
duplicate_samples: string[];
|
||||||
@@ -32,6 +33,7 @@ export namespace adif {
|
|||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.total = source["total"];
|
this.total = source["total"];
|
||||||
this.imported = source["imported"];
|
this.imported = source["imported"];
|
||||||
|
this.updated = source["updated"];
|
||||||
this.skipped = source["skipped"];
|
this.skipped = source["skipped"];
|
||||||
this.duplicates = source["duplicates"];
|
this.duplicates = source["duplicates"];
|
||||||
this.duplicate_samples = source["duplicate_samples"];
|
this.duplicate_samples = source["duplicate_samples"];
|
||||||
@@ -520,6 +522,7 @@ export namespace main {
|
|||||||
clublog_status: string;
|
clublog_status: string;
|
||||||
hrdlog_status: string;
|
hrdlog_status: string;
|
||||||
qrzcom_status: string;
|
qrzcom_status: string;
|
||||||
|
qrzcom_confirmed: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new QSLDefaults(source);
|
return new QSLDefaults(source);
|
||||||
@@ -536,6 +539,7 @@ export namespace main {
|
|||||||
this.clublog_status = source["clublog_status"];
|
this.clublog_status = source["clublog_status"];
|
||||||
this.hrdlog_status = source["hrdlog_status"];
|
this.hrdlog_status = source["hrdlog_status"];
|
||||||
this.qrzcom_status = source["qrzcom_status"];
|
this.qrzcom_status = source["qrzcom_status"];
|
||||||
|
this.qrzcom_confirmed = source["qrzcom_confirmed"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class RotatorHeading {
|
export class RotatorHeading {
|
||||||
@@ -674,6 +678,86 @@ export namespace main {
|
|||||||
this.my_pota_ref = source["my_pota_ref"];
|
this.my_pota_ref = source["my_pota_ref"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class WKMacro {
|
||||||
|
label: string;
|
||||||
|
text: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new WKMacro(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.label = source["label"];
|
||||||
|
this.text = source["text"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class WinkeyerSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
port: string;
|
||||||
|
baud: number;
|
||||||
|
wpm: number;
|
||||||
|
weight: number;
|
||||||
|
lead_in_ms: number;
|
||||||
|
tail_ms: number;
|
||||||
|
ratio: number;
|
||||||
|
farnsworth: number;
|
||||||
|
sidetone_hz: number;
|
||||||
|
mode: string;
|
||||||
|
swap: boolean;
|
||||||
|
autospace: boolean;
|
||||||
|
use_ptt: boolean;
|
||||||
|
serial_echo: boolean;
|
||||||
|
engine: string;
|
||||||
|
esc_clears_call: boolean;
|
||||||
|
send_on_type: boolean;
|
||||||
|
macros: WKMacro[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new WinkeyerSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.port = source["port"];
|
||||||
|
this.baud = source["baud"];
|
||||||
|
this.wpm = source["wpm"];
|
||||||
|
this.weight = source["weight"];
|
||||||
|
this.lead_in_ms = source["lead_in_ms"];
|
||||||
|
this.tail_ms = source["tail_ms"];
|
||||||
|
this.ratio = source["ratio"];
|
||||||
|
this.farnsworth = source["farnsworth"];
|
||||||
|
this.sidetone_hz = source["sidetone_hz"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.swap = source["swap"];
|
||||||
|
this.autospace = source["autospace"];
|
||||||
|
this.use_ptt = source["use_ptt"];
|
||||||
|
this.serial_echo = source["serial_echo"];
|
||||||
|
this.engine = source["engine"];
|
||||||
|
this.esc_clears_call = source["esc_clears_call"];
|
||||||
|
this.send_on_type = source["send_on_type"];
|
||||||
|
this.macros = this.convertValues(source["macros"], WKMacro);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1188,55 +1272,6 @@ export namespace qso {
|
|||||||
this.status = source["status"];
|
this.status = source["status"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class WorkedEntry {
|
|
||||||
id: number;
|
|
||||||
// Go type: time
|
|
||||||
qso_date: any;
|
|
||||||
band: string;
|
|
||||||
mode: string;
|
|
||||||
rst_sent?: string;
|
|
||||||
rst_rcvd?: string;
|
|
||||||
qsl_sent?: string;
|
|
||||||
qsl_rcvd?: string;
|
|
||||||
lotw_sent?: string;
|
|
||||||
lotw_rcvd?: string;
|
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
|
||||||
return new WorkedEntry(source);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
|
||||||
this.id = source["id"];
|
|
||||||
this.qso_date = this.convertValues(source["qso_date"], null);
|
|
||||||
this.band = source["band"];
|
|
||||||
this.mode = source["mode"];
|
|
||||||
this.rst_sent = source["rst_sent"];
|
|
||||||
this.rst_rcvd = source["rst_rcvd"];
|
|
||||||
this.qsl_sent = source["qsl_sent"];
|
|
||||||
this.qsl_rcvd = source["qsl_rcvd"];
|
|
||||||
this.lotw_sent = source["lotw_sent"];
|
|
||||||
this.lotw_rcvd = source["lotw_rcvd"];
|
|
||||||
}
|
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
|
||||||
if (!a) {
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
if (a.slice && a.map) {
|
|
||||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
|
||||||
} else if ("object" === typeof a) {
|
|
||||||
if (asMap) {
|
|
||||||
for (const key of Object.keys(a)) {
|
|
||||||
a[key] = new classs(a[key]);
|
|
||||||
}
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
return new classs(a);
|
|
||||||
}
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export class WorkedBefore {
|
export class WorkedBefore {
|
||||||
callsign: string;
|
callsign: string;
|
||||||
count: number;
|
count: number;
|
||||||
@@ -1247,7 +1282,7 @@ export namespace qso {
|
|||||||
bands: string[];
|
bands: string[];
|
||||||
modes: string[];
|
modes: string[];
|
||||||
band_modes: BandMode[];
|
band_modes: BandMode[];
|
||||||
entries: WorkedEntry[];
|
entries: QSO[];
|
||||||
dxcc?: number;
|
dxcc?: number;
|
||||||
dxcc_name?: string;
|
dxcc_name?: string;
|
||||||
dxcc_count: number;
|
dxcc_count: number;
|
||||||
@@ -1273,7 +1308,7 @@ export namespace qso {
|
|||||||
this.bands = source["bands"];
|
this.bands = source["bands"];
|
||||||
this.modes = source["modes"];
|
this.modes = source["modes"];
|
||||||
this.band_modes = this.convertValues(source["band_modes"], BandMode);
|
this.band_modes = this.convertValues(source["band_modes"], BandMode);
|
||||||
this.entries = this.convertValues(source["entries"], WorkedEntry);
|
this.entries = this.convertValues(source["entries"], QSO);
|
||||||
this.dxcc = source["dxcc"];
|
this.dxcc = source["dxcc"];
|
||||||
this.dxcc_name = source["dxcc_name"];
|
this.dxcc_name = source["dxcc_name"];
|
||||||
this.dxcc_count = source["dxcc_count"];
|
this.dxcc_count = source["dxcc_count"];
|
||||||
@@ -1341,3 +1376,30 @@ export namespace udp {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace winkeyer {
|
||||||
|
|
||||||
|
export class Status {
|
||||||
|
connected: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
wpm: number;
|
||||||
|
version: number;
|
||||||
|
port: string;
|
||||||
|
error?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Status(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.connected = source["connected"];
|
||||||
|
this.busy = source["busy"];
|
||||||
|
this.wpm = source["wpm"];
|
||||||
|
this.version = source["version"];
|
||||||
|
this.port = source["port"];
|
||||||
|
this.error = source["error"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/go-ole/go-ole v1.3.0
|
github.com/go-ole/go-ole v1.3.0
|
||||||
github.com/wailsapp/wails/v2 v2.11.0
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
|
go.bug.st/serial v1.7.1
|
||||||
golang.org/x/net v0.35.0
|
golang.org/x/net v0.35.0
|
||||||
golang.org/x/sys v0.45.0
|
golang.org/x/sys v0.45.0
|
||||||
golang.org/x/text v0.22.0
|
golang.org/x/text v0.22.0
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw
|
|||||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||||
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||||
|
go.bug.st/serial v1.7.1 h1:5aP8wYL0UjEYOVs3oPAGscjaSfRQLHtCvBFXNN/rwtc=
|
||||||
|
go.bug.st/serial v1.7.1/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ=
|
||||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package adif
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Loggers (notably Log4OM's UDP/exported ADIF) sometimes declare a field
|
||||||
|
// length as the CHARACTER count instead of the UTF-8 byte count, truncating
|
||||||
|
// multibyte values mid-rune. The parser must recover the full value.
|
||||||
|
func TestCharCountLengthRepair(t *testing.T) {
|
||||||
|
cases := []struct{ name, wantQTH, wantName, adi string }{
|
||||||
|
{
|
||||||
|
name: "latin",
|
||||||
|
wantQTH: "Tóalmás", // 7 chars / 9 bytes, declared 7
|
||||||
|
wantName: "Laci Budai",
|
||||||
|
adi: "<EOH>\n<CALL:5>HA5XY<QTH:7>Tóalmás<NAME:10>Laci Budai<EOR>\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cyrillic",
|
||||||
|
wantQTH: "Дзержинск", // 9 chars / 18 bytes, declared 9
|
||||||
|
wantName: "Александр Чайка", // 15 chars / 29 bytes, declared 15
|
||||||
|
adi: "<EOH>\n<CALL:6>UA3TFS<NAME:15>Александр Чайка<QTH:9>Дзержинск<EOR>\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
var got Record
|
||||||
|
if err := Parse(strings.NewReader(c.adi), func(r Record) error { got = r; return nil }); err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if got["qth"] != c.wantQTH {
|
||||||
|
t.Errorf("qth = %q, want %q", got["qth"], c.wantQTH)
|
||||||
|
}
|
||||||
|
if got["name"] != c.wantName {
|
||||||
|
t.Errorf("name = %q, want %q", got["name"], c.wantName)
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(got["name"]) || !utf8.ValidString(got["qth"]) {
|
||||||
|
t.Errorf("result not valid UTF-8: name=%q qth=%q", got["name"], got["qth"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-4
@@ -27,6 +27,13 @@ type Exporter struct {
|
|||||||
// AppName / AppVersion populate the ADIF header comments. Optional.
|
// AppName / AppVersion populate the ADIF header comments. Optional.
|
||||||
AppName string
|
AppName string
|
||||||
AppVersion string
|
AppVersion string
|
||||||
|
|
||||||
|
// IncludeAppFields controls whether application-specific fields (ADIF
|
||||||
|
// "APP_<programid>_<name>" tags, e.g. Log4OM's APP_LOG4OM_* or our own
|
||||||
|
// OpsLog extensions) are written. Leave false for a clean standard-ADIF
|
||||||
|
// export destined for another logger; set true for a full OpsLog→OpsLog
|
||||||
|
// round-trip that preserves everything.
|
||||||
|
IncludeAppFields bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportFile creates path (overwriting if it exists) and writes every QSO.
|
// ExportFile creates path (overwriting if it exists) and writes every QSO.
|
||||||
@@ -70,7 +77,7 @@ func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
|
|||||||
|
|
||||||
count := 0
|
count := 0
|
||||||
err := e.Repo.IterateAll(ctx, func(q qso.QSO) error {
|
err := e.Repo.IterateAll(ctx, func(q qso.QSO) error {
|
||||||
writeRecord(bw, q)
|
writeRecord(bw, q, e.IncludeAppFields)
|
||||||
count++
|
count++
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -84,7 +91,8 @@ func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
|
|||||||
func SingleRecordADIF(q qso.QSO) string {
|
func SingleRecordADIF(q qso.QSO) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
bw := bufio.NewWriter(&b)
|
bw := bufio.NewWriter(&b)
|
||||||
writeRecord(bw, q)
|
// Uploads target other services — keep it standard (no app-specific tags).
|
||||||
|
writeRecord(bw, q, false)
|
||||||
bw.Flush()
|
bw.Flush()
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
@@ -93,7 +101,7 @@ func SingleRecordADIF(q qso.QSO) string {
|
|||||||
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
||||||
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
||||||
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
|
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
|
||||||
func writeRecord(bw *bufio.Writer, q qso.QSO) {
|
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool) {
|
||||||
// --- Core ---
|
// --- Core ---
|
||||||
writeField(bw, "CALL", q.Callsign)
|
writeField(bw, "CALL", q.Callsign)
|
||||||
|
|
||||||
@@ -218,8 +226,15 @@ func writeRecord(bw *bufio.Writer, q qso.QSO) {
|
|||||||
writeField(bw, "NOTES", q.Notes)
|
writeField(bw, "NOTES", q.Notes)
|
||||||
|
|
||||||
// --- Extras (unpromoted ADIF fields preserved verbatim) ---
|
// --- Extras (unpromoted ADIF fields preserved verbatim) ---
|
||||||
|
// In standard mode we drop application-specific tags (APP_*) so the file
|
||||||
|
// stays portable to other loggers; in full mode they're kept for a
|
||||||
|
// lossless OpsLog round-trip.
|
||||||
for k, v := range q.Extras {
|
for k, v := range q.Extras {
|
||||||
writeField(bw, strings.ToUpper(k), v)
|
tag := strings.ToUpper(k)
|
||||||
|
if !includeApp && strings.HasPrefix(tag, "APP_") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
writeField(bw, tag, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
bw.WriteString("<EOR>\n")
|
bw.WriteString("<EOR>\n")
|
||||||
|
|||||||
+90
-1
@@ -20,6 +20,7 @@ import (
|
|||||||
type ImportResult struct {
|
type ImportResult struct {
|
||||||
Total int `json:"total"` // records found in the file
|
Total int `json:"total"` // records found in the file
|
||||||
Imported int `json:"imported"` // successfully inserted
|
Imported int `json:"imported"` // successfully inserted
|
||||||
|
Updated int `json:"updated"` // existing QSOs refreshed (update-duplicates mode)
|
||||||
Skipped int `json:"skipped"` // dropped (missing required fields)
|
Skipped int `json:"skipped"` // dropped (missing required fields)
|
||||||
Duplicates int `json:"duplicates"` // matched an existing or earlier-in-file QSO
|
Duplicates int `json:"duplicates"` // matched an existing or earlier-in-file QSO
|
||||||
DuplicateSamples []string `json:"duplicate_samples"` // up to maxDuplicateSamples human-readable IDs of skipped duplicates
|
DuplicateSamples []string `json:"duplicate_samples"` // up to maxDuplicateSamples human-readable IDs of skipped duplicates
|
||||||
@@ -36,6 +37,19 @@ type Importer struct {
|
|||||||
Repo *qso.Repo
|
Repo *qso.Repo
|
||||||
BatchSize int // 0 → 500
|
BatchSize int // 0 → 500
|
||||||
SkipDuplicates bool // when true, records matching an existing or earlier-in-file QSO are skipped; otherwise all are inserted
|
SkipDuplicates bool // when true, records matching an existing or earlier-in-file QSO are skipped; otherwise all are inserted
|
||||||
|
// UpdateDuplicates, when true, takes precedence over SkipDuplicates:
|
||||||
|
// a record matching an existing QSO MERGES its non-empty fields onto
|
||||||
|
// that QSO (refreshes QSL/confirmation statuses on re-sync) instead of
|
||||||
|
// being skipped or re-inserted.
|
||||||
|
UpdateDuplicates bool
|
||||||
|
// Enrich, when set, is called on each parsed QSO before dedup/insert.
|
||||||
|
// Used to recompute country / zones from cty.dat so a bad COUNTRY in the
|
||||||
|
// source file (common with contest loggers) is corrected on the way in.
|
||||||
|
Enrich func(*qso.QSO)
|
||||||
|
// OnProgress, when set, is called periodically with (processed, total)
|
||||||
|
// record counts so the UI can show a progress bar. total is an estimate
|
||||||
|
// from counting <EOR> tags up front.
|
||||||
|
OnProgress func(processed, total int)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportFile reads the file at path and imports it into the repo. The
|
// ImportFile reads the file at path and imports it into the repo. The
|
||||||
@@ -62,6 +76,14 @@ func (im *Importer) ImportFile(ctx context.Context, path string) (ImportResult,
|
|||||||
// Windows-1252 (É is one byte 0xC9). Pre-decoding to UTF-8 would make É
|
// Windows-1252 (É is one byte 0xC9). Pre-decoding to UTF-8 would make É
|
||||||
// two bytes, and the parser reading 7 bytes after the tag would chop the
|
// two bytes, and the parser reading 7 bytes after the tag would chop the
|
||||||
// É in half → "YAOUND" + an orphan 0xC3 byte → "YAOUND�" after JSON.
|
// É in half → "YAOUND" + an orphan 0xC3 byte → "YAOUND�" after JSON.
|
||||||
|
// ValueDecoderFor returns the per-field byte decoder appropriate for a raw
|
||||||
|
// ADIF payload: identity when it's valid UTF-8, otherwise a Windows-1252
|
||||||
|
// decoder. Exposed so non-file ingest paths (UDP auto-log from Log4OM /
|
||||||
|
// JTAlert) transcode accented NAME/QTH fields the same way file import does.
|
||||||
|
func ValueDecoderFor(data []byte) func([]byte) string {
|
||||||
|
return pickValueDecoder(data)
|
||||||
|
}
|
||||||
|
|
||||||
func pickValueDecoder(data []byte) func([]byte) string {
|
func pickValueDecoder(data []byte) func([]byte) string {
|
||||||
if utf8.Valid(data) {
|
if utf8.Valid(data) {
|
||||||
return nil // identity
|
return nil // identity
|
||||||
@@ -97,6 +119,15 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
|||||||
res := ImportResult{}
|
res := ImportResult{}
|
||||||
batch := make([]qso.QSO, 0, im.BatchSize)
|
batch := make([]qso.QSO, 0, im.BatchSize)
|
||||||
|
|
||||||
|
// Up-front record-count estimate (count <EOR> tags, case-insensitive) so
|
||||||
|
// the UI progress bar has a denominator. Cheap single scan.
|
||||||
|
total := countEOR(data)
|
||||||
|
reportProgress := func(force bool) {
|
||||||
|
if im.OnProgress != nil && (force || res.Total%200 == 0) {
|
||||||
|
im.OnProgress(res.Total, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// One upfront query for every existing dedup key — cheaper than N
|
// One upfront query for every existing dedup key — cheaper than N
|
||||||
// per-record EXISTS calls. The same map gets new keys appended as we
|
// per-record EXISTS calls. The same map gets new keys appended as we
|
||||||
// import so duplicates inside the file are caught too. Loaded
|
// import so duplicates inside the file are caught too. Loaded
|
||||||
@@ -107,6 +138,16 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
|||||||
return res, fmt.Errorf("load dedupe keys: %w", err)
|
return res, fmt.Errorf("load dedupe keys: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update-duplicates mode needs the existing row's ID per key so it can
|
||||||
|
// fetch, merge and write it back. Loaded only when needed (extra query).
|
||||||
|
var keyIDs map[string]int64
|
||||||
|
if im.UpdateDuplicates {
|
||||||
|
keyIDs, err = im.Repo.DedupeKeyIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("load dedupe ids: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
flush := func() error {
|
flush := func() error {
|
||||||
if len(batch) == 0 {
|
if len(batch) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -119,6 +160,7 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
|||||||
|
|
||||||
err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error {
|
err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error {
|
||||||
res.Total++
|
res.Total++
|
||||||
|
reportProgress(false)
|
||||||
q, ok := recordToQSO(rec)
|
q, ok := recordToQSO(rec)
|
||||||
if !ok {
|
if !ok {
|
||||||
res.Skipped++
|
res.Skipped++
|
||||||
@@ -128,6 +170,9 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if im.Enrich != nil {
|
||||||
|
im.Enrich(&q)
|
||||||
|
}
|
||||||
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
|
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
|
||||||
if _, dup := seen[key]; dup {
|
if _, dup := seen[key]; dup {
|
||||||
res.Duplicates++
|
res.Duplicates++
|
||||||
@@ -138,6 +183,28 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
|||||||
q.QSODate.UTC().Format("2006-01-02 15:04"),
|
q.QSODate.UTC().Format("2006-01-02 15:04"),
|
||||||
q.Band, q.Mode))
|
q.Band, q.Mode))
|
||||||
}
|
}
|
||||||
|
if im.UpdateDuplicates {
|
||||||
|
if id, ok := keyIDs[key]; ok {
|
||||||
|
existing, gerr := im.Repo.GetByID(ctx, id)
|
||||||
|
if gerr != nil {
|
||||||
|
if len(res.Errors) < maxErrors {
|
||||||
|
res.Errors = append(res.Errors,
|
||||||
|
fmt.Sprintf("record %d (%s): load existing: %v", res.Total, q.Callsign, gerr))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
qso.MergeNonZero(&existing, q)
|
||||||
|
if uerr := im.Repo.Update(ctx, existing); uerr != nil {
|
||||||
|
if len(res.Errors) < maxErrors {
|
||||||
|
res.Errors = append(res.Errors,
|
||||||
|
fmt.Sprintf("record %d (%s): update: %v", res.Total, q.Callsign, uerr))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
res.Updated++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if im.SkipDuplicates {
|
if im.SkipDuplicates {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -159,9 +226,28 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
|||||||
if err := flush(); err != nil {
|
if err := flush(); err != nil {
|
||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
|
reportProgress(true) // final 100%
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// countEOR estimates the record count by counting case-insensitive <EOR>
|
||||||
|
// tags. Used only to give the import progress bar a denominator.
|
||||||
|
func countEOR(data []byte) int {
|
||||||
|
n := 0
|
||||||
|
for i := 0; i+4 <= len(data); i++ {
|
||||||
|
if data[i] != '<' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (data[i+1] == 'e' || data[i+1] == 'E') &&
|
||||||
|
(data[i+2] == 'o' || data[i+2] == 'O') &&
|
||||||
|
(data[i+3] == 'r' || data[i+3] == 'R') &&
|
||||||
|
(i+4 < len(data) && (data[i+4] == '>' || data[i+4] == ':')) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
// adifPromoted lists every lowercase ADIF tag that maps to a promoted column.
|
// adifPromoted lists every lowercase ADIF tag that maps to a promoted column.
|
||||||
// Anything not in this set ends up in Extras.
|
// Anything not in this set ends up in Extras.
|
||||||
var adifPromoted = stringSet(
|
var adifPromoted = stringSet(
|
||||||
@@ -178,7 +264,7 @@ var adifPromoted = stringSet(
|
|||||||
"age", "lat", "lon", "rig", "ant",
|
"age", "lat", "lon", "rig", "ant",
|
||||||
// QSL
|
// QSL
|
||||||
"qsl_sent", "qsl_rcvd",
|
"qsl_sent", "qsl_rcvd",
|
||||||
"qslsdate", "qslrdate", "qsl_via", "qslmsg", "qslmsg_rcvd",
|
"qslsdate", "qslrdate", "qsl_via", "qsl_sent_via", "qsl_rcvd_via", "qslmsg", "qslmsg_rcvd",
|
||||||
"lotw_qsl_sent", "lotw_qsl_rcvd", "lotw_qslsdate", "lotw_qslrdate",
|
"lotw_qsl_sent", "lotw_qsl_rcvd", "lotw_qslsdate", "lotw_qslrdate",
|
||||||
"eqsl_qsl_sent", "eqsl_qsl_rcvd", "eqsl_qslsdate", "eqsl_qslrdate",
|
"eqsl_qsl_sent", "eqsl_qsl_rcvd", "eqsl_qslsdate", "eqsl_qslrdate",
|
||||||
"clublog_qso_upload_date", "clublog_qso_upload_status",
|
"clublog_qso_upload_date", "clublog_qso_upload_status",
|
||||||
@@ -300,6 +386,9 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
|||||||
q.QSLSentDate = rec["qslsdate"]
|
q.QSLSentDate = rec["qslsdate"]
|
||||||
q.QSLRcvdDate = rec["qslrdate"]
|
q.QSLRcvdDate = rec["qslrdate"]
|
||||||
q.QSLVia = rec["qsl_via"]
|
q.QSLVia = rec["qsl_via"]
|
||||||
|
if q.QSLVia == "" { // many loggers (Log4OM) write QSL_SENT_VIA instead
|
||||||
|
q.QSLVia = rec["qsl_sent_via"]
|
||||||
|
}
|
||||||
q.QSLMsg = rec["qslmsg"]
|
q.QSLMsg = rec["qslmsg"]
|
||||||
q.QSLMsgRcvd = rec["qslmsg_rcvd"]
|
q.QSLMsgRcvd = rec["qslmsg_rcvd"]
|
||||||
q.LOTWSent = rec["lotw_qsl_sent"]
|
q.LOTWSent = rec["lotw_qsl_sent"]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Record is a single ADIF record. Keys are lowercased field names.
|
// Record is a single ADIF record. Keys are lowercased field names.
|
||||||
@@ -83,6 +84,17 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
|
|||||||
if _, err := io.ReadFull(br, val); err != nil {
|
if _, err := io.ReadFull(br, val); err != nil {
|
||||||
return fmt.Errorf("read field %s: %w", name, err)
|
return fmt.Errorf("read field %s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
// Repair character-count lengths. The ADIF spec says LENGTH is a
|
||||||
|
// byte count, but some loggers (notably Log4OM's UDP "ADIF
|
||||||
|
// message") write the CHARACTER count instead. For UTF-8 values
|
||||||
|
// with accented chars that truncates mid-rune — e.g. "<QTH:7>
|
||||||
|
// Tóalmás" is 9 bytes but says 7, leaving an orphan byte that
|
||||||
|
// renders as "Tóalm�". When we're in UTF-8 mode (no Windows-1252
|
||||||
|
// decoder) and the naive byte read isn't valid UTF-8, keep reading
|
||||||
|
// until the value holds `length` whole runes (or the next tag).
|
||||||
|
if decodeValue == nil && !utf8.Valid(val) {
|
||||||
|
val = extendToRunes(br, val, length)
|
||||||
|
}
|
||||||
if headerDone && name != "" {
|
if headerDone && name != "" {
|
||||||
if decodeValue != nil {
|
if decodeValue != nil {
|
||||||
rec[name] = decodeValue(val)
|
rec[name] = decodeValue(val)
|
||||||
@@ -94,6 +106,37 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extendToRunes recovers a value whose declared length was a character count
|
||||||
|
// rather than a byte count. `have` holds the first `wantRunes` BYTES of the
|
||||||
|
// value, which turned out to be invalid UTF-8 (a multibyte rune was cut). We
|
||||||
|
// append bytes from br until the value holds `wantRunes` complete runes — or
|
||||||
|
// until the next '<' (start of the following tag) / EOF, so we never cross
|
||||||
|
// into another field. Capped so a genuinely-corrupt value can't run away.
|
||||||
|
func extendToRunes(br *bufio.Reader, have []byte, wantRunes int) []byte {
|
||||||
|
const maxExtra = 8 // at most ~4 extra bytes/rune for the few cut runes
|
||||||
|
limit := len(have) + maxExtra*wantRunes + maxExtra
|
||||||
|
for len(have) < limit {
|
||||||
|
// Stop only when the value is complete UTF-8 (no partial trailing
|
||||||
|
// rune) AND holds enough runes. Checking utf8.RuneCount alone is a
|
||||||
|
// trap: a trailing orphan lead byte (e.g. the D0 of a cut Cyrillic
|
||||||
|
// "а") counts as one rune, so the loop would stop one continuation
|
||||||
|
// byte short → "Чайк�". Requiring utf8.Valid forces us to read it.
|
||||||
|
if utf8.Valid(have) && utf8.RuneCount(have) >= wantRunes {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
b, err := br.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if b == '<' {
|
||||||
|
_ = br.UnreadByte() // belongs to the next tag — leave it
|
||||||
|
break
|
||||||
|
}
|
||||||
|
have = append(have, b)
|
||||||
|
}
|
||||||
|
return have
|
||||||
|
}
|
||||||
|
|
||||||
// parseSpec splits "callsign:5", "callsign:5:S" or "eor" into name and length.
|
// parseSpec splits "callsign:5", "callsign:5:S" or "eor" into name and length.
|
||||||
// name is lowercased; length is 0 for control tags or when missing.
|
// name is lowercased; length is 0 for control tags or when missing.
|
||||||
func parseSpec(spec string) (name string, length int) {
|
func parseSpec(spec string) (name string, length int) {
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ const (
|
|||||||
//
|
//
|
||||||
// QRZ.com → APIKey, ForceStationCallsign
|
// QRZ.com → APIKey, ForceStationCallsign
|
||||||
// Club Log → Email, Password, Callsign, APIKey
|
// Club Log → Email, Password, Callsign, APIKey
|
||||||
// LoTW → TQSLPath, StationLocation, KeyPassword (signs+uploads via TQSL)
|
// LoTW → TQSLPath, StationLocation, ForceStationCallsign, KeyPassword
|
||||||
|
// (signs+uploads via TQSL; ForceStationCallsign overrides
|
||||||
|
// STATION_CALLSIGN so one cert can sign F4BPO / F4BPO/P / TM2Q)
|
||||||
//
|
//
|
||||||
// AutoUpload + UploadMode are common to all (timing is per-service, so the
|
// AutoUpload + UploadMode are common to all (timing is per-service, so the
|
||||||
// user can run e.g. Club Log immediate and QRZ delayed).
|
// user can run e.g. Club Log immediate and QRZ delayed).
|
||||||
@@ -63,7 +65,7 @@ type ServiceConfig struct {
|
|||||||
Username string `json:"username"` // LoTW website login (for confirmation download)
|
Username string `json:"username"` // LoTW website login (for confirmation download)
|
||||||
Password string `json:"password"` // Club Log account / LoTW website password
|
Password string `json:"password"` // Club Log account / LoTW website password
|
||||||
Callsign string `json:"callsign"` // Club Log logbook (owner) callsign
|
Callsign string `json:"callsign"` // Club Log logbook (owner) callsign
|
||||||
ForceStationCallsign string `json:"force_station_callsign"` // QRZ
|
ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN
|
||||||
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
||||||
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
||||||
KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional)
|
KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional)
|
||||||
|
|||||||
@@ -196,7 +196,8 @@ func (m *Manager) flushLoTWBatch(ids []int64, cfg ServiceConfig) int {
|
|||||||
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(ServiceLoTW, id) {
|
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(ServiceLoTW, id) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if rec, ok := m.deps.BuildADIF(id, ""); ok {
|
// Override STATION_CALLSIGN so /P etc. signs against the base cert.
|
||||||
|
if rec, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign); ok {
|
||||||
records = append(records, rec)
|
records = append(records, rec)
|
||||||
kept = append(kept, id)
|
kept = append(kept, id)
|
||||||
}
|
}
|
||||||
@@ -259,8 +260,9 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
}
|
}
|
||||||
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
|
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
|
||||||
case ServiceLoTW:
|
case ServiceLoTW:
|
||||||
// LoTW signs the QSO's own station call via TQSL — no override.
|
// LoTW signs via TQSL; an optional force-call overrides STATION_CALLSIGN
|
||||||
record, ok := m.deps.BuildADIF(id, "")
|
// so the same cert can sign F4BPO, F4BPO/P, TM2Q… per profile.
|
||||||
|
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
||||||
if !ok {
|
if !ok {
|
||||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
return false
|
return false
|
||||||
|
|||||||
+45
-44
@@ -6,10 +6,39 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MergeNonZero copies every non-zero field of src onto dst. Zero-value src
|
||||||
|
// fields (empty string, nil pointer, zero time, zero number) are skipped so
|
||||||
|
// existing data is preserved. Maps (Extras) are merged key-by-key rather than
|
||||||
|
// replaced. Because an imported QSO has ID==0 and CreatedAt zero, dst's
|
||||||
|
// identity is naturally preserved. Used by the importer's "update duplicates"
|
||||||
|
// mode so re-importing an ADIF refreshes QSL/confirmation statuses without
|
||||||
|
// clobbering fields the source file doesn't carry.
|
||||||
|
func MergeNonZero(dst *QSO, src QSO) {
|
||||||
|
dv := reflect.ValueOf(dst).Elem()
|
||||||
|
sv := reflect.ValueOf(src)
|
||||||
|
for i := 0; i < dv.NumField(); i++ {
|
||||||
|
df, sf := dv.Field(i), sv.Field(i)
|
||||||
|
if !df.CanSet() || sf.IsZero() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sf.Kind() == reflect.Map {
|
||||||
|
if df.IsNil() {
|
||||||
|
df.Set(reflect.MakeMap(sf.Type()))
|
||||||
|
}
|
||||||
|
for _, k := range sf.MapKeys() {
|
||||||
|
df.SetMapIndex(k, sf.MapIndex(k))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
df.Set(sf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// QSO represents a contact. Fields are aligned on ADIF naming for
|
// QSO represents a contact. Fields are aligned on ADIF naming for
|
||||||
// import/export. Pointers are used to distinguish "absent" from "zero".
|
// import/export. Pointers are used to distinguish "absent" from "zero".
|
||||||
// Anything in ADIF that is not a promoted column lands in Extras.
|
// Anything in ADIF that is not a promoted column lands in Extras.
|
||||||
@@ -569,7 +598,7 @@ type WorkedBefore struct {
|
|||||||
Bands []string `json:"bands"` // distinct bands for this call
|
Bands []string `json:"bands"` // distinct bands for this call
|
||||||
Modes []string `json:"modes"` // distinct modes for this call
|
Modes []string `json:"modes"` // distinct modes for this call
|
||||||
BandModes []BandMode `json:"band_modes"` // distinct (band, mode) pairs
|
BandModes []BandMode `json:"band_modes"` // distinct (band, mode) pairs
|
||||||
Entries []WorkedEntry `json:"entries"` // up to maxWorkedEntries most recent
|
Entries []QSO `json:"entries"` // up to maxWorkedEntries most recent (full records)
|
||||||
|
|
||||||
// --- Per-DXCC entity (populated when DXCC is known) ---
|
// --- Per-DXCC entity (populated when DXCC is known) ---
|
||||||
DXCC int `json:"dxcc,omitempty"`
|
DXCC int `json:"dxcc,omitempty"`
|
||||||
@@ -615,19 +644,6 @@ type BandMode struct {
|
|||||||
|
|
||||||
// WorkedEntry is one prior contact row, lean enough to ship to the UI for
|
// WorkedEntry is one prior contact row, lean enough to ship to the UI for
|
||||||
// rendering a recent-contacts mini-list.
|
// rendering a recent-contacts mini-list.
|
||||||
type WorkedEntry struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
QSODate time.Time `json:"qso_date"`
|
|
||||||
Band string `json:"band"`
|
|
||||||
Mode string `json:"mode"`
|
|
||||||
RSTSent string `json:"rst_sent,omitempty"`
|
|
||||||
RSTRcvd string `json:"rst_rcvd,omitempty"`
|
|
||||||
QSLSent string `json:"qsl_sent,omitempty"`
|
|
||||||
QSLRcvd string `json:"qsl_rcvd,omitempty"`
|
|
||||||
LOTWSent string `json:"lotw_sent,omitempty"`
|
|
||||||
LOTWRcvd string `json:"lotw_rcvd,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxWorkedEntries = 50
|
const maxWorkedEntries = 50
|
||||||
|
|
||||||
// WorkedBefore returns aggregated history at both callsign and DXCC level.
|
// WorkedBefore returns aggregated history at both callsign and DXCC level.
|
||||||
@@ -640,7 +656,7 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
|||||||
Bands: []string{},
|
Bands: []string{},
|
||||||
Modes: []string{},
|
Modes: []string{},
|
||||||
BandModes: []BandMode{},
|
BandModes: []BandMode{},
|
||||||
Entries: []WorkedEntry{},
|
Entries: []QSO{},
|
||||||
DXCCBands: []string{},
|
DXCCBands: []string{},
|
||||||
DXCCModes: []string{},
|
DXCCModes: []string{},
|
||||||
DXCCBandModes: []BandMode{},
|
DXCCBandModes: []BandMode{},
|
||||||
@@ -655,9 +671,9 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
|||||||
return wb, fmt.Errorf("count worked: %w", err)
|
return wb, fmt.Errorf("count worked: %w", err)
|
||||||
}
|
}
|
||||||
if wb.Count > 0 {
|
if wb.Count > 0 {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
// Pull the full QSO records (same columns as the Recent QSOs list) so
|
||||||
SELECT id, qso_date, band, mode, rst_sent, rst_rcvd,
|
// the Worked-before grid can offer the same rich column picker.
|
||||||
qsl_sent, qsl_rcvd, lotw_sent, lotw_rcvd
|
rows, err := r.db.QueryContext(ctx, `SELECT `+selectCols+`
|
||||||
FROM qso WHERE upper(trim(callsign)) = ?
|
FROM qso WHERE upper(trim(callsign)) = ?
|
||||||
ORDER BY qso_date DESC, id DESC
|
ORDER BY qso_date DESC, id DESC
|
||||||
LIMIT ?`, wb.Callsign, maxWorkedEntries)
|
LIMIT ?`, wb.Callsign, maxWorkedEntries)
|
||||||
@@ -668,40 +684,25 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
|||||||
modesSet := map[string]struct{}{}
|
modesSet := map[string]struct{}{}
|
||||||
bmSet := map[string]BandMode{}
|
bmSet := map[string]BandMode{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var (
|
q, err := scanQSO(rows)
|
||||||
e WorkedEntry
|
if err != nil {
|
||||||
dateStr string
|
|
||||||
band, mode sql.NullString
|
|
||||||
rstS, rstR, qslS, qslR, lotwS, lotwR sql.NullString
|
|
||||||
)
|
|
||||||
if err := rows.Scan(&e.ID, &dateStr, &band, &mode,
|
|
||||||
&rstS, &rstR, &qslS, &qslR, &lotwS, &lotwR); err != nil {
|
|
||||||
rows.Close()
|
rows.Close()
|
||||||
return wb, fmt.Errorf("scan worked: %w", err)
|
return wb, fmt.Errorf("scan worked: %w", err)
|
||||||
}
|
}
|
||||||
e.QSODate = parseTimeLoose(dateStr)
|
wb.Entries = append(wb.Entries, q)
|
||||||
e.Band = band.String
|
if q.Band != "" {
|
||||||
e.Mode = mode.String
|
bandsSet[q.Band] = struct{}{}
|
||||||
e.RSTSent = rstS.String
|
|
||||||
e.RSTRcvd = rstR.String
|
|
||||||
e.QSLSent = qslS.String
|
|
||||||
e.QSLRcvd = qslR.String
|
|
||||||
e.LOTWSent = lotwS.String
|
|
||||||
e.LOTWRcvd = lotwR.String
|
|
||||||
wb.Entries = append(wb.Entries, e)
|
|
||||||
if e.Band != "" {
|
|
||||||
bandsSet[e.Band] = struct{}{}
|
|
||||||
}
|
}
|
||||||
if e.Mode != "" {
|
if q.Mode != "" {
|
||||||
modesSet[e.Mode] = struct{}{}
|
modesSet[q.Mode] = struct{}{}
|
||||||
}
|
}
|
||||||
if e.Band != "" && e.Mode != "" {
|
if q.Band != "" && q.Mode != "" {
|
||||||
bmSet[e.Band+"|"+e.Mode] = BandMode{Band: e.Band, Mode: e.Mode}
|
bmSet[q.Band+"|"+q.Mode] = BandMode{Band: q.Band, Mode: q.Mode}
|
||||||
}
|
}
|
||||||
if wb.Last.IsZero() {
|
if wb.Last.IsZero() {
|
||||||
wb.Last = e.QSODate
|
wb.Last = q.QSODate
|
||||||
}
|
}
|
||||||
wb.First = e.QSODate
|
wb.First = q.QSODate
|
||||||
}
|
}
|
||||||
rows.Close()
|
rows.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
// Package winkeyer drives a K1EL WinKeyer (WK1/WK2/WK3) CW keyer over a
|
||||||
|
// serial port — the same hardware Log4OM, N1MM and fldigi talk to. It opens
|
||||||
|
// the host-mode interface, applies the operator's keying parameters (speed,
|
||||||
|
// weight, lead-in/tail, sidetone, paddle mode…), sends arbitrary text as
|
||||||
|
// Morse, and aborts mid-message on demand.
|
||||||
|
//
|
||||||
|
// Protocol reference: K1EL "WinKeyer USB / WK3 Interface Description". The
|
||||||
|
// host link is 1200 baud 8N1. Bytes 0x00–0x1F are commands; printable ASCII
|
||||||
|
// is keyed directly. The device streams status bytes back (busy/idle, the
|
||||||
|
// speed-pot value, and an echo of each character as it's sent) which we
|
||||||
|
// surface to the UI via the OnStatus callback.
|
||||||
|
package winkeyer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mode selects the paddle keying mode (WinKey "mode register" low bits).
|
||||||
|
type Mode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModeIambicB Mode = "iambic_b"
|
||||||
|
ModeIambicA Mode = "iambic_a"
|
||||||
|
ModeUltimatic Mode = "ultimatic"
|
||||||
|
ModeBug Mode = "bug"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is the keyer configuration the UI persists and applies on connect.
|
||||||
|
type Config struct {
|
||||||
|
Port string `json:"port"` // e.g. "COM6"
|
||||||
|
Baud int `json:"baud"` // 1200 for WK2, also fine for WK3
|
||||||
|
WPM int `json:"wpm"` // 5..99
|
||||||
|
Weight int `json:"weight"` // 10..90, 50 = normal
|
||||||
|
LeadInMs int `json:"lead_in_ms"` // PTT lead-in, 10 ms units sent to device
|
||||||
|
TailMs int `json:"tail_ms"` // PTT tail
|
||||||
|
Ratio int `json:"ratio"` // dah/dit ratio 33..66 (50 = 3:1)
|
||||||
|
Farnsworth int `json:"farnsworth"` // Farnsworth WPM (0 = off)
|
||||||
|
Sidetone int `json:"sidetone_hz"` // 0 = off; else target Hz (mapped to WK code)
|
||||||
|
Mode Mode `json:"mode"` // paddle mode
|
||||||
|
Swap bool `json:"swap"` // swap dit/dah paddles
|
||||||
|
AutoSpace bool `json:"autospace"` // auto letter-space
|
||||||
|
UsePTT bool `json:"use_ptt"` // key PTT (Key/PTT output)
|
||||||
|
SerialEcho bool `json:"serial_echo"` // device echoes sent chars back to host
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) normalised() Config {
|
||||||
|
if c.Baud <= 0 {
|
||||||
|
c.Baud = 1200
|
||||||
|
}
|
||||||
|
if c.WPM < 5 {
|
||||||
|
c.WPM = 20
|
||||||
|
}
|
||||||
|
if c.WPM > 99 {
|
||||||
|
c.WPM = 99
|
||||||
|
}
|
||||||
|
if c.Weight < 10 || c.Weight > 90 {
|
||||||
|
c.Weight = 50
|
||||||
|
}
|
||||||
|
if c.Ratio < 33 || c.Ratio > 66 {
|
||||||
|
c.Ratio = 50
|
||||||
|
}
|
||||||
|
switch c.Mode {
|
||||||
|
case ModeIambicA, ModeIambicB, ModeUltimatic, ModeBug:
|
||||||
|
default:
|
||||||
|
c.Mode = ModeIambicB
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is pushed to the UI whenever the link state or keyer activity changes.
|
||||||
|
type Status struct {
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Busy bool `json:"busy"` // device is currently sending CW
|
||||||
|
WPM int `json:"wpm"` // current speed (tracks the speed pot)
|
||||||
|
Version int `json:"version"` // host firmware version byte
|
||||||
|
Port string `json:"port"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager owns the serial link. Safe for concurrent use.
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
port serial.Port
|
||||||
|
cfg Config
|
||||||
|
status Status
|
||||||
|
stopRead chan struct{}
|
||||||
|
doneRead chan struct{}
|
||||||
|
|
||||||
|
onStatus func(Status)
|
||||||
|
onEcho func(string) // chars the device echoes back as it keys them
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(onStatus func(Status), onEcho func(string)) *Manager {
|
||||||
|
return &Manager{onStatus: onStatus, onEcho: onEcho}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPorts returns the available serial port names (COM3, COM6, …).
|
||||||
|
func ListPorts() ([]string, error) {
|
||||||
|
ports, err := serial.GetPortsList()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ports, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status returns a snapshot.
|
||||||
|
func (m *Manager) Snapshot() Status {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) emit() {
|
||||||
|
if m.onStatus != nil {
|
||||||
|
m.onStatus(m.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect opens the port, performs the host-open handshake and applies cfg.
|
||||||
|
func (m *Manager) Connect(cfg Config) error {
|
||||||
|
cfg = cfg.normalised()
|
||||||
|
if strings.TrimSpace(cfg.Port) == "" {
|
||||||
|
return fmt.Errorf("winkeyer: no serial port selected")
|
||||||
|
}
|
||||||
|
m.Disconnect() // drop any existing link first
|
||||||
|
|
||||||
|
p, err := serial.Open(cfg.Port, &serial.Mode{
|
||||||
|
BaudRate: cfg.Baud,
|
||||||
|
DataBits: 8,
|
||||||
|
Parity: serial.NoParity,
|
||||||
|
StopBits: serial.OneStopBit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("winkeyer: open %s: %w", cfg.Port, err)
|
||||||
|
}
|
||||||
|
_ = p.SetReadTimeout(200 * time.Millisecond)
|
||||||
|
|
||||||
|
// Host Open: <0x00 0x02>. Device replies with its firmware version byte.
|
||||||
|
if _, err := p.Write([]byte{0x00, 0x02}); err != nil {
|
||||||
|
_ = p.Close()
|
||||||
|
return fmt.Errorf("winkeyer: host open: %w", err)
|
||||||
|
}
|
||||||
|
ver := 0
|
||||||
|
buf := make([]byte, 16)
|
||||||
|
_ = p.SetReadTimeout(1 * time.Second)
|
||||||
|
if n, _ := p.Read(buf); n > 0 {
|
||||||
|
ver = int(buf[0])
|
||||||
|
}
|
||||||
|
_ = p.SetReadTimeout(200 * time.Millisecond)
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.port = p
|
||||||
|
m.cfg = cfg
|
||||||
|
m.status = Status{Connected: true, WPM: cfg.WPM, Version: ver, Port: cfg.Port}
|
||||||
|
m.stopRead = make(chan struct{})
|
||||||
|
m.doneRead = make(chan struct{})
|
||||||
|
stop, done := m.stopRead, m.doneRead
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
applog.Printf("winkeyer: connected on %s (firmware byte %d)", cfg.Port, ver)
|
||||||
|
go m.readLoop(p, stop, done)
|
||||||
|
|
||||||
|
if err := m.applyConfig(cfg); err != nil {
|
||||||
|
applog.Printf("winkeyer: applyConfig: %v", err)
|
||||||
|
}
|
||||||
|
m.emit()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyConfig pushes the keying parameters to the device.
|
||||||
|
func (m *Manager) applyConfig(c Config) error {
|
||||||
|
cmds := [][]byte{
|
||||||
|
{0x0E, modeRegister(c)}, // set mode register (paddle mode, swap, autospace…)
|
||||||
|
{0x02, byte(c.WPM)}, // set speed (WPM)
|
||||||
|
{0x03, byte(c.Weight)}, // set weighting
|
||||||
|
{0x04, byte(c.LeadInMs / 10), byte(c.TailMs / 10)}, // PTT lead-in / tail (10 ms units)
|
||||||
|
{0x11, byte(c.Ratio)}, // set dit/dah ratio
|
||||||
|
}
|
||||||
|
// Sidetone: <0x01 n>. Bit6 enables, low nibble selects the pitch divisor.
|
||||||
|
cmds = append(cmds, []byte{0x01, sidetoneCode(c.Sidetone)})
|
||||||
|
if c.Farnsworth > 0 {
|
||||||
|
cmds = append(cmds, []byte{0x0D, byte(c.Farnsworth)}) // Farnsworth WPM
|
||||||
|
}
|
||||||
|
for _, cmd := range cmds {
|
||||||
|
if err := m.write(cmd); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// modeRegister builds the WinKey mode-register byte (command 0x0E).
|
||||||
|
// bits 1..0 : paddle mode (00 Iambic-B, 01 Iambic-A, 10 Ultimatic, 11 Bug)
|
||||||
|
// bit 3 : paddle swap
|
||||||
|
// bit 0/... : (autospace is bit 0 of a separate group on some firmwares)
|
||||||
|
// We keep to the widely-compatible WK2 layout.
|
||||||
|
func modeRegister(c Config) byte {
|
||||||
|
var b byte
|
||||||
|
switch c.Mode {
|
||||||
|
case ModeIambicB:
|
||||||
|
b |= 0x00
|
||||||
|
case ModeIambicA:
|
||||||
|
b |= 0x10
|
||||||
|
case ModeUltimatic:
|
||||||
|
b |= 0x20
|
||||||
|
case ModeBug:
|
||||||
|
b |= 0x30
|
||||||
|
}
|
||||||
|
if c.Swap {
|
||||||
|
b |= 0x08 // bit3 paddle swap
|
||||||
|
}
|
||||||
|
if c.AutoSpace {
|
||||||
|
b |= 0x02 // bit1 autospace
|
||||||
|
}
|
||||||
|
if c.SerialEcho {
|
||||||
|
b |= 0x04 // bit2 serial echoback — device echoes keyed chars to host
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// sidetoneCode maps a target Hz to the WinKey sidetone control byte. 0 = off.
|
||||||
|
func sidetoneCode(hz int) byte {
|
||||||
|
if hz <= 0 {
|
||||||
|
return 0x00 // sidetone off
|
||||||
|
}
|
||||||
|
// WK sidetone = 4000 / n Hz, n = 1..10. Pick the nearest n, enable bit6.
|
||||||
|
best, bestErr := 1, 1<<30
|
||||||
|
for n := 1; n <= 10; n++ {
|
||||||
|
f := 4000 / n
|
||||||
|
e := f - hz
|
||||||
|
if e < 0 {
|
||||||
|
e = -e
|
||||||
|
}
|
||||||
|
if e < bestErr {
|
||||||
|
bestErr, best = e, n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0x80 | byte(best) // bit7 paddle-only sidetone on; low nibble = divisor
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSpeed changes the WPM live (command 0x02).
|
||||||
|
func (m *Manager) SetSpeed(wpm int) error {
|
||||||
|
if wpm < 5 {
|
||||||
|
wpm = 5
|
||||||
|
}
|
||||||
|
if wpm > 99 {
|
||||||
|
wpm = 99
|
||||||
|
}
|
||||||
|
if err := m.write([]byte{0x02, byte(wpm)}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.cfg.WPM = wpm
|
||||||
|
m.status.WPM = wpm
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.emit()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowedCW is the set of characters WinKey can key (everything else dropped).
|
||||||
|
const allowedCW = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 .,?/=+-:();\"'@"
|
||||||
|
|
||||||
|
// Send keys the given text as Morse. The text is upper-cased and filtered to
|
||||||
|
// keyable characters. Non-keyable input is silently dropped.
|
||||||
|
func (m *Manager) Send(text string) error {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToUpper(text) {
|
||||||
|
if strings.ContainsRune(allowedCW, r) {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := b.String()
|
||||||
|
if out == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.write([]byte(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop aborts the current message and clears the keyer buffer (command 0x0A).
|
||||||
|
func (m *Manager) Stop() error {
|
||||||
|
return m.write([]byte{0x0A})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backspace removes the most recent character from the keyer's send buffer,
|
||||||
|
// IF it hasn't been keyed yet (command 0x08). Used by "send on typing" mode
|
||||||
|
// so a fast typo can be corrected before it goes on the air.
|
||||||
|
func (m *Manager) Backspace() error {
|
||||||
|
return m.write([]byte{0x08})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) write(b []byte) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
p := m.port
|
||||||
|
m.mu.Unlock()
|
||||||
|
if p == nil {
|
||||||
|
return fmt.Errorf("winkeyer: not connected")
|
||||||
|
}
|
||||||
|
_, err := p.Write(b)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect sends Host Close and releases the port.
|
||||||
|
func (m *Manager) Disconnect() {
|
||||||
|
m.mu.Lock()
|
||||||
|
p := m.port
|
||||||
|
stop, done := m.stopRead, m.doneRead
|
||||||
|
m.port = nil
|
||||||
|
m.stopRead = nil
|
||||||
|
m.doneRead = nil
|
||||||
|
connected := m.status.Connected
|
||||||
|
m.status = Status{Connected: false}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if p != nil {
|
||||||
|
_, _ = p.Write([]byte{0x00, 0x03}) // Host Close
|
||||||
|
_ = p.Close()
|
||||||
|
}
|
||||||
|
if stop != nil {
|
||||||
|
close(stop)
|
||||||
|
}
|
||||||
|
if done != nil {
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
if connected {
|
||||||
|
applog.Printf("winkeyer: disconnected")
|
||||||
|
m.emit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLoop drains device→host status bytes. WK status frames have bit7 set
|
||||||
|
// (0xC0 + flags); 0x80–0xBF carry the speed-pot value; printable bytes are
|
||||||
|
// the echo of characters being sent. We track busy/idle and the speed pot.
|
||||||
|
func (m *Manager) readLoop(p serial.Port, stop, done chan struct{}) {
|
||||||
|
defer close(done)
|
||||||
|
buf := make([]byte, 64)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
n, err := p.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
// Timeout is normal (no data); a real error ends the loop.
|
||||||
|
if isTimeout(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
b := buf[i]
|
||||||
|
switch {
|
||||||
|
case b&0xC0 == 0xC0: // status byte
|
||||||
|
busy := b&0x04 != 0 // bit2 = busy (sending)
|
||||||
|
m.mu.Lock()
|
||||||
|
changed := m.status.Busy != busy
|
||||||
|
m.status.Busy = busy
|
||||||
|
m.mu.Unlock()
|
||||||
|
if changed {
|
||||||
|
m.emit()
|
||||||
|
}
|
||||||
|
case b&0xC0 == 0x80: // speed-pot value: 0x80 | (wpm-min)
|
||||||
|
// Reported relative to the configured pot range; surfaced as-is.
|
||||||
|
default:
|
||||||
|
// Echo of a keyed character (serial echo). Surface printable
|
||||||
|
// ones so the UI can show the text as it's transmitted.
|
||||||
|
if b >= 0x20 && b < 0x7F && m.onEcho != nil {
|
||||||
|
m.onEcho(string(rune(b)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTimeout(err error) bool {
|
||||||
|
type timeout interface{ Timeout() bool }
|
||||||
|
if t, ok := err.(timeout); ok {
|
||||||
|
return t.Timeout()
|
||||||
|
}
|
||||||
|
return strings.Contains(strings.ToLower(err.Error()), "timeout")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user