Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dd15b09e9 | ||
|
|
4eec272c0b | ||
|
|
816a727e88 | ||
|
|
2b5c195ab4 | ||
|
|
139b4675e3 | ||
|
|
2ad72b19fb | ||
|
|
678c8821c2 | ||
|
|
5394b55bb7 |
@@ -30,6 +30,7 @@ import (
|
|||||||
"hamlog/internal/backup"
|
"hamlog/internal/backup"
|
||||||
"hamlog/internal/cabrillo"
|
"hamlog/internal/cabrillo"
|
||||||
"hamlog/internal/cat"
|
"hamlog/internal/cat"
|
||||||
|
"hamlog/internal/catemu"
|
||||||
"hamlog/internal/clublog"
|
"hamlog/internal/clublog"
|
||||||
"hamlog/internal/cluster"
|
"hamlog/internal/cluster"
|
||||||
"hamlog/internal/contest"
|
"hamlog/internal/contest"
|
||||||
@@ -917,6 +918,10 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
||||||
}
|
}
|
||||||
a.emitRadioUDP(s)
|
a.emitRadioUDP(s)
|
||||||
|
// Feed the frequency to any amplifier we are pretending to be a radio
|
||||||
|
// for. Just two atomic stores per amp — the reply itself is built when
|
||||||
|
// the amp polls, so a fast-tuning VFO costs nothing here.
|
||||||
|
a.feedAmpBandFollow(s)
|
||||||
// Drive station relays by the current frequency/band (PstRotator-style
|
// Drive station relays by the current frequency/band (PstRotator-style
|
||||||
// automatic control). Cheap cached-flag check keeps this a no-op when the
|
// automatic control). Cheap cached-flag check keeps this a no-op when the
|
||||||
// feature is off; when on, run off this callback so a slow relay board never
|
// feature is off; when on, run off this callback so a slow relay board never
|
||||||
@@ -1512,6 +1517,74 @@ type dbPointer struct {
|
|||||||
|
|
||||||
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
|
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
|
||||||
|
|
||||||
|
// ── Portable paths ─────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// OpsLog is meant to be carried on a stick or copied between machines: the exe,
|
||||||
|
// its data folder and the databases travel together. Storing "C:\OpsLog\data\
|
||||||
|
// logbook.db" broke that — dropped into D:\OpsLog on another PC, the pointer
|
||||||
|
// still named C:, and the app either lost the database or (worse) silently
|
||||||
|
// created a NEW empty one at a C: path that happened to be creatable.
|
||||||
|
//
|
||||||
|
// So a path INSIDE the application folder is stored relative to it, and any
|
||||||
|
// stored path is resolved against the CURRENT location at read time. Absolute
|
||||||
|
// paths outside the folder (a deliberate choice — a synced folder, another
|
||||||
|
// drive) keep working exactly as before: they are only re-rooted if they have
|
||||||
|
// gone missing AND the same file exists in this install's data folder.
|
||||||
|
|
||||||
|
// appDir is the folder the running exe lives in — the anchor for relative paths.
|
||||||
|
func appDir() string {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Dir(exe)
|
||||||
|
}
|
||||||
|
|
||||||
|
// portablePath prepares a path for STORAGE: relative to the app folder when it
|
||||||
|
// sits inside it, unchanged otherwise. Forward slashes so the value survives a
|
||||||
|
// round trip through a folder copied between machines.
|
||||||
|
func portablePath(p string) string {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
base := appDir()
|
||||||
|
if p == "" || base == "" || !filepath.IsAbs(p) {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(base, p)
|
||||||
|
if err != nil || strings.HasPrefix(rel, "..") {
|
||||||
|
return p // outside the app folder — the user meant that exact place
|
||||||
|
}
|
||||||
|
return filepath.ToSlash(rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePath turns a stored path back into an absolute one for THIS install.
|
||||||
|
// dataDir is where the app's own data lives, used for the re-rooting rescue.
|
||||||
|
func resolvePath(dataDir, p string) string {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(p) {
|
||||||
|
if base := appDir(); base != "" {
|
||||||
|
return filepath.Join(base, filepath.FromSlash(p))
|
||||||
|
}
|
||||||
|
return filepath.FromSlash(p)
|
||||||
|
}
|
||||||
|
if fileExists(p) {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
// An absolute path from another machine. Rescue it ONLY if a file of the same
|
||||||
|
// name is sitting in this install's data folder — that is the copied-folder
|
||||||
|
// case. Anything else is left alone so a genuinely-missing database is
|
||||||
|
// reported rather than silently replaced by an unrelated file.
|
||||||
|
if dataDir != "" {
|
||||||
|
if cand := filepath.Join(dataDir, filepath.Base(p)); fileExists(cand) {
|
||||||
|
applog.Printf("path: %q not found — using %q from this install (folder moved?)", p, cand)
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
// ── Window geometry (window.json) ──────────────────────────────────────
|
// ── Window geometry (window.json) ──────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Remembered across restarts so the window reopens where and how you left it.
|
// Remembered across restarts so the window reopens where and how you left it.
|
||||||
@@ -1564,6 +1637,14 @@ func (a *App) saveWindowState() {
|
|||||||
if w > 0 && h > 0 {
|
if w > 0 && h > 0 {
|
||||||
ws.Width, ws.Height, ws.X, ws.Y = w, h, x, y
|
ws.Width, ws.Height, ws.X, ws.Y = w, h, x, y
|
||||||
}
|
}
|
||||||
|
} else if max {
|
||||||
|
// The POSITION is recorded even when maximised — it is what identifies the
|
||||||
|
// MONITOR. Without it a multi-screen operator who always runs maximised got
|
||||||
|
// no position at all, and Windows reopened OpsLog on the primary screen
|
||||||
|
// every time. The SIZE is deliberately not touched here: it would be the
|
||||||
|
// maximised size, which must not become the restored-down size.
|
||||||
|
x, y := wruntime.WindowGetPosition(a.ctx)
|
||||||
|
ws.X, ws.Y = x, y
|
||||||
}
|
}
|
||||||
writeWindowState(a.dataDir, ws)
|
writeWindowState(a.dataDir, ws)
|
||||||
}
|
}
|
||||||
@@ -1578,7 +1659,24 @@ func (a *App) restoreWindowPosition() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ws, ok := readWindowState(a.dataDir)
|
ws, ok := readWindowState(a.dataDir)
|
||||||
if !ok || ws.Maximised {
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Maximised: Windows reopens it on the PRIMARY screen unless we say otherwise,
|
||||||
|
// so a two-screen operator lost OpsLog to the wrong monitor at every launch.
|
||||||
|
// Un-maximise, move to the saved corner (which names the monitor), maximise
|
||||||
|
// again — all while the window is still hidden, so nothing flickers.
|
||||||
|
if ws.Maximised {
|
||||||
|
if ws.X == 0 && ws.Y == 0 {
|
||||||
|
return // never recorded (or genuinely the primary corner) — nothing to do
|
||||||
|
}
|
||||||
|
if !onSomeMonitor(ws.X, ws.Y, normalMinW, normalMinH) {
|
||||||
|
applog.Printf("window: saved maximised corner %d,%d is off every monitor — opening where Windows puts it", ws.X, ws.Y)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wruntime.WindowUnmaximise(a.ctx)
|
||||||
|
wruntime.WindowSetPosition(a.ctx, ws.X, ws.Y)
|
||||||
|
wruntime.WindowMaximise(a.ctx)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH {
|
if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH {
|
||||||
@@ -1631,12 +1729,16 @@ func readBootstrap(dataDir string) dbPointer {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(b, &c)
|
_ = json.Unmarshal(b, &c)
|
||||||
c.DBPath = strings.TrimSpace(c.DBPath)
|
// Stored relative when it lives inside the app folder, so the pointer follows
|
||||||
|
// the folder from C:OpsLog to D:OpsLog or to a stick.
|
||||||
|
c.DBPath = resolvePath(dataDir, c.DBPath)
|
||||||
|
c.DeletePending = resolvePath(dataDir, c.DeletePending)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeBootstrap(dataDir string, c dbPointer) error {
|
func writeBootstrap(dataDir string, c dbPointer) error {
|
||||||
c.DBPath = strings.TrimSpace(c.DBPath)
|
c.DBPath = portablePath(c.DBPath)
|
||||||
|
c.DeletePending = portablePath(c.DeletePending)
|
||||||
b, _ := json.MarshalIndent(c, "", " ")
|
b, _ := json.MarshalIndent(c, "", " ")
|
||||||
return os.WriteFile(dbPointerPath(dataDir), b, 0o644)
|
return os.WriteFile(dbPointerPath(dataDir), b, 0o644)
|
||||||
}
|
}
|
||||||
@@ -1774,6 +1876,15 @@ func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
|
|||||||
if lp == "" {
|
if lp == "" {
|
||||||
return a.db, "sqlite", nil
|
return a.db, "sqlite", nil
|
||||||
}
|
}
|
||||||
|
// Resolve against THIS install before opening. Without it, a profile carried
|
||||||
|
// from C:\OpsLog to D:\OpsLog kept naming the C: path — and since db.Open
|
||||||
|
// CREATES what is missing, the operator silently got a brand-new empty
|
||||||
|
// logbook instead of an error. Their QSOs were still on disk, in the folder
|
||||||
|
// they had just copied.
|
||||||
|
if r := resolvePath(a.dataDir, lp); r != lp {
|
||||||
|
applog.Printf("logbook: profile path %q resolved to %q", lp, r)
|
||||||
|
lp = r
|
||||||
|
}
|
||||||
c, err := db.Open(lp)
|
c, err := db.Open(lp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("open logbook %s: %w", lp, err)
|
return nil, "", fmt.Errorf("open logbook %s: %w", lp, err)
|
||||||
@@ -5451,16 +5562,33 @@ func (a *App) BulkUpdateQSL(ids []int64, u QSLBulkUpdate) (int, error) {
|
|||||||
// layer so the frontend works with stable ids, not raw column names.
|
// layer so the frontend works with stable ids, not raw column names.
|
||||||
var bulkFieldColumns = map[string]string{
|
var bulkFieldColumns = map[string]string{
|
||||||
// QSL / upload status
|
// QSL / upload status
|
||||||
"lotw_sent": "lotw_sent",
|
"lotw_sent": "lotw_sent",
|
||||||
"lotw_rcvd": "lotw_rcvd",
|
"lotw_rcvd": "lotw_rcvd",
|
||||||
"eqsl_sent": "eqsl_sent",
|
"eqsl_sent": "eqsl_sent",
|
||||||
"eqsl_rcvd": "eqsl_rcvd",
|
"eqsl_rcvd": "eqsl_rcvd",
|
||||||
"qsl_sent": "qsl_sent",
|
"qsl_sent": "qsl_sent",
|
||||||
"qsl_rcvd": "qsl_rcvd",
|
"qsl_rcvd": "qsl_rcvd",
|
||||||
"qsl_via": "qsl_via",
|
"qsl_via": "qsl_via",
|
||||||
|
"qrz_sent": "qrzcom_qso_upload_status",
|
||||||
|
"qrz_rcvd": "qrzcom_qso_download_status",
|
||||||
|
"clublog_sent": "clublog_qso_upload_status",
|
||||||
|
"hrdlog_sent": "hrdlog_qso_upload_status",
|
||||||
|
// Old ids kept so anything holding one keeps working.
|
||||||
"qrz_upload": "qrzcom_qso_upload_status",
|
"qrz_upload": "qrzcom_qso_upload_status",
|
||||||
"clublog_upload": "clublog_qso_upload_status",
|
"clublog_upload": "clublog_qso_upload_status",
|
||||||
"hrdlog_upload": "hrdlog_qso_upload_status",
|
"hrdlog_upload": "hrdlog_qso_upload_status",
|
||||||
|
// Confirmation DATES. A status without its date is half the record: an
|
||||||
|
// operator correcting a batch after an import needs both.
|
||||||
|
"qsl_sent_date": "qsl_sent_date",
|
||||||
|
"qsl_rcvd_date": "qsl_rcvd_date",
|
||||||
|
"lotw_sent_date": "lotw_sent_date",
|
||||||
|
"lotw_rcvd_date": "lotw_rcvd_date",
|
||||||
|
"eqsl_sent_date": "eqsl_sent_date",
|
||||||
|
"eqsl_rcvd_date": "eqsl_rcvd_date",
|
||||||
|
"qrz_sent_date": "qrzcom_qso_upload_date",
|
||||||
|
"qrz_rcvd_date": "qrzcom_qso_download_date",
|
||||||
|
"clublog_sent_date": "clublog_qso_upload_date",
|
||||||
|
"hrdlog_sent_date": "hrdlog_qso_upload_date",
|
||||||
// My station / operator
|
// My station / operator
|
||||||
"station_callsign": "station_callsign",
|
"station_callsign": "station_callsign",
|
||||||
"operator": "operator",
|
"operator": "operator",
|
||||||
@@ -6455,7 +6583,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.reloadCAT()
|
a.restartAsync("cat", a.reloadCAT)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11678,12 +11806,34 @@ func (a *App) SwitchCATRig(n int) error {
|
|||||||
if err := a.settings.Set(a.ctx, keyCATOmniRigNum, strconv.Itoa(n)); err != nil {
|
if err := a.settings.Set(a.ctx, keyCATOmniRigNum, strconv.Itoa(n)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
a.reloadCAT()
|
a.restartAsync("cat", a.reloadCAT)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// reloadCAT (re)starts the CAT manager based on the current settings.
|
// reloadCAT (re)starts the CAT manager based on the current settings.
|
||||||
// Called at startup and after the user saves new CAT config.
|
// Called at startup and after the user saves new CAT config.
|
||||||
|
// restartAsync runs a hardware (re)start off the caller's goroutine.
|
||||||
|
//
|
||||||
|
// Saving settings must never block the UI on a device. Restarting a link tears
|
||||||
|
// the old one down FIRST and waits for its poll goroutine to exit — and that
|
||||||
|
// goroutine can be tens of seconds deep inside a call that cannot be
|
||||||
|
// interrupted: OmniRig's COM Connect when another program (MSHV, a contest
|
||||||
|
// logger) is holding the rig takes ~45 s to give up. Save-and-close froze for
|
||||||
|
// exactly that long, because the Wails binding was waiting on it.
|
||||||
|
//
|
||||||
|
// The restart still takes as long; it just no longer holds the window. The
|
||||||
|
// duration is logged, since "the rig took 45 s to let go" is invisible
|
||||||
|
// otherwise and looks like OpsLog hanging.
|
||||||
|
func (a *App) restartAsync(name string, f func()) {
|
||||||
|
go func() {
|
||||||
|
t0 := time.Now()
|
||||||
|
f()
|
||||||
|
if d := time.Since(t0); d > 2*time.Second {
|
||||||
|
applog.Printf("%s: restart took %s (device slow to release/connect)", name, d.Round(time.Millisecond))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) reloadCAT() {
|
func (a *App) reloadCAT() {
|
||||||
if a.cat == nil {
|
if a.cat == nil {
|
||||||
return
|
return
|
||||||
@@ -11955,6 +12105,10 @@ func (a *App) SaveProfile(p profile.Profile) (profile.Profile, error) {
|
|||||||
if a.profiles == nil {
|
if a.profiles == nil {
|
||||||
return profile.Profile{}, fmt.Errorf("profiles not initialized")
|
return profile.Profile{}, fmt.Errorf("profiles not initialized")
|
||||||
}
|
}
|
||||||
|
// Store a logbook that lives inside the app folder RELATIVE to it, so the
|
||||||
|
// profile keeps working when the folder is copied to another machine or
|
||||||
|
// another drive letter.
|
||||||
|
p.DB.Path = portablePath(p.DB.Path)
|
||||||
if err := a.profiles.Save(a.ctx, &p); err != nil {
|
if err := a.profiles.Save(a.ctx, &p); err != nil {
|
||||||
return profile.Profile{}, err
|
return profile.Profile{}, err
|
||||||
}
|
}
|
||||||
@@ -12015,7 +12169,10 @@ func (a *App) reloadAfterProfileSwitch() {
|
|||||||
if a.extsvc != nil {
|
if a.extsvc != nil {
|
||||||
a.extsvc.SetConfig(a.loadExternalServices())
|
a.extsvc.SetConfig(a.loadExternalServices())
|
||||||
}
|
}
|
||||||
a.reloadCAT()
|
// Off the caller's goroutine for the same reason as the settings save: this
|
||||||
|
// runs from ActivateProfile, a click, and a rig that is slow to release would
|
||||||
|
// otherwise freeze the switch.
|
||||||
|
a.restartAsync("cat", a.reloadCAT)
|
||||||
a.startQSORecorderIfEnabled()
|
a.startQSORecorderIfEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12745,7 +12902,7 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.startUltrabeam()
|
a.restartAsync("antenna", a.startUltrabeam)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13318,7 +13475,7 @@ func (a *App) SavePGXLSettings(s PGXLSettings) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.startAmps()
|
a.restartAsync("amp", a.startAmps)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13343,13 +13500,26 @@ type AmpConfig struct {
|
|||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
ComPort string `json:"com_port"`
|
ComPort string `json:"com_port"`
|
||||||
Baud int `json:"baud"`
|
Baud int `json:"baud"`
|
||||||
|
|
||||||
|
// Band-follow: an ACOM is the MASTER on its CAT/AUX connector — it polls a
|
||||||
|
// transceiver and changes band from the reply, so following OpsLog means
|
||||||
|
// answering those polls on a SECOND serial port (independent of the one used
|
||||||
|
// above for metering; both run at once). See internal/catemu.
|
||||||
|
FreqOut bool `json:"freq_out"`
|
||||||
|
FreqComPort string `json:"freq_com_port"`
|
||||||
|
FreqBaud int `json:"freq_baud"`
|
||||||
|
// FreqBroadcastMs > 0 also SENDS the frequency unprompted at that interval,
|
||||||
|
// for an amplifier that listens to a transceiver's CAT stream instead of
|
||||||
|
// polling it. 0 = answer polls only.
|
||||||
|
FreqBroadcastMs int `json:"freq_broadcast_ms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ampInst struct {
|
type ampInst struct {
|
||||||
cfg AmpConfig
|
cfg AmpConfig
|
||||||
pgxl *powergenius.Client
|
pgxl *powergenius.Client
|
||||||
spe *spe.Client
|
spe *spe.Client
|
||||||
acom *acom.Client
|
acom *acom.Client
|
||||||
|
catemu *catemu.Server // Kenwood-format responder for band-follow (ACOM)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *ampInst) stopAll() {
|
func (i *ampInst) stopAll() {
|
||||||
@@ -13362,6 +13532,9 @@ func (i *ampInst) stopAll() {
|
|||||||
if i.acom != nil {
|
if i.acom != nil {
|
||||||
i.acom.Stop()
|
i.acom.Stop()
|
||||||
}
|
}
|
||||||
|
if i.catemu != nil {
|
||||||
|
i.catemu.Stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ampTypeLabel is the default display name for an amp type.
|
// ampTypeLabel is the default display name for an amp type.
|
||||||
@@ -13438,7 +13611,7 @@ func (a *App) SaveAmplifiers(list []AmpConfig) error {
|
|||||||
if err := a.settings.Set(a.ctx, keyAmpsList, string(b)); err != nil {
|
if err := a.settings.Set(a.ctx, keyAmpsList, string(b)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
a.startAmps()
|
a.restartAsync("amp", a.startAmps)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13491,12 +13664,61 @@ func (a *App) startAmps() {
|
|||||||
a.spe = inst.spe
|
a.spe = inst.spe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Band-follow on a SECOND serial port, for any amp that takes its band from
|
||||||
|
// a transceiver CAT link (ACOM and SPE both do). Independent of the control
|
||||||
|
// transport above, so metering and band-follow run at the same time.
|
||||||
|
if c.FreqOut && strings.TrimSpace(c.FreqComPort) != "" {
|
||||||
|
inst.catemu = catemu.New(catemu.Config{
|
||||||
|
ComPort: c.FreqComPort, Baud: c.FreqBaud,
|
||||||
|
BroadcastMs: c.FreqBroadcastMs,
|
||||||
|
}, applog.Printf)
|
||||||
|
if st := a.cat.State(); st.Connected {
|
||||||
|
inst.catemu.SetFrequency(st.FreqHz)
|
||||||
|
inst.catemu.SetMode(st.Mode)
|
||||||
|
}
|
||||||
|
inst.catemu.Start()
|
||||||
|
applog.Printf("amp %s: band-follow on %s at %d baud (Kenwood format, broadcast=%dms)",
|
||||||
|
c.Name, c.FreqComPort, c.FreqBaud, c.FreqBroadcastMs)
|
||||||
|
}
|
||||||
a.ampsMu.Lock()
|
a.ampsMu.Lock()
|
||||||
a.ampInsts[c.ID] = inst
|
a.ampInsts[c.ID] = inst
|
||||||
a.ampsMu.Unlock()
|
a.ampsMu.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// feedAmpBandFollow pushes the rig's frequency/mode to every amplifier we are
|
||||||
|
// emulating a transceiver for. Called on each CAT state change.
|
||||||
|
//
|
||||||
|
// The TX frequency is what the amp must follow: in split it has to be tuned
|
||||||
|
// for where we transmit, not where we listen.
|
||||||
|
func (a *App) feedAmpBandFollow(s cat.RigState) {
|
||||||
|
if !s.Connected || s.FreqHz <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.ampsMu.Lock()
|
||||||
|
defer a.ampsMu.Unlock()
|
||||||
|
for _, inst := range a.ampInsts {
|
||||||
|
if inst.catemu != nil {
|
||||||
|
inst.catemu.SetFrequency(s.FreqHz)
|
||||||
|
inst.catemu.SetMode(s.Mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAmpBandFollowStatus returns the band-follow link state per amplifier id,
|
||||||
|
// so the settings panel can show whether the amp is actually polling us.
|
||||||
|
func (a *App) GetAmpBandFollowStatus() map[string]catemu.Status {
|
||||||
|
out := map[string]catemu.Status{}
|
||||||
|
a.ampsMu.Lock()
|
||||||
|
defer a.ampsMu.Unlock()
|
||||||
|
for id, inst := range a.ampInsts {
|
||||||
|
if inst.catemu != nil {
|
||||||
|
out[id] = inst.catemu.GetStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// AmpStatus is one amp's live state for the UI poll — exactly one of the
|
// AmpStatus is one amp's live state for the UI poll — exactly one of the
|
||||||
// per-family payloads is set, per the amp's type.
|
// per-family payloads is set, per the amp's type.
|
||||||
type AmpStatus struct {
|
type AmpStatus struct {
|
||||||
@@ -13834,6 +14056,18 @@ func (a *App) SaveWinkeyerSettings(s WinkeyerSettings) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetWinkeyerTrace turns the byte-level WinKeyer protocol trace on or off.
|
||||||
|
//
|
||||||
|
// The K1EL command set differs across WK1 / WK2 / WK3 and a mismatch is silent:
|
||||||
|
// the keyer accepts a byte meant for another command and keys something odd
|
||||||
|
// (one element then a long pause, a sidetone that will not switch off). Reading
|
||||||
|
// the actual wire is the only way to tell which command the firmware misread,
|
||||||
|
// and it beats guessing from a description — a guessed fix breaks the operators
|
||||||
|
// for whom it currently works.
|
||||||
|
func (a *App) SetWinkeyerTrace(on bool) {
|
||||||
|
winkeyer.SetTrace(on)
|
||||||
|
}
|
||||||
|
|
||||||
// WinkeyerConnect opens the serial link using the saved config.
|
// WinkeyerConnect opens the serial link using the saved config.
|
||||||
func (a *App) WinkeyerConnect() error {
|
func (a *App) WinkeyerConnect() error {
|
||||||
if a.winkeyer == nil {
|
if a.winkeyer == nil {
|
||||||
|
|||||||
@@ -1,4 +1,50 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.21.6",
|
||||||
|
"date": "2026-07-27",
|
||||||
|
"en": [
|
||||||
|
"Statistics: the activity chart now follows the period you selected, and Day / Week / Month / Year choose the bucket size (QSOs per week over the whole period, for instance). It used to show fixed windows — the last 7 days, the last 30 — regardless of the period.",
|
||||||
|
"Statistics: new \"When you are on the air\" card — by hour and by day of week, over the selected period.",
|
||||||
|
"Band map: the CW / data / phone sub-bands are no longer shaded in the status colours — the SSB portion was amber, the same amber that means \"new band\" on the spots above it. They now use distinct, colour-blind-checked hues and appear in the legend.",
|
||||||
|
"Help menu: a Support OpsLog entry, which opens the donation page in your browser.",
|
||||||
|
"The Callsign field is now visually marked out, so it is no longer mistaken for Name next to it.",
|
||||||
|
"Multi-screen: OpsLog reopens on the screen it was closed on, including when it was maximised — it used to come back on the primary screen every time.",
|
||||||
|
"The Windows title bar is gone: minimise, maximise and close now sit in the OpsLog bar, which you drag to move the window (double-click to maximise). That is 32 pixels of screen back.",
|
||||||
|
"Saving the settings no longer freezes OpsLog while a device is slow to answer. Choosing OmniRig while another program held the rig locked the window for about 45 seconds — the time OmniRig takes to give up. The link is now re-established in the background, and a slow restart is written to the diagnostic log.",
|
||||||
|
"Edit QSO → QSL Info: the sent and received dates now have a calendar picker, plus a button for today's date (UTC).",
|
||||||
|
"CW keyer: the diagnostic log now names the keyer generation (WK1 / WK2 / WK3), and Settings → CW keyer can log every byte exchanged with it — for reporting a keyer that behaves oddly.",
|
||||||
|
"Bulk edit: the confirmation fields are renamed \"sent/received status\" instead of \"upload\", the missing QRZ.com received status is added, and every channel now offers its sent and received DATE with a calendar."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Statistiques : le graphique d'activité suit désormais la période choisie, et Jour / Semaine / Mois / Année en choisissent le pas (les QSO par semaine sur toute la période, par exemple). Il affichait auparavant des fenêtres fixes — les 7 derniers jours, les 30 derniers — quelle que soit la période.",
|
||||||
|
"Statistiques : nouvelle carte « Quand vous trafiquez » — par heure et par jour de semaine, sur la période choisie.",
|
||||||
|
"Carte de bande : les portions CW / numérique / phonie ne sont plus teintées avec les couleurs de statut — la partie SSB était ambre, l'ambre qui signifie « nouvelle bande » sur les spots posés dessus. Elles utilisent maintenant des teintes distinctes, vérifiées pour le daltonisme, et figurent dans la légende.",
|
||||||
|
"Menu Aide : une entrée « Soutenir OpsLog », qui ouvre la page de don dans votre navigateur.",
|
||||||
|
"Le champ Indicatif est désormais mis en évidence, pour ne plus être confondu avec le champ Nom voisin.",
|
||||||
|
"Multi-écrans : OpsLog se rouvre sur l'écran où il a été fermé, y compris s'il était maximisé — il revenait jusqu'ici sur l'écran principal à chaque fois.",
|
||||||
|
"La barre de titre Windows a disparu : réduire, agrandir et fermer sont désormais dans la barre OpsLog, qu'on saisit pour déplacer la fenêtre (double-clic pour agrandir). Autant de pixels d'écran repris.",
|
||||||
|
"Enregistrer les réglages ne fige plus OpsLog quand un appareil tarde à répondre. Choisir OmniRig alors qu'un autre logiciel tenait la radio bloquait la fenêtre une quarantaine de secondes — le temps qu'OmniRig renonce. La liaison est désormais rétablie en arrière-plan, et un redémarrage lent est noté dans le journal de diagnostic.",
|
||||||
|
"Édition QSO → Infos QSL : les dates d'envoi et de réception disposent d'un calendrier, et d'un bouton pour la date du jour (UTC).",
|
||||||
|
"Keyer CW : le journal de diagnostic nomme désormais la génération du keyer (WK1 / WK2 / WK3), et Réglages → Keyer CW permet de journaliser chaque octet échangé avec lui — pour signaler un keyer au comportement anormal.",
|
||||||
|
"Édition en lot : les champs de confirmation s'appellent désormais « envoi/réception (statut) » au lieu d'« upload », le statut de réception QRZ.com manquant a été ajouté, et chaque canal propose ses DATES d'envoi et de réception avec un calendrier."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.21.5",
|
||||||
|
"date": "2026-07-27",
|
||||||
|
"en": [
|
||||||
|
"Column layouts (widths, order, hidden columns) now stick — five faults were undoing them.",
|
||||||
|
"Recent QSOs keeps a separate column layout in the Main tab and in its own tab — the pane is half as wide there.",
|
||||||
|
"ACOM and SPE amplifiers can follow OpsLog's frequency, on a second COM port (Settings → Amplifier).",
|
||||||
|
"Portable folder: moving OpsLog to another drive or PC (C:OpsLog → D:OpsLog) no longer loses the databases — paths inside the folder are now stored relative to it."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Les dispositions de colonnes (largeurs, ordre, colonnes masquées) tiennent enfin — cinq défauts les défaisaient.",
|
||||||
|
"Les QSO récents gardent une disposition de colonnes distincte dans l'onglet Principal et dans leur propre onglet — le volet y est deux fois moins large.",
|
||||||
|
"Les amplificateurs ACOM et SPE peuvent suivre la fréquence d'OpsLog, sur un second port COM (Réglages → Amplificateur).",
|
||||||
|
"Dossier portable : déplacer OpsLog sur un autre disque ou un autre PC (C:OpsLog → D:OpsLog) ne fait plus perdre les bases — les chemins internes au dossier sont désormais enregistrés relativement à celui-ci."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.21.4",
|
"version": "0.21.4",
|
||||||
"date": "2026-07-26",
|
"date": "2026-07-26",
|
||||||
|
|||||||
+92
-9
@@ -52,7 +52,7 @@ import {
|
|||||||
} from '../wailsjs/go/main/App';
|
} from '../wailsjs/go/main/App';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||||
import { EventsOn, BrowserOpenURL } from '../wailsjs/runtime/runtime';
|
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
|
||||||
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
||||||
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
|||||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||||
import { RotorCompass } from '@/components/RotorCompass';
|
import { RotorCompass } from '@/components/RotorCompass';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
import { setGridPrefsProfile } from '@/lib/gridPrefs';
|
import { setGridPrefsProfile, flushGridPrefs } from '@/lib/gridPrefs';
|
||||||
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
|
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -331,6 +331,54 @@ function computePrefix(call: string): string {
|
|||||||
return lastDigit >= 0 ? c.slice(0, lastDigit + 1) : c;
|
return lastDigit >= 0 ? c.slice(0, lastDigit + 1) : c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WindowControls — minimise / maximise / close, since the window is frameless.
|
||||||
|
//
|
||||||
|
// Deliberately shaped like the Windows ones (flat, wide, close goes red on
|
||||||
|
// hover) rather than styled like the app's own buttons: these are OS controls,
|
||||||
|
// and an operator must recognise them without reading them. Close runs the
|
||||||
|
// normal shutdown path — the same one the OS button used — so the backup and
|
||||||
|
// on-close uploads still happen.
|
||||||
|
function WindowControls() {
|
||||||
|
const [max, setMax] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
WindowIsMaximised().then(setMax).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
const btn = 'inline-flex items-center justify-center w-11 h-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors';
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0.5 -mr-2 ml-1" data-no-drag>
|
||||||
|
<button type="button" className={btn} onClick={() => WindowMinimise()} title="Minimise" aria-label="Minimise">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden><path d="M0 5h10" stroke="currentColor" strokeWidth="1.2" /></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" className={btn}
|
||||||
|
// Flip the icon straight away, then confirm from the runtime: reading it
|
||||||
|
// back immediately returns the state BEFORE the toggle has been applied.
|
||||||
|
onClick={() => {
|
||||||
|
WindowToggleMaximise();
|
||||||
|
setMax((v) => !v);
|
||||||
|
setTimeout(() => { WindowIsMaximised().then(setMax).catch(() => {}); }, 150);
|
||||||
|
}}
|
||||||
|
title={max ? 'Restore' : 'Maximise'} aria-label={max ? 'Restore' : 'Maximise'}>
|
||||||
|
{max ? (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden fill="none" stroke="currentColor" strokeWidth="1.2">
|
||||||
|
<rect x="0.6" y="2.6" width="6.8" height="6.8" /><path d="M2.6 2.6V0.6h6.8v6.8H7.4" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden fill="none" stroke="currentColor" strokeWidth="1.2">
|
||||||
|
<rect x="0.6" y="0.6" width="8.8" height="8.8" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
className="inline-flex items-center justify-center w-11 h-8 rounded-md text-muted-foreground hover:bg-danger hover:text-danger-foreground transition-colors"
|
||||||
|
onClick={() => Quit()} title="Close" aria-label="Close">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden stroke="currentColor" strokeWidth="1.2">
|
||||||
|
<path d="M0.5 0.5l9 9M9.5 0.5l-9 9" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t, lang } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
// === Lists from settings (fallback for first paint) ===
|
// === Lists from settings (fallback for first paint) ===
|
||||||
@@ -2356,6 +2404,14 @@ export default function App() {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// A column resized in the last seconds before quitting must still reach the
|
||||||
|
// database: the DB write is debounced, so flush it on the way out.
|
||||||
|
useEffect(() => {
|
||||||
|
const flush = () => flushGridPrefs();
|
||||||
|
window.addEventListener('beforeunload', flush);
|
||||||
|
return () => { window.removeEventListener('beforeunload', flush); flush(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Every setting is per-profile, so when the active profile changes the whole
|
// Every setting is per-profile, so when the active profile changes the whole
|
||||||
// main UI re-reads its config (station identity, lists, CAT, keyer). The Go
|
// main UI re-reads its config (station identity, lists, CAT, keyer). The Go
|
||||||
// side reloads its managers; this keeps the React state in sync.
|
// side reloads its managers; this keeps the React state in sync.
|
||||||
@@ -3284,6 +3340,8 @@ export default function App() {
|
|||||||
{ type: 'item' as const, label: sendingLog ? t('help.sendLogBusy') : t('help.sendLog'), action: 'help.sendlog', disabled: sendingLog },
|
{ type: 'item' as const, label: sendingLog ? t('help.sendLogBusy') : t('help.sendLog'), action: 'help.sendlog', disabled: sendingLog },
|
||||||
] : []),
|
] : []),
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
|
{ type: 'item', label: t('help.donate'), action: 'help.donate', accent: true },
|
||||||
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
||||||
]},
|
]},
|
||||||
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);
|
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);
|
||||||
@@ -3317,6 +3375,9 @@ export default function App() {
|
|||||||
case 'help.about': setShowAbout(true); checkUpdateNow(); break;
|
case 'help.about': setShowAbout(true); checkUpdateNow(); break;
|
||||||
case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
|
case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
|
||||||
case 'help.sendlog': sendLogToDeveloper(); break;
|
case 'help.sendlog': sendLogToDeveloper(); break;
|
||||||
|
// Opens in the system browser, NOT the app WebView: a payment page must show
|
||||||
|
// the address bar and padlock the donor knows how to check.
|
||||||
|
case 'help.donate': BrowserOpenURL('https://www.paypal.com/donate/?hosted_button_id=PDMY7KV99K38S'); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3448,7 +3509,7 @@ export default function App() {
|
|||||||
const callsignBlock = (
|
const callsignBlock = (
|
||||||
<div className="flex flex-col w-44" data-esm="call">
|
<div className="flex flex-col w-44" data-esm="call">
|
||||||
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
|
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
|
||||||
{t('field.callsign')}
|
<span className="text-primary font-semibold">{t('field.callsign')}</span>
|
||||||
{lookupBusy && (
|
{lookupBusy && (
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-muted/70 px-1.5 py-[1px] text-[9px] font-medium text-muted-foreground ring-1 ring-inset ring-border/60">
|
<span className="inline-flex items-center gap-1 rounded-full bg-muted/70 px-1.5 py-[1px] text-[9px] font-medium text-muted-foreground ring-1 ring-inset ring-border/60">
|
||||||
<Loader2 className="size-2.5 animate-spin" />…
|
<Loader2 className="size-2.5 animate-spin" />…
|
||||||
@@ -3502,8 +3563,14 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
<Input
|
<Input
|
||||||
ref={callsignRef}
|
ref={callsignRef}
|
||||||
|
// The call field is marked out at REST, not only on focus: operators were
|
||||||
|
// typing the callsign into Name, which sits right beside it and looked
|
||||||
|
// exactly the same. A primary-tinted border plus an inset left bar make
|
||||||
|
// it the obvious one without adding a colour the theme doesn't own. The
|
||||||
|
// contest-dupe state still overrides it — that one is a warning.
|
||||||
className={cn('font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card',
|
className={cn('font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card',
|
||||||
contest.active && contestDupe && 'ring-2 ring-destructive !bg-destructive-muted focus:!bg-destructive-muted text-destructive-muted-foreground')}
|
'border-primary/45 shadow-[inset_3px_0_0_0_var(--primary)]',
|
||||||
|
contest.active && contestDupe && 'ring-2 ring-destructive !bg-destructive-muted focus:!bg-destructive-muted text-destructive-muted-foreground shadow-none')}
|
||||||
value={callsign}
|
value={callsign}
|
||||||
onChange={(e) => onCallsignInput(e.target.value)}
|
onChange={(e) => onCallsignInput(e.target.value)}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
@@ -4147,7 +4214,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0 flex">
|
<div className="flex-1 min-h-0 flex">
|
||||||
<div className="flex-1 min-w-0 flex flex-col min-h-0">
|
<div className="flex-1 min-w-0 flex flex-col min-h-0">
|
||||||
<ClusterGrid rows={clusterRenderedRows as any} spotStatus={spotStatus} onSpotClick={handleSpotClick} />
|
<ClusterGrid key={`clg-${activeProfileId ?? 'x'}`} rows={clusterRenderedRows as any} spotStatus={spotStatus} onSpotClick={handleSpotClick} />
|
||||||
</div>
|
</div>
|
||||||
{clusterShowFilters && renderClusterFilters()}
|
{clusterShowFilters && renderClusterFilters()}
|
||||||
</div>
|
</div>
|
||||||
@@ -4156,7 +4223,7 @@ export default function App() {
|
|||||||
case 'worked':
|
case 'worked':
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||||
<WorkedBeforeGrid wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
<WorkedBeforeGrid key={`wbg-${activeProfileId ?? 'x'}`} wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog}
|
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog}
|
||||||
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||||
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
|
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
|
||||||
@@ -4192,6 +4259,11 @@ export default function App() {
|
|||||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
key={`rqg-${activeProfileId ?? 'x'}`}
|
key={`rqg-${activeProfileId ?? 'x'}`}
|
||||||
|
// Its OWN layout, separate from the full-width Recent QSOs tab.
|
||||||
|
// This pane is roughly half as wide, so it wants fewer columns and
|
||||||
|
// narrower ones; sharing one key meant whichever was opened last
|
||||||
|
// rewrote the other's widths.
|
||||||
|
storageKey="mainpane"
|
||||||
rows={qsosWithAwards as any}
|
rows={qsosWithAwards as any}
|
||||||
myGrid={station.my_grid}
|
myGrid={station.my_grid}
|
||||||
total={total}
|
total={total}
|
||||||
@@ -4222,7 +4294,7 @@ export default function App() {
|
|||||||
{compact ? (
|
{compact ? (
|
||||||
// Minimal compact topbar — brand + freq + toggle. Saves vertical space
|
// Minimal compact topbar — brand + freq + toggle. Saves vertical space
|
||||||
// so the single-row entry strip fits in a ~140px tall window.
|
// so the single-row entry strip fits in a ~140px tall window.
|
||||||
<header className="flex items-center gap-3 px-3 h-8 bg-card border-b border-border shrink-0">
|
<header className="app-dragregion flex items-center gap-3 px-3 h-8 bg-card border-b border-border shrink-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="size-2 rounded-full bg-gradient-to-br from-primary to-orange-400" />
|
<div className="size-2 rounded-full bg-gradient-to-br from-primary to-orange-400" />
|
||||||
<span className="font-bold text-xs tracking-tight">OpsLog</span>
|
<span className="font-bold text-xs tracking-tight">OpsLog</span>
|
||||||
@@ -4243,7 +4315,15 @@ export default function App() {
|
|||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
) : (
|
) : (
|
||||||
<header className="grid grid-cols-[auto_auto_1fr_auto_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm">
|
<header
|
||||||
|
className="app-dragregion grid grid-cols-[auto_auto_1fr_auto_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm"
|
||||||
|
// Double-click anywhere on the bar toggles maximise, as a title bar does.
|
||||||
|
// Frameless windows get none of that for free.
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest('button,a,input,select,nav,[role="button"]')) return;
|
||||||
|
WindowToggleMaximise();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2 pr-2 border-r border-border/60">
|
<div className="flex items-center gap-2 pr-2 border-r border-border/60">
|
||||||
<div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
|
<div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
@@ -4611,6 +4691,8 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
<Minimize2 className="size-3.5" />
|
<Minimize2 className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Window controls — the OS title bar is gone, so they live here. */}
|
||||||
|
<WindowControls />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)}
|
)}
|
||||||
@@ -5485,6 +5567,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ClusterGrid
|
<ClusterGrid
|
||||||
|
key={`clg2-${activeProfileId ?? 'x'}`}
|
||||||
rows={rendered as any}
|
rows={rendered as any}
|
||||||
spotStatus={spotStatus}
|
spotStatus={spotStatus}
|
||||||
onSpotClick={handleSpotClick}
|
onSpotClick={handleSpotClick}
|
||||||
@@ -5569,7 +5652,7 @@ export default function App() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||||
<WorkedBeforeGrid wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
<WorkedBeforeGrid key={`wbg-${activeProfileId ?? 'x'}`} wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => 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)}
|
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||||
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
|
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
|
||||||
|
|||||||
@@ -64,25 +64,47 @@ const BAND_RANGES: Record<string, [number, number]> = {
|
|||||||
'70cm': [430000, 440000],
|
'70cm': [430000, 440000],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sub-band shading: CW / digital / phone.
|
||||||
|
//
|
||||||
|
// These are IDENTITIES (which mode the segment is for), not states, so they take
|
||||||
|
// categorical hues — never the status tokens. They used to use success / info /
|
||||||
|
// warning, which collided head-on with the spot pills drawn ON TOP of them: amber
|
||||||
|
// is "new band" in this very component's legend, so the whole SSB portion read as
|
||||||
|
// a giant "new band" wash. Status colours are reserved for status.
|
||||||
|
//
|
||||||
|
// All three are drawn from the COOL end of the categorical order and laid down at
|
||||||
|
// low opacity, so the band plan stays background context while the warm spot pills
|
||||||
|
// keep the foreground to themselves.
|
||||||
|
// Checked with the palette validator in BOTH themes. Violet was the first pick
|
||||||
|
// for CW and failed on the dark steps — violet and blue land 1.9 ΔE apart for a
|
||||||
|
// protan reader there, i.e. the same colour. Magenta clears every check on both
|
||||||
|
// surfaces (worst adjacent pair 13.0 light / 15.9 dark).
|
||||||
|
const SEG_CW = 'var(--chart-7)'; // magenta
|
||||||
|
const SEG_DIGI = 'var(--chart-1)'; // blue
|
||||||
|
const SEG_PHONE = 'var(--chart-2)'; // aqua
|
||||||
|
// A band-plan wash is CONTEXT, not data: it must stay under the spot pills that
|
||||||
|
// are read on top of it.
|
||||||
|
const SEG_OPACITY = 0.13;
|
||||||
|
|
||||||
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
||||||
'160m': [[1800, 1838, 'fill-success-muted'], [1838, 1840, 'fill-info-muted'], [1840, 2000, 'fill-warning-muted']],
|
'160m': [[1800, 1838, SEG_CW], [1838, 1840, SEG_DIGI], [1840, 2000, SEG_PHONE]],
|
||||||
'80m': [[3500, 3580, 'fill-success-muted'], [3580, 3600, 'fill-info-muted'], [3600, 3800, 'fill-warning-muted']],
|
'80m': [[3500, 3580, SEG_CW], [3580, 3600, SEG_DIGI], [3600, 3800, SEG_PHONE]],
|
||||||
'60m': [[5350, 5450, 'fill-warning-muted']],
|
'60m': [[5350, 5450, SEG_PHONE]],
|
||||||
'40m': [[7000, 7040, 'fill-success-muted'], [7040, 7100, 'fill-info-muted'], [7100, 7200, 'fill-warning-muted']],
|
'40m': [[7000, 7040, SEG_CW], [7040, 7100, SEG_DIGI], [7100, 7200, SEG_PHONE]],
|
||||||
'30m': [[10100, 10130, 'fill-success-muted'], [10130, 10150, 'fill-info-muted']],
|
'30m': [[10100, 10130, SEG_CW], [10130, 10150, SEG_DIGI]],
|
||||||
'20m': [[14000, 14070, 'fill-success-muted'], [14070, 14100, 'fill-info-muted'], [14100, 14350, 'fill-warning-muted']],
|
'20m': [[14000, 14070, SEG_CW], [14070, 14100, SEG_DIGI], [14100, 14350, SEG_PHONE]],
|
||||||
'17m': [[18068, 18095, 'fill-success-muted'], [18095, 18110, 'fill-info-muted'], [18110, 18168, 'fill-warning-muted']],
|
'17m': [[18068, 18095, SEG_CW], [18095, 18110, SEG_DIGI], [18110, 18168, SEG_PHONE]],
|
||||||
'15m': [[21000, 21070, 'fill-success-muted'], [21070, 21150, 'fill-info-muted'], [21150, 21450, 'fill-warning-muted']],
|
'15m': [[21000, 21070, SEG_CW], [21070, 21150, SEG_DIGI], [21150, 21450, SEG_PHONE]],
|
||||||
'12m': [[24890, 24915, 'fill-success-muted'], [24915, 24940, 'fill-info-muted'], [24940, 24990, 'fill-warning-muted']],
|
'12m': [[24890, 24915, SEG_CW], [24915, 24940, SEG_DIGI], [24940, 24990, SEG_PHONE]],
|
||||||
'10m': [[28000, 28070, 'fill-success-muted'], [28070, 28300, 'fill-info-muted'], [28300, 29700, 'fill-warning-muted']],
|
'10m': [[28000, 28070, SEG_CW], [28070, 28300, SEG_DIGI], [28300, 29700, SEG_PHONE]],
|
||||||
'6m': [[50000, 50100, 'fill-success-muted'], [50100, 50500, 'fill-warning-muted']],
|
'6m': [[50000, 50100, SEG_CW], [50100, 50500, SEG_PHONE]],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Small coloured dot + label used in the band-map legend strip.
|
// Small coloured dot + label used in the band-map legend strip.
|
||||||
function LegendDot({ cls, label }: { cls: string; label: string }) {
|
function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<span className={cn('size-2 rounded-full', cls)} />
|
<span className={cn("size-2 rounded-full", cls)} style={colour ? { background: colour } : undefined} />
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -447,10 +469,13 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
height={totalH}
|
height={totalH}
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
>
|
>
|
||||||
{segments.map(([s, e, cls], i) => {
|
{segments.map(([s, e, colour], i) => {
|
||||||
const y1 = freqToY(Math.min(e, hi));
|
const y1 = freqToY(Math.min(e, hi));
|
||||||
const y2 = freqToY(Math.max(s, lo));
|
const y2 = freqToY(Math.max(s, lo));
|
||||||
return <rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)} className={cls} />;
|
return (
|
||||||
|
<rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)}
|
||||||
|
fill={colour} fillOpacity={SEG_OPACITY} />
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
<line x1={SCALE_W - 0.5} y1={0} x2={SCALE_W - 0.5} y2={totalH} className="stroke-border" strokeWidth={1} />
|
<line x1={SCALE_W - 0.5} y1={0} x2={SCALE_W - 0.5} y2={totalH} className="stroke-border" strokeWidth={1} />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -559,7 +584,12 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
||||||
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
||||||
<LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} />
|
<LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} />
|
||||||
<LegendDot cls="bg-muted-foreground/30" label={t('bmp.legendWorked')} />
|
<LegendDot cls="bg-muted-foreground/30" label={t("bmp.legendWorked")} />
|
||||||
|
{/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
|
||||||
|
<span className="mx-0.5 opacity-40">|</span>
|
||||||
|
<LegendDot colour={SEG_CW} label={t("bmp.legendCW")} />
|
||||||
|
<LegendDot colour={SEG_DIGI} label={t("bmp.legendData")} />
|
||||||
|
<LegendDot colour={SEG_PHONE} label={t("bmp.legendPhone")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
||||||
{t('bmp.footerHint')}
|
{t('bmp.footerHint')}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type FieldKind = 'status' | 'text' | 'freq';
|
type FieldKind = 'status' | 'text' | 'freq' | 'date';
|
||||||
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
|
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
|
||||||
|
|
||||||
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
|
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
|
||||||
@@ -21,16 +21,31 @@ type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upp
|
|||||||
// label holds an i18n key (resolved with t() at render time).
|
// label holds an i18n key (resolved with t() at render time).
|
||||||
const FIELDS: FieldDef[] = [
|
const FIELDS: FieldDef[] = [
|
||||||
// QSL / upload status
|
// QSL / upload status
|
||||||
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
|
// One channel at a time, each with its status and its date, in the same order
|
||||||
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
|
// as the QSL Info tab. "Upload" was the wrong word for half of them — a paper
|
||||||
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
|
// QSL is not uploaded — so every entry now reads "<channel> sent/received
|
||||||
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
|
// status" or "… date", which is also what the ADIF field is called.
|
||||||
{ id: 'qsl_sent', label: 'bulk.fQslSent', group: 'QSL / upload', kind: 'status' },
|
{ id: 'qsl_sent', label: 'bulk.fQslSent', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
|
{ id: 'qsl_sent_date', label: 'bulk.fQslSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
{ id: 'qrz_upload', label: 'bulk.fQrzUpload', group: 'QSL / upload', kind: 'status' },
|
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'clublog_upload', label: 'bulk.fClublogUpload', group: 'QSL / upload', kind: 'status' },
|
{ id: 'qsl_rcvd_date', label: 'bulk.fQslRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
{ id: 'hrdlog_upload', label: 'bulk.fHrdlogUpload', group: 'QSL / upload', kind: 'status' },
|
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
|
||||||
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
|
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'lotw_sent_date', label: 'bulk.fLotwSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'lotw_rcvd_date', label: 'bulk.fLotwRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'eqsl_sent_date', label: 'bulk.fEqslSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'eqsl_rcvd_date', label: 'bulk.fEqslRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'qrz_sent', label: 'bulk.fQrzSent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'qrz_sent_date', label: 'bulk.fQrzSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'qrz_rcvd', label: 'bulk.fQrzRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'qrz_rcvd_date', label: 'bulk.fQrzRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'clublog_sent', label: 'bulk.fClublogSent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'hrdlog_sent', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
// My station / operator
|
// My station / operator
|
||||||
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
||||||
@@ -117,7 +132,7 @@ type Props = {
|
|||||||
// so they become eligible for upload.
|
// so they become eligible for upload.
|
||||||
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [field, setField] = useState('hrdlog_upload');
|
const [field, setField] = useState('qsl_sent');
|
||||||
const [statusValue, setStatusValue] = useState('R');
|
const [statusValue, setStatusValue] = useState('R');
|
||||||
const [textValue, setTextValue] = useState('');
|
const [textValue, setTextValue] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -181,6 +196,29 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
|||||||
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{t(v.label)}</SelectItem>)}
|
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{t(v.label)}</SelectItem>)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
) : def.kind === 'date' ? (
|
||||||
|
// ADIF stores YYYYMMDD; the picker speaks YYYY-MM-DD. Clearing it
|
||||||
|
// writes an empty value — which is how a wrong date is removed
|
||||||
|
// from a batch, the same meaning as leaving a text field blank.
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="h-8 text-xs font-mono"
|
||||||
|
value={/^\d{8}$/.test(textValue) ? `${textValue.slice(0, 4)}-${textValue.slice(4, 6)}-${textValue.slice(6, 8)}` : ''}
|
||||||
|
onChange={(e) => setTextValue(e.target.value ? e.target.value.replace(/-/g, '') : '')}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button" variant="outline" size="sm" className="h-8 shrink-0 px-2"
|
||||||
|
title={t('bulk.todayUtc')}
|
||||||
|
onClick={() => {
|
||||||
|
const d = new Date();
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
// UTC: the whole log is UTC, and near midnight the local day
|
||||||
|
// is the wrong one.
|
||||||
|
setTextValue(`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`);
|
||||||
|
}}
|
||||||
|
>{t('bulk.today')}</Button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||||
@@ -371,10 +371,28 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
// Localized column catalog — rebuilt when the language changes.
|
// Localized column catalog — rebuilt when the language changes.
|
||||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||||
|
|
||||||
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => COL_CATALOG.map((c) => {
|
// A rebuild makes AG Grid re-apply every colDef hide/width DEFAULT and fire the
|
||||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
// matching column events. Without this guard those events were persisted, so a
|
||||||
return { ...rest, hide: !defaultVisible };
|
// single language change overwrote the saved cluster layout — in the cache AND
|
||||||
}), [COL_CATALOG]);
|
// in the database, with nothing to restore from.
|
||||||
|
const restoringRef = useRef(true);
|
||||||
|
|
||||||
|
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => {
|
||||||
|
restoringRef.current = true;
|
||||||
|
return COL_CATALOG.map((c) => {
|
||||||
|
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||||
|
return { ...rest, hide: !defaultVisible };
|
||||||
|
});
|
||||||
|
}, [COL_CATALOG]);
|
||||||
|
|
||||||
|
// Re-apply the saved state after every rebuild, then re-enable saving.
|
||||||
|
useEffect(() => {
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
const local = loadLocal(COL_STATE_KEY);
|
||||||
|
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
|
const tm = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||||
|
return () => window.clearTimeout(tm);
|
||||||
|
}, [columnDefs]);
|
||||||
|
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||||
@@ -394,17 +412,20 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
gridRef.current?.api?.refreshCells({ force: true });
|
gridRef.current?.api?.refreshCells({ force: true });
|
||||||
}, [spotStatus]);
|
}, [spotStatus]);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
// Restore AFTER the profile scope is known — this grid has no key= remount to
|
||||||
|
// save it from reading the wrong (unscoped) cache key at first paint.
|
||||||
|
async function onGridReady(e: GridReadyEvent) {
|
||||||
|
await whenGridPrefsReady();
|
||||||
const local = loadLocal(COL_STATE_KEY);
|
const local = loadLocal(COL_STATE_KEY);
|
||||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
loadRemote(COL_STATE_KEY).then((remote) => {
|
const remote = await loadRemote(COL_STATE_KEY);
|
||||||
if (remote && !local) {
|
if (remote && !local) {
|
||||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||||
seedLocal(COL_STATE_KEY, remote);
|
seedLocal(COL_STATE_KEY, remote);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const saveColumnState = useCallback(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
|
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||||
const state = gridRef.current?.api?.getColumnState();
|
const state = gridRef.current?.api?.getColumnState();
|
||||||
if (state) saveState(COL_STATE_KEY, state);
|
if (state) saveState(COL_STATE_KEY, state);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export type MenuItem =
|
export type MenuItem =
|
||||||
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean }
|
// accent highlights an item that is an OFFER rather than a command (Donate).
|
||||||
|
// Generic on purpose: a hard-coded label test in the renderer would break the
|
||||||
|
// moment the menu is translated.
|
||||||
|
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean; accent?: boolean }
|
||||||
| { type: 'separator' };
|
| { type: 'separator' };
|
||||||
|
|
||||||
export interface Menu {
|
export interface Menu {
|
||||||
@@ -77,6 +80,7 @@ export function Menubar({ menus, onAction }: Props) {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={i}
|
key={i}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
|
className={item.accent ? 'text-warning focus:text-warning font-medium' : undefined}
|
||||||
onSelect={() => onAction(item.action)}
|
onSelect={() => onAction(item.action)}
|
||||||
>
|
>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
|
|||||||
@@ -427,6 +427,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
// drives which QSOs the apply-form below updates; a search selects all.
|
// drives which QSOs the apply-form below updates; a search selects all.
|
||||||
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
|
// Its OWN column layout. Without a storageKey this fell back to
|
||||||
|
// the main log's key, so every resize or hidden column here
|
||||||
|
// silently rewrote the Recent QSOs layout, and vice versa.
|
||||||
|
storageKey="qslmgr.paper"
|
||||||
rows={paperRows as any}
|
rows={paperRows as any}
|
||||||
total={paperRows.length}
|
total={paperRows.length}
|
||||||
selectAllSignal={paperSelAllSig}
|
selectAllSignal={paperSelAllSig}
|
||||||
@@ -532,6 +536,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
|
storageKey="qslmgr.upload"
|
||||||
rows={rows as any}
|
rows={rows as any}
|
||||||
total={rows.length}
|
total={rows.length}
|
||||||
selectAllSignal={uploadSelAllSig}
|
selectAllSignal={uploadSelAllSig}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Trash2, Search, Loader2 } from 'lucide-react';
|
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
|
||||||
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
|
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
|
||||||
import { rstOptions, type RSTLists } from '@/lib/rst';
|
import { rstOptions, type RSTLists } from '@/lib/rst';
|
||||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||||
@@ -148,6 +148,56 @@ function F({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdifDateInput — a real date picker over an ADIF date.
|
||||||
|
//
|
||||||
|
// ADIF stores YYYYMMDD; the browser's date input speaks YYYY-MM-DD, so the two
|
||||||
|
// are converted at the edge and the log keeps its ADIF form. The native control
|
||||||
|
// is used on purpose: it brings the OS calendar, the locale's day/month order
|
||||||
|
// and keyboard entry for free, which a hand-rolled popover would have to
|
||||||
|
// reimplement and get wrong.
|
||||||
|
//
|
||||||
|
// A value that is NOT a valid 8-digit date (an old hand-typed entry) is shown in
|
||||||
|
// a plain text box instead, so it stays visible and correctable rather than
|
||||||
|
// silently disappearing behind an empty picker.
|
||||||
|
function AdifDateInput({ value, onChange, disabled }: { value?: string; onChange: (v: string) => void; disabled?: boolean }) {
|
||||||
|
const raw = (value ?? '').trim();
|
||||||
|
const valid = /^\d{8}$/.test(raw);
|
||||||
|
const iso = valid ? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}` : '';
|
||||||
|
const today = () => {
|
||||||
|
const d = new Date();
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
// UTC: every date in the log is UTC, and near midnight the local day is the
|
||||||
|
// wrong one.
|
||||||
|
onChange(`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}`);
|
||||||
|
};
|
||||||
|
if (raw !== '' && !valid) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Input value={raw} onChange={(e) => onChange(e.target.value)} disabled={disabled} className="font-mono" />
|
||||||
|
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" onClick={() => onChange('')} title="Clear">×</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={iso}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value; // "" when cleared
|
||||||
|
onChange(v ? v.replace(/-/g, '') : '');
|
||||||
|
}}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" disabled={disabled}
|
||||||
|
onClick={today} title="Today (UTC)">
|
||||||
|
<CalendarDays className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
|
function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
@@ -580,8 +630,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
|
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
|
||||||
: <Input disabled value="—" />}
|
: <Input disabled value="—" />}
|
||||||
</div>
|
</div>
|
||||||
<div><Label>{t('qedit.dateSent')}</Label><Input value={val(def.sentDate)} placeholder="YYYYMMDD" onChange={(e) => put(def.sentDate, e.target.value)} className="font-mono" /></div>
|
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></div>
|
||||||
<div><Label>{t('qedit.dateReceived')}</Label><Input value={val(def.rcvdDate)} placeholder="YYYYMMDD" disabled={!def.rcvdDate} onChange={(e) => put(def.rcvdDate, e.target.value)} className="font-mono" /></div>
|
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={val(def.rcvdDate)} onChange={(v) => put(def.rcvdDate, v)} disabled={!def.rcvdDate} /></div>
|
||||||
{def.via && (
|
{def.via && (
|
||||||
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div>
|
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -521,7 +521,13 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
||||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [awardCols, awardShown]);
|
// Keyed on columnDefs ITSELF, not on the reasons it was rebuilt. Listing the
|
||||||
|
// causes here (awardCols/awardShown) missed the others — switching the UI
|
||||||
|
// language rebuilds the memo through `t`, which set restoringRef and left it
|
||||||
|
// set, so every column change for the rest of the session was silently
|
||||||
|
// discarded and the layout reverted on reload. Any future dependency of the
|
||||||
|
// memo is now covered for free.
|
||||||
|
}, [columnDefs]);
|
||||||
|
|
||||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
||||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||||
GetQSLDefaults, SaveQSLDefaults,
|
GetQSLDefaults, SaveQSLDefaults,
|
||||||
|
SetWinkeyerTrace,
|
||||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
|
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
|
||||||
GetPOTAToken, SavePOTAToken,
|
GetPOTAToken, SavePOTAToken,
|
||||||
TestLoTWUpload, ListTQSLStationLocations,
|
TestLoTWUpload, ListTQSLStationLocations,
|
||||||
@@ -627,7 +628,10 @@ function ADIFMonitorPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AmpUI mirrors the backend AmpConfig — one configured amplifier.
|
// AmpUI mirrors the backend AmpConfig — one configured amplifier.
|
||||||
type AmpUI = { id: string; name: string; enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number };
|
type AmpUI = { id: string; name: string; enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number;
|
||||||
|
// Band-follow (ACOM): a SECOND serial port on which OpsLog answers the amp's
|
||||||
|
// frequency polls, pretending to be a Kenwood-format transceiver.
|
||||||
|
freq_out?: boolean; freq_com_port?: string; freq_baud?: number; freq_broadcast_ms?: number };
|
||||||
|
|
||||||
// RelayAutoPanel configures automatic control of the Station Control relay boards
|
// RelayAutoPanel configures automatic control of the Station Control relay boards
|
||||||
// from the rig's frequency / band (PstRotator-style). Each relay carries one rule:
|
// from the rig's frequency / band (PstRotator-style). Each relay carries one rule:
|
||||||
@@ -1090,6 +1094,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
swap: false, autospace: true, use_ptt: false, serial_echo: true, cw_key_line: 'dtr', cw_invert: false, macros: [],
|
swap: false, autospace: true, use_ptt: false, serial_echo: true, cw_key_line: 'dtr', cw_invert: false, macros: [],
|
||||||
});
|
});
|
||||||
const [wkPorts, setWkPorts] = useState<string[]>([]);
|
const [wkPorts, setWkPorts] = useState<string[]>([]);
|
||||||
|
// Session-only: the byte trace is a diagnostic, never a saved preference.
|
||||||
|
const [wkTrace, setWkTrace] = useState(false);
|
||||||
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
|
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
|
||||||
|
|
||||||
// ── Audio (DVK + QSO recorder) ──
|
// ── Audio (DVK + QSO recorder) ──
|
||||||
@@ -2811,6 +2817,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
});
|
});
|
||||||
const addAmp = () => setAmps((l) => [...l, {
|
const addAmp = () => setAmps((l) => [...l, {
|
||||||
id: '', name: '', enabled: true, type: 'spe13', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200,
|
id: '', name: '', enabled: true, type: 'spe13', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200,
|
||||||
|
freq_out: false, freq_com_port: '', freq_baud: 9600, freq_broadcast_ms: 0,
|
||||||
}]);
|
}]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -2927,6 +2934,62 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Band-follow, for any amp that takes its band from a transceiver
|
||||||
|
CAT link (ACOM and SPE both do) — never PowerGenius, which is
|
||||||
|
driven over its network protocol. A SECOND serial port,
|
||||||
|
separate from the metering one above. */}
|
||||||
|
{!isPGXL && (
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={!!amp.freq_out}
|
||||||
|
onCheckedChange={(c) => patchAmp(i, { freq_out: !!c })}
|
||||||
|
/>
|
||||||
|
{t('amp.freqOut')}
|
||||||
|
</label>
|
||||||
|
{amp.freq_out && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>{t('amp.freqPort')}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={amp.freq_com_port || '_'} onValueChange={(v) => patchAmp(i, { freq_com_port: v === '_' ? '' : v })}>
|
||||||
|
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||||
|
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
|
<ArrowDown className="size-3.5 rotate-90" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Baud</Label>
|
||||||
|
<Input type="number" min={1200} value={amp.freq_baud ?? 9600}
|
||||||
|
onChange={(e) => patchAmp(i, { freq_baud: parseInt(e.target.value) || 9600 })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>{t('amp.freqBroadcast')}</Label>
|
||||||
|
<Select value={String(amp.freq_broadcast_ms ?? 0)} onValueChange={(v) => patchAmp(i, { freq_broadcast_ms: parseInt(v) })}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0">{t('amp.freqPollOnly')}</SelectItem>
|
||||||
|
<SelectItem value="500">{t('amp.freqEvery', { ms: 500 })}</SelectItem>
|
||||||
|
<SelectItem value="1000">{t('amp.freqEvery', { ms: 1000 })}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{t('amp.freqHint')}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{amp.enabled && amp.id && <AmpStatusCard id={amp.id} />}
|
{amp.enabled && amp.id && <AmpStatusCard id={amp.id} />}
|
||||||
{!isPGXL && !isACOM && (
|
{!isPGXL && !isACOM && (
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
@@ -3303,6 +3366,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> {t('wk.serialEcho')}
|
<Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> {t('wk.serialEcho')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Protocol trace — for reporting a keyer that misbehaves. Session
|
||||||
|
only, deliberately not persisted: it is a diagnostic, and a log
|
||||||
|
full of hex bytes helps nobody who forgot it was on. */}
|
||||||
|
<div className="border-t border-border/60 pt-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={wkTrace} onCheckedChange={(c) => { setWkTrace(!!c); SetWinkeyerTrace(!!c); }} />
|
||||||
|
{t('wk.trace')}
|
||||||
|
</label>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">{t('wk.traceHint')}</p>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ type Stats = {
|
|||||||
by_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[];
|
by_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[];
|
||||||
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
||||||
by_hour: Bucket[]; by_day7: Bucket[]; by_day30: Bucket[]; by_month12: Bucket[];
|
by_hour: Bucket[]; by_day7: Bucket[]; by_day30: Bucket[]; by_month12: Bucket[];
|
||||||
|
// Chronological series across the SELECTED period, one per bucket size; empty
|
||||||
|
// when that bucket would be too fine for the period (see the Go side).
|
||||||
|
by_day_win: Bucket[]; by_week_win: Bucket[]; by_month_win: Bucket[]; by_year_win: Bucket[];
|
||||||
|
by_hour_of_day: Bucket[]; by_weekday: Bucket[];
|
||||||
// Period / contest metrics.
|
// Period / contest metrics.
|
||||||
window_start: string; window_end: string; window_hours: number;
|
window_start: string; window_end: string; window_hours: number;
|
||||||
avg_per_hour: number; avg_per_active: number;
|
avg_per_hour: number; avg_per_active: number;
|
||||||
@@ -509,19 +513,44 @@ function periodRange(p: Period, from: string, to: string): [string, string] {
|
|||||||
// own state, module-scoped so a parent re-render doesn't reset the choice. The
|
// own state, module-scoped so a parent re-render doesn't reset the choice. The
|
||||||
// timeline is the chronological month series; day/week/month/year are the cyclical
|
// timeline is the chronological month series; day/week/month/year are the cyclical
|
||||||
// distributions (hour-of-day, day-of-week, day-of-month, month-of-year).
|
// distributions (hour-of-day, day-of-week, day-of-month, month-of-year).
|
||||||
|
// The selector is a BUCKET SIZE applied to the period chosen at the top of the
|
||||||
|
// panel — not a window of its own. It used to be four fixed rolling windows
|
||||||
|
// anchored on the newest QSO, which ignored the period filter entirely: picking
|
||||||
|
// a year up top left this chart showing the last 7 days.
|
||||||
const ACT_GRAN = [
|
const ACT_GRAN = [
|
||||||
{ key: 'timeline', tkey: 'stats.granTimeline' },
|
|
||||||
{ key: 'day', tkey: 'stats.granDay' },
|
{ key: 'day', tkey: 'stats.granDay' },
|
||||||
{ key: 'week', tkey: 'stats.granWeek' },
|
{ key: 'week', tkey: 'stats.granWeek' },
|
||||||
{ key: 'month', tkey: 'stats.granMonth' },
|
{ key: 'month', tkey: 'stats.granMonth' },
|
||||||
{ key: 'year', tkey: 'stats.granYear' },
|
{ key: 'year', tkey: 'stats.granYear' },
|
||||||
] as const;
|
] as const;
|
||||||
|
type Gran = typeof ACT_GRAN[number]['key'];
|
||||||
|
const COARSER: Record<Gran, Gran | null> = { day: 'week', week: 'month', month: 'year', year: null };
|
||||||
|
|
||||||
function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
|
function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
|
||||||
const [g, setG] = useState<string>('timeline');
|
const [g, setG] = useState<Gran | 'auto'>('auto');
|
||||||
const series = g === 'day' ? stats.by_hour : g === 'week' ? stats.by_day7 : g === 'month' ? stats.by_day30 : g === 'year' ? stats.by_month12 : [];
|
const seriesFor = (k: Gran): Bucket[] => (
|
||||||
|
k === 'day' ? stats.by_day_win : k === 'week' ? stats.by_week_win : k === 'month' ? stats.by_month_win : stats.by_year_win
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto picks the finest bucket that stays readable: the backend leaves a
|
||||||
|
// series empty when it would be too fine for the period, so "the first
|
||||||
|
// non-empty one" is exactly the right rule and needs no thresholds here.
|
||||||
|
const auto: Gran = (['day', 'week', 'month', 'year'] as Gran[]).find((k) => seriesFor(k).length > 0 && seriesFor(k).length <= 120) ?? 'year';
|
||||||
|
// A bucket the user forces but that is too fine for the period falls back to
|
||||||
|
// the next coarser one rather than showing an empty chart.
|
||||||
|
let shown: Gran = g === 'auto' ? auto : g;
|
||||||
|
while (seriesFor(shown).length === 0 && COARSER[shown]) shown = COARSER[shown]!;
|
||||||
|
const series = seriesFor(shown);
|
||||||
|
const steppedUp = g !== 'auto' && shown !== g;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
|
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
|
||||||
<div className="mb-2 flex flex-wrap gap-1">
|
<div className="mb-2 flex flex-wrap gap-1 items-center">
|
||||||
|
<button type="button" onClick={() => setG('auto')}
|
||||||
|
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
||||||
|
g === 'auto' ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||||
|
{t('stats.granAuto')}
|
||||||
|
</button>
|
||||||
{ACT_GRAN.map((o) => (
|
{ACT_GRAN.map((o) => (
|
||||||
<button key={o.key} type="button" onClick={() => setG(o.key)}
|
<button key={o.key} type="button" onClick={() => setG(o.key)}
|
||||||
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
||||||
@@ -529,14 +558,39 @@ function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: an
|
|||||||
{t(o.tkey)}
|
{t(o.tkey)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{steppedUp && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">{t('stats.granTooFine')}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{g === 'timeline'
|
{/* Many buckets read better as a filled trend than as a forest of bars. */}
|
||||||
? <AreaTrend data={stats.by_month} empty={empty} />
|
{series.length > 60
|
||||||
|
? <AreaTrend data={series} empty={empty} />
|
||||||
: <VBars data={series} empty={empty} showValues height={160} />}
|
: <VBars data={series} empty={empty} showValues height={160} />}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RhythmCard — WHEN the operator is on the air over the selected period, which is
|
||||||
|
// a different question from "how much lately" and so gets its own card. The hour
|
||||||
|
// histogram used to cover a single day, too little to read anything from.
|
||||||
|
function RhythmCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
|
||||||
|
const [g, setG] = useState<'hour' | 'weekday'>('hour');
|
||||||
|
return (
|
||||||
|
<Card title={t('stats.rhythm')} sub={t('stats.rhythmSub')} className="lg:col-span-2" accent="var(--chart-2)">
|
||||||
|
<div className="mb-2 flex flex-wrap gap-1">
|
||||||
|
{([['hour', 'stats.byHourOfDay'], ['weekday', 'stats.byWeekday']] as const).map(([k, tk]) => (
|
||||||
|
<button key={k} type="button" onClick={() => setG(k)}
|
||||||
|
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
||||||
|
g === k ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||||
|
{t(tk)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<VBars data={g === 'hour' ? stats.by_hour_of_day : stats.by_weekday} empty={empty} showValues height={160} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
|
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
|
||||||
|
|
||||||
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
|
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
|
||||||
@@ -603,6 +657,8 @@ export function StatsPanel() {
|
|||||||
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
|
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
|
||||||
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
|
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
|
||||||
by_hour: arr(raw.by_hour), by_day7: arr(raw.by_day7), by_day30: arr(raw.by_day30), by_month12: arr(raw.by_month12),
|
by_hour: arr(raw.by_hour), by_day7: arr(raw.by_day7), by_day30: arr(raw.by_day30), by_month12: arr(raw.by_month12),
|
||||||
|
by_day_win: arr(raw.by_day_win), by_week_win: arr(raw.by_week_win), by_month_win: arr(raw.by_month_win), by_year_win: arr(raw.by_year_win),
|
||||||
|
by_hour_of_day: arr(raw.by_hour_of_day), by_weekday: arr(raw.by_weekday),
|
||||||
rate: arr(raw.rate), gaps: arr(raw.gaps),
|
rate: arr(raw.rate), gaps: arr(raw.gaps),
|
||||||
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
|
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
|
||||||
} as Stats);
|
} as Stats);
|
||||||
@@ -818,6 +874,7 @@ export function StatsPanel() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<ActivityCard stats={stats} t={t} empty={empty} />
|
<ActivityCard stats={stats} t={t} empty={empty} />
|
||||||
|
<RhythmCard stats={stats} t={t} empty={empty} />
|
||||||
|
|
||||||
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
||||||
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import type { WorkedBeforeView, QSOForm } from '@/types';
|
import type { WorkedBeforeView, QSOForm } from '@/types';
|
||||||
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
||||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
@@ -113,15 +113,18 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
|
|||||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
// Restore AFTER the profile scope is known: this grid has no key= remount to
|
||||||
|
// save it from reading the wrong (unscoped) cache key at first paint, so it
|
||||||
|
// used to miss the cache every time and then save under the scoped key.
|
||||||
|
async function onGridReady(e: GridReadyEvent) {
|
||||||
|
await whenGridPrefsReady();
|
||||||
const local = loadLocal(COL_STATE_KEY);
|
const local = loadLocal(COL_STATE_KEY);
|
||||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
loadRemote(COL_STATE_KEY).then((remote) => {
|
const remote = await loadRemote(COL_STATE_KEY);
|
||||||
if (remote && !local) {
|
if (remote && !local) {
|
||||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||||
seedLocal(COL_STATE_KEY, remote);
|
seedLocal(COL_STATE_KEY, remote);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const saveColumnState = useCallback(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
if (restoringRef.current) return; // ignore events fired by a column rebuild
|
if (restoringRef.current) return; // ignore events fired by a column rebuild
|
||||||
@@ -137,7 +140,9 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
|
|||||||
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [awardCols]);
|
// columnDefs itself, not the reasons it was rebuilt — see the same note in
|
||||||
|
// RecentQSOsGrid: a language change rebuilt the memo and left saving off.
|
||||||
|
}, [columnDefs]);
|
||||||
|
|
||||||
function isColVisible(colId: string): boolean {
|
function isColVisible(colId: string): boolean {
|
||||||
const col = gridRef.current?.api?.getColumn(colId);
|
const col = gridRef.current?.api?.getColumn(colId);
|
||||||
|
|||||||
@@ -19,6 +19,26 @@ let lsScope = '';
|
|||||||
// changes so each profile keeps its own column layout / widths.
|
// changes so each profile keeps its own column layout / widths.
|
||||||
export function setGridPrefsProfile(id: number | string | null | undefined): void {
|
export function setGridPrefsProfile(id: number | string | null | undefined): void {
|
||||||
lsScope = id == null || id === '' ? '' : `p${id}.`;
|
lsScope = id == null || id === '' ? '' : `p${id}.`;
|
||||||
|
markReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The active profile is only known after an async call, but the grids mount and
|
||||||
|
// read their state on the first paint. A grid that read before the scope was set
|
||||||
|
// looked up the UNSCOPED cache key (always a miss) and then saved under the
|
||||||
|
// scoped one — so its layout could never be restored, only rewritten. Grids now
|
||||||
|
// wait for this instead of racing it.
|
||||||
|
//
|
||||||
|
// The 5 s fallback matters: if the profile lookup fails outright, the grids must
|
||||||
|
// still come up with whatever the unscoped cache holds rather than hang with no
|
||||||
|
// columns restored.
|
||||||
|
let markReady: () => void = () => {};
|
||||||
|
const gridPrefsReady = new Promise<void>((resolve) => {
|
||||||
|
markReady = resolve;
|
||||||
|
setTimeout(resolve, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function whenGridPrefsReady(): Promise<void> {
|
||||||
|
return gridPrefsReady;
|
||||||
}
|
}
|
||||||
|
|
||||||
// lsKey scopes ONLY the localStorage cache key. The DB key passed to
|
// lsKey scopes ONLY the localStorage cache key. The DB key passed to
|
||||||
@@ -40,22 +60,66 @@ export function loadLocal(key: string): any[] | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loadRemote pulls the portable copy from the DB (null if none / unset).
|
// loadRemote pulls the portable copy from the DB (null if none / unset).
|
||||||
export async function loadRemote(key: string): Promise<any[] | null> {
|
//
|
||||||
try {
|
// GetUIPref returns an ERROR — not "" — while the settings store is still
|
||||||
const v = await GetUIPref(key);
|
// coming up and not yet scoped to the active profile. Treating that as "no
|
||||||
const parsed = v ? JSON.parse(v) : null;
|
// preference" was the silent data-loss path: the grid rendered defaults and the
|
||||||
return Array.isArray(parsed) ? parsed : null;
|
// first column event then wrote those defaults over the good saved copy. So
|
||||||
} catch {
|
// retry for a few seconds instead, and only give up on a real absence.
|
||||||
return null;
|
export async function loadRemote(key: string, attempts = 10): Promise<any[] | null> {
|
||||||
|
for (let i = 0; i < attempts; i++) {
|
||||||
|
try {
|
||||||
|
const v = await GetUIPref(key);
|
||||||
|
const parsed = v ? JSON.parse(v) : null;
|
||||||
|
return Array.isArray(parsed) ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
// Not ready yet (or a parse failure on a corrupt value — one more read
|
||||||
|
// costs nothing). 300 ms × 10 covers a slow startup without hanging.
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveState write-throughs to both the cache and the DB (fire-and-forget). Only
|
// saveState write-throughs to both the cache and the DB (fire-and-forget). Only
|
||||||
// the cache key is profile-scoped; the DB key is scoped by the backend.
|
// the cache key is profile-scoped; the DB key is scoped by the backend.
|
||||||
|
// The DB write is DEBOUNCED. AG-Grid emits a column-resized event per drag
|
||||||
|
// frame, so dragging one border used to fire dozens of settings writes; the
|
||||||
|
// cache write stays synchronous so nothing is lost if the window closes.
|
||||||
|
const pendingDB = new Map<string, string>();
|
||||||
|
const dbTimers = new Map<string, number>();
|
||||||
|
|
||||||
export function saveState(key: string, state: any[]) {
|
export function saveState(key: string, state: any[]) {
|
||||||
const json = JSON.stringify(state);
|
const json = JSON.stringify(state);
|
||||||
try { localStorage.setItem(lsKey(key), json); } catch { /* quota / private mode */ }
|
try { localStorage.setItem(lsKey(key), json); } catch { /* quota / private mode */ }
|
||||||
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
|
pendingDB.set(key, json);
|
||||||
|
const prev = dbTimers.get(key);
|
||||||
|
if (prev) clearTimeout(prev);
|
||||||
|
dbTimers.set(key, window.setTimeout(() => flushOne(key), 400));
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushOne(key: string) {
|
||||||
|
const json = pendingDB.get(key);
|
||||||
|
dbTimers.delete(key);
|
||||||
|
if (json == null) return;
|
||||||
|
pendingDB.delete(key);
|
||||||
|
SetUIPref(key, json).catch(() => {
|
||||||
|
// The store may not be scoped yet at startup. Keep the value and try once
|
||||||
|
// more shortly — dropping it here is how a fresh profile ended up with no
|
||||||
|
// portable copy at all.
|
||||||
|
pendingDB.set(key, json);
|
||||||
|
if (!dbTimers.has(key)) dbTimers.set(key, window.setTimeout(() => flushOne(key), 2000));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushGridPrefs writes any debounced state immediately. Call it when the app is
|
||||||
|
// about to close so a resize made in the last moments still reaches the DB.
|
||||||
|
export function flushGridPrefs() {
|
||||||
|
for (const key of Array.from(pendingDB.keys())) {
|
||||||
|
const t = dbTimers.get(key);
|
||||||
|
if (t) clearTimeout(t);
|
||||||
|
flushOne(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedLocal writes a value into the cache without touching the DB (used after
|
// seedLocal writes a value into the cache without touching the DB (used after
|
||||||
|
|||||||
+12
-12
File diff suppressed because one or more lines are too long
@@ -652,3 +652,37 @@
|
|||||||
border: 2px solid var(--background);
|
border: 2px solid var(--background);
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
|
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
|
||||||
|
|
||||||
|
/* ── Frameless window: drag region ──────────────────────────────────────────
|
||||||
|
The OS title bar is gone (main.go, Frameless), so the app header IS the title
|
||||||
|
bar and must be draggable. Everything interactive inside it opts OUT: a drag
|
||||||
|
region swallows clicks, and without this the menus and toolbar buttons would
|
||||||
|
move the window instead of doing their job. Opting out by ROLE rather than
|
||||||
|
listing each child keeps a future button working without anyone remembering
|
||||||
|
this rule. */
|
||||||
|
.app-dragregion {
|
||||||
|
--wails-draggable: drag;
|
||||||
|
}
|
||||||
|
.app-dragregion button,
|
||||||
|
.app-dragregion a,
|
||||||
|
.app-dragregion input,
|
||||||
|
.app-dragregion select,
|
||||||
|
.app-dragregion textarea,
|
||||||
|
.app-dragregion nav,
|
||||||
|
.app-dragregion [role='button'],
|
||||||
|
.app-dragregion [role='menu'],
|
||||||
|
.app-dragregion [data-no-drag] {
|
||||||
|
--wails-draggable: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Native date inputs: the WebView draws its calendar glyph in near-black, which
|
||||||
|
disappears on the dark themes. Invert it there — the control itself is worth
|
||||||
|
keeping (OS calendar, locale date order, keyboard entry), only its icon needs
|
||||||
|
help. */
|
||||||
|
[data-theme='dim-slate'] input[type='date']::-webkit-calendar-picker-indicator,
|
||||||
|
[data-theme='dark-warm'] input[type='date']::-webkit-calendar-picker-indicator,
|
||||||
|
[data-theme='dark-graphite'] input[type='date']::-webkit-calendar-picker-indicator,
|
||||||
|
[data-theme='high-contrast'] input[type='date']::-webkit-calendar-picker-indicator {
|
||||||
|
filter: invert(1) brightness(1.6);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.21.4';
|
export const APP_VERSION = '0.21.6';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+5
@@ -6,6 +6,7 @@ import {main} from '../models';
|
|||||||
import {cat} from '../models';
|
import {cat} from '../models';
|
||||||
import {profile} from '../models';
|
import {profile} from '../models';
|
||||||
import {acom} from '../models';
|
import {acom} from '../models';
|
||||||
|
import {catemu} from '../models';
|
||||||
import {antgenius} from '../models';
|
import {antgenius} from '../models';
|
||||||
import {award} from '../models';
|
import {award} from '../models';
|
||||||
import {awardref} from '../models';
|
import {awardref} from '../models';
|
||||||
@@ -344,6 +345,8 @@ export function GetActiveProfile():Promise<profile.Profile>;
|
|||||||
|
|
||||||
export function GetAlertEmailTo():Promise<string>;
|
export function GetAlertEmailTo():Promise<string>;
|
||||||
|
|
||||||
|
export function GetAmpBandFollowStatus():Promise<Record<string, catemu.Status>>;
|
||||||
|
|
||||||
export function GetAmpStatuses():Promise<Array<main.AmpStatus>>;
|
export function GetAmpStatuses():Promise<Array<main.AmpStatus>>;
|
||||||
|
|
||||||
export function GetAmplifiers():Promise<Array<main.AmpConfig>>;
|
export function GetAmplifiers():Promise<Array<main.AmpConfig>>;
|
||||||
@@ -904,6 +907,8 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
|||||||
|
|
||||||
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function StartCWDecoder():Promise<void>;
|
export function StartCWDecoder():Promise<void>;
|
||||||
|
|
||||||
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||||
|
|||||||
@@ -638,6 +638,10 @@ export function GetAlertEmailTo() {
|
|||||||
return window['go']['main']['App']['GetAlertEmailTo']();
|
return window['go']['main']['App']['GetAlertEmailTo']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAmpBandFollowStatus() {
|
||||||
|
return window['go']['main']['App']['GetAmpBandFollowStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAmpStatuses() {
|
export function GetAmpStatuses() {
|
||||||
return window['go']['main']['App']['GetAmpStatuses']();
|
return window['go']['main']['App']['GetAmpStatuses']();
|
||||||
}
|
}
|
||||||
@@ -1758,6 +1762,10 @@ export function SetUltrabeamDirection(arg1) {
|
|||||||
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetWinkeyerTrace(arg1) {
|
||||||
|
return window['go']['main']['App']['SetWinkeyerTrace'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function StartCWDecoder() {
|
export function StartCWDecoder() {
|
||||||
return window['go']['main']['App']['StartCWDecoder']();
|
return window['go']['main']['App']['StartCWDecoder']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1442,6 +1442,10 @@ export namespace main {
|
|||||||
port: number;
|
port: number;
|
||||||
com_port: string;
|
com_port: string;
|
||||||
baud: number;
|
baud: number;
|
||||||
|
freq_out: boolean;
|
||||||
|
freq_com_port: string;
|
||||||
|
freq_baud: number;
|
||||||
|
freq_broadcast_ms: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new AmpConfig(source);
|
return new AmpConfig(source);
|
||||||
@@ -1458,6 +1462,10 @@ export namespace main {
|
|||||||
this.port = source["port"];
|
this.port = source["port"];
|
||||||
this.com_port = source["com_port"];
|
this.com_port = source["com_port"];
|
||||||
this.baud = source["baud"];
|
this.baud = source["baud"];
|
||||||
|
this.freq_out = source["freq_out"];
|
||||||
|
this.freq_com_port = source["freq_com_port"];
|
||||||
|
this.freq_baud = source["freq_baud"];
|
||||||
|
this.freq_broadcast_ms = source["freq_broadcast_ms"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class AmpStatus {
|
export class AmpStatus {
|
||||||
@@ -4196,6 +4204,12 @@ export namespace qso {
|
|||||||
by_day7: Bucket[];
|
by_day7: Bucket[];
|
||||||
by_day30: Bucket[];
|
by_day30: Bucket[];
|
||||||
by_month12: Bucket[];
|
by_month12: Bucket[];
|
||||||
|
by_day_win: Bucket[];
|
||||||
|
by_week_win: Bucket[];
|
||||||
|
by_month_win: Bucket[];
|
||||||
|
by_year_win: Bucket[];
|
||||||
|
by_hour_of_day: Bucket[];
|
||||||
|
by_weekday: Bucket[];
|
||||||
window_start: string;
|
window_start: string;
|
||||||
window_end: string;
|
window_end: string;
|
||||||
window_hours: number;
|
window_hours: number;
|
||||||
@@ -4240,6 +4254,12 @@ export namespace qso {
|
|||||||
this.by_day7 = this.convertValues(source["by_day7"], Bucket);
|
this.by_day7 = this.convertValues(source["by_day7"], Bucket);
|
||||||
this.by_day30 = this.convertValues(source["by_day30"], Bucket);
|
this.by_day30 = this.convertValues(source["by_day30"], Bucket);
|
||||||
this.by_month12 = this.convertValues(source["by_month12"], Bucket);
|
this.by_month12 = this.convertValues(source["by_month12"], Bucket);
|
||||||
|
this.by_day_win = this.convertValues(source["by_day_win"], Bucket);
|
||||||
|
this.by_week_win = this.convertValues(source["by_week_win"], Bucket);
|
||||||
|
this.by_month_win = this.convertValues(source["by_month_win"], Bucket);
|
||||||
|
this.by_year_win = this.convertValues(source["by_year_win"], Bucket);
|
||||||
|
this.by_hour_of_day = this.convertValues(source["by_hour_of_day"], Bucket);
|
||||||
|
this.by_weekday = this.convertValues(source["by_weekday"], Bucket);
|
||||||
this.window_start = source["window_start"];
|
this.window_start = source["window_start"];
|
||||||
this.window_end = source["window_end"];
|
this.window_end = source["window_end"];
|
||||||
this.window_hours = source["window_hours"];
|
this.window_hours = source["window_hours"];
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
// Package catemu emulates a transceiver on a serial port so a device that
|
||||||
|
// POLLS a radio for its frequency can follow OpsLog instead.
|
||||||
|
//
|
||||||
|
// This exists for the ACOM amplifiers: on their CAT/AUX connector the amp is
|
||||||
|
// the master — it polls the transceiver every few hundred milliseconds and
|
||||||
|
// changes band only when it gets a valid reply. There is no way to push a
|
||||||
|
// frequency to it, so following OpsLog means answering its polls.
|
||||||
|
//
|
||||||
|
// The dialect is ACOM "command set 5" (Kenwood / Elecraft RS-232, also what
|
||||||
|
// Flex and SunSDR users select): plain ASCII commands terminated by ';'. It is
|
||||||
|
// the simplest of the five sets by a wide margin, which is why the SDC utility
|
||||||
|
// uses it to steer an ACOM with no physical radio attached.
|
||||||
|
//
|
||||||
|
// Only the handful of commands an amp actually asks for are implemented:
|
||||||
|
//
|
||||||
|
// FA; → FA00014025000; TX frequency, 11 digits, Hz
|
||||||
|
// FB; → same (sub VFO — amps poll it on some firmware)
|
||||||
|
// IF; → the 38-character TS-2000 status frame
|
||||||
|
// ID; → ID019; (TS-2000 — a known model keeps the amp from timing out)
|
||||||
|
//
|
||||||
|
// Anything else is ignored rather than answered: a wrong-length reply is worse
|
||||||
|
// than none, because it desynchronises the amp's parser for the next poll.
|
||||||
|
package catemu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is the serial port the amplifier's CAT/AUX cable is wired to. This is
|
||||||
|
// a SECOND port, independent of the one used for the amp's own remote/metering
|
||||||
|
// protocol — both run at the same time on an ACOM.
|
||||||
|
type Config struct {
|
||||||
|
ComPort string
|
||||||
|
Baud int
|
||||||
|
|
||||||
|
// BroadcastMs > 0 also sends the frequency UNPROMPTED every that many
|
||||||
|
// milliseconds. Some amplifiers do not poll at all: they sit in parallel on
|
||||||
|
// the radio↔PC CAT line and read whatever goes past. Such an amp would never
|
||||||
|
// hear us, since answering polls means speaking only when spoken to.
|
||||||
|
// 0 = answer polls only.
|
||||||
|
BroadcastMs int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is what the settings panel shows about the link.
|
||||||
|
type Status struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Port string `json:"port"`
|
||||||
|
Polls int64 `json:"polls"` // replies sent since start
|
||||||
|
LastCmd string `json:"last_cmd"` // last command received, e.g. "FA;"
|
||||||
|
LastAt string `json:"last_at"` // RFC3339 of the last poll, "" if none
|
||||||
|
FreqHz int64 `json:"freq_hz"` // what we are currently answering
|
||||||
|
Error string `json:"error"` // last open/IO failure
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server answers a polling amplifier on one serial port.
|
||||||
|
type Server struct {
|
||||||
|
cfg Config
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
port serial.Port
|
||||||
|
status Status
|
||||||
|
|
||||||
|
freqHz atomic.Int64
|
||||||
|
mode atomic.Value // string, ADIF-ish ("CW", "USB"…)
|
||||||
|
|
||||||
|
stop chan struct{}
|
||||||
|
done chan struct{}
|
||||||
|
|
||||||
|
logf func(string, ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a server. Nothing is opened until Start.
|
||||||
|
func New(cfg Config, logf func(string, ...any)) *Server {
|
||||||
|
if cfg.Baud <= 0 {
|
||||||
|
cfg.Baud = 9600
|
||||||
|
}
|
||||||
|
s := &Server{cfg: cfg, logf: logf}
|
||||||
|
s.mode.Store("")
|
||||||
|
s.status.Port = cfg.ComPort
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) log(format string, args ...any) {
|
||||||
|
if s.logf != nil {
|
||||||
|
s.logf(format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFrequency updates the frequency reported to the amplifier. Called from the
|
||||||
|
// CAT state callback; safe from any goroutine and never blocks — the serve loop
|
||||||
|
// reads the value when a poll arrives, so a fast-tuning VFO costs nothing.
|
||||||
|
func (s *Server) SetFrequency(hz int64) { s.freqHz.Store(hz) }
|
||||||
|
|
||||||
|
// SetMode updates the mode digit in the IF frame. Optional: the amp only cares
|
||||||
|
// about the frequency, but a coherent frame avoids odd firmware behaviour.
|
||||||
|
func (s *Server) SetMode(mode string) { s.mode.Store(strings.ToUpper(strings.TrimSpace(mode))) }
|
||||||
|
|
||||||
|
// Start opens the port and serves polls until Stop. It returns immediately;
|
||||||
|
// a port that is missing or busy is retried every 5 s, because the amplifier is
|
||||||
|
// often powered on after the software.
|
||||||
|
func (s *Server) Start() {
|
||||||
|
s.stop = make(chan struct{})
|
||||||
|
s.done = make(chan struct{})
|
||||||
|
go s.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop closes the port and waits for the loop to end.
|
||||||
|
func (s *Server) Stop() {
|
||||||
|
if s.stop == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
close(s.stop)
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.port != nil {
|
||||||
|
_ = s.port.Close()
|
||||||
|
s.port = nil
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
<-s.done
|
||||||
|
s.stop = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus returns a snapshot for the UI.
|
||||||
|
func (s *Server) GetStatus() Status {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
st := s.status
|
||||||
|
st.Enabled = true
|
||||||
|
st.FreqHz = s.freqHz.Load()
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) setErr(msg string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.status.Error = msg
|
||||||
|
s.status.Connected = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) run() {
|
||||||
|
defer close(s.done)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if err := s.open(); err != nil {
|
||||||
|
s.setErr(err.Error())
|
||||||
|
s.log("catemu: %s open failed: %v (retry in 5s)", s.cfg.ComPort, err)
|
||||||
|
select {
|
||||||
|
case <-s.stop:
|
||||||
|
return
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.serve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) open() error {
|
||||||
|
if strings.TrimSpace(s.cfg.ComPort) == "" {
|
||||||
|
return fmt.Errorf("no COM port configured")
|
||||||
|
}
|
||||||
|
p, err := serial.Open(s.cfg.ComPort, &serial.Mode{
|
||||||
|
BaudRate: s.cfg.Baud,
|
||||||
|
DataBits: 8,
|
||||||
|
Parity: serial.NoParity,
|
||||||
|
StopBits: serial.OneStopBit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// A short read timeout keeps the loop responsive to Stop while idle: the amp
|
||||||
|
// may poll only every few hundred ms, and a blocking read would hold the
|
||||||
|
// port open past shutdown.
|
||||||
|
_ = p.SetReadTimeout(200 * time.Millisecond)
|
||||||
|
s.mu.Lock()
|
||||||
|
s.port = p
|
||||||
|
s.status.Connected = true
|
||||||
|
s.status.Error = ""
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.log("catemu: serving Kenwood-format polls on %s at %d baud", s.cfg.ComPort, s.cfg.Baud)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcast sends an unsolicited FA frame at the configured interval, for an
|
||||||
|
// amplifier that listens to the CAT line rather than polling it. It stops when
|
||||||
|
// the port is closed or Stop is called.
|
||||||
|
func (s *Server) broadcast(stopServe <-chan struct{}) {
|
||||||
|
if s.cfg.BroadcastMs <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Below ~100 ms this is pure noise on the wire; the band only ever changes
|
||||||
|
// at human speed.
|
||||||
|
every := time.Duration(s.cfg.BroadcastMs) * time.Millisecond
|
||||||
|
if every < 100*time.Millisecond {
|
||||||
|
every = 100 * time.Millisecond
|
||||||
|
}
|
||||||
|
t := time.NewTicker(every)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stop:
|
||||||
|
return
|
||||||
|
case <-stopServe:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
hz := s.freqHz.Load()
|
||||||
|
if hz <= 0 {
|
||||||
|
continue // nothing known yet — say nothing rather than "0 Hz"
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
p := s.port
|
||||||
|
s.mu.Unlock()
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := p.Write([]byte(fmt.Sprintf("FA%011d;", hz))); err != nil {
|
||||||
|
s.setErr(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve reads commands until the port fails or Stop is called.
|
||||||
|
func (s *Server) serve() {
|
||||||
|
// The broadcaster shares this port and must die with it, or it would write
|
||||||
|
// into a closed handle after a reopen.
|
||||||
|
stopServe := make(chan struct{})
|
||||||
|
defer close(stopServe)
|
||||||
|
go s.broadcast(stopServe)
|
||||||
|
|
||||||
|
buf := make([]byte, 64)
|
||||||
|
var acc []byte
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
p := s.port
|
||||||
|
s.mu.Unlock()
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := p.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
s.setErr(err.Error())
|
||||||
|
s.log("catemu: %s read failed: %v — reopening", s.cfg.ComPort, err)
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.port != nil {
|
||||||
|
_ = s.port.Close()
|
||||||
|
s.port = nil
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
acc = append(acc, buf[:n]...)
|
||||||
|
// Commands are ';'-terminated; handle every complete one in the buffer.
|
||||||
|
for {
|
||||||
|
i := indexByte(acc, ';')
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cmd := strings.ToUpper(strings.TrimSpace(string(acc[:i])))
|
||||||
|
acc = acc[i+1:]
|
||||||
|
s.handle(p, cmd)
|
||||||
|
}
|
||||||
|
// A runaway buffer means we are seeing something that is not this
|
||||||
|
// protocol (wrong baud, or the amp's other port); drop it rather than
|
||||||
|
// grow without bound.
|
||||||
|
if len(acc) > 512 {
|
||||||
|
acc = acc[:0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexByte(b []byte, c byte) int {
|
||||||
|
for i := range b {
|
||||||
|
if b[i] == c {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle answers one command. cmd has no trailing ';'.
|
||||||
|
func (s *Server) handle(p serial.Port, cmd string) {
|
||||||
|
hz := s.freqHz.Load()
|
||||||
|
var reply string
|
||||||
|
switch {
|
||||||
|
case cmd == "FA" || cmd == "FB":
|
||||||
|
reply = fmt.Sprintf("%s%011d;", cmd, hz)
|
||||||
|
case cmd == "IF":
|
||||||
|
reply = s.ifFrame(hz)
|
||||||
|
case cmd == "ID":
|
||||||
|
reply = "ID019;" // TS-2000
|
||||||
|
default:
|
||||||
|
// Unknown or a SET command (FA00014025000;) — silently ignored: an amp
|
||||||
|
// never sets our frequency, and answering the wrong length would break
|
||||||
|
// its parser for the following poll.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := p.Write([]byte(reply)); err != nil {
|
||||||
|
s.setErr(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.status.Polls++
|
||||||
|
s.status.LastCmd = cmd + ";"
|
||||||
|
s.status.LastAt = time.Now().Format(time.RFC3339)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// modeDigit maps our mode name to the Kenwood mode digit used in IF.
|
||||||
|
func (s *Server) modeDigit() byte {
|
||||||
|
m, _ := s.mode.Load().(string)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(m, "CW"):
|
||||||
|
return '3'
|
||||||
|
case strings.HasPrefix(m, "LSB"):
|
||||||
|
return '1'
|
||||||
|
case strings.HasPrefix(m, "USB"), strings.HasPrefix(m, "SSB"):
|
||||||
|
return '2'
|
||||||
|
case strings.HasPrefix(m, "FM"):
|
||||||
|
return '4'
|
||||||
|
case strings.HasPrefix(m, "AM"):
|
||||||
|
return '5'
|
||||||
|
case m == "RTTY", strings.HasPrefix(m, "FSK"):
|
||||||
|
return '6'
|
||||||
|
case m == "":
|
||||||
|
return '2'
|
||||||
|
default:
|
||||||
|
// Data modes (FT8, PSK…) ride on SSB as far as an amplifier cares.
|
||||||
|
return '2'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ifFrame builds the 38-character TS-2000 IF status frame:
|
||||||
|
//
|
||||||
|
// IF | freq(11) | step(4) | RIT(6) | RIT/XIT/…(3) | memch(2) | rx/tx | mode |
|
||||||
|
// FR | scan | split | tone | tone#(2) | shift | ;
|
||||||
|
//
|
||||||
|
// Only the frequency and mode carry meaning here; the rest is a valid, inert
|
||||||
|
// state (no RIT, receiving, simplex) so the amp's parser is satisfied.
|
||||||
|
func (s *Server) ifFrame(hz int64) string {
|
||||||
|
return fmt.Sprintf("IF%011d%04d%+06d%03d%02d%01d%c%01d%01d%01d%01d%02d%01d;",
|
||||||
|
hz, // P1 frequency, Hz
|
||||||
|
0, // P2 frequency step
|
||||||
|
0, // P3 RIT/XIT offset, signed 5 digits
|
||||||
|
0, // P4-P6 RIT off, XIT off, channel-bank
|
||||||
|
0, // P7 memory channel
|
||||||
|
0, // P8 0 = RX
|
||||||
|
s.modeDigit(), // P9 mode
|
||||||
|
0, // P10 VFO A
|
||||||
|
0, // P11 scan off
|
||||||
|
0, // P12 split off
|
||||||
|
0, // P13 tone off
|
||||||
|
0, // P14 tone number
|
||||||
|
0, // P15 shift
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package catemu
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The reply LENGTHS are the contract: an amplifier parses these frames by
|
||||||
|
// fixed offsets, so a frame one character short desynchronises its parser for
|
||||||
|
// every following poll. Pin them.
|
||||||
|
func TestReplyShapes(t *testing.T) {
|
||||||
|
s := New(Config{ComPort: "COM99", Baud: 9600}, nil)
|
||||||
|
s.SetFrequency(14025000)
|
||||||
|
s.SetMode("CW")
|
||||||
|
|
||||||
|
if got := s.ifFrame(14025000); len(got) != 38 {
|
||||||
|
t.Errorf("IF frame is %d chars, want 38: %q", len(got), got)
|
||||||
|
}
|
||||||
|
// TS-2000 IF: "IF" then the 11-digit frequency in Hz.
|
||||||
|
if got := s.ifFrame(14025000); got[:13] != "IF00014025000" {
|
||||||
|
t.Errorf("IF frame frequency field = %q", got[:13])
|
||||||
|
}
|
||||||
|
if got := s.ifFrame(14025000); got[len(got)-1] != ';' {
|
||||||
|
t.Errorf("IF frame not terminated by ';': %q", got)
|
||||||
|
}
|
||||||
|
// Mode digit sits at P9, right after freq(11)+step(4)+rit(6)+3+2+1 — index 29 in the frame.
|
||||||
|
if got := s.ifFrame(14025000)[29]; got != '3' {
|
||||||
|
t.Errorf("CW mode digit = %q, want '3'", got)
|
||||||
|
}
|
||||||
|
s.SetMode("FT8") // data rides on SSB as far as an amp cares
|
||||||
|
if got := s.ifFrame(14074000)[29]; got != '2' {
|
||||||
|
t.Errorf("FT8 mode digit = %q, want '2'", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModeDigit(t *testing.T) {
|
||||||
|
cases := map[string]byte{
|
||||||
|
"CW": '3', "CW-R": '3', "LSB": '1', "USB": '2', "SSB": '2',
|
||||||
|
"FM": '4', "AM": '5', "RTTY": '6', "": '2', "FT8": '2',
|
||||||
|
}
|
||||||
|
for mode, want := range cases {
|
||||||
|
s := New(Config{}, nil)
|
||||||
|
s.SetMode(mode)
|
||||||
|
if got := s.modeDigit(); got != want {
|
||||||
|
t.Errorf("mode %q → %q, want %q", mode, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-5
@@ -536,11 +536,23 @@ type UploadRow struct {
|
|||||||
// uploadStatusCols whitelists the per-service sent-status columns the QSL
|
// uploadStatusCols whitelists the per-service sent-status columns the QSL
|
||||||
// Manager may filter on (guards the dynamic column name in the query).
|
// Manager may filter on (guards the dynamic column name in the query).
|
||||||
var uploadStatusCols = map[string]bool{
|
var uploadStatusCols = map[string]bool{
|
||||||
"lotw_sent": true,
|
"lotw_sent": true,
|
||||||
"eqsl_sent": true,
|
"eqsl_sent": true,
|
||||||
"qrzcom_qso_upload_status": true,
|
"qrzcom_qso_upload_status": true,
|
||||||
"clublog_qso_upload_status": true,
|
"qrzcom_qso_download_status": true,
|
||||||
"hrdlog_qso_upload_status": true,
|
"clublog_qso_upload_status": true,
|
||||||
|
"hrdlog_qso_upload_status": true,
|
||||||
|
// Confirmation dates (ADIF YYYYMMDD strings, so plain TEXT like the rest).
|
||||||
|
"qsl_sent_date": true,
|
||||||
|
"qsl_rcvd_date": true,
|
||||||
|
"lotw_sent_date": true,
|
||||||
|
"lotw_rcvd_date": true,
|
||||||
|
"eqsl_sent_date": true,
|
||||||
|
"eqsl_rcvd_date": true,
|
||||||
|
"qrzcom_qso_upload_date": true,
|
||||||
|
"qrzcom_qso_download_date": true,
|
||||||
|
"clublog_qso_upload_date": true,
|
||||||
|
"hrdlog_qso_upload_date": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListForUpload returns QSOs whose per-service sent-status column equals
|
// ListForUpload returns QSOs whose per-service sent-status column equals
|
||||||
|
|||||||
+145
-32
@@ -156,28 +156,49 @@ type Stats struct {
|
|||||||
ByMode []Bucket `json:"by_mode"`
|
ByMode []Bucket `json:"by_mode"`
|
||||||
ByBand []Bucket `json:"by_band"`
|
ByBand []Bucket `json:"by_band"`
|
||||||
ByBandCategory []BandCategory `json:"by_band_category"` // per band: CW / phone / data split
|
ByBandCategory []BandCategory `json:"by_band_category"` // per band: CW / phone / data split
|
||||||
ByOperator []Bucket `json:"by_operator"`
|
ByOperator []Bucket `json:"by_operator"`
|
||||||
ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air)
|
ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air)
|
||||||
ByContinent []Bucket `json:"by_continent"`
|
ByContinent []Bucket `json:"by_continent"`
|
||||||
TopEntities []Bucket `json:"top_entities"`
|
TopEntities []Bucket `json:"top_entities"`
|
||||||
ByYear []Bucket `json:"by_year"` // chronological
|
ByYear []Bucket `json:"by_year"` // chronological
|
||||||
ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological
|
ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological
|
||||||
|
|
||||||
// Rolling chronological activity windows (UTC), anchored to the newest QSO, for
|
// Rolling chronological activity windows (UTC), anchored to the newest QSO, for
|
||||||
// the "activity over time" chart's day/week/month/year granularity selector.
|
// the "activity over time" chart's day/week/month/year granularity selector.
|
||||||
// Real dates in order (empty buckets are real, just zero) — no cyclical wrap.
|
// Real dates in order (empty buckets are real, just zero) — no cyclical wrap.
|
||||||
ByHour []Bucket `json:"by_hour"` // the newest QSO's day, hour by hour (00..23)
|
ByHour []Bucket `json:"by_hour"` // the newest QSO's day, hour by hour (00..23)
|
||||||
ByDay7 []Bucket `json:"by_day7"` // the last 7 days ending at the newest QSO
|
ByDay7 []Bucket `json:"by_day7"` // the last 7 days ending at the newest QSO
|
||||||
ByDay30 []Bucket `json:"by_day30"` // the last 30 days
|
ByDay30 []Bucket `json:"by_day30"` // the last 30 days
|
||||||
ByMonth12 []Bucket `json:"by_month12"` // the last 12 months
|
ByMonth12 []Bucket `json:"by_month12"` // the last 12 months
|
||||||
|
|
||||||
|
// Chronological activity ACROSS THE SELECTED PERIOD, one series per bucket
|
||||||
|
// size. These are what the activity chart's day/week/month/year selector
|
||||||
|
// drives: the rolling windows above ignored the period filter, so choosing a
|
||||||
|
// year in the panel left the chart showing the last 7 days regardless — two
|
||||||
|
// controls contradicting each other on screen.
|
||||||
|
//
|
||||||
|
// A series is EMPTY when it would exceed maxActivityBuckets (per-day over a
|
||||||
|
// seventeen-year log is ~6000 bars: unreadable, and pointless to transport).
|
||||||
|
// The UI treats an empty series as "too fine for this period" and steps up.
|
||||||
|
ByDayWin []Bucket `json:"by_day_win"`
|
||||||
|
ByWeekWin []Bucket `json:"by_week_win"`
|
||||||
|
ByMonthWin []Bucket `json:"by_month_win"`
|
||||||
|
ByYearWin []Bucket `json:"by_year_win"`
|
||||||
|
|
||||||
|
// Rhythm: WHEN the operator is on the air, over the selected period. Distinct
|
||||||
|
// question from the above ("how much, lately"), so it gets its own card. The
|
||||||
|
// hour histogram used to cover a single day, which is too little to read
|
||||||
|
// anything from.
|
||||||
|
ByHourOfDay []Bucket `json:"by_hour_of_day"` // 00h..23h UTC
|
||||||
|
ByWeekday []Bucket `json:"by_weekday"` // Mon..Sun
|
||||||
|
|
||||||
// ── Period / contest metrics ──
|
// ── Period / contest metrics ──
|
||||||
// Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says
|
// Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says
|
||||||
// nothing, but across a contest weekend it is the score. The window is the
|
// nothing, but across a contest weekend it is the score. The window is the
|
||||||
// requested [from,to] when given, else the span of the log.
|
// requested [from,to] when given, else the span of the log.
|
||||||
WindowStart string `json:"window_start"`
|
WindowStart string `json:"window_start"`
|
||||||
WindowEnd string `json:"window_end"`
|
WindowEnd string `json:"window_end"`
|
||||||
WindowHours float64 `json:"window_hours"`
|
WindowHours float64 `json:"window_hours"`
|
||||||
AvgPerHour float64 `json:"avg_per_hour"` // QSOs ÷ window hours (breaks included — the honest rate)
|
AvgPerHour float64 `json:"avg_per_hour"` // QSOs ÷ window hours (breaks included — the honest rate)
|
||||||
AvgPerActive float64 `json:"avg_per_active"` // QSOs ÷ ON-AIR hours
|
AvgPerActive float64 `json:"avg_per_active"` // QSOs ÷ ON-AIR hours
|
||||||
|
|
||||||
@@ -297,28 +318,28 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
|||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
calls = map[string]struct{}{}
|
calls = map[string]struct{}{}
|
||||||
entities = map[int]struct{}{}
|
entities = map[int]struct{}{}
|
||||||
modeC = map[string]int{}
|
modeC = map[string]int{}
|
||||||
bandC = map[string]int{}
|
bandC = map[string]int{}
|
||||||
bandCat = map[string]*BandCategory{} // per band: cw/phone/data
|
bandCat = map[string]*BandCategory{} // per band: cw/phone/data
|
||||||
opC = map[string]int{}
|
opC = map[string]int{}
|
||||||
stationC = map[string]int{}
|
stationC = map[string]int{}
|
||||||
contC = map[string]int{}
|
contC = map[string]int{}
|
||||||
entityC = map[string]int{}
|
entityC = map[string]int{}
|
||||||
yearC = map[string]int{}
|
yearC = map[string]int{}
|
||||||
monthC = map[string]int{}
|
monthC = map[string]int{}
|
||||||
times []entry // every dated QSO (+ its operator), for the rate / gap maths
|
times []entry // every dated QSO (+ its operator), for the rate / gap maths
|
||||||
first, last time.Time
|
first, last time.Time
|
||||||
)
|
)
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var (
|
var (
|
||||||
call, band, mode, cont, country sql.NullString
|
call, band, mode, cont, country sql.NullString
|
||||||
oper, station sql.NullString
|
oper, station sql.NullString
|
||||||
lotw, eqsl, paper sql.NullString
|
lotw, eqsl, paper sql.NullString
|
||||||
dxcc sql.NullInt64
|
dxcc sql.NullInt64
|
||||||
dateStr, contestID2 sql.NullString
|
dateStr, contestID2 sql.NullString
|
||||||
)
|
)
|
||||||
if err := rows.Scan(&call, &dateStr, &band, &mode, &cont, &country, &dxcc,
|
if err := rows.Scan(&call, &dateStr, &band, &mode, &cont, &country, &dxcc,
|
||||||
&oper, &station, &lotw, &eqsl, &paper, &contestID2); err != nil {
|
&oper, &station, &lotw, &eqsl, &paper, &contestID2); err != nil {
|
||||||
@@ -492,11 +513,95 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
|||||||
ref = time.Now()
|
ref = time.Now()
|
||||||
}
|
}
|
||||||
s.recentSeries(times, ref)
|
s.recentSeries(times, ref)
|
||||||
|
s.windowSeries(times, first, last, from, to)
|
||||||
s.ensureNonNil()
|
s.ensureNonNil()
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxActivityBuckets caps a chronological series. Beyond this the chart is a
|
||||||
|
// grey smear and the payload grows for nothing; the series is dropped and the UI
|
||||||
|
// steps up to a coarser bucket.
|
||||||
|
const maxActivityBuckets = 750
|
||||||
|
|
||||||
|
// windowSeries builds the chronological activity series ACROSS THE SELECTED
|
||||||
|
// PERIOD, one per bucket size, plus the hour-of-day / weekday rhythm.
|
||||||
|
//
|
||||||
|
// The period is [from,to] when the caller filtered, else the span of the log —
|
||||||
|
// the same window the period metrics use, so every card on the panel answers for
|
||||||
|
// the same slice of time.
|
||||||
|
func (s *Stats) windowSeries(times []entry, first, last, from, to time.Time) {
|
||||||
|
start, end := from, to
|
||||||
|
if start.IsZero() {
|
||||||
|
start = first
|
||||||
|
}
|
||||||
|
if end.IsZero() {
|
||||||
|
end = last
|
||||||
|
}
|
||||||
|
if start.IsZero() || end.IsZero() || end.Before(start) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start, end = start.UTC(), end.UTC()
|
||||||
|
|
||||||
|
day := map[string]int{}
|
||||||
|
week := map[string]int{}
|
||||||
|
month := map[string]int{}
|
||||||
|
year := map[string]int{}
|
||||||
|
hour := make([]int, 24)
|
||||||
|
weekday := make([]int, 7)
|
||||||
|
for _, e := range times {
|
||||||
|
t := e.t.UTC()
|
||||||
|
if t.Before(start) || t.After(end) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
day[t.Format("2006-01-02")]++
|
||||||
|
// ISO week, so a week is Monday→Sunday everywhere and the key sorts.
|
||||||
|
wy, wn := t.ISOWeek()
|
||||||
|
week[fmt.Sprintf("%04d-W%02d", wy, wn)]++
|
||||||
|
month[t.Format("2006-01")]++
|
||||||
|
year[t.Format("2006")]++
|
||||||
|
hour[t.Hour()]++
|
||||||
|
weekday[(int(t.Weekday())+6)%7]++ // Go weeks start on Sunday; shift to Monday
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every bucket in the range is emitted, including the empty ones: a gap in
|
||||||
|
// the log is real information and must show as zero, not be closed up.
|
||||||
|
d0 := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
dEnd := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
if n := int(dEnd.Sub(d0).Hours()/24) + 1; n >= 1 && n <= maxActivityBuckets {
|
||||||
|
for d := d0; !d.After(dEnd); d = d.AddDate(0, 0, 1) {
|
||||||
|
s.ByDayWin = append(s.ByDayWin, Bucket{Key: d.Format("2006-01-02"), Count: day[d.Format("2006-01-02")]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Weeks: start on the Monday of the first week and step by 7 days.
|
||||||
|
w0 := d0.AddDate(0, 0, -((int(d0.Weekday()) + 6) % 7))
|
||||||
|
if n := int(dEnd.Sub(w0).Hours()/24)/7 + 1; n >= 1 && n <= maxActivityBuckets {
|
||||||
|
for w := w0; !w.After(dEnd); w = w.AddDate(0, 0, 7) {
|
||||||
|
wy, wn := w.ISOWeek()
|
||||||
|
key := fmt.Sprintf("%04d-W%02d", wy, wn)
|
||||||
|
s.ByWeekWin = append(s.ByWeekWin, Bucket{Key: key, Count: week[key]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m0 := time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
mEnd := time.Date(end.Year(), end.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
if n := (mEnd.Year()-m0.Year())*12 + int(mEnd.Month()) - int(m0.Month()) + 1; n >= 1 && n <= maxActivityBuckets {
|
||||||
|
for m := m0; !m.After(mEnd); m = m.AddDate(0, 1, 0) {
|
||||||
|
s.ByMonthWin = append(s.ByMonthWin, Bucket{Key: m.Format("2006-01"), Count: month[m.Format("2006-01")]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for y := start.Year(); y <= end.Year(); y++ {
|
||||||
|
k := fmt.Sprintf("%04d", y)
|
||||||
|
s.ByYearWin = append(s.ByYearWin, Bucket{Key: k, Count: year[k]})
|
||||||
|
}
|
||||||
|
|
||||||
|
for h := 0; h < 24; h++ {
|
||||||
|
s.ByHourOfDay = append(s.ByHourOfDay, Bucket{Key: fmt.Sprintf("%02dh", h), Count: hour[h]})
|
||||||
|
}
|
||||||
|
for i, name := range []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} {
|
||||||
|
s.ByWeekday = append(s.ByWeekday, Bucket{Key: name, Count: weekday[i]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// recentSeries builds the rolling chronological activity windows for the chart's
|
// recentSeries builds the rolling chronological activity windows for the chart's
|
||||||
// day/week/month/year selector, in UTC and anchored to `ref` (the period end when
|
// day/week/month/year selector, in UTC and anchored to `ref` (the period end when
|
||||||
// filtered, else now). Real dates in order — today and the days before it — so the
|
// filtered, else now). Real dates in order — today and the days before it — so the
|
||||||
@@ -569,6 +674,14 @@ func (s *Stats) ensureNonNil() {
|
|||||||
if s.ByMonth == nil {
|
if s.ByMonth == nil {
|
||||||
s.ByMonth = []Bucket{}
|
s.ByMonth = []Bucket{}
|
||||||
}
|
}
|
||||||
|
// A window series stays EMPTY on purpose when the bucket would be too fine
|
||||||
|
// for the period — but it must be [] and not null, so the UI can tell
|
||||||
|
// "too fine" from "not computed".
|
||||||
|
for _, p := range []*[]Bucket{&s.ByDayWin, &s.ByWeekWin, &s.ByMonthWin, &s.ByYearWin, &s.ByHourOfDay, &s.ByWeekday} {
|
||||||
|
if *p == nil {
|
||||||
|
*p = []Bucket{}
|
||||||
|
}
|
||||||
|
}
|
||||||
if s.Rate == nil {
|
if s.Rate == nil {
|
||||||
s.Rate = []Bucket{}
|
s.Rate = []Bucket{}
|
||||||
}
|
}
|
||||||
@@ -588,9 +701,10 @@ func (s *Stats) ensureNonNil() {
|
|||||||
// log itself.
|
// log itself.
|
||||||
//
|
//
|
||||||
// Two rates are reported on purpose, because a single one always flatters:
|
// Two rates are reported on purpose, because a single one always flatters:
|
||||||
// • AvgPerHour = QSOs ÷ the WHOLE window — breaks included. The honest number.
|
// - AvgPerHour = QSOs ÷ the WHOLE window — breaks included. The honest number.
|
||||||
// • AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you
|
// - AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you
|
||||||
// how fast you go when you ARE at the radio.
|
// how fast you go when you ARE at the radio.
|
||||||
|
//
|
||||||
// Quoting only the second is how an 8-hour effort gets sold as a 48-hour score.
|
// Quoting only the second is how an 8-hour effort gets sold as a 48-hour score.
|
||||||
func (s *Stats) periodMetrics(times []entry, from, to, first, last time.Time) {
|
func (s *Stats) periodMetrics(times []entry, from, to, first, last time.Time) {
|
||||||
if len(times) == 0 {
|
if len(times) == 0 {
|
||||||
@@ -790,7 +904,6 @@ func sortedBuckets(m map[string]int, less func(a, b string) bool) []Bucket {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// fillMonths emits EVERY month between the first and last QSO — zeros included —
|
// fillMonths emits EVERY month between the first and last QSO — zeros included —
|
||||||
// so the trend line's x-axis is real time rather than "months that happen to have
|
// so the trend line's x-axis is real time rather than "months that happen to have
|
||||||
// data". A quiet decade must read as a decade at zero, not vanish.
|
// data". A quiet decade must read as a decade at zero, not vanish.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.bug.st/serial"
|
"go.bug.st/serial"
|
||||||
@@ -179,7 +180,7 @@ func (m *Manager) Connect(cfg Config) error {
|
|||||||
stop, done := m.stopRead, m.doneRead
|
stop, done := m.stopRead, m.doneRead
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
applog.Printf("winkeyer: connected on %s (firmware byte %d)", cfg.Port, ver)
|
applog.Printf("winkeyer: connected on %s — %s", cfg.Port, firmwareFamily(ver))
|
||||||
go m.readLoop(p, stop, done)
|
go m.readLoop(p, stop, done)
|
||||||
|
|
||||||
if err := m.applyConfig(cfg); err != nil {
|
if err := m.applyConfig(cfg); err != nil {
|
||||||
@@ -398,10 +399,112 @@ func (m *Manager) write(b []byte) error {
|
|||||||
if p == nil {
|
if p == nil {
|
||||||
return fmt.Errorf("winkeyer: not connected")
|
return fmt.Errorf("winkeyer: not connected")
|
||||||
}
|
}
|
||||||
|
traceTX(b)
|
||||||
_, err := p.Write(b)
|
_, err := p.Write(b)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Protocol trace ─────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The WinKeyer command set differs between WK1, WK2 and WK3, and the failures
|
||||||
|
// it produces are silent: the keyer accepts a byte meant for another command
|
||||||
|
// and keys something odd. Guessing at that from a description ("it sends one
|
||||||
|
// element then pauses ten seconds") is how a fix for one firmware breaks the
|
||||||
|
// others, so the wire is made visible instead.
|
||||||
|
//
|
||||||
|
// Off by default — 1200 baud is slow but a long message would still fill the
|
||||||
|
// log. Enabled per session from the CW settings panel.
|
||||||
|
|
||||||
|
var traceOn atomic.Bool
|
||||||
|
|
||||||
|
// SetTrace turns the byte-level protocol trace on or off.
|
||||||
|
func SetTrace(on bool) {
|
||||||
|
traceOn.Store(on)
|
||||||
|
if on {
|
||||||
|
applog.Printf("winkeyer: protocol trace ON — every byte to and from the keyer is logged")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hexBytes(b []byte) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
for i, c := range b {
|
||||||
|
if i > 0 {
|
||||||
|
sb.WriteByte(' ')
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, "%02X", c)
|
||||||
|
if c >= 0x20 && c < 0x7F {
|
||||||
|
fmt.Fprintf(&sb, "(%c)", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func traceTX(b []byte) {
|
||||||
|
if !traceOn.Load() || len(b) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applog.Printf("winkeyer: TX %s %s", hexBytes(b), cmdName(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func traceRX(b []byte) {
|
||||||
|
if !traceOn.Load() || len(b) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applog.Printf("winkeyer: RX %s", hexBytes(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmdName names the command a TX frame carries, so the trace can be read
|
||||||
|
// without the datasheet open beside it.
|
||||||
|
func cmdName(b []byte) string {
|
||||||
|
if len(b) == 0 || b[0] >= 0x20 {
|
||||||
|
return "(text)"
|
||||||
|
}
|
||||||
|
switch b[0] {
|
||||||
|
case 0x00:
|
||||||
|
return "admin"
|
||||||
|
case 0x01:
|
||||||
|
return "sidetone"
|
||||||
|
case 0x02:
|
||||||
|
return "set wpm"
|
||||||
|
case 0x03:
|
||||||
|
return "set weight"
|
||||||
|
case 0x04:
|
||||||
|
return "ptt lead/tail"
|
||||||
|
case 0x05:
|
||||||
|
return "setup speed pot"
|
||||||
|
case 0x06:
|
||||||
|
return "pause"
|
||||||
|
case 0x0A:
|
||||||
|
return "clear buffer"
|
||||||
|
case 0x0D:
|
||||||
|
return "farnsworth wpm"
|
||||||
|
case 0x0E:
|
||||||
|
return "set mode register"
|
||||||
|
case 0x11:
|
||||||
|
return "0x11 (WK2: key compensation / WK3: see datasheet)"
|
||||||
|
case 0x15:
|
||||||
|
return "request status"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("cmd 0x%02X", b[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// firmwareFamily names the keyer generation from the byte returned by Host
|
||||||
|
// Open. The version is what decides which command set applies, so it is spelled
|
||||||
|
// out in the log rather than left as a bare number.
|
||||||
|
func firmwareFamily(ver int) string {
|
||||||
|
switch {
|
||||||
|
case ver == 0:
|
||||||
|
return "no reply — the keyer did not answer Host Open"
|
||||||
|
case ver < 20:
|
||||||
|
return fmt.Sprintf("WK1 (v%d)", ver)
|
||||||
|
case ver < 30:
|
||||||
|
return fmt.Sprintf("WK2 (v%d)", ver)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("WK3 (v%d)", ver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Disconnect sends Host Close and releases the port.
|
// Disconnect sends Host Close and releases the port.
|
||||||
func (m *Manager) Disconnect() {
|
func (m *Manager) Disconnect() {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -473,6 +576,7 @@ func (m *Manager) readLoop(p serial.Port, stop, done chan struct{}) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
traceRX(buf[:n])
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
b := buf[i]
|
b := buf[i]
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -112,6 +112,12 @@ func main() {
|
|||||||
MinWidth: 1100,
|
MinWidth: 1100,
|
||||||
MinHeight: 700,
|
MinHeight: 700,
|
||||||
WindowStartState: startState,
|
WindowStartState: startState,
|
||||||
|
// No OS title bar: it was a dead 32-pixel band above a window that already
|
||||||
|
// has its own title strip. The app header takes over — it carries the drag
|
||||||
|
// region, the double-click-to-maximise, and the minimise/maximise/close
|
||||||
|
// buttons. Windows still draws the resize borders and honours Aero Snap,
|
||||||
|
// which is why the frame is dropped rather than the whole chrome.
|
||||||
|
Frameless: true,
|
||||||
// Start hidden and reveal only once the saved position has been applied and
|
// Start hidden and reveal only once the saved position has been applied and
|
||||||
// the DOM has painted (OnDomReady → domReady) — so the window appears
|
// the DOM has painted (OnDomReady → domReady) — so the window appears
|
||||||
// already at its final size and position, with no post-launch jump.
|
// already at its final size and position, with no post-launch jump.
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The portable-folder contract: a database that lives inside the application
|
||||||
|
// folder must be stored relative to it, so copying C:\OpsLog to D:\OpsLog (or to
|
||||||
|
// a USB stick) keeps working. A path the operator deliberately put elsewhere
|
||||||
|
// must be left exactly as it is.
|
||||||
|
func TestPortablePath(t *testing.T) {
|
||||||
|
base := appDir()
|
||||||
|
if base == "" {
|
||||||
|
t.Skip("no executable dir available")
|
||||||
|
}
|
||||||
|
|
||||||
|
inside := filepath.Join(base, "data", "logbook.db")
|
||||||
|
if got := portablePath(inside); got != "data/logbook.db" {
|
||||||
|
t.Errorf("inside the app folder: got %q, want %q", got, "data/logbook.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outside: an absolute path on another drive / a synced folder is a
|
||||||
|
// deliberate choice and must survive untouched.
|
||||||
|
outside := filepath.FromSlash("Z:/Sync/ham/logbook.db")
|
||||||
|
if got := portablePath(outside); got != outside {
|
||||||
|
t.Errorf("outside the app folder: got %q, want it unchanged", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := portablePath(""); got != "" {
|
||||||
|
t.Errorf("empty path: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolvePath(t *testing.T) {
|
||||||
|
base := appDir()
|
||||||
|
if base == "" {
|
||||||
|
t.Skip("no executable dir available")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A relative path is anchored to the CURRENT install, whatever drive it is on.
|
||||||
|
want := filepath.Join(base, "data", "logbook.db")
|
||||||
|
if got := resolvePath("", "data/logbook.db"); got != want {
|
||||||
|
t.Errorf("relative: got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An absolute path that still exists is honoured as-is.
|
||||||
|
dir := t.TempDir()
|
||||||
|
real := filepath.Join(dir, "logbook.db")
|
||||||
|
if err := os.WriteFile(real, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := resolvePath(dir, real); got != real {
|
||||||
|
t.Errorf("existing absolute: got %q, want %q", got, real)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rescue: a path from ANOTHER machine, with the same file present in this
|
||||||
|
// install's data folder — the copied-folder case.
|
||||||
|
stale := filepath.FromSlash("C:/OldPC/OpsLog/data/logbook.db")
|
||||||
|
if got := resolvePath(dir, stale); got != real {
|
||||||
|
t.Errorf("stale absolute with a local twin: got %q, want %q", got, real)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No local twin: leave it alone so the failure is REPORTED rather than
|
||||||
|
// silently replaced by an unrelated database.
|
||||||
|
missing := filepath.FromSlash("C:/OldPC/OpsLog/data/other.db")
|
||||||
|
if got := resolvePath(dir, missing); got != missing {
|
||||||
|
t.Errorf("stale absolute with no twin: got %q, want it unchanged", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.21.4"
|
appVersion = "0.21.6"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user