diff --git a/app.go b/app.go index cfb9733..0aa73b1 100644 --- a/app.go +++ b/app.go @@ -14,10 +14,12 @@ import ( "time" "hamlog/internal/adif" + "hamlog/internal/applog" "hamlog/internal/backup" "hamlog/internal/cat" "hamlog/internal/cluster" "hamlog/internal/db" + "hamlog/internal/integrations/udp" "hamlog/internal/operating" "hamlog/internal/dxcc" "hamlog/internal/lookup" @@ -72,8 +74,33 @@ const ( keyBackupRotation = "backup.rotation" keyBackupZip = "backup.zip" keyBackupLast = "backup.last_at" + + keyQSLDefaultQSLSent = "qsl.qsl_sent" + keyQSLDefaultQSLRcvd = "qsl.qsl_rcvd" + keyQSLDefaultLOTWSent = "qsl.lotw_sent" + keyQSLDefaultLOTWRcvd = "qsl.lotw_rcvd" + keyQSLDefaultEQSLSent = "qsl.eqsl_sent" + keyQSLDefaultEQSLRcvd = "qsl.eqsl_rcvd" + keyQSLDefaultClublogStatus = "qsl.clublog_status" + keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status" ) +// QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload +// status fields. Applied to every QSO when the corresponding field is +// empty — both manual entry and UDP auto-log. Values are ADIF status +// codes: "Y" yes, "N" no, "R" requested, "Q" queued, "I" ignore, "" +// (empty) leaves the field untouched. +type QSLDefaults struct { + QSLSent string `json:"qsl_sent"` + QSLRcvd string `json:"qsl_rcvd"` + LOTWSent string `json:"lotw_sent"` + LOTWRcvd string `json:"lotw_rcvd"` + EQSLSent string `json:"eqsl_sent"` + EQSLRcvd string `json:"eqsl_rcvd"` + ClublogStatus string `json:"clublog_status"` + HRDLogStatus string `json:"hrdlog_status"` +} + // CATSettings is the user-tweakable rig-control configuration. Stored as // individual key/value pairs to keep the settings table flat. type CATSettings struct { @@ -155,6 +182,8 @@ type App struct { dxcc *dxcc.Manager cluster *cluster.Manager operating *operating.Repo + udp *udp.Manager + udpRepo *udp.Repo startupErr string // captured for surfacing to the frontend dbPath string @@ -277,19 +306,30 @@ func (a *App) startup(ctx context.Context) { dataDir, err := userDataDir() if err != nil { a.startupErr = "cannot resolve data dir: " + err.Error() - fmt.Println("HamLog:", a.startupErr) + fmt.Println("OpsLog:", a.startupErr) return } if err := os.MkdirAll(dataDir, 0o755); err != nil { a.startupErr = "cannot create data dir: " + err.Error() - fmt.Println("HamLog:", a.startupErr) + fmt.Println("OpsLog:", a.startupErr) return } - a.dbPath = filepath.Join(dataDir, "hamlog.db") + a.dbPath = filepath.Join(dataDir, "opslog.db") + // One-shot rename for users coming from the HamLog era. + if _, err := os.Stat(a.dbPath); os.IsNotExist(err) { + oldDB := filepath.Join(dataDir, "hamlog.db") + if _, err := os.Stat(oldDB); err == nil { + _ = os.Rename(oldDB, a.dbPath) + } + } + if _, err := applog.Init(dataDir); err != nil { + fmt.Println("OpsLog: log init:", err) + } + applog.Printf("startup: data dir = %s", dataDir) conn, err := db.Open(a.dbPath) if err != nil { a.startupErr = "cannot open db: " + err.Error() - fmt.Println("HamLog:", a.startupErr) + fmt.Println("OpsLog:", a.startupErr) return } a.db = conn @@ -297,6 +337,9 @@ func (a *App) startup(ctx context.Context) { a.settings = settings.NewStore(conn) a.profiles = profile.NewRepo(conn) a.operating = operating.NewRepo(conn) + a.udpRepo = udp.NewRepo(conn) + a.udp = udp.NewManager(a.udpRepo) + go a.consumeUDPEvents() // On first run, copy the legacy single-station settings into a // "Default" profile so the user's existing config carries over without // any manual step. Subsequent runs just confirm an active profile. @@ -308,7 +351,7 @@ func (a *App) startup(ctx context.Context) { SOTA: keyStationSOTA, POTA: keyStationPOTA, }); err != nil { - fmt.Println("HamLog: EnsureDefault profile:", err) + fmt.Println("OpsLog: EnsureDefault profile:", err) } a.cache = lookup.NewCache(conn, 30*24*time.Hour) a.lookup = lookup.NewManager(a.cache) @@ -321,10 +364,10 @@ func (a *App) startup(ctx context.Context) { a.lookup.SetDXCCResolver(dxccAdapter{m: a.dxcc}) go func() { if err := a.dxcc.EnsureLoaded(context.Background()); err != nil { - fmt.Println("HamLog: cty.dat unavailable —", err) + fmt.Println("OpsLog: cty.dat unavailable —", err) return } - fmt.Println("HamLog: cty.dat loaded —", a.dxcc.Info().Entities, "entities") + fmt.Println("OpsLog: cty.dat loaded —", a.dxcc.Info().Entities, "entities") }() // CAT manager: emit pushes state to the frontend via Wails events. a.cat = cat.NewManager(func(s cat.RigState) { @@ -368,8 +411,13 @@ func (a *App) startup(ctx context.Context) { if cs, _ := a.clusterAutoConnect(); cs { a.startAllEnabledClusters() } + if errs := a.udp.Reload(a.ctx); len(errs) > 0 { + for _, e := range errs { + fmt.Println("OpsLog: udp:", e) + } + } - fmt.Println("HamLog: db ready at", a.dbPath) + fmt.Println("OpsLog: db ready at", a.dbPath) } // StartupStatus returns a diagnostic snapshot for the frontend. @@ -502,17 +550,33 @@ func (a *App) shutdown(ctx context.Context) { if !a.shuttingDown { a.maybeShutdownBackup() } + if a.udp != nil { + a.udp.StopAll() + } if a.db != nil { _ = a.db.Close() } } +// userDataDir returns the OpsLog data directory under the user's config +// dir. The app was previously called HamLog — if the old folder exists +// and the new one doesn't, we rename it atomically so the user keeps +// their database, settings and cluster history through the rebrand. func userDataDir() (string, error) { base, err := os.UserConfigDir() if err != nil { return "", err } - return filepath.Join(base, "HamLog"), nil + newDir := filepath.Join(base, "OpsLog") + oldDir := filepath.Join(base, "HamLog") + if _, err := os.Stat(newDir); os.IsNotExist(err) { + if _, err := os.Stat(oldDir); err == nil { + // One-shot migration: HamLog → OpsLog. Best-effort: on + // failure we fall through and create OpsLog fresh. + _ = os.Rename(oldDir, newDir) + } + } + return newDir, nil } // reloadLookupProviders rebuilds the lookup chain from current settings. @@ -529,7 +593,7 @@ func (a *App) reloadLookupProviders() { keyQRZUser, keyQRZPassword, keyHQUser, keyHQPassword, keyCacheTTL, keyLookupPrimary, keyLookupFailsafe) if err != nil { - fmt.Println("HamLog: settings load error:", err) + fmt.Println("OpsLog: settings load error:", err) return } if days, _ := strconv.Atoi(m[keyCacheTTL]); days > 0 { @@ -582,9 +646,60 @@ func (a *App) AddQSO(q qso.QSO) (int64, error) { return 0, fmt.Errorf("db not initialized") } a.applyStationDefaults(&q) + a.applyDXCCNumber(&q) + a.applyQSLDefaults(&q) return a.qso.Add(a.ctx, q) } +// StationInfoComputed bundles the data we resolve live from the +// profile's callsign + grid: country, ARRL DXCC#, CQ zone, ITU zone, +// lat/lon. Used by the Settings UI to show the "what will be stamped on +// each QSO" preview next to the editable fields. +type StationInfoComputed struct { + Country string `json:"country"` + DXCC int `json:"dxcc"` + CQZ int `json:"cqz"` + ITUZ int `json:"ituz"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` +} + +// ComputeStationInfo resolves a station's structured metadata from the +// callsign (via cty.dat) and grid (via Maidenhead → lat/lon). The +// frontend calls this whenever Callsign or Grid changes in the Station +// Information panel so the user sees the auto-filled values live. +func (a *App) ComputeStationInfo(callsign, grid string) StationInfoComputed { + var out StationInfoComputed + if a.dxcc != nil && callsign != "" { + if m, ok := a.dxcc.Lookup(callsign); ok && m.Entity != nil { + out.Country = m.Entity.Name + out.CQZ = m.CQZone + out.ITUZ = m.ITUZone + out.Lat = m.Lat + out.Lon = m.Lon + out.DXCC = dxcc.EntityDXCC(m.Entity.Name) + } + } + // Grid wins on lat/lon — it's user-set, finer than the DXCC centroid. + if lat, lon, ok := gridToLatLon(grid); ok { + out.Lat = lat + out.Lon = lon + } + return out +} + +// applyDXCCNumber fills DXCC (contacted station) from the cty.dat entity +// name when it's empty. Same lookup as applyStationDefaults does for +// MY_DXCC — uses our entity-name → ADIF DXCC# table since cty.dat itself +// doesn't store the ARRL number. +func (a *App) applyDXCCNumber(q *qso.QSO) { + if q.DXCC == nil && q.Country != "" { + if n := dxcc.EntityDXCC(q.Country); n != 0 { + q.DXCC = &n + } + } +} + // applyStationDefaults fills any empty MY_* / station field on q with the // currently-active profile's values. Multi-profile support means a user // can be /P with a different callsign + grid + SOTA ref than home — the @@ -640,6 +755,53 @@ func (a *App) applyStationDefaults(q *qso.QSO) { v := *p.TxPower q.TXPower = &v } + // Resolve my zones / lat / lon via cty.dat using the profile's + // callsign. The profile only stores the human-friendly fields + // (callsign, grid, country name); cty.dat fills the structured + // DXCC metadata that the ADIF spec wants for every QSO. + if a.dxcc != nil && p.Callsign != "" { + if m, ok := a.dxcc.Lookup(p.Callsign); ok && m.Entity != nil { + if q.MyCQZone == nil && m.CQZone != 0 { + v := m.CQZone + q.MyCQZone = &v + } + if q.MyITUZone == nil && m.ITUZone != 0 { + v := m.ITUZone + q.MyITUZone = &v + } + if q.MyCountry == "" && m.Entity.Name != "" { + q.MyCountry = m.Entity.Name + } + if q.MyDXCC == nil { + if n := dxcc.EntityDXCC(m.Entity.Name); n != 0 { + q.MyDXCC = &n + } + } + // Lat/Lon: prefer the profile's grid (more precise than the + // DXCC entity centroid). Fall back to cty.dat coordinates. + if q.MyLat == nil || q.MyLon == nil { + if lat, lon, gOK := gridToLatLon(p.MyGrid); gOK { + if q.MyLat == nil { + v := lat + q.MyLat = &v + } + if q.MyLon == nil { + v := lon + q.MyLon = &v + } + } else { + if q.MyLat == nil && m.Lat != 0 { + v := m.Lat + q.MyLat = &v + } + if q.MyLon == nil && m.Lon != 0 { + v := m.Lon + q.MyLon = &v + } + } + } + } + } } func (a *App) ListQSO(f qso.ListFilter) ([]qso.QSO, error) { @@ -750,9 +912,9 @@ func (a *App) ImportADIF(path string, skipDuplicates bool) (adif.ImportResult, e } // SaveADIFFile shows a native Save-As dialog suggesting a timestamped -// HamLog_YYYYMMDD_HHMMSS.adi filename. Returns "" if the user cancelled. +// OpsLog_YYYYMMDD_HHMMSS.adi filename. Returns "" if the user cancelled. func (a *App) SaveADIFFile() (string, error) { - suggested := "HamLog_" + time.Now().UTC().Format("20060102_150405") + ".adi" + suggested := "OpsLog_" + time.Now().UTC().Format("20060102_150405") + ".adi" return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{ Title: "Export ADIF", DefaultFilename: suggested, @@ -772,7 +934,7 @@ func (a *App) ExportADIF(path string) (adif.ExportResult, error) { if path == "" { return adif.ExportResult{}, fmt.Errorf("empty path") } - ex := &adif.Exporter{Repo: a.qso, AppName: "HamLog", AppVersion: "0.1"} + ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1"} return ex.ExportFile(a.ctx, path) } @@ -989,6 +1151,276 @@ func (a *App) SaveCATSettings(s CATSettings) error { return nil } +// GetLogFilePath returns where the diagnostic log file lives so the user +// can open it from the Settings UI. Empty when applog hasn't initialised. +func (a *App) GetLogFilePath() string { + return applog.Path() +} + +// ── QSL defaults ────────────────────────────────────────────────────── + +// GetQSLDefaults returns the stored defaults — empty strings when the +// user hasn't configured anything (= leave QSO fields untouched). +func (a *App) GetQSLDefaults() (QSLDefaults, error) { + out := QSLDefaults{} + if a.settings == nil { + return out, nil + } + m, err := a.settings.GetMany(a.ctx, + keyQSLDefaultQSLSent, keyQSLDefaultQSLRcvd, + keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd, + keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd, + keyQSLDefaultClublogStatus, keyQSLDefaultHRDLogStatus, + ) + if err != nil { + return out, err + } + out.QSLSent = m[keyQSLDefaultQSLSent] + out.QSLRcvd = m[keyQSLDefaultQSLRcvd] + out.LOTWSent = m[keyQSLDefaultLOTWSent] + out.LOTWRcvd = m[keyQSLDefaultLOTWRcvd] + out.EQSLSent = m[keyQSLDefaultEQSLSent] + out.EQSLRcvd = m[keyQSLDefaultEQSLRcvd] + out.ClublogStatus = m[keyQSLDefaultClublogStatus] + out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus] + return out, nil +} + +// SaveQSLDefaults persists the configured defaults. Future QSO inserts +// pick them up automatically — no app restart needed. +func (a *App) SaveQSLDefaults(d QSLDefaults) error { + if a.settings == nil { + return fmt.Errorf("db not initialized") + } + for k, v := range map[string]string{ + keyQSLDefaultQSLSent: strings.ToUpper(strings.TrimSpace(d.QSLSent)), + keyQSLDefaultQSLRcvd: strings.ToUpper(strings.TrimSpace(d.QSLRcvd)), + keyQSLDefaultLOTWSent: strings.ToUpper(strings.TrimSpace(d.LOTWSent)), + keyQSLDefaultLOTWRcvd: strings.ToUpper(strings.TrimSpace(d.LOTWRcvd)), + keyQSLDefaultEQSLSent: strings.ToUpper(strings.TrimSpace(d.EQSLSent)), + keyQSLDefaultEQSLRcvd: strings.ToUpper(strings.TrimSpace(d.EQSLRcvd)), + keyQSLDefaultClublogStatus: strings.ToUpper(strings.TrimSpace(d.ClublogStatus)), + keyQSLDefaultHRDLogStatus: strings.ToUpper(strings.TrimSpace(d.HRDLogStatus)), + } { + if err := a.settings.Set(a.ctx, k, v); err != nil { + return err + } + } + return nil +} + +// applyQSLDefaults stamps the user-configured defaults onto a QSO when +// the corresponding fields are still empty. Called from every save path +// (manual entry via AddQSO, UDP auto-log via LogUDPLoggedADIF) so the +// confirmations columns always reflect the user's preferences. +func (a *App) applyQSLDefaults(q *qso.QSO) { + if a.settings == nil { + return + } + d, err := a.GetQSLDefaults() + if err != nil { + return + } + if q.QSLSent == "" { q.QSLSent = d.QSLSent } + if q.QSLRcvd == "" { q.QSLRcvd = d.QSLRcvd } + if q.LOTWSent == "" { q.LOTWSent = d.LOTWSent } + if q.LOTWRcvd == "" { q.LOTWRcvd = d.LOTWRcvd } + if q.EQSLSent == "" { q.EQSLSent = d.EQSLSent } + if q.EQSLRcvd == "" { q.EQSLRcvd = d.EQSLRcvd } + if q.ClublogUploadStatus == "" { q.ClublogUploadStatus = d.ClublogStatus } + if q.HRDLogUploadStatus == "" { q.HRDLogUploadStatus = d.HRDLogStatus } +} + +// ── UDP integrations ─────────────────────────────────────────────────── + +// ListUDPIntegrations returns every saved UDP connection row. +func (a *App) ListUDPIntegrations() ([]udp.Config, error) { + if a.udpRepo == nil { + return nil, fmt.Errorf("db not initialized") + } + return a.udpRepo.List(a.ctx) +} + +// SaveUDPIntegration upserts a UDP connection and reloads the manager so +// inbound listeners pick up the change without an app restart. Reload +// errors are surfaced — a "port already in use" failure should reach the +// user rather than be silently dropped. +func (a *App) SaveUDPIntegration(c udp.Config) (udp.Config, error) { + if a.udpRepo == nil { + return c, fmt.Errorf("db not initialized") + } + if err := a.udpRepo.Save(a.ctx, &c); err != nil { + return c, err + } + if a.udp != nil { + errs := a.udp.Reload(a.ctx) + if len(errs) > 0 { + return c, fmt.Errorf("listener errors: %s", strings.Join(errs, "; ")) + } + } + return c, nil +} + +// DeleteUDPIntegration removes a row and reloads the manager. +func (a *App) DeleteUDPIntegration(id int64) error { + if a.udpRepo == nil { + return fmt.Errorf("db not initialized") + } + if err := a.udpRepo.Delete(a.ctx, id); err != nil { + return err + } + if a.udp != nil { + a.udp.Reload(a.ctx) + } + return nil +} + +// ReloadUDPIntegrations is a no-arg way for the UI to force a restart +// (e.g. after toggling Enabled on a row). +func (a *App) ReloadUDPIntegrations() []string { + if a.udp == nil { + return nil + } + return a.udp.Reload(a.ctx) +} + +// LogUDPLoggedADIF takes an ADIF blob received over UDP and inserts the +// first record into the local logbook. Returns the ID of the inserted +// row. Used by the auto-log handler (WSJT-X / JTDX / MSHV / JTAlert). +func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) { + if a.qso == nil { + return 0, fmt.Errorf("db not initialized") + } + // 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. + var record adif.Record + err := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error { + if record == nil { + record = rec + } + return nil + }) + if err != nil { + return 0, fmt.Errorf("parse adif: %w", err) + } + 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 { + if record == nil { + record = rec + } + return nil + }) + if err != nil || record == nil { + return 0, fmt.Errorf("no valid QSO record in payload") + } + } + q, ok := adif.RecordToQSO(record) + if !ok { + return 0, fmt.Errorf("record missing required fields (call/band/mode/date)") + } + + // ── Lookup-based enrichment ── + // WSJT sends only call/freq/mode/RST/date. Fill Name/QTH/Country/ + // Grid/CQZ/ITUZ/DXCC/Continent via the lookup chain (QRZ/HamQTH/ + // cty.dat). Best-effort: a network failure shouldn't block the log. + if a.lookup != nil { + if lr, lerr := a.lookup.Lookup(a.ctx, q.Callsign); lerr == nil { + if q.Name == "" { q.Name = lr.Name } + if q.QTH == "" { q.QTH = lr.QTH } + if q.Country == "" { q.Country = lr.Country } + if q.Grid == "" { q.Grid = lr.Grid } + if q.Continent == "" { q.Continent = lr.Continent } + if q.State == "" { q.State = lr.State } + if q.County == "" { q.County = lr.County } + if q.Address == "" { q.Address = lr.Address } + if q.Email == "" { q.Email = lr.Email } + if q.DXCC == nil && lr.DXCC != 0 { v := lr.DXCC; q.DXCC = &v } + if q.CQZ == nil && lr.CQZ != 0 { v := lr.CQZ; q.CQZ = &v } + if q.ITUZ == nil && lr.ITUZ != 0 { v := lr.ITUZ; q.ITUZ = &v } + if q.Lat == nil && lr.Lat != 0 { v := lr.Lat; q.Lat = &v } + if q.Lon == nil && lr.Lon != 0 { v := lr.Lon; q.Lon = &v } + } + } + + // ── Operating-conditions stamp ── + // Pre-fill MY_RIG / MY_ANTENNA / TX_PWR from the default antenna for + // this band (if the user has configured Operating conditions). + if a.operating != nil && a.profiles != nil { + if p, err := a.profiles.Active(a.ctx); err == nil { + if d, ok2, _ := a.operating.BandDefault(a.ctx, p.ID, q.Band); ok2 { + if q.MyRig == "" { q.MyRig = d.StationName } + if q.MyAntenna == "" { q.MyAntenna = d.AntennaName } + if q.TXPower == nil && d.TXPower != nil { v := *d.TXPower; q.TXPower = &v } + } + } + } + + // ── DXCC# + QSL defaults ── + // applyDXCCNumber stamps the contacted-station DXCC# from the + // entity-name table; QSL defaults are applied last so explicit ADIF + // fields (or what the lookup gave us) always win. + a.applyDXCCNumber(&q) + a.applyQSLDefaults(&q) + + // ── Dedup ── + // Match by call + minute + band + mode (same key the importer uses). + seen, err := a.qso.ExistingDedupeKeys(a.ctx) + if err == nil { + key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode) + if _, dup := seen[key]; dup { + return 0, fmt.Errorf("duplicate (already in log)") + } + } + + id, err := a.qso.Add(a.ctx, q) + if err != nil { + return 0, fmt.Errorf("insert qso: %w", err) + } + return id, nil +} + +// consumeUDPEvents bridges parsed UDP events to the frontend over Wails' +// event bus. The frontend listens on: +// udp:dx_call → string callsign (also Grid/Mode/Freq when known) +// udp:logged_qso → ADIF text of a QSO that finished in WSJT-X/JTDX/MSHV +// udp:remote_call → string callsign from a remote-control source +func (a *App) consumeUDPEvents() { + if a.udp == nil { + return + } + for ev := range a.udp.Events() { + if a.ctx == nil { + continue + } + switch { + case ev.LoggedADIF != "": + applog.Printf("udp: emit udp:logged_qso (%d bytes ADIF)\n", len(ev.LoggedADIF)) + wruntime.EventsEmit(a.ctx, "udp:logged_qso", map[string]any{ + "config_id": ev.ConfigID, + "service": string(ev.Service), + "source": ev.Source, + "adif": ev.LoggedADIF, + }) + case ev.DXCall != "" && ev.Service == udp.ServiceRemoteCall: + applog.Printf("udp: emit udp:remote_call %q\n", ev.DXCall) + wruntime.EventsEmit(a.ctx, "udp:remote_call", ev.DXCall) + case ev.DXCall != "": + applog.Printf("udp: emit udp:dx_call %q (mode=%s freq=%d)\n", ev.DXCall, ev.Mode, ev.FreqHz) + wruntime.EventsEmit(a.ctx, "udp:dx_call", map[string]any{ + "call": ev.DXCall, + "grid": ev.DXGrid, + "mode": ev.Mode, + "freq_hz": ev.FreqHz, + "service": string(ev.Service), + "source": ev.Source, + }) + } + } +} + // ── Operating conditions ─────────────────────────────────────────────── // ListOperatingTree returns the stations/antennas/bands tree for the @@ -1173,7 +1605,7 @@ func (a *App) maybeShutdownBackup() { return } if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil { - fmt.Println("HamLog: shutdown backup failed:", err) + fmt.Println("OpsLog: shutdown backup failed:", err) return } _ = a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339)) @@ -1198,7 +1630,7 @@ func (a *App) PickBackupFolder() (string, error) { } defaultDir = firstExistingAncestor(defaultDir) return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{ - Title: "Pick a folder for HamLog backups", + Title: "Pick a folder for OpsLog backups", DefaultDirectory: defaultDir, }) } @@ -1645,7 +2077,7 @@ func (a *App) clusterAutoConnect() (bool, error) { func (a *App) startAllEnabledClusters() { servers, err := a.listClusterServers() if err != nil { - fmt.Println("HamLog: list cluster servers:", err) + fmt.Println("OpsLog: list cluster servers:", err) return } for _, s := range servers { diff --git a/build/appicon.png b/build/appicon.png index 63617fe..5f7fc0b 100644 Binary files a/build/appicon.png and b/build/appicon.png differ diff --git a/build/appicon.svg b/build/appicon.svg new file mode 100644 index 0000000..8fe0715 --- /dev/null +++ b/build/appicon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + diff --git a/build/windows/icon.ico b/build/windows/icon.ico index f334798..a13f9d2 100644 Binary files a/build/windows/icon.ico and b/build/windows/icon.ico differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a395174..3ed22f7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ import { ListClusterServers, ClusterSpotStatuses, GetCATSettings, OperatingDefaultForBand, + LogUDPLoggedADIF, } from '../wailsjs/go/main/App'; import { EventsOn } from '../wailsjs/runtime/runtime'; import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models'; @@ -246,7 +247,7 @@ export default function App() { // CAT — receives live rig state via Wails events. const [catState, setCatState] = useState({ enabled: false, connected: false } as any); - // Mode HamLog shows when the rig reports generic DIG_U/DIG_L. OmniRig + // Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig // can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default // in Preferences > Hardware > CAT interface. const digitalDefaultRef = useRef('FT8'); @@ -637,6 +638,35 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // ── UDP integration events ─────────────────────────────────────────── + // Live updates from external apps (WSJT-X / JTDX / MSHV / DXHunter…). + // We push the broadcast DX call into the entry field and auto-log any + // ADIF record that arrives. + useEffect(() => { + const unsubDX = EventsOn('udp:dx_call', (p: any) => { + const call = String(p?.call ?? '').trim(); + if (!call) return; + // Don't clobber what the user is currently typing — only update + // when the entry field is empty or matches a previous broadcast. + onCallsignInput(call); + }); + const unsubRC = EventsOn('udp:remote_call', (call: string) => { + if (call) onCallsignInput(String(call).trim()); + }); + const unsubLog = EventsOn('udp:logged_qso', async (p: any) => { + const text = String(p?.adif ?? '').trim(); + if (!text) return; + try { + await LogUDPLoggedADIF(text); + await refresh(); + } catch (e: any) { + setError('UDP auto-log: ' + String(e?.message ?? e)); + } + }); + return () => { unsubDX?.(); unsubRC?.(); unsubLog?.(); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // 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 @@ -941,7 +971,7 @@ export default function App() { { type: 'item', label: ctyRefreshing ? 'Refreshing cty.dat…' : 'Refresh cty.dat', action: 'tools.refreshCty', disabled: ctyRefreshing }, ]}, { name: 'help', label: 'Help', items: [ - { type: 'item', label: 'About HamLog', action: 'help.about', disabled: true }, + { type: 'item', label: 'About OpsLog', action: 'help.about', disabled: true }, ]}, ], [total, selectedId, ctyRefreshing, exporting]); @@ -1006,7 +1036,7 @@ export default function App() {
- HamLog + OpsLog
{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'} @@ -1027,7 +1057,7 @@ export default function App() {
- HamLog + OpsLog v0.1
diff --git a/frontend/src/components/RecentQSOsGrid.tsx b/frontend/src/components/RecentQSOsGrid.tsx index 42095d9..d8b1cb4 100644 --- a/frontend/src/components/RecentQSOsGrid.tsx +++ b/frontend/src/components/RecentQSOsGrid.tsx @@ -17,7 +17,7 @@ import { Checkbox } from '@/components/ui/checkbox'; // virtual-scroll — everything we want out of the box for a logbook table. ModuleRegistry.registerModules([AllCommunityModule]); -// Custom Quartz theme tuned to match HamLog's warm palette. +// Custom Quartz theme tuned to match OpsLog's warm palette. const hamlogTheme = themeQuartz.withParams({ fontFamily: 'inherit', fontSize: 12.5, @@ -40,8 +40,6 @@ const hamlogTheme = themeQuartz.withParams({ iconSize: 12, }); -const badgeCellClass = 'flex items-center'; - type Props = { rows: QSOForm[]; total: number; @@ -73,21 +71,6 @@ function fmtDateOnly(s: any): string { return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`; } -const bandPill = (p: any) => p.value - ? {p.value} - : ''; -const modePill = (p: any) => p.value - ? {p.value} - : ''; - // Full catalog of selectable columns, grouped for the picker. `defaultVisible` // = shown out of the box; anything else stays hidden until the user toggles // it in the Columns dialog. @@ -98,9 +81,9 @@ 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) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true }, { group: 'QSO', label: 'Date Off', colId: 'qso_date_off', headerName: 'Date off', field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) }, { group: 'QSO', label: 'Callsign', colId: 'callsign', headerName: 'Callsign', field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', cellStyle: { color: '#b8410c' }, defaultVisible: true }, - { group: 'QSO', label: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, cellClass: badgeCellClass, cellRenderer: bandPill, defaultVisible: true }, - { group: 'QSO', label: 'Band RX', colId: 'band_rx', headerName: 'Band RX', field: 'band_rx' as any, width: 75, cellClass: badgeCellClass, cellRenderer: bandPill }, - { group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: badgeCellClass, cellRenderer: modePill, defaultVisible: true }, + { group: 'QSO', label: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, cellClass: 'font-mono', defaultVisible: true }, + { group: 'QSO', label: 'Band RX', colId: 'band_rx', headerName: 'Band RX', field: 'band_rx' as any, width: 75, cellClass: 'font-mono' }, + { group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true }, { group: 'QSO', label: 'Submode', colId: 'submode', headerName: 'Submode', field: 'submode' as any, width: 80, cellClass: 'font-mono' }, { group: 'QSO', label: 'MHz (TX)', colId: 'freq_hz', headerName: 'MHz', field: 'freq_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true }, { group: 'QSO', label: 'MHz (RX)', colId: 'freq_rx_hz', headerName: 'MHz RX', field: 'freq_rx_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) }, @@ -175,8 +158,6 @@ const COL_CATALOG: ColEntry[] = [ { group: 'Propagation', label: 'Ant az', colId: 'ant_az', headerName: 'Az', field: 'ant_az' as any, width: 70, type: 'rightAligned' }, { group: 'Propagation', label: 'Ant el', colId: 'ant_el', headerName: 'El', field: 'ant_el' as any, width: 70, type: 'rightAligned' }, { group: 'Propagation', label: 'Ant path', colId: 'ant_path', headerName: 'Path', field: 'ant_path' as any, width: 70 }, - { group: 'Propagation', label: 'Rig', colId: 'rig', headerName: 'Rig', field: 'rig' as any, width: 120 }, - { group: 'Propagation', label: 'Antenna', colId: 'ant', headerName: 'Antenna', field: 'ant' as any, width: 140 }, // ── My station (operator side) ── { group: 'My station', label: 'Station call', colId: 'station_callsign', headerName: 'Station', field: 'station_callsign' as any, width: 100, cellClass: 'font-mono', defaultVisible: true }, diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index b26c81c..e38b472 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -16,6 +16,8 @@ import { ConnectClusterServer, DisconnectClusterServer, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder, + GetQSLDefaults, SaveQSLDefaults, + ComputeStationInfo, } from '../../wailsjs/go/main/App'; import type { profile as profileModels } from '../../wailsjs/go/models'; import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; @@ -35,6 +37,7 @@ import { } from '@/components/ui/select'; import { cn } from '@/lib/utils'; import { OperatingPanel } from '@/components/OperatingPanel'; +import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel'; type LookupSettings = LookupSettingsForm; type StationSettings = StationSettingsForm; @@ -98,7 +101,7 @@ const MODE_CATALOG: Array<{ name: string; sent: string; rcvd: string }> = [ const emptyProfile = (): Profile => ({ id: 0, name: '', - callsign: '', operator: '', + callsign: '', operator: '', owner_callsign: '', my_grid: '', my_country: '', my_state: '', my_cnty: '', my_street: '', my_city: '', my_postal_code: '', @@ -117,6 +120,45 @@ interface Props { onSaved: () => void; } +// Pretty little card showing what OpsLog will stamp on each QSO based on +// the callsign + grid in the Station Information form. Debounces the +// backend resolver so we don't fire on every keystroke; refreshes when +// inputs change. Empty card when no callsign yet. +function StationInfoComputedBadge({ callsign, grid }: { callsign: string; grid: string }) { + const [info, setInfo] = useState<{ + country: string; dxcc: number; cqz: number; ituz: number; lat: number; lon: number; + } | null>(null); + useEffect(() => { + const c = callsign.trim(); + if (!c) { setInfo(null); return; } + const t = window.setTimeout(async () => { + try { + const i = await ComputeStationInfo(c, grid.trim()); + setInfo(i as any); + } catch { setInfo(null); } + }, 200); + return () => window.clearTimeout(t); + }, [callsign, grid]); + if (!info || (!info.country && !info.cqz && !info.ituz)) { + return null; + } + return ( +
+
+ Auto-filled on each QSO (MY_*) +
+
+ {info.country && Country: {info.country}} + {info.dxcc > 0 && DXCC#: {info.dxcc}} + {info.cqz > 0 && CQ: {info.cqz}} + {info.ituz > 0 && ITU: {info.ituz}} + {info.lat !== 0 && Lat: {info.lat.toFixed(4)}} + {info.lon !== 0 && Lon: {info.lon.toFixed(4)}} +
+
+ ); +} + /* ====== Tree definition ====== Section IDs are stable strings — adding new ones means adding a panel below. `disabled: true` greys them out and shows the "coming soon" placeholder. */ @@ -124,6 +166,8 @@ type SectionId = | 'station' | 'profiles' | 'operating' + | 'confirmations' + | 'udp' | 'lookup' | 'lists-bands' | 'lists-modes' @@ -145,6 +189,7 @@ const TREE: TreeNode[] = [ { kind: 'item', label: 'Station Information', id: 'station' }, { kind: 'item', label: 'Profiles (portable, home, contest)', id: 'profiles' }, { kind: 'item', label: 'Operating conditions (rigs & antennas)', id: 'operating' }, + { kind: 'item', label: 'Confirmations (QSL / eQSL / LoTW defaults)', id: 'confirmations' }, ], }, { @@ -155,6 +200,7 @@ const TREE: TreeNode[] = [ { kind: 'item', label: 'Modes & default RST', id: 'lists-modes' }, ]}, { kind: 'item', label: 'DX Cluster', id: 'cluster' }, + { kind: 'item', label: 'UDP integrations (WSJT-X, JTDX, MSHV…)', id: 'udp' }, { kind: 'item', label: 'Database backup', id: 'backup' }, { kind: 'item', label: 'Awards', id: 'awards', disabled: true }, ], @@ -174,11 +220,13 @@ const SECTION_LABELS: Partial> = { station: 'Station Information', profiles: 'Profiles', operating: 'Operating conditions', + confirmations: 'Confirmations', lookup: 'Callsign Lookup', 'lists-bands': 'Bands', 'lists-modes': 'Modes & default RST', cluster: 'DX Cluster', backup: 'Database backup', + udp: 'UDP integrations', awards: 'Awards', cat: 'CAT interface', rotator: 'Rotator', @@ -316,6 +364,19 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { const [rotatorTesting, setRotatorTesting] = useState(false); const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null); + type QSLDefaults = { + qsl_sent: string; qsl_rcvd: string; + lotw_sent: string; lotw_rcvd: string; + eqsl_sent: string; eqsl_rcvd: string; + clublog_status: string; hrdlog_status: string; + }; + const [qslDefaults, setQslDefaults] = useState({ + qsl_sent: '', qsl_rcvd: '', + lotw_sent: '', lotw_rcvd: '', + eqsl_sent: '', eqsl_rcvd: '', + clublog_status: '', hrdlog_status: '', + }); + const [backupCfg, setBackupCfg] = useState({ enabled: false, folder: '', rotation: 5, zip: false, last_backup_at: '', default_folder: '', @@ -389,9 +450,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { useEffect(() => { (async () => { try { - const [l, ls, c, ap, r, b] = await Promise.all([ + const [l, ls, c, ap, r, b, qd] = await Promise.all([ GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(), - GetRotatorSettings(), GetBackupSettings(), + GetRotatorSettings(), GetBackupSettings(), GetQSLDefaults(), ]); setLookup(l); setActiveProfile(ap as Profile); @@ -401,6 +462,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { setCatCfg(c); setRotator(r); setBackupCfg(b as any); + setQslDefaults(qd as any); } catch (e: any) { setErr(String(e?.message ?? e)); } finally { @@ -519,6 +581,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { await SaveCATSettings(catCfg as any); await SaveRotatorSettings(rotator as any); await SaveBackupSettings(backupCfg as any); + await SaveQSLDefaults(qslDefaults as any); await SetClusterAutoConnect(clusterAutoConnect); setMsg('Settings saved.'); @@ -577,11 +640,19 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
updateActive({ callsign: e.target.value })} placeholder="F4XYZ" /> +
What's transmitted (ADIF STATION_CALLSIGN).
- + updateActive({ operator: e.target.value })} placeholder="F4XYZ" /> +
Who's at the radio (ADIF OPERATOR).
+
+ + updateActive({ owner_callsign: e.target.value })} placeholder="(leave blank if same as station)" /> +
Legal station owner — only differs at club stations or remote setups (ADIF STATION_OWNER).
+
+
updateActive({ my_grid: e.target.value })} placeholder="JN18BU" /> @@ -1106,7 +1177,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { <>

Configure your rig (COM port, baud rate, model) in OmniRig's own settings GUI first. - HamLog will read whichever Rig slot you select here. Set CAT delay + OpsLog will read whichever Rig slot you select here. Set CAT delay {' '}above 0 if your rig drops commands sent back-to-back (some older Kenwood/Yaesu). OmniRig only reports generic "DIG" for digital modes — Default digital mode - {' '}is the specific mode HamLog will surface (and log). + {' '}is the specific mode OpsLog will surface (and log).

@@ -1196,7 +1267,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { <>