Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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)
|
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
|
||||||
liveBand string
|
liveBand string
|
||||||
liveMode 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
|
awardSnapMu sync.Mutex // guards the award QSO snapshot
|
||||||
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
|
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
|
||||||
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
|
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
|
||||||
@@ -811,6 +812,7 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
applog.Printf("startup: logbook backend = %s", backend)
|
applog.Printf("startup: logbook backend = %s", backend)
|
||||||
a.logDb = logbookConn
|
a.logDb = logbookConn
|
||||||
a.qso = qso.NewRepo(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.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks
|
||||||
go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs
|
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
|
a.relayAutoOn.Store(a.GetRelayAuto().Enabled) // prime the relay auto-control hot-path flag
|
||||||
@@ -1850,13 +1852,12 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
|
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
|
||||||
a.applyQSLDefaults(&q)
|
a.applyQSLDefaults(&q)
|
||||||
a.applySolar(&q) // stamp SFI / A / K (and SSN as an extra) from live space-weather
|
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
|
// NOTE: the contacted operator's e-mail already rides in on the QSO — the entry
|
||||||
// recording can be auto-sent. Cheap: the entry already looked the call up.
|
// lookup (when you typed the call) fetched it along with name/QTH/grid and the
|
||||||
if strings.TrimSpace(q.Email) == "" && a.lookup != nil {
|
// form carries it here. There is deliberately NO second lookup at log time: it
|
||||||
if lr, e := a.lookup.Lookup(a.ctx, q.Callsign); e == nil && lr.Email != "" {
|
// was redundant with the entry lookup and only slowed logging (a call not yet in
|
||||||
q.Email = lr.Email
|
// 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)
|
id, err = a.qso.Add(a.ctx, q)
|
||||||
if err != nil && db.IsConnLost(err) {
|
if err != nil && db.IsConnLost(err) {
|
||||||
// The database is UNREACHABLE (not a data error) — park the QSO in the
|
// The database is UNREACHABLE (not a data error) — park the QSO in the
|
||||||
@@ -1872,6 +1873,8 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
q.ID = id
|
q.ID = id
|
||||||
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh
|
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).
|
// Announce the log so UI widgets can react (e.g. the Flex panel zeroing RIT).
|
||||||
wruntime.EventsEmit(a.ctx, "qso:logged", id)
|
wruntime.EventsEmit(a.ctx, "qso:logged", id)
|
||||||
a.saveQSORecording(&q)
|
a.saveQSORecording(&q)
|
||||||
@@ -2514,6 +2517,7 @@ func (a *App) SaveAwardDefs(defs []award.Def) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
go a.mirrorAwardsToFolder(defs)
|
go a.mirrorAwardsToFolder(defs)
|
||||||
|
a.recomputeAwardRefsAsync() // definitions changed → refresh every row's award_refs
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3638,24 +3642,31 @@ func (a *App) ComputeQSOAwardRefs(q qso.QSO) ([]QSOAwardRef, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AwardRefsForQSOs returns, per QSO id, a map of award code → the reference(s)
|
// awardMatCtx bundles the precompiled award state needed to derive a QSO's
|
||||||
// that QSO contributes to (joined when several). Powers the per-award columns in
|
// materialised references. Built ONCE (newAwardMatCtx) and reused across a batch
|
||||||
// the Recent QSOs / Worked-before grids. The reference metadata is computed ONCE
|
// or a full recompute so award.Compute's per-pattern compilation isn't repeated.
|
||||||
// for the whole batch so a page of QSOs stays cheap.
|
type awardMatCtx struct {
|
||||||
func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error) {
|
defs []award.Def
|
||||||
out := map[int64]map[string]string{}
|
metas map[string][]award.RefMeta
|
||||||
if a.qso == nil || len(ids) == 0 {
|
fieldByCode map[string]string
|
||||||
return out, nil
|
dispByCode map[string]string
|
||||||
|
nameOf award.NameResolver
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) newAwardMatCtx() awardMatCtx {
|
||||||
defs := a.awardDefs()
|
defs := a.awardDefs()
|
||||||
metas := a.awardRefMetas(defs)
|
|
||||||
fieldByCode := map[string]string{}
|
fieldByCode := map[string]string{}
|
||||||
dispByCode := map[string]string{}
|
dispByCode := map[string]string{}
|
||||||
for _, d := range defs {
|
for _, d := range defs {
|
||||||
fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field))
|
fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field))
|
||||||
dispByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.RefDisplay))
|
dispByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.RefDisplay))
|
||||||
}
|
}
|
||||||
nameOf := func(field, ref string) string {
|
return awardMatCtx{
|
||||||
|
defs: defs,
|
||||||
|
metas: a.awardRefMetas(defs),
|
||||||
|
fieldByCode: fieldByCode,
|
||||||
|
dispByCode: dispByCode,
|
||||||
|
nameOf: func(field, ref string) string {
|
||||||
switch field {
|
switch field {
|
||||||
case "dxcc":
|
case "dxcc":
|
||||||
if n, err := strconv.Atoi(ref); err == nil {
|
if n, err := strconv.Atoi(ref); err == nil {
|
||||||
@@ -3665,24 +3676,28 @@ func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error)
|
|||||||
return continentName(ref)
|
return continentName(ref)
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
},
|
||||||
}
|
}
|
||||||
err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error {
|
}
|
||||||
|
|
||||||
|
// 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)
|
a.enrichQSOForAwards(&q)
|
||||||
results := award.Compute(defs, []qso.QSO{q}, metas, nameOf)
|
results := award.Compute(ac.defs, []qso.QSO{q}, ac.metas, ac.nameOf)
|
||||||
m := map[string]string{}
|
m := map[string]string{}
|
||||||
for i := range results {
|
for i := range results {
|
||||||
r := &results[i]
|
r := &results[i]
|
||||||
code := strings.ToUpper(r.Code)
|
code := strings.ToUpper(r.Code)
|
||||||
dxccField := fieldByCode[code] == "dxcc"
|
dxccField := ac.fieldByCode[code] == "dxcc"
|
||||||
var refs []string
|
var refs []string
|
||||||
for _, rf := range r.Refs {
|
for _, rf := range r.Refs {
|
||||||
if !rf.Worked {
|
if !rf.Worked {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Per-award display choice: ref (default), name (description), or
|
|
||||||
// both. DXCC keeps showing the country name under the default.
|
|
||||||
label := rf.Ref
|
label := rf.Ref
|
||||||
switch dispByCode[code] {
|
switch ac.dispByCode[code] {
|
||||||
case "name":
|
case "name":
|
||||||
if rf.Name != "" {
|
if rf.Name != "" {
|
||||||
label = rf.Name
|
label = rf.Name
|
||||||
@@ -3702,7 +3717,149 @@ func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error)
|
|||||||
m[code] = strings.Join(refs, ", ")
|
m[code] = strings.Join(refs, ", ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(m) > 0 {
|
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 {
|
||||||
|
if js := a.awardRefsJSONFor(ac, q); js != q.AwardRefs {
|
||||||
|
changes[q.ID] = js
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
const keyAwardsMaterialized = "awards.materialized.v1"
|
||||||
|
|
||||||
|
// 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
|
out[q.ID] = m
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -3748,6 +3905,17 @@ func (a *App) GetAwardReferenceMeta() ([]AwardRefMeta, error) {
|
|||||||
// UpdateAwardReferenceList downloads the latest reference list for an award and
|
// UpdateAwardReferenceList downloads the latest reference list for an award and
|
||||||
// replaces the stored set. Returns the new reference count.
|
// replaces the stored set. Returns the new reference count.
|
||||||
func (a *App) UpdateAwardReferenceList(code string) (AwardRefMeta, error) {
|
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 {
|
if a.awardRefs == nil {
|
||||||
return AwardRefMeta{}, fmt.Errorf("db not initialized")
|
return AwardRefMeta{}, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
@@ -3784,7 +3952,7 @@ func (a *App) DownloadAllReferenceLists() (string, error) {
|
|||||||
if !awardref.CanUpdate(code) {
|
if !awardref.CanUpdate(code) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
meta, err := a.UpdateAwardReferenceList(code)
|
meta, err := a.updateAwardReferenceList(code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
parts = append(parts, fmt.Sprintf("%s ✗", code))
|
parts = append(parts, fmt.Sprintf("%s ✗", code))
|
||||||
if firstErr == nil {
|
if firstErr == nil {
|
||||||
@@ -3794,6 +3962,7 @@ func (a *App) DownloadAllReferenceLists() (string, error) {
|
|||||||
}
|
}
|
||||||
parts = append(parts, fmt.Sprintf("%s %d", code, meta.Count))
|
parts = append(parts, fmt.Sprintf("%s %d", code, meta.Count))
|
||||||
}
|
}
|
||||||
|
a.recomputeAwardRefsAsync() // one bulk recompute after all lists updated
|
||||||
return strings.Join(parts, " · "), firstErr
|
return strings.Join(parts, " · "), firstErr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3837,6 +4006,7 @@ func (a *App) DeleteAwardReference(code, refCode string) error {
|
|||||||
}
|
}
|
||||||
a.markAwardEdited(code)
|
a.markAwardEdited(code)
|
||||||
a.mirrorAwards()
|
a.mirrorAwards()
|
||||||
|
a.recomputeAwardRefsAsync()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3880,6 +4050,7 @@ func (a *App) ReplaceAwardReferences(code string, refs []awardref.Ref) (int, err
|
|||||||
}
|
}
|
||||||
a.markAwardEdited(code)
|
a.markAwardEdited(code)
|
||||||
a.mirrorAwards()
|
a.mirrorAwards()
|
||||||
|
a.recomputeAwardRefsAsync() // reference list replaced → refresh every row's award_refs
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4484,6 +4655,7 @@ func (a *App) UpdateQSO(q qso.QSO) error {
|
|||||||
err := a.qso.Update(a.ctx, q)
|
err := a.qso.Update(a.ctx, q)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
a.invalidateAwardStats()
|
a.invalidateAwardStats()
|
||||||
|
a.materializeAwardRefs(q) // fields may have changed → refresh award_refs
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -4517,22 +4689,31 @@ func (a *App) GetOperators() ([]string, error) {
|
|||||||
// QSORate is the live QSO-rate meter shown in the header: how many QSOs were
|
// QSORate is the live QSO-rate meter shown in the header: how many QSOs were
|
||||||
// logged in the trailing 10 and 60 minutes.
|
// logged in the trailing 10 and 60 minutes.
|
||||||
type QSORate struct {
|
type QSORate struct {
|
||||||
Last10 int `json:"last10"`
|
Last10 int `json:"last10"` // active operator, last 10 min
|
||||||
Last60 int `json:"last60"`
|
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.
|
// GetQSORate returns the number of QSOs logged in the last 10 and 60 minutes, both
|
||||||
// Cheap (scans only the most recent rows); polled by the header and refreshed on
|
// for the active operator (their own performance) AND for all operators combined
|
||||||
// each qso:logged event.
|
// (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 {
|
func (a *App) GetQSORate() QSORate {
|
||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
return QSORate{}
|
return QSORate{}
|
||||||
}
|
}
|
||||||
counts, err := a.qso.RecentRate(a.ctx, time.Now(), 10*time.Minute, 60*time.Minute)
|
operator := ""
|
||||||
if err != nil || len(counts) < 2 {
|
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{}
|
||||||
}
|
}
|
||||||
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
|
// GetContestRuns lists the (contest, year) pairs actually present in the log, so
|
||||||
@@ -4802,6 +4983,7 @@ func (a *App) BulkUpdateField(ids []int64, field, value string) (int64, error) {
|
|||||||
}
|
}
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
a.invalidateAwardStats()
|
a.invalidateAwardStats()
|
||||||
|
a.materializeAwardRefsForIDs(ids) // the edited field may feed an award → refresh
|
||||||
}
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
@@ -4951,7 +5133,11 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStatio
|
|||||||
im.OnProgress = func(processed, total int) {
|
im.OnProgress = func(processed, total int) {
|
||||||
wruntime.EventsEmit(a.ctx, "import:progress", map[string]int{"processed": processed, "total": total})
|
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
|
// SaveADIFFile shows a native Save-As dialog suggesting a timestamped
|
||||||
@@ -5292,7 +5478,14 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
|
|||||||
if a.lookup == nil {
|
if a.lookup == nil {
|
||||||
return lookup.Result{}, fmt.Errorf("lookup not initialized")
|
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) {
|
if errors.Is(err, lookup.ErrNotFound) {
|
||||||
return lookup.Result{}, fmt.Errorf("callsign not found")
|
return lookup.Result{}, fmt.Errorf("callsign not found")
|
||||||
}
|
}
|
||||||
@@ -5822,8 +6015,10 @@ func (a *App) saveQSORecording(q *qso.QSO) {
|
|||||||
if q.Extras == nil {
|
if q.Extras == nil {
|
||||||
q.Extras = map[string]string{}
|
q.Extras = map[string]string{}
|
||||||
}
|
}
|
||||||
q.Extras["APP_OPSLOG_RECORDING"] = name
|
q.Extras["APP_OPSLOG_RECORDING"] = name // in-memory copy for the encode goroutine
|
||||||
if err := a.qso.Update(a.ctx, *q); err != nil {
|
// 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)
|
applog.Printf("qso-rec: store recording path: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6695,17 +6890,10 @@ func (a *App) markRecordingSent(id int64) {
|
|||||||
if a.qso == nil || id == 0 {
|
if a.qso == nil || id == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
q, err := a.qso.GetByID(a.ctx, id)
|
// Targeted extras write — never a full-row Update, which (from a stale copy)
|
||||||
if err != nil {
|
// could revert a clublog/qrz upload-status another action just stamped.
|
||||||
applog.Printf("qso-rec: mark sent: load %d: %v", id, err)
|
if err := a.qso.SetExtra(a.ctx, id, "APP_OPSLOG_RECORDING_SENT", time.Now().UTC().Format("2006-01-02")); err != nil {
|
||||||
return
|
applog.Printf("qso-rec: mark sent %d: %v", id, err)
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8748,6 +8936,7 @@ func (a *App) UpdateQSOsFromCty(ids []int64) (int, error) {
|
|||||||
}
|
}
|
||||||
if changed > 0 {
|
if changed > 0 {
|
||||||
a.invalidateAwardStats()
|
a.invalidateAwardStats()
|
||||||
|
a.materializeAwardRefsForIDs(ids) // entity fields changed → refresh award_refs
|
||||||
}
|
}
|
||||||
return changed, nil
|
return changed, nil
|
||||||
}
|
}
|
||||||
@@ -8823,6 +9012,7 @@ func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) {
|
|||||||
}
|
}
|
||||||
if changed > 0 {
|
if changed > 0 {
|
||||||
a.invalidateAwardStats()
|
a.invalidateAwardStats()
|
||||||
|
a.materializeAwardRefsForIDs(ids) // entity/geo fields changed → refresh award_refs
|
||||||
}
|
}
|
||||||
return changed, nil
|
return changed, nil
|
||||||
}
|
}
|
||||||
@@ -9277,6 +9467,8 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
|||||||
return 0, fmt.Errorf("insert qso: %w", err)
|
return 0, fmt.Errorf("insert qso: %w", err)
|
||||||
}
|
}
|
||||||
q.ID = id
|
q.ID = id
|
||||||
|
a.noteLiveQSO() // multi-op: flip this operator back "online"
|
||||||
|
a.materializeAwardRefs(q)
|
||||||
a.saveQSORecording(&q)
|
a.saveQSORecording(&q)
|
||||||
if a.extsvc != nil {
|
if a.extsvc != nil {
|
||||||
a.extsvc.OnQSOLogged(id)
|
a.extsvc.OnQSOLogged(id)
|
||||||
|
|||||||
+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)
|
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field —
|
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field — NOT
|
||||||
// NOT the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay
|
// the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay independent.
|
||||||
// independent. q came straight from GetByID, so a full Update rewrites the
|
//
|
||||||
// row unchanged apart from this field.
|
// Stamp ONLY this extras key (targeted UPDATE), never a full-row write. The `q`
|
||||||
if q.Extras == nil {
|
// read up top is now stale after the slow e-mail send, and rewriting the whole
|
||||||
q.Extras = map[string]string{}
|
// 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.
|
||||||
q.Extras[appQSLCardSentField] = time.Now().UTC().Format(time.RFC3339)
|
if err := a.qso.SetExtra(a.ctx, qsoID, appQSLCardSentField, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||||
if err := a.qso.Update(a.ctx, q); err != nil {
|
applog.Printf("qsl: card sent to %s but marking failed: %v", q.Callsign, err)
|
||||||
applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err)
|
return fmt.Errorf("QSL card sent but status not saved: %w", err)
|
||||||
return fmt.Errorf("eQSL sent but status not saved: %w", err)
|
|
||||||
}
|
}
|
||||||
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
|
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
|
||||||
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
|
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
|
||||||
|
|||||||
+310
-59
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
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';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
||||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
||||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||||
GetStartupStatus, CheckForUpdate,
|
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations,
|
||||||
WorkedBefore,
|
WorkedBefore,
|
||||||
SetCompactMode,
|
SetCompactMode,
|
||||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
|
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
|
||||||
@@ -41,9 +41,8 @@ import {
|
|||||||
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
||||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
||||||
GetAwardDefs,
|
GetAwardDefs,
|
||||||
GetUIPref,
|
GetUIPref, GetActiveProfile, QuitApp,
|
||||||
ReportLiveActivity,
|
ReportLiveActivity, GetLiveStatusEnabled, LiveLastQSOAgeSec,
|
||||||
AwardRefsForQSOs,
|
|
||||||
} from '../wailsjs/go/main/App';
|
} from '../wailsjs/go/main/App';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||||
@@ -87,6 +86,7 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
|||||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||||
import { RotorCompass } from '@/components/RotorCompass';
|
import { RotorCompass } from '@/components/RotorCompass';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { setGridPrefsProfile } from '@/lib/gridPrefs';
|
||||||
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
|
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -135,6 +135,22 @@ const emptyDetails: DetailsState = {
|
|||||||
award_refs: '',
|
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 {
|
function fmtDateUTC(s: any): string {
|
||||||
if (!s) return '';
|
if (!s) return '';
|
||||||
const d = new Date(s);
|
const d = new Date(s);
|
||||||
@@ -210,6 +226,16 @@ function bandForMHz(mhz: number): string {
|
|||||||
return '';
|
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.
|
// rstCategory buckets a mode into the report family used for its RST list.
|
||||||
type RSTLists = { phone: string[]; cw: string[]; digital: string[] };
|
type RSTLists = { phone: string[]; cw: string[]; digital: string[] };
|
||||||
function rstCategory(mode: string): keyof RSTLists {
|
function rstCategory(mode: string): keyof RSTLists {
|
||||||
@@ -410,6 +436,18 @@ export default function App() {
|
|||||||
// click reverts the UI and the click looks like it did nothing.
|
// 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 agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
|
||||||
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
|
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
|
// 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
|
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
||||||
// in Preferences > Hardware > CAT interface.
|
// in Preferences > Hardware > CAT interface.
|
||||||
@@ -605,11 +643,17 @@ export default function App() {
|
|||||||
QSOAudioResetClock().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
|
QSOAudioResetClock().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
|
||||||
};
|
};
|
||||||
const [saving, setSaving] = useState(false);
|
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('');
|
const [filterCallsign, setFilterCallsign] = useState('');
|
||||||
// Advanced filter builder (replaces the old band/mode dropdowns).
|
// Advanced filter builder (replaces the old band/mode dropdowns).
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
|
const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
|
||||||
const [matchCount, setMatchCount] = useState<number | null>(null);
|
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
|
// 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
|
// 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
|
// a feature or CAT backend that isn't known this early, and restoring one that
|
||||||
@@ -1086,13 +1130,31 @@ export default function App() {
|
|||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
||||||
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
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.
|
// QSO-rate meter (10/60 min) in the header — opt-in via Settings→General.
|
||||||
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
||||||
useEffect(() => { if (!showSettings) setShowQsoRate(localStorage.getItem('opslog.showQsoRate') === '1'); }, [showSettings]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!showQsoRate) return;
|
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();
|
load();
|
||||||
// Refresh on each logged QSO (immediate feedback) and on a 30s tick so the
|
// Refresh on each logged QSO (immediate feedback) and on a 30s tick so the
|
||||||
// trailing windows roll forward even when nothing new is logged.
|
// trailing windows roll forward even when nothing new is logged.
|
||||||
@@ -1106,15 +1168,39 @@ export default function App() {
|
|||||||
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
||||||
const [showAbout, setShowAbout] = useState(false);
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
const [showDuplicates, setShowDuplicates] = useState(false);
|
const [showDuplicates, setShowDuplicates] = useState(false);
|
||||||
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string } | null>(null);
|
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
|
||||||
// Check GitHub for a newer release once at startup (unless disabled in
|
const [updating, setUpdating] = useState(false);
|
||||||
// General); surface a toast if one exists. Best effort — silent on failure.
|
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(() => {
|
useEffect(() => {
|
||||||
if (localStorage.getItem('opslog.checkUpdates') === '0') return;
|
if (localStorage.getItem('opslog.checkUpdates') === '0') return;
|
||||||
CheckForUpdate().then((u: any) => {
|
const check = () => CheckForUpdate().then((u: any) => {
|
||||||
if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? '') });
|
if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') });
|
||||||
}).catch(() => {});
|
}).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 [deletingAll, setDeletingAll] = useState(false);
|
||||||
const [ctyRefreshing, setCtyRefreshing] = useState(false);
|
const [ctyRefreshing, setCtyRefreshing] = useState(false);
|
||||||
const [refsDownloading, setRefsDownloading] = useState(false);
|
const [refsDownloading, setRefsDownloading] = useState(false);
|
||||||
@@ -1153,6 +1239,10 @@ export default function App() {
|
|||||||
const [lookupError, setLookupError] = useState('');
|
const [lookupError, setLookupError] = useState('');
|
||||||
const lookupTimerRef = useRef<number | null>(null);
|
const lookupTimerRef = useRef<number | null>(null);
|
||||||
const wbTimerRef = 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 [wb, setWb] = useState<WB | null>(null);
|
||||||
const [wbBusy, setWbBusy] = useState(false);
|
const [wbBusy, setWbBusy] = useState(false);
|
||||||
|
|
||||||
@@ -1160,38 +1250,28 @@ export default function App() {
|
|||||||
// list once, then compute each shown QSO's reference per award and attach it
|
// 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).
|
// to the rows (the grids render one hideable column per award).
|
||||||
const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]);
|
const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]);
|
||||||
// Bumped whenever award definitions are saved so the grid columns AND the
|
// Bumped when award definitions change (or the backend finishes a bulk
|
||||||
// per-QSO refs re-fetch — the ref/name display choice is computed live, so
|
// award_refs recompute) so the set of award COLUMNS re-reads from GetAwardDefs.
|
||||||
// changing it updates ALL contacts (old included) with no restart.
|
// 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);
|
const [awardsVersion, setAwardsVersion] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
GetAwardDefs().then((defs: any[]) =>
|
GetAwardDefs().then((defs: any[]) =>
|
||||||
setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))),
|
setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))),
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
}, [awardsVersion]);
|
}, [awardsVersion]);
|
||||||
const [qsoAwardRefs, setQsoAwardRefs] = useState<Record<string, Record<string, string>>>({});
|
// Award references are now MATERIALISED on the QSO row (the award_refs JSON
|
||||||
useEffect(() => {
|
// column, written by the backend on log/edit and bulk-recomputed when awards
|
||||||
const ids = (qsos as any[]).map((q) => q.id).filter(Boolean);
|
// change). The grid reads them straight from the row — no per-page backend
|
||||||
if (ids.length === 0 || awardCols.length === 0) { setQsoAwardRefs({}); return; }
|
// recompute — so here we just parse the stored JSON string into the code→ref
|
||||||
let alive = true;
|
// object the award columns expect (keys are already upper-case).
|
||||||
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setQsoAwardRefs(m ?? {}); }).catch(() => {});
|
|
||||||
return () => { alive = false; };
|
|
||||||
}, [qsos, awardCols.length, awardsVersion]);
|
|
||||||
const qsosWithAwards = useMemo(
|
const qsosWithAwards = useMemo(
|
||||||
() => (qsos as any[]).map((q) => ({ ...q, award_refs: qsoAwardRefs[String(q.id)] })),
|
() => (qsos as any[]).map((q) => ({ ...q, award_refs: parseAwardRefs(q.award_refs) })),
|
||||||
[qsos, qsoAwardRefs],
|
[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(
|
const wbWithAwards = useMemo(
|
||||||
() => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: wbAwardRefs[String(e.id)] })) } : null),
|
() => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: parseAwardRefs(e.award_refs) })) } : null),
|
||||||
[wb, wbAwardRefs],
|
[wb],
|
||||||
);
|
);
|
||||||
// Always-current copy of the entry callsign, so the UDP event handlers
|
// Always-current copy of the entry callsign, so the UDP event handlers
|
||||||
// (which live in a []-deps effect with a stale `callsign` closure) can
|
// (which live in a []-deps effect with a stale `callsign` closure) can
|
||||||
@@ -1352,6 +1432,15 @@ export default function App() {
|
|||||||
return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
|
return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
|
||||||
}, [refresh]);
|
}, [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).
|
// Backend-emitted toast messages (e.g. recording auto-send result/skip).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); });
|
const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); });
|
||||||
@@ -1951,11 +2040,25 @@ export default function App() {
|
|||||||
} catch { /* keyer not configured */ }
|
} 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
|
// Every setting is per-profile, so when the active profile changes the whole
|
||||||
// main UI re-reads its config (station identity, lists, CAT, keyer). The Go
|
// main UI re-reads its config (station identity, lists, CAT, keyer). The Go
|
||||||
// side reloads its managers; this keeps the React state in sync.
|
// side reloads its managers; this keeps the React state in sync.
|
||||||
useEffect(() => {
|
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();
|
loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes();
|
||||||
// The chat is per shared logbook — clear the previous profile's messages
|
// 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).
|
// and reload for the new logbook (or hide if it isn't a MySQL log).
|
||||||
@@ -2155,7 +2258,9 @@ export default function App() {
|
|||||||
}, [spots]);
|
}, [spots]);
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
if (savingRef.current) return; // a log is already in flight — ignore the repeat
|
||||||
if (!callsign.trim()) { setError('Callsign required'); return; }
|
if (!callsign.trim()) { setError('Callsign required'); return; }
|
||||||
|
savingRef.current = true;
|
||||||
setSaving(true); setError('');
|
setSaving(true); setError('');
|
||||||
try {
|
try {
|
||||||
const freqHz = freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined;
|
const freqHz = freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined;
|
||||||
@@ -2225,13 +2330,21 @@ export default function App() {
|
|||||||
await refresh();
|
await refresh();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(String(e?.message ?? e));
|
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
|
// resetEntry clears the form for the next QSO. Triggered after a
|
||||||
// successful log AND by ESC. Locked values (band/mode/freq/start/end)
|
// successful log AND by ESC. Locked values (band/mode/freq/start/end)
|
||||||
// are preserved so backdated batches stay productive.
|
// are preserved so backdated batches stay productive.
|
||||||
function resetEntry() {
|
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
|
// Discard any in-progress QSO recording (no-op if it was already saved on
|
||||||
// log, or if the recorder is off).
|
// log, or if the recorder is off).
|
||||||
QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
|
QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
|
||||||
@@ -2436,14 +2549,15 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
async function runLookup(call: string) {
|
async function runLookup(call: string) {
|
||||||
if (call !== lastLookedUpRef.current) resetAutoFill();
|
if (call !== lastLookedUpRef.current) resetAutoFill();
|
||||||
|
const gen = lookupGenRef.current; // invalidated by ESC / resetEntry
|
||||||
setLookupBusy(true);
|
setLookupBusy(true);
|
||||||
try {
|
try {
|
||||||
const r = await LookupCallsign(call);
|
const r = await LookupCallsign(call);
|
||||||
// Discard a STALE result: the operator already moved to another 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
|
// (clicked a new spot / typed) OR cleared the entry (ESC) while this lookup
|
||||||
// would clobber the current call's fields and zoom the map to the wrong
|
// was in flight. Applying it would clobber the current fields and zoom the
|
||||||
// station — the bug where replacing a call didn't re-zoom the map.
|
// map to the wrong station.
|
||||||
if (call !== callsignValRef.current.trim().toUpperCase()) return;
|
if (gen !== lookupGenRef.current || call !== callsignValRef.current.trim().toUpperCase()) return;
|
||||||
lastLookedUpRef.current = call;
|
lastLookedUpRef.current = call;
|
||||||
// cty.dat carries ONLY DXCC-entity data (country / CQ / ITU zones / continent).
|
// 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
|
// A QRZ/HamQTH hit is far richer (name, QTH, grid, address, image). When the
|
||||||
@@ -2496,9 +2610,15 @@ export default function App() {
|
|||||||
QSOAudioBegin().then(setRecording).catch(() => {});
|
QSOAudioBegin().then(setRecording).catch(() => {});
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
if (gen === lookupGenRef.current && call === callsignValRef.current.trim().toUpperCase()) {
|
||||||
setLookupResult(null);
|
setLookupResult(null);
|
||||||
setLookupError(String(e?.message ?? e));
|
setLookupError(String(e?.message ?? e));
|
||||||
} finally { setLookupBusy(false); }
|
}
|
||||||
|
} 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) {
|
function scheduleLookup(value: string, force?: boolean) {
|
||||||
setLookupError('');
|
setLookupError('');
|
||||||
@@ -2552,7 +2672,14 @@ export default function App() {
|
|||||||
// keeps the pre-roll from before this); clearing it discards the take.
|
// keeps the pre-roll from before this); clearing it discards the take.
|
||||||
// Recording START happens on blur (leaving the callsign field), NOT here —
|
// Recording START happens on blur (leaving the callsign field), NOT here —
|
||||||
// you may type a call and work it minutes later. Clearing it cancels.
|
// 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() === '';
|
const isEmpty = v.trim() === '';
|
||||||
if (!isEmpty && !locks.start) {
|
if (!isEmpty && !locks.start) {
|
||||||
// Restart the start time on every callsign change (each keystroke, a
|
// Restart the start time on every callsign change (each keystroke, a
|
||||||
@@ -2635,7 +2762,7 @@ export default function App() {
|
|||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: t('file.deleteAll'), action: 'file.deleteall', disabled: total === 0 },
|
{ type: 'item', label: t('file.deleteAll'), action: 'file.deleteall', disabled: total === 0 },
|
||||||
{ type: 'separator' },
|
{ 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: [
|
{ name: 'edit', label: t('menu.edit'), items: [
|
||||||
{ type: 'item', label: t('edit.editSel'), action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
|
{ type: 'item', label: t('edit.editSel'), action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
|
||||||
@@ -2680,6 +2807,7 @@ export default function App() {
|
|||||||
case 'file.export': exportAdif(); break;
|
case 'file.export': exportAdif(); break;
|
||||||
case 'file.exportCabrillo': exportCabrillo(); break;
|
case 'file.exportCabrillo': exportCabrillo(); break;
|
||||||
case 'file.deleteall': setShowDeleteAll(true); break;
|
case 'file.deleteall': setShowDeleteAll(true); break;
|
||||||
|
case 'file.exit': QuitApp(); break;
|
||||||
case 'view.refresh': refresh(); break;
|
case 'view.refresh': refresh(); break;
|
||||||
case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
|
case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
|
||||||
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
|
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
|
||||||
@@ -3519,6 +3647,7 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
|
key={`rqg-${activeProfileId ?? 'x'}`}
|
||||||
rows={qsosWithAwards as any}
|
rows={qsosWithAwards as any}
|
||||||
total={total}
|
total={total}
|
||||||
awardCols={awardCols}
|
awardCols={awardCols}
|
||||||
@@ -3772,6 +3901,24 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* QSO-rate meter (opt-in) + propagation share ONE grid cell: the header
|
{/* QSO-rate meter (opt-in) + propagation share ONE grid cell: the header
|
||||||
@@ -3779,12 +3926,28 @@ export default function App() {
|
|||||||
the last columns (profile / band map / compact) onto a 2nd row. */}
|
the last columns (profile / band map / compact) onto a 2nd row. */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{showQsoRate && (
|
{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')}>
|
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')} />
|
<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
|
{dbConn?.backend === 'mysql' ? (
|
||||||
(10-min count ×6; the 60-min count is already per hour). Numbers
|
<div className="flex flex-col gap-0.5 leading-none">
|
||||||
glow the brand accent when active, dim to muted when idle. */}
|
<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="inline-flex items-baseline gap-1">
|
||||||
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">10′</span>
|
<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 className={cn('font-bold text-[12px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span>
|
||||||
@@ -3793,7 +3956,9 @@ export default function App() {
|
|||||||
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">60′</span>
|
<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 className={cn('font-bold text-[12px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground text-[9px] uppercase tracking-wider">Q/h</span>
|
</>
|
||||||
|
)}
|
||||||
|
<span className="text-muted-foreground text-[9px] uppercase tracking-wider self-center">Q/h</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -3914,22 +4079,42 @@ export default function App() {
|
|||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<div className="size-2.5 mt-1 rounded-full bg-primary shrink-0 animate-pulse" />
|
<div className="size-2.5 mt-1 rounded-full bg-primary shrink-0 animate-pulse" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-semibold">OpsLog v{updateInfo.latest} available</p>
|
<p className="text-sm font-semibold">{t('upd.available', { v: updateInfo.latest })}</p>
|
||||||
<p className="text-xs text-muted-foreground">You're on v{APP_VERSION}.</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">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<button
|
<button onClick={startUpdate} className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">
|
||||||
onClick={() => { if (updateInfo.url) BrowserOpenURL(updateInfo.url); setUpdateInfo(null); }}
|
{updateInfo.downloadUrl ? t('upd.install') : t('upd.download')}
|
||||||
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>
|
</button>
|
||||||
|
<button onClick={() => setUpdateInfo(null)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">{t('upd.later')}</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!updating && (
|
||||||
<button onClick={() => setUpdateInfo(null)} className="text-muted-foreground hover:text-foreground shrink-0" title="Dismiss">
|
<button onClick={() => setUpdateInfo(null)} className="text-muted-foreground hover:text-foreground shrink-0" title="Dismiss">
|
||||||
<X className="size-4" />
|
<X className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -4120,8 +4305,55 @@ export default function App() {
|
|||||||
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
||||||
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
||||||
otherwise it shows the QRZ profile photo. */}
|
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">
|
<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 && (
|
{chatShown && (
|
||||||
// relative + absolute inner: the chat takes the row height (set by the
|
// relative + absolute inner: the chat takes the row height (set by the
|
||||||
// entry strip) WITHOUT its message list growing the row, like the
|
// entry strip) WITHOUT its message list growing the row, like the
|
||||||
@@ -4442,9 +4674,11 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
|
key={`rqg2-${activeProfileId ?? 'x'}`}
|
||||||
rows={qsosWithAwards as any}
|
rows={qsosWithAwards as any}
|
||||||
total={total}
|
total={total}
|
||||||
awardCols={awardCols}
|
awardCols={awardCols}
|
||||||
|
onFilteredCountChange={setGridFilteredCount}
|
||||||
onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||||
onUpdateFromCty={bulkUpdateFromCty}
|
onUpdateFromCty={bulkUpdateFromCty}
|
||||||
onUpdateFromQRZ={bulkUpdateFromQRZ}
|
onUpdateFromQRZ={bulkUpdateFromQRZ}
|
||||||
@@ -4480,11 +4714,18 @@ export default function App() {
|
|||||||
onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }}
|
onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }}
|
||||||
>clear</button>
|
>clear</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{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>
|
<span>
|
||||||
Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
|
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>
|
<span className="font-semibold text-foreground">{(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total}</span>
|
||||||
{(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''}
|
{(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{qsos.length >= qsoLimit && qsos.length < total && (
|
{qsos.length >= qsoLimit && qsos.length < total && (
|
||||||
@@ -4854,6 +5095,16 @@ export default function App() {
|
|||||||
disabled={!rotatorHeading.enabled}
|
disabled={!rotatorHeading.enabled}
|
||||||
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
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
|
{/* 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),
|
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
|
but CLICK opens the full text wrapped — long messages (a TQSL or
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ type Props = {
|
|||||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
onExportCabrilloFiltered?: () => void;
|
onExportCabrilloFiltered?: () => void;
|
||||||
onDelete?: (ids: number[]) => 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
|
// 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.
|
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
||||||
awardCols?: { code: string; name: string }[];
|
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[] =>
|
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
||||||
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
(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 { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
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(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||||
const state = gridRef.current?.api?.getColumnState();
|
const state = gridRef.current?.api?.getColumnState();
|
||||||
if (state) saveState(colStateKey, stripAwardCols(state));
|
if (state) saveState(colStateKey, stripAwardCols(state));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// The award columns load asynchronously; when they arrive (or change) the
|
// columnDefs is rebuilt whenever the award columns load OR the user toggles an
|
||||||
// columnDefs memo is rebuilt and AG Grid re-applies each colDef's `hide`
|
// award column (both change the memo → restoringRef flips true at line 316). Each
|
||||||
// default — wiping the user's saved visibility (award columns reappear,
|
// rebuild makes AG Grid re-apply every colDef's `hide` default, wiping the user's
|
||||||
// manually-shown ones like LoTW sent vanish). Re-apply the saved state after
|
// saved visibility of the NON-award columns (QTH/Grid reappear, a manually-shown
|
||||||
// every rebuild so the user's choices win. No-op before the grid is ready.
|
// 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(() => {
|
useEffect(() => {
|
||||||
const api = gridRef.current?.api;
|
const api = gridRef.current?.api;
|
||||||
const local = loadLocal(colStateKey);
|
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.
|
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
||||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [awardCols]);
|
}, [awardCols, awardShown]);
|
||||||
|
|
||||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
||||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
@@ -467,6 +480,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
defaultColDef={defaultColDef}
|
defaultColDef={defaultColDef}
|
||||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||||
onGridReady={onGridReady}
|
onGridReady={onGridReady}
|
||||||
|
onFilterChanged={reportFilteredCount}
|
||||||
|
onModelUpdated={reportFilteredCount}
|
||||||
onColumnResized={saveColumnState}
|
onColumnResized={saveColumnState}
|
||||||
onColumnMoved={saveColumnState}
|
onColumnMoved={saveColumnState}
|
||||||
onColumnPinned={saveColumnState}
|
onColumnPinned={saveColumnState}
|
||||||
|
|||||||
@@ -6,11 +6,32 @@
|
|||||||
// back to the DB copy and re-seed the cache.
|
// back to the DB copy and re-seed the cache.
|
||||||
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
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
|
// loadLocal reads the cached column state synchronously (used in onGridReady
|
||||||
// to apply instantly, before the async DB round-trip).
|
// to apply instantly, before the async DB round-trip).
|
||||||
export function loadLocal(key: string): any[] | null {
|
export function loadLocal(key: string): any[] | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(key);
|
const raw = localStorage.getItem(lsKey(key));
|
||||||
const v = raw ? JSON.parse(raw) : null;
|
const v = raw ? JSON.parse(raw) : null;
|
||||||
return Array.isArray(v) ? v : null;
|
return Array.isArray(v) ? v : null;
|
||||||
} catch {
|
} 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[]) {
|
export function saveState(key: string, state: any[]) {
|
||||||
const json = JSON.stringify(state);
|
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 */ });
|
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedLocal writes a value into the cache without touching the DB (used after
|
// seedLocal writes a value into the cache without touching the DB (used after
|
||||||
// hydrating the cache from the DB on a fresh machine).
|
// hydrating the cache from the DB on a fresh machine).
|
||||||
export function seedLocal(key: string, state: any[]) {
|
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 = {
|
const en: Dict = {
|
||||||
// Menu bar
|
// Menu bar
|
||||||
'prop.title': 'Propagation', 'prop.geomag': 'Geomag', 'prop.refresh': 'Refresh space weather',
|
'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',
|
'rate.title': 'QSO rate (QSOs/hour) — projected from the last 10 / 60 minutes',
|
||||||
'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)',
|
'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)',
|
||||||
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
|
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
|
||||||
@@ -324,6 +333,10 @@ const en: Dict = {
|
|||||||
|
|
||||||
const fr: Dict = {
|
const fr: Dict = {
|
||||||
'prop.title': 'Propagation', 'prop.geomag': 'Géomag', 'prop.refresh': 'Actualiser la météo spatiale',
|
'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',
|
'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)',
|
'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)',
|
||||||
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
|
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||||
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||||
'opslog.activeTab', // last selected tab
|
'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:
|
// 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).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.20.1';
|
export const APP_VERSION = '0.20.4';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+8
@@ -146,6 +146,8 @@ export function DismissAwardUpdate(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function DownloadAllReferenceLists():Promise<string>;
|
export function DownloadAllReferenceLists():Promise<string>;
|
||||||
|
|
||||||
|
export function DownloadAndApplyUpdate(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
||||||
|
|
||||||
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
||||||
@@ -352,6 +354,8 @@ export function GetIcomState():Promise<cat.IcomTXState>;
|
|||||||
|
|
||||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||||
|
|
||||||
|
export function GetLiveStations():Promise<Array<main.LiveStation>>;
|
||||||
|
|
||||||
export function GetLiveStatusEnabled():Promise<boolean>;
|
export function GetLiveStatusEnabled():Promise<boolean>;
|
||||||
|
|
||||||
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
||||||
@@ -546,6 +550,8 @@ export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>
|
|||||||
|
|
||||||
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
||||||
|
|
||||||
|
export function LiveLastQSOAgeSec():Promise<number>;
|
||||||
|
|
||||||
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
||||||
|
|
||||||
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||||
@@ -660,6 +666,8 @@ export function QSOAudioRestart():Promise<boolean>;
|
|||||||
|
|
||||||
export function QuitApp():Promise<void>;
|
export function QuitApp():Promise<void>;
|
||||||
|
|
||||||
|
export function RecomputeAllAwardRefs():Promise<number>;
|
||||||
|
|
||||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
export function RefreshSolar():Promise<void>;
|
export function RefreshSolar():Promise<void>;
|
||||||
|
|||||||
@@ -250,6 +250,10 @@ export function DownloadAllReferenceLists() {
|
|||||||
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DownloadAndApplyUpdate(arg1) {
|
||||||
|
return window['go']['main']['App']['DownloadAndApplyUpdate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function DownloadClublogCty() {
|
export function DownloadClublogCty() {
|
||||||
return window['go']['main']['App']['DownloadClublogCty']();
|
return window['go']['main']['App']['DownloadClublogCty']();
|
||||||
}
|
}
|
||||||
@@ -662,6 +666,10 @@ export function GetListsSettings() {
|
|||||||
return window['go']['main']['App']['GetListsSettings']();
|
return window['go']['main']['App']['GetListsSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetLiveStations() {
|
||||||
|
return window['go']['main']['App']['GetLiveStations']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetLiveStatusEnabled() {
|
export function GetLiveStatusEnabled() {
|
||||||
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
||||||
}
|
}
|
||||||
@@ -1050,6 +1058,10 @@ export function ListUDPIntegrations() {
|
|||||||
return window['go']['main']['App']['ListUDPIntegrations']();
|
return window['go']['main']['App']['ListUDPIntegrations']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LiveLastQSOAgeSec() {
|
||||||
|
return window['go']['main']['App']['LiveLastQSOAgeSec']();
|
||||||
|
}
|
||||||
|
|
||||||
export function LoTWUserInfo(arg1) {
|
export function LoTWUserInfo(arg1) {
|
||||||
return window['go']['main']['App']['LoTWUserInfo'](arg1);
|
return window['go']['main']['App']['LoTWUserInfo'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1278,6 +1290,10 @@ export function QuitApp() {
|
|||||||
return window['go']['main']['App']['QuitApp']();
|
return window['go']['main']['App']['QuitApp']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RecomputeAllAwardRefs() {
|
||||||
|
return window['go']['main']['App']['RecomputeAllAwardRefs']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RefreshCtyDat() {
|
export function RefreshCtyDat() {
|
||||||
return window['go']['main']['App']['RefreshCtyDat']();
|
return window['go']['main']['App']['RefreshCtyDat']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2054,6 +2054,32 @@ export namespace main {
|
|||||||
return a;
|
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 {
|
export class LoTWUsersStatus {
|
||||||
count: number;
|
count: number;
|
||||||
updated?: string;
|
updated?: string;
|
||||||
@@ -2381,6 +2407,8 @@ export namespace main {
|
|||||||
export class QSORate {
|
export class QSORate {
|
||||||
last10: number;
|
last10: number;
|
||||||
last60: number;
|
last60: number;
|
||||||
|
team_last10: number;
|
||||||
|
team_last60: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new QSORate(source);
|
return new QSORate(source);
|
||||||
@@ -2390,6 +2418,8 @@ export namespace main {
|
|||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.last10 = source["last10"];
|
this.last10 = source["last10"];
|
||||||
this.last60 = source["last60"];
|
this.last60 = source["last60"];
|
||||||
|
this.team_last10 = source["team_last10"];
|
||||||
|
this.team_last60 = source["team_last60"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class RelayAutoRule {
|
export class RelayAutoRule {
|
||||||
@@ -2763,6 +2793,7 @@ export namespace main {
|
|||||||
latest: string;
|
latest: string;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
|
download_url: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new UpdateInfo(source);
|
return new UpdateInfo(source);
|
||||||
@@ -2774,6 +2805,7 @@ export namespace main {
|
|||||||
this.latest = source["latest"];
|
this.latest = source["latest"];
|
||||||
this.available = source["available"];
|
this.available = source["available"];
|
||||||
this.url = source["url"];
|
this.url = source["url"];
|
||||||
|
this.download_url = source["download_url"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class WKMacro {
|
export class WKMacro {
|
||||||
@@ -3616,6 +3648,7 @@ export namespace qso {
|
|||||||
my_arrl_sect?: string;
|
my_arrl_sect?: string;
|
||||||
my_vucc_grids?: string;
|
my_vucc_grids?: string;
|
||||||
extras?: Record<string, string>;
|
extras?: Record<string, string>;
|
||||||
|
award_refs?: string;
|
||||||
// Go type: time
|
// Go type: time
|
||||||
created_at: any;
|
created_at: any;
|
||||||
// Go type: time
|
// Go type: time
|
||||||
@@ -3753,6 +3786,7 @@ export namespace qso {
|
|||||||
this.my_arrl_sect = source["my_arrl_sect"];
|
this.my_arrl_sect = source["my_arrl_sect"];
|
||||||
this.my_vucc_grids = source["my_vucc_grids"];
|
this.my_vucc_grids = source["my_vucc_grids"];
|
||||||
this.extras = source["extras"];
|
this.extras = source["extras"];
|
||||||
|
this.award_refs = source["award_refs"];
|
||||||
this.created_at = this.convertValues(source["created_at"], null);
|
this.created_at = this.convertValues(source["created_at"], null);
|
||||||
this.updated_at = this.convertValues(source["updated_at"], null);
|
this.updated_at = this.convertValues(source["updated_at"], null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,11 @@ type icomNet struct {
|
|||||||
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
|
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
|
||||||
lastRx atomic.Int64
|
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.
|
// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
|
||||||
// Torn down alongside the CI-V/control streams in Close.
|
// Torn down alongside the CI-V/control streams in Close.
|
||||||
audio *icomAudio
|
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
|
// 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.
|
// reads as Alive and the session isn't torn down. Satisfies aliveTransport.
|
||||||
func (n *icomNet) Alive() bool {
|
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()
|
last := n.lastRx.Load()
|
||||||
if last == 0 {
|
if last == 0 {
|
||||||
return true // just connected, nothing received yet — give it a chance
|
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:]))
|
n.ctrlResend(icnLE.Uint16(buf[6:]))
|
||||||
}
|
}
|
||||||
case 0x05: // rig-initiated disconnect — it dropped US
|
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")
|
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
|
||||||
default:
|
default:
|
||||||
// Anything else on the control stream is (almost always) the rig's
|
// 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:]))
|
n.resend(icnLE.Uint16(buf[6:]))
|
||||||
}
|
}
|
||||||
case typ == 0x05: // rig-initiated disconnect — it dropped US
|
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")
|
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
|
case typ == 0x00 && k > 0x15 && buf[0x10] == 0xc1: // CI-V data
|
||||||
n.trackRxSeq(icnLE.Uint16(buf[6:])) // note gaps for retransmit
|
n.trackRxSeq(icnLE.Uint16(buf[6:])) // note gaps for retransmit
|
||||||
|
|||||||
+14
-9
@@ -32,6 +32,7 @@ type OmniRig struct {
|
|||||||
omnirig *ole.IDispatch
|
omnirig *ole.IDispatch
|
||||||
rig *ole.IDispatch
|
rig *ole.IDispatch
|
||||||
lastSig string // last logged Split/VFO signature — only log on change
|
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.
|
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
|
||||||
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
|
// 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()
|
o.rig = rigVar.ToIDispatch()
|
||||||
|
|
||||||
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -194,17 +196,20 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
|||||||
s.FreqHz, s.RxFreqHz = freqB, freqA
|
s.FreqHz, s.RxFreqHz = freqB, freqA
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Simplex: the operating frequency is OmniRig's generic Freq (the active
|
// Simplex: read VFO A first, fall back to the generic Freq — exactly like
|
||||||
// VFO), like Log4OM. Fall back to the per-VFO value only if Freq is 0.
|
// 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.Split = false
|
||||||
s.RxFreqHz = 0
|
s.RxFreqHz = 0
|
||||||
s.FreqHz = freqMain
|
switch {
|
||||||
if s.FreqHz == 0 {
|
case freqA != 0:
|
||||||
if s.Vfo == "B" || s.Vfo == "BB" {
|
|
||||||
s.FreqHz = freqB
|
|
||||||
} else {
|
|
||||||
s.FreqHz = freqA
|
s.FreqHz = freqA
|
||||||
}
|
case freqMain != 0:
|
||||||
|
s.FreqHz = freqMain
|
||||||
|
default:
|
||||||
|
s.FreqHz = freqB
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s, nil
|
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;
|
||||||
+23
-61
@@ -87,77 +87,39 @@ type Manager struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cfg ExternalServices
|
cfg ExternalServices
|
||||||
rnd *rand.Rand
|
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.
|
// maxUploadAttempts bounds retries of a transient upload failure.
|
||||||
type uploadJob struct {
|
const maxUploadAttempts = 4
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewManager(deps Deps) *Manager {
|
func NewManager(deps Deps) *Manager {
|
||||||
if deps.Client == nil {
|
if deps.Client == nil {
|
||||||
deps.Client = &http.Client{Timeout: 20 * time.Second}
|
deps.Client = &http.Client{Timeout: 20 * time.Second}
|
||||||
}
|
}
|
||||||
m := &Manager{
|
return &Manager{
|
||||||
deps: deps,
|
deps: deps,
|
||||||
// Seeded from the clock; the delay only needs to be unpredictable
|
// Seeded from the clock; the delay only needs to be unpredictable
|
||||||
// enough to spread bursts, not cryptographically random.
|
// enough to spread bursts, not cryptographically random.
|
||||||
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
|
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||||
uploadCh: make(chan uploadJob, 4096),
|
|
||||||
}
|
|
||||||
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) })
|
|
||||||
}
|
|
||||||
time.Sleep(uploadGap)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// enqueueUpload queues a first-attempt upload without blocking the logging path.
|
// attemptUpload uploads a QSO in its OWN goroutine and, on a TRANSIENT failure
|
||||||
func (m *Manager) enqueueUpload(svc Service, id int64, cfg ServiceConfig) {
|
// (rate-limit / network), re-arms itself with exponential back-off. Each upload is
|
||||||
m.enqueueJob(uploadJob{svc: svc, id: id, cfg: cfg})
|
// 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
|
||||||
// enqueueJob queues a job (possibly a retry). If the queue is somehow full (an
|
// the operator on the newest build while everyone on the old concurrent path was
|
||||||
// enormous burst), it falls back to a goroutine rather than dropping the upload —
|
// fine).
|
||||||
// a dropped upload would leave the QSO stuck at "R".
|
func (m *Manager) attemptUpload(svc Service, id int64, cfg ServiceConfig, attempt int) {
|
||||||
func (m *Manager) enqueueJob(job uploadJob) {
|
go func() {
|
||||||
select {
|
ok, retryable := m.upload(svc, id, cfg)
|
||||||
case m.uploadCh <- job:
|
if !ok && retryable && attempt+1 < maxUploadAttempts {
|
||||||
default:
|
backoff := time.Duration(1<<uint(attempt)) * time.Second // 1s, 2s, 4s…
|
||||||
go m.upload(job.svc, job.id, job.cfg)
|
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) })
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) logf(format string, args ...any) {
|
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)
|
m.scheduleUpload(svc, id, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// scheduleUpload either queues the upload now (immediate) or arms a timer that
|
// scheduleUpload uploads now (immediate) or after a random fuse (delayed). Each
|
||||||
// queues it later (delayed). Both go through the serialised worker so uploads are
|
// upload runs in its own goroutine (attemptUpload) — never serialised — so a slow
|
||||||
// never fired concurrently in a burst.
|
// one never holds up the rest.
|
||||||
func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
|
func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
|
||||||
if cfg.UploadMode == ModeDelayed {
|
if cfg.UploadMode == ModeDelayed {
|
||||||
d := m.delaySeconds()
|
d := m.delaySeconds()
|
||||||
m.logf("extsvc: %s upload of QSO %d scheduled in %s", svc, id, d)
|
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
|
return
|
||||||
}
|
}
|
||||||
m.enqueueUpload(svc, id, cfg)
|
m.attemptUpload(svc, id, cfg, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// onCloseServices returns the services configured for on-close auto-upload,
|
// 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.
|
// ADIF field names (e.g. "MS_SHOWER"); values are the raw string content.
|
||||||
Extras map[string]string `json:"extras,omitempty"`
|
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"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_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,
|
credit_granted, credit_submitted, my_arrl_sect, my_vucc_grids,
|
||||||
extras_json`
|
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.
|
// columnCount is derived from columnList at init so they can never drift.
|
||||||
var columnCount = countColumns(columnList)
|
var columnCount = countColumns(columnList)
|
||||||
@@ -781,6 +792,81 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
|
|||||||
return n, nil
|
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.
|
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
|
||||||
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
||||||
if q.ID == 0 {
|
if q.ID == 0 {
|
||||||
@@ -1863,38 +1949,71 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
|
|||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecentRate counts QSOs whose start time falls within each trailing window from
|
// LastQSOTime returns the start time of the most recently LOGGED QSO for an
|
||||||
// `now` — the live "QSO rate" meter shown in the header. It scans only the most
|
// operator (highest id wins) — used to seed the live "on air" state at launch so an
|
||||||
// recently inserted rows (ORDER BY id DESC LIMIT), since any QSO in the last hour
|
// operator who just worked someone before (re)starting OpsLog shows online right
|
||||||
// was inserted recently; that keeps it cheap even on a large log. qso_date is the
|
// away instead of waiting for their next QSO. Empty operator matches every QSO.
|
||||||
// repo's text column, parsed with parseTimeLoose (backend-format agnostic).
|
func (r *Repo) LastQSOTime(ctx context.Context, operator string) (time.Time, bool) {
|
||||||
func (r *Repo) RecentRate(ctx context.Context, now time.Time, windows ...time.Duration) ([]int, error) {
|
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 400`)
|
||||||
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`)
|
|
||||||
if err != nil {
|
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()
|
defer rows.Close()
|
||||||
now = now.UTC()
|
now = now.UTC()
|
||||||
|
opFilter := strings.ToUpper(strings.TrimSpace(operator))
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var dateStr sql.NullString
|
var oper, dateStr sql.NullString
|
||||||
if err := rows.Scan(&dateStr); err != nil {
|
if err := rows.Scan(&oper, &dateStr); err != nil {
|
||||||
return counts, err
|
return op, all, err
|
||||||
}
|
}
|
||||||
t := parseTimeLoose(dateStr.String).UTC()
|
t := parseTimeLoose(dateStr.String).UTC()
|
||||||
if t.IsZero() || t.After(now) {
|
if t.IsZero() || t.After(now) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
mine := strings.ToUpper(strings.TrimSpace(oper.String)) == opFilter
|
||||||
age := now.Sub(t)
|
age := now.Sub(t)
|
||||||
for i, w := range windows {
|
for i, w := range windows {
|
||||||
if age <= w {
|
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,
|
// 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
|
creditGranted, creditSubmitted sql.NullString
|
||||||
myARRLSect, myVUCCGrids sql.NullString
|
myARRLSect, myVUCCGrids sql.NullString
|
||||||
extrasJSON sql.NullString
|
extrasJSON sql.NullString
|
||||||
|
awardRefs sql.NullString
|
||||||
createdStr, updatedStr string
|
createdStr, updatedStr string
|
||||||
)
|
)
|
||||||
if err := s.Scan(
|
if err := s.Scan(
|
||||||
@@ -2336,7 +2456,7 @@ func scanQSO(s scanner) (QSO, error) {
|
|||||||
&skcc, &fists, &tenTen, &contactedOp, &eqCall, &pfx, &myName, &class,
|
&skcc, &fists, &tenTen, &contactedOp, &eqCall, &pfx, &myName, &class,
|
||||||
&darcDOK, &myDarcDOK, ®ion, &silentKey, &swl, &qsoComplete, &qsoRandom,
|
&darcDOK, &myDarcDOK, ®ion, &silentKey, &swl, &qsoComplete, &qsoRandom,
|
||||||
&creditGranted, &creditSubmitted, &myARRLSect, &myVUCCGrids,
|
&creditGranted, &creditSubmitted, &myARRLSect, &myVUCCGrids,
|
||||||
&extrasJSON, &createdStr, &updatedStr,
|
&extrasJSON, &awardRefs, &createdStr, &updatedStr,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return QSO{}, fmt.Errorf("scan qso: %w", err)
|
return QSO{}, fmt.Errorf("scan qso: %w", err)
|
||||||
}
|
}
|
||||||
@@ -2533,6 +2653,7 @@ func scanQSO(s scanner) (QSO, error) {
|
|||||||
q.MyARRLSect = myARRLSect.String
|
q.MyARRLSect = myARRLSect.String
|
||||||
q.MyVUCCGrids = myVUCCGrids.String
|
q.MyVUCCGrids = myVUCCGrids.String
|
||||||
q.Extras = decodeExtras(extrasJSON.String)
|
q.Extras = decodeExtras(extrasJSON.String)
|
||||||
|
q.AwardRefs = awardRefs.String
|
||||||
return q, nil
|
return q, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+163
-7
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -19,6 +20,23 @@ import (
|
|||||||
|
|
||||||
const keyLiveStatusEnabled = "livestatus.enabled"
|
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.
|
// GetLiveStatusEnabled reports whether this operator publishes live status.
|
||||||
func (a *App) GetLiveStatusEnabled() bool {
|
func (a *App) GetLiveStatusEnabled() bool {
|
||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
@@ -50,11 +68,61 @@ func (a *App) SetLiveStatusEnabled(on bool) error {
|
|||||||
return nil
|
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
|
// liveStatusLoop heartbeats the current activity while enabled. Started once at
|
||||||
// startup; cheap no-op when disabled or not on MySQL.
|
// startup; cheap no-op when disabled or not on MySQL.
|
||||||
func (a *App) liveStatusLoop() {
|
func (a *App) liveStatusLoop() {
|
||||||
defer func() { _ = recover() }() // never crash the app from here
|
defer func() { _ = recover() }() // never crash the app from here
|
||||||
applog.Printf("livestatus: loop started")
|
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
|
a.publishLiveStatus() // attempt immediately, don't wait the first tick
|
||||||
t := time.NewTicker(15 * time.Second)
|
t := time.NewTicker(15 * time.Second)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
@@ -133,34 +201,122 @@ func (a *App) publishLiveStatus() {
|
|||||||
mode = a.liveMode
|
mode = a.liveMode
|
||||||
}
|
}
|
||||||
a.liveActMu.Unlock()
|
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 {
|
if err := a.ensureLiveStatusTable(); err != nil {
|
||||||
applog.Printf("livestatus: CREATE TABLE failed: %v", err)
|
applog.Printf("livestatus: CREATE TABLE failed: %v", err)
|
||||||
return
|
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,
|
_, err := a.logDb.ExecContext(a.ctx,
|
||||||
"INSERT INTO live_status (operator, station, freq_hz, band, mode, updated_at) "+
|
"INSERT INTO live_status (operator, station, freq_hz, band, mode, online, version, last_qso_at, updated_at) "+
|
||||||
"VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
|
||||||
"ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+
|
"ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+
|
||||||
"band=VALUES(band), mode=VALUES(mode), updated_at=UTC_TIMESTAMP()",
|
"band=VALUES(band), mode=VALUES(mode), online=VALUES(online), version=VALUES(version), "+
|
||||||
op, station, freqHz, band, mode)
|
"last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()",
|
||||||
|
op, station, freqHz, band, mode, 1, appVersion, lastQSOArg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
applog.Printf("livestatus: INSERT failed: %v", err)
|
applog.Printf("livestatus: INSERT failed: %v", err)
|
||||||
return
|
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 {
|
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 ("+
|
"CREATE TABLE IF NOT EXISTS live_status ("+
|
||||||
"operator VARCHAR(32) PRIMARY KEY, "+
|
"operator VARCHAR(32) PRIMARY KEY, "+
|
||||||
"station VARCHAR(32), "+
|
"station VARCHAR(32), "+
|
||||||
"freq_hz BIGINT, "+
|
"freq_hz BIGINT, "+
|
||||||
"band VARCHAR(16), "+
|
"band VARCHAR(16), "+
|
||||||
"mode VARCHAR(16), "+
|
"mode VARCHAR(16), "+
|
||||||
"updated_at DATETIME)")
|
"online TINYINT DEFAULT 0, "+
|
||||||
|
"version VARCHAR(32), "+
|
||||||
|
"last_qso_at DATETIME NULL, "+
|
||||||
|
"updated_at DATETIME)"); err != nil {
|
||||||
return err
|
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).
|
// clearLiveStatus removes this operator's row (on disable / shutdown).
|
||||||
func (a *App) clearLiveStatus() {
|
func (a *App) clearLiveStatus() {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2"
|
"github.com/wailsapp/wails/v2"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options"
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
@@ -35,15 +36,53 @@ func profileArg(args []string) string {
|
|||||||
return ""
|
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() {
|
func main() {
|
||||||
// Single-instance guard: if OpsLog is already running, focus that window and
|
// 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
|
// exit instead of spawning a duplicate. A second process would open its own
|
||||||
// CAT (FlexRadio) connection and Ultrabeam follow loop, and the two would
|
// 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
|
// fight over the rig/antenna frequency — the cause of "the antenna re-tunes on
|
||||||
// its own" when a windowless zombie instance was left running.
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
if postUpdate {
|
||||||
|
cleanupOldUpdateBinary()
|
||||||
|
}
|
||||||
|
|
||||||
// Create an instance of the app structure
|
// Create an instance of the app structure
|
||||||
app := NewApp()
|
app := NewApp()
|
||||||
|
|||||||
+61
-10
@@ -96,8 +96,23 @@ func bandInList(bands []string, band string) bool {
|
|||||||
return false
|
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
|
// 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) {
|
func (a *App) applyRelayAuto(freqHz int64, band string) {
|
||||||
a.relayAutoMu.Lock()
|
a.relayAutoMu.Lock()
|
||||||
defer a.relayAutoMu.Unlock()
|
defer a.relayAutoMu.Unlock()
|
||||||
@@ -110,8 +125,11 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
|
|||||||
a.relayAutoLast = map[string]bool{}
|
a.relayAutoLast = map[string]bool{}
|
||||||
}
|
}
|
||||||
khz := float64(freqHz) / 1000.0
|
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 {
|
for _, r := range cfg.Rules {
|
||||||
if r.Relay < 1 {
|
if r.Relay < 1 {
|
||||||
continue
|
continue
|
||||||
@@ -119,8 +137,11 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
|
|||||||
var want bool
|
var want bool
|
||||||
switch r.Mode {
|
switch r.Mode {
|
||||||
case "freq":
|
case "freq":
|
||||||
|
if freqHz <= 0 {
|
||||||
|
continue // no known frequency (CAT off/closing) → leave the relay as-is
|
||||||
|
}
|
||||||
if r.FreqLoKHz <= 0 && r.FreqHiKHz <= 0 {
|
if r.FreqLoKHz <= 0 && r.FreqHiKHz <= 0 {
|
||||||
continue // unconfigured range → leave the relay alone
|
continue // unconfigured range
|
||||||
}
|
}
|
||||||
lo, hi := r.FreqLoKHz, r.FreqHiKHz
|
lo, hi := r.FreqLoKHz, r.FreqHiKHz
|
||||||
if hi < lo {
|
if hi < lo {
|
||||||
@@ -128,6 +149,9 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
|
|||||||
}
|
}
|
||||||
want = khz >= lo && khz <= hi
|
want = khz >= lo && khz <= hi
|
||||||
case "band":
|
case "band":
|
||||||
|
if band == "" {
|
||||||
|
continue // no known band → leave the relay as-is
|
||||||
|
}
|
||||||
if len(r.Bands) == 0 {
|
if len(r.Bands) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -135,16 +159,43 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
|
|||||||
default:
|
default:
|
||||||
continue // "off"/empty → not managed
|
continue // "off"/empty → not managed
|
||||||
}
|
}
|
||||||
|
acts = append(acts, relayAction{r.DeviceID, r.Relay, want})
|
||||||
key := relayAutoKey(r.DeviceID, r.Relay)
|
if _, ok := a.relayAutoLast[relayAutoKey(r.DeviceID, r.Relay)]; !ok {
|
||||||
if last, ok := a.relayAutoLast[key]; ok && last == want {
|
needLive = true
|
||||||
continue // no change → don't hammer the board
|
|
||||||
}
|
}
|
||||||
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
|
continue // don't cache a failed write — retry next change
|
||||||
}
|
}
|
||||||
a.relayAutoLast[key] = want
|
a.relayAutoLast[key] = ac.want
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.20.1"
|
appVersion = "0.20.4"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
"hamlog/internal/applog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,12 +23,13 @@ import (
|
|||||||
// build (the exe lives there; source stays on Gitea). Adjust the repo if needed.
|
// 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"
|
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 {
|
type UpdateInfo struct {
|
||||||
Current string `json:"current"` // this build's version (appVersion)
|
Current string `json:"current"` // this build's version (appVersion)
|
||||||
Latest string `json:"latest"` // newest published release, "" if unknown
|
Latest string `json:"latest"` // newest published release, "" if unknown
|
||||||
Available bool `json:"available"` // Latest > Current
|
Available bool `json:"available"` // Latest > Current
|
||||||
URL string `json:"url"` // release page to open
|
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
|
// CheckForUpdate asks GitHub for the latest release and compares it to this
|
||||||
@@ -45,6 +55,10 @@ func (a *App) CheckForUpdate() UpdateInfo {
|
|||||||
var r struct {
|
var r struct {
|
||||||
TagName string `json:"tag_name"`
|
TagName string `json:"tag_name"`
|
||||||
HTMLURL string `json:"html_url"`
|
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 {
|
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
||||||
return out
|
return out
|
||||||
@@ -52,8 +66,25 @@ func (a *App) CheckForUpdate() UpdateInfo {
|
|||||||
out.Latest = strings.TrimPrefix(strings.TrimSpace(r.TagName), "v")
|
out.Latest = strings.TrimPrefix(strings.TrimSpace(r.TagName), "v")
|
||||||
out.URL = r.HTMLURL
|
out.URL = r.HTMLURL
|
||||||
out.Available = versionLess(appVersion, out.Latest)
|
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 {
|
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
|
return out
|
||||||
}
|
}
|
||||||
@@ -82,6 +113,176 @@ func versionLess(a, b string) bool {
|
|||||||
return false
|
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.
|
// leadingInt parses the leading digits of s (e.g. "2beta" → 2), 0 if none.
|
||||||
func leadingInt(s string) int {
|
func leadingInt(s string) int {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
|
|||||||
Reference in New Issue
Block a user