Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
add4b175fc | ||
|
|
11940ae843 | ||
|
|
91f046444a | ||
|
|
386fb7f030 | ||
|
|
5a6db2b656 | ||
|
|
c3958be0b2 | ||
|
|
a42a3d5713 | ||
|
|
0a1569dbbd | ||
|
|
843dccf0c4 | ||
|
|
d49126d45c | ||
|
|
cc1ad06a9d | ||
|
|
4dd15b09e9 | ||
|
|
4eec272c0b | ||
|
|
816a727e88 |
@@ -98,6 +98,8 @@ const (
|
||||
keyCATEnabled = "cat.enabled"
|
||||
keyCATBackend = "cat.backend" // "omnirig" | "flex"
|
||||
keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2
|
||||
// Which VFO to believe when OmniRig names one. "" = trust the rig file.
|
||||
keyCATOmniRigVFO = "cat.omnirig.vfo" // "" | "A" | "B"
|
||||
keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend)
|
||||
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
|
||||
keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter
|
||||
@@ -330,6 +332,11 @@ type CATSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Backend string `json:"backend"` // "omnirig" | "flex" | "icom" | "icom-net" | "tci"
|
||||
OmniRigNum int `json:"omnirig_rig"` // 1 or 2 (OmniRig "Rig1"/"Rig2" slot)
|
||||
// OmniRigVFO overrides which VFO OpsLog reads: "" follows what the rig file
|
||||
// reports, "A"/"B" force one. Needed because that report is only as good as
|
||||
// the .ini: an IC-7610 file was seen declaring VFO B permanently while the
|
||||
// operator worked on the main VFO, so OpsLog wrote to A and read B.
|
||||
OmniRigVFO string `json:"omnirig_vfo"` // "" | "A" | "B"
|
||||
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
|
||||
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
|
||||
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
|
||||
@@ -1637,6 +1644,14 @@ func (a *App) saveWindowState() {
|
||||
if w > 0 && h > 0 {
|
||||
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)
|
||||
}
|
||||
@@ -1651,7 +1666,24 @@ func (a *App) restoreWindowPosition() {
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH {
|
||||
@@ -5544,9 +5576,26 @@ var bulkFieldColumns = map[string]string{
|
||||
"qsl_sent": "qsl_sent",
|
||||
"qsl_rcvd": "qsl_rcvd",
|
||||
"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",
|
||||
"clublog_upload": "clublog_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
|
||||
"station_callsign": "station_callsign",
|
||||
"operator": "operator",
|
||||
@@ -6224,6 +6273,22 @@ func (a *App) SaveCabrilloFile() (string, error) {
|
||||
// Errors are returned as-is to the frontend; ErrNotFound surfaces as
|
||||
// "callsign not found".
|
||||
func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
|
||||
return a.lookupCallsign(callsign, false)
|
||||
}
|
||||
|
||||
// LookupCallsignFresh is the same, but SKIPS the cache and refreshes it.
|
||||
//
|
||||
// For a lookup the operator asked for by clicking, in the QSO editor. The cache
|
||||
// is right for the automatic lookup that fires while typing, but it also freezes
|
||||
// a wrong answer for its whole 30-day life: an operator who upgraded their QRZ
|
||||
// subscription went on getting the thin free-account record, and deleting the
|
||||
// cached row by hand was the only way out. A deliberate click must reach the
|
||||
// provider and overwrite what was stored.
|
||||
func (a *App) LookupCallsignFresh(callsign string) (lookup.Result, error) {
|
||||
return a.lookupCallsign(callsign, true)
|
||||
}
|
||||
|
||||
func (a *App) lookupCallsign(callsign string, force bool) (lookup.Result, error) {
|
||||
if a.lookup == nil {
|
||||
return lookup.Result{}, fmt.Errorf("lookup not initialized")
|
||||
}
|
||||
@@ -6245,6 +6310,16 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(a.ctx, budget)
|
||||
defer cancel()
|
||||
if force {
|
||||
// A forced lookup has to reach the network, so it gets a longer leash than
|
||||
// the type-ahead one: giving up at 2 s would just fall back to cty.dat and
|
||||
// look like the click did nothing.
|
||||
cancel()
|
||||
ctx, cancel = context.WithTimeout(a.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
ctx = lookup.WithForce(ctx)
|
||||
applog.Printf("lookup: FORCED (cache bypassed) for %s", strings.ToUpper(strings.TrimSpace(callsign)))
|
||||
}
|
||||
r, err := a.lookup.Lookup(ctx, callsign)
|
||||
if errors.Is(err, lookup.ErrNotFound) {
|
||||
return lookup.Result{}, fmt.Errorf("callsign not found")
|
||||
@@ -6402,7 +6477,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
if a.settings == nil {
|
||||
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
|
||||
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
|
||||
if err != nil {
|
||||
return CATSettings{}, err
|
||||
}
|
||||
@@ -6450,6 +6525,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
if out.DigitalDefault == "" {
|
||||
out.DigitalDefault = "FT8"
|
||||
}
|
||||
if v := strings.ToUpper(strings.TrimSpace(m[keyCATOmniRigVFO])); v == "A" || v == "B" {
|
||||
out.OmniRigVFO = v
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATOmniRigNum]); n == 1 || n == 2 {
|
||||
out.OmniRigNum = n
|
||||
}
|
||||
@@ -6518,6 +6596,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
keyCATEnabled: enabled,
|
||||
keyCATBackend: s.Backend,
|
||||
keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum),
|
||||
keyCATOmniRigVFO: strings.ToUpper(strings.TrimSpace(s.OmniRigVFO)),
|
||||
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
|
||||
keyCATFlexPort: strconv.Itoa(s.FlexPort),
|
||||
keyCATFlexSpots: flexSpots,
|
||||
@@ -6541,7 +6620,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
a.reloadCAT()
|
||||
a.restartAsync("cat", a.reloadCAT)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9483,7 +9562,16 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
} else {
|
||||
emit("Fetching QRZ.com logbook (full — no since date)…")
|
||||
}
|
||||
fr, err := extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, "ALL")
|
||||
// Ask QRZ for the WINDOW rather than the whole logbook. "ALL" made the
|
||||
// server serialise every QSO — 56 MB for a 117k-record log — which it
|
||||
// truncates mid-record, so the parse died two thirds of the way through
|
||||
// and everything past that point was never seen. AFTER: narrows it at the
|
||||
// source; the client-side date filter below stays as the backstop.
|
||||
fetchOpt := "ALL"
|
||||
if sinceDate != "" {
|
||||
fetchOpt = "AFTER:" + sinceDate
|
||||
}
|
||||
fr, err := extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, fetchOpt)
|
||||
if err != nil {
|
||||
emit("Fetch failed: " + err.Error())
|
||||
done(matched, total)
|
||||
@@ -9491,14 +9579,11 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
}
|
||||
adifText := fr.ADIF
|
||||
emit(fmt.Sprintf("QRZ RESULT=%s COUNT=%s, ADIF %d bytes", fr.Result, fr.Count, len(adifText)))
|
||||
// Persist the last-download date NOW (right after a successful fetch),
|
||||
// not at the end: the QRZ logbook can be huge (tens of thousands of
|
||||
// records) and the user may close the panel mid-processing — storing it
|
||||
// late meant the date was never saved, so "since last download" kept
|
||||
// resolving to empty and re-pulled everything.
|
||||
if a.settings != nil {
|
||||
a.setSetting(a.profileScope()+keyExtQRZLastDownload, time.Now().UTC().Format("2006-01-02"))
|
||||
}
|
||||
// The last-download date is stored at the END, and ONLY if the whole ADIF
|
||||
// parsed. It used to be written here, right after the fetch: when the
|
||||
// response was truncated (see above) the window advanced over records that
|
||||
// were never processed, and every later run skipped them for good. A
|
||||
// window that moves past unseen data is worse than one that re-reads.
|
||||
if snip := strings.TrimSpace(adifText); snip != "" {
|
||||
if len(snip) > 300 {
|
||||
snip = snip[:300]
|
||||
@@ -9630,10 +9715,15 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
sort.Strings(keys)
|
||||
emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", ")))
|
||||
emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total))
|
||||
if perr != nil {
|
||||
emit("The download was INCOMPLETE — QRZ.com cut the ADIF short, so the records after that point were not read.")
|
||||
emit("The last-download date has NOT been moved, so nothing is lost: run it again (a narrower window returns less data).")
|
||||
} else if a.settings != nil {
|
||||
a.setSetting(a.profileScope()+keyExtQRZLastDownload, time.Now().UTC().Format("2006-01-02"))
|
||||
}
|
||||
if qrzSkippedOtherCall > 0 {
|
||||
emit(fmt.Sprintf("Skipped %d QSO(s) logged under another of your callsigns (scoped to %s).", qrzSkippedOtherCall, qrzOwner))
|
||||
}
|
||||
// (last-download date already stored right after the fetch above)
|
||||
|
||||
case extsvc.ServiceEQSL:
|
||||
sinceDate := resolveSince(keyExtEQSLLastDownload)
|
||||
@@ -10248,6 +10338,31 @@ func (a *App) ReloadUDPIntegrations() []string {
|
||||
return a.udp.Reload(a.ctx)
|
||||
}
|
||||
|
||||
// refineGrid picks between the locator a QSO arrived with and the one the
|
||||
// callsign lookup returned.
|
||||
//
|
||||
// WSJT-X and MSHV always send a FOUR-character grid — that is all the FT8
|
||||
// protocol carries — so a QSO logged from them landed with JN05 while QRZ knew
|
||||
// JN05JG. The enrichment rule everywhere else on this path is "fill only what is
|
||||
// empty", which meant the coarse grid always won and the precise one was thrown
|
||||
// away, silently costing the operator ~100 km of accuracy on every digital QSO.
|
||||
//
|
||||
// The upgrade is only taken when the lookup grid EXTENDS the received one (same
|
||||
// first four characters). A lookup that disagrees outright — JN18 against JN05 —
|
||||
// is not a refinement: the station may be portable, and what came over the air
|
||||
// is then the better record. Case-insensitive, since ADIF grids arrive in both.
|
||||
func refineGrid(have, found string) string {
|
||||
h := strings.ToUpper(strings.TrimSpace(have))
|
||||
f := strings.ToUpper(strings.TrimSpace(found))
|
||||
if h == "" {
|
||||
return f
|
||||
}
|
||||
if len(f) > len(h) && strings.HasPrefix(f, h) {
|
||||
return f
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// LogUDPLoggedADIF takes an ADIF blob received over UDP and inserts the
|
||||
// first record into the local logbook. Returns the ID of the inserted
|
||||
// row. Used by the auto-log handler (WSJT-X / JTDX / MSHV / JTAlert /
|
||||
@@ -10315,9 +10430,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
if q.Country == "" {
|
||||
q.Country = lr.Country
|
||||
}
|
||||
if q.Grid == "" {
|
||||
q.Grid = lr.Grid
|
||||
}
|
||||
q.Grid = refineGrid(q.Grid, lr.Grid)
|
||||
if q.Continent == "" {
|
||||
q.Continent = lr.Continent
|
||||
}
|
||||
@@ -11764,12 +11877,34 @@ func (a *App) SwitchCATRig(n int) error {
|
||||
if err := a.settings.Set(a.ctx, keyCATOmniRigNum, strconv.Itoa(n)); err != nil {
|
||||
return err
|
||||
}
|
||||
a.reloadCAT()
|
||||
a.restartAsync("cat", a.reloadCAT)
|
||||
return nil
|
||||
}
|
||||
|
||||
// reloadCAT (re)starts the CAT manager based on the current settings.
|
||||
// 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() {
|
||||
if a.cat == nil {
|
||||
return
|
||||
@@ -11794,7 +11929,7 @@ func (a *App) reloadCAT() {
|
||||
// Spawning OmniRig.exe ourselves (even with /Embedding) on every
|
||||
// reloadCAT raised the existing instance's window to the front,
|
||||
// which is what Log4OM avoids by relying entirely on COM activation.
|
||||
a.cat.Start(cat.NewOmniRig(s.OmniRigNum))
|
||||
a.cat.Start(cat.NewOmniRig(s.OmniRigNum, s.OmniRigVFO))
|
||||
case "flex":
|
||||
// Native FlexRadio (SmartSDR) TCP API — no OmniRig needed.
|
||||
fb := cat.NewFlex(s.FlexHost, s.FlexPort, s.FlexSpots)
|
||||
@@ -12105,7 +12240,10 @@ func (a *App) reloadAfterProfileSwitch() {
|
||||
if a.extsvc != nil {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -12835,7 +12973,7 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
a.startUltrabeam()
|
||||
a.restartAsync("antenna", a.startUltrabeam)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13408,7 +13546,7 @@ func (a *App) SavePGXLSettings(s PGXLSettings) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
a.startAmps()
|
||||
a.restartAsync("amp", a.startAmps)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13544,7 +13682,7 @@ func (a *App) SaveAmplifiers(list []AmpConfig) error {
|
||||
if err := a.settings.Set(a.ctx, keyAmpsList, string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
a.startAmps()
|
||||
a.restartAsync("amp", a.startAmps)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13989,6 +14127,18 @@ func (a *App) SaveWinkeyerSettings(s WinkeyerSettings) error {
|
||||
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.
|
||||
func (a *App) WinkeyerConnect() error {
|
||||
if a.winkeyer == nil {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// Every field the bulk-edit dialog OFFERS must be one the repository accepts.
|
||||
//
|
||||
// These two lists live in different packages — a TypeScript field table on one
|
||||
// side, a Go whitelist on the other — and they drifted apart the day the
|
||||
// confirmation dates were added: the new columns went into the QSL Manager's
|
||||
// filter whitelist instead of the bulk-edit one, so the dialog listed fields it
|
||||
// could not write and the operator got "field … is not bulk-editable" only after
|
||||
// selecting 54 QSOs and clicking Apply. Nothing in the build caught it.
|
||||
//
|
||||
// The test reads the ACTUAL field ids out of the .tsx rather than a copy, so a
|
||||
// field added to the UI alone fails here immediately.
|
||||
func TestBulkEditFieldsAreWritable(t *testing.T) {
|
||||
src, err := os.ReadFile("frontend/src/components/BulkEditModal.tsx")
|
||||
if err != nil {
|
||||
t.Skipf("frontend source not available: %v", err)
|
||||
}
|
||||
ids := regexp.MustCompile(`\{ id: '([a-z0-9_]+)'`).FindAllStringSubmatch(string(src), -1)
|
||||
if len(ids) < 10 {
|
||||
t.Fatalf("only %d field ids found — the parse is wrong, not the data", len(ids))
|
||||
}
|
||||
for _, m := range ids {
|
||||
id := m[1]
|
||||
// Handled before the column map: freq takes a numeric path (freq_hz +
|
||||
// band together) and the extras live in extras_json.
|
||||
if id == "freq" || qso.IsBulkEditableExtra(id) {
|
||||
continue
|
||||
}
|
||||
col, ok := bulkFieldColumns[id]
|
||||
if !ok {
|
||||
t.Errorf("bulk-edit UI offers %q, which maps to no column (bulkFieldColumns)", id)
|
||||
continue
|
||||
}
|
||||
// Extras live outside the qso table (owner_callsign and friends): they are
|
||||
// written into extras_json, not a column, so the column whitelist does not
|
||||
// apply to them.
|
||||
if strings.HasPrefix(col, "extras.") {
|
||||
continue
|
||||
}
|
||||
if !qso.IsBulkEditable(col) && !qso.IsBulkEditableExtra(col) {
|
||||
t.Errorf("bulk-edit UI offers %q → column %q, which the repository refuses", id, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Same contract for the filter builder: every field it offers must be one the
|
||||
// query layer will accept.
|
||||
func TestFilterFieldsAreQueryable(t *testing.T) {
|
||||
src, err := os.ReadFile("frontend/src/components/FilterBuilder.tsx")
|
||||
if err != nil {
|
||||
t.Skipf("frontend source not available: %v", err)
|
||||
}
|
||||
// Anchored on `type:` — the FIELDS entries carry one, the OPERATOR list does
|
||||
// not, and without it every operator (eq, contains, …) was read as a field.
|
||||
ids := regexp.MustCompile(`\{ value: '([a-z0-9_]+)', label: '[^']*', type:`).FindAllStringSubmatch(string(src), -1)
|
||||
if len(ids) < 10 {
|
||||
t.Fatalf("only %d field ids found — the parse is wrong, not the data", len(ids))
|
||||
}
|
||||
for _, m := range ids {
|
||||
col := m[1]
|
||||
if !qso.IsFilterable(col) && !qso.IsFilterableExtra(col) {
|
||||
t.Errorf("filter builder offers %q, which the query layer refuses", col)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,66 @@
|
||||
[
|
||||
{
|
||||
"version": "0.21.7",
|
||||
"date": "2026-07-28",
|
||||
"en": [
|
||||
"The filter builder uses the same confirmation field names as bulk edit, gains the missing QRZ.com received status, and can filter on the sent and received dates (before / after).",
|
||||
"A fresh install now starts on the dark theme. Existing installs keep the theme they are on.",
|
||||
"A QSO logged from WSJT-X or MSHV now keeps the precise 6-character locator from QRZ/HamQTH instead of the 4-character one the digital protocol carries — about 100 km of accuracy that was being discarded on every digital QSO. A lookup that disagrees with the received square is not applied.",
|
||||
"Edit QSO: the QRZ/HamQTH fetch button now bypasses the cache, so it really re-reads the callsign — upgrading a QRZ subscription used to change nothing until the cached entry expired a month later. It also no longer blanks an existing value when the lookup comes back with that field empty.",
|
||||
"The diagnostic log no longer carries the FlexRadio meter readings, which drowned everything else.",
|
||||
"OmniRig: a new \"VFO to read\" setting. OmniRig reports the active VFO from the rig file, and some files get it wrong — an IC-7610 file declared VFO B while the operator was on the main VFO, so OpsLog wrote to one VFO and read the other, and the frequency looked frozen. Force VFO A or B when that happens.",
|
||||
"Bulk edit: fixed \"field is not bulk-editable\" on the QRZ.com received status and the confirmation dates added in 0.21.6 — they were declared in the wrong place and refused at Apply. Bulk-editing State and County, offered since the start, was refused the same way and now works.",
|
||||
"QRZ.com confirmation download: the whole logbook was requested every time, which QRZ cuts short on a large log — the read stopped two thirds of the way through and the rest was never seen. Only the window is requested now. And the last-download date is no longer moved when the download was incomplete: it used to advance over records that had never been read, which skipped them for good.",
|
||||
"The callsign in the top bar is now the station switcher: click it and pick a profile to activate it. Managing profiles is still there, at the bottom of the list.",
|
||||
"The UTC clock moved from the top bar to the status bar at the bottom, beside the database.",
|
||||
"FlexRadio panel: the built-in ATU is now controllable — Tune, Bypass, memories, and the tuner state in words (tuning, tuned, TUNE FAILED…), because a failed tune and one that never ran look identical otherwise.",
|
||||
"Diagnostic log: each database migration now names the database it runs on, and a frequency set through OmniRig is checked again 1.5 s later — both to make a reported problem readable from the log instead of guessed at."
|
||||
],
|
||||
"fr": [
|
||||
"Le constructeur de filtres reprend les mêmes noms de champs de confirmation que l'édition en lot, gagne le statut de réception QRZ.com manquant, et permet de filtrer sur les dates d'envoi et de réception (avant / après).",
|
||||
"Une nouvelle installation démarre désormais sur le thème sombre. Les installations existantes conservent le leur.",
|
||||
"Un QSO enregistré depuis WSJT-X ou MSHV conserve désormais le locator précis à 6 caractères de QRZ/HamQTH au lieu de celui à 4 caractères transporté par le protocole numérique — une centaine de kilomètres de précision perdus jusqu'ici à chaque QSO numérique. Une réponse qui contredit le carré reçu n'est pas appliquée.",
|
||||
"Édition QSO : le bouton de récupération QRZ/HamQTH contourne désormais le cache et relit donc réellement l'indicatif — passer à un abonnement QRZ payant ne changeait rien tant que l'entrée en cache n'avait pas expiré un mois plus tard. Il n'efface plus non plus une valeur existante lorsque la recherche revient sans ce champ.",
|
||||
"Le journal de diagnostic ne contient plus les mesures des instruments FlexRadio, qui noyaient tout le reste.",
|
||||
"OmniRig : nouveau réglage « VFO à lire ». OmniRig indique le VFO actif d'après le fichier radio, et certains fichiers se trompent — un fichier IC-7610 déclarait le VFO B alors que l'opérateur était sur le VFO principal, si bien qu'OpsLog écrivait dans l'un et lisait l'autre, et la fréquence semblait figée. Forcez le VFO A ou B le cas échéant.",
|
||||
"Édition en lot : correction du refus « field is not bulk-editable » sur le statut de réception QRZ.com et les dates de confirmation ajoutées en 0.21.6 — ils étaient déclarés au mauvais endroit et rejetés au moment d'appliquer. L'édition en lot de State et County, proposée depuis toujours, était refusée de la même façon et fonctionne désormais.",
|
||||
"Téléchargement des confirmations QRZ.com : le carnet entier était demandé à chaque fois, ce que QRZ tronque sur un grand log — la lecture s'arrêtait aux deux tiers et le reste n'était jamais vu. Seule la fenêtre est désormais demandée. Et la date du dernier téléchargement n'avance plus lorsque le téléchargement est incomplet : elle passait par-dessus des enregistrements jamais lus, qui étaient alors ignorés définitivement.",
|
||||
"L'indicatif en haut de la fenêtre est désormais le sélecteur de station : un clic, on choisit un profil et il devient actif. La gestion des profils reste accessible, en bas de la liste.",
|
||||
"L'horloge UTC est passée de la barre du haut à la barre d'état en bas, à côté de la base de données.",
|
||||
"Panneau FlexRadio : le coupleur intégré est désormais pilotable — Accord, Bypass, mémoires, et l'état du coupleur en clair (accord en cours, accordé, ÉCHEC ACCORD…), car sans ça un accord raté et un accord jamais lancé se ressemblent.",
|
||||
"Journal de diagnostic : chaque migration de base indique désormais sur quelle base elle tourne, et une fréquence envoyée via OmniRig est relue 1,5 s plus tard — de quoi lire un problème signalé dans le journal au lieu de le deviner."
|
||||
]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
|
||||
+155
-40
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Gauge, Hash, Loader2, Lock,
|
||||
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Gauge, Hash, Loader2, Lock,
|
||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -45,18 +45,21 @@ import {
|
||||
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
||||
GetAwardDefs,
|
||||
GetUIPref, GetActiveProfile, QuitApp,
|
||||
GetUIPref, GetActiveProfile, ListProfiles, ActivateProfile, QuitApp,
|
||||
ReportLiveActivity, LiveLastQSOAgeSec,
|
||||
GetAmpStatuses, AmpOperate,
|
||||
GetFlexState, FlexAmpOperate,
|
||||
} from '../wailsjs/go/main/App';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
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 { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
|
||||
import { Menubar, type Menu } from '@/components/Menubar';
|
||||
import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { APP_VERSION, APP_AUTHOR } from '@/version';
|
||||
import { QSLManagerPanel } from '@/components/QSLManagerModal';
|
||||
import { QslDesignerModal } from '@/components/qsl/QslDesignerModal';
|
||||
@@ -331,6 +334,54 @@ function computePrefix(call: string): string {
|
||||
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() {
|
||||
const { t, lang } = useI18n();
|
||||
// === Lists from settings (fallback for first paint) ===
|
||||
@@ -2319,7 +2370,7 @@ export default function App() {
|
||||
if (n <= 0) return;
|
||||
await refresh();
|
||||
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
||||
showToast(`ADIF: ${n} QSO imported${file ? ` from ${file}` : ''}`);
|
||||
showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
|
||||
});
|
||||
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); unsubAdifMon?.(); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -2350,6 +2401,18 @@ export default function App() {
|
||||
// every profile switch; the grids take it as their React key so they remount
|
||||
// and re-read the now-correct per-profile layout.
|
||||
const [activeProfileId, setActiveProfileId] = useState<number | null>(null);
|
||||
// The station switcher in the header needs the whole list, not just the active
|
||||
// one. Reloaded on every profile change so a profile renamed or added in the
|
||||
// settings shows up without a restart.
|
||||
const [profileList, setProfileList] = useState<{ id: number; callsign: string; name: string }[]>([]);
|
||||
const loadProfileList = useCallback(() => {
|
||||
ListProfiles().then((ps: any) => {
|
||||
setProfileList((Array.isArray(ps) ? ps : []).map((p: any) => ({
|
||||
id: p.id, callsign: p.callsign ?? '', name: p.name ?? '',
|
||||
})));
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => { loadProfileList(); }, [loadProfileList]);
|
||||
useEffect(() => {
|
||||
GetActiveProfile().then((p: any) => {
|
||||
if (p && p.id != null) { setGridPrefsProfile(p.id); setActiveProfileId(p.id); }
|
||||
@@ -2372,7 +2435,7 @@ export default function App() {
|
||||
// Re-scope the grid column layout BEFORE the grids remount (key change).
|
||||
setGridPrefsProfile(id ?? null);
|
||||
setActiveProfileId(typeof id === 'number' ? id : null);
|
||||
loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes();
|
||||
loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes(); loadProfileList();
|
||||
// The chat is per shared logbook — clear the previous profile's messages
|
||||
// and reload for the new logbook (or hide if it isn't a MySQL log).
|
||||
setChatMsgs([]); chatSeen.current.clear(); setChatOnline([]); setChatUnread(0);
|
||||
@@ -2380,7 +2443,7 @@ export default function App() {
|
||||
setChatEpoch((e) => e + 1);
|
||||
});
|
||||
return () => { off(); };
|
||||
}, [loadStation, loadLists, loadCATCfg, reloadWk, loadMainPanes]);
|
||||
}, [loadStation, loadLists, loadCATCfg, reloadWk, loadMainPanes, loadProfileList]);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await reloadWk();
|
||||
@@ -3292,6 +3355,8 @@ export default function App() {
|
||||
{ type: 'item' as const, label: sendingLog ? t('help.sendLogBusy') : t('help.sendLog'), action: 'help.sendlog', disabled: sendingLog },
|
||||
] : []),
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: t('help.donate'), action: 'help.donate', accent: true },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
||||
]},
|
||||
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);
|
||||
@@ -3325,6 +3390,9 @@ export default function App() {
|
||||
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.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3456,7 +3524,7 @@ export default function App() {
|
||||
const callsignBlock = (
|
||||
<div className="flex flex-col w-44" data-esm="call">
|
||||
<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 && (
|
||||
<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" />…
|
||||
@@ -3510,8 +3578,14 @@ export default function App() {
|
||||
)}
|
||||
<Input
|
||||
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',
|
||||
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}
|
||||
onChange={(e) => onCallsignInput(e.target.value)}
|
||||
onBlur={() => {
|
||||
@@ -4235,7 +4309,7 @@ export default function App() {
|
||||
{compact ? (
|
||||
// Minimal compact topbar — brand + freq + toggle. Saves vertical space
|
||||
// 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="size-2 rounded-full bg-gradient-to-br from-primary to-orange-400" />
|
||||
<span className="font-bold text-xs tracking-tight">OpsLog</span>
|
||||
@@ -4256,7 +4330,15 @@ export default function App() {
|
||||
</Button>
|
||||
</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="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">
|
||||
@@ -4583,24 +4665,48 @@ export default function App() {
|
||||
) : <span />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
|
||||
<Clock className="size-3" />
|
||||
{utcNow}<span className="text-[10px]">UTC</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{station.callsign ? (
|
||||
// Switching station is the frequent act; editing a profile is the
|
||||
// rare one. The callsign used to open the settings, so changing
|
||||
// station took four clicks and a scroll — it now IS the switcher,
|
||||
// with the settings kept at the bottom of the list.
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
onClick={() => { setSettingsSection('profiles'); setShowSettings(true); }}
|
||||
className="flex items-center gap-1.5 bg-accent border border-accent-foreground/20 text-accent-foreground px-3 py-1 rounded-full font-mono text-xs font-bold hover:bg-accent/80 transition-colors"
|
||||
title="Click to switch / edit profiles"
|
||||
title={t('prof.switchTitle')}
|
||||
>
|
||||
<Antenna className="size-3" />
|
||||
{station.callsign}
|
||||
{station.my_grid && (
|
||||
<span className="bg-card/60 px-1.5 rounded text-[11px] text-primary">{station.my_grid}</span>
|
||||
)}
|
||||
<ChevronDown className="size-3 opacity-70" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-56">
|
||||
{profileList.map((p) => (
|
||||
<DropdownMenuItem
|
||||
key={p.id}
|
||||
disabled={p.id === activeProfileId}
|
||||
onSelect={() => { if (p.id !== activeProfileId) ActivateProfile(p.id).catch((e: any) => setError(String(e?.message ?? e))); }}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
{p.id === activeProfileId
|
||||
? <Check className="size-3.5 shrink-0 text-primary" />
|
||||
: <span className="size-3.5 shrink-0" />}
|
||||
<span className="font-mono font-semibold">{p.callsign || '—'}</span>
|
||||
{p.name && <span className="text-muted-foreground truncate">{p.name}</span>}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => { setSettingsSection('profiles'); setShowSettings(true); }}>
|
||||
{t('prof.manage')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => setShowSettings(true)}>
|
||||
<Settings className="size-3.5" /> Set station
|
||||
@@ -4624,6 +4730,8 @@ export default function App() {
|
||||
>
|
||||
<Minimize2 className="size-3.5" />
|
||||
</Button>
|
||||
{/* Window controls — the OS title bar is gone, so they live here. */}
|
||||
<WindowControls />
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
@@ -5299,27 +5407,27 @@ export default function App() {
|
||||
: 'bg-success-muted border-success-border text-success-muted-foreground',
|
||||
)}>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<strong>Import complete.</strong>
|
||||
<Badge variant="outline" className="bg-card/60 font-mono text-success border-success-border">{importResult.imported} imported</Badge>
|
||||
<strong>{t('imp.complete')}</strong>
|
||||
<Badge variant="outline" className="bg-card/60 font-mono text-success border-success-border">{t('imp.imported', { n: importResult.imported })}</Badge>
|
||||
{importResult.updated > 0 && (
|
||||
<Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{importResult.updated} updated</Badge>
|
||||
<Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{t('imp.updated', { n: importResult.updated })}</Badge>
|
||||
)}
|
||||
{importResult.duplicates > 0 && (
|
||||
<Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{importResult.duplicates} duplicates</Badge>
|
||||
<Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{t('imp.duplicates', { n: importResult.duplicates })}</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="bg-card/60 font-mono text-warning border-warning-border">{importResult.skipped} skipped</Badge>
|
||||
<Badge variant="outline" className="bg-card/60 font-mono">{importResult.total} total</Badge>
|
||||
<Badge variant="outline" className="bg-card/60 font-mono text-warning border-warning-border">{t('imp.skipped', { n: importResult.skipped })}</Badge>
|
||||
<Badge variant="outline" className="bg-card/60 font-mono">{t('imp.total', { n: importResult.total })}</Badge>
|
||||
{importResult.duplicates > 0 && importResult.duplicate_samples && importResult.duplicate_samples.length > 0 && (
|
||||
<button className="underline text-xs" onClick={() => setImportDupsOpen((v) => !v)}>
|
||||
{importDupsOpen ? 'Hide' : 'Show'} duplicates
|
||||
{importDupsOpen ? t('imp.hideDups') : t('imp.showDups')}
|
||||
{importResult.duplicates > importResult.duplicate_samples.length
|
||||
? ` (first ${importResult.duplicate_samples.length} of ${importResult.duplicates})`
|
||||
? t('imp.dupsFirst', { shown: importResult.duplicate_samples.length, total: importResult.duplicates })
|
||||
: ''}
|
||||
</button>
|
||||
)}
|
||||
{importResult.errors && importResult.errors.length > 0 && (
|
||||
<button className="underline text-xs" onClick={() => setImportErrorsOpen((v) => !v)}>
|
||||
{importErrorsOpen ? 'Hide' : 'Show'} {importResult.errors.length} error{importResult.errors.length > 1 ? 's' : ''}
|
||||
{importErrorsOpen ? t('imp.hideErrors', { n: importResult.errors.length }) : t('imp.showErrors', { n: importResult.errors.length })}
|
||||
</button>
|
||||
)}
|
||||
<button className="ml-auto" onClick={() => setImportResult(null)}><X className="size-4" /></button>
|
||||
@@ -5861,6 +5969,13 @@ export default function App() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* UTC clock — moved out of the header, where it competed with the
|
||||
frequency for the eye. It belongs with the other passive
|
||||
indicators. */}
|
||||
<span className="inline-flex items-center gap-1 font-mono text-[11px] text-muted-foreground shrink-0" title="UTC">
|
||||
<Clock className="size-3" />
|
||||
{utcNow}<span className="text-[9px]">Z</span>
|
||||
</span>
|
||||
{dbConn && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -6044,19 +6159,19 @@ export default function App() {
|
||||
<Dialog open onOpenChange={(o) => { if (!o) setPendingImportPath(null); }}>
|
||||
<DialogContent className="max-w-lg px-6">
|
||||
<DialogHeader className="px-2">
|
||||
<DialogTitle>Import ADIF</DialogTitle>
|
||||
<DialogTitle>{t('imp.dialogTitle')}</DialogTitle>
|
||||
<DialogDescription className="text-xs break-all">
|
||||
{pendingImportPath}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2 px-2 space-y-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Duplicate = same <span className="font-medium text-foreground">callsign + UTC minute + band + mode</span> as a QSO already in the log.
|
||||
{t('imp.dupHintPre')}<span className="font-medium text-foreground">{t('imp.dupHintKey')}</span>{t('imp.dupHintPost')}
|
||||
</div>
|
||||
{([
|
||||
{ id: 'skip', title: 'Skip duplicates', desc: 'Leave existing QSOs untouched, only add new ones. Safe default.' },
|
||||
{ id: 'update', title: 'Update duplicates', desc: 'Refresh existing QSOs with this file — merges its non-empty fields (QSL/LoTW/eQSL/QRZ statuses & dates, etc.) onto the matching QSO. Use this to re-sync from Log4OM or LoTW. Fields the file omits are kept.' },
|
||||
{ id: 'all', title: 'Import everything', desc: 'Insert every record, duplicates included. For intentionally merging two overlapping logs.' },
|
||||
{ id: 'skip', title: t('imp.skipTitle'), desc: t('imp.skipDesc') },
|
||||
{ id: 'update', title: t('imp.updateTitle'), desc: t('imp.updateDesc') },
|
||||
{ id: 'all', title: t('imp.allTitle'), desc: t('imp.allDesc') },
|
||||
] as const).map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
@@ -6084,9 +6199,9 @@ export default function App() {
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
Fix country & zones (cty.dat + ClubLog)
|
||||
{t('imp.ctyTitle')}
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||
Recompute Country, DXCC & CQ/ITU zones from cty.dat, overriding the file — corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). ClubLog's DXpedition overrides are applied on top per QSO date (e.g. TO974REF → Reunion, TO2A 2012 → French Guiana) whenever the ClubLog data is downloaded. Everything else in the ADIF is kept as-is. Tip: use <strong>Update duplicates</strong> to re-fix QSOs already in your log.
|
||||
{t('imp.ctyDesc')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
@@ -6097,16 +6212,16 @@ export default function App() {
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
Fill my station fields from my profile
|
||||
{t('imp.stationTitle')}
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||
Backfill <strong>empty</strong> MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power…) plus <strong>Operator</strong> and <strong>Owner callsign</strong> from your active profile. Existing values are kept. Only <strong>STATION_CALLSIGN</strong> is left untouched so a mixed-call log isn't re-routed. Enable when importing <em>your own</em> log.
|
||||
{t('imp.stationDesc')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter className="px-2 bg-transparent border-t-0">
|
||||
<Button variant="outline" onClick={() => setPendingImportPath(null)}>Cancel</Button>
|
||||
<Button onClick={runImport}>Import</Button>
|
||||
<Button variant="outline" onClick={() => setPendingImportPath(null)}>{t('imp.cancel')}</Button>
|
||||
<Button onClick={runImport}>{t('imp.import')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -6116,7 +6231,7 @@ export default function App() {
|
||||
<Dialog open>
|
||||
<DialogContent className="max-w-sm px-6" hideClose>
|
||||
<DialogHeader className="px-2">
|
||||
<DialogTitle>Importing ADIF…</DialogTitle>
|
||||
<DialogTitle>{t('imp.progressTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="px-2 pb-4 space-y-2">
|
||||
{(() => {
|
||||
@@ -6133,8 +6248,8 @@ export default function App() {
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground text-center font-mono">
|
||||
{tot > 0
|
||||
? `${done.toLocaleString()} / ${tot.toLocaleString()} records · ${pct}%`
|
||||
: `${done.toLocaleString()} records…`}
|
||||
? t('imp.progressCount', { done: done.toLocaleString(), tot: tot.toLocaleString(), pct })
|
||||
: t('imp.progressCountOnly', { done: done.toLocaleString() })}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -64,25 +64,47 @@ const BAND_RANGES: Record<string, [number, number]> = {
|
||||
'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][]> = {
|
||||
'160m': [[1800, 1838, 'fill-success-muted'], [1838, 1840, 'fill-info-muted'], [1840, 2000, 'fill-warning-muted']],
|
||||
'80m': [[3500, 3580, 'fill-success-muted'], [3580, 3600, 'fill-info-muted'], [3600, 3800, 'fill-warning-muted']],
|
||||
'60m': [[5350, 5450, 'fill-warning-muted']],
|
||||
'40m': [[7000, 7040, 'fill-success-muted'], [7040, 7100, 'fill-info-muted'], [7100, 7200, 'fill-warning-muted']],
|
||||
'30m': [[10100, 10130, 'fill-success-muted'], [10130, 10150, 'fill-info-muted']],
|
||||
'20m': [[14000, 14070, 'fill-success-muted'], [14070, 14100, 'fill-info-muted'], [14100, 14350, 'fill-warning-muted']],
|
||||
'17m': [[18068, 18095, 'fill-success-muted'], [18095, 18110, 'fill-info-muted'], [18110, 18168, 'fill-warning-muted']],
|
||||
'15m': [[21000, 21070, 'fill-success-muted'], [21070, 21150, 'fill-info-muted'], [21150, 21450, 'fill-warning-muted']],
|
||||
'12m': [[24890, 24915, 'fill-success-muted'], [24915, 24940, 'fill-info-muted'], [24940, 24990, 'fill-warning-muted']],
|
||||
'10m': [[28000, 28070, 'fill-success-muted'], [28070, 28300, 'fill-info-muted'], [28300, 29700, 'fill-warning-muted']],
|
||||
'6m': [[50000, 50100, 'fill-success-muted'], [50100, 50500, 'fill-warning-muted']],
|
||||
'160m': [[1800, 1838, SEG_CW], [1838, 1840, SEG_DIGI], [1840, 2000, SEG_PHONE]],
|
||||
'80m': [[3500, 3580, SEG_CW], [3580, 3600, SEG_DIGI], [3600, 3800, SEG_PHONE]],
|
||||
'60m': [[5350, 5450, SEG_PHONE]],
|
||||
'40m': [[7000, 7040, SEG_CW], [7040, 7100, SEG_DIGI], [7100, 7200, SEG_PHONE]],
|
||||
'30m': [[10100, 10130, SEG_CW], [10130, 10150, SEG_DIGI]],
|
||||
'20m': [[14000, 14070, SEG_CW], [14070, 14100, SEG_DIGI], [14100, 14350, SEG_PHONE]],
|
||||
'17m': [[18068, 18095, SEG_CW], [18095, 18110, SEG_DIGI], [18110, 18168, SEG_PHONE]],
|
||||
'15m': [[21000, 21070, SEG_CW], [21070, 21150, SEG_DIGI], [21150, 21450, SEG_PHONE]],
|
||||
'12m': [[24890, 24915, SEG_CW], [24915, 24940, SEG_DIGI], [24940, 24990, SEG_PHONE]],
|
||||
'10m': [[28000, 28070, SEG_CW], [28070, 28300, SEG_DIGI], [28300, 29700, SEG_PHONE]],
|
||||
'6m': [[50000, 50100, SEG_CW], [50100, 50500, SEG_PHONE]],
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<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}
|
||||
</span>
|
||||
);
|
||||
@@ -447,10 +469,13 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
height={totalH}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{segments.map(([s, e, cls], i) => {
|
||||
{segments.map(([s, e, colour], i) => {
|
||||
const y1 = freqToY(Math.min(e, hi));
|
||||
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} />
|
||||
</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-warning" label={t('bmp.legendNewBand')} />
|
||||
<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 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')}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
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 };
|
||||
|
||||
// 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).
|
||||
const FIELDS: FieldDef[] = [
|
||||
// QSL / upload status
|
||||
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
// One channel at a time, each with its status and its date, in the same order
|
||||
// as the QSL Info tab. "Upload" was the wrong word for half of them — a paper
|
||||
// QSL is not uploaded — so every entry now reads "<channel> sent/received
|
||||
// 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_date', label: 'bulk.fQslSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qrz_upload', label: 'bulk.fQrzUpload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'clublog_upload', label: 'bulk.fClublogUpload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'hrdlog_upload', label: 'bulk.fHrdlogUpload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qsl_rcvd_date', label: 'bulk.fQslRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ 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
|
||||
{ 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 },
|
||||
@@ -117,7 +132,7 @@ type Props = {
|
||||
// so they become eligible for upload.
|
||||
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [field, setField] = useState('hrdlog_upload');
|
||||
const [field, setField] = useState('qsl_sent');
|
||||
const [statusValue, setStatusValue] = useState('R');
|
||||
const [textValue, setTextValue] = useState('');
|
||||
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>)}
|
||||
</SelectContent>
|
||||
</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
|
||||
className="h-8 text-xs"
|
||||
|
||||
@@ -26,7 +26,9 @@ export interface QueryFilter {
|
||||
|
||||
// Curated field catalog. `value` MUST match a column in the backend whitelist
|
||||
// (qso.FilterableFields); `type` only drives which operators/value input we show.
|
||||
type FieldType = 'text' | 'number' | 'date';
|
||||
// 'date' is an ISO timestamp column (qso_date); 'adifdate' is an ADIF YYYYMMDD
|
||||
// string (the confirmation dates) — picked with a calendar, stored 8-digit.
|
||||
type FieldType = 'text' | 'number' | 'date' | 'adifdate';
|
||||
const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
||||
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
|
||||
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' },
|
||||
@@ -57,16 +59,30 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
||||
{ value: 'wwff_ref', label: 'fltb.fWwff', type: 'text' },
|
||||
{ value: 'rig', label: 'fltb.fRig', type: 'text' },
|
||||
{ value: 'ant', label: 'fltb.fAntenna', type: 'text' },
|
||||
// Same naming as the bulk editor: each entry says whether it is a STATUS or a
|
||||
// DATE and in which direction, grouped by channel. "Upload" was wrong for half
|
||||
// of them — a paper QSL is not uploaded.
|
||||
{ value: 'qsl_sent', label: 'fltb.fQslSent', type: 'text' },
|
||||
{ value: 'qsl_sent_date', label: 'fltb.fQslSentDate', type: 'adifdate' },
|
||||
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
|
||||
{ value: 'qsl_rcvd_date', label: 'fltb.fQslRcvdDate', type: 'adifdate' },
|
||||
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
|
||||
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
|
||||
{ value: 'lotw_sent_date', label: 'fltb.fLotwSentDate', type: 'adifdate' },
|
||||
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
|
||||
{ value: 'lotw_rcvd_date', label: 'fltb.fLotwRcvdDate', type: 'adifdate' },
|
||||
{ value: 'eqsl_sent', label: 'fltb.fEqslSent', type: 'text' },
|
||||
{ value: 'eqsl_sent_date', label: 'fltb.fEqslSentDate', type: 'adifdate' },
|
||||
{ value: 'eqsl_rcvd', label: 'fltb.fEqslRcvd', type: 'text' },
|
||||
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzUpload', type: 'text' },
|
||||
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogUpload', type: 'text' },
|
||||
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogUpload', type: 'text' },
|
||||
{ value: 'eqsl_rcvd_date', label: 'fltb.fEqslRcvdDate', type: 'adifdate' },
|
||||
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzSent', type: 'text' },
|
||||
{ value: 'qrzcom_qso_upload_date', label: 'fltb.fQrzSentDate', type: 'adifdate' },
|
||||
{ value: 'qrzcom_qso_download_status', label: 'fltb.fQrzRcvd', type: 'text' },
|
||||
{ value: 'qrzcom_qso_download_date', label: 'fltb.fQrzRcvdDate', type: 'adifdate' },
|
||||
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' },
|
||||
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' },
|
||||
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
|
||||
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
|
||||
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
||||
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
||||
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
||||
@@ -112,6 +128,8 @@ const NUM_OPS: FilterOp[] = ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'empty', 'notem
|
||||
|
||||
function opsFor(field: string): { value: FilterOp; label: string }[] {
|
||||
const t = FIELDS.find((f) => f.value === field)?.type ?? 'text';
|
||||
// 'adifdate' is stored as a string but sorts chronologically, so it takes the
|
||||
// comparison operators (before/after), not the text ones.
|
||||
const allow = t === 'text' ? TEXT_OPS : NUM_OPS;
|
||||
return OPS.filter((o) => allow.includes(o.value));
|
||||
}
|
||||
@@ -253,12 +271,19 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type={fieldType === 'date' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
|
||||
// An ADIF date is picked with a calendar but STORED as the
|
||||
// 8-digit form the column holds, so the comparison stays a
|
||||
// plain string one on both sides.
|
||||
type={fieldType === 'date' || fieldType === 'adifdate' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
|
||||
className="h-8 flex-1 text-xs"
|
||||
disabled={!needsValue}
|
||||
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'}
|
||||
value={c.value}
|
||||
onChange={(e) => setCond(i, { value: e.target.value })}
|
||||
value={fieldType === 'adifdate' && /^d{8}$/.test(c.value)
|
||||
? `${c.value.slice(0, 4)}-${c.value.slice(4, 6)}-${c.value.slice(6, 8)}`
|
||||
: c.value}
|
||||
onChange={(e) => setCond(i, {
|
||||
value: fieldType === 'adifdate' ? e.target.value.replace(/-/g, '') : e.target.value,
|
||||
})}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
|
||||
/>
|
||||
<button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title={t('fltb.remove')}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX, ChevronDown, SlidersHorizontal } from 'lucide-react';
|
||||
import {
|
||||
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
||||
GetFlexState, FlexATUStart, FlexATUBypass, FlexSetATUMemories, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||
FlexMox, FlexAmpOperate,
|
||||
GetPGXLStatus, PGXLSetFanMode,
|
||||
@@ -125,8 +125,8 @@ function Segmented({ value, options, onChange, disabled }: {
|
||||
}
|
||||
|
||||
// Chip — a compact on/off pill (NB/NR/ANF/VOX/MON…).
|
||||
function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
|
||||
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber';
|
||||
function Chip({ on, onClick, label, disabled, accent = 'emerald', title }: {
|
||||
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; title?: string;
|
||||
}) {
|
||||
const onCls = {
|
||||
emerald: 'bg-success border-success text-success-foreground',
|
||||
@@ -135,7 +135,7 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
|
||||
amber: 'bg-warning border-warning text-warning-foreground',
|
||||
}[accent];
|
||||
return (
|
||||
<button type="button" onClick={onClick} disabled={disabled}
|
||||
<button type="button" onClick={onClick} disabled={disabled} title={title}
|
||||
className={cn(
|
||||
// min width (not fixed) so a longer label like STONE keeps symmetric
|
||||
// padding instead of overflowing; short labels (NB/NR/APF) stay aligned.
|
||||
@@ -146,6 +146,37 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
|
||||
);
|
||||
}
|
||||
|
||||
// SmartSDR reports the tuner as an enum. It is worth decoding rather than
|
||||
// showing raw: TUNE_FAIL and "never tuned" both leave the radio looking normal,
|
||||
// and the operator transmits into a bad match either way.
|
||||
function atuLabel(status: string | undefined, t: (k: string) => string): string {
|
||||
switch ((status || '').toUpperCase()) {
|
||||
case 'TUNE_IN_PROGRESS': return t('flxp.atuTuning');
|
||||
case 'TUNE_SUCCESSFUL':
|
||||
case 'TUNE_OK': return t('flxp.atuOk');
|
||||
case 'TUNE_FAIL':
|
||||
case 'TUNE_FAIL_BYPASS': return t('flxp.atuFail');
|
||||
case 'TUNE_BYPASS':
|
||||
case 'TUNE_MANUAL_BYPASS': return t('flxp.atuBypassed');
|
||||
case 'TUNE_ABORTED': return t('flxp.atuAborted');
|
||||
case 'TUNE_NOT_STARTED':
|
||||
case 'NONE':
|
||||
case '': return t('flxp.atuIdle');
|
||||
default: return status || '';
|
||||
}
|
||||
}
|
||||
|
||||
function atuTone(status: string | undefined): string {
|
||||
switch ((status || '').toUpperCase()) {
|
||||
case 'TUNE_SUCCESSFUL':
|
||||
case 'TUNE_OK': return 'text-success';
|
||||
case 'TUNE_FAIL':
|
||||
case 'TUNE_FAIL_BYPASS': return 'text-danger';
|
||||
case 'TUNE_IN_PROGRESS': return 'text-warning';
|
||||
default: return 'text-muted-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, sliderAccent }: {
|
||||
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
|
||||
disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; sliderAccent?: string;
|
||||
@@ -624,6 +655,33 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ATU — the radio's built-in tuner. Sits under TUNE because that is
|
||||
the order of operations: the tuner needs a carrier to measure
|
||||
against, so TUNE first, then start a tuning cycle. */}
|
||||
<div className="flex items-center gap-2 border-t border-border/60 pt-3">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">ATU</span>
|
||||
<button type="button" disabled={off}
|
||||
title={t('flxp.atuTuneHint')}
|
||||
onClick={() => FlexATUStart().catch(() => {})}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 border-warning text-warning bg-card hover:bg-warning-muted transition-all disabled:opacity-30">
|
||||
{t('flxp.atuTune')}
|
||||
</button>
|
||||
<button type="button" disabled={off}
|
||||
title={t('flxp.atuBypassHint')}
|
||||
onClick={() => FlexATUBypass().catch(() => {})}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 border-border text-muted-foreground bg-card hover:bg-muted transition-all disabled:opacity-30">
|
||||
{t('flxp.atuBypass')}
|
||||
</button>
|
||||
<Chip on={st.atu_memories} disabled={off} label={t('flxp.atuMem')} accent="cyan"
|
||||
title={t('flxp.atuMemHint')}
|
||||
onClick={() => change('atu_memories', !st.atu_memories, () => FlexSetATUMemories(!st.atu_memories))} />
|
||||
{/* The status is the whole point: a tune that FAILED and one that
|
||||
never ran look identical on the radio's own front panel. */}
|
||||
<span className={cn('ml-auto text-[11px] font-mono font-semibold whitespace-nowrap', atuTone(st.atu_status))}>
|
||||
{atuLabel(st.atu_status, t)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
{!isCW ? (
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
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' };
|
||||
|
||||
export interface Menu {
|
||||
@@ -77,6 +80,7 @@ export function Menubar({ menus, onAction }: Props) {
|
||||
<DropdownMenuItem
|
||||
key={i}
|
||||
disabled={item.disabled}
|
||||
className={item.accent ? 'text-warning focus:text-warning font-medium' : undefined}
|
||||
onSelect={() => onAction(item.action)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Trash2, Search, Loader2 } from 'lucide-react';
|
||||
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
|
||||
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
|
||||
import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
|
||||
import { rstOptions, type RSTLists } from '@/lib/rst';
|
||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
||||
@@ -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 }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
@@ -280,19 +330,28 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
setLooking(true);
|
||||
setLocalErr('');
|
||||
try {
|
||||
const r: any = await LookupCallsign(call);
|
||||
// FRESH: this fetch is a deliberate click, so it bypasses the cache and
|
||||
// refreshes it. A cached answer from a thinner QRZ subscription (or any
|
||||
// stale row) otherwise stayed for its whole 30-day life and the button
|
||||
// appeared to do nothing.
|
||||
const r: any = await LookupCallsignFresh(call);
|
||||
// The lookup WINS over what is in the record — that is the point of asking
|
||||
// for it. But an EMPTY result must never blank a good value: `??` only
|
||||
// guards against null, and Go marshals an unset string as "", so a QRZ
|
||||
// record with no grid used to wipe the grid that was already there.
|
||||
const keep = (found: any, cur: any) => (found === undefined || found === null || found === '' ? cur : found);
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
name: r.name ?? d.name,
|
||||
qth: r.qth ?? d.qth,
|
||||
address: r.address ?? (d as any).address,
|
||||
email: r.email ?? (d as any).email,
|
||||
country: r.country ?? d.country,
|
||||
grid: r.grid ?? d.grid,
|
||||
state: r.state ?? d.state,
|
||||
cnty: r.cnty ?? d.cnty,
|
||||
cont: r.cont ?? d.cont,
|
||||
qsl_via: r.qsl_via ?? d.qsl_via,
|
||||
name: keep(r.name, d.name),
|
||||
qth: keep(r.qth, d.qth),
|
||||
address: keep(r.address, (d as any).address),
|
||||
email: keep(r.email, (d as any).email),
|
||||
country: keep(r.country, d.country),
|
||||
grid: keep(r.grid, d.grid),
|
||||
state: keep(r.state, d.state),
|
||||
cnty: keep(r.cnty, d.cnty),
|
||||
cont: keep(r.cont, d.cont),
|
||||
qsl_via: keep(r.qsl_via, d.qsl_via),
|
||||
dxcc: r.dxcc || d.dxcc,
|
||||
cqz: r.cqz || d.cqz,
|
||||
ituz: r.ituz || d.ituz,
|
||||
@@ -580,8 +639,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
|
||||
: <Input disabled value="—" />}
|
||||
</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.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.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></div>
|
||||
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={val(def.rcvdDate)} onChange={(v) => put(def.rcvdDate, v)} disabled={!def.rcvdDate} /></div>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||
GetQSLDefaults, SaveQSLDefaults,
|
||||
SetWinkeyerTrace,
|
||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
|
||||
GetPOTAToken, SavePOTAToken,
|
||||
TestLoTWUpload, ListTQSLStationLocations,
|
||||
@@ -1051,7 +1052,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const [bandDraft, setBandDraft] = useState('');
|
||||
const [modeDraft, setModeDraft] = useState('');
|
||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
|
||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
|
||||
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
||||
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
||||
digital_default: 'FT8',
|
||||
@@ -1093,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: [],
|
||||
});
|
||||
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 }));
|
||||
|
||||
// ── Audio (DVK + QSO recorder) ──
|
||||
@@ -2331,6 +2334,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{catCfg.backend === 'omnirig' && (
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.omnirigVfo')}</Label>
|
||||
<Select value={catCfg.omnirig_vfo || 'auto'}
|
||||
onValueChange={(v) => setCatCfg((s) => ({ ...s, omnirig_vfo: v === 'auto' ? '' : v }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t('cat.omnirigVfoAuto')}</SelectItem>
|
||||
<SelectItem value="A">{t('cat.omnirigVfoA')}</SelectItem>
|
||||
<SelectItem value="B">{t('cat.omnirigVfoB')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">{t('cat.omnirigVfoHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{catCfg.backend === 'flex' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
@@ -3363,6 +3381,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> {t('wk.serialEcho')}
|
||||
</label>
|
||||
</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_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: 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.
|
||||
window_start: string; window_end: string; window_hours: 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
|
||||
// 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).
|
||||
// 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 = [
|
||||
{ key: 'timeline', tkey: 'stats.granTimeline' },
|
||||
{ key: 'day', tkey: 'stats.granDay' },
|
||||
{ key: 'week', tkey: 'stats.granWeek' },
|
||||
{ key: 'month', tkey: 'stats.granMonth' },
|
||||
{ key: 'year', tkey: 'stats.granYear' },
|
||||
] 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 }) {
|
||||
const [g, setG] = useState<string>('timeline');
|
||||
const series = g === 'day' ? stats.by_hour : g === 'week' ? stats.by_day7 : g === 'month' ? stats.by_day30 : g === 'year' ? stats.by_month12 : [];
|
||||
const [g, setG] = useState<Gran | 'auto'>('auto');
|
||||
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 (
|
||||
<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) => (
|
||||
<button key={o.key} type="button" onClick={() => setG(o.key)}
|
||||
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)}
|
||||
</button>
|
||||
))}
|
||||
{steppedUp && (
|
||||
<span className="text-[10px] text-muted-foreground">{t('stats.granTooFine')}</span>
|
||||
)}
|
||||
</div>
|
||||
{g === 'timeline'
|
||||
? <AreaTrend data={stats.by_month} empty={empty} />
|
||||
{/* Many buckets read better as a filled trend than as a forest of bars. */}
|
||||
{series.length > 60
|
||||
? <AreaTrend data={series} empty={empty} />
|
||||
: <VBars data={series} empty={empty} showValues height={160} />}
|
||||
</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) ─────────────────────────────────────────
|
||||
|
||||
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),
|
||||
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_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_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
|
||||
} as Stats);
|
||||
@@ -818,6 +874,7 @@ export function StatsPanel() {
|
||||
</Card>
|
||||
|
||||
<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
|
||||
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
||||
|
||||
+52
-16
File diff suppressed because one or more lines are too long
@@ -14,7 +14,11 @@ export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
||||
];
|
||||
|
||||
export const LS_KEY = 'opslog.theme';
|
||||
const DEFAULT: ThemeChoice = 'light-warm';
|
||||
// A fresh install starts DARK. A shack is usually a dim room and the screen is
|
||||
// looked at for hours; every other logger defaults the same way. Graphite
|
||||
// specifically, because that is what 'auto' already resolves to for a dark
|
||||
// system — so the two paths agree instead of landing on different darks.
|
||||
const DEFAULT: ThemeChoice = 'dark-graphite';
|
||||
const ALL: ThemeChoice[] = ['auto', ...CONCRETE_THEMES];
|
||||
|
||||
function systemDark(): boolean {
|
||||
|
||||
@@ -652,3 +652,37 @@
|
||||
border: 2px solid var(--background);
|
||||
}
|
||||
::-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).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.21.5';
|
||||
export const APP_VERSION = '0.21.7';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+4
@@ -637,6 +637,8 @@ export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||
|
||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||
|
||||
export function LookupCallsignFresh(arg1:string):Promise<lookup.Result>;
|
||||
|
||||
export function MotorReadElements():Promise<Array<number>>;
|
||||
|
||||
export function MotorSetElement(arg1:number,arg2:number):Promise<void>;
|
||||
@@ -907,6 +909,8 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||
|
||||
export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
|
||||
|
||||
export function StartCWDecoder():Promise<void>;
|
||||
|
||||
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||
|
||||
@@ -1222,6 +1222,10 @@ export function LookupCallsign(arg1) {
|
||||
return window['go']['main']['App']['LookupCallsign'](arg1);
|
||||
}
|
||||
|
||||
export function LookupCallsignFresh(arg1) {
|
||||
return window['go']['main']['App']['LookupCallsignFresh'](arg1);
|
||||
}
|
||||
|
||||
export function MotorReadElements() {
|
||||
return window['go']['main']['App']['MotorReadElements']();
|
||||
}
|
||||
@@ -1762,6 +1766,10 @@ export function SetUltrabeamDirection(arg1) {
|
||||
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
||||
}
|
||||
|
||||
export function SetWinkeyerTrace(arg1) {
|
||||
return window['go']['main']['App']['SetWinkeyerTrace'](arg1);
|
||||
}
|
||||
|
||||
export function StartCWDecoder() {
|
||||
return window['go']['main']['App']['StartCWDecoder']();
|
||||
}
|
||||
|
||||
@@ -1829,6 +1829,7 @@ export namespace main {
|
||||
enabled: boolean;
|
||||
backend: string;
|
||||
omnirig_rig: number;
|
||||
omnirig_vfo: string;
|
||||
flex_host: string;
|
||||
flex_port: number;
|
||||
flex_spots: boolean;
|
||||
@@ -1857,6 +1858,7 @@ export namespace main {
|
||||
this.enabled = source["enabled"];
|
||||
this.backend = source["backend"];
|
||||
this.omnirig_rig = source["omnirig_rig"];
|
||||
this.omnirig_vfo = source["omnirig_vfo"];
|
||||
this.flex_host = source["flex_host"];
|
||||
this.flex_port = source["flex_port"];
|
||||
this.flex_spots = source["flex_spots"];
|
||||
@@ -4204,6 +4206,12 @@ export namespace qso {
|
||||
by_day7: Bucket[];
|
||||
by_day30: 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_end: string;
|
||||
window_hours: number;
|
||||
@@ -4248,6 +4256,12 @@ export namespace qso {
|
||||
this.by_day7 = this.convertValues(source["by_day7"], Bucket);
|
||||
this.by_day30 = this.convertValues(source["by_day30"], 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_end = source["window_end"];
|
||||
this.window_hours = source["window_hours"];
|
||||
|
||||
+7
-11
@@ -50,7 +50,6 @@ type Flex struct {
|
||||
meterMeta map[int]meterInfo
|
||||
meterVal map[int]float64
|
||||
meterSub map[int]bool // ids we've already sent "sub meter <id>" for
|
||||
meterLogAt time.Time // throttle for value logging
|
||||
vitaSeen int // count of UDP datagrams (first few logged for diag)
|
||||
meterRawLogged bool // log the first raw meter-definition status once
|
||||
txRawLogged bool // log the first raw transmit status once (field-name audit)
|
||||
@@ -768,11 +767,17 @@ func (f *Flex) handleStatus(payload string) {
|
||||
f.meterMeta[num] = old
|
||||
}
|
||||
f.mu.Unlock()
|
||||
// One line for the whole batch, not one per meter: a Flex announces
|
||||
// dozens at connect and that was a wall of text every time.
|
||||
var names []string
|
||||
for _, id := range newIDs {
|
||||
mi := f.meterMeta[id]
|
||||
debugLog.Printf("Flex: meter def #%d %s/%s unit=%s lo=%g hi=%g → sub", id, mi.src, mi.name, mi.unit, mi.lo, mi.hi)
|
||||
names = append(names, nonEmpty(mi.name, strconv.Itoa(id)))
|
||||
f.subscribeMeter(id)
|
||||
}
|
||||
if len(names) > 0 {
|
||||
debugLog.Printf("Flex: subscribed to %d meter(s): %s", len(names), strings.Join(names, " "))
|
||||
}
|
||||
}
|
||||
// Spot status: "spot <index> …". Track the index so we can clear the
|
||||
// panadapter, and log it verbatim — a click on a panadapter spot pushes a
|
||||
@@ -2154,15 +2159,6 @@ func (f *Flex) parseVita(p []byte, seen int) {
|
||||
raw := int16(binary.BigEndian.Uint16(payload[i+2 : i+4]))
|
||||
f.meterVal[id] = scaleMeter(raw, f.meterMeta[id].unit)
|
||||
}
|
||||
if time.Since(f.meterLogAt) > 5*time.Second { // throttled dump to validate names
|
||||
f.meterLogAt = time.Now()
|
||||
var b strings.Builder
|
||||
for id, v := range f.meterVal {
|
||||
mi := f.meterMeta[id]
|
||||
fmt.Fprintf(&b, "%s=%.1f%s ", nonEmpty(mi.name, strconv.Itoa(id)), v, mi.unit)
|
||||
}
|
||||
debugLog.Printf("Flex: meters %s", strings.TrimSpace(b.String()))
|
||||
}
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
|
||||
+50
-8
@@ -29,6 +29,14 @@ const (
|
||||
type OmniRig struct {
|
||||
RigNum int // 1 (Rig1) or 2 (Rig2)
|
||||
|
||||
// ForceVFO overrides which VFO to read: "" trusts the rig file, "A"/"B" do
|
||||
// not. OmniRig's VFO report is only as good as the .ini behind it, and a
|
||||
// wrong one is invisible: an IC-7610 file was seen declaring VFO B while the
|
||||
// operator worked on the main VFO, so OpsLog wrote to A (SetSimplexMode acts
|
||||
// on the main VFO) and read B — the frequency "never followed the knob",
|
||||
// while it was following the other one all along.
|
||||
ForceVFO string
|
||||
|
||||
omnirig *ole.IDispatch
|
||||
rig *ole.IDispatch
|
||||
lastSig string // last logged Split/VFO signature — only log on change
|
||||
@@ -59,14 +67,24 @@ type OmniRig struct {
|
||||
splitFlaky bool
|
||||
splitFlips int
|
||||
splitFlipWindow time.Time
|
||||
|
||||
// pendingReadback schedules one delayed frequency log after a Set — see
|
||||
// SetFrequency. Zero when nothing is pending.
|
||||
pendingReadback time.Time
|
||||
}
|
||||
|
||||
// NewOmniRig creates a non-connected backend. Call Connect before use.
|
||||
func NewOmniRig(rigNum int) *OmniRig {
|
||||
// NewOmniRig builds the backend. forceVFO is "" to follow whatever the rig file
|
||||
// reports, or "A"/"B" to override it — see the ForceVFO field.
|
||||
func NewOmniRig(rigNum int, forceVFO string) *OmniRig {
|
||||
if rigNum < 1 || rigNum > 2 {
|
||||
rigNum = 1
|
||||
}
|
||||
return &OmniRig{RigNum: rigNum}
|
||||
v := strings.ToUpper(strings.TrimSpace(forceVFO))
|
||||
if v != "A" && v != "B" {
|
||||
v = ""
|
||||
}
|
||||
return &OmniRig{RigNum: rigNum, ForceVFO: v}
|
||||
}
|
||||
|
||||
func (o *OmniRig) Name() string { return "omnirig" }
|
||||
@@ -306,7 +324,21 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
||||
}
|
||||
splitRecentOn := o.splitFlaky && !o.lastSplitOnAt.IsZero() && now.Sub(o.lastSplitOnAt) < 6*time.Second
|
||||
|
||||
s.FreqHz, s.RxFreqHz, s.Split = resolveOmniRigVFOs(o.rigType, freqMain, freqA, freqB, s.Vfo, splitRaw, splitRecentOn)
|
||||
// A forced VFO replaces whatever the rig file reported, BEFORE anything is
|
||||
// derived from it. Nothing downstream then has to know about the override.
|
||||
vfo := s.Vfo
|
||||
if o.ForceVFO != "" {
|
||||
vfo = o.ForceVFO
|
||||
}
|
||||
s.FreqHz, s.RxFreqHz, s.Split = resolveOmniRigVFOs(o.rigType, freqMain, freqA, freqB, vfo, splitRaw, splitRecentOn)
|
||||
|
||||
// Delayed readback after a Set (see SetFrequency): logged from HERE because
|
||||
// this runs on the COM-owning goroutine. One line, once per set.
|
||||
if !o.pendingReadback.IsZero() && time.Now().After(o.pendingReadback) {
|
||||
o.pendingReadback = time.Time{}
|
||||
debugLog.Printf("OmniRig.SetFrequency: readback +1.5s FreqA=%d FreqB=%d Freq=%d → shown %d",
|
||||
freqA, freqB, freqMain, s.FreqHz)
|
||||
}
|
||||
|
||||
// Diagnostic logged ONLY when Split or VFO changes (not on a timer), so normal
|
||||
// operation stays quiet but toggling split or SUB VFO on the radio is
|
||||
@@ -497,10 +529,17 @@ func (o *OmniRig) SetFrequency(hz int64) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Read back all three immediately. OmniRig is async (the CAT command is
|
||||
// queued + sent over serial), so these may still show the OLD value for
|
||||
// one poll cycle — but if they NEVER change in the next poll, the rig
|
||||
// isn't honouring the write (wrong .ini WRITE command for this model).
|
||||
// Read back all three, then AGAIN a moment later. OmniRig is async — the CAT
|
||||
// command is queued and sent over serial — so an immediate read always shows
|
||||
// the old value and proves nothing. The delayed one is the useful half: it
|
||||
// separates "the rig ignored the write" from "the rig moved but its .ini
|
||||
// never re-reads the frequency", which look identical to an operator whose
|
||||
// display stays on the old band until they nudge the VFO.
|
||||
//
|
||||
// Off the CAT goroutine so the poll loop is not held for a second. COM is
|
||||
// thread-affine, so the delayed read goes through the manager's own command
|
||||
// channel rather than touching o.rig from here.
|
||||
logFreqs := func(when string) {
|
||||
fa, fb, fg := int64(-1), int64(-1), int64(-1)
|
||||
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
|
||||
fa = v.Val
|
||||
@@ -511,7 +550,10 @@ func (o *OmniRig) SetFrequency(hz int64) error {
|
||||
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
||||
fg = v.Val
|
||||
}
|
||||
debugLog.Printf("OmniRig.SetFrequency: readback FreqA=%d FreqB=%d Freq=%d (target %d)", fa, fb, fg, hz)
|
||||
debugLog.Printf("OmniRig.SetFrequency: readback %s FreqA=%d FreqB=%d Freq=%d (target %d)", when, fa, fb, fg, hz)
|
||||
}
|
||||
logFreqs("now")
|
||||
o.pendingReadback = time.Now().Add(1500 * time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,14 @@ func TestResolveOmniRigVFOs(t *testing.T) {
|
||||
{"pair enum AB, simplex → listening on A", "", a14200, a14200, b14205, "AB", pmSplitOff, false, a14200, 0, false},
|
||||
{"pair enum AA, simplex → listening on A", "", a14200, a14200, b14205, "AA", pmSplitOff, false, a14200, 0, false},
|
||||
|
||||
// F4?? IC-7610 report, 2026-07-27: the operator's custom rig file declares
|
||||
// VFO B permanently while they work on the MAIN VFO. OpsLog then wrote to A
|
||||
// (SetSimplexMode acts on main) and read B — "the frequency never follows
|
||||
// the knob". Honouring the enum is right in general, which is why the cure
|
||||
// is an explicit override in the settings, applied before this function.
|
||||
{"IC-7610 file wrongly says B — enum honoured, hence the override", "IC-7610", 0, 7150000, 14295000, "B", pmSplitOff, false, 14295000, 0, false},
|
||||
{"same rig with the override forcing A", "IC-7610", 0, 7150000, 14295000, "A", pmSplitOff, false, 7150000, 0, false},
|
||||
|
||||
// Non-regression: a rig that does not report the VFO enum keeps the old
|
||||
// order — freqA, then the generic Freq, then freqB.
|
||||
{"no VFO enum, freqA populated (Yaesu/Kenwood)", "FT-891", a14200, a14200, 0, "", pmSplitOff, false, a14200, 0, false},
|
||||
|
||||
+21
-6
@@ -6,6 +6,7 @@ import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -161,7 +162,7 @@ func Open(path string) (*sql.DB, error) {
|
||||
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||
}
|
||||
Dialect = "sqlite"
|
||||
if err := migrate(conn, nil, path); err != nil {
|
||||
if err := migrate(conn, nil, path, filepath.Base(path)); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
@@ -175,8 +176,8 @@ var LogSink func(format string, args ...any)
|
||||
|
||||
// logMigration records a migration that has just been applied, and how long it
|
||||
// took — the only trace an operator has that a data-rewriting migration ran.
|
||||
func logMigration(name string, start time.Time) {
|
||||
logf("db: migration %s applied in %s", name, time.Since(start).Round(time.Millisecond))
|
||||
func logMigration(label, name string, start time.Time) {
|
||||
logf("db[%s]: migration %s applied in %s", label, name, time.Since(start).Round(time.Millisecond))
|
||||
}
|
||||
|
||||
func logf(format string, args ...any) {
|
||||
@@ -231,7 +232,11 @@ func backupBeforeRewrite(conn *sql.DB, dbPath, migration string) {
|
||||
// skipping those already applied. Intentionally minimal in-house system
|
||||
// (no external dependency). translate, when non-nil, rewrites each statement
|
||||
// for a non-SQLite backend (see mysqlDDL); nil means run the SQLite DDL as-is.
|
||||
func migrate(conn *sql.DB, translate func(string) string, dbPath string) error {
|
||||
// label names the database being migrated, for the log. Without it three
|
||||
// interleaved migration runs in one log were indistinguishable — an operator
|
||||
// reported "migrations are very slow" and the lines gave no way to tell one
|
||||
// database migrated three times from three databases migrated once.
|
||||
func migrate(conn *sql.DB, translate func(string) string, dbPath, label string) error {
|
||||
// A non-nil translator means this is the MySQL connection (use the
|
||||
// per-statement, FK-aware path); nil means a SQLite connection. This is
|
||||
// determined by the caller's argument, NOT the global Dialect, so the
|
||||
@@ -274,6 +279,16 @@ func migrate(conn *sql.DB, translate func(string) string, dbPath string) error {
|
||||
return fmt.Errorf("read applied migrations: %w", err)
|
||||
}
|
||||
|
||||
pending := 0
|
||||
for _, name := range names {
|
||||
if !applied[name] {
|
||||
pending++
|
||||
}
|
||||
}
|
||||
if pending > 0 {
|
||||
logf("db[%s]: %d migration(s) to apply", label, pending)
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
if applied[name] {
|
||||
continue // already applied
|
||||
@@ -307,7 +322,7 @@ func migrate(conn *sql.DB, translate func(string) string, dbPath string) error {
|
||||
if _, err := conn.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil {
|
||||
return fmt.Errorf("record migration %s: %w", name, err)
|
||||
}
|
||||
logMigration(name, start)
|
||||
logMigration(label, name, start)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -327,7 +342,7 @@ func migrate(conn *sql.DB, translate func(string) string, dbPath string) error {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", name, err)
|
||||
}
|
||||
logMigration(name, start)
|
||||
logMigration(label, name, start)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@ func translateTextColumns(s string) string {
|
||||
// app-lifetime ctx with no per-query timeout) hold a connection for the whole
|
||||
// chain of remote-MySQL latency, so a handful of concurrent UI bursts drained
|
||||
// the pool and "after a while nothing updated" — grid, chat, live_status froze.
|
||||
//
|
||||
// 40 fits the actual deployment (max 5 ops on a server with max_connections=300 →
|
||||
// 40*5 = 200, leaving ~100 for phpMyAdmin/server overhead). This is the per-INSTANCE
|
||||
// pool, so keep max_connections >= pool*ops + headroom.
|
||||
@@ -200,7 +201,7 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
|
||||
err = applyMySQLBaseline(conn)
|
||||
} else {
|
||||
// Existing database: apply only the migrations it's missing.
|
||||
err = migrate(conn, mysqlDDL, "")
|
||||
err = migrate(conn, mysqlDDL, "", "mysql:"+name)
|
||||
}
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
@@ -287,7 +288,11 @@ func applyMySQLBaseline(conn *sql.DB) error {
|
||||
return fmt.Errorf("open baseline sqlite: %w", err)
|
||||
}
|
||||
defer mem.Close()
|
||||
if err := migrate(mem, nil, ""); err != nil {
|
||||
// In-memory SQLite, used only to derive the final schema for a FRESH MySQL
|
||||
// database. Labelled so its (fast) migration lines are not mistaken for a
|
||||
// real database being migrated — in one operator's log this pass sat between
|
||||
// two slow MySQL runs and looked like a third database.
|
||||
if err := migrate(mem, nil, "", "baseline:memory"); err != nil {
|
||||
return fmt.Errorf("build baseline schema: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,22 @@ func (m *Manager) SetProviders(p ...Provider) {
|
||||
|
||||
// Lookup returns a Result for the callsign. Falls back through providers
|
||||
// when one returns ErrNotFound or fails.
|
||||
// forceKey marks a context as a FORCED (operator-requested) lookup, which
|
||||
// bypasses the cache on the way in and refreshes it on the way out. Carried on
|
||||
// the context rather than as a parameter so every existing caller — and the
|
||||
// Provider interface — stays untouched.
|
||||
type forceKey struct{}
|
||||
|
||||
// WithForce returns a context that makes Lookup skip the cache.
|
||||
func WithForce(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, forceKey{}, true)
|
||||
}
|
||||
|
||||
func isForced(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(forceKey{}).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
||||
call := strings.ToUpper(strings.TrimSpace(callsign))
|
||||
if call == "" {
|
||||
@@ -97,6 +113,13 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
||||
dxcc := m.dxcc
|
||||
m.mu.RUnlock()
|
||||
|
||||
// A FORCED lookup skips the cache. The cache is right for the automatic
|
||||
// lookup that fires as you type, but it also freezes a wrong answer for the
|
||||
// whole TTL: an operator who upgraded their QRZ subscription kept getting the
|
||||
// thin free-account record for a month, and clearing the cache by hand was
|
||||
// the only way out. A lookup the operator asked for by clicking is a
|
||||
// deliberate act and must reach the provider.
|
||||
if !isForced(ctx) {
|
||||
if r, ok := m.cache.Get(ctx, call); ok {
|
||||
r.Source = "cache"
|
||||
// Re-assert the authoritative DXCC fields (country/zones/continent)
|
||||
@@ -107,6 +130,7 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
||||
normalizeNames(&r)
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
// An operational suffix (/M, /P, …) is never registered as such: skip the
|
||||
|
||||
+46
-1
@@ -751,8 +751,20 @@ var bulkEditableCols = map[string]bool{
|
||||
"qsl_rcvd": true,
|
||||
"qsl_via": true,
|
||||
"qrzcom_qso_upload_status": true,
|
||||
"qrzcom_qso_download_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,
|
||||
// My station / operator
|
||||
"station_callsign": true,
|
||||
"operator": true,
|
||||
@@ -775,6 +787,14 @@ var bulkEditableCols = map[string]bool{
|
||||
"my_arrl_sect": true,
|
||||
"my_darc_dok": true,
|
||||
"my_vucc_grids": true,
|
||||
// Contacted station: the LOCATION fields only. State and county are the ones
|
||||
// an import loses and a whole run shares (a park activation, a county line
|
||||
// operation), and the dialog has always offered them — but they were missing
|
||||
// here, so choosing one failed at Apply. The rest of the contacted station
|
||||
// (callsign, name, RST…) stays deliberately out: bulk-setting those would
|
||||
// corrupt the log.
|
||||
"state": true,
|
||||
"cnty": true,
|
||||
// Contest — the exchange/label fields that are constant across a run.
|
||||
// (srx/stx serial numbers stay excluded: they are per-QSO.)
|
||||
"contest_id": true,
|
||||
@@ -1173,7 +1193,15 @@ var filterableColumns = map[string]bool{
|
||||
"iota": true, "sota_ref": true, "pota_ref": true, "wwff_ref": true, "rig": true, "ant": true,
|
||||
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true,
|
||||
"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
|
||||
"qrzcom_qso_upload_status": true, "clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true,
|
||||
"qrzcom_qso_upload_status": true, "qrzcom_qso_download_status": true,
|
||||
"clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true,
|
||||
// Confirmation DATES. ADIF YYYYMMDD strings, so a plain string comparison is
|
||||
// also chronological — "before 20240101" works with no date parsing.
|
||||
"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,
|
||||
"contest_id": true, "srx": true, "stx": true,
|
||||
"prop_mode": true, "sat_name": true,
|
||||
"station_callsign": true, "operator": true, "my_grid": true, "my_country": true,
|
||||
@@ -2800,3 +2828,20 @@ func parseTimeLoose(s string) time.Time {
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// IsBulkEditable reports whether a column may be written by a bulk edit. Used by
|
||||
// the app layer's own test to prove that every field the UI offers is actually
|
||||
// accepted here — the two lists live in different packages and drifted apart
|
||||
// once already: the new confirmation columns were added to the QSL Manager's
|
||||
// filter whitelist instead of this one, so the UI offered fields that the
|
||||
// repository then refused with "field … is not bulk-editable".
|
||||
func IsBulkEditable(column string) bool { return bulkEditableCols[column] }
|
||||
|
||||
// IsFilterable reports whether a column may appear in a query filter. Same
|
||||
// reason as IsBulkEditable.
|
||||
func IsFilterable(column string) bool { return filterableColumns[column] }
|
||||
|
||||
// IsBulkEditableExtra / IsFilterableExtra cover the ADIF fields that have no
|
||||
// column of their own and live in extras_json.
|
||||
func IsBulkEditableExtra(field string) bool { _, ok := bulkEditableExtras[field]; return ok }
|
||||
func IsFilterableExtra(field string) bool { _, ok := filterableExtras[field]; return ok }
|
||||
|
||||
+116
-3
@@ -171,6 +171,27 @@ type Stats struct {
|
||||
ByDay30 []Bucket `json:"by_day30"` // the last 30 days
|
||||
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 ──
|
||||
// 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
|
||||
@@ -492,11 +513,95 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
||||
ref = time.Now()
|
||||
}
|
||||
s.recentSeries(times, ref)
|
||||
s.windowSeries(times, first, last, from, to)
|
||||
s.ensureNonNil()
|
||||
|
||||
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
|
||||
// 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
|
||||
@@ -569,6 +674,14 @@ func (s *Stats) ensureNonNil() {
|
||||
if s.ByMonth == nil {
|
||||
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 {
|
||||
s.Rate = []Bucket{}
|
||||
}
|
||||
@@ -588,9 +701,10 @@ func (s *Stats) ensureNonNil() {
|
||||
// log itself.
|
||||
//
|
||||
// Two rates are reported on purpose, because a single one always flatters:
|
||||
// • AvgPerHour = QSOs ÷ the WHOLE window — breaks included. The honest number.
|
||||
// • AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you
|
||||
// - AvgPerHour = QSOs ÷ the WHOLE window — breaks included. The honest number.
|
||||
// - AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you
|
||||
// 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.
|
||||
func (s *Stats) periodMetrics(times []entry, from, to, first, last time.Time) {
|
||||
if len(times) == 0 {
|
||||
@@ -790,7 +904,6 @@ func sortedBuckets(m map[string]int, less func(a, b string) bool) []Bucket {
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
// 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
|
||||
// data". A quiet decade must read as a decade at zero, not vanish.
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
@@ -179,7 +180,7 @@ func (m *Manager) Connect(cfg Config) error {
|
||||
stop, done := m.stopRead, m.doneRead
|
||||
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)
|
||||
|
||||
if err := m.applyConfig(cfg); err != nil {
|
||||
@@ -398,10 +399,112 @@ func (m *Manager) write(b []byte) error {
|
||||
if p == nil {
|
||||
return fmt.Errorf("winkeyer: not connected")
|
||||
}
|
||||
traceTX(b)
|
||||
_, err := p.Write(b)
|
||||
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.
|
||||
func (m *Manager) Disconnect() {
|
||||
m.mu.Lock()
|
||||
@@ -473,6 +576,7 @@ func (m *Manager) readLoop(p serial.Port, stop, done chan struct{}) {
|
||||
}
|
||||
return
|
||||
}
|
||||
traceRX(buf[:n])
|
||||
for i := 0; i < n; i++ {
|
||||
b := buf[i]
|
||||
switch {
|
||||
|
||||
@@ -112,6 +112,12 @@ func main() {
|
||||
MinWidth: 1100,
|
||||
MinHeight: 700,
|
||||
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
|
||||
// the DOM has painted (OnDomReady → domReady) — so the window appears
|
||||
// already at its final size and position, with no post-launch jump.
|
||||
@@ -119,7 +125,10 @@ func main() {
|
||||
AssetServer: &assetserver.Options{
|
||||
Assets: assets,
|
||||
},
|
||||
BackgroundColour: &options.RGBA{R: 250, G: 250, B: 249, A: 1},
|
||||
// The colour the window is painted before the WebView draws. Matches the
|
||||
// graphite theme's background (#16181d), which is the default on a fresh
|
||||
// install — otherwise every launch flashes white first.
|
||||
BackgroundColour: &options.RGBA{R: 0x16, G: 0x18, B: 0x1d, A: 1},
|
||||
OnStartup: app.startup,
|
||||
OnDomReady: app.domReady,
|
||||
OnBeforeClose: app.beforeClose,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// The case that started this: WSJT-X / MSHV can only send a 4-character grid, so
|
||||
// a digital QSO arrived with JN05 while QRZ knew JN05JG — and the fill-if-empty
|
||||
// enrichment rule kept the coarse one. Roughly 100 km of accuracy, silently lost
|
||||
// on every digital QSO.
|
||||
func TestRefineGrid(t *testing.T) {
|
||||
cases := []struct{ have, found, want, why string }{
|
||||
{"JN05", "JN05JG", "JN05JG", "the lookup EXTENDS the received square — take the precise one"},
|
||||
{"jn05", "JN05JG", "JN05JG", "grids arrive in both cases"},
|
||||
{"", "JN05JG", "JN05JG", "nothing received — take whatever was found"},
|
||||
{"JN05JG", "", "JN05JG", "nothing found — keep what was received"},
|
||||
{"JN05JG", "JN05", "JN05JG", "never trade a precise grid for a coarse one"},
|
||||
{"JN05JG", "JN05JG", "JN05JG", "same grid"},
|
||||
// A DISAGREEMENT is not a refinement: the station may be portable, and
|
||||
// what came over the air is then the better record.
|
||||
{"JN05", "JN18AA", "JN05", "different square — keep what was actually received"},
|
||||
{"JN18AA", "JN05", "JN18AA", "different square, coarse lookup — keep the received one"},
|
||||
{"", "", "", "nothing either way"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := refineGrid(c.have, c.found); got != c.want {
|
||||
t.Errorf("refineGrid(%q, %q) = %q, want %q — %s", c.have, c.found, got, c.want, c.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.21.5"
|
||||
appVersion = "0.21.7"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user