diff --git a/README.md b/README.md index 2e62a37..2596cb2 100644 --- a/README.md +++ b/README.md @@ -14,3 +14,29 @@ to this in your browser, and you can call your Go code from devtools. ## Building To build a redistributable, production mode package, use `wails build`. + +## QSL Card Designer + +Tools → *QSL Card Designer…* turns a few photos into a polished eQSL card: + +1. Pick 1–6 photos (jpeg/png). OpsLog analyzes them offline (detail/luminance + grid) and proposes **3 designs** — callsign in the calmest zone of the best + photo, operator name, CQ/ITU zones + locator line, country flag, the other + photos as bordered inserts, and a per-QSO confirmation box. +2. Pick a proposal and fine-tune it: click an element to select, drag to move, + change font / style preset (gel gold, gel silver, classic white outline, + script, flat) and per-preset knobs in the right panel. +3. Save the template (photos are copied into `data/qsl/templates//`, so the + originals can move). One template can be the default per profile. + +Sending: right-click a QSO → *Send eQSL by e-mail*. The card is rendered with +that QSO's data, rasterized to a ≤ 800 KB JPEG, archived in `data/qsl/outbox/` +and sent through the configured SMTP account to the address found by the +QRZ/HamQTH lookup. On success the QSO is stamped `EQSL_SENT=Y` (ADIF). The +e-mail subject/body templates live in the designer +(`{CALL} {DATE} {BAND} {MODE} {MYCALL}` variables). + +Fonts: Archivo Black, Lilita One, Baloo 2, Oswald, Great Vibes, Allura (all +OFL, embedded — licenses in `internal/qslcard/assets/fonts/`); Cooper Black is +offered when MS Office installed it. Flags: flag-icons (MIT), embedded for the +commonly-worked DXCC entities. diff --git a/app.go b/app.go index 112004b..586b053 100644 --- a/app.go +++ b/app.go @@ -18,27 +18,28 @@ import ( "hamlog/internal/adif" "hamlog/internal/applog" - "hamlog/internal/backup" "hamlog/internal/audio" - "hamlog/internal/cat" - "hamlog/internal/clublog" "hamlog/internal/award" "hamlog/internal/awardref" + "hamlog/internal/backup" + "hamlog/internal/cat" + "hamlog/internal/clublog" "hamlog/internal/cluster" - "hamlog/internal/pota" "hamlog/internal/db" + "hamlog/internal/dxcc" "hamlog/internal/email" "hamlog/internal/extsvc" "hamlog/internal/integrations/udp" - "hamlog/internal/operating" - "hamlog/internal/dxcc" "hamlog/internal/lookup" + "hamlog/internal/operating" + "hamlog/internal/pota" "hamlog/internal/profile" + "hamlog/internal/qslcard" "hamlog/internal/qso" "hamlog/internal/rotator/pst" + "hamlog/internal/settings" "hamlog/internal/ultrabeam" "hamlog/internal/winkeyer" - "hamlog/internal/settings" wruntime "github.com/wailsapp/wails/v2/pkg/runtime" "go.bug.st/serial" @@ -72,11 +73,11 @@ const ( keyListsRSTDigital = "lists.rst_digital" keyCATEnabled = "cat.enabled" - keyCATBackend = "cat.backend" // "omnirig" (only one for now) - keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2 + keyCATBackend = "cat.backend" // "omnirig" (only one for now) + keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2 keyCATPollMs = "cat.poll_ms" - keyCATDelayMs = "cat.delay_ms" // pause between commands - keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA + keyCATDelayMs = "cat.delay_ms" // pause between commands + keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA // Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so // global (not per-profile) like CAT/rotator. Device fields store the @@ -118,10 +119,10 @@ const ( // file download. Visible in the binary but must not be exposed publicly. clublogAppAPIKey = "5767f19333363a9ef432ee9cd4141fe76b8adf38" - keyRotatorEnabled = "rotator.enabled" - keyRotatorHost = "rotator.host" - keyRotatorPort = "rotator.port" - keyRotatorHasElevation = "rotator.has_elevation" + keyRotatorEnabled = "rotator.enabled" + keyRotatorHost = "rotator.host" + keyRotatorPort = "rotator.port" + keyRotatorHasElevation = "rotator.has_elevation" // Ultrabeam antenna (TCP, e.g. via an RS232↔Ethernet adapter) — Hardware → Antenna. keyUltrabeamEnabled = "ultrabeam.enabled" @@ -146,10 +147,10 @@ const ( keyWKAutoSpace = "winkeyer.autospace" keyWKUsePTT = "winkeyer.use_ptt" keyWKSerialEcho = "winkeyer.serial_echo" - keyWKMacros = "winkeyer.macros" // JSON array of {label,text} - keyWKEngine = "winkeyer.engine" // "winkeyer" | "tci" + 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 + keyWKSendOnType = "winkeyer.send_on_type" // key characters live as typed keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start @@ -172,10 +173,10 @@ const ( // External services (logbook upload). QRZ.com first; Clublog / LoTW // will add their own keys under the same extsvc.* prefix. - keyExtQRZAPIKey = "extsvc.qrz.api_key" - keyExtQRZForceCall = "extsvc.qrz.force_station_callsign" - keyExtQRZAutoUpload = "extsvc.qrz.auto_upload" - keyExtQRZUploadMode = "extsvc.qrz.upload_mode" + keyExtQRZAPIKey = "extsvc.qrz.api_key" + keyExtQRZForceCall = "extsvc.qrz.force_station_callsign" + keyExtQRZAutoUpload = "extsvc.qrz.auto_upload" + keyExtQRZUploadMode = "extsvc.qrz.upload_mode" keyExtClublogEmail = "extsvc.clublog.email" keyExtClublogPassword = "extsvc.clublog.password" @@ -240,11 +241,11 @@ type ModePreset struct { // ListsSettings holds the user-customisable dropdown lists used by the // entry form. Default values match common HF/VHF practice. type ListsSettings struct { - Bands []string `json:"bands"` - Modes []ModePreset `json:"modes"` - RSTPhone []string `json:"rst_phone"` // RS reports for phone modes - RSTCW []string `json:"rst_cw"` // RST reports for CW/RTTY/PSK - RSTDigital []string `json:"rst_digital"` // dB reports for FT8/FT4/JT… + Bands []string `json:"bands"` + Modes []ModePreset `json:"modes"` + RSTPhone []string `json:"rst_phone"` // RS reports for phone modes + RSTCW []string `json:"rst_cw"` // RST reports for CW/RTTY/PSK + RSTDigital []string `json:"rst_digital"` // dB reports for FT8/FT4/JT… } var defaultBands = []string{ @@ -322,51 +323,52 @@ type StationSettings struct { // Primary / Failsafe hold a provider name ("qrz" | "hamqth" | "") to // route lookups: primary first, failsafe on not-found / error. type LookupSettings struct { - QRZUser string `json:"qrz_user"` - QRZPassword string `json:"qrz_password"` - HamQTHUser string `json:"hamqth_user"` - HamQTHPassword string `json:"hamqth_password"` - Primary string `json:"primary"` - Failsafe string `json:"failsafe"` - DownloadImages bool `json:"download_images"` // show QRZ profile pictures in the UI - CacheTTLDays int `json:"cache_ttl_days"` + QRZUser string `json:"qrz_user"` + QRZPassword string `json:"qrz_password"` + HamQTHUser string `json:"hamqth_user"` + HamQTHPassword string `json:"hamqth_password"` + Primary string `json:"primary"` + Failsafe string `json:"failsafe"` + DownloadImages bool `json:"download_images"` // show QRZ profile pictures in the UI + CacheTTLDays int `json:"cache_ttl_days"` } // App is the application context bound to the Wails runtime. type App struct { - ctx context.Context - db *sql.DB - qso *qso.Repo - settings *settings.Store - profiles *profile.Repo - lookup *lookup.Manager - cache *lookup.Cache - cat *cat.Manager - dxcc *dxcc.Manager - cluster *cluster.Manager - pota *pota.Cache - awardRefs *awardref.Repo - operating *operating.Repo - udp *udp.Manager - udpRepo *udp.Repo - extsvc *extsvc.Manager - winkeyer *winkeyer.Manager - clublog *clublog.Manager - ultrabeam *ultrabeam.Client // Ultrabeam antenna (TCP); nil when disabled - ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off - audioMgr *audio.Manager - qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) - dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord) - dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends - pttMu sync.Mutex - udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check - pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted - pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle - pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission) - startupErr string // captured for surfacing to the frontend - dbPath string // active database file (may be a user-chosen location) - dataDir string // /data — holds config.json, logs, cty.dat - migratedFromAppData bool // true when we auto-copied AppData on first portable launch + ctx context.Context + db *sql.DB + qso *qso.Repo + settings *settings.Store + profiles *profile.Repo + lookup *lookup.Manager + cache *lookup.Cache + cat *cat.Manager + dxcc *dxcc.Manager + cluster *cluster.Manager + pota *pota.Cache + awardRefs *awardref.Repo + qslTemplates *qslcard.Repo + operating *operating.Repo + udp *udp.Manager + udpRepo *udp.Repo + extsvc *extsvc.Manager + winkeyer *winkeyer.Manager + clublog *clublog.Manager + ultrabeam *ultrabeam.Client // Ultrabeam antenna (TCP); nil when disabled + ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off + audioMgr *audio.Manager + qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) + dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord) + dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends + pttMu sync.Mutex + udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check + pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted + pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle + pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission) + startupErr string // captured for surfacing to the frontend + dbPath string // active database file (may be a user-chosen location) + dataDir string // /data — holds config.json, logs, cty.dat + migratedFromAppData bool // true when we auto-copied AppData on first portable launch // shuttingDown gates beforeClose re-entry: the first user attempt to // close fires shutdown tasks (backup, future LoTW upload, ...) while @@ -545,8 +547,10 @@ func (a *App) startup(ctx context.Context) { a.db = conn a.qso = qso.NewRepo(conn) a.settings = settings.NewStore(conn) + a.settings.SetSensitivePredicate(isSensitiveSetting) // encrypt passwords at rest when a passphrase is set a.profiles = profile.NewRepo(conn) a.awardRefs = awardref.NewRepo(conn) + a.qslTemplates = qslcard.NewRepo(conn) a.migrateAwardDefs() // upgrade legacy award definitions (enable + new fields) a.seedBuiltinReferences() // first-run: populate built-in award reference lists a.operating = operating.NewRepo(conn) @@ -777,8 +781,8 @@ func (a *App) plannedShutdownSteps() []shutdownStep { if a.extsvc != nil { if n := a.extsvc.PendingCount(); n > 0 { out = append(out, shutdownStep{ - ID: "extsvc-upload", - Label: fmt.Sprintf("Uploading %d QSO(s) to online logbooks", n), + ID: "extsvc-upload", + Label: fmt.Sprintf("Uploading %d QSO(s) to online logbooks", n), Status: "pending", }) } @@ -1744,14 +1748,14 @@ type POTAUnmatched struct { // POTASyncResult summarises a hunter-log sync run for the UI. type POTASyncResult struct { - Fetched int `json:"fetched"` // hunter-log entries downloaded - Updated int `json:"updated"` // QSOs stamped/appended with a park ref - AlreadyTagged int `json:"already_tagged"` // already carried the park - Added int `json:"added"` // new QSOs inserted (addMissing) - Unmatched int `json:"unmatched"` // no local QSO and not added - UnmatchedList []POTAUnmatched `json:"unmatched_list"` // per-entry detail (capped) - SkippedOtherCall int `json:"skipped_other_call"` // hunts made under another callsign (onlyMyCall) - MyCall string `json:"my_call"` // the profile call used for the onlyMyCall filter + Fetched int `json:"fetched"` // hunter-log entries downloaded + Updated int `json:"updated"` // QSOs stamped/appended with a park ref + AlreadyTagged int `json:"already_tagged"` // already carried the park + Added int `json:"added"` // new QSOs inserted (addMissing) + Unmatched int `json:"unmatched"` // no local QSO and not added + UnmatchedList []POTAUnmatched `json:"unmatched_list"` // per-entry detail (capped) + SkippedOtherCall int `json:"skipped_other_call"` // hunts made under another callsign (onlyMyCall) + MyCall string `json:"my_call"` // the profile call used for the onlyMyCall filter } // SyncPOTAHunterLog downloads the user's POTA hunter log and stamps pota_ref on @@ -2815,8 +2819,8 @@ 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 = 1240, 158 - normalW, normalH = 1400, 900 + 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 @@ -2868,8 +2872,8 @@ func (a *App) OpenADIFFile() (string, error) { // 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 +// 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 @@ -3924,7 +3928,11 @@ func (a *App) DVKStopRecord() error { } // DVKCancelRecord aborts a recording without saving. -func (a *App) DVKCancelRecord() { if a.audioMgr != nil { a.audioMgr.CancelRecording() } } +func (a *App) DVKCancelRecord() { + if a.audioMgr != nil { + a.audioMgr.CancelRecording() + } +} // DVKPlay transmits a slot's message to the rig ("To Radio"), asserting serial // PTT (RTS/DTR) first unless the operator uses VOX. PTT is released @@ -4195,16 +4203,36 @@ func (a *App) applyQSLDefaults(q *qso.QSO) { 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 } - if q.QRZComUploadStatus == "" { q.QRZComUploadStatus = d.QRZComStatus } - if q.QRZComDownloadStatus == "" { q.QRZComDownloadStatus = d.QRZComCfm } + 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 + } + if q.QRZComUploadStatus == "" { + q.QRZComUploadStatus = d.QRZComStatus + } + if q.QRZComDownloadStatus == "" { + q.QRZComDownloadStatus = d.QRZComCfm + } } // ── External services (logbook upload) ───────────────────────────────── @@ -4305,12 +4333,12 @@ func (a *App) loadExternalServices() extsvc.ExternalServices { StationLocation: m[keyExtLoTWStationLoc], ForceStationCallsign: m[keyExtLoTWForceCall], KeyPassword: m[keyExtLoTWKeyPassword], - UploadFlag: m[keyExtLoTWUploadFlag], - WriteLog: m[keyExtLoTWWriteLog] == "1", - Username: m[keyExtLoTWUsername], - Password: m[keyExtLoTWWebPassword], - AutoUpload: m[keyExtLoTWAutoUpload] == "1", - UploadMode: extsvc.UploadMode(m[keyExtLoTWUploadMode]), + UploadFlag: m[keyExtLoTWUploadFlag], + WriteLog: m[keyExtLoTWWriteLog] == "1", + Username: m[keyExtLoTWUsername], + Password: m[keyExtLoTWWebPassword], + AutoUpload: m[keyExtLoTWAutoUpload] == "1", + UploadMode: extsvc.UploadMode(m[keyExtLoTWUploadMode]), } // Default the TQSL path to the standard install location when unset, so // the field is pre-populated if TQSL is present. @@ -5236,20 +5264,53 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) { // 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 } + 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 + } } } @@ -5268,9 +5329,16 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) { 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 } + 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 + } } } } @@ -5328,9 +5396,10 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) { // 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 +// +// 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 @@ -5447,11 +5516,11 @@ func (a *App) OperatingDefaultForBand(band string) (operating.BandDefault, error // BackupSettings is the user-tweakable database backup configuration. type BackupSettings struct { - Enabled bool `json:"enabled"` - Folder string `json:"folder"` - Rotation int `json:"rotation"` - Zip bool `json:"zip"` - LastBackupAt string `json:"last_backup_at"` + Enabled bool `json:"enabled"` + Folder string `json:"folder"` + Rotation int `json:"rotation"` + Zip bool `json:"zip"` + LastBackupAt string `json:"last_backup_at"` DefaultFolder string `json:"default_folder"` // computed, read-only — shown as a hint } @@ -5692,10 +5761,10 @@ func (a *App) ClearLookupCache() error { // if it hasn't been loaded yet). Exposed for the Maintenance menu so the // user can see what they're working with before triggering a refresh. type CtyDatInfo struct { - Path string `json:"path"` - Entities int `json:"entities"` - LoadedAt string `json:"loaded_at,omitempty"` // RFC3339, "" if not loaded - FileModTime string `json:"file_mod_time,omitempty"` // RFC3339, "" if missing + Path string `json:"path"` + Entities int `json:"entities"` + LoadedAt string `json:"loaded_at,omitempty"` // RFC3339, "" if not loaded + FileModTime string `json:"file_mod_time,omitempty"` // RFC3339, "" if missing } // GetCtyDatInfo returns metadata about the on-disk cty.dat. @@ -5923,10 +5992,10 @@ func (a *App) DuplicateProfile(id int64, newName string) (profile.Profile, error // RotatorSettings is the JSON shape for the Hardware → Rotator panel. type RotatorSettings struct { - Enabled bool `json:"enabled"` - Host string `json:"host"` // default 127.0.0.1 - Port int `json:"port"` // default 12000 - HasElevation bool `json:"has_elevation"` // include EL in GoTo packets + Enabled bool `json:"enabled"` + Host string `json:"host"` // default 127.0.0.1 + Port int `json:"port"` // default 12000 + HasElevation bool `json:"has_elevation"` // include EL in GoTo packets } // GetRotatorSettings returns the persisted rotator config with defaults. @@ -6201,12 +6270,12 @@ func (a *App) ultrabeamFollowLoop(c *ultrabeam.Client, stepKHz int, stop <-chan // direction control). Enabled mirrors the setting; the rest comes from the // device's most recent status poll. type UltrabeamStatusInfo struct { - Enabled bool `json:"enabled"` - Connected bool `json:"connected"` - Direction int `json:"direction"` // 0=normal, 1=180°, 2=bidirectional - Frequency int `json:"frequency"` // KHz - Band int `json:"band"` - Moving bool `json:"moving"` + Enabled bool `json:"enabled"` + Connected bool `json:"connected"` + Direction int `json:"direction"` // 0=normal, 1=180°, 2=bidirectional + Frequency int `json:"frequency"` // KHz + Band int `json:"band"` + Moving bool `json:"moving"` } // GetUltrabeamStatus returns the antenna's current state for the UI poll. @@ -6289,9 +6358,9 @@ type WKMacro struct { 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 + 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"` } diff --git a/app_qsl_designer.go b/app_qsl_designer.go new file mode 100644 index 0000000..31066a4 --- /dev/null +++ b/app_qsl_designer.go @@ -0,0 +1,589 @@ +package main + +// QSL card designer bindings. All logic lives in internal/qslcard — this file +// only adapts it to the Wails boundary (thin methods on *App, same receiver +// as app.go so bindings keep working). Template documents cross the bridge +// as JSON strings: the schema is the single source of truth and the frontend +// mirrors it in qslTypes.ts. + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "hamlog/internal/applog" + "hamlog/internal/dxcc" + "hamlog/internal/email" + "hamlog/internal/qslcard" + "hamlog/internal/qso" + + wruntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// Settings keys for the eQSL e-mail templates (subject/body share the +// {CALL}/{DATE}/{BAND}/{MODE}/{MYCALL} variables of the recording e-mail). +const ( + keyQSLEmailSubject = "qsl.email_subject" + keyQSLEmailBody = "qsl.email_body" +) + +const ( + defaultQSLEmailSubject = "eQSL — {CALL} de {MYCALL}" + defaultQSLEmailBody = "Hi,\n\nThank you for our QSO! Please find attached your eQSL card.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}" +) + +// qslDir is the root of all designer artifacts: templates// and outbox/. +func (a *App) qslDir() string { return filepath.Join(a.dataDir, "qsl") } + +// QSLTemplateInfo is the template-list row shown in the designer. +type QSLTemplateInfo struct { + ID int64 `json:"id"` + Name string `json:"name"` + ProfileID *int64 `json:"profile_id,omitempty"` + IsDefault bool `json:"is_default"` + UpdatedAt string `json:"updated_at"` +} + +// QSLFontInfo is one font face offered by the designer, TTF bytes included +// so the frontend can build @font-face rules (and inline them into the SVG +// before rasterization). +type QSLFontInfo struct { + Family string `json:"family"` + Kind string `json:"kind"` // "display" | "script" | "system" + Variable bool `json:"variable"` + DataB64 string `json:"data_b64"` +} + +// QSLPresetInfo describes one style preset for the picker. +type QSLPresetInfo struct { + Name string `json:"name"` + Label string `json:"label"` + Params []string `json:"params"` // accepted style_params keys + Defaults qslcard.StyleParams `json:"defaults"` +} + +// QSLPickPhotos opens a multi-select dialog for the designer drop zone's +// "browse" path (drag & drop hands the webview File objects without paths, +// so picking through the native dialog is the reliable route to real paths). +func (a *App) QSLPickPhotos() ([]string, error) { + return wruntime.OpenMultipleFilesDialog(a.ctx, wruntime.OpenDialogOptions{ + Title: "Choose card photos (1–6)", + Filters: []wruntime.FileFilter{ + {DisplayName: "Photos (*.jpg;*.jpeg;*.png)", Pattern: "*.jpg;*.jpeg;*.png"}, + }, + }) +} + +// QSLGenerateProposals analyzes the photos and returns 3 distinct template +// documents (JSON) from the automatic placement engine. +func (a *App) QSLGenerateProposals(photoPaths []string) ([]string, error) { + if len(photoPaths) == 0 { + return nil, fmt.Errorf("no photos selected") + } + if len(photoPaths) > 6 { + return nil, fmt.Errorf("at most 6 photos (got %d)", len(photoPaths)) + } + photos := make([]qslcard.PhotoAnalysis, 0, len(photoPaths)) + for _, p := range photoPaths { + ph, err := qslcard.AnalyzePhoto(p) + if err != nil { + return nil, fmt.Errorf("analyze %s: %w", filepath.Base(p), err) + } + photos = append(photos, ph) + } + info, err := a.qslProfileInfo() + if err != nil { + return nil, err + } + var engine qslcard.LayoutEngine = qslcard.HeuristicEngine{} + templates, err := engine.Propose(photos, info) + if err != nil { + return nil, err + } + out := make([]string, len(templates)) + for i, t := range templates { + b, err := qslcard.Encode(t) + if err != nil { + return nil, err + } + out[i] = string(b) + } + return out, nil +} + +// QSLListTemplates returns all saved templates (defaults first). +func (a *App) QSLListTemplates() ([]QSLTemplateInfo, error) { + if a.qslTemplates == nil { + return nil, fmt.Errorf("db not initialized") + } + recs, err := a.qslTemplates.List(a.ctx) + if err != nil { + return nil, err + } + out := make([]QSLTemplateInfo, 0, len(recs)) + for _, r := range recs { + out = append(out, QSLTemplateInfo{ + ID: r.ID, Name: r.Name, ProfileID: r.ProfileID, + IsDefault: r.IsDefault, UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04"), + }) + } + return out, nil +} + +// QSLGetTemplate returns a stored template document (JSON). +func (a *App) QSLGetTemplate(id int64) (string, error) { + if a.qslTemplates == nil { + return "", fmt.Errorf("db not initialized") + } + rec, err := a.qslTemplates.Get(a.ctx, id) + if err != nil { + return "", err + } + return rec.JSON, nil +} + +// QSLSaveTemplate validates and stores a template. id 0 creates; the photos +// the document references at their original (absolute) paths are copied into +// the template's asset folder so the design survives the user moving files. +// Returns the template id. +func (a *App) QSLSaveTemplate(id int64, name string, doc string, forActiveProfile bool) (int64, error) { + if a.qslTemplates == nil { + return 0, fmt.Errorf("db not initialized") + } + name = strings.TrimSpace(name) + if name == "" { + return 0, fmt.Errorf("template name required") + } + t, err := qslcard.Parse([]byte(doc)) + if err != nil { + return 0, err + } + t.Name = name + if err := qslcard.Validate(t, nil); err != nil { + return 0, err + } + + rec := qslcard.Record{ID: id, Name: name} + if forActiveProfile { + if p, err := a.profiles.Active(a.ctx); err == nil { + rec.ProfileID = &p.ID + } + } + // Two-phase save: the row first (a new template needs its id to own an + // asset folder), then photo import + the final document. + rec.JSON = doc + if err := a.qslTemplates.Save(a.ctx, &rec); err != nil { + return 0, err + } + dir := qslcard.TemplateDir(a.qslDir(), rec.ID) + if err := qslcard.ImportPhotos(&t, dir); err != nil { + return 0, err + } + if err := qslcard.Validate(t, qslcard.PhotoExistsIn(dir)); err != nil { + return 0, err + } + final, err := qslcard.Encode(t) + if err != nil { + return 0, err + } + rec.JSON = string(final) + if err := a.qslTemplates.Save(a.ctx, &rec); err != nil { + return 0, err + } + applog.Printf("qsl: template %q saved (id %d)", name, rec.ID) + return rec.ID, nil +} + +// QSLSetDefaultTemplate marks a template as default for its profile scope. +func (a *App) QSLSetDefaultTemplate(id int64) error { + if a.qslTemplates == nil { + return fmt.Errorf("db not initialized") + } + return a.qslTemplates.SetDefault(a.ctx, id) +} + +// QSLDeleteTemplate removes a template and its asset folder. +func (a *App) QSLDeleteTemplate(id int64) error { + if a.qslTemplates == nil { + return fmt.Errorf("db not initialized") + } + if err := a.qslTemplates.Delete(a.ctx, id); err != nil { + return err + } + return qslcard.RemoveTemplateDir(a.qslDir(), id) +} + +// QSLSavePreview stores the rasterized thumbnail (PNG, base64) the frontend +// renders at save time, shown in the template list. +func (a *App) QSLSavePreview(id int64, pngB64 string) error { + data, err := base64.StdEncoding.DecodeString(pngB64) + if err != nil { + return fmt.Errorf("decode preview: %w", err) + } + dir := qslcard.TemplateDir(a.qslDir(), id) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create template dir: %w", err) + } + if err := os.WriteFile(filepath.Join(dir, "preview.png"), data, 0o644); err != nil { + return fmt.Errorf("write preview: %w", err) + } + return nil +} + +// QSLPreviewDataURL returns the stored thumbnail as a data URL ("" if the +// template has none yet). +func (a *App) QSLPreviewDataURL(id int64) (string, error) { + data, err := os.ReadFile(filepath.Join(qslcard.TemplateDir(a.qslDir(), id), "preview.png")) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("read preview: %w", err) + } + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(data), nil +} + +// QSLPhotoDataURL returns a template photo as a data URL. templateID 0 reads +// a draft photo at its original absolute path (pre-save editing); otherwise +// ref is a name inside the template's asset folder. +func (a *App) QSLPhotoDataURL(templateID int64, ref string) (string, error) { + var path string + switch { + case templateID > 0 && !qslcard.IsDraftPhoto(ref): + path = filepath.Join(qslcard.TemplateDir(a.qslDir(), templateID), filepath.Base(ref)) + case qslcard.IsDraftPhoto(ref): + path = ref + default: + return "", fmt.Errorf("photo %q not available without a template id", ref) + } + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read photo: %w", err) + } + mime := "image/jpeg" + if strings.EqualFold(filepath.Ext(path), ".png") { + mime = "image/png" + } + return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil +} + +// QSLFlagDataURL returns an embedded flag SVG as a data URL ("" = no flag). +func (a *App) QSLFlagDataURL(iso string) (string, error) { + if iso == "" { + return "", nil + } + svg, err := qslcard.FlagSVG(iso) + if err != nil { + return "", err + } + return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString(svg), nil +} + +// QSLFonts returns the embedded designer fonts (plus Cooper Black when the +// machine has it) for @font-face registration. +func (a *App) QSLFonts() ([]QSLFontInfo, error) { + fonts, err := qslcard.Fonts() + if err != nil { + return nil, err + } + out := make([]QSLFontInfo, 0, len(fonts)) + for _, f := range fonts { + out = append(out, QSLFontInfo{ + Family: f.Family, Kind: f.Kind, Variable: f.Variable, + DataB64: base64.StdEncoding.EncodeToString(f.Data), + }) + } + return out, nil +} + +// QSLStylePresets lists the built-in style presets for the picker. +func (a *App) QSLStylePresets() []QSLPresetInfo { + out := make([]QSLPresetInfo, 0, len(qslcard.Presets)) + for _, p := range qslcard.Presets { + params := make([]string, 0, len(p.AllowedParams)) + for k := range p.AllowedParams { + params = append(params, k) + } + sort.Strings(params) + out = append(out, QSLPresetInfo{Name: p.Name, Label: p.Label, Params: params, Defaults: p.Defaults}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// QSLResolvePreview fills a template's placeholders with the active profile +// and a representative sample QSO, for the live editor preview. Returns the +// RenderModel as JSON. +func (a *App) QSLResolvePreview(doc string) (string, error) { + t, err := qslcard.Parse([]byte(doc)) + if err != nil { + return "", err + } + vars, country, err := a.qslVars(sampleQSO()) + if err != nil { + return "", err + } + return encodeRenderModel(qslcard.Resolve(t, vars, country)) +} + +// QSLDefaultTemplateID returns the template the eQSL send flow should use +// for the active profile (0 when none exists yet). +func (a *App) QSLDefaultTemplateID() (int64, error) { + if a.qslTemplates == nil { + return 0, fmt.Errorf("db not initialized") + } + p, err := a.profiles.Active(a.ctx) + if err != nil { + return 0, fmt.Errorf("no active profile: %w", err) + } + rec, err := a.qslTemplates.DefaultFor(a.ctx, p.ID) + if err != nil { + return 0, nil // no templates yet — the UI offers to open the designer + } + return rec.ID, nil +} + +// RenderEQSL loads a QSO and a template and returns the fully-resolved +// render model (JSON) for the frontend to rasterize. +func (a *App) RenderEQSL(qsoID int64, templateID int64) (string, error) { + if a.qso == nil || a.qslTemplates == nil { + return "", fmt.Errorf("db not initialized") + } + q, err := a.qso.GetByID(a.ctx, qsoID) + if err != nil { + return "", err + } + rec, err := a.qslTemplates.Get(a.ctx, templateID) + if err != nil { + return "", fmt.Errorf("template %d: %w", templateID, err) + } + t, err := qslcard.Parse([]byte(rec.JSON)) + if err != nil { + return "", err + } + dir := qslcard.TemplateDir(a.qslDir(), templateID) + if err := qslcard.Validate(t, qslcard.PhotoExistsIn(dir)); err != nil { + return "", err + } + vars, country, err := a.qslVars(q) + if err != nil { + return "", err + } + return encodeRenderModel(qslcard.Resolve(t, vars, country)) +} + +// SendEQSL e-mails the rasterized card (JPEG, base64) to the QSO's +// correspondent, archives it in the outbox and stamps eqsl_sent on success. +func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error { + if a.qso == nil { + return fmt.Errorf("db not initialized") + } + q, err := a.qso.GetByID(a.ctx, qsoID) + if err != nil { + return err + } + to := strings.TrimSpace(q.Email) + if to == "" { + return fmt.Errorf("no e-mail address for %s — run a QRZ/HamQTH lookup first", q.Callsign) + } + data, err := base64.StdEncoding.DecodeString(jpegB64) + if err != nil { + return fmt.Errorf("decode card image: %w", err) + } + outbox := filepath.Join(a.qslDir(), "outbox") + if err := os.MkdirAll(outbox, 0o755); err != nil { + return fmt.Errorf("create outbox: %w", err) + } + safeCall := strings.Map(func(r rune) rune { + if r == '/' || r == '\\' || r == ':' { + return '_' + } + return r + }, q.Callsign) + path := filepath.Join(outbox, fmt.Sprintf("%s_%s.jpg", safeCall, q.QSODate.UTC().Format("20060102_1504"))) + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("write outbox file: %w", err) + } + + s, _ := a.GetEmailSettings() + subject, _ := a.settings.Get(a.ctx, keyQSLEmailSubject) + if subject == "" { + subject = defaultQSLEmailSubject + } + body, _ := a.settings.Get(a.ctx, keyQSLEmailBody) + if body == "" { + body = defaultQSLEmailBody + } + if err := email.Send(a.emailConfig(s), to, a.fillTemplate(subject, q), a.fillTemplate(body, q), path); err != nil { + applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err) + return err + } + if err := a.qso.MarkEQSLSent(a.ctx, qsoID, time.Now().UTC().Format("20060102")); err != nil { + applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err) + return fmt.Errorf("eQSL sent but status not saved: %w", err) + } + applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign) + wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID) + return nil +} + +// QSLEmailTemplates is the subject/body pair of the eQSL e-mail. +type QSLEmailTemplates struct { + Subject string `json:"subject"` + Body string `json:"body"` +} + +// QSLGetEmailTemplates returns the eQSL e-mail templates (with defaults). +func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) { + out := QSLEmailTemplates{Subject: defaultQSLEmailSubject, Body: defaultQSLEmailBody} + if a.settings == nil { + return out, nil + } + m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody) + if err != nil { + return out, err + } + if s := m[keyQSLEmailSubject]; s != "" { + out.Subject = s + } + if b := m[keyQSLEmailBody]; b != "" { + out.Body = b + } + return out, nil +} + +// QSLSaveEmailTemplates persists the eQSL e-mail templates. +func (a *App) QSLSaveEmailTemplates(t QSLEmailTemplates) error { + if a.settings == nil { + return fmt.Errorf("db not initialized") + } + if err := a.settings.Set(a.ctx, keyQSLEmailSubject, t.Subject); err != nil { + return err + } + return a.settings.Set(a.ctx, keyQSLEmailBody, t.Body) +} + +// ── helpers ───────────────────────────────────────────────────────────── + +func encodeRenderModel(m qslcard.RenderModel) (string, error) { + b, err := json.Marshal(m) + if err != nil { + return "", fmt.Errorf("encode render model: %w", err) + } + return string(b), nil +} + +// qslProfileInfo extracts the placement-engine inputs from the active +// profile, filling missing zones from cty.dat. +func (a *App) qslProfileInfo() (qslcard.ProfileInfo, error) { + if a.profiles == nil { + return qslcard.ProfileInfo{}, fmt.Errorf("db not initialized") + } + p, err := a.profiles.Active(a.ctx) + if err != nil { + return qslcard.ProfileInfo{}, fmt.Errorf("no active profile: %w", err) + } + info := qslcard.ProfileInfo{ + Callsign: p.Callsign, + Operator: p.OpName, // personal name for the QSL script (not the OPERATOR callsign) + Grid: p.MyGrid, + } + if p.MyCQZone != nil { + info.CQZone = *p.MyCQZone + } + if p.MyITUZone != nil { + info.ITUZone = *p.MyITUZone + } + if (info.CQZone == 0 || info.ITUZone == 0) && a.dxcc != nil { + if m, ok := a.dxcc.Lookup(p.Callsign); ok { + if info.CQZone == 0 { + info.CQZone = m.CQZone + } + if info.ITUZone == 0 { + info.ITUZone = m.ITUZone + } + } + } + return info, nil +} + +// qslVars builds the full placeholder map (profile + QSO) and the country +// block resolution for one render. +func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error) { + info, err := a.qslProfileInfo() + if err != nil { + return nil, qslcard.CountryInfo{}, err + } + zone := func(z int) string { // unknown zone reads better blank than "0" + if z == 0 { + return "" + } + return strconv.Itoa(z) + } + vars := map[string]string{ + "profile.callsign": info.Callsign, + "profile.operator_name": info.Operator, + "profile.grid": info.Grid, + "profile.cq_zone": zone(info.CQZone), + "profile.itu_zone": zone(info.ITUZone), + + "qso.callsign": q.Callsign, + "qso.qso_date": q.QSODate.UTC().Format("2006-01-02"), + "qso.time_on": q.QSODate.UTC().Format("15:04"), + "qso.band": q.Band, + "qso.mode": q.Mode, + "qso.submode": q.Submode, + "qso.rst_sent": q.RSTSent, + "qso.qsl_msg": q.QSLMsg, + "qso.name": q.Name, + } + if q.FreqHz != nil { + vars["qso.freq"] = strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64) + " MHz" + } + return vars, a.qslCountryInfo(), nil +} + +// qslCountryInfo resolves the operator's own country (label + flag) from the +// active profile: explicit MY_DXCC wins, then a cty.dat lookup of the +// callsign. No match → label only or nothing (graceful). +func (a *App) qslCountryInfo() qslcard.CountryInfo { + p, err := a.profiles.Active(a.ctx) + if err != nil { + return qslcard.CountryInfo{} + } + out := qslcard.CountryInfo{Label: p.MyCountry} + entityNum := 0 + if p.MyDXCC != nil { + entityNum = *p.MyDXCC + if out.Label == "" { + out.Label = dxcc.NameForDXCC(entityNum) + } + } else if a.dxcc != nil { + if m, ok := a.dxcc.Lookup(p.Callsign); ok && m.Entity != nil { + if out.Label == "" { + out.Label = m.Entity.Name + } + entityNum = dxcc.EntityDXCC(m.Entity.Name) + } + } + out.FlagISO = qslcard.FlagISO(entityNum) + return out +} + +// sampleQSO is the canned contact used by the editor's live preview. +func sampleQSO() qso.QSO { + return qso.QSO{ + Callsign: "DL1ABC", + QSODate: time.Date(2026, 5, 17, 14, 2, 0, 0, time.UTC), + Band: "20m", + Mode: "SSB", + RSTSent: "59", + QSLMsg: "TNX FB QSO — 73!", + } +} diff --git a/app_secret.go b/app_secret.go new file mode 100644 index 0000000..1e61ab1 --- /dev/null +++ b/app_secret.go @@ -0,0 +1,169 @@ +package main + +import ( + "encoding/base64" + "fmt" + + "hamlog/internal/applog" + "hamlog/internal/secret" +) + +// Secret-vault settings keys (NOT themselves sensitive — they hold the salt and +// an encrypted verifier token, never a password). +const ( + keySecretSalt = "secret.salt" // base64 PBKDF2 salt + keySecretVerifier = "secret.verifier" // enc:v1: token, validates the passphrase +) + +// sensitiveSettingKeys are the password fields encrypted at rest when the user +// sets a passphrase. Everything else stays plaintext. +var sensitiveSettingKeys = map[string]bool{ + keyQRZPassword: true, + keyHQPassword: true, + keyEmailPassword: true, + keyExtClublogPassword: true, + keyExtLoTWKeyPassword: true, + keyExtLoTWWebPassword: true, +} + +func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] } + +// SecretStatus tells the UI whether a passphrase is configured and unlocked. +type SecretStatus struct { + HasPassphrase bool `json:"has_passphrase"` + Unlocked bool `json:"unlocked"` +} + +// GetSecretStatus is polled at launch: if HasPassphrase && !Unlocked the UI +// shows the unlock prompt. +func (a *App) GetSecretStatus() SecretStatus { + if a.settings == nil { + return SecretStatus{} + } + salt, _ := a.settings.GetRaw(a.ctx, keySecretSalt) + return SecretStatus{HasPassphrase: salt != "", Unlocked: a.settings.Unlocked()} +} + +// reloadAfterSecretChange rebuilds anything that read passwords at startup while +// they were still locked (lookup providers + external-service config). +func (a *App) reloadAfterSecretChange() { + a.reloadLookupProviders() + if a.extsvc != nil { + a.extsvc.SetConfig(a.loadExternalServices()) + } +} + +// UnlockSecrets validates the passphrase against the stored verifier and, on +// success, installs the cipher so passwords decrypt for the rest of the session. +func (a *App) UnlockSecrets(passphrase string) error { + if a.settings == nil { + return fmt.Errorf("not initialized") + } + saltB64, _ := a.settings.GetRaw(a.ctx, keySecretSalt) + verifier, _ := a.settings.GetRaw(a.ctx, keySecretVerifier) + if saltB64 == "" || verifier == "" { + return fmt.Errorf("no passphrase configured") + } + salt, err := base64.StdEncoding.DecodeString(saltB64) + if err != nil { + return fmt.Errorf("corrupt salt") + } + key, err := secret.DeriveKey(passphrase, salt) + if err != nil { + return fmt.Errorf("derive key: %w", err) + } + c, err := secret.New(key) + if err != nil { + return fmt.Errorf("cipher: %w", err) + } + if !c.CheckVerifier(verifier) { + return fmt.Errorf("wrong passphrase") + } + a.settings.Unlock(c) + a.reloadAfterSecretChange() + return nil +} + +// SetPassphrase sets a new passphrase or changes an existing one, then +// re-encrypts every stored secret under the new key. Changing an existing +// passphrase requires the vault to be unlocked first (so current secrets are +// readable as plaintext). +func (a *App) SetPassphrase(passphrase string) error { + if a.settings == nil { + return fmt.Errorf("not initialized") + } + if len(passphrase) < 4 { + return fmt.Errorf("passphrase too short (min 4 characters)") + } + saltB64, _ := a.settings.GetRaw(a.ctx, keySecretSalt) + if saltB64 != "" && !a.settings.Unlocked() { + return fmt.Errorf("unlock with the current passphrase first") + } + // Snapshot current plaintext (Get decrypts when unlocked, passes through + // when no passphrase yet). + current := map[string]string{} + for k := range sensitiveSettingKeys { + if v, _ := a.settings.Get(a.ctx, k); v != "" { + current[k] = v + } + } + salt, err := secret.NewSalt() + if err != nil { + return fmt.Errorf("salt: %w", err) + } + key, err := secret.DeriveKey(passphrase, salt) + if err != nil { + return fmt.Errorf("derive key: %w", err) + } + c, err := secret.New(key) + if err != nil { + return fmt.Errorf("cipher: %w", err) + } + // Persist salt + verifier first so an interrupted migration is still + // recoverable (the prompt appears next launch; plaintext secrets read + // through transparently until re-encrypted). + if err := a.settings.SetRaw(a.ctx, keySecretSalt, base64.StdEncoding.EncodeToString(salt)); err != nil { + return err + } + if err := a.settings.SetRaw(a.ctx, keySecretVerifier, c.MakeVerifier()); err != nil { + return err + } + a.settings.Unlock(c) + for k, v := range current { + if err := a.settings.Set(a.ctx, k, v); err != nil { // now encrypts + applog.Printf("secret: re-encrypt %q failed: %v", k, err) + } + } + a.reloadAfterSecretChange() + return nil +} + +// RemovePassphrase decrypts all secrets back to plaintext and clears the vault. +// Requires the current passphrase (validated) so it can't be cleared blindly. +func (a *App) RemovePassphrase(passphrase string) error { + if a.settings == nil { + return fmt.Errorf("not initialized") + } + if !a.settings.Unlocked() { + if err := a.UnlockSecrets(passphrase); err != nil { + return err + } + } + // Read decrypted, then store back as plaintext and drop salt/verifier. + plain := map[string]string{} + for k := range sensitiveSettingKeys { + if v, _ := a.settings.Get(a.ctx, k); v != "" { + plain[k] = v + } + } + a.settings.Lock() + for k, v := range plain { + if err := a.settings.SetRaw(a.ctx, k, v); err != nil { + applog.Printf("secret: decrypt-on-remove %q failed: %v", k, err) + } + } + _ = a.settings.SetRaw(a.ctx, keySecretSalt, "") + _ = a.settings.SetRaw(a.ctx, keySecretVerifier, "") + a.reloadAfterSecretChange() + return nil +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fe00931..790ac17 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { WorkedBefore, SetCompactMode, GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, + GetSecretStatus, UnlockSecrets, RefreshCtyDat, RotatorGoTo, RotatorStop, GetRotatorHeading, GetUltrabeamStatus, SetUltrabeamDirection, @@ -38,6 +39,8 @@ import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, import { Menubar, type Menu } from '@/components/Menubar'; import { QSLManagerPanel } from '@/components/QSLManagerModal'; +import { QslDesignerModal } from '@/components/qsl/QslDesignerModal'; +import { SendEQSLModal } from '@/components/qsl/SendEQSLModal'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { SettingsModal } from '@/components/SettingsModal'; import { QSOEditModal } from '@/components/QSOEditModal'; @@ -430,6 +433,18 @@ export default function App() { const [total, setTotal] = useState(0); const [error, setError] = useState(''); const [migratedBanner, setMigratedBanner] = useState(false); + // Secret vault (encrypted passwords): prompt to unlock at launch when a + // passphrase is configured but not yet entered this session. + const [unlockOpen, setUnlockOpen] = useState(false); + const [unlockPass, setUnlockPass] = useState(''); + const [unlockErr, setUnlockErr] = useState(''); + const [unlockBusy, setUnlockBusy] = useState(false); + const doUnlock = async () => { + setUnlockBusy(true); setUnlockErr(''); + try { await UnlockSecrets(unlockPass); setUnlockOpen(false); setUnlockPass(''); } + catch (e: any) { setUnlockErr(String(e?.message ?? e)); } + finally { setUnlockBusy(false); } + }; // Transient success toast (bottom-right, auto-dismiss). Used for things // like "spot sent" where a blocking error banner would be overkill. const [toast, setToast] = useState(''); @@ -460,9 +475,14 @@ export default function App() { const id = window.setInterval(() => setRecSeconds(Math.floor((Date.now() - start) / 1000)), 1000); return () => window.clearInterval(id); }, [recording, recTick]); - // restartRecordingForNewTarget (re)starts the take for a new programmatic - // target (clicked spot / external app via UDP) and resets the elapsed timer. - const restartRecordingForNewTarget = () => { + // The callsign the in-progress recording belongs to (uppercased; '' = none). + // Lets us restart from zero when the operator edits the call to a different + // station mid-recording, instead of continuing the old take. + const recordingCallRef = useRef(''); + // restartRecordingForNewTarget (re)starts the take for a new target (clicked + // spot / external app / edited callsign) and resets the elapsed timer. + const restartRecordingForNewTarget = (forCall?: string) => { + if (forCall !== undefined) recordingCallRef.current = forCall.trim().toUpperCase(); QSOAudioRestart().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {}); }; const [saving, setSaving] = useState(false); @@ -474,6 +494,8 @@ export default function App() { const [activeTab, setActiveTab] = useState('recent'); // QSL Manager is a closable tab opened on demand from Tools → QSL Manager. const [qslTabOpen, setQslTabOpen] = useState(false); + const [qslDesignerOpen, setQslDesignerOpen] = useState(false); + const [eqslQsoId, setEqslQsoId] = useState(null); // QSO being sent as eQSL function closeQslTab() { setQslTabOpen(false); setActiveTab((t) => (t === 'qsl' ? 'recent' : t)); @@ -786,7 +808,8 @@ export default function App() { const ping = () => { if (t) window.clearTimeout(t); t = window.setTimeout(() => { refresh(); }, 400); }; const offUploaded = EventsOn('extsvc:uploaded', ping); const offDone = EventsOn('qslmgr:done', ping); - return () => { offUploaded(); offDone(); if (t) window.clearTimeout(t); }; + const offEqsl = EventsOn('qsl:sent', ping); + return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); }; }, [refresh]); // Poll PstRotator for the live antenna heading (status bar). Cheap when the @@ -917,9 +940,9 @@ export default function App() { if (s.band) setBand(s.band); } if (m) applyModeFromSpot(m); - onCallsignInput(s.dx_call); + onCallsignInput(s.dx_call, { force: true }); applySpotPOTA((s as any).pota_ref); - if (s.dx_call?.trim()) restartRecordingForNewTarget(); + if (s.dx_call?.trim()) restartRecordingForNewTarget(s.dx_call); } useEffect(() => { refresh(); }, [refresh]); @@ -930,6 +953,11 @@ export default function App() { if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; } if ((st as any).migrated_from_app_data) setMigratedBanner(true); } catch {} + // Prompt to unlock encrypted passwords if a passphrase is configured. + try { + const ss: any = await GetSecretStatus(); + if (ss?.has_passphrase && !ss?.unlocked) setUnlockOpen(true); + } catch {} loadStation(); loadLists(); loadCATCfg(); @@ -1063,15 +1091,15 @@ export default function App() { lastUdpCallRef.current = upper; // remember this broadcast either way if (current === upper) return false; // already shown → no-op if (current !== '' && current !== prev) return false; // user typed a different call → leave it - onCallsignInput(call); + onCallsignInput(call, { force: true }); // programmatic → always look up return true; }; const unsubDX = EventsOn('udp:dx_call', (p: any) => { // External app moved to a new station → fresh recording for the new target. - if (applyUdpCall(p?.call)) restartRecordingForNewTarget(); + if (applyUdpCall(p?.call)) restartRecordingForNewTarget(String(p?.call ?? '')); }); const unsubRC = EventsOn('udp:remote_call', (raw: string) => { - if (applyUdpCall(raw)) restartRecordingForNewTarget(); + if (applyUdpCall(raw)) restartRecordingForNewTarget(String(raw ?? '')); }); const unsubProg = EventsOn('import:progress', (p: any) => { setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) }); @@ -1266,7 +1294,7 @@ export default function App() { function resetEntry() { // Discard any in-progress QSO recording (no-op if it was already saved on // log, or if the recorder is off). - QSOAudioCancel(); setRecording(false); + QSOAudioCancel(); setRecording(false); recordingCallRef.current = ""; setCallsign(''); setComment(''); setNote(''); if (!locks.start) setQsoStartedAt(null); if (!locks.end) setQsoEndedAt(null); @@ -1470,17 +1498,23 @@ export default function App() { qsl_via: d.qsl_via || (r.qsl_via ?? ''), })); if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc); - // Begin the recording once the call resolves (a real, ≥3-char callsign, - // not 1–2 stray letters). Covers the fast CW workflow (type → Enter to log - // via the WinKeyer, no blur). No-op if the recorder is off or already - // running; the pre-roll covers the lead-in. - QSOAudioBegin().then(setRecording).catch(() => {}); + // Recording: tie it to the resolved callsign. Start once a real (≥3-char) + // call resolves — covers the fast CW workflow (type → Enter, no blur). If + // we're already recording a DIFFERENT call (the operator edited the + // callsign), restart from zero instead of continuing the old take. + const recCall = call.toUpperCase(); + if (recordingCallRef.current && recordingCallRef.current !== recCall) { + restartRecordingForNewTarget(recCall); + } else { + recordingCallRef.current = recCall; + QSOAudioBegin().then(setRecording).catch(() => {}); + } } catch (e: any) { setLookupResult(null); setLookupError(String(e?.message ?? e)); } finally { setLookupBusy(false); } } - function scheduleLookup(value: string) { + function scheduleLookup(value: string, force?: boolean) { setLookupError(''); if (lookupTimerRef.current) window.clearTimeout(lookupTimerRef.current); if (wbTimerRef.current) window.clearTimeout(wbTimerRef.current); @@ -1490,8 +1524,14 @@ export default function App() { if (lastLookedUpRef.current !== '') resetAutoFill(); return; } - lookupTimerRef.current = window.setTimeout(() => runLookup(call), 400); - wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150); + // Option: defer the (network) callsign lookup until the operator leaves the + // Call field instead of firing it as they type. `force` (programmatic set — + // clicked spot / external app) always looks up, since there's no blur to + // wait for. Worked-before stays live (local, feeds the band matrix). + if (force || localStorage.getItem('opslog.lookupOnBlur') !== '1') { + lookupTimerRef.current = window.setTimeout(() => runLookup(call), force ? 0 : 400); + } + wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150); } // applySpotPOTA sets the QSO's POTA award reference(s) from a clicked spot's // park ref ("US-4164" or n-fer "US-1,US-2"). Empty ref clears it (fresh @@ -1501,7 +1541,7 @@ export default function App() { .split(/[,;]/).map((x) => x.trim().toUpperCase()).filter(Boolean); setDetails((d) => ({ ...d, award_refs: refs.map((r) => `POTA@${r}`).join(';') })); } - function onCallsignInput(v: string) { + function onCallsignInput(v: string, opts?: { force?: boolean }) { // 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 @@ -1512,7 +1552,7 @@ export default function App() { // keeps the pre-roll from before this); clearing it discards the take. // Recording START happens on blur (leaving the callsign field), NOT here — // you may type a call and work it minutes later. Clearing it cancels. - if (v.trim() === '') { QSOAudioCancel(); setRecording(false); } + if (v.trim() === '') { QSOAudioCancel(); setRecording(false); recordingCallRef.current = ""; } const wasEmpty = callsign.trim() === ''; const isEmpty = v.trim() === ''; if (wasEmpty && !isEmpty && !locks.start) { @@ -1526,7 +1566,10 @@ export default function App() { setQsoStartedAt(null); } setCallsign(v); - scheduleLookup(v); + // opts.force = the call was set programmatically (clicked spot / external + // app): there's no "leaving the field", so look it up now regardless of the + // lookup-on-blur option. + scheduleLookup(v, opts?.force); } function markEdited(field: string) { userEditedRef.current.add(field); } @@ -1608,6 +1651,7 @@ export default function App() { ]}, { name: 'tools', label: 'Tools', items: [ { type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' }, + { type: 'item', label: 'QSL Card Designer…', action: 'tools.qsldesigner' }, { type: 'separator' }, { type: 'item', label: wkEnabled ? '✓ WinKeyer CW keyer' : 'WinKeyer CW keyer', action: 'tools.winkeyer' }, { type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' }, @@ -1632,6 +1676,7 @@ export default function App() { case 'edit.delete': if (selectedId !== null) askDelete(selectedId); break; case 'edit.prefs': setShowSettings(true); break; case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break; + case 'tools.qsldesigner': setQslDesignerOpen(true); break; case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break; case 'tools.dvk': setDvkEnabled((v) => !v); break; case 'tools.refreshCty': refreshCtyDat(); break; @@ -1749,9 +1794,14 @@ export default function App() { className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card" value={callsign} onChange={(e) => onCallsignInput(e.target.value)} - // Start the QSO recording when leaving the callsign field (the pre-roll - // covers the seconds before). No-op when the recorder is off. - onBlur={() => { if (callsign.trim()) QSOAudioBegin().then(setRecording).catch(() => {}); }} + onBlur={() => { + const c = callsign.trim(); + if (!c) return; + // Lookup-on-blur mode: run the deferred lookup now (it also starts the + // recording). Otherwise just start the recording (lookup already ran). + if (localStorage.getItem('opslog.lookupOnBlur') === '1') runLookup(c.toUpperCase()); + else QSOAudioBegin().then(setRecording).catch(() => {}); + }} /> @@ -2264,6 +2314,38 @@ export default function App() { )} + {/* Unlock encrypted passwords (set via Settings → Security). Dismissable: + skipping leaves lookups/uploads without their passwords until unlocked. */} + {unlockOpen && ( +
+
+
+ +

Unlock saved passwords

+
+

+ Enter your passphrase to decrypt your QRZ / HamQTH / LoTW / SMTP passwords for this session. +

+ { setUnlockPass(e.target.value); setUnlockErr(''); }} + onKeyDown={(e) => { if (e.key === 'Enter' && unlockPass) doUnlock(); }} + className="mb-2" + /> + {unlockErr &&
{unlockErr}
} +
+ + +
+
+
+ )} + {/* "You have been spotted" banner — shows when our own callsign appears in a cluster spot (Log4OM-style). Floated as a bottom-center overlay @@ -2587,6 +2669,7 @@ export default function App() { onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} + onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onExportSelected={exportSelectedADIF} onExportFiltered={exportFilteredADIF} onRowSelected={(id) => setSelectedId(id)} @@ -2933,7 +3016,8 @@ export default function App() { openEdit(q.id as number)} - onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} /> + onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} + onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} /> {/* Opened on demand from Tools → QSL Manager; closable via the @@ -3089,6 +3173,14 @@ export default function App() { /> )} + setQslDesignerOpen(false)} /> + setEqslQsoId(null)} + onOpenDesigner={() => setQslDesignerOpen(true)} + /> + {deletingQSO && ( Array.from({ length: 14 }, (_, i) => { const d = destinationPoint(from.lat, from.lon, b, (D * (i + 1)) / 14); return [d.lat, d.lon] as [number, number]; }); + const edge = { color: '#dc2626', weight: 1.5, opacity: 0.6 }; for (const az of beamAzimuths) { const arc: [number, number][] = []; for (let b = az - half; b <= az + half + 0.001; b += 2) { @@ -139,10 +140,18 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be ...arc, ...radial(az + half).reverse(), ]); - L.polygon(ring as L.LatLngExpression[], { - color: '#dc2626', weight: 1, opacity: 0.5, fillColor: '#dc2626', fillOpacity: 0.14, - }).addTo(wo); - // Boresight (dashed centre line). + // A geodesic lobe that reaches near a pole can't be filled on a + // Mercator map without the polygon snapping across the whole world — + // draw just the two edges in that case; otherwise the translucent lobe. + if (ring.some(([la]) => Math.abs(la) > 82)) { + L.polyline(unwrapLon([[from.lat, from.lon], ...radial(az - half)]) as L.LatLngExpression[], edge).addTo(wo); + L.polyline(unwrapLon([[from.lat, from.lon], ...radial(az + half)]) as L.LatLngExpression[], edge).addTo(wo); + } else { + L.polygon(ring as L.LatLngExpression[], { + color: '#dc2626', weight: 1, opacity: 0.5, fillColor: '#dc2626', fillOpacity: 0.14, + }).addTo(wo); + } + // Boresight (dashed centre line) — always; great-circle polyline is safe. const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]); L.polyline(cl as L.LatLngExpression[], { color: '#dc2626', weight: 1.5, opacity: 0.7, dashArray: '5 4' }) .bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo); diff --git a/frontend/src/components/QSOContextMenu.tsx b/frontend/src/components/QSOContextMenu.tsx index 3b7f77b..3008d34 100644 --- a/frontend/src/components/QSOContextMenu.tsx +++ b/frontend/src/components/QSOContextMenu.tsx @@ -11,6 +11,7 @@ type Props = { onUpdateFromClublog?: (ids: number[]) => void; onSendTo?: (service: string, ids: number[]) => void; onSendRecording?: (ids: number[]) => void; + onSendEQSL?: (ids: number[]) => void; onExportSelected?: (ids: number[]) => void; onExportFiltered?: () => void; }; @@ -24,7 +25,7 @@ const UPLOAD_TARGETS: { service: string; label: string }[] = [ // Lightweight right-click menu for the QSO grids. AG Grid's native context // menu is an Enterprise feature, so this is a plain floating menu driven by // onCellContextMenu. Closes on any outside click, scroll or Escape. -export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onExportSelected, onExportFiltered }: Props) { +export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onExportSelected, onExportFiltered }: Props) { useEffect(() => { if (!menu) return; const close = () => onClose(); @@ -80,16 +81,27 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ )} - {onSendRecording && ( + {(onSendRecording || onSendEQSL) && ( <>
- + {onSendEQSL && ( + + )} + {onSendRecording && ( + + )} )} diff --git a/frontend/src/components/RecentQSOsGrid.tsx b/frontend/src/components/RecentQSOsGrid.tsx index 91170b5..1643378 100644 --- a/frontend/src/components/RecentQSOsGrid.tsx +++ b/frontend/src/components/RecentQSOsGrid.tsx @@ -52,6 +52,7 @@ type Props = { onUpdateFromClublog?: (ids: number[]) => void; onSendTo?: (service: string, ids: number[]) => void; onSendRecording?: (ids: number[]) => void; + onSendEQSL?: (ids: number[]) => void; onExportSelected?: (ids: number[]) => void; onExportFiltered?: () => void; }; @@ -215,7 +216,7 @@ export const GROUP_ORDER = [ 'Contest', 'Propagation', 'My station', 'Misc', ]; -export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onExportSelected, onExportFiltered }: Props) { +export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onExportSelected, onExportFiltered }: Props) { const gridRef = useRef(null); const [pickerOpen, setPickerOpen] = useState(false); const [menu, setMenu] = useState(null); @@ -361,6 +362,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda onUpdateFromClublog={onUpdateFromClublog} onSendTo={onSendTo} onSendRecording={onSendRecording} + onSendEQSL={onSendEQSL} onExportSelected={onExportSelected} onExportFiltered={onExportFiltered} /> diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 5a758bf..02bbeea 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -15,6 +15,7 @@ import { GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT, GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty, + GetSecretStatus, SetPassphrase, RemovePassphrase, GetEmailSettings, SaveEmailSettings, TestEmail, GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus, ListClusterServers, SaveClusterServer, DeleteClusterServer, @@ -113,7 +114,7 @@ const MODE_CATALOG: Array<{ name: string; sent: string; rcvd: string }> = [ const emptyProfile = (): Profile => ({ id: 0, name: '', - callsign: '', operator: '', owner_callsign: '', + callsign: '', operator: '', op_name: '', owner_callsign: '', my_grid: '', my_country: '', my_state: '', my_cnty: '', my_street: '', my_city: '', my_postal_code: '', @@ -426,7 +427,29 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0'); const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0'); const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1'); + const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1'); const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1'); + // Password-encryption (secret vault) state. + const [secret, setSecret] = useState<{ has_passphrase: boolean; unlocked: boolean }>({ has_passphrase: false, unlocked: false }); + const [ppNew, setPpNew] = useState(''); + const [ppConfirm, setPpConfirm] = useState(''); + const [ppErr, setPpErr] = useState(''); + const [ppBusy, setPpBusy] = useState(false); + const refreshSecret = async () => { try { setSecret(await GetSecretStatus() as any); } catch {} }; + useEffect(() => { refreshSecret(); }, []); + const applyPassphrase = async () => { + if (ppNew !== ppConfirm) { setPpErr('Passphrases do not match'); return; } + setPpBusy(true); setPpErr(''); + try { await SetPassphrase(ppNew); setPpNew(''); setPpConfirm(''); await refreshSecret(); } + catch (e: any) { setPpErr(String(e?.message ?? e)); } + finally { setPpBusy(false); } + }; + const removePassphrase = async () => { + setPpBusy(true); setPpErr(''); + try { await RemovePassphrase(ppNew); setPpNew(''); setPpConfirm(''); await refreshSecret(); } + catch (e: any) { setPpErr(String(e?.message ?? e)); } + finally { setPpBusy(false); } + }; // E-mail / SMTP (send QSO recordings). type EmailCfg = { @@ -837,6 +860,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { updateActive({ operator: e.target.value })} placeholder="F4XYZ" />
Who's at the radio (ADIF OPERATOR).
+
+ + updateActive({ op_name: e.target.value })} placeholder="e.g. Greg" /> +
Your first name — used as the signature on QSL cards.
+
updateActive({ owner_callsign: e.target.value })} placeholder="(leave blank if same as station)" /> @@ -1488,6 +1516,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
+

Configure your rig (COM port, baud rate, model) in OmniRig's own settings GUI first. OpsLog will read whichever Rig slot you select here. Set CAT delay @@ -2896,63 +2931,60 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { function GeneralPanel() { return ( <> - -

-