Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8db653981 | ||
|
|
9729ef62ba | ||
|
|
cc6411a618 | ||
|
|
991831bdec | ||
|
|
1b2da95ad4 | ||
|
|
10d86db50a | ||
|
|
8538f48259 | ||
|
|
0fa91c3d5f | ||
|
|
c86d331bd9 | ||
|
|
77a752efe3 | ||
|
|
781fbfaa30 | ||
|
|
61c11c0fe3 | ||
|
|
64b746f007 | ||
|
|
9cc72c7575 | ||
|
|
9e4f43f648 | ||
|
|
5f044b959e | ||
|
|
68a49be8c1 | ||
|
|
8eb82d6cdb | ||
|
|
d327db3f57 | ||
|
|
59e6570f17 | ||
|
|
82a2c6cb7f | ||
|
|
24eaf597fd | ||
|
|
14a22ddb66 | ||
|
|
9156acea5f | ||
|
|
5d0906f00e | ||
|
|
901e967b53 | ||
|
|
4ab4f70349 | ||
|
|
64e80986ea | ||
|
|
816c6ffcf1 | ||
|
|
2166d1aa4b |
@@ -505,6 +505,7 @@ type App struct {
|
||||
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
|
||||
liveBand string
|
||||
liveMode string
|
||||
liveLastQSOAt time.Time // when this operator last logged a NEW contact — drives online/offline
|
||||
awardSnapMu sync.Mutex // guards the award QSO snapshot
|
||||
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
|
||||
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
|
||||
@@ -698,6 +699,20 @@ func (a *App) startup(ctx context.Context) {
|
||||
usingDefault = false
|
||||
}
|
||||
}
|
||||
// A rename in a previous session left the OLD file to delete now that it's no
|
||||
// longer open. Only ever delete a file that ISN'T the one we're about to use.
|
||||
if boot := readBootstrap(dataDir); strings.TrimSpace(boot.DeletePending) != "" {
|
||||
old := strings.TrimSpace(boot.DeletePending)
|
||||
if old != a.dbPath {
|
||||
for _, p := range []string{old, old + "-wal", old + "-shm"} {
|
||||
if err := os.Remove(p); err == nil {
|
||||
fmt.Printf("OpsLog: removed old database file %s\n", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
boot.DeletePending = ""
|
||||
_ = writeBootstrap(dataDir, boot)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil {
|
||||
a.startupErr = "cannot create db folder: " + err.Error()
|
||||
fmt.Println("OpsLog:", a.startupErr)
|
||||
@@ -811,6 +826,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
applog.Printf("startup: logbook backend = %s", backend)
|
||||
a.logDb = logbookConn
|
||||
a.qso = qso.NewRepo(logbookConn)
|
||||
a.backfillAwardRefsOnce() // one-time: materialise award_refs for pre-existing QSOs
|
||||
go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks
|
||||
go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs
|
||||
a.relayAutoOn.Store(a.GetRelayAuto().Enabled) // prime the relay auto-control hot-path flag
|
||||
@@ -1338,6 +1354,11 @@ func copyFileData(src, dst string) error {
|
||||
type dbPointer struct {
|
||||
DBPath string `json:"db_path"`
|
||||
MySQL *MySQLSettings `json:"mysql,omitempty"`
|
||||
// DeletePending is the previous database file to remove on the NEXT launch —
|
||||
// set by a rename, which can't delete the still-open old file in-process. The
|
||||
// startup path deletes it (with its -wal/-shm sidecars) once the new DB is the
|
||||
// one in use, then clears this.
|
||||
DeletePending string `json:"delete_pending,omitempty"`
|
||||
}
|
||||
|
||||
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
|
||||
@@ -1651,9 +1672,10 @@ func (a *App) PickSaveDatabase() (string, error) {
|
||||
return "", fmt.Errorf("no app context")
|
||||
}
|
||||
return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
|
||||
Title: "Save the OpsLog database to…",
|
||||
DefaultFilename: "opslog.db",
|
||||
Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}},
|
||||
Title: "Save the OpsLog database to…",
|
||||
DefaultDirectory: filepath.Dir(a.dbPath),
|
||||
DefaultFilename: "opslog.db",
|
||||
Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1695,6 +1717,44 @@ func (a *App) MoveDatabase(dest string) error {
|
||||
return writeDBPointer(a.dataDir, dest)
|
||||
}
|
||||
|
||||
// RenameDatabase renames the current database file to dest — keeping ALL config
|
||||
// (it is the same database under a new name), unlike "New database" which starts
|
||||
// empty. Implemented as a consistent copy (VACUUM INTO) plus a switch, then the
|
||||
// ORIGINAL file is scheduled for deletion on the next launch (it is open now and
|
||||
// can't be removed in-process on Windows). dest must not already exist.
|
||||
func (a *App) RenameDatabase(dest string) error {
|
||||
dest = strings.TrimSpace(dest)
|
||||
if dest == "" {
|
||||
return fmt.Errorf("no destination given")
|
||||
}
|
||||
if a.db == nil {
|
||||
return fmt.Errorf("database not open")
|
||||
}
|
||||
old := a.dbPath
|
||||
if strings.EqualFold(filepath.Clean(dest), filepath.Clean(old)) {
|
||||
return fmt.Errorf("that is already the current database name")
|
||||
}
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
return fmt.Errorf("a file already exists at %s — pick a new name", dest)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return fmt.Errorf("create folder: %w", err)
|
||||
}
|
||||
safe := strings.ReplaceAll(dest, "'", "''")
|
||||
if _, err := a.db.ExecContext(a.ctx, "VACUUM INTO '"+safe+"'"); err != nil {
|
||||
return fmt.Errorf("copy database: %w", err)
|
||||
}
|
||||
boot := readBootstrap(a.dataDir)
|
||||
boot.DBPath = dest
|
||||
// Only schedule the old file for deletion when it's a real, on-disk file (a
|
||||
// custom path or the default opslog.db) — never something we somehow share
|
||||
// with the destination.
|
||||
if strings.TrimSpace(old) != "" && !strings.EqualFold(filepath.Clean(old), filepath.Clean(dest)) {
|
||||
boot.DeletePending = old
|
||||
}
|
||||
return writeBootstrap(a.dataDir, boot)
|
||||
}
|
||||
|
||||
// CreateDatabase creates a fresh, empty logbook at dest (schema migrated) and
|
||||
// points OpsLog at it for the next launch. dest must not already exist.
|
||||
func (a *App) CreateDatabase(dest string) error {
|
||||
@@ -1850,13 +1910,12 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
||||
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
|
||||
a.applyQSLDefaults(&q)
|
||||
a.applySolar(&q) // stamp SFI / A / K (and SSN as an extra) from live space-weather
|
||||
// Fill the contacted operator's e-mail from the (cached) lookup so the
|
||||
// recording can be auto-sent. Cheap: the entry already looked the call up.
|
||||
if strings.TrimSpace(q.Email) == "" && a.lookup != nil {
|
||||
if lr, e := a.lookup.Lookup(a.ctx, q.Callsign); e == nil && lr.Email != "" {
|
||||
q.Email = lr.Email
|
||||
}
|
||||
}
|
||||
// NOTE: the contacted operator's e-mail already rides in on the QSO — the entry
|
||||
// lookup (when you typed the call) fetched it along with name/QTH/grid and the
|
||||
// form carries it here. There is deliberately NO second lookup at log time: it
|
||||
// was redundant with the entry lookup and only slowed logging (a call not yet in
|
||||
// the cache made AddQSO wait on QRZ/HamQTH). If the entry lookup hadn't finished
|
||||
// when you logged (fast CW: type → Enter), the e-mail is simply blank — fine.
|
||||
id, err = a.qso.Add(a.ctx, q)
|
||||
if err != nil && db.IsConnLost(err) {
|
||||
// The database is UNREACHABLE (not a data error) — park the QSO in the
|
||||
@@ -1872,6 +1931,8 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
||||
if err == nil {
|
||||
q.ID = id
|
||||
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh
|
||||
a.noteLiveQSO() // multi-op: flip this operator back "online"
|
||||
a.materializeAwardRefs(q) // stamp award_refs so the grid columns show at once
|
||||
// Announce the log so UI widgets can react (e.g. the Flex panel zeroing RIT).
|
||||
wruntime.EventsEmit(a.ctx, "qso:logged", id)
|
||||
a.saveQSORecording(&q)
|
||||
@@ -2449,6 +2510,14 @@ func (a *App) migrateAwardDefs() {
|
||||
applog.Printf("awards: %s updated from the catalog (v%d)", code, defByCode(migrated, code).Version)
|
||||
a.reseedRefsFromCatalog(code)
|
||||
}
|
||||
// A catalog definition changed (e.g. DDFM gained the postal-code OR-rule), so
|
||||
// the materialised award_refs on existing QSOs are now stale. Clear the
|
||||
// one-shot backfill flag; backfillAwardRefsOnce runs later in startup and will
|
||||
// re-materialise every row against the updated definitions. This makes ANY
|
||||
// future catalog bump self-heal the stored columns without a manual flag bump.
|
||||
if len(updated) > 0 {
|
||||
_ = a.settings.Set(a.ctx, keyAwardsMaterialized, "")
|
||||
}
|
||||
// Version-gated correction of the built-in awards' Validate sources, which
|
||||
// an earlier version wrongly set equal to Confirm (so VALIDATED == CONFIRMED
|
||||
// even for paper-QSL-only entities). Re-apply the canonical Confirm/Validate
|
||||
@@ -2514,6 +2583,7 @@ func (a *App) SaveAwardDefs(defs []award.Def) error {
|
||||
return err
|
||||
}
|
||||
go a.mirrorAwardsToFolder(defs)
|
||||
a.recomputeAwardRefsAsync() // definitions changed → refresh every row's award_refs
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3411,6 +3481,10 @@ func (a *App) invalidateAwardStats() {
|
||||
// flips qsl_rcvd flags on existing rows).
|
||||
func (a *App) RescanAwards() error {
|
||||
a.invalidateAwardStats()
|
||||
// Also refresh the materialised award_refs on every QSO (the grid columns read
|
||||
// from there). Manual trigger for when a definition/reference change should be
|
||||
// re-applied to existing rows on demand rather than waiting for the next event.
|
||||
a.recomputeAwardRefsAsync()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3638,71 +3712,229 @@ func (a *App) ComputeQSOAwardRefs(q qso.QSO) ([]QSOAwardRef, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AwardRefsForQSOs returns, per QSO id, a map of award code → the reference(s)
|
||||
// that QSO contributes to (joined when several). Powers the per-award columns in
|
||||
// the Recent QSOs / Worked-before grids. The reference metadata is computed ONCE
|
||||
// for the whole batch so a page of QSOs stays cheap.
|
||||
func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error) {
|
||||
out := map[int64]map[string]string{}
|
||||
if a.qso == nil || len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
// awardMatCtx bundles the precompiled award state needed to derive a QSO's
|
||||
// materialised references. Built ONCE (newAwardMatCtx) and reused across a batch
|
||||
// or a full recompute so award.Compute's per-pattern compilation isn't repeated.
|
||||
type awardMatCtx struct {
|
||||
defs []award.Def
|
||||
metas map[string][]award.RefMeta
|
||||
fieldByCode map[string]string
|
||||
dispByCode map[string]string
|
||||
nameOf award.NameResolver
|
||||
}
|
||||
|
||||
func (a *App) newAwardMatCtx() awardMatCtx {
|
||||
defs := a.awardDefs()
|
||||
metas := a.awardRefMetas(defs)
|
||||
fieldByCode := map[string]string{}
|
||||
dispByCode := map[string]string{}
|
||||
for _, d := range defs {
|
||||
fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field))
|
||||
dispByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.RefDisplay))
|
||||
}
|
||||
nameOf := func(field, ref string) string {
|
||||
switch field {
|
||||
case "dxcc":
|
||||
if n, err := strconv.Atoi(ref); err == nil {
|
||||
return dxcc.NameForDXCC(n)
|
||||
return awardMatCtx{
|
||||
defs: defs,
|
||||
metas: a.awardRefMetas(defs),
|
||||
fieldByCode: fieldByCode,
|
||||
dispByCode: dispByCode,
|
||||
nameOf: func(field, ref string) string {
|
||||
switch field {
|
||||
case "dxcc":
|
||||
if n, err := strconv.Atoi(ref); err == nil {
|
||||
return dxcc.NameForDXCC(n)
|
||||
}
|
||||
case "cont":
|
||||
return continentName(ref)
|
||||
}
|
||||
case "cont":
|
||||
return continentName(ref)
|
||||
return ""
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// awardRefLabels derives, for one QSO, the map of award code → the reference(s)
|
||||
// that QSO contributes to (joined when several), formatted per the award's
|
||||
// RefDisplay choice (ref / name / both; DXCC shows the country name by default).
|
||||
func (a *App) awardRefLabels(ac awardMatCtx, q qso.QSO) map[string]string {
|
||||
a.enrichQSOForAwards(&q)
|
||||
results := award.Compute(ac.defs, []qso.QSO{q}, ac.metas, ac.nameOf)
|
||||
m := map[string]string{}
|
||||
for i := range results {
|
||||
r := &results[i]
|
||||
code := strings.ToUpper(r.Code)
|
||||
dxccField := ac.fieldByCode[code] == "dxcc"
|
||||
var refs []string
|
||||
for _, rf := range r.Refs {
|
||||
if !rf.Worked {
|
||||
continue
|
||||
}
|
||||
label := rf.Ref
|
||||
switch ac.dispByCode[code] {
|
||||
case "name":
|
||||
if rf.Name != "" {
|
||||
label = rf.Name
|
||||
}
|
||||
case "both":
|
||||
if rf.Name != "" {
|
||||
label = rf.Ref + " — " + rf.Name
|
||||
}
|
||||
default: // "" or "ref"
|
||||
if dxccField && rf.Name != "" {
|
||||
label = rf.Name
|
||||
}
|
||||
}
|
||||
refs = append(refs, label)
|
||||
}
|
||||
if len(refs) > 0 {
|
||||
m[code] = strings.Join(refs, ", ")
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// awardRefsJSONFor returns awardRefLabels marshalled to a compact JSON object
|
||||
// keyed by award code, or "" when the QSO contributes to no award (blank column).
|
||||
func (a *App) awardRefsJSONFor(ac awardMatCtx, q qso.QSO) string {
|
||||
m := a.awardRefLabels(ac, q)
|
||||
if len(m) == 0 {
|
||||
return ""
|
||||
}
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// materializeAwardRefs computes ONE QSO's award references and stores them on the
|
||||
// row (the award_refs column) so the grid columns read them straight from the DB.
|
||||
// Cheap: an in-memory award.Compute plus one targeted UPDATE. Called on every
|
||||
// log / edit / UDP-import. Never fatal — a failure just leaves the column stale
|
||||
// until the next recompute.
|
||||
func (a *App) materializeAwardRefs(q qso.QSO) {
|
||||
if a.qso == nil || q.ID == 0 {
|
||||
return
|
||||
}
|
||||
ac := a.newAwardMatCtx()
|
||||
if err := a.qso.SetAwardRefs(a.ctx, q.ID, a.awardRefsJSONFor(ac, q)); err != nil {
|
||||
applog.Printf("award_refs: store for qso %d failed: %v", q.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// materializeAwardRefsForIDs recomputes award_refs for a specific set of QSOs
|
||||
// (e.g. after a bulk cty/QRZ/Club Log update or a bulk edit changed fields that
|
||||
// feed awards). Writes only the changed rows, in one transaction.
|
||||
func (a *App) materializeAwardRefsForIDs(ids []int64) {
|
||||
if a.qso == nil || len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
ac := a.newAwardMatCtx()
|
||||
changes := map[int64]string{}
|
||||
err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error {
|
||||
a.enrichQSOForAwards(&q)
|
||||
results := award.Compute(defs, []qso.QSO{q}, metas, nameOf)
|
||||
m := map[string]string{}
|
||||
for i := range results {
|
||||
r := &results[i]
|
||||
code := strings.ToUpper(r.Code)
|
||||
dxccField := fieldByCode[code] == "dxcc"
|
||||
var refs []string
|
||||
for _, rf := range r.Refs {
|
||||
if !rf.Worked {
|
||||
continue
|
||||
}
|
||||
// Per-award display choice: ref (default), name (description), or
|
||||
// both. DXCC keeps showing the country name under the default.
|
||||
label := rf.Ref
|
||||
switch dispByCode[code] {
|
||||
case "name":
|
||||
if rf.Name != "" {
|
||||
label = rf.Name
|
||||
}
|
||||
case "both":
|
||||
if rf.Name != "" {
|
||||
label = rf.Ref + " — " + rf.Name
|
||||
}
|
||||
default: // "" or "ref"
|
||||
if dxccField && rf.Name != "" {
|
||||
label = rf.Name
|
||||
}
|
||||
}
|
||||
refs = append(refs, label)
|
||||
}
|
||||
if len(refs) > 0 {
|
||||
m[code] = strings.Join(refs, ", ")
|
||||
}
|
||||
if js := a.awardRefsJSONFor(ac, q); js != q.AwardRefs {
|
||||
changes[q.ID] = js
|
||||
}
|
||||
if len(m) > 0 {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
applog.Printf("award_refs: recompute for ids failed: %v", err)
|
||||
return
|
||||
}
|
||||
if err := a.qso.SetAwardRefsBatch(a.ctx, changes); err != nil {
|
||||
applog.Printf("award_refs: batch write for ids failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// RecomputeAllAwardRefs rebuilds every QSO's materialised award references. Run
|
||||
// when an award definition or reference list changes (stored labels would
|
||||
// otherwise go stale) and once after upgrade to backfill existing rows. Rows
|
||||
// whose result is unchanged are skipped; the changed ones are written in a single
|
||||
// transaction (SetAwardRefsBatch) so even a large logbook on a remote MySQL is
|
||||
// one round-trip's worth of work rather than N. Returns how many rows changed.
|
||||
func (a *App) RecomputeAllAwardRefs() (int, error) {
|
||||
if a.qso == nil {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
}
|
||||
ac := a.newAwardMatCtx()
|
||||
// Collect updates during the scan; DON'T write mid-iteration (a nested query on
|
||||
// the same connection can deadlock SQLite / trip MySQL "commands out of sync").
|
||||
changes := map[int64]string{}
|
||||
err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
|
||||
if js := a.awardRefsJSONFor(ac, q); js != q.AwardRefs {
|
||||
changes[q.ID] = js
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := a.qso.SetAwardRefsBatch(a.ctx, changes); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(changes), nil
|
||||
}
|
||||
|
||||
// recomputeAwardRefsAsync runs a full recompute off the UI goroutine and, when
|
||||
// done, tells the frontend to reload so the refreshed award columns show. Used
|
||||
// wherever the set of matches could shift for MANY rows at once: an award
|
||||
// definition / reference-list change, or a bulk ADIF import.
|
||||
func (a *App) recomputeAwardRefsAsync() {
|
||||
go func() {
|
||||
n, err := a.RecomputeAllAwardRefs()
|
||||
if err != nil {
|
||||
applog.Printf("award_refs: bulk recompute failed: %v", err)
|
||||
return
|
||||
}
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "awards:recomputed", n)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// keyAwardsMaterialized marks (per profile / logbook) that the one-time award_refs
|
||||
// backfill has run, so it doesn't re-scan the whole logbook on every launch.
|
||||
// keyAwardsMaterialized is versioned: bump the suffix to force every operator to
|
||||
// re-materialise award_refs once on next launch. Needed when the stored values
|
||||
// could be stale against the CURRENT award definitions — e.g. rows materialised
|
||||
// before an award gained a new matching rule (DDFM's postal-code OR-rule), which
|
||||
// the one-shot backfill would otherwise never revisit.
|
||||
const keyAwardsMaterialized = "awards.materialized.v2"
|
||||
|
||||
// backfillAwardRefsOnce populates award_refs for QSOs that predate the feature
|
||||
// (or were logged by an older client that didn't materialise them). It runs at
|
||||
// most once per logbook — guarded by a per-profile flag — in the background so it
|
||||
// never delays startup, even on a large remote MySQL logbook.
|
||||
func (a *App) backfillAwardRefsOnce() {
|
||||
if a.qso == nil || a.settings == nil {
|
||||
return
|
||||
}
|
||||
if done, _ := a.settings.Get(a.ctx, keyAwardsMaterialized); done == "1" {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
n, err := a.RecomputeAllAwardRefs()
|
||||
if err != nil {
|
||||
applog.Printf("award_refs: initial backfill failed: %v", err)
|
||||
return
|
||||
}
|
||||
_ = a.settings.Set(a.ctx, keyAwardsMaterialized, "1")
|
||||
applog.Printf("award_refs: backfilled %d QSO(s)", n)
|
||||
if a.ctx != nil && n > 0 {
|
||||
wruntime.EventsEmit(a.ctx, "awards:recomputed", n)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// AwardRefsForQSOs returns, per QSO id, the award code → reference(s) map. It is
|
||||
// the live (non-materialised) path, kept for callers that want fresh values
|
||||
// without touching the DB. The grid now reads the stored award_refs column
|
||||
// instead, but this stays available and is the single source of the label logic.
|
||||
func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error) {
|
||||
out := map[int64]map[string]string{}
|
||||
if a.qso == nil || len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
ac := a.newAwardMatCtx()
|
||||
err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error {
|
||||
if m := a.awardRefLabels(ac, q); len(m) > 0 {
|
||||
out[q.ID] = m
|
||||
}
|
||||
return nil
|
||||
@@ -3748,6 +3980,17 @@ func (a *App) GetAwardReferenceMeta() ([]AwardRefMeta, error) {
|
||||
// UpdateAwardReferenceList downloads the latest reference list for an award and
|
||||
// replaces the stored set. Returns the new reference count.
|
||||
func (a *App) UpdateAwardReferenceList(code string) (AwardRefMeta, error) {
|
||||
meta, err := a.updateAwardReferenceList(code)
|
||||
if err == nil {
|
||||
a.recomputeAwardRefsAsync() // new reference list → labels change → refresh rows
|
||||
}
|
||||
return meta, err
|
||||
}
|
||||
|
||||
// updateAwardReferenceList is the recompute-free core, so DownloadAllReferenceLists
|
||||
// can update several lists in a loop and trigger ONE bulk recompute at the end
|
||||
// rather than one per list.
|
||||
func (a *App) updateAwardReferenceList(code string) (AwardRefMeta, error) {
|
||||
if a.awardRefs == nil {
|
||||
return AwardRefMeta{}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
@@ -3784,7 +4027,7 @@ func (a *App) DownloadAllReferenceLists() (string, error) {
|
||||
if !awardref.CanUpdate(code) {
|
||||
continue
|
||||
}
|
||||
meta, err := a.UpdateAwardReferenceList(code)
|
||||
meta, err := a.updateAwardReferenceList(code)
|
||||
if err != nil {
|
||||
parts = append(parts, fmt.Sprintf("%s ✗", code))
|
||||
if firstErr == nil {
|
||||
@@ -3794,6 +4037,7 @@ func (a *App) DownloadAllReferenceLists() (string, error) {
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s %d", code, meta.Count))
|
||||
}
|
||||
a.recomputeAwardRefsAsync() // one bulk recompute after all lists updated
|
||||
return strings.Join(parts, " · "), firstErr
|
||||
}
|
||||
|
||||
@@ -3837,6 +4081,7 @@ func (a *App) DeleteAwardReference(code, refCode string) error {
|
||||
}
|
||||
a.markAwardEdited(code)
|
||||
a.mirrorAwards()
|
||||
a.recomputeAwardRefsAsync()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3880,6 +4125,7 @@ func (a *App) ReplaceAwardReferences(code string, refs []awardref.Ref) (int, err
|
||||
}
|
||||
a.markAwardEdited(code)
|
||||
a.mirrorAwards()
|
||||
a.recomputeAwardRefsAsync() // reference list replaced → refresh every row's award_refs
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -4015,6 +4261,65 @@ func (a *App) ExportAward(code string) (string, error) {
|
||||
return a.exportAwardBundle([]string{code}, "OpsLog_award_"+code+".json", "Export award "+code)
|
||||
}
|
||||
|
||||
// ExportAwardForCatalog writes ONE award as a ready-to-ship CATALOG file — the
|
||||
// exact shape internal/award/catalog/*.json use ({"def":{…},"references":[…]}).
|
||||
//
|
||||
// The workflow it enables: edit an award in the UI, call this with the NEXT
|
||||
// version number, and paste the file over that award's catalog JSON. A new OpsLog
|
||||
// release then carries the change to the whole team: every operator whose copy is
|
||||
// unedited auto-upgrades to it (mergeCatalog), and those who edited it keep theirs
|
||||
// but are offered the update.
|
||||
//
|
||||
// Two things a plain export/mirror can't do and this does, both essential for a
|
||||
// catalog file: it STAMPS the award's Version (the UI save deliberately never
|
||||
// bumps it) and it CLEARS user_edited, so a fresh install seeded from this file is
|
||||
// NOT pre-flagged as the operator's own work — which would otherwise freeze it out
|
||||
// of every future catalog update.
|
||||
func (a *App) ExportAwardForCatalog(code string, version int) (string, error) {
|
||||
if a.awardRefs == nil || a.ctx == nil {
|
||||
return "", fmt.Errorf("db not initialized")
|
||||
}
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
var def award.Def
|
||||
found := false
|
||||
for _, d := range a.awardDefs() {
|
||||
if strings.EqualFold(strings.TrimSpace(d.Code), code) {
|
||||
def, found = d, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return "", fmt.Errorf("unknown award %q", code)
|
||||
}
|
||||
def.Version = version
|
||||
def.UserEdited = false // a catalog seed is not "the operator's edit"
|
||||
def.Builtin = true
|
||||
refs, err := a.awardRefs.List(a.ctx, code)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load references: %w", err)
|
||||
}
|
||||
entry := struct {
|
||||
Def award.Def `json:"def"`
|
||||
References []awardref.Ref `json:"references,omitempty"`
|
||||
}{Def: def, References: refs}
|
||||
b, err := json.MarshalIndent(entry, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path, err := wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
|
||||
DefaultFilename: strings.ToLower(code) + ".json",
|
||||
Title: "Export " + code + " for the catalog",
|
||||
Filters: []wruntime.FileFilter{{DisplayName: "JSON (*.json)", Pattern: "*.json"}},
|
||||
})
|
||||
if err != nil || strings.TrimSpace(path) == "" {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(path, b, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// exportAwardBundle writes the given award codes (nil = all) to a JSON bundle.
|
||||
func (a *App) exportAwardBundle(codes []string, defaultName, title string) (string, error) {
|
||||
if a.awardRefs == nil {
|
||||
@@ -4484,6 +4789,7 @@ func (a *App) UpdateQSO(q qso.QSO) error {
|
||||
err := a.qso.Update(a.ctx, q)
|
||||
if err == nil {
|
||||
a.invalidateAwardStats()
|
||||
a.materializeAwardRefs(q) // fields may have changed → refresh award_refs
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -4517,22 +4823,31 @@ func (a *App) GetOperators() ([]string, error) {
|
||||
// QSORate is the live QSO-rate meter shown in the header: how many QSOs were
|
||||
// logged in the trailing 10 and 60 minutes.
|
||||
type QSORate struct {
|
||||
Last10 int `json:"last10"`
|
||||
Last60 int `json:"last60"`
|
||||
Last10 int `json:"last10"` // active operator, last 10 min
|
||||
Last60 int `json:"last60"` // active operator, last 60 min
|
||||
TeamLast10 int `json:"team_last10"` // ALL operators (the whole station), last 10 min
|
||||
TeamLast60 int `json:"team_last60"` // ALL operators, last 60 min
|
||||
}
|
||||
|
||||
// GetQSORate returns the number of QSOs logged in the last 10 and 60 minutes.
|
||||
// Cheap (scans only the most recent rows); polled by the header and refreshed on
|
||||
// each qso:logged event.
|
||||
// GetQSORate returns the number of QSOs logged in the last 10 and 60 minutes, both
|
||||
// for the active operator (their own performance) AND for all operators combined
|
||||
// (the team/station rate). Cheap (one scan of the most recent rows); polled by the
|
||||
// header and refreshed on each qso:logged event.
|
||||
func (a *App) GetQSORate() QSORate {
|
||||
if a.qso == nil {
|
||||
return QSORate{}
|
||||
}
|
||||
counts, err := a.qso.RecentRate(a.ctx, time.Now(), 10*time.Minute, 60*time.Minute)
|
||||
if err != nil || len(counts) < 2 {
|
||||
operator := ""
|
||||
if a.profiles != nil {
|
||||
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||||
operator = p.Operator
|
||||
}
|
||||
}
|
||||
op, all, err := a.qso.RecentRateBreakdown(a.ctx, time.Now(), operator, 10*time.Minute, 60*time.Minute)
|
||||
if err != nil || len(op) < 2 || len(all) < 2 {
|
||||
return QSORate{}
|
||||
}
|
||||
return QSORate{Last10: counts[0], Last60: counts[1]}
|
||||
return QSORate{Last10: op[0], Last60: op[1], TeamLast10: all[0], TeamLast60: all[1]}
|
||||
}
|
||||
|
||||
// GetContestRuns lists the (contest, year) pairs actually present in the log, so
|
||||
@@ -4802,6 +5117,7 @@ func (a *App) BulkUpdateField(ids []int64, field, value string) (int64, error) {
|
||||
}
|
||||
if n > 0 {
|
||||
a.invalidateAwardStats()
|
||||
a.materializeAwardRefsForIDs(ids) // the edited field may feed an award → refresh
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -4951,7 +5267,11 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStatio
|
||||
im.OnProgress = func(processed, total int) {
|
||||
wruntime.EventsEmit(a.ctx, "import:progress", map[string]int{"processed": processed, "total": total})
|
||||
}
|
||||
return im.ImportFile(a.ctx, path)
|
||||
res, err := im.ImportFile(a.ctx, path)
|
||||
if err == nil && (res.Imported > 0 || res.Updated > 0) {
|
||||
a.recomputeAwardRefsAsync() // materialise award_refs for the imported rows
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
// SaveADIFFile shows a native Save-As dialog suggesting a timestamped
|
||||
@@ -5292,7 +5612,14 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
|
||||
if a.lookup == nil {
|
||||
return lookup.Result{}, fmt.Errorf("lookup not initialized")
|
||||
}
|
||||
r, err := a.lookup.Lookup(a.ctx, callsign)
|
||||
// Bound the whole lookup: give the providers a couple of seconds, then let
|
||||
// Lookup fall through to cty.dat (country/zones). Without this a call that isn't
|
||||
// in QRZ.com — or a slow/unresponsive provider — left the "looking up" spinner
|
||||
// turning for 10 s+ before the cty.dat fallback showed. The providers respect
|
||||
// the context, so they're cancelled at the deadline and cty.dat answers instantly.
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
r, err := a.lookup.Lookup(ctx, callsign)
|
||||
if errors.Is(err, lookup.ErrNotFound) {
|
||||
return lookup.Result{}, fmt.Errorf("callsign not found")
|
||||
}
|
||||
@@ -5822,8 +6149,10 @@ func (a *App) saveQSORecording(q *qso.QSO) {
|
||||
if q.Extras == nil {
|
||||
q.Extras = map[string]string{}
|
||||
}
|
||||
q.Extras["APP_OPSLOG_RECORDING"] = name
|
||||
if err := a.qso.Update(a.ctx, *q); err != nil {
|
||||
q.Extras["APP_OPSLOG_RECORDING"] = name // in-memory copy for the encode goroutine
|
||||
// Persist ONLY this extras key (targeted) — a full-row Update from this
|
||||
// in-memory copy could revert a column a concurrent post-log action changed.
|
||||
if err := a.qso.SetExtra(a.ctx, q.ID, "APP_OPSLOG_RECORDING", name); err != nil {
|
||||
applog.Printf("qso-rec: store recording path: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -6695,17 +7024,10 @@ func (a *App) markRecordingSent(id int64) {
|
||||
if a.qso == nil || id == 0 {
|
||||
return
|
||||
}
|
||||
q, err := a.qso.GetByID(a.ctx, id)
|
||||
if err != nil {
|
||||
applog.Printf("qso-rec: mark sent: load %d: %v", id, err)
|
||||
return
|
||||
}
|
||||
if q.Extras == nil {
|
||||
q.Extras = map[string]string{}
|
||||
}
|
||||
q.Extras["APP_OPSLOG_RECORDING_SENT"] = time.Now().UTC().Format("2006-01-02")
|
||||
if err := a.qso.Update(a.ctx, q); err != nil {
|
||||
applog.Printf("qso-rec: mark sent: update %d: %v", id, err)
|
||||
// Targeted extras write — never a full-row Update, which (from a stale copy)
|
||||
// could revert a clublog/qrz upload-status another action just stamped.
|
||||
if err := a.qso.SetExtra(a.ctx, id, "APP_OPSLOG_RECORDING_SENT", time.Now().UTC().Format("2006-01-02")); err != nil {
|
||||
applog.Printf("qso-rec: mark sent %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8748,6 +9070,7 @@ func (a *App) UpdateQSOsFromCty(ids []int64) (int, error) {
|
||||
}
|
||||
if changed > 0 {
|
||||
a.invalidateAwardStats()
|
||||
a.materializeAwardRefsForIDs(ids) // entity fields changed → refresh award_refs
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
@@ -8823,6 +9146,7 @@ func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) {
|
||||
}
|
||||
if changed > 0 {
|
||||
a.invalidateAwardStats()
|
||||
a.materializeAwardRefsForIDs(ids) // entity/geo fields changed → refresh award_refs
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
@@ -9277,6 +9601,8 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
return 0, fmt.Errorf("insert qso: %w", err)
|
||||
}
|
||||
q.ID = id
|
||||
a.noteLiveQSO() // multi-op: flip this operator back "online"
|
||||
a.materializeAwardRefs(q)
|
||||
a.saveQSORecording(&q)
|
||||
if a.extsvc != nil {
|
||||
a.extsvc.OnQSOLogged(id)
|
||||
@@ -10033,6 +10359,36 @@ func (a *App) IcomSendCW(text string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// FlexSendCW keys a CW message through the FlexRadio CWX keyer (SmartSDR), so a
|
||||
// Flex needs no WinKeyer / SmartCAT. Text is already variable-resolved by the UI.
|
||||
func (a *App) FlexSendCW(text string) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
err := a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SendCW(text) })
|
||||
if err != nil {
|
||||
applog.Printf("flex cw: FlexSendCW(%q) failed: %v", text, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// FlexStopCW clears the CWX buffer, aborting whatever is being keyed.
|
||||
func (a *App) FlexStopCW() error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.StopCW() })
|
||||
}
|
||||
|
||||
// FlexSetKeySpeed sets the CW keyer speed in WPM (the CWX keyer uses the radio's
|
||||
// CW speed).
|
||||
func (a *App) FlexSetKeySpeed(wpm int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWSpeed(wpm) })
|
||||
}
|
||||
|
||||
// IcomStopCW aborts the CW message currently being sent.
|
||||
func (a *App) IcomStopCW() error {
|
||||
if a.cat == nil {
|
||||
|
||||
+10
-11
@@ -437,17 +437,16 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
|
||||
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
|
||||
return err
|
||||
}
|
||||
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field —
|
||||
// NOT the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay
|
||||
// independent. q came straight from GetByID, so a full Update rewrites the
|
||||
// row unchanged apart from this field.
|
||||
if q.Extras == nil {
|
||||
q.Extras = map[string]string{}
|
||||
}
|
||||
q.Extras[appQSLCardSentField] = time.Now().UTC().Format(time.RFC3339)
|
||||
if err := a.qso.Update(a.ctx, q); err != nil {
|
||||
applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err)
|
||||
return fmt.Errorf("eQSL sent but status not saved: %w", err)
|
||||
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field — NOT
|
||||
// the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay independent.
|
||||
//
|
||||
// Stamp ONLY this extras key (targeted UPDATE), never a full-row write. The `q`
|
||||
// read up top is now stale after the slow e-mail send, and rewriting the whole
|
||||
// row would revert any column an auto-upload changed meanwhile — that's how
|
||||
// sending a QSL card was flipping clublog_qso_upload_status back from Y to R.
|
||||
if err := a.qso.SetExtra(a.ctx, qsoID, appQSLCardSentField, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
applog.Printf("qsl: card sent to %s but marking failed: %v", q.Callsign, err)
|
||||
return fmt.Errorf("QSL card sent but status not saved: %w", err)
|
||||
}
|
||||
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
|
||||
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
|
||||
|
||||
+363
-93
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||
GetStartupStatus, CheckForUpdate,
|
||||
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations,
|
||||
WorkedBefore,
|
||||
SetCompactMode,
|
||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
|
||||
@@ -36,14 +36,14 @@ import {
|
||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
|
||||
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
|
||||
IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
|
||||
FlexSendCW, FlexStopCW, FlexSetKeySpeed,
|
||||
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
||||
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
||||
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
||||
GetAwardDefs,
|
||||
GetUIPref,
|
||||
ReportLiveActivity,
|
||||
AwardRefsForQSOs,
|
||||
GetUIPref, GetActiveProfile, QuitApp,
|
||||
ReportLiveActivity, GetLiveStatusEnabled, LiveLastQSOAgeSec,
|
||||
} from '../wailsjs/go/main/App';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||
@@ -87,6 +87,7 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||
import { RotorCompass } from '@/components/RotorCompass';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { setGridPrefsProfile } from '@/lib/gridPrefs';
|
||||
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -135,6 +136,22 @@ const emptyDetails: DetailsState = {
|
||||
award_refs: '',
|
||||
};
|
||||
|
||||
// parseAwardRefs turns the QSO row's materialised award_refs JSON string
|
||||
// ({"DDFM":"74","WAJA":"12"}) into the code→ref object the grid columns read.
|
||||
// Tolerant of empty / malformed values (returns {}), and passes an already-parsed
|
||||
// object straight through.
|
||||
function parseAwardRefs(s: any): Record<string, string> {
|
||||
if (!s) return {};
|
||||
if (typeof s === 'object') return s as Record<string, string>;
|
||||
if (typeof s !== 'string') return {};
|
||||
try {
|
||||
const o = JSON.parse(s);
|
||||
return o && typeof o === 'object' ? o : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDateUTC(s: any): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
@@ -210,6 +227,16 @@ function bandForMHz(mhz: number): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
// modeAccent maps a mode to a theme-aware colour for the live-stations widget:
|
||||
// CW gold, phone green, digital blue, unknown muted.
|
||||
function modeAccent(mode?: string): string {
|
||||
const m = (mode || '').toUpperCase();
|
||||
if (/CW/.test(m)) return 'var(--chart-3)';
|
||||
if (/SSB|USB|LSB|AM|FM|PHONE|DV/.test(m)) return 'var(--chart-2)';
|
||||
if (/FT8|FT4|RTTY|PSK|JT|JS8|Q65|MSK|FST|MFSK|OLIVIA|DIG|DATA|WSPR/.test(m)) return 'var(--chart-1)';
|
||||
return 'var(--muted-foreground)';
|
||||
}
|
||||
|
||||
// rstCategory buckets a mode into the report family used for its RST list.
|
||||
type RSTLists = { phone: string[]; cw: string[]; digital: string[] };
|
||||
function rstCategory(mode: string): keyof RSTLists {
|
||||
@@ -410,6 +437,18 @@ export default function App() {
|
||||
// click reverts the UI and the click looks like it did nothing.
|
||||
const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
|
||||
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
|
||||
// Multi-op "who's on air" widget: every operator's live status from the shared
|
||||
// MySQL logbook (freq/mode/version). Only polled on a MySQL logbook.
|
||||
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
|
||||
const [liveStations, setLiveStations] = useState<LiveStation[]>([]);
|
||||
const [showLiveStations, setShowLiveStations] = useState(() => localStorage.getItem('opslog.showLiveStations') === '1');
|
||||
useEffect(() => {
|
||||
if (dbConn?.backend !== 'mysql') { setLiveStations([]); return; }
|
||||
const load = () => GetLiveStations().then((s) => setLiveStations((s ?? []) as LiveStation[])).catch(() => {});
|
||||
load();
|
||||
const id = window.setInterval(load, 15 * 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [dbConn]);
|
||||
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
|
||||
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
||||
// in Preferences > Hardware > CAT interface.
|
||||
@@ -605,11 +644,17 @@ export default function App() {
|
||||
QSOAudioResetClock().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
|
||||
};
|
||||
const [saving, setSaving] = useState(false);
|
||||
// Synchronous re-entrancy guard: `saving` is React state (updates async), so it
|
||||
// can't stop a burst of Enter presses / clicks fired before the re-render — each
|
||||
// would run a full AddQSO and log the SAME contact several times when a slow QRZ
|
||||
// lookup made the log take seconds. This ref blocks the repeat immediately.
|
||||
const savingRef = useRef(false);
|
||||
const [filterCallsign, setFilterCallsign] = useState('');
|
||||
// Advanced filter builder (replaces the old band/mode dropdowns).
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
|
||||
const [matchCount, setMatchCount] = useState<number | null>(null);
|
||||
const [gridFilteredCount, setGridFilteredCount] = useState<number | null>(null); // rows after AG-Grid column filters, or null if none
|
||||
// The selected tab is remembered across restarts. Only the always-present tabs
|
||||
// are restored: the conditional ones (flex/icom/contest/net/stats/qsl) depend on
|
||||
// a feature or CAT backend that isn't known this early, and restoring one that
|
||||
@@ -731,7 +776,7 @@ export default function App() {
|
||||
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
|
||||
// auto-call and <LOGQSO> are shared; only the transport differs.
|
||||
const [wkEngine, setWkEngine] = useState<string>('winkeyer');
|
||||
const cwSource: 'winkeyer' | 'icom' = wkEngine === 'icom' ? 'icom' : 'winkeyer';
|
||||
const cwSource: 'winkeyer' | 'icom' | 'flex' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : 'winkeyer';
|
||||
const cwSourceRef = useRef(cwSource);
|
||||
useEffect(() => { cwSourceRef.current = cwSource; }, [cwSource]);
|
||||
// CW break-in (0=OFF, 1=SEMI, 2=FULL) — must be on for the rig's 0x17 keyer to
|
||||
@@ -760,7 +805,9 @@ export default function App() {
|
||||
const wkBusyRef = useRef(false); // live "keyer is sending" flag, for the <LOGQSO> wait-then-log
|
||||
useEffect(() => { wkBusyRef.current = wkStatus.busy; }, [wkStatus.busy]);
|
||||
useEffect(() => {
|
||||
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected) : wkStatus.connected;
|
||||
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected)
|
||||
: cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected)
|
||||
: wkStatus.connected;
|
||||
wkActiveRef.current = wkEnabled && connected;
|
||||
}, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
|
||||
useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);
|
||||
@@ -1086,13 +1133,31 @@ export default function App() {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
||||
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
||||
// "ON AIR" status-bar badge: mirrors the multi-op live status this operator
|
||||
// publishes — online (blinking) when a QSO was logged in the last 5 min, else
|
||||
// offline. Only shown when live-status publishing is enabled (Settings→General).
|
||||
const [liveStatusOn, setLiveStatusOn] = useState(false);
|
||||
const [onAir, setOnAir] = useState(false);
|
||||
useEffect(() => { if (!showSettings) GetLiveStatusEnabled().then((v) => setLiveStatusOn(!!v)).catch(() => {}); }, [showSettings]);
|
||||
useEffect(() => {
|
||||
// Read the ON-AIR state straight from the backend (single source of truth:
|
||||
// liveLastQSOAt, stamped on every log and seeded from the DB at launch). Poll
|
||||
// it + refresh on each logged QSO — no fragile frontend timestamp to drift.
|
||||
const refresh = () => LiveLastQSOAgeSec()
|
||||
.then((sec: number) => setOnAir(liveStatusOn && typeof sec === 'number' && sec >= 0 && sec < 300))
|
||||
.catch(() => {});
|
||||
refresh();
|
||||
const off = EventsOn('qso:logged', refresh);
|
||||
const id = window.setInterval(refresh, 5 * 1000); // responsive without hammering (cheap 400-row scan)
|
||||
return () => { off(); window.clearInterval(id); };
|
||||
}, [liveStatusOn]);
|
||||
// QSO-rate meter (10/60 min) in the header — opt-in via Settings→General.
|
||||
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
||||
useEffect(() => { if (!showSettings) setShowQsoRate(localStorage.getItem('opslog.showQsoRate') === '1'); }, [showSettings]);
|
||||
const [qsoRate, setQsoRate] = useState<{ last10: number; last60: number }>({ last10: 0, last60: 0 });
|
||||
const [qsoRate, setQsoRate] = useState<{ last10: number; last60: number; team10: number; team60: number }>({ last10: 0, last60: 0, team10: 0, team60: 0 });
|
||||
useEffect(() => {
|
||||
if (!showQsoRate) return;
|
||||
const load = () => { GetQSORate().then((r) => setQsoRate({ last10: r?.last10 ?? 0, last60: r?.last60 ?? 0 })).catch(() => {}); };
|
||||
const load = () => { GetQSORate().then((r: any) => setQsoRate({ last10: r?.last10 ?? 0, last60: r?.last60 ?? 0, team10: r?.team_last10 ?? 0, team60: r?.team_last60 ?? 0 })).catch(() => {}); };
|
||||
load();
|
||||
// Refresh on each logged QSO (immediate feedback) and on a 30s tick so the
|
||||
// trailing windows roll forward even when nothing new is logged.
|
||||
@@ -1106,15 +1171,39 @@ export default function App() {
|
||||
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
const [showDuplicates, setShowDuplicates] = useState(false);
|
||||
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string } | null>(null);
|
||||
// Check GitHub for a newer release once at startup (unless disabled in
|
||||
// General); surface a toast if one exists. Best effort — silent on failure.
|
||||
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [updateProgress, setUpdateProgress] = useState(0);
|
||||
const [updateError, setUpdateError] = useState('');
|
||||
// Check GitHub for a newer release at startup AND every 10 minutes (unless
|
||||
// disabled in General). Best effort — silent on failure.
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem('opslog.checkUpdates') === '0') return;
|
||||
CheckForUpdate().then((u: any) => {
|
||||
if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? '') });
|
||||
const check = () => CheckForUpdate().then((u: any) => {
|
||||
if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') });
|
||||
}).catch(() => {});
|
||||
check();
|
||||
const id = window.setInterval(check, 10 * 60 * 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
// Live download progress for the in-app updater.
|
||||
useEffect(() => {
|
||||
const off = EventsOn('update:progress', (p: any) => setUpdateProgress(Math.max(0, Math.min(100, Number(p) || 0))));
|
||||
return () => { off(); };
|
||||
}, []);
|
||||
// startUpdate downloads the new build in-app and (on success) swaps + relaunches.
|
||||
// Falls back to opening the release page when the release has no auto-download asset.
|
||||
const startUpdate = useCallback(async () => {
|
||||
if (!updateInfo) return;
|
||||
if (!updateInfo.downloadUrl) { if (updateInfo.url) BrowserOpenURL(updateInfo.url); return; }
|
||||
setUpdating(true); setUpdateProgress(0); setUpdateError('');
|
||||
try {
|
||||
await DownloadAndApplyUpdate(updateInfo.downloadUrl); // app quits + relaunches on success
|
||||
} catch (e: any) {
|
||||
setUpdateError(String(e?.message ?? e));
|
||||
setUpdating(false);
|
||||
}
|
||||
}, [updateInfo]);
|
||||
const [deletingAll, setDeletingAll] = useState(false);
|
||||
const [ctyRefreshing, setCtyRefreshing] = useState(false);
|
||||
const [refsDownloading, setRefsDownloading] = useState(false);
|
||||
@@ -1153,6 +1242,10 @@ export default function App() {
|
||||
const [lookupError, setLookupError] = useState('');
|
||||
const lookupTimerRef = useRef<number | null>(null);
|
||||
const wbTimerRef = useRef<number | null>(null);
|
||||
// Bumped whenever the entry is cleared (ESC) or a new call starts, so a still
|
||||
// in-flight lookup discards its result when it finally returns instead of
|
||||
// re-populating a field the operator just cleared.
|
||||
const lookupGenRef = useRef(0);
|
||||
const [wb, setWb] = useState<WB | null>(null);
|
||||
const [wbBusy, setWbBusy] = useState(false);
|
||||
|
||||
@@ -1160,38 +1253,28 @@ export default function App() {
|
||||
// list once, then compute each shown QSO's reference per award and attach it
|
||||
// to the rows (the grids render one hideable column per award).
|
||||
const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]);
|
||||
// Bumped whenever award definitions are saved so the grid columns AND the
|
||||
// per-QSO refs re-fetch — the ref/name display choice is computed live, so
|
||||
// changing it updates ALL contacts (old included) with no restart.
|
||||
// Bumped when award definitions change (or the backend finishes a bulk
|
||||
// award_refs recompute) so the set of award COLUMNS re-reads from GetAwardDefs.
|
||||
// The per-QSO values themselves ride on the row (award_refs) and refresh with
|
||||
// the grid reload triggered alongside this bump.
|
||||
const [awardsVersion, setAwardsVersion] = useState(0);
|
||||
useEffect(() => {
|
||||
GetAwardDefs().then((defs: any[]) =>
|
||||
setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))),
|
||||
).catch(() => {});
|
||||
}, [awardsVersion]);
|
||||
const [qsoAwardRefs, setQsoAwardRefs] = useState<Record<string, Record<string, string>>>({});
|
||||
useEffect(() => {
|
||||
const ids = (qsos as any[]).map((q) => q.id).filter(Boolean);
|
||||
if (ids.length === 0 || awardCols.length === 0) { setQsoAwardRefs({}); return; }
|
||||
let alive = true;
|
||||
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setQsoAwardRefs(m ?? {}); }).catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, [qsos, awardCols.length, awardsVersion]);
|
||||
// Award references are now MATERIALISED on the QSO row (the award_refs JSON
|
||||
// column, written by the backend on log/edit and bulk-recomputed when awards
|
||||
// change). The grid reads them straight from the row — no per-page backend
|
||||
// recompute — so here we just parse the stored JSON string into the code→ref
|
||||
// object the award columns expect (keys are already upper-case).
|
||||
const qsosWithAwards = useMemo(
|
||||
() => (qsos as any[]).map((q) => ({ ...q, award_refs: qsoAwardRefs[String(q.id)] })),
|
||||
[qsos, qsoAwardRefs],
|
||||
() => (qsos as any[]).map((q) => ({ ...q, award_refs: parseAwardRefs(q.award_refs) })),
|
||||
[qsos],
|
||||
);
|
||||
const [wbAwardRefs, setWbAwardRefs] = useState<Record<string, Record<string, string>>>({});
|
||||
useEffect(() => {
|
||||
const ids = ((wb?.entries ?? []) as any[]).map((e) => e.id).filter(Boolean);
|
||||
if (ids.length === 0 || awardCols.length === 0) { setWbAwardRefs({}); return; }
|
||||
let alive = true;
|
||||
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setWbAwardRefs(m ?? {}); }).catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, [wb, awardCols.length, awardsVersion]);
|
||||
const wbWithAwards = useMemo(
|
||||
() => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: wbAwardRefs[String(e.id)] })) } : null),
|
||||
[wb, wbAwardRefs],
|
||||
() => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: parseAwardRefs(e.award_refs) })) } : null),
|
||||
[wb],
|
||||
);
|
||||
// Always-current copy of the entry callsign, so the UDP event handlers
|
||||
// (which live in a []-deps effect with a stale `callsign` closure) can
|
||||
@@ -1352,6 +1435,15 @@ export default function App() {
|
||||
return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
|
||||
}, [refresh]);
|
||||
|
||||
// The backend bulk-recomputed the materialised award_refs (an award definition
|
||||
// or reference list changed, or the one-time backfill ran). Reload the rows so
|
||||
// the award columns show the new values, and bump awardsVersion so the set of
|
||||
// award COLUMNS refreshes too (a new award may have appeared).
|
||||
useEffect(() => {
|
||||
const off = EventsOn('awards:recomputed', () => { refresh(); setAwardsVersion((v) => v + 1); });
|
||||
return () => { off(); };
|
||||
}, [refresh]);
|
||||
|
||||
// Backend-emitted toast messages (e.g. recording auto-send result/skip).
|
||||
useEffect(() => {
|
||||
const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); });
|
||||
@@ -1947,15 +2039,29 @@ export default function App() {
|
||||
setWkMacros((s.macros ?? []) as WKMacro[]);
|
||||
setWkEscClears(s.esc_clears_call !== false);
|
||||
setWkSendOnType(!!s.send_on_type);
|
||||
setWkEngine(s.engine === 'icom' ? 'icom' : 'winkeyer');
|
||||
setWkEngine(s.engine === 'icom' ? 'icom' : s.engine === 'flex' ? 'flex' : 'winkeyer');
|
||||
} catch { /* keyer not configured */ }
|
||||
}, []);
|
||||
|
||||
// Active profile id — scopes the grids' column layout (visibility / width /
|
||||
// order) so each profile keeps its own. Seeded once on mount and updated on
|
||||
// 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);
|
||||
useEffect(() => {
|
||||
GetActiveProfile().then((p: any) => {
|
||||
if (p && p.id != null) { setGridPrefsProfile(p.id); setActiveProfileId(p.id); }
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Every setting is per-profile, so when the active profile changes the whole
|
||||
// main UI re-reads its config (station identity, lists, CAT, keyer). The Go
|
||||
// side reloads its managers; this keeps the React state in sync.
|
||||
useEffect(() => {
|
||||
const off = EventsOn('profile:changed', () => {
|
||||
const off = EventsOn('profile:changed', (id: any) => {
|
||||
// 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();
|
||||
// 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).
|
||||
@@ -2031,11 +2137,13 @@ export default function App() {
|
||||
const keyed = resolved ? resolved + ' ' : resolved;
|
||||
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
|
||||
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
|
||||
if (cwSourceRef.current === 'icom') {
|
||||
// The rig's keyer gives no busy echo back, so show the text we sent and,
|
||||
// for <LOGQSO>, wait the estimated send duration before logging.
|
||||
if (cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex') {
|
||||
// The rig keyer (Icom 0x17 / Flex CWX) gives no busy echo we track, so show
|
||||
// the text we sent and, for <LOGQSO>, wait the estimated send duration
|
||||
// before logging.
|
||||
setWkSent(resolved);
|
||||
await IcomSendCW(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : IcomSendCW;
|
||||
await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||
if (doLog) { await sleep(Math.round(estimateCwMs(resolved, wkWpm)) + 600); void save(); }
|
||||
return;
|
||||
}
|
||||
@@ -2071,7 +2179,7 @@ export default function App() {
|
||||
// the wait at the ESTIMATED send time (not the busy flag alone): over a
|
||||
// remote/serial-over-IP link the "busy" status lags badly and stays stuck
|
||||
// true for tens of seconds, which made the next CQ fire ~120s late.
|
||||
if (cwSourceRef.current === 'icom') {
|
||||
if (cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex') {
|
||||
await sleep(Math.round(estimateCwMs(resolveCW(m.text), wkWpm)) + 300);
|
||||
} else {
|
||||
const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500;
|
||||
@@ -2109,7 +2217,10 @@ export default function App() {
|
||||
writeUiPref('opslog.wkAutoCallSecs', String(v));
|
||||
}
|
||||
// send-on-type: key the typed chars verbatim (no variable substitution).
|
||||
function wkSendRaw(chars: string) { WinkeyerSend(chars).catch(() => {}); }
|
||||
function wkSendRaw(chars: string) {
|
||||
if (cwSourceRef.current === 'flex') { FlexSendCW(chars).catch(() => {}); return; }
|
||||
WinkeyerSend(chars).catch(() => {});
|
||||
}
|
||||
function wkBackspace() { WinkeyerBackspace().catch(() => {}); }
|
||||
function wkToggleSendOnType(on: boolean) { setWkSendOnType(on); saveWk({ send_on_type: on }); }
|
||||
|
||||
@@ -2155,7 +2266,9 @@ export default function App() {
|
||||
}, [spots]);
|
||||
|
||||
async function save() {
|
||||
if (savingRef.current) return; // a log is already in flight — ignore the repeat
|
||||
if (!callsign.trim()) { setError('Callsign required'); return; }
|
||||
savingRef.current = true;
|
||||
setSaving(true); setError('');
|
||||
try {
|
||||
const freqHz = freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined;
|
||||
@@ -2225,13 +2338,21 @@ export default function App() {
|
||||
await refresh();
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
} finally { setSaving(false); }
|
||||
} finally { setSaving(false); savingRef.current = false; }
|
||||
}
|
||||
|
||||
// resetEntry clears the form for the next QSO. Triggered after a
|
||||
// successful log AND by ESC. Locked values (band/mode/freq/start/end)
|
||||
// are preserved so backdated batches stay productive.
|
||||
function resetEntry() {
|
||||
// Stop any callsign lookup DEAD: cancel the pending debounce, hide the "looking
|
||||
// up" spinner immediately, and invalidate any request already in flight so its
|
||||
// late result can't re-fill the field we're about to clear (the "ESC clears the
|
||||
// QSO but the QRZ lookup keeps going" bug).
|
||||
if (lookupTimerRef.current) { window.clearTimeout(lookupTimerRef.current); lookupTimerRef.current = null; }
|
||||
if (wbTimerRef.current) { window.clearTimeout(wbTimerRef.current); wbTimerRef.current = null; }
|
||||
lookupGenRef.current++;
|
||||
setLookupBusy(false);
|
||||
// Discard any in-progress QSO recording (no-op if it was already saved on
|
||||
// log, or if the recorder is off).
|
||||
QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
|
||||
@@ -2436,14 +2557,15 @@ export default function App() {
|
||||
}
|
||||
async function runLookup(call: string) {
|
||||
if (call !== lastLookedUpRef.current) resetAutoFill();
|
||||
const gen = lookupGenRef.current; // invalidated by ESC / resetEntry
|
||||
setLookupBusy(true);
|
||||
try {
|
||||
const r = await LookupCallsign(call);
|
||||
// Discard a STALE result: the operator already moved to another call
|
||||
// (clicked a new spot / typed) while this lookup was in flight. Applying it
|
||||
// would clobber the current call's fields and zoom the map to the wrong
|
||||
// station — the bug where replacing a call didn't re-zoom the map.
|
||||
if (call !== callsignValRef.current.trim().toUpperCase()) return;
|
||||
// (clicked a new spot / typed) OR cleared the entry (ESC) while this lookup
|
||||
// was in flight. Applying it would clobber the current fields and zoom the
|
||||
// map to the wrong station.
|
||||
if (gen !== lookupGenRef.current || call !== callsignValRef.current.trim().toUpperCase()) return;
|
||||
lastLookedUpRef.current = call;
|
||||
// cty.dat carries ONLY DXCC-entity data (country / CQ / ITU zones / continent).
|
||||
// A QRZ/HamQTH hit is far richer (name, QTH, grid, address, image). When the
|
||||
@@ -2496,9 +2618,15 @@ export default function App() {
|
||||
QSOAudioBegin().then(setRecording).catch(() => {});
|
||||
}
|
||||
} catch (e: any) {
|
||||
setLookupResult(null);
|
||||
setLookupError(String(e?.message ?? e));
|
||||
} finally { setLookupBusy(false); }
|
||||
if (gen === lookupGenRef.current && call === callsignValRef.current.trim().toUpperCase()) {
|
||||
setLookupResult(null);
|
||||
setLookupError(String(e?.message ?? e));
|
||||
}
|
||||
} finally {
|
||||
// Only clear the spinner if we're still the current lookup — a newer one
|
||||
// (or an ESC that already reset it) owns the busy state otherwise.
|
||||
if (gen === lookupGenRef.current) setLookupBusy(false);
|
||||
}
|
||||
}
|
||||
function scheduleLookup(value: string, force?: boolean) {
|
||||
setLookupError('');
|
||||
@@ -2552,7 +2680,14 @@ export default function App() {
|
||||
// keeps the pre-roll from before this); clearing it discards the take.
|
||||
// Recording START happens on blur (leaving the callsign field), NOT here —
|
||||
// you may type a call and work it minutes later. Clearing it cancels.
|
||||
if (v.trim() === '') { QSOAudioCancel(); setRecording(false); recordingCallRef.current = ""; }
|
||||
if (v.trim() === '') {
|
||||
QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
|
||||
// Callsign wiped → drop this contact's award references. They are auto-added
|
||||
// per call (live detection merges pickable refs into award_refs), so without
|
||||
// this they'd carry over to the NEXT call — e.g. IT9AOT's ref lingering when
|
||||
// you then type F4BPO, showing both in the F3 Awards tab.
|
||||
updateDetails({ award_refs: '' });
|
||||
}
|
||||
const isEmpty = v.trim() === '';
|
||||
if (!isEmpty && !locks.start) {
|
||||
// Restart the start time on every callsign change (each keystroke, a
|
||||
@@ -2635,7 +2770,7 @@ export default function App() {
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: t('file.deleteAll'), action: 'file.deleteall', disabled: total === 0 },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: t('file.exit'), action: 'file.exit', shortcut: 'Ctrl+Q', disabled: true },
|
||||
{ type: 'item', label: t('file.exit'), action: 'file.exit', shortcut: 'Ctrl+Q' },
|
||||
]},
|
||||
{ name: 'edit', label: t('menu.edit'), items: [
|
||||
{ type: 'item', label: t('edit.editSel'), action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
|
||||
@@ -2680,6 +2815,7 @@ export default function App() {
|
||||
case 'file.export': exportAdif(); break;
|
||||
case 'file.exportCabrillo': exportCabrillo(); break;
|
||||
case 'file.deleteall': setShowDeleteAll(true); break;
|
||||
case 'file.exit': QuitApp(); break;
|
||||
case 'view.refresh': refresh(); break;
|
||||
case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
|
||||
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
|
||||
@@ -2752,7 +2888,14 @@ export default function App() {
|
||||
const keyerLive = wkActiveRef.current;
|
||||
// ESC aborts the current CW transmission AND the auto-call loop, so it
|
||||
// won't resend after the gap — you must click a CQ macro to restart it.
|
||||
if (keyerLive) { stopAutoCall(); WinkeyerStop().catch(() => {}); }
|
||||
// Route the abort to whichever engine is active (was WinKeyer-only, so
|
||||
// ESC didn't stop the Icom or Flex keyer).
|
||||
if (keyerLive) {
|
||||
stopAutoCall();
|
||||
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
|
||||
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
||||
else WinkeyerStop().catch(() => {});
|
||||
}
|
||||
if (!keyerLive || wkEscClearsRef.current) {
|
||||
resetEntry();
|
||||
callsignRef.current?.focus();
|
||||
@@ -3519,6 +3662,7 @@ export default function App() {
|
||||
return (
|
||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||
<RecentQSOsGrid
|
||||
key={`rqg-${activeProfileId ?? 'x'}`}
|
||||
rows={qsosWithAwards as any}
|
||||
total={total}
|
||||
awardCols={awardCols}
|
||||
@@ -3772,6 +3916,24 @@ export default function App() {
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* Multi-op "who's on air": a dockable widget (toggle), not a popover. */}
|
||||
{dbConn?.backend === 'mysql' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { const v = !showLiveStations; setShowLiveStations(v); writeUiPref('opslog.showLiveStations', v ? '1' : '0'); }}
|
||||
title={showLiveStations ? `${t('live.stationsTitle')} — shown · click to hide` : `${t('live.stationsTitle')} · click to show`}
|
||||
className={cn('relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||
showLiveStations ? 'border-info-border bg-info-muted text-info-muted-foreground hover:bg-info-muted'
|
||||
: 'border-border text-muted-foreground hover:bg-muted')}
|
||||
>
|
||||
<Radio className="size-4" />
|
||||
{liveStations.filter((s) => s.online).length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 min-w-3.5 h-3.5 px-0.5 rounded-full bg-danger text-danger-foreground text-[9px] font-bold leading-[14px] text-center">
|
||||
{(() => { const n = liveStations.filter((s) => s.online).length; return n > 9 ? '9+' : n; })()}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* QSO-rate meter (opt-in) + propagation share ONE grid cell: the header
|
||||
@@ -3779,21 +3941,39 @@ export default function App() {
|
||||
the last columns (profile / band map / compact) onto a 2nd row. */}
|
||||
<div className="flex items-center gap-2">
|
||||
{showQsoRate && (
|
||||
<div className="flex items-center gap-2.5 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap"
|
||||
<div className="flex items-center gap-2 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap"
|
||||
title={t('rate.title')}>
|
||||
{/* Contest-style rate: QSOs/hour projected from each window (10-min
|
||||
count ×6; the 60-min count is already per hour). On a shared MySQL
|
||||
logbook it shows both OP (the active operator, accent) and TEAM (all
|
||||
operators, muted); single-op shows one line. */}
|
||||
<Activity className={cn('size-3.5', (qsoRate.last10 + qsoRate.last60) > 0 ? 'text-primary' : 'text-muted-foreground')} />
|
||||
{/* Contest-style rate: QSOs/hour projected from each window
|
||||
(10-min count ×6; the 60-min count is already per hour). Numbers
|
||||
glow the brand accent when active, dim to muted when idle. */}
|
||||
<span className="inline-flex items-baseline gap-1">
|
||||
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">10′</span>
|
||||
<span className={cn('font-bold text-[12px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-baseline gap-1">
|
||||
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">60′</span>
|
||||
<span className={cn('font-bold text-[12px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[9px] uppercase tracking-wider">Q/h</span>
|
||||
{dbConn?.backend === 'mysql' ? (
|
||||
<div className="flex flex-col gap-0.5 leading-none">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground uppercase tracking-wider text-[8px] w-9">OP</span>
|
||||
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span><span className="text-muted-foreground text-[7px]">10′</span></span>
|
||||
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span><span className="text-muted-foreground text-[7px]">60′</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground uppercase tracking-wider text-[8px] w-9">Team</span>
|
||||
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.team10 > 0 ? 'text-foreground' : 'text-muted-foreground')}>{qsoRate.team10 * 6}</span><span className="text-muted-foreground text-[7px]">10′</span></span>
|
||||
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.team60 > 0 ? 'text-foreground' : 'text-muted-foreground')}>{qsoRate.team60}</span><span className="text-muted-foreground text-[7px]">60′</span></span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="inline-flex items-baseline gap-1">
|
||||
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">10′</span>
|
||||
<span className={cn('font-bold text-[12px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-baseline gap-1">
|
||||
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">60′</span>
|
||||
<span className={cn('font-bold text-[12px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground text-[9px] uppercase tracking-wider self-center">Q/h</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3914,22 +4094,42 @@ export default function App() {
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="size-2.5 mt-1 rounded-full bg-primary shrink-0 animate-pulse" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold">OpsLog v{updateInfo.latest} available</p>
|
||||
<p className="text-xs text-muted-foreground">You're on v{APP_VERSION}.</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => { if (updateInfo.url) BrowserOpenURL(updateInfo.url); setUpdateInfo(null); }}
|
||||
className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">
|
||||
Download
|
||||
</button>
|
||||
<button onClick={() => setUpdateInfo(null)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">
|
||||
Later
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm font-semibold">{t('upd.available', { v: updateInfo.latest })}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('upd.current', { v: APP_VERSION })}</p>
|
||||
|
||||
{updating ? (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between text-[11px] text-muted-foreground mb-1">
|
||||
<span>{updateProgress >= 100 ? t('upd.installing') : t('upd.downloading')}</span>
|
||||
<span className="tabular-nums">{updateProgress}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-full bg-primary transition-[width] duration-150" style={{ width: `${updateProgress}%` }} />
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">{t('upd.restartNote')}</p>
|
||||
</div>
|
||||
) : updateError ? (
|
||||
<div className="mt-2">
|
||||
<p className="text-[11px] text-destructive break-words">{updateError}</p>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<button onClick={startUpdate} className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">{t('upd.retry')}</button>
|
||||
{updateInfo.url && <button onClick={() => BrowserOpenURL(updateInfo.url)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">{t('upd.browser')}</button>}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button onClick={startUpdate} className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">
|
||||
{updateInfo.downloadUrl ? t('upd.install') : t('upd.download')}
|
||||
</button>
|
||||
<button onClick={() => setUpdateInfo(null)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">{t('upd.later')}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => setUpdateInfo(null)} className="text-muted-foreground hover:text-foreground shrink-0" title="Dismiss">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
{!updating && (
|
||||
<button onClick={() => setUpdateInfo(null)} className="text-muted-foreground hover:text-foreground shrink-0" title="Dismiss">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -4120,8 +4320,55 @@ export default function App() {
|
||||
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
||||
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
||||
otherwise it shows the QRZ profile photo. */}
|
||||
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled)) && (
|
||||
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
|
||||
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
|
||||
{/* Multi-op "who's on air" widget: every operator on the shared logbook,
|
||||
their freq/mode (colour-coded) and OpsLog version. */}
|
||||
{showLiveStations && dbConn?.backend === 'mysql' && (
|
||||
<div className="w-[248px] shrink-0 min-h-0 relative">
|
||||
<div className="absolute inset-0 flex flex-col min-h-0 rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 px-3 h-8 border-b border-border shrink-0">
|
||||
<Radio className="size-3.5 text-primary" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider truncate">{t('live.stationsTitle')}</span>
|
||||
<div className="flex-1" />
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">{liveStations.filter((s) => s.online).length}</span>
|
||||
<button type="button" className="text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={() => { setShowLiveStations(false); writeUiPref('opslog.showLiveStations', '0'); }} title={t('live.stationsHide')}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-auto p-1.5 flex flex-col gap-1">
|
||||
{liveStations.filter((s) => s.online).length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic px-1 py-2">{t('live.stationsEmpty')}</p>
|
||||
) : liveStations.filter((s) => s.online).map((s, i) => {
|
||||
const mc = modeAccent(s.mode);
|
||||
return (
|
||||
<div key={i} className={cn('flex items-center gap-2 rounded-md px-2 py-1.5 border', s.online ? 'bg-muted/40 border-border' : 'border-transparent opacity-60')}>
|
||||
<span className={cn('size-2 rounded-full shrink-0', s.online ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')}
|
||||
title={s.online ? t('live.onAir') : t('live.offline')} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-1.5 min-w-0">
|
||||
<span className="text-xs font-bold font-mono truncate">{s.operator}</span>
|
||||
{s.version && <span className="text-[9px] text-muted-foreground shrink-0 tabular-nums ml-auto">v{s.version}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5 min-w-0">
|
||||
<span className="font-mono text-[11px] font-semibold tabular-nums" style={{ color: mc }}>
|
||||
{s.freq_hz ? (s.freq_hz / 1e6).toFixed(3) : '—'}
|
||||
</span>
|
||||
{s.mode && (
|
||||
<span className="text-[9px] font-bold uppercase px-1.5 rounded-full leading-[15px] shrink-0"
|
||||
style={{ background: `${mc}22`, color: mc }}>{s.mode}</span>
|
||||
)}
|
||||
{s.band && <span className="text-[10px] text-muted-foreground shrink-0">{s.band}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{chatShown && (
|
||||
// relative + absolute inner: the chat takes the row height (set by the
|
||||
// entry strip) WITHOUT its message list growing the row, like the
|
||||
@@ -4174,8 +4421,8 @@ export default function App() {
|
||||
{wkEnabled && (
|
||||
<div className="w-[380px] shrink-0 min-h-0">
|
||||
<WinkeyerPanel
|
||||
status={cwSource === 'icom'
|
||||
? { connected: catState.backend === 'icom' && catState.connected, busy: false, wpm: wkWpm, version: 0, port: 'CI-V' }
|
||||
status={cwSource === 'icom' || cwSource === 'flex'
|
||||
? { connected: catState.backend === cwSource && catState.connected, busy: false, wpm: wkWpm, version: 0, port: cwSource === 'flex' ? 'CWX' : 'CI-V' }
|
||||
: wkStatus}
|
||||
ports={wkPorts}
|
||||
port={wkPort}
|
||||
@@ -4192,11 +4439,15 @@ export default function App() {
|
||||
onSetSpeed={(w) => {
|
||||
setWkWpm(w); saveWk({ wpm: w });
|
||||
if (cwSource === 'icom') IcomSetKeySpeed(w).catch(() => {});
|
||||
else if (cwSource === 'flex') FlexSetKeySpeed(w).catch(() => {});
|
||||
else WinkeyerSetSpeed(w).catch(() => {});
|
||||
}}
|
||||
onSend={wkSend}
|
||||
onSendMacro={wkSendMacro}
|
||||
onStop={() => { stopAutoCall(); if (cwSource === 'icom') IcomStopCW().catch(() => {}); else WinkeyerStop().catch(() => {}); }}
|
||||
onStop={() => { stopAutoCall();
|
||||
if (cwSource === 'icom') IcomStopCW().catch(() => {});
|
||||
else if (cwSource === 'flex') FlexStopCW().catch(() => {});
|
||||
else WinkeyerStop().catch(() => {}); }}
|
||||
onClose={() => wkSetEnabled(false)}
|
||||
sendOnType={wkSendOnType}
|
||||
onToggleSendOnType={wkToggleSendOnType}
|
||||
@@ -4442,9 +4693,11 @@ export default function App() {
|
||||
)}
|
||||
|
||||
<RecentQSOsGrid
|
||||
key={`rqg2-${activeProfileId ?? 'x'}`}
|
||||
rows={qsosWithAwards as any}
|
||||
total={total}
|
||||
awardCols={awardCols}
|
||||
onFilteredCountChange={setGridFilteredCount}
|
||||
onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
onUpdateFromCty={bulkUpdateFromCty}
|
||||
onUpdateFromQRZ={bulkUpdateFromQRZ}
|
||||
@@ -4480,11 +4733,18 @@ export default function App() {
|
||||
onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }}
|
||||
>clear</button>
|
||||
) : null}
|
||||
<span>
|
||||
Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
|
||||
<span className="font-semibold text-foreground">{(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total}</span>
|
||||
{(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''}
|
||||
</span>
|
||||
{gridFilteredCount != null ? (
|
||||
<span>
|
||||
Showing <span className="font-semibold text-foreground">{gridFilteredCount}</span> of{' '}
|
||||
<span className="font-semibold text-foreground">{qsos.length}</span> (column filter)
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
|
||||
<span className="font-semibold text-foreground">{(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total}</span>
|
||||
{(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{qsos.length >= qsoLimit && qsos.length < total && (
|
||||
@@ -4854,6 +5114,16 @@ export default function App() {
|
||||
disabled={!rotatorHeading.enabled}
|
||||
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
||||
/>
|
||||
{liveStatusOn && (
|
||||
<div
|
||||
className={cn('inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider shrink-0 transition-colors',
|
||||
onAir ? 'border-danger-border bg-danger-muted text-danger-muted-foreground' : 'border-border text-muted-foreground')}
|
||||
title={onAir ? t('live.onAirTip') : t('live.offlineTip')}
|
||||
>
|
||||
<span className={cn('size-2 rounded-full', onAir ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')} />
|
||||
{onAir ? t('live.onAir') : t('live.offline')}
|
||||
</div>
|
||||
)}
|
||||
{/* Toasts / errors: the status bar's free space is far wider than the
|
||||
header band they used to sit in. Still one line (the bar is 28px),
|
||||
but CLICK opens the full text wrapped — long messages (a TQSL or
|
||||
|
||||
@@ -33,6 +33,37 @@ function pretty(name: string): string {
|
||||
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
// PortBtn is defined at MODULE scope on purpose. Defined inside AntGeniusPanel it
|
||||
// would be a new component *type* on every render, so React would unmount and
|
||||
// remount every port button each time the panel re-renders — harmless when that's
|
||||
// rare, but with the CW decoder running the parent re-renders many times a second
|
||||
// and the buttons were being torn down mid-click (mousedown and mouseup landing on
|
||||
// different element instances), so antenna changes silently did nothing.
|
||||
function PortBtn({ port, index, active, tx, onActivate, t }: {
|
||||
port: 1 | 2; index: number; active: boolean; tx: boolean;
|
||||
onActivate: (port: number, antenna: number) => void;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
}) {
|
||||
const letter = port === 1 ? 'A' : 'B';
|
||||
const cls = tx
|
||||
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
|
||||
: active
|
||||
? (port === 1
|
||||
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
||||
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
|
||||
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActivate(port, active ? 0 : index)}
|
||||
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
|
||||
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
||||
>
|
||||
{letter}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
|
||||
// match the app's light theme with soft gradients + glows. Each antenna row has
|
||||
// a port-A button (left) and port-B button (right). Colours: green = selected on
|
||||
@@ -61,27 +92,6 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
|
||||
if (filtered.length > 0) list = filtered;
|
||||
}
|
||||
|
||||
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
|
||||
const letter = port === 1 ? 'A' : 'B';
|
||||
const cls = tx
|
||||
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
|
||||
: active
|
||||
? (port === 1
|
||||
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
||||
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
|
||||
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActivate(port, active ? 0 : index)}
|
||||
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
|
||||
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
||||
>
|
||||
{letter}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
||||
@@ -124,11 +134,11 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
|
||||
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
||||
return (
|
||||
<div key={a.index} className="flex items-center gap-1.5">
|
||||
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} />
|
||||
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} onActivate={onActivate} t={t} />
|
||||
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
|
||||
{pretty(a.name)}
|
||||
</div>
|
||||
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} />
|
||||
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} onActivate={onActivate} t={t} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ListCountries, DXCCForCountry, DXCCName,
|
||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
||||
ExportAwardForCatalog,
|
||||
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
@@ -37,7 +38,7 @@ export type AwardDef = {
|
||||
or_rules?: AwardOrRule[];
|
||||
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
||||
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
||||
total: number; builtin?: boolean;
|
||||
total: number; builtin?: boolean; version?: number;
|
||||
};
|
||||
|
||||
type AwardOrRule = {
|
||||
@@ -155,6 +156,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
const [err, setErr] = useState('');
|
||||
// Version to stamp into a "publish for catalog" export — defaults to one past
|
||||
// the selected award's current version whenever the selection changes.
|
||||
const [catVer, setCatVer] = useState('1');
|
||||
|
||||
// The err banner doubles as a success/notice area (export path, import counts,
|
||||
// "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay
|
||||
@@ -212,6 +216,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
|
||||
const cur = defs[sel];
|
||||
const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null;
|
||||
useEffect(() => { setCatVer(String((cur?.version ?? 0) + 1)); }, [cur?.code]);
|
||||
|
||||
// ── Award tester: run the award's rules against a real QSO and show every step.
|
||||
type Rejected = { candidate: string; reason: string };
|
||||
@@ -299,6 +304,19 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
if (p) setErr(t('awed.exportedTo', { path: p }));
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
|
||||
// paste over internal/award/catalog/<code>.json. A new release then ships it to
|
||||
// the whole team (unedited copies auto-upgrade; edited ones are offered it).
|
||||
async function exportForCatalog() {
|
||||
setErr('');
|
||||
if (!cur) return;
|
||||
const v = Math.trunc(Number(catVer));
|
||||
if (!Number.isFinite(v) || v < 1) { setErr(t('awed.catalogBadVersion')); return; }
|
||||
try {
|
||||
const p = await ExportAwardForCatalog(cur.code.trim().toUpperCase(), v);
|
||||
if (p) setErr(t('awed.catalogExportedTo', { path: p }));
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
// Import: LOOK FIRST, then ask.
|
||||
//
|
||||
// This used to merge by code with "imported wins", silently — import a WAPC
|
||||
@@ -352,7 +370,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogContent className="max-w-6xl w-[95vw] max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogHeader className="px-5 py-3 border-b">
|
||||
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -726,6 +744,18 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
title={t('awed.awardsFolderTip')}>
|
||||
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
|
||||
</Button>
|
||||
{/* Publish the selected award to the catalog: stamp a version and write a
|
||||
file to paste over internal/award/catalog/<code>.json, so a release
|
||||
ships your change to the whole team. */}
|
||||
{cur && (
|
||||
<div className="flex items-center gap-1" title={t('awed.catalogPublishTip')}>
|
||||
<input type="number" min={1} value={catVer} onChange={(e) => setCatVer(e.target.value)}
|
||||
className="h-8 w-14 rounded border border-input bg-background px-1.5 text-xs font-mono" />
|
||||
<Button variant="outline" onClick={exportForCatalog}>
|
||||
<Download className="size-3.5 mr-1" /> {t('awed.catalogPublish')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
|
||||
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
|
||||
|
||||
@@ -49,6 +49,10 @@ type Props = {
|
||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||
onExportCabrilloFiltered?: () => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
// Reports how many rows the grid shows after its COLUMN filters (the funnel
|
||||
// icons), or null when no column filter is active — so the parent's "Showing X
|
||||
// of Y" can reflect them. Fired on filter change and when the data updates.
|
||||
onFilteredCountChange?: (count: number | null) => void;
|
||||
// One column per defined award; the cell shows the reference this QSO counts
|
||||
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
||||
awardCols?: { code: string; name: string }[];
|
||||
@@ -245,7 +249,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
||||
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
||||
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
||||
|
||||
export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) {
|
||||
export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
@@ -360,17 +364,26 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
||||
}
|
||||
});
|
||||
}
|
||||
// Report the post-column-filter row count (funnel filters) to the parent, or
|
||||
// null when no column filter is active, so "Showing X of Y" reflects them.
|
||||
const reportFilteredCount = useCallback((e: { api?: any }) => {
|
||||
const api = e?.api ?? gridRef.current?.api;
|
||||
if (!api || !onFilteredCountChange) return;
|
||||
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
|
||||
}, [onFilteredCountChange]);
|
||||
const saveColumnState = useCallback(() => {
|
||||
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||
const state = gridRef.current?.api?.getColumnState();
|
||||
if (state) saveState(colStateKey, stripAwardCols(state));
|
||||
}, []);
|
||||
|
||||
// The award columns load asynchronously; when they arrive (or change) the
|
||||
// columnDefs memo is rebuilt and AG Grid re-applies each colDef's `hide`
|
||||
// default — wiping the user's saved visibility (award columns reappear,
|
||||
// manually-shown ones like LoTW sent vanish). Re-apply the saved state after
|
||||
// every rebuild so the user's choices win. No-op before the grid is ready.
|
||||
// columnDefs is rebuilt whenever the award columns load OR the user toggles an
|
||||
// award column (both change the memo → restoringRef flips true at line 316). Each
|
||||
// rebuild makes AG Grid re-apply every colDef's `hide` default, wiping the user's
|
||||
// saved visibility of the NON-award columns (QTH/Grid reappear, a manually-shown
|
||||
// LoTW-sent vanishes). Re-apply the saved (award-stripped) state after EVERY such
|
||||
// rebuild — hence awardShown in the deps, not just awardCols; without it, toggling
|
||||
// an award reset the other columns AND left restoringRef stuck true (saving off).
|
||||
useEffect(() => {
|
||||
const api = gridRef.current?.api;
|
||||
const local = loadLocal(colStateKey);
|
||||
@@ -378,7 +391,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
||||
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [awardCols]);
|
||||
}, [awardCols, awardShown]);
|
||||
|
||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||
@@ -467,6 +480,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
||||
defaultColDef={defaultColDef}
|
||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||
onGridReady={onGridReady}
|
||||
onFilterChanged={reportFilteredCount}
|
||||
onModelUpdated={reportFilteredCount}
|
||||
onColumnResized={saveColumnState}
|
||||
onColumnMoved={saveColumnState}
|
||||
onColumnPinned={saveColumnState}
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||
ChevronDown, ChevronRight,
|
||||
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
||||
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff,
|
||||
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff, Pencil,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
ConnectClusterServer, DisconnectClusterServer,
|
||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase,
|
||||
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, RenameDatabase,
|
||||
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
|
||||
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||
@@ -2807,6 +2807,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<SelectContent>
|
||||
<SelectItem value="winkeyer">WinKeyer (serial)</SelectItem>
|
||||
<SelectItem value="icom">Icom CI-V (rig keyer)</SelectItem>
|
||||
<SelectItem value="flex">FlexRadio (CWX)</SelectItem>
|
||||
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -2837,6 +2838,26 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : wk.engine === 'flex' ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
FlexRadio keys CW through the radio's <strong>CWX</strong> keyer over the existing SmartSDR CAT connection — no WinKeyer or SmartCAT needed. It reuses the connection set in Settings → CAT, so there's nothing else to wire up here. Put a slice in CW mode. Only the speed is set from here; weight, sidetone and break-in are configured on the radio (break-in must be on for CW to actually transmit).
|
||||
</p>
|
||||
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
|
||||
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
||||
<span aria-hidden>⚠</span>
|
||||
<span>
|
||||
Your CAT backend is set to <strong>{catCfg.enabled ? (catCfg.backend || 'none') : 'disabled'}</strong>. Flex CWX needs the CAT backend set to <strong>FlexRadio</strong> and connected — change it under Settings → CAT interface, otherwise sending CW will fail.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Speed (WPM)</Label>
|
||||
<Input type="number" min={6} max={48} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
@@ -3978,6 +3999,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
setDbMsg(p);
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
// Rename the CURRENT database (keeps all config), unlike New database which
|
||||
// starts empty. The old file is removed on the next launch.
|
||||
async function renameDb() {
|
||||
try {
|
||||
const p = await PickSaveDatabase();
|
||||
if (!p) return;
|
||||
await RenameDatabase(p);
|
||||
await refreshDb();
|
||||
setDbMsg(p);
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function resetDefault() {
|
||||
try {
|
||||
await ResetDatabaseToDefault();
|
||||
@@ -4056,6 +4088,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
|
||||
<Button variant="outline" size="sm" onClick={openExisting}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button>
|
||||
<Button variant="outline" size="sm" onClick={renameDb} title={t('db.renameTip')}><Pencil className="size-3.5" /> {t('db.rename')}</Button>
|
||||
<Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
|
||||
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ interface Props {
|
||||
wpm: number;
|
||||
macros: WKMacro[];
|
||||
sent: string; // text echoed back by the keyer as it transmits
|
||||
source: 'winkeyer' | 'icom'; // CW output engine (chosen in Settings → CW Keyer)
|
||||
source: 'winkeyer' | 'icom' | 'flex'; // CW output engine (chosen in Settings → CW Keyer)
|
||||
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||
onSetBreakIn?: (mode: number) => void;
|
||||
onSelectPort: (p: string) => void;
|
||||
@@ -101,14 +101,16 @@ export function WinkeyerPanel({
|
||||
<Radio className="size-4 text-primary shrink-0" />
|
||||
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||
{source === 'icom' ? 'Icom CW' : 'WinKeyer'}
|
||||
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : 'WinKeyer'}
|
||||
</span>
|
||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
||||
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
||||
<div className="flex-1" />
|
||||
{source === 'icom' ? (
|
||||
{source === 'icom' || source === 'flex' ? (
|
||||
<span className="text-[11px] font-medium text-muted-foreground">
|
||||
{connected ? t('wkp.civReady') : t('wkp.civOffline')}
|
||||
{source === 'flex'
|
||||
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
|
||||
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
||||
</span>
|
||||
) : !connected ? (
|
||||
<>
|
||||
@@ -183,7 +185,7 @@ export function WinkeyerPanel({
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
|
||||
{t('wkp.cwText')}
|
||||
{source === 'winkeyer' && (
|
||||
{(source === 'winkeyer' || source === 'flex') && (
|
||||
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
|
||||
title={t('wkp.sendOnTypeHint')}>
|
||||
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
||||
|
||||
@@ -6,11 +6,32 @@
|
||||
// back to the DB copy and re-seed the cache.
|
||||
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
||||
|
||||
// The DB copy is ALREADY per-profile (the backend prefixes every ui.* key with
|
||||
// the active profile id). The localStorage cache, however, is one namespace for
|
||||
// the whole WebView, so without scoping it too a profile switch would keep
|
||||
// serving the previous profile's cached layout and the correct per-profile DB
|
||||
// value would never win. lsScope makes the cache per-profile as well; it's set
|
||||
// once the active profile is known and updated on every profile switch.
|
||||
let lsScope = '';
|
||||
|
||||
// setGridPrefsProfile scopes the localStorage cache to a profile. Call it before
|
||||
// the grids read their state (at startup) and again whenever the active profile
|
||||
// changes so each profile keeps its own column layout / widths.
|
||||
export function setGridPrefsProfile(id: number | string | null | undefined): void {
|
||||
lsScope = id == null || id === '' ? '' : `p${id}.`;
|
||||
}
|
||||
|
||||
// lsKey scopes ONLY the localStorage cache key. The DB key passed to
|
||||
// GetUIPref/SetUIPref is left untouched — the backend already scopes it.
|
||||
function lsKey(key: string): string {
|
||||
return lsScope + key;
|
||||
}
|
||||
|
||||
// loadLocal reads the cached column state synchronously (used in onGridReady
|
||||
// to apply instantly, before the async DB round-trip).
|
||||
export function loadLocal(key: string): any[] | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
const raw = localStorage.getItem(lsKey(key));
|
||||
const v = raw ? JSON.parse(raw) : null;
|
||||
return Array.isArray(v) ? v : null;
|
||||
} catch {
|
||||
@@ -29,15 +50,16 @@ export async function loadRemote(key: string): Promise<any[] | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// saveState write-throughs to both the cache and the DB (fire-and-forget).
|
||||
// saveState write-throughs to both the cache and the DB (fire-and-forget). Only
|
||||
// the cache key is profile-scoped; the DB key is scoped by the backend.
|
||||
export function saveState(key: string, state: any[]) {
|
||||
const json = JSON.stringify(state);
|
||||
try { localStorage.setItem(key, json); } catch { /* quota / private mode */ }
|
||||
try { localStorage.setItem(lsKey(key), json); } catch { /* quota / private mode */ }
|
||||
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
|
||||
}
|
||||
|
||||
// seedLocal writes a value into the cache without touching the DB (used after
|
||||
// hydrating the cache from the DB on a fresh machine).
|
||||
export function seedLocal(key: string, state: any[]) {
|
||||
try { localStorage.setItem(key, JSON.stringify(state)); } catch { /* ignore */ }
|
||||
try { localStorage.setItem(lsKey(key), JSON.stringify(state)); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -14,6 +14,15 @@ type Dict = Record<string, string>;
|
||||
const en: Dict = {
|
||||
// Menu bar
|
||||
'prop.title': 'Propagation', 'prop.geomag': 'Geomag', 'prop.refresh': 'Refresh space weather',
|
||||
'live.onAir': 'On air', 'live.offline': 'Offline',
|
||||
'live.onAirTip': 'On air — a QSO was logged in the last 5 minutes (published to the live status)',
|
||||
'live.offlineTip': 'Offline — no QSO logged in the last 5 minutes',
|
||||
'live.stationsTitle': 'Stations on air', 'live.stationsEmpty': 'No station reporting yet.', 'live.stationsHide': 'Hide',
|
||||
'upd.available': 'OpsLog v{v} available', 'upd.current': "You're on v{v}.",
|
||||
'upd.install': 'Update now', 'upd.download': 'Download', 'upd.later': 'Later',
|
||||
'upd.downloading': 'Downloading…', 'upd.installing': 'Installing…',
|
||||
'upd.restartNote': 'OpsLog will restart on the new version.',
|
||||
'upd.retry': 'Retry', 'upd.browser': 'Open page',
|
||||
'rate.title': 'QSO rate (QSOs/hour) — projected from the last 10 / 60 minutes',
|
||||
'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)',
|
||||
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
|
||||
@@ -235,7 +244,7 @@ const en: Dict = {
|
||||
'db.backend': 'Backend', 'db.configLocal': 'settings stay in the local SQLite file', 'db.connectUse': 'Connect & use',
|
||||
'db.savedRestart': 'Saved. Restart OpsLog to open this logbook:', 'db.restartNow': 'Restart OpsLog', 'db.restartHint': '(reopens automatically)',
|
||||
'db.current': 'Current database', 'db.customLoc': '(custom location)', 'db.default': '(default)', 'db.defaultLabel': 'Default:',
|
||||
'db.newDb': 'New database…', 'db.openExisting': 'Open existing…', 'db.saveCopy': 'Save a copy & switch…', 'db.resetDefault': 'Reset to default', 'db.quitNow': 'Quit now',
|
||||
'db.newDb': 'New database…', 'db.openExisting': 'Open existing…', 'db.saveCopy': 'Save a copy & switch…', 'db.rename': 'Rename…', 'db.renameTip': 'Rename this database (keeps all your config). The old file is removed on the next launch.', 'db.resetDefault': 'Reset to default', 'db.quitNow': 'Quit now',
|
||||
'db.host': 'Host', 'db.port': 'Port', 'db.database': 'Database', 'db.user': 'User', 'db.testCreate': 'Test & create database', 'db.testing': 'Testing…', 'db.connectedReady': 'Connected — database ready ✓', 'db.failed': 'Failed: ',
|
||||
'db.mysqlHint': 'Several OpsLog instances pointed at one MySQL database see each other\'s QSOs live (refreshed every 2 s). Test & create the database, then Save & switch logbook above to start logging there.',
|
||||
'db.dataLocation': 'Data location', 'db.currentDataDir': 'Current data directory',
|
||||
@@ -266,7 +275,7 @@ const en: Dict = {
|
||||
'adx.introPart1': 'Every ADIF 3.1.7 field not shown in the other tabs. Pick a field to add it, or type a custom/vendor tag (e.g. ', 'adx.introPart2': '). Stored losslessly and exported in the ', 'adx.fullMode': 'full', 'adx.introPart3': ' ADIF mode.', 'adx.addFieldPh': 'Add ADIF field…', 'adx.showDeprecated': 'Show deprecated', 'adx.noExtra': 'No extra ADIF fields. Use the picker above to add one.', 'adx.deprecated': 'deprecated', 'adx.intl': 'intl', 'adx.nonStandard': 'non-standard', 'adx.removeField': 'Remove field',
|
||||
// Hardware panels (winkeyer / dvk / antgenius / flex / icom)
|
||||
'wkp.sending': 'Sending…', 'wkp.connectedV': 'Connected (v{version})', 'wkp.disconnected': 'Disconnected', 'wkp.comPort': 'COM port', 'wkp.noPorts': 'No ports', 'wkp.refreshPorts': 'Refresh ports', 'wkp.connect': 'Connect', 'wkp.disconnect': 'Disconnect', 'wkp.hide': 'Hide / disable WinKeyer',
|
||||
'wkp.sourceHint': 'CW output: WK = WinKeyer hardware · CI-V = the Icom rig’s own keyer (over CAT, no extra hardware)', 'wkp.civReady': 'Icom CI-V ready', 'wkp.civOffline': 'Icom not connected (Settings → CAT)',
|
||||
'wkp.sourceHint': 'CW output: WK = WinKeyer hardware · CI-V = the Icom rig’s own keyer (over CAT, no extra hardware)', 'wkp.civReady': 'Icom CI-V ready', 'wkp.civOffline': 'Icom not connected (Settings → CAT)', 'wkp.cwxReady': 'Flex CWX ready', 'wkp.cwxOffline': 'Flex not connected (Settings → CAT)',
|
||||
'wkp.cwSpeed': 'CW speed (WPM)', 'wkp.faster': 'Faster', 'wkp.slower': 'Slower', 'wkp.cwText': 'CW text', 'wkp.sendOnTypeHint': 'Key each character live as you type (backspace removes un-sent chars)', 'wkp.sendOnType': 'send on type', 'wkp.phLive': 'Type — sent live…', 'wkp.phEnter': 'Type and press Enter to send…', 'wkp.clear': 'Clear', 'wkp.send': 'Send', 'wkp.abort': 'Abort (clear keyer buffer)', 'wkp.stop': 'Stop',
|
||||
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL",
|
||||
'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}',
|
||||
@@ -298,6 +307,10 @@ const en: Dict = {
|
||||
'awed.builtin': 'Built-in',
|
||||
'awed.awardsFolder': 'Awards folder',
|
||||
'awed.awardsFolderTip': 'Every award you create is saved here as JSON, automatically. To share one, send the file. To receive one, use Import.',
|
||||
'awed.catalogPublish': 'Publish to catalog…',
|
||||
'awed.catalogPublishTip': 'Export this award as a catalog file (with the version on the left), to paste over internal/award/catalog/<code>.json. A new release then ships it to the whole team — copies nobody edited auto-upgrade, edited ones are offered it.',
|
||||
'awed.catalogExportedTo': 'Catalog file written to:\n{path}\n\nPaste it over internal/award/catalog/<code>.json, then build and release.',
|
||||
'awed.catalogBadVersion': 'Enter a version number (1 or more).',
|
||||
'awed.builtinTip': 'Tick before shipping this award in the catalog. Left off, a “Reset to defaults” DELETES it on the user machine — even though you shipped it.',
|
||||
'awed.protectedFlag': 'Protected',
|
||||
'awed.protectedTip': 'Protected awards cannot be deleted from the editor.',
|
||||
@@ -324,6 +337,10 @@ const en: Dict = {
|
||||
|
||||
const fr: Dict = {
|
||||
'prop.title': 'Propagation', 'prop.geomag': 'Géomag', 'prop.refresh': 'Actualiser la météo spatiale',
|
||||
'live.onAir': 'On air', 'live.offline': 'Hors ligne',
|
||||
'live.onAirTip': "On air — un QSO a été loggé dans les 5 dernières minutes (publié dans le statut live)",
|
||||
'live.offlineTip': 'Hors ligne — aucun QSO loggé depuis 5 minutes',
|
||||
'live.stationsTitle': 'Stations on air', 'live.stationsEmpty': 'Aucune station ne reporte pour le moment.', 'live.stationsHide': 'Masquer',
|
||||
'rate.title': 'Rythme QSO (QSO/heure) — projeté sur les 10 / 60 dernières minutes',
|
||||
'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)',
|
||||
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
|
||||
@@ -527,7 +544,7 @@ const fr: Dict = {
|
||||
'db.backend': 'Base de données', 'db.configLocal': 'les réglages restent dans le fichier SQLite local', 'db.connectUse': 'Connecter et utiliser',
|
||||
'db.savedRestart': 'Enregistré. Redémarrez OpsLog pour ouvrir cette base :', 'db.restartNow': 'Redémarrer OpsLog', 'db.restartHint': '(réouverture automatique)',
|
||||
'db.current': 'Base actuelle', 'db.customLoc': '(emplacement personnalisé)', 'db.default': '(par défaut)', 'db.defaultLabel': 'Par défaut :',
|
||||
'db.newDb': 'Nouvelle base…', 'db.openExisting': 'Ouvrir existante…', 'db.saveCopy': 'Enregistrer une copie & basculer…', 'db.resetDefault': 'Réinitialiser par défaut', 'db.quitNow': 'Quitter maintenant',
|
||||
'db.newDb': 'Nouvelle base…', 'db.openExisting': 'Ouvrir existante…', 'db.saveCopy': 'Enregistrer une copie & basculer…', 'db.rename': 'Renommer…', 'db.renameTip': 'Renomme cette base (garde toute ta config). L’ancien fichier est supprimé au prochain lancement.', 'db.resetDefault': 'Réinitialiser par défaut', 'db.quitNow': 'Quitter maintenant',
|
||||
'db.host': 'Hôte', 'db.port': 'Port', 'db.database': 'Base', 'db.user': 'Utilisateur', 'db.testCreate': 'Tester & créer la base', 'db.testing': 'Test…', 'db.connectedReady': 'Connecté — base prête ✓', 'db.failed': 'Échec : ',
|
||||
'db.mysqlHint': "Plusieurs instances d'OpsLog pointées vers une même base MySQL voient leurs QSO en direct (rafraîchi toutes les 2 s). Teste & crée la base, puis Enregistrer & basculer le journal ci-dessus pour commencer à logger là-bas.",
|
||||
'db.dataLocation': 'Emplacement des données', 'db.currentDataDir': 'Dossier de données actuel',
|
||||
@@ -555,7 +572,7 @@ const fr: Dict = {
|
||||
'ctp.stopContest': 'Arrêter le contest', 'ctp.startContest': 'Démarrer le contest', 'ctp.activeHint': 'Le formulaire de saisie affiche Env/Reç et un badge DUPE ; les QSO sont marqués avec ce contest.', 'ctp.inactiveHint': 'Choisis un contest, règle la fenêtre, puis Démarrer.', 'ctp.scoreboard': 'Tableau des scores', 'ctp.estimate': 'estimation', 'ctp.qsos': 'QSO', 'ctp.mult': 'Mult', 'ctp.scoreEst': 'Score (est.)', 'ctp.last60': 'Dernières 60 min', 'ctp.band': 'Bande',
|
||||
'adx.introPart1': 'Tous les champs ADIF 3.1.7 non affichés dans les autres onglets. Choisis un champ à ajouter, ou saisis un tag personnalisé/constructeur (ex. ', 'adx.introPart2': '). Stocké sans perte et exporté en mode ADIF ', 'adx.fullMode': 'complet', 'adx.introPart3': '.', 'adx.addFieldPh': 'Ajouter un champ ADIF…', 'adx.showDeprecated': 'Afficher les obsolètes', 'adx.noExtra': 'Aucun champ ADIF supplémentaire. Utilise le sélecteur ci-dessus pour en ajouter un.', 'adx.deprecated': 'obsolète', 'adx.intl': 'intl', 'adx.nonStandard': 'non standard', 'adx.removeField': 'Supprimer le champ',
|
||||
'wkp.sending': 'Émission…', 'wkp.connectedV': 'Connecté (v{version})', 'wkp.disconnected': 'Déconnecté', 'wkp.comPort': 'Port COM', 'wkp.noPorts': 'Aucun port', 'wkp.refreshPorts': 'Rafraîchir les ports', 'wkp.connect': 'Connecter', 'wkp.disconnect': 'Déconnecter', 'wkp.hide': 'Masquer / désactiver le WinKeyer',
|
||||
'wkp.sourceHint': 'Sortie CW : WK = WinKeyer matériel · CI-V = le keyer interne de l’Icom (via CAT, sans matériel en plus)', 'wkp.civReady': 'Icom CI-V prêt', 'wkp.civOffline': 'Icom non connecté (Réglages → CAT)',
|
||||
'wkp.sourceHint': 'Sortie CW : WK = WinKeyer matériel · CI-V = le keyer interne de l’Icom (via CAT, sans matériel en plus)', 'wkp.civReady': 'Icom CI-V prêt', 'wkp.civOffline': 'Icom non connecté (Réglages → CAT)', 'wkp.cwxReady': 'Flex CWX prêt', 'wkp.cwxOffline': 'Flex non connecté (Réglages → CAT)',
|
||||
'wkp.cwSpeed': 'Vitesse CW (WPM)', 'wkp.faster': 'Plus rapide', 'wkp.slower': 'Plus lent', 'wkp.cwText': 'Texte CW', 'wkp.sendOnTypeHint': 'Manipule chaque caractère en direct à la frappe (retour arrière supprime les caractères non émis)', 'wkp.sendOnType': 'émission à la frappe', 'wkp.phLive': 'Tape — émis en direct…', 'wkp.phEnter': 'Tape et appuie sur Entrée pour émettre…', 'wkp.clear': 'Effacer', 'wkp.send': 'Émettre', 'wkp.abort': 'Interrompre (vider le tampon du manipulateur)', 'wkp.stop': 'Stop',
|
||||
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL",
|
||||
'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}',
|
||||
@@ -585,6 +602,10 @@ const fr: Dict = {
|
||||
'awed.builtin': 'Intégré',
|
||||
'awed.awardsFolder': 'Dossier awards',
|
||||
'awed.awardsFolderTip': 'Chaque diplôme que tu crées est enregistré ici en JSON, automatiquement. Pour en partager un : envoie le fichier. Pour en recevoir un : Importer.',
|
||||
'awed.catalogPublish': 'Publier au catalogue…',
|
||||
'awed.catalogPublishTip': "Exporte ce diplôme en fichier catalogue (avec la version à gauche), à coller par-dessus internal/award/catalog/<code>.json. Une nouvelle release le diffuse à toute l'équipe — les copies non modifiées s'upgradent seules, les modifiées se le voient proposer.",
|
||||
'awed.catalogExportedTo': 'Fichier catalogue écrit dans :\n{path}\n\nColle-le par-dessus internal/award/catalog/<code>.json, puis build et release.',
|
||||
'awed.catalogBadVersion': 'Entre un numéro de version (1 ou plus).',
|
||||
'awed.builtinTip': 'À cocher avant de livrer ce diplôme dans le catalogue. Sans ça, un « Réinitialiser par défaut » le SUPPRIME chez l’utilisateur — alors que tu l’as livré.',
|
||||
'awed.protectedFlag': 'Protégé',
|
||||
'awed.protectedTip': 'Un diplôme protégé ne peut pas être supprimé depuis l’éditeur.',
|
||||
|
||||
@@ -32,7 +32,10 @@ const PORTABLE_KEYS = [
|
||||
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||
'opslog.activeTab', // last selected tab
|
||||
'hamlog.awardColsShown', // which award columns are shown in the QSO grid
|
||||
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
||||
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
||||
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
||||
// through this global path would fight that per-profile scoping.
|
||||
];
|
||||
|
||||
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
||||
|
||||
@@ -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.20.1';
|
||||
export const APP_VERSION = '0.20.4';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+18
@@ -146,6 +146,8 @@ export function DismissAwardUpdate(arg1:string):Promise<void>;
|
||||
|
||||
export function DownloadAllReferenceLists():Promise<string>;
|
||||
|
||||
export function DownloadAndApplyUpdate(arg1:string):Promise<void>;
|
||||
|
||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
||||
|
||||
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
||||
@@ -166,6 +168,8 @@ export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):
|
||||
|
||||
export function ExportAward(arg1:string):Promise<string>;
|
||||
|
||||
export function ExportAwardForCatalog(arg1:string,arg2:number):Promise<string>;
|
||||
|
||||
export function ExportAwards():Promise<string>;
|
||||
|
||||
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
|
||||
@@ -190,6 +194,8 @@ export function FlexApplyBandAntenna(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexMox(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSendCW(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexSetAGCMode(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexSetAGCThreshold(arg1:number):Promise<void>;
|
||||
@@ -220,6 +226,8 @@ export function FlexSetCWSpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function FlexSetKeySpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetMic(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetMicProfile(arg1:string):Promise<void>;
|
||||
@@ -276,6 +284,8 @@ export function FlexSetXIT(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetXITFreq(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexStopCW():Promise<void>;
|
||||
|
||||
export function FlexTune(arg1:boolean):Promise<void>;
|
||||
|
||||
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
|
||||
@@ -352,6 +362,8 @@ export function GetIcomState():Promise<cat.IcomTXState>;
|
||||
|
||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||
|
||||
export function GetLiveStations():Promise<Array<main.LiveStation>>;
|
||||
|
||||
export function GetLiveStatusEnabled():Promise<boolean>;
|
||||
|
||||
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
||||
@@ -546,6 +558,8 @@ export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>
|
||||
|
||||
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
||||
|
||||
export function LiveLastQSOAgeSec():Promise<number>;
|
||||
|
||||
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
||||
|
||||
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||
@@ -660,6 +674,8 @@ export function QSOAudioRestart():Promise<boolean>;
|
||||
|
||||
export function QuitApp():Promise<void>;
|
||||
|
||||
export function RecomputeAllAwardRefs():Promise<number>;
|
||||
|
||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||
|
||||
export function RefreshSolar():Promise<void>;
|
||||
@@ -668,6 +684,8 @@ export function ReloadUDPIntegrations():Promise<Array<string>>;
|
||||
|
||||
export function RemovePassphrase(arg1:string):Promise<void>;
|
||||
|
||||
export function RenameDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
|
||||
|
||||
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
||||
|
||||
@@ -250,6 +250,10 @@ export function DownloadAllReferenceLists() {
|
||||
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
||||
}
|
||||
|
||||
export function DownloadAndApplyUpdate(arg1) {
|
||||
return window['go']['main']['App']['DownloadAndApplyUpdate'](arg1);
|
||||
}
|
||||
|
||||
export function DownloadClublogCty() {
|
||||
return window['go']['main']['App']['DownloadClublogCty']();
|
||||
}
|
||||
@@ -290,6 +294,10 @@ export function ExportAward(arg1) {
|
||||
return window['go']['main']['App']['ExportAward'](arg1);
|
||||
}
|
||||
|
||||
export function ExportAwardForCatalog(arg1, arg2) {
|
||||
return window['go']['main']['App']['ExportAwardForCatalog'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ExportAwards() {
|
||||
return window['go']['main']['App']['ExportAwards']();
|
||||
}
|
||||
@@ -338,6 +346,10 @@ export function FlexMox(arg1) {
|
||||
return window['go']['main']['App']['FlexMox'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSendCW(arg1) {
|
||||
return window['go']['main']['App']['FlexSendCW'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetAGCMode(arg1) {
|
||||
return window['go']['main']['App']['FlexSetAGCMode'](arg1);
|
||||
}
|
||||
@@ -398,6 +410,10 @@ export function FlexSetFilter(arg1, arg2) {
|
||||
return window['go']['main']['App']['FlexSetFilter'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function FlexSetKeySpeed(arg1) {
|
||||
return window['go']['main']['App']['FlexSetKeySpeed'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetMic(arg1) {
|
||||
return window['go']['main']['App']['FlexSetMic'](arg1);
|
||||
}
|
||||
@@ -510,6 +526,10 @@ export function FlexSetXITFreq(arg1) {
|
||||
return window['go']['main']['App']['FlexSetXITFreq'](arg1);
|
||||
}
|
||||
|
||||
export function FlexStopCW() {
|
||||
return window['go']['main']['App']['FlexStopCW']();
|
||||
}
|
||||
|
||||
export function FlexTune(arg1) {
|
||||
return window['go']['main']['App']['FlexTune'](arg1);
|
||||
}
|
||||
@@ -662,6 +682,10 @@ export function GetListsSettings() {
|
||||
return window['go']['main']['App']['GetListsSettings']();
|
||||
}
|
||||
|
||||
export function GetLiveStations() {
|
||||
return window['go']['main']['App']['GetLiveStations']();
|
||||
}
|
||||
|
||||
export function GetLiveStatusEnabled() {
|
||||
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
||||
}
|
||||
@@ -1050,6 +1074,10 @@ export function ListUDPIntegrations() {
|
||||
return window['go']['main']['App']['ListUDPIntegrations']();
|
||||
}
|
||||
|
||||
export function LiveLastQSOAgeSec() {
|
||||
return window['go']['main']['App']['LiveLastQSOAgeSec']();
|
||||
}
|
||||
|
||||
export function LoTWUserInfo(arg1) {
|
||||
return window['go']['main']['App']['LoTWUserInfo'](arg1);
|
||||
}
|
||||
@@ -1278,6 +1306,10 @@ export function QuitApp() {
|
||||
return window['go']['main']['App']['QuitApp']();
|
||||
}
|
||||
|
||||
export function RecomputeAllAwardRefs() {
|
||||
return window['go']['main']['App']['RecomputeAllAwardRefs']();
|
||||
}
|
||||
|
||||
export function RefreshCtyDat() {
|
||||
return window['go']['main']['App']['RefreshCtyDat']();
|
||||
}
|
||||
@@ -1294,6 +1326,10 @@ export function RemovePassphrase(arg1) {
|
||||
return window['go']['main']['App']['RemovePassphrase'](arg1);
|
||||
}
|
||||
|
||||
export function RenameDatabase(arg1) {
|
||||
return window['go']['main']['App']['RenameDatabase'](arg1);
|
||||
}
|
||||
|
||||
export function RenderEQSL(arg1, arg2) {
|
||||
return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -2054,6 +2054,32 @@ export namespace main {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class LiveStation {
|
||||
operator: string;
|
||||
station: string;
|
||||
freq_hz: number;
|
||||
band: string;
|
||||
mode: string;
|
||||
online: boolean;
|
||||
version: string;
|
||||
age_sec: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new LiveStation(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.operator = source["operator"];
|
||||
this.station = source["station"];
|
||||
this.freq_hz = source["freq_hz"];
|
||||
this.band = source["band"];
|
||||
this.mode = source["mode"];
|
||||
this.online = source["online"];
|
||||
this.version = source["version"];
|
||||
this.age_sec = source["age_sec"];
|
||||
}
|
||||
}
|
||||
export class LoTWUsersStatus {
|
||||
count: number;
|
||||
updated?: string;
|
||||
@@ -2381,6 +2407,8 @@ export namespace main {
|
||||
export class QSORate {
|
||||
last10: number;
|
||||
last60: number;
|
||||
team_last10: number;
|
||||
team_last60: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new QSORate(source);
|
||||
@@ -2390,6 +2418,8 @@ export namespace main {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.last10 = source["last10"];
|
||||
this.last60 = source["last60"];
|
||||
this.team_last10 = source["team_last10"];
|
||||
this.team_last60 = source["team_last60"];
|
||||
}
|
||||
}
|
||||
export class RelayAutoRule {
|
||||
@@ -2763,6 +2793,7 @@ export namespace main {
|
||||
latest: string;
|
||||
available: boolean;
|
||||
url: string;
|
||||
download_url: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new UpdateInfo(source);
|
||||
@@ -2774,6 +2805,7 @@ export namespace main {
|
||||
this.latest = source["latest"];
|
||||
this.available = source["available"];
|
||||
this.url = source["url"];
|
||||
this.download_url = source["download_url"];
|
||||
}
|
||||
}
|
||||
export class WKMacro {
|
||||
@@ -3616,6 +3648,7 @@ export namespace qso {
|
||||
my_arrl_sect?: string;
|
||||
my_vucc_grids?: string;
|
||||
extras?: Record<string, string>;
|
||||
award_refs?: string;
|
||||
// Go type: time
|
||||
created_at: any;
|
||||
// Go type: time
|
||||
@@ -3753,6 +3786,7 @@ export namespace qso {
|
||||
this.my_arrl_sect = source["my_arrl_sect"];
|
||||
this.my_vucc_grids = source["my_vucc_grids"];
|
||||
this.extras = source["extras"];
|
||||
this.award_refs = source["award_refs"];
|
||||
this.created_at = this.convertValues(source["created_at"], null);
|
||||
this.updated_at = this.convertValues(source["updated_at"], null);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,27 @@
|
||||
"name": "Départements Français Métropolitains",
|
||||
"valid": true,
|
||||
"protected": true,
|
||||
"ref_display": "name",
|
||||
"type": "QSOFIELDS",
|
||||
"field": "note",
|
||||
"pattern": "(?i)\\b(D\\d{1,2}[AB]?)\\b",
|
||||
"or_rules": [
|
||||
{
|
||||
"field": "address",
|
||||
"match_by": "code",
|
||||
"pattern": "\\b(\\d{2})\\d{3}\\b",
|
||||
"prefix": "D"
|
||||
},
|
||||
{
|
||||
"field": "qth",
|
||||
"match_by": "code",
|
||||
"pattern": "\\b(\\d{2})\\d{3}\\b",
|
||||
"prefix": "D"
|
||||
}
|
||||
],
|
||||
"dxcc_filter": [
|
||||
227
|
||||
227,
|
||||
214
|
||||
],
|
||||
"confirm": [
|
||||
"lotw",
|
||||
@@ -18,6 +34,777 @@
|
||||
"lotw"
|
||||
],
|
||||
"total": 96,
|
||||
"builtin": true
|
||||
}
|
||||
}
|
||||
"builtin": true,
|
||||
"version": 2
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"code": "D01",
|
||||
"name": "Ain",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D02",
|
||||
"name": "Aisne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D03",
|
||||
"name": "Allier",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D04",
|
||||
"name": "Alpes-de-Haute-Provence",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D05",
|
||||
"name": "Hautes-Alpes",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D06",
|
||||
"name": "Alpes-Maritimes",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D07",
|
||||
"name": "Ardèche",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D08",
|
||||
"name": "Ardennes",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D09",
|
||||
"name": "Ariège",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D10",
|
||||
"name": "Aube",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D11",
|
||||
"name": "Aude",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D12",
|
||||
"name": "Aveyron",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D13",
|
||||
"name": "Bouches-du-Rhône",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D14",
|
||||
"name": "Calvados",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D15",
|
||||
"name": "Cantal",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D16",
|
||||
"name": "Charente",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D17",
|
||||
"name": "Charente-Maritime",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D18",
|
||||
"name": "Cher",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D19",
|
||||
"name": "Corrèze",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D21",
|
||||
"name": "Côte-d'Or",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D22",
|
||||
"name": "Côtes-d'Armor",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D23",
|
||||
"name": "Creuse",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D24",
|
||||
"name": "Dordogne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D25",
|
||||
"name": "Doubs",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D26",
|
||||
"name": "Drôme",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D27",
|
||||
"name": "Eure",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D28",
|
||||
"name": "Eure-et-Loir",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D29",
|
||||
"name": "Finistère",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D2A",
|
||||
"name": "Corse-du-Sud",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D2B",
|
||||
"name": "Haute-Corse",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D30",
|
||||
"name": "Gard",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D31",
|
||||
"name": "Haute-Garonne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D32",
|
||||
"name": "Gers",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D33",
|
||||
"name": "Gironde",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D34",
|
||||
"name": "Hérault",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D35",
|
||||
"name": "Ille-et-Vilaine",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D36",
|
||||
"name": "Indre",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D37",
|
||||
"name": "Indre-et-Loire",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D38",
|
||||
"name": "Isère",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D39",
|
||||
"name": "Jura",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D40",
|
||||
"name": "Landes",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D41",
|
||||
"name": "Loir-et-Cher",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D42",
|
||||
"name": "Loire",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D43",
|
||||
"name": "Haute-Loire",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D44",
|
||||
"name": "Loire-Atlantique",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D45",
|
||||
"name": "Loiret",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D46",
|
||||
"name": "Lot",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D47",
|
||||
"name": "Lot-et-Garonne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D48",
|
||||
"name": "Lozère",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D49",
|
||||
"name": "Maine-et-Loire",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D50",
|
||||
"name": "Manche",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D51",
|
||||
"name": "Marne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D52",
|
||||
"name": "Haute-Marne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D53",
|
||||
"name": "Mayenne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D54",
|
||||
"name": "Meurthe-et-Moselle",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D55",
|
||||
"name": "Meuse",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D56",
|
||||
"name": "Morbihan",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D57",
|
||||
"name": "Moselle",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D58",
|
||||
"name": "Nièvre",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D59",
|
||||
"name": "Nord",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D60",
|
||||
"name": "Oise",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D61",
|
||||
"name": "Orne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D62",
|
||||
"name": "Pas-de-Calais",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D63",
|
||||
"name": "Puy-de-Dôme",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D64",
|
||||
"name": "Pyrénées-Atlantiques",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D65",
|
||||
"name": "Hautes-Pyrénées",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D66",
|
||||
"name": "Pyrénées-Orientales",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D67",
|
||||
"name": "Bas-Rhin",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D68",
|
||||
"name": "Haut-Rhin",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D69",
|
||||
"name": "Rhône",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D70",
|
||||
"name": "Haute-Saône",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D71",
|
||||
"name": "Saône-et-Loire",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D72",
|
||||
"name": "Sarthe",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D73",
|
||||
"name": "Savoie",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D74",
|
||||
"name": "Haute-Savoie",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D75",
|
||||
"name": "Paris",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D76",
|
||||
"name": "Seine-Maritime",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D77",
|
||||
"name": "Seine-et-Marne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D78",
|
||||
"name": "Yvelines",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D79",
|
||||
"name": "Deux-Sèvres",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D80",
|
||||
"name": "Somme",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D81",
|
||||
"name": "Tarn",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D82",
|
||||
"name": "Tarn-et-Garonne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D83",
|
||||
"name": "Var",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D84",
|
||||
"name": "Vaucluse",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D85",
|
||||
"name": "Vendée",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D86",
|
||||
"name": "Vienne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D87",
|
||||
"name": "Haute-Vienne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D88",
|
||||
"name": "Vosges",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D89",
|
||||
"name": "Yonne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D90",
|
||||
"name": "Territoire de Belfort",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D91",
|
||||
"name": "Essonne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D92",
|
||||
"name": "Hauts-de-Seine",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D93",
|
||||
"name": "Seine-Saint-Denis",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D94",
|
||||
"name": "Val-de-Marne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D95",
|
||||
"name": "Val-d'Oise",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -394,6 +394,11 @@ type FlexController interface {
|
||||
SetCWSpeed(int) error
|
||||
SetCWPitch(int) error
|
||||
SetCWBreakInDelay(int) error
|
||||
// CWX keyer — buffered CW keying via SmartSDR's CWX subsystem, so a Flex needs
|
||||
// no WinKeyer / SmartCAT. SendCW queues text (the radio buffers and keys it);
|
||||
// StopCW clears the buffer, aborting the send.
|
||||
SendCW(string) error
|
||||
StopCW() error
|
||||
SetCWSidetone(bool) error
|
||||
SetSidetoneLevel(int) error
|
||||
SetCWFilter(int) error
|
||||
|
||||
@@ -1581,6 +1581,26 @@ func (f *Flex) SetCWBreakInDelay(ms int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendCW queues text on the radio's CWX keyer. SmartSDR buffers and keys it, so
|
||||
// no WinKeyer / SmartCAT is needed — and because the radio owns the buffer,
|
||||
// characters fed while it's already sending append and key in order (the basis
|
||||
// for type-ahead). Quotes/backslashes are escaped for the "cwx send \"…\"" form.
|
||||
func (f *Flex) SendCW(text string) error {
|
||||
text = strings.TrimRight(text, "\r\n")
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
esc := strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(text)
|
||||
f.send(`cwx send "` + esc + `"`)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopCW clears the CWX buffer, aborting whatever is currently being keyed.
|
||||
func (f *Flex) StopCW() error {
|
||||
f.send("cwx clear")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Flex) SetCWSidetone(on bool) error {
|
||||
f.mu.Lock()
|
||||
f.tx.cwSidetone = on
|
||||
|
||||
@@ -136,6 +136,11 @@ type icomNet struct {
|
||||
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
|
||||
lastRx atomic.Int64
|
||||
|
||||
// dead is set when the rig explicitly tears the session down (control 0x05):
|
||||
// Alive() then returns false immediately so ReadState fails on the next poll and
|
||||
// the manager reconnects cleanly, instead of waiting out the 6 s lastRx timeout.
|
||||
dead atomic.Bool
|
||||
|
||||
// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
|
||||
// Torn down alongside the CI-V/control streams in Close.
|
||||
audio *icomAudio
|
||||
@@ -178,6 +183,9 @@ func (n *icomNet) markRx() { n.lastRx.Store(time.Now().UnixNano()) }
|
||||
// the radio — is gone. Independent of CI-V replies, so a powered-off rig still
|
||||
// reads as Alive and the session isn't torn down. Satisfies aliveTransport.
|
||||
func (n *icomNet) Alive() bool {
|
||||
if n.dead.Load() {
|
||||
return false // rig sent an explicit disconnect — reconnect now, don't wait
|
||||
}
|
||||
last := n.lastRx.Load()
|
||||
if last == 0 {
|
||||
return true // just connected, nothing received yet — give it a chance
|
||||
@@ -292,6 +300,7 @@ func (n *icomNet) ctrlPump() {
|
||||
n.ctrlResend(icnLE.Uint16(buf[6:]))
|
||||
}
|
||||
case 0x05: // rig-initiated disconnect — it dropped US
|
||||
n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
|
||||
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
|
||||
default:
|
||||
// Anything else on the control stream is (almost always) the rig's
|
||||
@@ -367,6 +376,7 @@ func (n *icomNet) civPump() {
|
||||
n.resend(icnLE.Uint16(buf[6:]))
|
||||
}
|
||||
case typ == 0x05: // rig-initiated disconnect — it dropped US
|
||||
n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
|
||||
debugLog.Printf("icom net: rig sent DISCONNECT on CI-V stream — session dropped by the rig")
|
||||
case typ == 0x00 && k > 0x15 && buf[0x10] == 0xc1: // CI-V data
|
||||
n.trackRxSeq(icnLE.Uint16(buf[6:])) // note gaps for retransmit
|
||||
|
||||
+15
-10
@@ -32,6 +32,7 @@ type OmniRig struct {
|
||||
omnirig *ole.IDispatch
|
||||
rig *ole.IDispatch
|
||||
lastSig string // last logged Split/VFO signature — only log on change
|
||||
rigType string // OmniRig's RigType string (the .ini title), e.g. "IC-7610"
|
||||
|
||||
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
|
||||
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
|
||||
@@ -80,7 +81,8 @@ func (o *OmniRig) Connect() error {
|
||||
o.rig = rigVar.ToIDispatch()
|
||||
|
||||
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
|
||||
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, rt.ToString())
|
||||
o.rigType = rt.ToString()
|
||||
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, o.rigType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -194,17 +196,20 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
||||
s.FreqHz, s.RxFreqHz = freqB, freqA
|
||||
}
|
||||
} else {
|
||||
// Simplex: the operating frequency is OmniRig's generic Freq (the active
|
||||
// VFO), like Log4OM. Fall back to the per-VFO value only if Freq is 0.
|
||||
// Simplex: read VFO A first, fall back to the generic Freq — exactly like
|
||||
// DXHunter/WSJT-X. PM_FREQA rigs (Yaesu, Kenwood) populate FreqA; some
|
||||
// Icoms (IC-9100 etc.) only populate the generic Freq. On the IC-7610
|
||||
// OmniRig's generic Freq reports VFO B (its Main/Sub model confuses the
|
||||
// stock ini), so keying off FreqA gives the operator the VFO they expect.
|
||||
s.Split = false
|
||||
s.RxFreqHz = 0
|
||||
s.FreqHz = freqMain
|
||||
if s.FreqHz == 0 {
|
||||
if s.Vfo == "B" || s.Vfo == "BB" {
|
||||
s.FreqHz = freqB
|
||||
} else {
|
||||
s.FreqHz = freqA
|
||||
}
|
||||
switch {
|
||||
case freqA != 0:
|
||||
s.FreqHz = freqA
|
||||
case freqMain != 0:
|
||||
s.FreqHz = freqMain
|
||||
default:
|
||||
s.FreqHz = freqB
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Materialised award references per QSO. As soon as a QSO matches an award
|
||||
-- (via the operator's award definitions) or a reference is set by hand, the
|
||||
-- resolved reference(s) are stored here as a compact JSON object keyed by award
|
||||
-- code, e.g. {"DDFM":"74","WAJA":"12"}. The grid's per-award columns then read
|
||||
-- straight from the row like any other column instead of recomputing the whole
|
||||
-- award engine on every page load — much faster, and easy to display anywhere
|
||||
-- (including the shared MySQL logbook). Kept in step by the app: written on log
|
||||
-- / edit / UDP-import and bulk-recomputed when an award definition or reference
|
||||
-- list changes. SQLite ADD COLUMN is metadata-only, fast even on large logbooks.
|
||||
ALTER TABLE qso ADD COLUMN award_refs TEXT;
|
||||
+24
-62
@@ -87,77 +87,39 @@ type Manager struct {
|
||||
mu sync.Mutex
|
||||
cfg ExternalServices
|
||||
rnd *rand.Rand
|
||||
|
||||
// uploadCh serialises immediate auto-uploads through a single worker. Firing a
|
||||
// goroutine per QSO meant a pileup / ADIF-import burst hit a service with dozens
|
||||
// of concurrent requests at once — Club Log's nginx answers that with 403, the
|
||||
// upload is counted as failed and the QSO stays at "R" despite the others going
|
||||
// through. One-at-a-time with a small gap keeps every upload under the limit.
|
||||
uploadCh chan uploadJob
|
||||
}
|
||||
|
||||
// uploadJob is one queued auto-upload.
|
||||
type uploadJob struct {
|
||||
svc Service
|
||||
id int64
|
||||
cfg ServiceConfig
|
||||
attempt int // 0 on first try; incremented on each retry
|
||||
}
|
||||
|
||||
// uploadGap spaces serialized uploads so a burst never trips a service's per-IP
|
||||
// rate limiter. maxUploadAttempts bounds retries of a transient failure.
|
||||
const (
|
||||
uploadGap = 250 * time.Millisecond
|
||||
maxUploadAttempts = 4
|
||||
)
|
||||
// maxUploadAttempts bounds retries of a transient upload failure.
|
||||
const maxUploadAttempts = 4
|
||||
|
||||
func NewManager(deps Deps) *Manager {
|
||||
if deps.Client == nil {
|
||||
deps.Client = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
m := &Manager{
|
||||
return &Manager{
|
||||
deps: deps,
|
||||
// Seeded from the clock; the delay only needs to be unpredictable
|
||||
// enough to spread bursts, not cryptographically random.
|
||||
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
uploadCh: make(chan uploadJob, 4096),
|
||||
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
}
|
||||
go m.uploadWorker()
|
||||
return m
|
||||
}
|
||||
|
||||
// uploadWorker drains the queue one upload at a time, spacing them so a burst of
|
||||
// freshly-logged QSOs can't hammer (and get 403'd by) a service. A transient
|
||||
// failure is re-queued with an exponential back-off, so a QSO that hit a
|
||||
// momentary rate-limit still ends up marked instead of stuck at "R".
|
||||
func (m *Manager) uploadWorker() {
|
||||
for job := range m.uploadCh {
|
||||
ok, retryable := m.upload(job.svc, job.id, job.cfg)
|
||||
if !ok && retryable && job.attempt+1 < maxUploadAttempts {
|
||||
next := job
|
||||
next.attempt++
|
||||
backoff := time.Duration(1<<uint(job.attempt)) * time.Second // 1s, 2s, 4s…
|
||||
m.logf("extsvc: %s upload of QSO %d will retry (attempt %d) in %s", job.svc, job.id, next.attempt+1, backoff)
|
||||
time.AfterFunc(backoff, func() { m.enqueueJob(next) })
|
||||
// attemptUpload uploads a QSO in its OWN goroutine and, on a TRANSIENT failure
|
||||
// (rate-limit / network), re-arms itself with exponential back-off. Each upload is
|
||||
// independent — never serialised through a shared worker, because a single slow
|
||||
// upload (LoTW signs via TQSL; a service on a 30 s timeout) would otherwise block
|
||||
// every following QSO's upload and strand them all at "R" (the regression that hit
|
||||
// the operator on the newest build while everyone on the old concurrent path was
|
||||
// fine).
|
||||
func (m *Manager) attemptUpload(svc Service, id int64, cfg ServiceConfig, attempt int) {
|
||||
go func() {
|
||||
ok, retryable := m.upload(svc, id, cfg)
|
||||
if !ok && retryable && attempt+1 < maxUploadAttempts {
|
||||
backoff := time.Duration(1<<uint(attempt)) * time.Second // 1s, 2s, 4s…
|
||||
m.logf("extsvc: %s upload of QSO %d will retry (attempt %d) in %s", svc, id, attempt+2, backoff)
|
||||
time.AfterFunc(backoff, func() { m.attemptUpload(svc, id, cfg, attempt+1) })
|
||||
}
|
||||
time.Sleep(uploadGap)
|
||||
}
|
||||
}
|
||||
|
||||
// enqueueUpload queues a first-attempt upload without blocking the logging path.
|
||||
func (m *Manager) enqueueUpload(svc Service, id int64, cfg ServiceConfig) {
|
||||
m.enqueueJob(uploadJob{svc: svc, id: id, cfg: cfg})
|
||||
}
|
||||
|
||||
// enqueueJob queues a job (possibly a retry). If the queue is somehow full (an
|
||||
// enormous burst), it falls back to a goroutine rather than dropping the upload —
|
||||
// a dropped upload would leave the QSO stuck at "R".
|
||||
func (m *Manager) enqueueJob(job uploadJob) {
|
||||
select {
|
||||
case m.uploadCh <- job:
|
||||
default:
|
||||
go m.upload(job.svc, job.id, job.cfg)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *Manager) logf(format string, args ...any) {
|
||||
@@ -234,17 +196,17 @@ func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
|
||||
m.scheduleUpload(svc, id, cfg)
|
||||
}
|
||||
|
||||
// scheduleUpload either queues the upload now (immediate) or arms a timer that
|
||||
// queues it later (delayed). Both go through the serialised worker so uploads are
|
||||
// never fired concurrently in a burst.
|
||||
// scheduleUpload uploads now (immediate) or after a random fuse (delayed). Each
|
||||
// upload runs in its own goroutine (attemptUpload) — never serialised — so a slow
|
||||
// one never holds up the rest.
|
||||
func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
|
||||
if cfg.UploadMode == ModeDelayed {
|
||||
d := m.delaySeconds()
|
||||
m.logf("extsvc: %s upload of QSO %d scheduled in %s", svc, id, d)
|
||||
time.AfterFunc(d, func() { m.enqueueUpload(svc, id, cfg) })
|
||||
time.AfterFunc(d, func() { m.attemptUpload(svc, id, cfg, 0) })
|
||||
return
|
||||
}
|
||||
m.enqueueUpload(svc, id, cfg)
|
||||
m.attemptUpload(svc, id, cfg, 0)
|
||||
}
|
||||
|
||||
// onCloseServices returns the services configured for on-close auto-upload,
|
||||
|
||||
+139
-18
@@ -197,6 +197,14 @@ type QSO struct {
|
||||
// ADIF field names (e.g. "MS_SHOWER"); values are the raw string content.
|
||||
Extras map[string]string `json:"extras,omitempty"`
|
||||
|
||||
// AwardRefs is the materialised award-reference JSON for this QSO — a compact
|
||||
// object keyed by award code, e.g. {"DDFM":"74","WAJA":"12"}. Derived (the app
|
||||
// computes it on log/edit and bulk-recomputes it when awards change) and stored
|
||||
// so the grid's award columns read straight from the row. Written ONLY via
|
||||
// SetAwardRefs, never through the normal insert/update column list, so an edit
|
||||
// that doesn't know about it can't clobber it.
|
||||
AwardRefs string `json:"award_refs,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -250,7 +258,10 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
|
||||
credit_granted, credit_submitted, my_arrl_sect, my_vucc_grids,
|
||||
extras_json`
|
||||
|
||||
const selectCols = `id, ` + columnList + `, created_at, updated_at`
|
||||
// award_refs is read here but is NOT part of columnList (the insert/update
|
||||
// write path) — it is a derived cache written only via SetAwardRefs, so a
|
||||
// normal QSO write can never clobber it.
|
||||
const selectCols = `id, ` + columnList + `, award_refs, created_at, updated_at`
|
||||
|
||||
// columnCount is derived from columnList at init so they can never drift.
|
||||
var columnCount = countColumns(columnList)
|
||||
@@ -781,6 +792,81 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// SetExtra merges ONE key into a QSO's extras JSON without rewriting the rest of
|
||||
// the row. A slow caller that only wants to stamp its own app field (e-mailing a
|
||||
// QSL card, saving a recording) must not do a full-row Update: the row it read may
|
||||
// be seconds stale, and writing it all back silently reverts any column another
|
||||
// action changed meanwhile — e.g. an auto-upload flipping clublog_qso_upload_status
|
||||
// from R to Y. Touching only `extras` makes that impossible. Empty value deletes
|
||||
// the key.
|
||||
func (r *Repo) SetExtra(ctx context.Context, id int64, key, value string) error {
|
||||
if id == 0 || strings.TrimSpace(key) == "" {
|
||||
return fmt.Errorf("missing id or key")
|
||||
}
|
||||
var extrasJSON sql.NullString
|
||||
if err := r.db.QueryRowContext(ctx, `SELECT extras_json FROM qso WHERE id = ?`, id).Scan(&extrasJSON); err != nil {
|
||||
return fmt.Errorf("load extras: %w", err)
|
||||
}
|
||||
m := decodeExtras(extrasJSON.String)
|
||||
if m == nil {
|
||||
m = map[string]string{}
|
||||
}
|
||||
if value == "" {
|
||||
delete(m, key)
|
||||
} else {
|
||||
m[key] = value
|
||||
}
|
||||
if _, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET extras_json = ?, updated_at = ? WHERE id = ?`,
|
||||
encodeExtras(m), db.NowISO(), id); err != nil {
|
||||
return fmt.Errorf("set extra %s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAwardRefs stores the materialised award-reference JSON for one QSO. Like
|
||||
// SetExtra it is a targeted single-column UPDATE — never a full-row write — so it
|
||||
// cannot clobber a field another action changed meanwhile. updated_at is left
|
||||
// UNTOUCHED on purpose: award_refs is a derived cache, and bumping updated_at
|
||||
// would masquerade as a real edit (re-triggering uploads / sync that watch it).
|
||||
func (r *Repo) SetAwardRefs(ctx context.Context, id int64, jsonStr string) error {
|
||||
if id == 0 {
|
||||
return fmt.Errorf("missing id")
|
||||
}
|
||||
if _, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET award_refs = ? WHERE id = ?`, jsonStr, id); err != nil {
|
||||
return fmt.Errorf("set award_refs: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAwardRefsBatch stores materialised award refs for many QSOs in a single
|
||||
// transaction — used by the bulk recompute so a large logbook on a remote MySQL
|
||||
// is one round-trip's worth of work, not N. Like SetAwardRefs it touches only the
|
||||
// award_refs column and leaves updated_at alone (derived cache). A nil/empty map
|
||||
// is a no-op.
|
||||
func (r *Repo) SetAwardRefsBatch(ctx context.Context, byID map[int64]string) error {
|
||||
if len(byID) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
stmt, err := tx.PrepareContext(ctx, `UPDATE qso SET award_refs = ? WHERE id = ?`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
for id, js := range byID {
|
||||
if _, err := stmt.ExecContext(ctx, js, id); err != nil {
|
||||
return fmt.Errorf("set award_refs %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
|
||||
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
||||
if q.ID == 0 {
|
||||
@@ -1863,38 +1949,71 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// RecentRate counts QSOs whose start time falls within each trailing window from
|
||||
// `now` — the live "QSO rate" meter shown in the header. It scans only the most
|
||||
// recently inserted rows (ORDER BY id DESC LIMIT), since any QSO in the last hour
|
||||
// was inserted recently; that keeps it cheap even on a large log. qso_date is the
|
||||
// repo's text column, parsed with parseTimeLoose (backend-format agnostic).
|
||||
func (r *Repo) RecentRate(ctx context.Context, now time.Time, windows ...time.Duration) ([]int, error) {
|
||||
counts := make([]int, len(windows))
|
||||
// 400 rows covers a full hour even at a blistering contest rate (>300/h); any
|
||||
// QSO inside the trailing windows is among the most recently inserted.
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT qso_date FROM qso ORDER BY id DESC LIMIT 400`)
|
||||
// LastQSOTime returns the start time of the most recently LOGGED QSO for an
|
||||
// operator (highest id wins) — used to seed the live "on air" state at launch so an
|
||||
// operator who just worked someone before (re)starting OpsLog shows online right
|
||||
// away instead of waiting for their next QSO. Empty operator matches every QSO.
|
||||
func (r *Repo) LastQSOTime(ctx context.Context, operator string) (time.Time, bool) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 400`)
|
||||
if err != nil {
|
||||
return counts, err
|
||||
return time.Time{}, false
|
||||
}
|
||||
defer rows.Close()
|
||||
opFilter := strings.ToUpper(strings.TrimSpace(operator))
|
||||
for rows.Next() {
|
||||
var oper, dateStr sql.NullString
|
||||
if err := rows.Scan(&oper, &dateStr); err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if strings.ToUpper(strings.TrimSpace(oper.String)) != opFilter {
|
||||
continue
|
||||
}
|
||||
if t := parseTimeLoose(dateStr.String).UTC(); !t.IsZero() {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// RecentRateBreakdown counts, in ONE pass over the most recent rows, QSOs whose
|
||||
// start time falls within each trailing window from `now` — for a specific operator
|
||||
// (their own rate, `op`) AND for ALL operators combined (the team/station rate,
|
||||
// `all`). The header rate meter shows both. It scans only recently inserted rows
|
||||
// (ORDER BY id DESC LIMIT), since any QSO in the last hour was inserted recently, so
|
||||
// it stays cheap on a large log. qso_date is parsed with parseTimeLoose (backend-
|
||||
// format agnostic).
|
||||
func (r *Repo) RecentRateBreakdown(ctx context.Context, now time.Time, operator string, windows ...time.Duration) (op []int, all []int, err error) {
|
||||
op = make([]int, len(windows))
|
||||
all = make([]int, len(windows))
|
||||
// 2000 rows covers a full hour even in a busy multi-op run.
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 2000`)
|
||||
if err != nil {
|
||||
return op, all, err
|
||||
}
|
||||
defer rows.Close()
|
||||
now = now.UTC()
|
||||
opFilter := strings.ToUpper(strings.TrimSpace(operator))
|
||||
for rows.Next() {
|
||||
var dateStr sql.NullString
|
||||
if err := rows.Scan(&dateStr); err != nil {
|
||||
return counts, err
|
||||
var oper, dateStr sql.NullString
|
||||
if err := rows.Scan(&oper, &dateStr); err != nil {
|
||||
return op, all, err
|
||||
}
|
||||
t := parseTimeLoose(dateStr.String).UTC()
|
||||
if t.IsZero() || t.After(now) {
|
||||
continue
|
||||
}
|
||||
mine := strings.ToUpper(strings.TrimSpace(oper.String)) == opFilter
|
||||
age := now.Sub(t)
|
||||
for i, w := range windows {
|
||||
if age <= w {
|
||||
counts[i]++
|
||||
all[i]++
|
||||
if mine {
|
||||
op[i]++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts, rows.Err()
|
||||
return op, all, rows.Err()
|
||||
}
|
||||
|
||||
// ExistingDedupeKeys returns a set of every QSO key currently in the DB,
|
||||
@@ -2309,6 +2428,7 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
creditGranted, creditSubmitted sql.NullString
|
||||
myARRLSect, myVUCCGrids sql.NullString
|
||||
extrasJSON sql.NullString
|
||||
awardRefs sql.NullString
|
||||
createdStr, updatedStr string
|
||||
)
|
||||
if err := s.Scan(
|
||||
@@ -2336,7 +2456,7 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
&skcc, &fists, &tenTen, &contactedOp, &eqCall, &pfx, &myName, &class,
|
||||
&darcDOK, &myDarcDOK, ®ion, &silentKey, &swl, &qsoComplete, &qsoRandom,
|
||||
&creditGranted, &creditSubmitted, &myARRLSect, &myVUCCGrids,
|
||||
&extrasJSON, &createdStr, &updatedStr,
|
||||
&extrasJSON, &awardRefs, &createdStr, &updatedStr,
|
||||
); err != nil {
|
||||
return QSO{}, fmt.Errorf("scan qso: %w", err)
|
||||
}
|
||||
@@ -2533,6 +2653,7 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
q.MyARRLSect = myARRLSect.String
|
||||
q.MyVUCCGrids = myVUCCGrids.String
|
||||
q.Extras = decodeExtras(extrasJSON.String)
|
||||
q.AwardRefs = awardRefs.String
|
||||
return q, nil
|
||||
}
|
||||
|
||||
|
||||
+164
-8
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -19,6 +20,23 @@ import (
|
||||
|
||||
const keyLiveStatusEnabled = "livestatus.enabled"
|
||||
|
||||
// liveOnlineWindow is how long after the last logged contact an operator still
|
||||
// counts as "on air". Leaving the log open without working anyone flips them
|
||||
// offline once this elapses; logging a new QSO flips them back online.
|
||||
const liveOnlineWindow = 5 * time.Minute
|
||||
|
||||
// noteLiveQSO records that this operator just logged a new contact and pushes the
|
||||
// live status right away, so they flip back to online the instant they work
|
||||
// someone. Called from the logging paths (manual entry, UDP auto-log).
|
||||
func (a *App) noteLiveQSO() {
|
||||
a.liveActMu.Lock()
|
||||
a.liveLastQSOAt = time.Now()
|
||||
a.liveActMu.Unlock()
|
||||
if a.liveStatusActive() {
|
||||
go a.publishLiveStatus()
|
||||
}
|
||||
}
|
||||
|
||||
// GetLiveStatusEnabled reports whether this operator publishes live status.
|
||||
func (a *App) GetLiveStatusEnabled() bool {
|
||||
if a.settings == nil {
|
||||
@@ -50,11 +68,61 @@ func (a *App) SetLiveStatusEnabled(on bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedLiveLastQSO primes liveLastQSOAt from the DB at launch, so an operator who
|
||||
// worked someone shortly before (re)starting OpsLog is shown "on air" right away
|
||||
// instead of offline until their next QSO.
|
||||
func (a *App) seedLiveLastQSO() {
|
||||
if a.qso == nil {
|
||||
return
|
||||
}
|
||||
op, _ := a.liveStatusOperator()
|
||||
if op == "" {
|
||||
return
|
||||
}
|
||||
if t, ok := a.qso.LastQSOTime(a.ctx, op); ok {
|
||||
a.liveActMu.Lock()
|
||||
if a.liveLastQSOAt.IsZero() {
|
||||
a.liveLastQSOAt = t
|
||||
}
|
||||
a.liveActMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// liveLastQSOTime is the authoritative "last contact" instant for this operator:
|
||||
// the most recent of the in-memory stamp (this session's local logs, updated
|
||||
// instantly) AND the DB (a contact that arrived via the SHARED logbook from another
|
||||
// station, or one logged before launch). Used by both the published status and the
|
||||
// UI badge so on-air/offline is right in every multi-op case.
|
||||
func (a *App) liveLastQSOTime() time.Time {
|
||||
a.liveActMu.Lock()
|
||||
last := a.liveLastQSOAt
|
||||
a.liveActMu.Unlock()
|
||||
if a.qso != nil {
|
||||
if op, _ := a.liveStatusOperator(); op != "" {
|
||||
if t, ok := a.qso.LastQSOTime(a.ctx, op); ok && t.After(last) {
|
||||
last = t
|
||||
}
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
// LiveLastQSOAgeSec returns seconds since this operator's last logged QSO, or -1 if
|
||||
// none is known — the UI polls it for the "on air" badge.
|
||||
func (a *App) LiveLastQSOAgeSec() int {
|
||||
last := a.liveLastQSOTime()
|
||||
if last.IsZero() {
|
||||
return -1
|
||||
}
|
||||
return int(time.Since(last).Seconds())
|
||||
}
|
||||
|
||||
// liveStatusLoop heartbeats the current activity while enabled. Started once at
|
||||
// startup; cheap no-op when disabled or not on MySQL.
|
||||
func (a *App) liveStatusLoop() {
|
||||
defer func() { _ = recover() }() // never crash the app from here
|
||||
applog.Printf("livestatus: loop started")
|
||||
a.seedLiveLastQSO() // so online/offline is right at launch, not only after the next QSO
|
||||
a.publishLiveStatus() // attempt immediately, don't wait the first tick
|
||||
t := time.NewTicker(15 * time.Second)
|
||||
defer t.Stop()
|
||||
@@ -133,33 +201,121 @@ func (a *App) publishLiveStatus() {
|
||||
mode = a.liveMode
|
||||
}
|
||||
a.liveActMu.Unlock()
|
||||
lastQSO := a.liveLastQSOTime() // authoritative (in-memory OR shared DB)
|
||||
// On air = a new contact was logged within the window. An operator who leaves
|
||||
// the log open but stops working goes offline after `liveOnlineWindow`; the next
|
||||
// QSO puts them back on. never-logged (zero time) → offline.
|
||||
online := !lastQSO.IsZero() && time.Since(lastQSO) < liveOnlineWindow
|
||||
if err := a.ensureLiveStatusTable(); err != nil {
|
||||
applog.Printf("livestatus: CREATE TABLE failed: %v", err)
|
||||
return
|
||||
}
|
||||
// Offline → REMOVE the row entirely, not just flip a flag: a status page that
|
||||
// lists the present rows (the common case, keyed on updated_at) then shows the
|
||||
// operator as gone without having to read the online column. The row reappears
|
||||
// on the next QSO. This is the whole point — no one shows on air when they're not.
|
||||
if !online {
|
||||
if _, err := a.logDb.ExecContext(a.ctx, "DELETE FROM live_status WHERE operator=?", op); err != nil {
|
||||
applog.Printf("livestatus: offline DELETE failed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
lastQSOArg := lastQSO.UTC()
|
||||
_, err := a.logDb.ExecContext(a.ctx,
|
||||
"INSERT INTO live_status (operator, station, freq_hz, band, mode, updated_at) "+
|
||||
"VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
|
||||
"INSERT INTO live_status (operator, station, freq_hz, band, mode, online, version, last_qso_at, updated_at) "+
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
|
||||
"ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+
|
||||
"band=VALUES(band), mode=VALUES(mode), updated_at=UTC_TIMESTAMP()",
|
||||
op, station, freqHz, band, mode)
|
||||
"band=VALUES(band), mode=VALUES(mode), online=VALUES(online), version=VALUES(version), "+
|
||||
"last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()",
|
||||
op, station, freqHz, band, mode, 1, appVersion, lastQSOArg)
|
||||
if err != nil {
|
||||
applog.Printf("livestatus: INSERT failed: %v", err)
|
||||
return
|
||||
}
|
||||
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s", op, station, freqHz, band, mode)
|
||||
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s ON AIR", op, station, freqHz, band, mode)
|
||||
}
|
||||
|
||||
// LiveStation is one operator's live status for the multi-op "who's on air" widget.
|
||||
type LiveStation struct {
|
||||
Operator string `json:"operator"`
|
||||
Station string `json:"station"`
|
||||
FreqHz int64 `json:"freq_hz"`
|
||||
Band string `json:"band"`
|
||||
Mode string `json:"mode"`
|
||||
Online bool `json:"online"` // logged a QSO in the last 5 min
|
||||
Version string `json:"version"` // that operator's OpsLog version
|
||||
AgeSec int `json:"age_sec"` // seconds since their last heartbeat (stale = OpsLog closed)
|
||||
}
|
||||
|
||||
// GetLiveStations returns every operator's live status from the shared MySQL
|
||||
// logbook (empty on a local SQLite logbook). Rows whose heartbeat is very stale
|
||||
// (OpsLog closed without clearing its row) are dropped. Online stations first.
|
||||
func (a *App) GetLiveStations() []LiveStation {
|
||||
if a.logDb == nil || a.dbBackend != "mysql" {
|
||||
return nil
|
||||
}
|
||||
if err := a.ensureLiveStatusTable(); err != nil {
|
||||
return nil
|
||||
}
|
||||
rows, err := a.logDb.QueryContext(a.ctx,
|
||||
"SELECT operator, COALESCE(station,''), COALESCE(freq_hz,0), COALESCE(band,''), "+
|
||||
"COALESCE(mode,''), COALESCE(online,0), COALESCE(version,''), "+
|
||||
"TIMESTAMPDIFF(SECOND, updated_at, UTC_TIMESTAMP()) "+
|
||||
"FROM live_status ORDER BY online DESC, operator")
|
||||
if err != nil {
|
||||
applog.Printf("livestatus: list failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []LiveStation{}
|
||||
for rows.Next() {
|
||||
var s LiveStation
|
||||
var online int
|
||||
var age sql.NullInt64
|
||||
if err := rows.Scan(&s.Operator, &s.Station, &s.FreqHz, &s.Band, &s.Mode, &online, &s.Version, &age); err != nil {
|
||||
continue
|
||||
}
|
||||
// Drop rows from an OpsLog that hasn't heartbeated in a while (closed): the
|
||||
// heartbeat is every 15 s, so > 3 min means it's gone.
|
||||
if age.Valid && age.Int64 > 180 {
|
||||
continue
|
||||
}
|
||||
s.Online = online == 1
|
||||
if age.Valid {
|
||||
s.AgeSec = int(age.Int64)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) ensureLiveStatusTable() error {
|
||||
_, err := a.logDb.ExecContext(a.ctx,
|
||||
if _, err := a.logDb.ExecContext(a.ctx,
|
||||
"CREATE TABLE IF NOT EXISTS live_status ("+
|
||||
"operator VARCHAR(32) PRIMARY KEY, "+
|
||||
"station VARCHAR(32), "+
|
||||
"freq_hz BIGINT, "+
|
||||
"band VARCHAR(16), "+
|
||||
"mode VARCHAR(16), "+
|
||||
"updated_at DATETIME)")
|
||||
return err
|
||||
"online TINYINT DEFAULT 0, "+
|
||||
"version VARCHAR(32), "+
|
||||
"last_qso_at DATETIME NULL, "+
|
||||
"updated_at DATETIME)"); err != nil {
|
||||
return err
|
||||
}
|
||||
// Add newer columns to a table created by an older build. MySQL has no portable
|
||||
// "ADD COLUMN IF NOT EXISTS", so just run the ALTERs and ignore the duplicate-
|
||||
// column error when they already exist.
|
||||
for _, ddl := range []string{
|
||||
"ALTER TABLE live_status ADD COLUMN online TINYINT DEFAULT 0",
|
||||
"ALTER TABLE live_status ADD COLUMN version VARCHAR(32)",
|
||||
"ALTER TABLE live_status ADD COLUMN last_qso_at DATETIME NULL",
|
||||
} {
|
||||
if _, err := a.logDb.ExecContext(a.ctx, ddl); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
|
||||
applog.Printf("livestatus: %q: %v", ddl, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearLiveStatus removes this operator's row (on disable / shutdown).
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"embed"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
@@ -35,15 +36,53 @@ func profileArg(args []string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// hasFlag reports whether flag is present in args.
|
||||
func hasFlag(args []string, flag string) bool {
|
||||
for _, a := range args {
|
||||
if a == flag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// acquireInstance grabs the single-instance mutex. On a normal launch it's a plain
|
||||
// try (fail → another OpsLog is running, so exit). On a --post-update relaunch the
|
||||
// previous instance may still be shutting down and holding the mutex, so retry for
|
||||
// a few seconds until it frees.
|
||||
func acquireInstance(postUpdate bool) bool {
|
||||
if acquireSingleInstance() {
|
||||
return true
|
||||
}
|
||||
if !postUpdate {
|
||||
return false
|
||||
}
|
||||
deadline := time.Now().Add(20 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if acquireSingleInstance() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Single-instance guard: if OpsLog is already running, focus that window and
|
||||
// exit instead of spawning a duplicate. A second process would open its own
|
||||
// CAT (FlexRadio) connection and Ultrabeam follow loop, and the two would
|
||||
// fight over the rig/antenna frequency — the cause of "the antenna re-tunes on
|
||||
// its own" when a windowless zombie instance was left running.
|
||||
if !acquireSingleInstance() {
|
||||
// A --post-update relaunch (from the auto-updater) may start while the previous
|
||||
// instance is still exiting and holding the single-instance mutex — wait for it
|
||||
// to free instead of bailing out. Then clear the old exe it left behind.
|
||||
postUpdate := hasFlag(os.Args[1:], "--post-update")
|
||||
if !acquireInstance(postUpdate) {
|
||||
return
|
||||
}
|
||||
if postUpdate {
|
||||
cleanupOldUpdateBinary()
|
||||
}
|
||||
|
||||
// Create an instance of the app structure
|
||||
app := NewApp()
|
||||
|
||||
+61
-10
@@ -96,8 +96,23 @@ func bandInList(bands []string, band string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// relayAction is one relay's computed desired state for this evaluation.
|
||||
type relayAction struct {
|
||||
dev string
|
||||
relay int
|
||||
want bool
|
||||
}
|
||||
|
||||
// applyRelayAuto evaluates every rule against the current frequency/band and
|
||||
// switches only the relays whose desired state changed since the last apply.
|
||||
// switches only the relays that are NOT already in the wanted position. Two things
|
||||
// it deliberately does NOT do, which used to make the relay clunk on every
|
||||
// launch/close:
|
||||
// - Never acts on an UNKNOWN frequency/band. When the CAT disconnects (app close)
|
||||
// the frequency drops to 0; reading that as "out of range" and switching the
|
||||
// relay off — then back on at the next launch — was the whole bug.
|
||||
// - Never commands a relay already in the right position: on the first evaluation
|
||||
// after launch/save it reads the boards' LIVE state, so a relay that's already
|
||||
// correct is left untouched instead of being re-sent.
|
||||
func (a *App) applyRelayAuto(freqHz int64, band string) {
|
||||
a.relayAutoMu.Lock()
|
||||
defer a.relayAutoMu.Unlock()
|
||||
@@ -110,8 +125,11 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
|
||||
a.relayAutoLast = map[string]bool{}
|
||||
}
|
||||
khz := float64(freqHz) / 1000.0
|
||||
band = strings.TrimSpace(band)
|
||||
|
||||
changed := false
|
||||
// Compute desired states, skipping rules whose input is unknown right now.
|
||||
var acts []relayAction
|
||||
needLive := false
|
||||
for _, r := range cfg.Rules {
|
||||
if r.Relay < 1 {
|
||||
continue
|
||||
@@ -119,8 +137,11 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
|
||||
var want bool
|
||||
switch r.Mode {
|
||||
case "freq":
|
||||
if freqHz <= 0 {
|
||||
continue // no known frequency (CAT off/closing) → leave the relay as-is
|
||||
}
|
||||
if r.FreqLoKHz <= 0 && r.FreqHiKHz <= 0 {
|
||||
continue // unconfigured range → leave the relay alone
|
||||
continue // unconfigured range
|
||||
}
|
||||
lo, hi := r.FreqLoKHz, r.FreqHiKHz
|
||||
if hi < lo {
|
||||
@@ -128,6 +149,9 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
|
||||
}
|
||||
want = khz >= lo && khz <= hi
|
||||
case "band":
|
||||
if band == "" {
|
||||
continue // no known band → leave the relay as-is
|
||||
}
|
||||
if len(r.Bands) == 0 {
|
||||
continue
|
||||
}
|
||||
@@ -135,16 +159,43 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
|
||||
default:
|
||||
continue // "off"/empty → not managed
|
||||
}
|
||||
|
||||
key := relayAutoKey(r.DeviceID, r.Relay)
|
||||
if last, ok := a.relayAutoLast[key]; ok && last == want {
|
||||
continue // no change → don't hammer the board
|
||||
acts = append(acts, relayAction{r.DeviceID, r.Relay, want})
|
||||
if _, ok := a.relayAutoLast[relayAutoKey(r.DeviceID, r.Relay)]; !ok {
|
||||
needLive = true
|
||||
}
|
||||
if err := a.StationSetRelay(r.DeviceID, r.Relay, want); err != nil {
|
||||
applog.Printf("relay auto: set %s relay %d = %v failed: %v", r.DeviceID, r.Relay, want, err)
|
||||
}
|
||||
if len(acts) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// First evaluation after launch/save: read the boards' LIVE relay states once
|
||||
// so we don't re-command a relay that's already in the wanted position.
|
||||
var live map[string]bool
|
||||
if needLive {
|
||||
live = map[string]bool{}
|
||||
for _, ds := range a.GetStationStatus() {
|
||||
for _, rl := range ds.Relays {
|
||||
live[relayAutoKey(ds.ID, rl.Number)] = rl.On
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
changed := false
|
||||
for _, ac := range acts {
|
||||
key := relayAutoKey(ac.dev, ac.relay)
|
||||
cur, known := a.relayAutoLast[key]
|
||||
if !known && live != nil {
|
||||
cur, known = live[key]
|
||||
}
|
||||
if known && cur == ac.want {
|
||||
a.relayAutoLast[key] = ac.want // already in position — record it, don't switch
|
||||
continue
|
||||
}
|
||||
if err := a.StationSetRelay(ac.dev, ac.relay, ac.want); err != nil {
|
||||
applog.Printf("relay auto: set %s relay %d = %v failed: %v", ac.dev, ac.relay, ac.want, err)
|
||||
continue // don't cache a failed write — retry next change
|
||||
}
|
||||
a.relayAutoLast[key] = want
|
||||
a.relayAutoLast[key] = ac.want
|
||||
changed = true
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.20.1"
|
||||
appVersion = "0.20.4"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
@@ -14,12 +23,13 @@ import (
|
||||
// build (the exe lives there; source stays on Gitea). Adjust the repo if needed.
|
||||
const updateCheckURL = "https://api.github.com/repos/GregTroar/OpsLog/releases/latest"
|
||||
|
||||
// UpdateInfo is the result of the startup version check.
|
||||
// UpdateInfo is the result of the version check.
|
||||
type UpdateInfo struct {
|
||||
Current string `json:"current"` // this build's version (appVersion)
|
||||
Latest string `json:"latest"` // newest published release, "" if unknown
|
||||
Available bool `json:"available"` // Latest > Current
|
||||
URL string `json:"url"` // release page to open
|
||||
Current string `json:"current"` // this build's version (appVersion)
|
||||
Latest string `json:"latest"` // newest published release, "" if unknown
|
||||
Available bool `json:"available"` // Latest > Current
|
||||
URL string `json:"url"` // release page to open (manual fallback)
|
||||
DownloadURL string `json:"download_url"` // the .exe/.zip asset to auto-download, "" if none
|
||||
}
|
||||
|
||||
// CheckForUpdate asks GitHub for the latest release and compares it to this
|
||||
@@ -45,6 +55,10 @@ func (a *App) CheckForUpdate() UpdateInfo {
|
||||
var r struct {
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"browser_download_url"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
||||
return out
|
||||
@@ -52,8 +66,25 @@ func (a *App) CheckForUpdate() UpdateInfo {
|
||||
out.Latest = strings.TrimPrefix(strings.TrimSpace(r.TagName), "v")
|
||||
out.URL = r.HTMLURL
|
||||
out.Available = versionLess(appVersion, out.Latest)
|
||||
// Pick the auto-download asset: a bare Windows .exe (portable build) first,
|
||||
// else a .zip we can unpack. The frontend hands this straight to
|
||||
// DownloadAndApplyUpdate for a one-click in-app update.
|
||||
for _, as := range r.Assets {
|
||||
if strings.HasSuffix(strings.ToLower(as.Name), ".exe") {
|
||||
out.DownloadURL = as.URL
|
||||
break
|
||||
}
|
||||
}
|
||||
if out.DownloadURL == "" {
|
||||
for _, as := range r.Assets {
|
||||
if strings.HasSuffix(strings.ToLower(as.Name), ".zip") {
|
||||
out.DownloadURL = as.URL
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.Available {
|
||||
applog.Printf("update: newer version available — current=%s latest=%s", appVersion, out.Latest)
|
||||
applog.Printf("update: newer version available — current=%s latest=%s asset=%q", appVersion, out.Latest, out.DownloadURL)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -82,6 +113,176 @@ func versionLess(a, b string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// DownloadAndApplyUpdate downloads the new build, swaps it in for the running exe
|
||||
// and relaunches — the fully in-app update. Progress is emitted on "update:progress"
|
||||
// (0-100) so the UI can show a bar. On success it never returns normally: it starts
|
||||
// the new process and quits this one.
|
||||
func (a *App) DownloadAndApplyUpdate(url string) error {
|
||||
if strings.TrimSpace(url) == "" {
|
||||
return fmt.Errorf("no download URL")
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("locate executable: %w", err)
|
||||
}
|
||||
dir := filepath.Dir(exe)
|
||||
|
||||
// Download to a temp file next to the exe (same volume, so the rename-swap is
|
||||
// atomic and can't fail across drives).
|
||||
tmp := filepath.Join(dir, ".opslog-update.download")
|
||||
_ = os.Remove(tmp)
|
||||
if err := a.downloadWithProgress(url, tmp); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("download: %w", err)
|
||||
}
|
||||
|
||||
// The asset is either the bare exe or a zip holding it. Resolve to the new exe.
|
||||
newExe := tmp
|
||||
if strings.HasSuffix(strings.ToLower(url), ".zip") {
|
||||
extracted, xerr := extractExeFromZip(tmp, dir)
|
||||
_ = os.Remove(tmp)
|
||||
if xerr != nil {
|
||||
return fmt.Errorf("unpack: %w", xerr)
|
||||
}
|
||||
newExe = extracted
|
||||
}
|
||||
|
||||
// Swap: rename the running exe out of the way (Windows allows renaming a
|
||||
// running image), move the new one into its place, then relaunch. Roll back if
|
||||
// the second rename fails so we never end up with no exe.
|
||||
oldExe := exe + ".old"
|
||||
_ = os.Remove(oldExe)
|
||||
if err := os.Rename(exe, oldExe); err != nil {
|
||||
_ = os.Remove(newExe)
|
||||
return fmt.Errorf("stage current exe: %w", err)
|
||||
}
|
||||
if err := os.Rename(newExe, exe); err != nil {
|
||||
_ = os.Rename(oldExe, exe) // roll back
|
||||
return fmt.Errorf("install new exe: %w", err)
|
||||
}
|
||||
// Clear the "downloaded from the internet" mark (NTFS Zone.Identifier stream).
|
||||
// Otherwise Windows SmartScreen wants to prompt "are you sure you want to open
|
||||
// this?" — but since we launch the exe programmatically that prompt never shows,
|
||||
// and the launch is silently blocked. This is exactly why the relaunch failed.
|
||||
_ = os.Remove(exe + ":Zone.Identifier")
|
||||
applog.Printf("update: installed new exe, scheduling relaunch")
|
||||
|
||||
// Relaunch via a detached, hidden PowerShell that WAITS for this process to exit
|
||||
// (so the single-instance mutex is free) and THEN starts the new exe. Launching
|
||||
// the new exe directly while we're still alive raced the mutex and often left
|
||||
// nothing running; waiting for our own exit first makes the restart reliable,
|
||||
// and the launcher outlives us.
|
||||
quoted := strings.ReplaceAll(exe, "'", "''")
|
||||
ps := fmt.Sprintf(
|
||||
"Wait-Process -Id %d -ErrorAction SilentlyContinue; Start-Sleep -Milliseconds 400; Start-Process -FilePath '%s' -ArgumentList '--post-update'",
|
||||
os.Getpid(), quoted)
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("schedule relaunch: %w", err)
|
||||
}
|
||||
if a.ctx != nil {
|
||||
wruntime.Quit(a.ctx)
|
||||
} else {
|
||||
os.Exit(0)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadWithProgress streams url into dest, emitting "update:progress" (0-100).
|
||||
func (a *App) downloadWithProgress(url, dest string) error {
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
total := resp.ContentLength
|
||||
var read int64
|
||||
last := -1
|
||||
buf := make([]byte, 64*1024)
|
||||
emit := func(pct int) {
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "update:progress", pct)
|
||||
}
|
||||
}
|
||||
emit(0)
|
||||
for {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := f.Write(buf[:n]); werr != nil {
|
||||
return werr
|
||||
}
|
||||
read += int64(n)
|
||||
if total > 0 {
|
||||
if pct := int(read * 100 / total); pct != last {
|
||||
last = pct
|
||||
emit(pct)
|
||||
}
|
||||
}
|
||||
}
|
||||
if rerr == io.EOF {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
}
|
||||
emit(100)
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractExeFromZip unpacks the first *.exe found in the zip into dir and returns
|
||||
// its path.
|
||||
func extractExeFromZip(zipPath, dir string) (string, error) {
|
||||
zr, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer zr.Close()
|
||||
for _, zf := range zr.File {
|
||||
if !strings.HasSuffix(strings.ToLower(zf.Name), ".exe") {
|
||||
continue
|
||||
}
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out := filepath.Join(dir, ".opslog-update.exe")
|
||||
f, err := os.Create(out)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return "", err
|
||||
}
|
||||
_, cerr := io.Copy(f, rc)
|
||||
rc.Close()
|
||||
f.Close()
|
||||
if cerr != nil {
|
||||
return "", cerr
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return "", fmt.Errorf("no .exe inside the archive")
|
||||
}
|
||||
|
||||
// cleanupOldUpdateBinary removes the previous exe left behind by a self-update
|
||||
// (exe + ".old"). Called at startup after a --post-update relaunch. Best-effort:
|
||||
// the file may still be briefly locked, in which case the next launch gets it.
|
||||
func cleanupOldUpdateBinary() {
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
_ = os.Remove(exe + ".old")
|
||||
}
|
||||
}
|
||||
|
||||
// leadingInt parses the leading digits of s (e.g. "2beta" → 2), 0 if none.
|
||||
func leadingInt(s string) int {
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
Reference in New Issue
Block a user