diff --git a/app.go b/app.go index 011cc68..bfa0e1d 100644 --- a/app.go +++ b/app.go @@ -28,6 +28,7 @@ import ( "hamlog/internal/profile" "hamlog/internal/qso" "hamlog/internal/rotator/pst" + "hamlog/internal/winkeyer" "hamlog/internal/settings" wruntime "github.com/wailsapp/wails/v2/pkg/runtime" @@ -72,6 +73,27 @@ const ( keyRotatorPort = "rotator.port" 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 keyBackupEnabled = "backup.enabled" @@ -89,6 +111,7 @@ const ( keyQSLDefaultClublogStatus = "qsl.clublog_status" keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status" keyQSLDefaultQRZComStatus = "qsl.qrzcom_status" + keyQSLDefaultQRZComCfm = "qsl.qrzcom_confirmed" // External services (logbook upload). QRZ.com first; Clublog / LoTW // will add their own keys under the same extsvc.* prefix. @@ -106,6 +129,7 @@ const ( keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path" 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" keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag" keyExtLoTWWriteLog = "extsvc.lotw.write_log" @@ -131,6 +155,7 @@ type QSLDefaults struct { ClublogStatus string `json:"clublog_status"` HRDLogStatus string `json:"hrdlog_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 @@ -263,6 +288,7 @@ type App struct { udp *udp.Manager udpRepo *udp.Repo extsvc *extsvc.Manager + winkeyer *winkeyer.Manager startupErr string // captured for surfacing to the frontend dbPath string // active database file (may be a user-chosen location) 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()) + // 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) } @@ -674,6 +714,9 @@ func (a *App) shutdown(ctx context.Context) { if a.udp != nil { a.udp.StopAll() } + if a.winkeyer != nil { + a.winkeyer.Disconnect() + } if a.db != nil { _ = a.db.Close() } @@ -804,11 +847,51 @@ func (a *App) MoveDatabase(dest string) error { 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). func (a *App) ResetDatabaseToDefault() error { 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). func (a *App) QuitApp() { 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 // the previous (larger) min — and increased BEFORE resizing up. const ( - compactW, compactH = 980, 140 + compactW, compactH = 1240, 158 normalW, normalH = 1400, 900 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) { @@ -1144,11 +1229,17 @@ func (a *App) SetCompactMode(on bool) { return } 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.WindowSetMaxSize(a.ctx, compactW, compactH) wruntime.WindowSetSize(a.ctx, compactW, compactH) wruntime.WindowSetAlwaysOnTop(a.ctx, true) } else { 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.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 { return adif.ImportResult{}, fmt.Errorf("db not initialized") } if 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) } @@ -1202,14 +1323,16 @@ func (a *App) SaveADIFFile() (string, error) { // 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. -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 { return adif.ExportResult{}, fmt.Errorf("db not initialized") } if 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) } @@ -1441,12 +1564,16 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) { if a.settings == 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, keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd, keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd, keyQSLDefaultClublogStatus, keyQSLDefaultHRDLogStatus, - keyQSLDefaultQRZComStatus, + keyQSLDefaultQRZComStatus, keyQSLDefaultQRZComCfm, ) if err != nil { return out, err @@ -1460,6 +1587,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) { out.ClublogStatus = m[keyQSLDefaultClublogStatus] out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus] out.QRZComStatus = m[keyQSLDefaultQRZComStatus] + out.QRZComCfm = m[keyQSLDefaultQRZComCfm] return out, nil } @@ -1469,6 +1597,7 @@ func (a *App) SaveQSLDefaults(d QSLDefaults) error { if a.settings == nil { return fmt.Errorf("db not initialized") } + scope := a.profileScope() for k, v := range map[string]string{ keyQSLDefaultQSLSent: strings.ToUpper(strings.TrimSpace(d.QSLSent)), 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)), keyQSLDefaultHRDLogStatus: strings.ToUpper(strings.TrimSpace(d.HRDLogStatus)), 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 } } + if err := a.settings.Set(a.ctx, scope+markerQSL, "1"); err != nil { + return err + } return nil } @@ -1508,21 +1641,75 @@ func (a *App) applyQSLDefaults(q *qso.QSO) { if q.ClublogUploadStatus == "" { q.ClublogUploadStatus = d.ClublogStatus } if q.HRDLogUploadStatus == "" { q.HRDLogUploadStatus = d.HRDLogStatus } if q.QRZComUploadStatus == "" { q.QRZComUploadStatus = d.QRZComStatus } + if q.QRZComDownloadStatus == "" { q.QRZComDownloadStatus = d.QRZComCfm } } // ── External services (logbook upload) ───────────────────────────────── // 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." 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."). +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 { var out extsvc.ExternalServices if a.settings == nil { 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, keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign, keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode, - keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWKeyPassword, + keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWForceCall, keyExtLoTWKeyPassword, keyExtLoTWUploadFlag, keyExtLoTWWriteLog, keyExtLoTWAutoUpload, keyExtLoTWUploadMode, keyExtLoTWUsername, keyExtLoTWWebPassword) @@ -1551,9 +1738,10 @@ func (a *App) loadExternalServices() extsvc.ExternalServices { } } out.LoTW = extsvc.ServiceConfig{ - TQSLPath: m[keyExtLoTWTQSLPath], - StationLocation: m[keyExtLoTWStationLoc], - KeyPassword: m[keyExtLoTWKeyPassword], + TQSLPath: m[keyExtLoTWTQSLPath], + StationLocation: m[keyExtLoTWStationLoc], + ForceStationCallsign: m[keyExtLoTWForceCall], + KeyPassword: m[keyExtLoTWKeyPassword], UploadFlag: m[keyExtLoTWUploadFlag], WriteLog: m[keyExtLoTWWriteLog] == "1", Username: m[keyExtLoTWUsername], @@ -1612,6 +1800,7 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error { if cfg.LoTW.WriteLog { ltWriteLog = "1" } + scope := a.profileScope() // write under the active profile's prefix for k, v := range map[string]string{ keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey), 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), keyExtLoTWStationLoc: strings.TrimSpace(cfg.LoTW.StationLocation), + keyExtLoTWForceCall: strings.ToUpper(strings.TrimSpace(cfg.LoTW.ForceStationCallsign)), keyExtLoTWKeyPassword: cfg.LoTW.KeyPassword, keyExtLoTWUploadFlag: ltFlag, keyExtLoTWWriteLog: ltWriteLog, @@ -1635,10 +1825,15 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error { keyExtLoTWUsername: strings.TrimSpace(cfg.LoTW.Username), 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 } } + // 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 { 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))) var recs []string 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) } } @@ -1819,9 +2014,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe case extsvc.ServiceLoTW: since := "" if a.settings != nil { - if m, e := a.settings.GetMany(ctx, keyExtLoTWLastDownload); e == nil { - since = m[keyExtLoTWLastDownload] - } + // Scoped to the active profile — each identity tracks its own + // LoTW account's last incremental-download date. + since, _ = a.settings.Get(ctx, a.profileScope()+keyExtLoTWLastDownload) } if since != "" { emit("Downloading LoTW confirmations received since " + since + "…") @@ -1905,9 +2100,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe } else { 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 { - _ = 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: @@ -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, // for the LoTW settings dropdown. 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 // always send a single QSO per UDP packet (no header) but we tolerate // 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 - 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 { record = rec } @@ -2264,7 +2580,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) { if record == nil { // Some senders skip the header; try treating the whole // payload as a single record by prepending a fake header. - 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 { 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 ── // applyDXCCNumber stamps the contacted-station DXCC# from the // entity-name table; QSL defaults are applied last so explicit ADIF @@ -2905,6 +3228,15 @@ func (a *App) ActivateProfile(id int64) error { return err } 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 } @@ -3060,6 +3392,206 @@ func boolStr(b bool) string { return "0" } +// --- WinKeyer (CW keyer) bindings --- + +// WKMacro is one CW message slot (F1…): a short label + the macro text, which +// may contain 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 K"}, + {Label: "His call", Text: " "}, + {Label: "Report", Text: " UR = "}, + {Label: "Answer", Text: " DE TU UR = "}, + {Label: "Name/QTH", Text: "NAME QTH = "}, + {Label: "73", Text: " TU 73 DE "}, + {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) --- // resolveClusterLogin returns the login callsign for a server: explicit diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 383dc06..2e458f1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - AlertCircle, Antenna, CheckCircle2, Clock, Compass, ExternalLink, Hash, Loader2, Lock, + AlertCircle, Antenna, CheckCircle2, Clock, Compass, Eraser, ExternalLink, Hash, Loader2, Lock, Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, Square, Trash2, Unlock, X, } from 'lucide-react'; @@ -8,6 +8,7 @@ import { AddQSO, ListQSO, CountQSO, OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO, + UpdateQSOsFromCty, UpdateQSOsFromQRZ, LookupCallsign, GetStationSettings, GetListsSettings, GetStartupStatus, WorkedBefore, @@ -22,6 +23,8 @@ import { OperatingDefaultForBand, LogUDPLoggedADIF, ListCountries, + GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus, + WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace, } from '../wailsjs/go/main/App'; import { Combobox } from '@/components/ui/combobox'; import { EventsOn } from '../wailsjs/runtime/runtime'; @@ -41,6 +44,7 @@ import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/ import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel'; import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal'; +import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -369,6 +373,9 @@ export default function App() { const userEditedRef = useRef>(new Set()); const lastLookedUpRef = useRef(''); + // Tracks the call we last auto-switched to the Worked-before tab for, so we + // don't keep yanking the tab on every wb refresh of the same callsign. + const lastWbFocusRef = useRef(''); const rstUserEditedRef = useRef(false); const [details, setDetails] = useState(emptyDetails); @@ -467,6 +474,31 @@ export default function App() { const [clusterServerStatuses, setClusterServerStatuses] = useState([]); // "Send DX spot" dialog (Log4OM-style) — only reachable when a cluster is up. const [showSpotModal, setShowSpotModal] = useState(false); + // "You have been spotted" banner — set when a cluster spot's DX call is our + // own station callsign. Ref holds our call for the (one-shot) spot listener. + const [selfSpot, setSelfSpot] = useState<{ spotter: string; freqKHz: number; band?: string; comment?: string; at: number } | null>(null); + const myCallRef = useRef(''); + const selfSpotTimerRef = useRef(null); + + // === WinKeyer CW keyer === + const [wkEnabled, setWkEnabled] = useState(false); + const [wkPort, setWkPort] = useState(''); + const [wkWpm, setWkWpm] = useState(25); + const [wkMacros, setWkMacros] = useState([]); + const [wkPorts, setWkPorts] = useState([]); + const [wkStatus, setWkStatus] = useState({ connected: false, busy: false, wpm: 25, version: 0, port: '' }); + const [wkSent, setWkSent] = useState(''); // rolling text the keyer echoes as it transmits + const [wkEscClears, setWkEscClears] = useState(true); // ESC also clears the callsign + const [wkSendOnType, setWkSendOnType] = useState(false); // key chars live as typed + // F1-F12 macro shortcuts active only when the keyer is enabled + connected. + const wkActiveRef = useRef(false); + const wkEscClearsRef = useRef(true); + useEffect(() => { wkActiveRef.current = wkEnabled && wkStatus.connected; }, [wkEnabled, wkStatus.connected]); + useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]); + // Controlled active tab of the F1-F5 detail panel (so Ctrl+F1-F5 can switch + // it from the keyboard without clashing with the F1-F12 keyer macros). + type DetailTab = 'stats' | 'info' | 'awards' | 'my' | 'extended'; + const [detailTab, setDetailTab] = useState('stats'); const [clusterServers, setClusterServers] = useState<{ id: number; name: string; enabled: boolean; sort_order: number }[]>([]); // Ring buffer — only keep the last N spots; cluster firehose can be heavy. const [spots, setSpots] = useState([]); @@ -511,14 +543,18 @@ export default function App() { // === ADIF === const [importing, setImporting] = useState(false); + const [importProgress, setImportProgress] = useState<{ processed: number; total: number } | null>(null); const [exporting, setExporting] = useState(false); + // Export mode chooser: standard ADIF (other loggers) vs full (OpsLog round-trip). + const [showExportChoice, setShowExportChoice] = useState(false); const [importResult, setImportResult] = useState(null); const [importErrorsOpen, setImportErrorsOpen] = useState(false); const [importDupsOpen, setImportDupsOpen] = useState(false); // ADIF import confirmation: after the user picks a file, hold the path // until they confirm the options (skip duplicates etc.). const [pendingImportPath, setPendingImportPath] = useState(null); - const [importSkipDups, setImportSkipDups] = useState(true); + const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip'); + const [importApplyCty, setImportApplyCty] = useState(true); // === Lookup + WB === const [lookupResult, setLookupResult] = useState(null); @@ -528,12 +564,29 @@ export default function App() { const wbTimerRef = useRef(null); const [wb, setWb] = useState(null); const [wbBusy, setWbBusy] = useState(false); + // Always-current copy of the entry callsign, so the UDP event handlers + // (which live in a []-deps effect with a stale `callsign` closure) can + // tell whether an incoming DX call actually changed anything. + const callsignValRef = useRef(''); + useEffect(() => { callsignValRef.current = callsign; }, [callsign]); + + // When the entered callsign turns out to be worked-before, jump to the + // Worked-before tab so the history is front-and-centre. Only once per call, + // and we don't yank the user out of the Cluster / QSL-manager tabs. + useEffect(() => { + const c = callsign.trim().toUpperCase(); + if (!c || !wb || (wb.count ?? 0) <= 0 || (wb.callsign ?? '').toUpperCase() !== c) return; + if (lastWbFocusRef.current === c) return; + lastWbFocusRef.current = c; + setActiveTab((t) => (t === 'cluster' || t === 'qsl' ? t : 'worked')); + }, [wb, callsign]); // === Station === const [station, setStation] = useState({ callsign: '', operator: '', my_grid: '', my_country: '', my_sota_ref: '', my_pota_ref: '', }); + myCallRef.current = (station.callsign || '').toUpperCase(); // === Clock === const [utcNow, setUtcNow] = useState(''); @@ -745,6 +798,15 @@ export default function App() { const next = [sp, ...arr]; return next.length > SPOTS_CAP ? next.slice(0, SPOTS_CAP) : next; }); + // Self-spot: someone spotted OUR callsign on the cluster. + const mine = myCallRef.current; + if (mine && (sp.dx_call ?? '').toUpperCase() === mine) { + setSelfSpot({ spotter: cleanSpotter(sp.spotter ?? ''), freqKHz: sp.freq_khz, band: sp.band, comment: sp.comment, at: Date.now() }); + showToast(`You've been spotted by ${cleanSpotter(sp.spotter ?? '') || '?'} on ${sp.freq_khz?.toFixed(1)} kHz`); + // Auto-hide 3 s after the last self-spot; a new one resets the timer. + if (selfSpotTimerRef.current) window.clearTimeout(selfSpotTimerRef.current); + selfSpotTimerRef.current = window.setTimeout(() => setSelfSpot(null), 3000); + } }); return () => { unsubState?.(); unsubSpot?.(); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -765,6 +827,9 @@ export default function App() { const unsubRC = EventsOn('udp:remote_call', (call: string) => { if (call) onCallsignInput(String(call).trim()); }); + const unsubProg = EventsOn('import:progress', (p: any) => { + setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) }); + }); const unsubLog = EventsOn('udp:logged_qso', async (p: any) => { const text = String(p?.adif ?? '').trim(); if (!text) return; @@ -775,10 +840,90 @@ export default function App() { setError('UDP auto-log: ' + String(e?.message ?? e)); } }); - return () => { unsubDX?.(); unsubRC?.(); unsubLog?.(); }; + return () => { unsubDX?.(); unsubRC?.(); unsubProg?.(); unsubLog?.(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // ── WinKeyer wiring ─────────────────────────────────────────────────── + const reloadWkPorts = useCallback(() => { + ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {}); + }, []); + const reloadWk = useCallback(async () => { + try { + const s: any = await GetWinkeyerSettings(); + setWkEnabled(!!s.enabled); + setWkPort(s.port ?? ''); + setWkWpm(s.wpm ?? 25); + setWkMacros((s.macros ?? []) as WKMacro[]); + setWkEscClears(s.esc_clears_call !== false); + setWkSendOnType(!!s.send_on_type); + } catch { /* keyer not configured */ } + }, []); + useEffect(() => { + (async () => { + await reloadWk(); + const st: any = await GetWinkeyerStatus().catch(() => null); + if (st) setWkStatus(st as WKStatus); + reloadWkPorts(); + })(); + const unsub = EventsOn('winkeyer:status', (st: WKStatus) => setWkStatus(st)); + // Append each echoed char as the keyer transmits it; keep a rolling tail. + const unsubEcho = EventsOn('winkeyer:echo', (ch: string) => setWkSent((s) => (s + ch).slice(-160))); + return () => { unsub?.(); unsubEcho?.(); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Keep a live ref to wkSendMacro so the global key handler calls the latest. + const wkSendMacroRef = useRef<(i: number) => void>(() => {}); + + // Persist a single WinKeyer field then save (used by the panel's port etc.). + async function saveWk(patch: Record) { + try { + const cur: any = await GetWinkeyerSettings(); + await SaveWinkeyerSettings({ ...cur, ...patch }); + } catch (e: any) { setError(String(e?.message ?? e)); } + } + function wkSetEnabled(on: boolean) { + setWkEnabled(on); + saveWk({ enabled: on }); + if (!on) WinkeyerDisconnect().catch(() => {}); + } + function wkSelectPort(p: string) { setWkPort(p); saveWk({ port: p }); } + + // Resolve macro / CW-text variables from the current entry + active profile. + function resolveCW(text: string): string { + const myCall = (station.callsign || '').toUpperCase(); + const his = callsign.trim().toUpperCase(); + const cut = (s: string) => (s || '').replace(/0/g, 'T').replace(/9/g, 'N'); // cut numbers + const vars: Record = { + MY_CALL: myCall, CALL: his, + STX: cut(rstSent), STRX: cut(rstRcvd), RST_R: cut(rstRcvd), + STXF: rstSent, STRXF: rstRcvd, + NAME: station.operator || '', MY_NAME: station.operator || '', MY_OPCALL: station.operator || myCall, + HIS_NAME: name || '', + GRID: station.my_grid || '', COUNTRY: (station as any).my_country || '', + MY_QTH: (station as any).my_city || '', MY_RIG: details.my_rig || '', MY_ANTENNA: details.my_antenna || '', + MY_IOTA: (station as any).my_iota || '', MY_SOTA: (station as any).my_sota_ref || '', + CONT_RX: details.srx != null ? String(details.srx) : '', CONT_TX: details.stx != null ? String(details.stx) : '', + }; + let out = text.replace(/<([A-Z_]+)>/g, (_m, k) => vars[k] ?? ''); + out = out.replace(/\*/g, myCall).replace(/!/g, his); + // (2..9): repeat the whole resolved string n times. + const rep = out.match(/<([2-9])>/); + if (rep) { + const n = parseInt(rep[1], 10); + const base = out.replace(/<[2-9]>/g, '').replace(/\s+/g, ' ').trim(); + out = Array(n).fill(base).join(' '); + } + return out.replace(/\s+/g, ' ').trim(); + } + function wkSend(rawText: string) { setWkSent(''); WinkeyerSend(resolveCW(rawText)).catch((e) => setError(String(e?.message ?? e))); } + function wkSendMacro(i: number) { const m = wkMacros[i]; if (m) wkSend(m.text); } + wkSendMacroRef.current = wkSendMacro; + // send-on-type: key the typed chars verbatim (no variable substitution). + function wkSendRaw(chars: string) { WinkeyerSend(chars).catch(() => {}); } + function wkBackspace() { WinkeyerBackspace().catch(() => {}); } + function wkToggleSendOnType(on: boolean) { setWkSendOnType(on); saveWk({ send_on_type: on }); } + // Resolve slot status for any spot we haven't seen yet — debounced so we // don't hammer the backend at firehose rate. The mode passed to the // backend is derived from the comment (CW / FT8 / FT4 / SSB...) and the @@ -879,7 +1024,10 @@ export default function App() { function resetAutoFill() { setName(''); setQth(''); setCountry(''); setGrid(''); - setWb(null); + // NOTE: don't clear `wb` here. It's owned by runWorkedBefore (fast 150 ms + // pass) and the short-callsign guard in scheduleLookup. Clearing it inside + // runLookup blanked the Worked-before table for the whole (possibly slow, + // QRZ-then-cty.dat) lookup → entries flashed in and immediately vanished. setLookupResult(null); setDetails((d) => ({ ...d, @@ -891,6 +1039,7 @@ export default function App() { })); userEditedRef.current.clear(); lastLookedUpRef.current = ''; + lastWbFocusRef.current = ''; setLookupResult(null); } @@ -899,13 +1048,41 @@ export default function App() { catch (e: any) { setError(String(e?.message ?? e)); } } async function onModalSave(q: QSO) { - try { await UpdateQSO(q as any); setEditingQSO(null); await refresh(); } - catch (err: any) { setError(String(err?.message ?? err)); } + try { + await UpdateQSO(q as any); + setEditingQSO(null); + await refresh(); + // The Worked-before grid is loaded separately (per callsign) and isn't + // touched by refresh(), so an edit made from it would leave stale data + // on screen. Reload it when one is shown. + const wbCall = callsign.trim(); + if (wb && wbCall.length >= 3) runWorkedBefore(wbCall); + } catch (err: any) { setError(String(err?.message ?? err)); } } function onModalDelete(id: number) { const q = editingQSO; setEditingQSO(null); if (q) setDeletingQSO(q); else askDelete(id); } + + // Bulk grid actions (right-click menu). Recompute country/zones from + // cty.dat (instant, offline) or re-query QRZ.com, then refresh the views. + async function afterBulkUpdate(n: number, label: string) { + await refresh(); + const wbCall = callsign.trim(); + if (wb && wbCall.length >= 3) runWorkedBefore(wbCall); + showToast(n > 0 ? `${n} QSO${n > 1 ? 's' : ''} updated ${label}` : `No change ${label}`); + } + async function bulkUpdateFromCty(ids: number[]) { + if (ids.length === 0) return; + try { await afterBulkUpdate(await UpdateQSOsFromCty(ids as any), 'from cty.dat'); } + catch (e: any) { setError(String(e?.message ?? e)); } + } + async function bulkUpdateFromQRZ(ids: number[]) { + if (ids.length === 0) return; + showToast(`Querying QRZ.com for ${ids.length} QSO${ids.length > 1 ? 's' : ''}…`); + try { await afterBulkUpdate(await UpdateQSOsFromQRZ(ids as any), 'from QRZ.com'); } + catch (e: any) { setError(String(e?.message ?? e)); } + } function askDelete(id: number) { const q = qsos.find((x) => x.id === id); if (q) setDeletingQSO(q); @@ -945,13 +1122,21 @@ export default function App() { setLookupBusy(true); try { const r = await LookupCallsign(call); - setLookupResult(r); lastLookedUpRef.current = call; + // cty.dat carries ONLY DXCC-entity data (country / CQ / ITU zones / continent). + // A QRZ/HamQTH hit is far richer (name, QTH, grid, address, image). When the + // result is cty.dat-only, it must NEVER overwrite the richer data already + // shown for the same call — so don't downgrade the result badge/image, and + // below we only fill text fields that actually carry a value. + const ctyOnly = r.source === 'cty.dat'; + setLookupResult((prev) => (ctyOnly && prev && prev.callsign === r.callsign ? prev : r)); const ue = userEditedRef.current; - if (!ue.has('name')) setName(r.name ?? ''); - if (!ue.has('qth')) setQth(r.qth ?? ''); - if (!ue.has('country')) setCountry(r.country ?? ''); - if (!ue.has('grid')) setGrid(r.grid ?? ''); + if (!ue.has('name') && (r.name ?? '') !== '') setName(r.name ?? ''); + if (!ue.has('qth') && (r.qth ?? '') !== '') setQth(r.qth ?? ''); + if (!ue.has('grid') && (r.grid ?? '') !== '') setGrid(r.grid ?? ''); + // Country/zones are exactly what cty.dat IS authoritative for — set them + // (only skipped if empty, so we never blank a known country). + if (!ue.has('country') && (r.country ?? '') !== '') setCountry(r.country ?? ''); setDetails((d) => ({ ...d, address: d.address || (r.address ?? ''), @@ -986,6 +1171,12 @@ export default function App() { wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150); } function onCallsignInput(v: string) { + // No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call + // on every status packet. If it matches what's already in the entry, + // do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and + // reload worked-before + the band matrix, making them flicker. Compared + // via the ref so it's correct even from the stale UDP closure. + if (v.trim().toUpperCase() === callsignValRef.current.trim().toUpperCase()) return; const wasEmpty = callsign.trim() === ''; const isEmpty = v.trim() === ''; if (wasEmpty && !isEmpty && !locks.start) { @@ -1015,16 +1206,21 @@ export default function App() { } catch (e: any) { setError(String(e?.message ?? e)); } } - async function exportAdif() { + function exportAdif() { + if (exporting) return; + setShowExportChoice(true); // pick standard vs full first + } + async function runExport(includeAppFields: boolean) { + setShowExportChoice(false); if (exporting) return; setError(''); try { const path = await SaveADIFFile(); if (!path) return; setExporting(true); - const res = await ExportADIF(path); + const res = await ExportADIF(path, includeAppFields); // Reuse the error banner area for a brief success note (4s auto-dismiss). - const msg = `ADIF exported: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`; + const msg = `ADIF exported${includeAppFields ? ' (full)' : ' (standard)'}: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`; setError(msg); setTimeout(() => setError((e) => e === msg ? '' : e), 4000); } catch (e: any) { @@ -1039,17 +1235,19 @@ export default function App() { if (!path || importing) return; setPendingImportPath(null); setImporting(true); + setImportProgress({ processed: 0, total: 0 }); setImportResult(null); setImportErrorsOpen(false); setImportDupsOpen(false); try { - const res = await ImportADIF(path, importSkipDups); + const res = await ImportADIF(path, importDupMode, importApplyCty); setImportResult(res); await refresh(); } catch (e: any) { setError(String(e?.message ?? e)); } finally { setImporting(false); + setImportProgress(null); } } @@ -1078,6 +1276,7 @@ export default function App() { { type: 'item', label: 'Callsign lookup settings…', action: 'tools.lookup' }, { type: 'item', label: 'CAT interface…', action: 'tools.cat' }, { type: 'item', label: 'Rotator…', action: 'tools.rotator' }, + { type: 'item', label: wkEnabled ? '✓ WinKeyer CW keyer' : 'WinKeyer CW keyer', action: 'tools.winkeyer' }, { type: 'separator' }, // Maintenance — bumped here while we only have one entry. Will move // to a Tools → Maintenance submenu once Clublog + LoTW refresh land. @@ -1086,7 +1285,7 @@ export default function App() { { name: 'help', label: 'Help', items: [ { type: 'item', label: 'About OpsLog', action: 'help.about', disabled: true }, ]}, - ], [total, selectedId, ctyRefreshing, exporting]); + ], [total, selectedId, ctyRefreshing, exporting, wkEnabled]); function handleMenu(action: string) { switch (action) { @@ -1102,6 +1301,7 @@ export default function App() { case 'tools.lookup': setSettingsSection('lookup'); setShowSettings(true); break; case 'tools.cat': setSettingsSection('cat'); setShowSettings(true); break; case 'tools.rotator': setSettingsSection('rotator'); setShowSettings(true); break; + case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break; case 'tools.refreshCty': refreshCtyDat(); break; } } @@ -1127,7 +1327,39 @@ export default function App() { function onKey(e: KeyboardEvent) { const tag = (e.target as HTMLElement)?.tagName; const typing = tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA'; - if (e.key === 'F5') { e.preventDefault(); refresh(); return; } + + // ESC: abort CW when the keyer is live. Whether it ALSO clears the + // callsign depends on the "ESC clears callsign" option; with the keyer + // off it always resets the entry (the classic behaviour). + if (e.key === 'Escape') { + const keyerLive = wkActiveRef.current; + if (keyerLive) WinkeyerStop().catch(() => {}); + if (!keyerLive || wkEscClearsRef.current) { + resetEntry(); + callsignRef.current?.focus(); + } + e.preventDefault(); + return; + } + + // Function keys (work even while typing — they're not text input): + const fn = /^F([1-9]|1[0-2])$/.exec(e.key); + if (fn) { + const n = parseInt(fn[1], 10); // 1..12 + const TABS = ['stats', 'info', 'awards', 'my', 'extended'] as const; + const mod = e.ctrlKey || e.metaKey; + const plain = !mod && !e.altKey; + if (wkActiveRef.current) { + // Keyer live: plain F1..F12 fire macros; Ctrl+F1..F5 switch the + // detail tab (so the two don't clash). Labels read "Ctrl+F1…". + if (mod && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; } + if (plain) { e.preventDefault(); wkSendMacroRef.current(n - 1); return; } + return; + } + // Keyer off: plain F1..F5 switch the detail tab (labels read "F1…"). + if (plain && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; } + return; + } if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'o') { e.preventDefault(); importAdif(); return; } @@ -1142,6 +1374,261 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedId, refresh]); + // ── Entry-field blocks ───────────────────────────────────────────────── + // Each field is defined once here, then composed into either the compact + // single-row strip or the full Log4OM-style columnar layout below. Keeping + // them as shared consts avoids duplicating the (large) per-field JSX + + // handlers across the two layouts. + const callsignBlock = ( +
+ + onCallsignInput(e.target.value)} + /> +
+ ); + const rstTxBlock = ( +
+ { setRstSent(v); rstUserEditedRef.current = true; }} /> +
+ ); + const rstRxBlock = ( +
+ { setRstRcvd(v); rstUserEditedRef.current = true; }} /> +
+ ); + const startBlock = ( +
+ + { setStartInputStr(qsoStartedAt ? fmtHMSUTC(qsoStartedAt) : ''); setStartFocused(true); }} + onBlur={() => { + setStartFocused(false); + if (startInputStr.trim() === '') { if (locks.start) setQsoStartedAt(null); return; } + setQsoStartedAt(parseHMSUTC(startInputStr, qsoStartedAt ?? new Date())); + }} + onChange={(e) => setStartInputStr(e.target.value)} + placeholder="HH:MM:SS" + className={cn('font-mono', locks.start ? '' : 'bg-muted/40 cursor-default')} + /> +
+ ); + const endBlock = ( +
+ + { setEndInputStr(qsoEndedAt ? fmtHMSUTC(qsoEndedAt) : ''); setEndFocused(true); }} + onBlur={() => { + setEndFocused(false); + if (endInputStr.trim() === '') { if (locks.end) setQsoEndedAt(null); return; } + setQsoEndedAt(parseHMSUTC(endInputStr, qsoEndedAt ?? new Date())); + }} + onChange={(e) => setEndInputStr(e.target.value)} + placeholder="HH:MM:SS" + className={cn('font-mono', locks.end ? '' : 'bg-muted/40 cursor-default')} + /> +
+ ); + const nameBlock = ( +
+ { setName(e.target.value); markEdited('name'); }} /> +
+ ); + const qthBlock = ( +
+ { setQth(e.target.value); markEdited('qth'); }} /> +
+ ); + const gridBlock = ( +
+ { setGrid(e.target.value); markEdited('grid'); }} /> +
+ ); + // Compact-strip Country (stacked label) + a narrow Comment. + const countryBlockSm = ( +
+ + { setCountry(v); markEdited('country'); }} /> +
+ ); + const commentSm = ( +
+ setComment(e.target.value)} /> +
+ ); + // Inline-label variants (label to the LEFT of the control, Log4OM-style) — + // used in the full layout to save vertical height. + const bandRow = ( +
+ +
+ +
+
+ ); + const modeRow = ( +
+ +
+ +
+
+ ); + const countryRow = ( +
+ +
+ { setCountry(v); markEdited('country'); }} /> +
+
+ ); + const cqBlock = ( +
+ { const v = e.target.value.replace(/\D/g, ''); updateDetails({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} /> +
+ ); + const ituBlock = ( +
+ { const v = e.target.value.replace(/\D/g, ''); updateDetails({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} /> +
+ ); + const freqBlock = ( +
+ + setFreqFocused(true)} + onBlur={() => setFreqFocused(false)} + onChange={(e) => { setFreqMhz(e.target.value); noteManualEdit(); const b = bandForMHz(parseFloat(e.target.value)); if (b) setBand(b); }} + /> +
+ ); + const rxFreqBlock = ( +
+ + setFreqFocused(true)} + onBlur={() => setFreqFocused(false)} + onChange={(e) => { setRxFreqMhz(e.target.value); noteManualEdit(); const rb = bandForMHz(parseFloat(e.target.value)); if (rb) setBandRx(rb); }} + className={cn('font-mono', catState.split && 'bg-rose-50/40 border-rose-200 focus:bg-card')} + /> +
+ ); + const bandRxBlock = ( +
+ +
+ ); + // Single-line Comment/Note for the full layout (stacked in the right + // column). No flex-1 so they stay one row tall. + const commentLine = ( +
+ setComment(e.target.value)} /> +
+ ); + const noteLine = ( +
+ setNote(e.target.value)} /> +
+ ); + const logButtons = ( +
+ +
+ {clusterServerStatuses.some((s) => s.state === 'connected') && ( + + )} + + +
+
+ ); + // Compact strip: Log only — the Spot dialog would be clipped by the tiny + // always-on-top window, so it's reachable only in normal mode. + const logButtonCompact = ( +
+ + +
+ ); + return (
@@ -1322,251 +1809,104 @@ export default function App() {
)} + {/* "You have been spotted" banner — shows when our own callsign appears + in a cluster spot (Log4OM-style). Floated as a bottom-center overlay + so it never shifts the layout (push-down / spring-back) and never + covers the entry fields; auto-hides 3s after the last self-spot. */} + {!compact && selfSpot && ( +
+ + + You've been spotted by {selfSpot.spotter || '?'} + {' '}on {selfSpot.freqKHz?.toFixed(1)} kHz + {selfSpot.band ? ` (${selfSpot.band})` : ''} + {selfSpot.comment ? — {selfSpot.comment} : null} + +
+ +
+ )} + {/* ===== ENTRY STRIP ===== Enter from any inside the strip logs the QSO. Radix Selects render as - )} - {lookupBusy && Looking up…} - {!lookupBusy && lookupResult && ( - - {lookupResult.source} - - )} - {!lookupBusy && !lookupResult && lookupError && ( - {lookupError} - )} - - onCallsignInput(e.target.value)} - /> -
-
- { setRstSent(v); rstUserEditedRef.current = true; }} /> -
-
- { setRstRcvd(v); rstUserEditedRef.current = true; }} /> -
-
- - { - setStartInputStr(qsoStartedAt ? fmtHMSUTC(qsoStartedAt) : ''); - setStartFocused(true); - }} - onBlur={() => { - setStartFocused(false); - if (startInputStr.trim() === '') { - if (locks.start) setQsoStartedAt(null); - return; - } - setQsoStartedAt(parseHMSUTC(startInputStr, qsoStartedAt ?? new Date())); - }} - onChange={(e) => setStartInputStr(e.target.value)} - placeholder="HH:MM:SS" - className={cn('font-mono', locks.start ? '' : 'bg-muted/40 cursor-default')} - /> -
-
- - { - setEndInputStr(qsoEndedAt ? fmtHMSUTC(qsoEndedAt) : ''); - setEndFocused(true); - }} - onBlur={() => { - setEndFocused(false); - if (endInputStr.trim() === '') { - if (locks.end) setQsoEndedAt(null); - return; - } - setQsoEndedAt(parseHMSUTC(endInputStr, qsoEndedAt ?? new Date())); - }} - onChange={(e) => setEndInputStr(e.target.value)} - placeholder="HH:MM:SS" - className={cn('font-mono', locks.end ? '' : 'bg-muted/40 cursor-default')} - /> -
+ {compact ? ( + /* Compact strip: Call + RST + Name/QTH/Country + Comment on one + row. Band/Mode/Freq are omitted — they're shown in the compact + top bar just above. */ + <> + {callsignBlock} + {rstTxBlock} + {rstRxBlock} + {nameBlock} + {qthBlock} + {countryBlockSm} + {commentSm} +
{logButtonCompact}
+ + ) : ( + /* Full Log4OM-style columnar layout. */ + <> + {/* Row 1: Callsign + RST + CQ/ITU zones, then Start/End at right. */} +
+ {callsignBlock} + {rstTxBlock} + {rstRxBlock} + {cqBlock} + {ituBlock} +
+ {startBlock} + {endBlock} +
+
- {/* ── Row 2: Operator name + QTH + Grid + Country + zones (hidden in compact) ── */} - {!compact && <> -
-
- { setName(e.target.value); markEdited('name'); }} /> -
-
- { setQth(e.target.value); markEdited('qth'); }} /> -
-
- { setGrid(e.target.value); markEdited('grid'); }} /> -
-
- - { setCountry(v); markEdited('country'); }} /> -
- {/* DXCC # and Continent are derived from the callsign — read-only. - CQ/ITU stay editable but as plain text (no number spinners). - Kept compact (Log4OM-style) — just wide enough for their digits. */} -
- -
-
- { const v = e.target.value.replace(/\D/g, ''); updateDetails({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} /> -
-
- { const v = e.target.value.replace(/\D/g, ''); updateDetails({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} /> -
-
- -
- } + {/* Row 2: wide Name + QTH + Grid across the full width. */} +
+ {nameBlock} + {qthBlock} + {gridBlock} +
- {/* ── Row 3: Freq + Band + Mode + Band RX + RX Freq ── */} -
-
- - setFreqFocused(true)} - onBlur={() => setFreqFocused(false)} - onChange={(e) => { setFreqMhz(e.target.value); noteManualEdit(); const b = bandForMHz(parseFloat(e.target.value)); if (b) setBand(b); }} - /> -
-
- - -
-
- - -
-
- - -
-
- - setFreqFocused(true)} - onBlur={() => setFreqFocused(false)} - onChange={(e) => { setRxFreqMhz(e.target.value); noteManualEdit(); const rb = bandForMHz(parseFloat(e.target.value)); if (rb) setBandRx(rb); }} - className={cn('font-mono', catState.split && 'bg-rose-50/40 border-rose-200 focus:bg-card')} - /> -
+ {/* Row 3: tight left detail column (Band/Mode/Country) and + single-line Comment/Note on the right. */} +
+
+ {bandRow} + {modeRow} + {countryRow} +
+
+ {commentLine} + {noteLine} +
+
- {/* ── Row 4: Comment + Note ── */} -
-
- setComment(e.target.value)} /> -
- {!compact && ( -
- setNote(e.target.value)} /> -
+ {/* Bottom: TX freq, RX freq, RX band — plus the action buttons. */} +
+ {freqBlock} + {rxFreqBlock} + {bandRxBlock} +
{logButtons}
+
+ )} -
- -
- {/* Send DX spot — only when a cluster is connected. Pre-fills the - dialog from the current entry (or the last logged QSO). */} - {clusterServerStatuses.some((s) => s.state === 'connected') && ( - - )} - -
-
{/* Right of the entry (Log4OM-style): F1 Stats (matrix) + F2-F5 detail @@ -1584,28 +1924,63 @@ export default function App() { wbBusy={wbBusy} band={band} mode={mode} + tab={detailTab} + onTab={setDetailTab} + keyerActive={wkEnabled && wkStatus.connected} />
)} - {/* Reserved free space to the right — shows the QRZ profile photo large - so it's actually legible. Click opens the full-size image on QRZ. */} - {!compact && lookupResult?.image_url && ( -
- + {/* Reserved free space to the right. When the WinKeyer CW keyer is + enabled it takes this slot (Log4OM-style); otherwise it shows the + QRZ profile photo. */} + {!compact && (wkEnabled || lookupResult?.image_url) && ( +
+ {wkEnabled && ( +
+ WinkeyerConnect().catch((e) => setError(String(e?.message ?? e)))} + onDisconnect={() => WinkeyerDisconnect().catch(() => {})} + onSetSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }} + onSend={wkSend} + onSendMacro={wkSendMacro} + onStop={() => WinkeyerStop().catch(() => {})} + onClose={() => wkSetEnabled(false)} + sendOnType={wkSendOnType} + onToggleSendOnType={wkToggleSendOnType} + onSendRaw={wkSendRaw} + onBackspace={wkBackspace} + /> +
+ )} + {/* QRZ photo: when the keyer is open it sits to its right at natural + (capped) width, shrinking the keyer panel rather than hiding it. */} + {lookupResult?.image_url && ( +
+ +
+ )}
)}
{/* /entry + aside row */} @@ -1684,6 +2059,9 @@ export default function App() {
Import complete. {importResult.imported} imported + {importResult.updated > 0 && ( + {importResult.updated} updated + )} {importResult.duplicates > 0 && ( {importResult.duplicates} duplicates )} @@ -1721,6 +2099,8 @@ export default function App() { rows={qsos as any} total={total} onRowDoubleClicked={(q) => openEdit(q.id as number)} + onUpdateFromCty={bulkUpdateFromCty} + onUpdateFromQRZ={bulkUpdateFromQRZ} onRowSelected={(id) => setSelectedId(id)} />
@@ -2051,7 +2431,8 @@ export default function App() { - + openEdit(q.id as number)} + onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} /> {/* Opened on demand from Tools → QSL Manager; closable via the @@ -2184,7 +2565,7 @@ export default function App() { { setShowSettings(false); setSettingsSection(undefined); }} - onSaved={() => { loadStation(); loadLists(); loadCATCfg(); }} + onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); }} /> )} @@ -2209,6 +2590,45 @@ export default function App() { onCancel={() => { if (!deletingAll) setShowDeleteAll(false); }} /> )} + {showExportChoice && ( + { if (!o) setShowExportChoice(false); }}> + + + Export ADIF + + Choose which fields to include in the export. + + +
+ + +
+ + + +
+
+ )} {pendingImportPath && ( { if (!o) setPendingImportPath(null); }}> @@ -2218,28 +2638,87 @@ export default function App() { {pendingImportPath} -
-
)} + + {importing && ( + + + + Importing ADIF… + +
+ {(() => { + const done = importProgress?.processed ?? 0; + const tot = importProgress?.total ?? 0; + const pct = tot > 0 ? Math.min(100, Math.round((done / tot) * 100)) : 0; + return ( + <> +
+
0 ? { width: `${pct}%` } : undefined} + /> +
+
+ {tot > 0 + ? `${done.toLocaleString()} / ${tot.toLocaleString()} records · ${pct}%` + : `${done.toLocaleString()} records…`} +
+ + ); + })()} +
+ +
+ )}
); } diff --git a/frontend/src/components/BandMap.tsx b/frontend/src/components/BandMap.tsx index 89742a7..c62ca4f 100644 --- a/frontend/src/components/BandMap.tsx +++ b/frontend/src/components/BandMap.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Minus, Plus, Crosshair, X } from 'lucide-react'; 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. // - 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 TOP_PAD = 14; // px of breathing room above/below the band edges so const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0 -// Max FT-mode (FT8/FT4/…) pills drawn at once. These pile up on a single -// watering-hole frequency and otherwise spawn hundreds of spots that fan out -// and cover the whole map. ONLY the FT family is capped — CW, SSB and other -// digital spots are always shown in full. When more than this FT spots are in -// band we keep the most useful (new entities first, worked last; ties broken -// by closeness to the rig freq). +// Max DATA-class (digital: FT8/FT4/JS8/RTTY/PSK/…) pills drawn at once. +// These pile up on the watering-hole frequencies and otherwise spawn +// hundreds of spots that fan out and cover the whole map. ONLY digital is +// capped — CW and SSB are always shown in full. When more than this digital +// spots are in band we keep the most useful (new entities first, worked +// last; ties broken by closeness to the rig freq). const MAX_VISIBLE_SPOTS = 30; export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose }: Props) { @@ -189,14 +189,16 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o inBand.push(s); } - // Only the FT family (FT8/FT4/…) is capped — it's what floods a single - // watering-hole frequency. Everything else (CW, SSB, RTTY, PSK, …) is - // always shown in full. - const isFlood = (s: Spot) => /^FT/.test(inferSpotMode(s.comment ?? '', s.freq_hz)); + // Only DATA-class spots (every digital mode — FT8/FT4/JS8/RTTY/PSK/…) + // are capped — they're what floods the watering-hole frequencies. We key + // off the mode CATEGORY (not a literal "FT8" string) because many FT8 + // 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 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. const rank = (s: Spot) => { 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
scroll · ctrl+wheel = zoom · ◎ = jump to rig - {hidden > 0 && · {hidden} FT spot{hidden > 1 ? 's' : ''} hidden (top {MAX_VISIBLE_SPOTS} shown)} + {hidden > 0 && · {hidden} data spot{hidden > 1 ? 's' : ''} hidden (top {MAX_VISIBLE_SPOTS} shown)}
); diff --git a/frontend/src/components/BandSlotGrid.tsx b/frontend/src/components/BandSlotGrid.tsx index bd7b5d1..1bb88ea 100644 --- a/frontend/src/components/BandSlotGrid.tsx +++ b/frontend/src/components/BandSlotGrid.tsx @@ -46,6 +46,17 @@ const STATUS_CLASSES: Record = { 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 { const desc = status === 'call_c' ? 'This callsign confirmed' : @@ -75,8 +86,8 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) { return (
@@ -120,6 +131,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) { )}
+
@@ -170,6 +182,23 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) { })}
+ + {/* Colour legend — sits in the spare room under the matrix. */} +
+ {LEGEND.map((l) => ( + + + {l.label} + + ))} +
+
); } diff --git a/frontend/src/components/ClusterGrid.tsx b/frontend/src/components/ClusterGrid.tsx index 27a256e..d52881e 100644 --- a/frontend/src/components/ClusterGrid.tsx +++ b/frontend/src/components/ClusterGrid.tsx @@ -4,13 +4,14 @@ import { type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent, } from 'ag-grid-community'; import { AgGridReact } from 'ag-grid-react'; -import { Columns3 } from 'lucide-react'; +import { Columns3, FilterX } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot'; +import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs'; ModuleRegistry.registerModules([AllCommunityModule]); @@ -304,19 +305,18 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) { const context = useMemo(() => ({ spotStatus }), [spotStatus]); function onGridReady(e: GridReadyEvent) { - try { - const raw = localStorage.getItem(COL_STATE_KEY); - if (raw) { - const state = JSON.parse(raw) as ColumnState[]; - if (Array.isArray(state)) e.api.applyColumnState({ state, applyOrder: true }); + const local = loadLocal(COL_STATE_KEY); + if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true }); + loadRemote(COL_STATE_KEY).then((remote) => { + if (remote && !local) { + e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true }); + seedLocal(COL_STATE_KEY, remote); } - } catch {} + }); } const saveColumnState = useCallback(() => { - try { - const state = gridRef.current?.api?.getColumnState(); - if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state)); - } catch {} + const state = gridRef.current?.api?.getColumnState(); + if (state) saveState(COL_STATE_KEY, state); }, []); function handleRowClicked(e: RowClickedEvent) { @@ -360,6 +360,10 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) { return ( <>
+ diff --git a/frontend/src/components/DetailsPanel.tsx b/frontend/src/components/DetailsPanel.tsx index c04601e..a33a07f 100644 --- a/frontend/src/components/DetailsPanel.tsx +++ b/frontend/src/components/DetailsPanel.tsx @@ -53,9 +53,15 @@ interface Props { mode: string; imageUrl?: string; 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']; @@ -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) { - const [open, setOpen] = useState('stats'); +export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, tab, onTab, keyerActive }: Props) { + const [internalOpen, setInternalOpen] = useState('stats'); + const open = tab ?? internalOpen; // controlled when `tab` is provided // Bearing/distance from operator's home grid to the remote station. // 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 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'; function setSatellite(on: boolean) { @@ -102,15 +110,15 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, } const tabs: { key: TabName; label: string }[] = [ - { key: 'stats', label: 'Stats (F1)' }, - { key: 'info', label: 'Info (F2)' }, - { key: 'awards', label: 'Awards (F3)' }, - { key: 'my', label: 'My (F4)' }, - { key: 'extended', label: 'Extended (F5)' }, + { key: 'stats', label: `Stats (${fk}1)` }, + { key: 'info', label: `Info (${fk}2)` }, + { key: 'awards', label: `Awards (${fk}3)` }, + { key: 'my', label: `My (${fk}4)` }, + { key: 'extended', label: `Extended (${fk}5)` }, ]; return ( -
+
+ ); +} diff --git a/frontend/src/components/QSOEditModal.tsx b/frontend/src/components/QSOEditModal.tsx index 4d8170c..9d32bd6 100644 --- a/frontend/src/components/QSOEditModal.tsx +++ b/frontend/src/components/QSOEditModal.tsx @@ -13,11 +13,24 @@ import { Badge } from '@/components/ui/badge'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; +import { Checkbox } from '@/components/ui/checkbox'; import { cn } from '@/lib/utils'; +import { flagURL } from '@/lib/flags'; import type { QSOForm } from '@/types'; 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 MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9']; 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']; +// 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 {label}; +} + interface Props { qso: QSO; 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) { const [draft, setDraft] = useState(() => JSON.parse(JSON.stringify(qso))); - const [freqMhz, setFreqMhz] = useState(draft.freq_hz ? String(draft.freq_hz / 1_000_000) : ''); - const [freqRxMhz, setFreqRxMhz] = useState(draft.freq_rx_hz ? String(draft.freq_rx_hz / 1_000_000) : ''); + // Frequencies are edited as kHz + Hz (Log4OM style) and recombined on save. + 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 [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 [localErr, setLocalErr] = useState(''); 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_pota_ref: (draft.my_pota_ref ?? '').trim().toUpperCase(), qso_date: parseLocalISO(dateOn) ?? new Date().toISOString(), - qso_date_off: parseLocalISO(dateOff) ?? undefined, - freq_hz: freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined, - freq_rx_hz: freqRxMhz.trim() ? Math.round(parseFloat(freqRxMhz) * 1_000_000) : undefined, + qso_date_off: endEnabled ? (parseLocalISO(dateOff) ?? undefined) : undefined, + freq_hz: freqKHz.trim() ? parseInt(freqKHz, 10) * 1000 + (parseInt(freqHz, 10) || 0) : undefined, + freq_rx_hz: freqRxKHz.trim() ? parseInt(freqRxKHz, 10) * 1000 + (parseInt(freqRxHz, 10) || 0) : undefined, dxcc: intOrUndef(draft.dxcc), cqz: intOrUndef(draft.cqz), ituz: intOrUndef(draft.ituz), @@ -210,14 +262,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) { Edit fields for QSO #{draft.id} - + - Basic - Contacted - QSL + QSO Info + Contact's details + QSL Info Contest Sat / Prop - My station + My Station Notes Extras @@ -234,106 +286,177 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) { )}
- -
- -
- set('callsign', e.target.value)} /> - + + {/* Top: Callsign + RST + Fetch */} +
+
+ + set('callsign', e.target.value)} /> +
+
+ set('rst_sent', e.target.value)} className="font-mono" />
+
+ set('rst_rcvd', e.target.value)} className="font-mono" />
+ +
+ +
+ {/* ── Left column ── */} +
+
set('name', e.target.value)} />
+
+ +
- - setDateOn(e.target.value)} /> - setDateOff(e.target.value)} /> - - - - - - - set('submode', e.target.value)} /> - - - - setFreqMhz(e.target.value)} /> - setFreqRxMhz(e.target.value)} /> - set('rst_sent', e.target.value)} /> - set('rst_rcvd', e.target.value)} /> - set('tx_pwr', numOrUndef(e.target.value) as any)} /> - set('name', e.target.value)} /> - set('country', e.target.value)} /> - set('grid', e.target.value)} /> +
+ + +
+
+ + +
+
+ + set('country', e.target.value)} className="flex-1" /> +
+
+ + set('ituz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" /> + + set('cqz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" /> + set('dxcc', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center bg-muted/40" title="DXCC entity #" /> + {flagURL(draft.dxcc) && } +
+
+ + setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" /> + setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" /> +
+
+ + setFreqRxKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" /> + setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" /> +
+
+ + {/* ── Right column ── */} +
+
setDateOn(e.target.value)} />
+
+ + setDateOff(e.target.value)} /> +
+
+
set('grid', e.target.value)} className="font-mono uppercase" />
+
+
+
set('comment', e.target.value)} />
+