Compare commits
13
Commits
v0.20.3
...
cc6411a618
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc6411a618 | ||
|
|
991831bdec | ||
|
|
1b2da95ad4 | ||
|
|
10d86db50a | ||
|
|
8538f48259 | ||
|
|
0fa91c3d5f | ||
|
|
c86d331bd9 | ||
|
|
77a752efe3 | ||
|
|
781fbfaa30 | ||
|
|
61c11c0fe3 | ||
|
|
64b746f007 | ||
|
|
9cc72c7575 | ||
|
|
9e4f43f648 |
@@ -699,6 +699,20 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
usingDefault = false
|
usingDefault = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A rename in a previous session left the OLD file to delete now that it's no
|
||||||
|
// longer open. Only ever delete a file that ISN'T the one we're about to use.
|
||||||
|
if boot := readBootstrap(dataDir); strings.TrimSpace(boot.DeletePending) != "" {
|
||||||
|
old := strings.TrimSpace(boot.DeletePending)
|
||||||
|
if old != a.dbPath {
|
||||||
|
for _, p := range []string{old, old + "-wal", old + "-shm"} {
|
||||||
|
if err := os.Remove(p); err == nil {
|
||||||
|
fmt.Printf("OpsLog: removed old database file %s\n", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boot.DeletePending = ""
|
||||||
|
_ = writeBootstrap(dataDir, boot)
|
||||||
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil {
|
||||||
a.startupErr = "cannot create db folder: " + err.Error()
|
a.startupErr = "cannot create db folder: " + err.Error()
|
||||||
fmt.Println("OpsLog:", a.startupErr)
|
fmt.Println("OpsLog:", a.startupErr)
|
||||||
@@ -812,6 +826,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
|
||||||
@@ -1339,6 +1354,11 @@ func copyFileData(src, dst string) error {
|
|||||||
type dbPointer struct {
|
type dbPointer struct {
|
||||||
DBPath string `json:"db_path"`
|
DBPath string `json:"db_path"`
|
||||||
MySQL *MySQLSettings `json:"mysql,omitempty"`
|
MySQL *MySQLSettings `json:"mysql,omitempty"`
|
||||||
|
// DeletePending is the previous database file to remove on the NEXT launch —
|
||||||
|
// set by a rename, which can't delete the still-open old file in-process. The
|
||||||
|
// startup path deletes it (with its -wal/-shm sidecars) once the new DB is the
|
||||||
|
// one in use, then clears this.
|
||||||
|
DeletePending string `json:"delete_pending,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
|
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
|
||||||
@@ -1652,9 +1672,10 @@ func (a *App) PickSaveDatabase() (string, error) {
|
|||||||
return "", fmt.Errorf("no app context")
|
return "", fmt.Errorf("no app context")
|
||||||
}
|
}
|
||||||
return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
|
return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
|
||||||
Title: "Save the OpsLog database to…",
|
Title: "Save the OpsLog database to…",
|
||||||
DefaultFilename: "opslog.db",
|
DefaultDirectory: filepath.Dir(a.dbPath),
|
||||||
Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}},
|
DefaultFilename: "opslog.db",
|
||||||
|
Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1696,6 +1717,44 @@ func (a *App) MoveDatabase(dest string) error {
|
|||||||
return writeDBPointer(a.dataDir, dest)
|
return writeDBPointer(a.dataDir, dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RenameDatabase renames the current database file to dest — keeping ALL config
|
||||||
|
// (it is the same database under a new name), unlike "New database" which starts
|
||||||
|
// empty. Implemented as a consistent copy (VACUUM INTO) plus a switch, then the
|
||||||
|
// ORIGINAL file is scheduled for deletion on the next launch (it is open now and
|
||||||
|
// can't be removed in-process on Windows). dest must not already exist.
|
||||||
|
func (a *App) RenameDatabase(dest string) error {
|
||||||
|
dest = strings.TrimSpace(dest)
|
||||||
|
if dest == "" {
|
||||||
|
return fmt.Errorf("no destination given")
|
||||||
|
}
|
||||||
|
if a.db == nil {
|
||||||
|
return fmt.Errorf("database not open")
|
||||||
|
}
|
||||||
|
old := a.dbPath
|
||||||
|
if strings.EqualFold(filepath.Clean(dest), filepath.Clean(old)) {
|
||||||
|
return fmt.Errorf("that is already the current database name")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dest); err == nil {
|
||||||
|
return fmt.Errorf("a file already exists at %s — pick a new name", dest)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create folder: %w", err)
|
||||||
|
}
|
||||||
|
safe := strings.ReplaceAll(dest, "'", "''")
|
||||||
|
if _, err := a.db.ExecContext(a.ctx, "VACUUM INTO '"+safe+"'"); err != nil {
|
||||||
|
return fmt.Errorf("copy database: %w", err)
|
||||||
|
}
|
||||||
|
boot := readBootstrap(a.dataDir)
|
||||||
|
boot.DBPath = dest
|
||||||
|
// Only schedule the old file for deletion when it's a real, on-disk file (a
|
||||||
|
// custom path or the default opslog.db) — never something we somehow share
|
||||||
|
// with the destination.
|
||||||
|
if strings.TrimSpace(old) != "" && !strings.EqualFold(filepath.Clean(old), filepath.Clean(dest)) {
|
||||||
|
boot.DeletePending = old
|
||||||
|
}
|
||||||
|
return writeBootstrap(a.dataDir, boot)
|
||||||
|
}
|
||||||
|
|
||||||
// CreateDatabase creates a fresh, empty logbook at dest (schema migrated) and
|
// CreateDatabase creates a fresh, empty logbook at dest (schema migrated) and
|
||||||
// points OpsLog at it for the next launch. dest must not already exist.
|
// points OpsLog at it for the next launch. dest must not already exist.
|
||||||
func (a *App) CreateDatabase(dest string) error {
|
func (a *App) CreateDatabase(dest string) error {
|
||||||
@@ -1873,6 +1932,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
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.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)
|
||||||
@@ -2450,6 +2510,14 @@ func (a *App) migrateAwardDefs() {
|
|||||||
applog.Printf("awards: %s updated from the catalog (v%d)", code, defByCode(migrated, code).Version)
|
applog.Printf("awards: %s updated from the catalog (v%d)", code, defByCode(migrated, code).Version)
|
||||||
a.reseedRefsFromCatalog(code)
|
a.reseedRefsFromCatalog(code)
|
||||||
}
|
}
|
||||||
|
// A catalog definition changed (e.g. DDFM gained the postal-code OR-rule), so
|
||||||
|
// the materialised award_refs on existing QSOs are now stale. Clear the
|
||||||
|
// one-shot backfill flag; backfillAwardRefsOnce runs later in startup and will
|
||||||
|
// re-materialise every row against the updated definitions. This makes ANY
|
||||||
|
// future catalog bump self-heal the stored columns without a manual flag bump.
|
||||||
|
if len(updated) > 0 {
|
||||||
|
_ = a.settings.Set(a.ctx, keyAwardsMaterialized, "")
|
||||||
|
}
|
||||||
// Version-gated correction of the built-in awards' Validate sources, which
|
// Version-gated correction of the built-in awards' Validate sources, which
|
||||||
// an earlier version wrongly set equal to Confirm (so VALIDATED == CONFIRMED
|
// an earlier version wrongly set equal to Confirm (so VALIDATED == CONFIRMED
|
||||||
// even for paper-QSL-only entities). Re-apply the canonical Confirm/Validate
|
// even for paper-QSL-only entities). Re-apply the canonical Confirm/Validate
|
||||||
@@ -2515,6 +2583,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3412,6 +3481,10 @@ func (a *App) invalidateAwardStats() {
|
|||||||
// flips qsl_rcvd flags on existing rows).
|
// flips qsl_rcvd flags on existing rows).
|
||||||
func (a *App) RescanAwards() error {
|
func (a *App) RescanAwards() error {
|
||||||
a.invalidateAwardStats()
|
a.invalidateAwardStats()
|
||||||
|
// Also refresh the materialised award_refs on every QSO (the grid columns read
|
||||||
|
// from there). Manual trigger for when a definition/reference change should be
|
||||||
|
// re-applied to existing rows on demand rather than waiting for the next event.
|
||||||
|
a.recomputeAwardRefsAsync()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3639,71 +3712,229 @@ 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{
|
||||||
switch field {
|
defs: defs,
|
||||||
case "dxcc":
|
metas: a.awardRefMetas(defs),
|
||||||
if n, err := strconv.Atoi(ref); err == nil {
|
fieldByCode: fieldByCode,
|
||||||
return dxcc.NameForDXCC(n)
|
dispByCode: dispByCode,
|
||||||
|
nameOf: func(field, ref string) string {
|
||||||
|
switch field {
|
||||||
|
case "dxcc":
|
||||||
|
if n, err := strconv.Atoi(ref); err == nil {
|
||||||
|
return dxcc.NameForDXCC(n)
|
||||||
|
}
|
||||||
|
case "cont":
|
||||||
|
return continentName(ref)
|
||||||
}
|
}
|
||||||
case "cont":
|
return ""
|
||||||
return continentName(ref)
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// awardRefLabels derives, for one QSO, the map of award code → the reference(s)
|
||||||
|
// that QSO contributes to (joined when several), formatted per the award's
|
||||||
|
// RefDisplay choice (ref / name / both; DXCC shows the country name by default).
|
||||||
|
func (a *App) awardRefLabels(ac awardMatCtx, q qso.QSO) map[string]string {
|
||||||
|
a.enrichQSOForAwards(&q)
|
||||||
|
results := award.Compute(ac.defs, []qso.QSO{q}, ac.metas, ac.nameOf)
|
||||||
|
m := map[string]string{}
|
||||||
|
for i := range results {
|
||||||
|
r := &results[i]
|
||||||
|
code := strings.ToUpper(r.Code)
|
||||||
|
dxccField := ac.fieldByCode[code] == "dxcc"
|
||||||
|
var refs []string
|
||||||
|
for _, rf := range r.Refs {
|
||||||
|
if !rf.Worked {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
label := rf.Ref
|
||||||
|
switch ac.dispByCode[code] {
|
||||||
|
case "name":
|
||||||
|
if rf.Name != "" {
|
||||||
|
label = rf.Name
|
||||||
|
}
|
||||||
|
case "both":
|
||||||
|
if rf.Name != "" {
|
||||||
|
label = rf.Ref + " — " + rf.Name
|
||||||
|
}
|
||||||
|
default: // "" or "ref"
|
||||||
|
if dxccField && rf.Name != "" {
|
||||||
|
label = rf.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refs = append(refs, label)
|
||||||
}
|
}
|
||||||
|
if len(refs) > 0 {
|
||||||
|
m[code] = strings.Join(refs, ", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// awardRefsJSONFor returns awardRefLabels marshalled to a compact JSON object
|
||||||
|
// keyed by award code, or "" when the QSO contributes to no award (blank column).
|
||||||
|
func (a *App) awardRefsJSONFor(ac awardMatCtx, q qso.QSO) string {
|
||||||
|
m := a.awardRefLabels(ac, q)
|
||||||
|
if len(m) == 0 {
|
||||||
return ""
|
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 {
|
err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error {
|
||||||
a.enrichQSOForAwards(&q)
|
if js := a.awardRefsJSONFor(ac, q); js != q.AwardRefs {
|
||||||
results := award.Compute(defs, []qso.QSO{q}, metas, nameOf)
|
changes[q.ID] = js
|
||||||
m := map[string]string{}
|
|
||||||
for i := range results {
|
|
||||||
r := &results[i]
|
|
||||||
code := strings.ToUpper(r.Code)
|
|
||||||
dxccField := fieldByCode[code] == "dxcc"
|
|
||||||
var refs []string
|
|
||||||
for _, rf := range r.Refs {
|
|
||||||
if !rf.Worked {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Per-award display choice: ref (default), name (description), or
|
|
||||||
// both. DXCC keeps showing the country name under the default.
|
|
||||||
label := rf.Ref
|
|
||||||
switch dispByCode[code] {
|
|
||||||
case "name":
|
|
||||||
if rf.Name != "" {
|
|
||||||
label = rf.Name
|
|
||||||
}
|
|
||||||
case "both":
|
|
||||||
if rf.Name != "" {
|
|
||||||
label = rf.Ref + " — " + rf.Name
|
|
||||||
}
|
|
||||||
default: // "" or "ref"
|
|
||||||
if dxccField && rf.Name != "" {
|
|
||||||
label = rf.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
refs = append(refs, label)
|
|
||||||
}
|
|
||||||
if len(refs) > 0 {
|
|
||||||
m[code] = strings.Join(refs, ", ")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if len(m) > 0 {
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("award_refs: recompute for ids failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.qso.SetAwardRefsBatch(a.ctx, changes); err != nil {
|
||||||
|
applog.Printf("award_refs: batch write for ids failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecomputeAllAwardRefs rebuilds every QSO's materialised award references. Run
|
||||||
|
// when an award definition or reference list changes (stored labels would
|
||||||
|
// otherwise go stale) and once after upgrade to backfill existing rows. Rows
|
||||||
|
// whose result is unchanged are skipped; the changed ones are written in a single
|
||||||
|
// transaction (SetAwardRefsBatch) so even a large logbook on a remote MySQL is
|
||||||
|
// one round-trip's worth of work rather than N. Returns how many rows changed.
|
||||||
|
func (a *App) RecomputeAllAwardRefs() (int, error) {
|
||||||
|
if a.qso == nil {
|
||||||
|
return 0, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
ac := a.newAwardMatCtx()
|
||||||
|
// Collect updates during the scan; DON'T write mid-iteration (a nested query on
|
||||||
|
// the same connection can deadlock SQLite / trip MySQL "commands out of sync").
|
||||||
|
changes := map[int64]string{}
|
||||||
|
err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
|
||||||
|
if js := a.awardRefsJSONFor(ac, q); js != q.AwardRefs {
|
||||||
|
changes[q.ID] = js
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := a.qso.SetAwardRefsBatch(a.ctx, changes); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return len(changes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recomputeAwardRefsAsync runs a full recompute off the UI goroutine and, when
|
||||||
|
// done, tells the frontend to reload so the refreshed award columns show. Used
|
||||||
|
// wherever the set of matches could shift for MANY rows at once: an award
|
||||||
|
// definition / reference-list change, or a bulk ADIF import.
|
||||||
|
func (a *App) recomputeAwardRefsAsync() {
|
||||||
|
go func() {
|
||||||
|
n, err := a.RecomputeAllAwardRefs()
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("award_refs: bulk recompute failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "awards:recomputed", n)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyAwardsMaterialized marks (per profile / logbook) that the one-time award_refs
|
||||||
|
// backfill has run, so it doesn't re-scan the whole logbook on every launch.
|
||||||
|
// keyAwardsMaterialized is versioned: bump the suffix to force every operator to
|
||||||
|
// re-materialise award_refs once on next launch. Needed when the stored values
|
||||||
|
// could be stale against the CURRENT award definitions — e.g. rows materialised
|
||||||
|
// before an award gained a new matching rule (DDFM's postal-code OR-rule), which
|
||||||
|
// the one-shot backfill would otherwise never revisit.
|
||||||
|
const keyAwardsMaterialized = "awards.materialized.v2"
|
||||||
|
|
||||||
|
// backfillAwardRefsOnce populates award_refs for QSOs that predate the feature
|
||||||
|
// (or were logged by an older client that didn't materialise them). It runs at
|
||||||
|
// most once per logbook — guarded by a per-profile flag — in the background so it
|
||||||
|
// never delays startup, even on a large remote MySQL logbook.
|
||||||
|
func (a *App) backfillAwardRefsOnce() {
|
||||||
|
if a.qso == nil || a.settings == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if done, _ := a.settings.Get(a.ctx, keyAwardsMaterialized); done == "1" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
n, err := a.RecomputeAllAwardRefs()
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("award_refs: initial backfill failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = a.settings.Set(a.ctx, keyAwardsMaterialized, "1")
|
||||||
|
applog.Printf("award_refs: backfilled %d QSO(s)", n)
|
||||||
|
if a.ctx != nil && n > 0 {
|
||||||
|
wruntime.EventsEmit(a.ctx, "awards:recomputed", n)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AwardRefsForQSOs returns, per QSO id, the award code → reference(s) map. It is
|
||||||
|
// the live (non-materialised) path, kept for callers that want fresh values
|
||||||
|
// without touching the DB. The grid now reads the stored award_refs column
|
||||||
|
// instead, but this stays available and is the single source of the label logic.
|
||||||
|
func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error) {
|
||||||
|
out := map[int64]map[string]string{}
|
||||||
|
if a.qso == nil || len(ids) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
ac := a.newAwardMatCtx()
|
||||||
|
err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error {
|
||||||
|
if m := a.awardRefLabels(ac, q); len(m) > 0 {
|
||||||
out[q.ID] = m
|
out[q.ID] = m
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -3749,6 +3980,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")
|
||||||
}
|
}
|
||||||
@@ -3785,7 +4027,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 {
|
||||||
@@ -3795,6 +4037,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3838,6 +4081,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3881,6 +4125,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4016,6 +4261,65 @@ func (a *App) ExportAward(code string) (string, error) {
|
|||||||
return a.exportAwardBundle([]string{code}, "OpsLog_award_"+code+".json", "Export award "+code)
|
return a.exportAwardBundle([]string{code}, "OpsLog_award_"+code+".json", "Export award "+code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportAwardForCatalog writes ONE award as a ready-to-ship CATALOG file — the
|
||||||
|
// exact shape internal/award/catalog/*.json use ({"def":{…},"references":[…]}).
|
||||||
|
//
|
||||||
|
// The workflow it enables: edit an award in the UI, call this with the NEXT
|
||||||
|
// version number, and paste the file over that award's catalog JSON. A new OpsLog
|
||||||
|
// release then carries the change to the whole team: every operator whose copy is
|
||||||
|
// unedited auto-upgrades to it (mergeCatalog), and those who edited it keep theirs
|
||||||
|
// but are offered the update.
|
||||||
|
//
|
||||||
|
// Two things a plain export/mirror can't do and this does, both essential for a
|
||||||
|
// catalog file: it STAMPS the award's Version (the UI save deliberately never
|
||||||
|
// bumps it) and it CLEARS user_edited, so a fresh install seeded from this file is
|
||||||
|
// NOT pre-flagged as the operator's own work — which would otherwise freeze it out
|
||||||
|
// of every future catalog update.
|
||||||
|
func (a *App) ExportAwardForCatalog(code string, version int) (string, error) {
|
||||||
|
if a.awardRefs == nil || a.ctx == nil {
|
||||||
|
return "", fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
code = strings.ToUpper(strings.TrimSpace(code))
|
||||||
|
var def award.Def
|
||||||
|
found := false
|
||||||
|
for _, d := range a.awardDefs() {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(d.Code), code) {
|
||||||
|
def, found = d, true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return "", fmt.Errorf("unknown award %q", code)
|
||||||
|
}
|
||||||
|
def.Version = version
|
||||||
|
def.UserEdited = false // a catalog seed is not "the operator's edit"
|
||||||
|
def.Builtin = true
|
||||||
|
refs, err := a.awardRefs.List(a.ctx, code)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("load references: %w", err)
|
||||||
|
}
|
||||||
|
entry := struct {
|
||||||
|
Def award.Def `json:"def"`
|
||||||
|
References []awardref.Ref `json:"references,omitempty"`
|
||||||
|
}{Def: def, References: refs}
|
||||||
|
b, err := json.MarshalIndent(entry, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path, err := wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
|
||||||
|
DefaultFilename: strings.ToLower(code) + ".json",
|
||||||
|
Title: "Export " + code + " for the catalog",
|
||||||
|
Filters: []wruntime.FileFilter{{DisplayName: "JSON (*.json)", Pattern: "*.json"}},
|
||||||
|
})
|
||||||
|
if err != nil || strings.TrimSpace(path) == "" {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, b, 0o644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
// exportAwardBundle writes the given award codes (nil = all) to a JSON bundle.
|
// exportAwardBundle writes the given award codes (nil = all) to a JSON bundle.
|
||||||
func (a *App) exportAwardBundle(codes []string, defaultName, title string) (string, error) {
|
func (a *App) exportAwardBundle(codes []string, defaultName, title string) (string, error) {
|
||||||
if a.awardRefs == nil {
|
if a.awardRefs == nil {
|
||||||
@@ -4485,6 +4789,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
|
||||||
}
|
}
|
||||||
@@ -4812,6 +5117,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
|
||||||
}
|
}
|
||||||
@@ -4961,7 +5267,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
|
||||||
@@ -8760,6 +9070,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
|
||||||
}
|
}
|
||||||
@@ -8835,6 +9146,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
|
||||||
}
|
}
|
||||||
@@ -9290,6 +9602,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
|||||||
}
|
}
|
||||||
q.ID = id
|
q.ID = id
|
||||||
a.noteLiveQSO() // multi-op: flip this operator back "online"
|
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)
|
||||||
|
|||||||
+67
-28
@@ -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, GetLiveStatusEnabled, LiveLastQSOAgeSec,
|
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);
|
||||||
@@ -1234,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
|
||||||
@@ -1426,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)); });
|
||||||
@@ -2025,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).
|
||||||
@@ -2643,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
|
||||||
@@ -2726,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 },
|
||||||
@@ -2771,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;
|
||||||
@@ -3610,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}
|
||||||
@@ -4636,6 +4674,7 @@ 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}
|
||||||
|
|||||||
@@ -33,6 +33,37 @@ function pretty(name: string): string {
|
|||||||
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
|
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PortBtn is defined at MODULE scope on purpose. Defined inside AntGeniusPanel it
|
||||||
|
// would be a new component *type* on every render, so React would unmount and
|
||||||
|
// remount every port button each time the panel re-renders — harmless when that's
|
||||||
|
// rare, but with the CW decoder running the parent re-renders many times a second
|
||||||
|
// and the buttons were being torn down mid-click (mousedown and mouseup landing on
|
||||||
|
// different element instances), so antenna changes silently did nothing.
|
||||||
|
function PortBtn({ port, index, active, tx, onActivate, t }: {
|
||||||
|
port: 1 | 2; index: number; active: boolean; tx: boolean;
|
||||||
|
onActivate: (port: number, antenna: number) => void;
|
||||||
|
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||||
|
}) {
|
||||||
|
const letter = port === 1 ? 'A' : 'B';
|
||||||
|
const cls = tx
|
||||||
|
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
|
||||||
|
: active
|
||||||
|
? (port === 1
|
||||||
|
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
||||||
|
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
|
||||||
|
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onActivate(port, active ? 0 : index)}
|
||||||
|
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
|
||||||
|
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
|
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
|
||||||
// match the app's light theme with soft gradients + glows. Each antenna row has
|
// match the app's light theme with soft gradients + glows. Each antenna row has
|
||||||
// a port-A button (left) and port-B button (right). Colours: green = selected on
|
// a port-A button (left) and port-B button (right). Colours: green = selected on
|
||||||
@@ -61,27 +92,6 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
|
|||||||
if (filtered.length > 0) list = filtered;
|
if (filtered.length > 0) list = filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
|
|
||||||
const letter = port === 1 ? 'A' : 'B';
|
|
||||||
const cls = tx
|
|
||||||
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
|
|
||||||
: active
|
|
||||||
? (port === 1
|
|
||||||
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
|
||||||
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
|
|
||||||
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onActivate(port, active ? 0 : index)}
|
|
||||||
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
|
|
||||||
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
|
||||||
>
|
|
||||||
{letter}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
||||||
@@ -124,11 +134,11 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
|
|||||||
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
||||||
return (
|
return (
|
||||||
<div key={a.index} className="flex items-center gap-1.5">
|
<div key={a.index} className="flex items-center gap-1.5">
|
||||||
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} />
|
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} onActivate={onActivate} t={t} />
|
||||||
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
|
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
|
||||||
{pretty(a.name)}
|
{pretty(a.name)}
|
||||||
</div>
|
</div>
|
||||||
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} />
|
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} onActivate={onActivate} t={t} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ListCountries, DXCCForCountry, DXCCName,
|
ListCountries, DXCCForCountry, DXCCName,
|
||||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||||
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
||||||
|
ExportAwardForCatalog,
|
||||||
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ export type AwardDef = {
|
|||||||
or_rules?: AwardOrRule[];
|
or_rules?: AwardOrRule[];
|
||||||
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
||||||
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
||||||
total: number; builtin?: boolean;
|
total: number; builtin?: boolean; version?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AwardOrRule = {
|
type AwardOrRule = {
|
||||||
@@ -155,6 +156,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [updating, setUpdating] = useState<string | null>(null);
|
const [updating, setUpdating] = useState<string | null>(null);
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
|
// Version to stamp into a "publish for catalog" export — defaults to one past
|
||||||
|
// the selected award's current version whenever the selection changes.
|
||||||
|
const [catVer, setCatVer] = useState('1');
|
||||||
|
|
||||||
// The err banner doubles as a success/notice area (export path, import counts,
|
// The err banner doubles as a success/notice area (export path, import counts,
|
||||||
// "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay
|
// "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay
|
||||||
@@ -212,6 +216,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
|
|
||||||
const cur = defs[sel];
|
const cur = defs[sel];
|
||||||
const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null;
|
const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null;
|
||||||
|
useEffect(() => { setCatVer(String((cur?.version ?? 0) + 1)); }, [cur?.code]);
|
||||||
|
|
||||||
// ── Award tester: run the award's rules against a real QSO and show every step.
|
// ── Award tester: run the award's rules against a real QSO and show every step.
|
||||||
type Rejected = { candidate: string; reason: string };
|
type Rejected = { candidate: string; reason: string };
|
||||||
@@ -299,6 +304,19 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
if (p) setErr(t('awed.exportedTo', { path: p }));
|
if (p) setErr(t('awed.exportedTo', { path: p }));
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
|
||||||
|
// paste over internal/award/catalog/<code>.json. A new release then ships it to
|
||||||
|
// the whole team (unedited copies auto-upgrade; edited ones are offered it).
|
||||||
|
async function exportForCatalog() {
|
||||||
|
setErr('');
|
||||||
|
if (!cur) return;
|
||||||
|
const v = Math.trunc(Number(catVer));
|
||||||
|
if (!Number.isFinite(v) || v < 1) { setErr(t('awed.catalogBadVersion')); return; }
|
||||||
|
try {
|
||||||
|
const p = await ExportAwardForCatalog(cur.code.trim().toUpperCase(), v);
|
||||||
|
if (p) setErr(t('awed.catalogExportedTo', { path: p }));
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
// Import: LOOK FIRST, then ask.
|
// Import: LOOK FIRST, then ask.
|
||||||
//
|
//
|
||||||
// This used to merge by code with "imported wins", silently — import a WAPC
|
// This used to merge by code with "imported wins", silently — import a WAPC
|
||||||
@@ -352,7 +370,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
<DialogContent className="max-w-6xl w-[95vw] max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||||
<DialogHeader className="px-5 py-3 border-b">
|
<DialogHeader className="px-5 py-3 border-b">
|
||||||
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
|
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -726,6 +744,18 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
title={t('awed.awardsFolderTip')}>
|
title={t('awed.awardsFolderTip')}>
|
||||||
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
|
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Publish the selected award to the catalog: stamp a version and write a
|
||||||
|
file to paste over internal/award/catalog/<code>.json, so a release
|
||||||
|
ships your change to the whole team. */}
|
||||||
|
{cur && (
|
||||||
|
<div className="flex items-center gap-1" title={t('awed.catalogPublishTip')}>
|
||||||
|
<input type="number" min={1} value={catVer} onChange={(e) => setCatVer(e.target.value)}
|
||||||
|
className="h-8 w-14 rounded border border-input bg-background px-1.5 text-xs font-mono" />
|
||||||
|
<Button variant="outline" onClick={exportForCatalog}>
|
||||||
|
<Download className="size-3.5 mr-1" /> {t('awed.catalogPublish')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
|
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
|
||||||
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
|
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
|
||||||
|
|||||||
@@ -377,11 +377,13 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
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);
|
||||||
@@ -389,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);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||||
ChevronDown, ChevronRight,
|
ChevronDown, ChevronRight,
|
||||||
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
||||||
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff,
|
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff, Pencil,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
ConnectClusterServer, DisconnectClusterServer,
|
ConnectClusterServer, DisconnectClusterServer,
|
||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||||
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase,
|
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, RenameDatabase,
|
||||||
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
|
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
|
||||||
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||||
@@ -3978,6 +3978,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
setDbMsg(p);
|
setDbMsg(p);
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
// Rename the CURRENT database (keeps all config), unlike New database which
|
||||||
|
// starts empty. The old file is removed on the next launch.
|
||||||
|
async function renameDb() {
|
||||||
|
try {
|
||||||
|
const p = await PickSaveDatabase();
|
||||||
|
if (!p) return;
|
||||||
|
await RenameDatabase(p);
|
||||||
|
await refreshDb();
|
||||||
|
setDbMsg(p);
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
async function resetDefault() {
|
async function resetDefault() {
|
||||||
try {
|
try {
|
||||||
await ResetDatabaseToDefault();
|
await ResetDatabaseToDefault();
|
||||||
@@ -4056,6 +4067,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
|
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
|
||||||
<Button variant="outline" size="sm" onClick={openExisting}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button>
|
<Button variant="outline" size="sm" onClick={openExisting}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={renameDb} title={t('db.renameTip')}><Pencil className="size-3.5" /> {t('db.rename')}</Button>
|
||||||
<Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
|
<Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
|
||||||
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
|
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ const en: Dict = {
|
|||||||
'db.backend': 'Backend', 'db.configLocal': 'settings stay in the local SQLite file', 'db.connectUse': 'Connect & use',
|
'db.backend': 'Backend', 'db.configLocal': 'settings stay in the local SQLite file', 'db.connectUse': 'Connect & use',
|
||||||
'db.savedRestart': 'Saved. Restart OpsLog to open this logbook:', 'db.restartNow': 'Restart OpsLog', 'db.restartHint': '(reopens automatically)',
|
'db.savedRestart': 'Saved. Restart OpsLog to open this logbook:', 'db.restartNow': 'Restart OpsLog', 'db.restartHint': '(reopens automatically)',
|
||||||
'db.current': 'Current database', 'db.customLoc': '(custom location)', 'db.default': '(default)', 'db.defaultLabel': 'Default:',
|
'db.current': 'Current database', 'db.customLoc': '(custom location)', 'db.default': '(default)', 'db.defaultLabel': 'Default:',
|
||||||
'db.newDb': 'New database…', 'db.openExisting': 'Open existing…', 'db.saveCopy': 'Save a copy & switch…', 'db.resetDefault': 'Reset to default', 'db.quitNow': 'Quit now',
|
'db.newDb': 'New database…', 'db.openExisting': 'Open existing…', 'db.saveCopy': 'Save a copy & switch…', 'db.rename': 'Rename…', 'db.renameTip': 'Rename this database (keeps all your config). The old file is removed on the next launch.', 'db.resetDefault': 'Reset to default', 'db.quitNow': 'Quit now',
|
||||||
'db.host': 'Host', 'db.port': 'Port', 'db.database': 'Database', 'db.user': 'User', 'db.testCreate': 'Test & create database', 'db.testing': 'Testing…', 'db.connectedReady': 'Connected — database ready ✓', 'db.failed': 'Failed: ',
|
'db.host': 'Host', 'db.port': 'Port', 'db.database': 'Database', 'db.user': 'User', 'db.testCreate': 'Test & create database', 'db.testing': 'Testing…', 'db.connectedReady': 'Connected — database ready ✓', 'db.failed': 'Failed: ',
|
||||||
'db.mysqlHint': 'Several OpsLog instances pointed at one MySQL database see each other\'s QSOs live (refreshed every 2 s). Test & create the database, then Save & switch logbook above to start logging there.',
|
'db.mysqlHint': 'Several OpsLog instances pointed at one MySQL database see each other\'s QSOs live (refreshed every 2 s). Test & create the database, then Save & switch logbook above to start logging there.',
|
||||||
'db.dataLocation': 'Data location', 'db.currentDataDir': 'Current data directory',
|
'db.dataLocation': 'Data location', 'db.currentDataDir': 'Current data directory',
|
||||||
@@ -307,6 +307,10 @@ const en: Dict = {
|
|||||||
'awed.builtin': 'Built-in',
|
'awed.builtin': 'Built-in',
|
||||||
'awed.awardsFolder': 'Awards folder',
|
'awed.awardsFolder': 'Awards folder',
|
||||||
'awed.awardsFolderTip': 'Every award you create is saved here as JSON, automatically. To share one, send the file. To receive one, use Import.',
|
'awed.awardsFolderTip': 'Every award you create is saved here as JSON, automatically. To share one, send the file. To receive one, use Import.',
|
||||||
|
'awed.catalogPublish': 'Publish to catalog…',
|
||||||
|
'awed.catalogPublishTip': 'Export this award as a catalog file (with the version on the left), to paste over internal/award/catalog/<code>.json. A new release then ships it to the whole team — copies nobody edited auto-upgrade, edited ones are offered it.',
|
||||||
|
'awed.catalogExportedTo': 'Catalog file written to:\n{path}\n\nPaste it over internal/award/catalog/<code>.json, then build and release.',
|
||||||
|
'awed.catalogBadVersion': 'Enter a version number (1 or more).',
|
||||||
'awed.builtinTip': 'Tick before shipping this award in the catalog. Left off, a “Reset to defaults” DELETES it on the user machine — even though you shipped it.',
|
'awed.builtinTip': 'Tick before shipping this award in the catalog. Left off, a “Reset to defaults” DELETES it on the user machine — even though you shipped it.',
|
||||||
'awed.protectedFlag': 'Protected',
|
'awed.protectedFlag': 'Protected',
|
||||||
'awed.protectedTip': 'Protected awards cannot be deleted from the editor.',
|
'awed.protectedTip': 'Protected awards cannot be deleted from the editor.',
|
||||||
@@ -540,7 +544,7 @@ const fr: Dict = {
|
|||||||
'db.backend': 'Base de données', 'db.configLocal': 'les réglages restent dans le fichier SQLite local', 'db.connectUse': 'Connecter et utiliser',
|
'db.backend': 'Base de données', 'db.configLocal': 'les réglages restent dans le fichier SQLite local', 'db.connectUse': 'Connecter et utiliser',
|
||||||
'db.savedRestart': 'Enregistré. Redémarrez OpsLog pour ouvrir cette base :', 'db.restartNow': 'Redémarrer OpsLog', 'db.restartHint': '(réouverture automatique)',
|
'db.savedRestart': 'Enregistré. Redémarrez OpsLog pour ouvrir cette base :', 'db.restartNow': 'Redémarrer OpsLog', 'db.restartHint': '(réouverture automatique)',
|
||||||
'db.current': 'Base actuelle', 'db.customLoc': '(emplacement personnalisé)', 'db.default': '(par défaut)', 'db.defaultLabel': 'Par défaut :',
|
'db.current': 'Base actuelle', 'db.customLoc': '(emplacement personnalisé)', 'db.default': '(par défaut)', 'db.defaultLabel': 'Par défaut :',
|
||||||
'db.newDb': 'Nouvelle base…', 'db.openExisting': 'Ouvrir existante…', 'db.saveCopy': 'Enregistrer une copie & basculer…', 'db.resetDefault': 'Réinitialiser par défaut', 'db.quitNow': 'Quitter maintenant',
|
'db.newDb': 'Nouvelle base…', 'db.openExisting': 'Ouvrir existante…', 'db.saveCopy': 'Enregistrer une copie & basculer…', 'db.rename': 'Renommer…', 'db.renameTip': 'Renomme cette base (garde toute ta config). L’ancien fichier est supprimé au prochain lancement.', 'db.resetDefault': 'Réinitialiser par défaut', 'db.quitNow': 'Quitter maintenant',
|
||||||
'db.host': 'Hôte', 'db.port': 'Port', 'db.database': 'Base', 'db.user': 'Utilisateur', 'db.testCreate': 'Tester & créer la base', 'db.testing': 'Test…', 'db.connectedReady': 'Connecté — base prête ✓', 'db.failed': 'Échec : ',
|
'db.host': 'Hôte', 'db.port': 'Port', 'db.database': 'Base', 'db.user': 'Utilisateur', 'db.testCreate': 'Tester & créer la base', 'db.testing': 'Test…', 'db.connectedReady': 'Connecté — base prête ✓', 'db.failed': 'Échec : ',
|
||||||
'db.mysqlHint': "Plusieurs instances d'OpsLog pointées vers une même base MySQL voient leurs QSO en direct (rafraîchi toutes les 2 s). Teste & crée la base, puis Enregistrer & basculer le journal ci-dessus pour commencer à logger là-bas.",
|
'db.mysqlHint': "Plusieurs instances d'OpsLog pointées vers une même base MySQL voient leurs QSO en direct (rafraîchi toutes les 2 s). Teste & crée la base, puis Enregistrer & basculer le journal ci-dessus pour commencer à logger là-bas.",
|
||||||
'db.dataLocation': 'Emplacement des données', 'db.currentDataDir': 'Dossier de données actuel',
|
'db.dataLocation': 'Emplacement des données', 'db.currentDataDir': 'Dossier de données actuel',
|
||||||
@@ -598,6 +602,10 @@ const fr: Dict = {
|
|||||||
'awed.builtin': 'Intégré',
|
'awed.builtin': 'Intégré',
|
||||||
'awed.awardsFolder': 'Dossier awards',
|
'awed.awardsFolder': 'Dossier awards',
|
||||||
'awed.awardsFolderTip': 'Chaque diplôme que tu crées est enregistré ici en JSON, automatiquement. Pour en partager un : envoie le fichier. Pour en recevoir un : Importer.',
|
'awed.awardsFolderTip': 'Chaque diplôme que tu crées est enregistré ici en JSON, automatiquement. Pour en partager un : envoie le fichier. Pour en recevoir un : Importer.',
|
||||||
|
'awed.catalogPublish': 'Publier au catalogue…',
|
||||||
|
'awed.catalogPublishTip': "Exporte ce diplôme en fichier catalogue (avec la version à gauche), à coller par-dessus internal/award/catalog/<code>.json. Une nouvelle release le diffuse à toute l'équipe — les copies non modifiées s'upgradent seules, les modifiées se le voient proposer.",
|
||||||
|
'awed.catalogExportedTo': 'Fichier catalogue écrit dans :\n{path}\n\nColle-le par-dessus internal/award/catalog/<code>.json, puis build et release.',
|
||||||
|
'awed.catalogBadVersion': 'Entre un numéro de version (1 ou plus).',
|
||||||
'awed.builtinTip': 'À cocher avant de livrer ce diplôme dans le catalogue. Sans ça, un « Réinitialiser par défaut » le SUPPRIME chez l’utilisateur — alors que tu l’as livré.',
|
'awed.builtinTip': 'À cocher avant de livrer ce diplôme dans le catalogue. Sans ça, un « Réinitialiser par défaut » le SUPPRIME chez l’utilisateur — alors que tu l’as livré.',
|
||||||
'awed.protectedFlag': 'Protégé',
|
'awed.protectedFlag': 'Protégé',
|
||||||
'awed.protectedTip': 'Un diplôme protégé ne peut pas être supprimé depuis l’éditeur.',
|
'awed.protectedTip': 'Un diplôme protégé ne peut pas être supprimé depuis l’éditeur.',
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
'opslog.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.3';
|
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
+6
@@ -168,6 +168,8 @@ export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):
|
|||||||
|
|
||||||
export function ExportAward(arg1:string):Promise<string>;
|
export function ExportAward(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function ExportAwardForCatalog(arg1:string,arg2:number):Promise<string>;
|
||||||
|
|
||||||
export function ExportAwards():Promise<string>;
|
export function ExportAwards():Promise<string>;
|
||||||
|
|
||||||
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
|
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
|
||||||
@@ -666,6 +668,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>;
|
||||||
@@ -674,6 +678,8 @@ export function ReloadUDPIntegrations():Promise<Array<string>>;
|
|||||||
|
|
||||||
export function RemovePassphrase(arg1:string):Promise<void>;
|
export function RemovePassphrase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function RenameDatabase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
|
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
|
||||||
|
|
||||||
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
||||||
|
|||||||
@@ -294,6 +294,10 @@ export function ExportAward(arg1) {
|
|||||||
return window['go']['main']['App']['ExportAward'](arg1);
|
return window['go']['main']['App']['ExportAward'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ExportAwardForCatalog(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['ExportAwardForCatalog'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function ExportAwards() {
|
export function ExportAwards() {
|
||||||
return window['go']['main']['App']['ExportAwards']();
|
return window['go']['main']['App']['ExportAwards']();
|
||||||
}
|
}
|
||||||
@@ -1290,6 +1294,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']();
|
||||||
}
|
}
|
||||||
@@ -1306,6 +1314,10 @@ export function RemovePassphrase(arg1) {
|
|||||||
return window['go']['main']['App']['RemovePassphrase'](arg1);
|
return window['go']['main']['App']['RemovePassphrase'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RenameDatabase(arg1) {
|
||||||
|
return window['go']['main']['App']['RenameDatabase'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function RenderEQSL(arg1, arg2) {
|
export function RenderEQSL(arg1, arg2) {
|
||||||
return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
|
return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3648,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
|
||||||
@@ -3785,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,27 @@
|
|||||||
"name": "Départements Français Métropolitains",
|
"name": "Départements Français Métropolitains",
|
||||||
"valid": true,
|
"valid": true,
|
||||||
"protected": true,
|
"protected": true,
|
||||||
|
"ref_display": "name",
|
||||||
"type": "QSOFIELDS",
|
"type": "QSOFIELDS",
|
||||||
"field": "note",
|
"field": "note",
|
||||||
"pattern": "(?i)\\b(D\\d{1,2}[AB]?)\\b",
|
"pattern": "(?i)\\b(D\\d{1,2}[AB]?)\\b",
|
||||||
|
"or_rules": [
|
||||||
|
{
|
||||||
|
"field": "address",
|
||||||
|
"match_by": "code",
|
||||||
|
"pattern": "\\b(\\d{2})\\d{3}\\b",
|
||||||
|
"prefix": "D"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "code",
|
||||||
|
"pattern": "\\b(\\d{2})\\d{3}\\b",
|
||||||
|
"prefix": "D"
|
||||||
|
}
|
||||||
|
],
|
||||||
"dxcc_filter": [
|
"dxcc_filter": [
|
||||||
227
|
227,
|
||||||
|
214
|
||||||
],
|
],
|
||||||
"confirm": [
|
"confirm": [
|
||||||
"lotw",
|
"lotw",
|
||||||
@@ -18,6 +34,777 @@
|
|||||||
"lotw"
|
"lotw"
|
||||||
],
|
],
|
||||||
"total": 96,
|
"total": 96,
|
||||||
"builtin": true
|
"builtin": true,
|
||||||
}
|
"version": 2
|
||||||
}
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"code": "D01",
|
||||||
|
"name": "Ain",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D02",
|
||||||
|
"name": "Aisne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D03",
|
||||||
|
"name": "Allier",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D04",
|
||||||
|
"name": "Alpes-de-Haute-Provence",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D05",
|
||||||
|
"name": "Hautes-Alpes",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D06",
|
||||||
|
"name": "Alpes-Maritimes",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D07",
|
||||||
|
"name": "Ardèche",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D08",
|
||||||
|
"name": "Ardennes",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D09",
|
||||||
|
"name": "Ariège",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D10",
|
||||||
|
"name": "Aube",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D11",
|
||||||
|
"name": "Aude",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D12",
|
||||||
|
"name": "Aveyron",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D13",
|
||||||
|
"name": "Bouches-du-Rhône",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D14",
|
||||||
|
"name": "Calvados",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D15",
|
||||||
|
"name": "Cantal",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D16",
|
||||||
|
"name": "Charente",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D17",
|
||||||
|
"name": "Charente-Maritime",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D18",
|
||||||
|
"name": "Cher",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D19",
|
||||||
|
"name": "Corrèze",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D21",
|
||||||
|
"name": "Côte-d'Or",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D22",
|
||||||
|
"name": "Côtes-d'Armor",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D23",
|
||||||
|
"name": "Creuse",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D24",
|
||||||
|
"name": "Dordogne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D25",
|
||||||
|
"name": "Doubs",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D26",
|
||||||
|
"name": "Drôme",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D27",
|
||||||
|
"name": "Eure",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D28",
|
||||||
|
"name": "Eure-et-Loir",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D29",
|
||||||
|
"name": "Finistère",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D2A",
|
||||||
|
"name": "Corse-du-Sud",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D2B",
|
||||||
|
"name": "Haute-Corse",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D30",
|
||||||
|
"name": "Gard",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D31",
|
||||||
|
"name": "Haute-Garonne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D32",
|
||||||
|
"name": "Gers",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D33",
|
||||||
|
"name": "Gironde",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D34",
|
||||||
|
"name": "Hérault",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D35",
|
||||||
|
"name": "Ille-et-Vilaine",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D36",
|
||||||
|
"name": "Indre",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D37",
|
||||||
|
"name": "Indre-et-Loire",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D38",
|
||||||
|
"name": "Isère",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D39",
|
||||||
|
"name": "Jura",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D40",
|
||||||
|
"name": "Landes",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D41",
|
||||||
|
"name": "Loir-et-Cher",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D42",
|
||||||
|
"name": "Loire",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D43",
|
||||||
|
"name": "Haute-Loire",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D44",
|
||||||
|
"name": "Loire-Atlantique",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D45",
|
||||||
|
"name": "Loiret",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D46",
|
||||||
|
"name": "Lot",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D47",
|
||||||
|
"name": "Lot-et-Garonne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D48",
|
||||||
|
"name": "Lozère",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D49",
|
||||||
|
"name": "Maine-et-Loire",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D50",
|
||||||
|
"name": "Manche",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D51",
|
||||||
|
"name": "Marne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D52",
|
||||||
|
"name": "Haute-Marne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D53",
|
||||||
|
"name": "Mayenne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D54",
|
||||||
|
"name": "Meurthe-et-Moselle",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D55",
|
||||||
|
"name": "Meuse",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D56",
|
||||||
|
"name": "Morbihan",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D57",
|
||||||
|
"name": "Moselle",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D58",
|
||||||
|
"name": "Nièvre",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D59",
|
||||||
|
"name": "Nord",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D60",
|
||||||
|
"name": "Oise",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D61",
|
||||||
|
"name": "Orne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D62",
|
||||||
|
"name": "Pas-de-Calais",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D63",
|
||||||
|
"name": "Puy-de-Dôme",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D64",
|
||||||
|
"name": "Pyrénées-Atlantiques",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D65",
|
||||||
|
"name": "Hautes-Pyrénées",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D66",
|
||||||
|
"name": "Pyrénées-Orientales",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D67",
|
||||||
|
"name": "Bas-Rhin",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D68",
|
||||||
|
"name": "Haut-Rhin",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D69",
|
||||||
|
"name": "Rhône",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D70",
|
||||||
|
"name": "Haute-Saône",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D71",
|
||||||
|
"name": "Saône-et-Loire",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D72",
|
||||||
|
"name": "Sarthe",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D73",
|
||||||
|
"name": "Savoie",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D74",
|
||||||
|
"name": "Haute-Savoie",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D75",
|
||||||
|
"name": "Paris",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D76",
|
||||||
|
"name": "Seine-Maritime",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D77",
|
||||||
|
"name": "Seine-et-Marne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D78",
|
||||||
|
"name": "Yvelines",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D79",
|
||||||
|
"name": "Deux-Sèvres",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D80",
|
||||||
|
"name": "Somme",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D81",
|
||||||
|
"name": "Tarn",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D82",
|
||||||
|
"name": "Tarn-et-Garonne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D83",
|
||||||
|
"name": "Var",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D84",
|
||||||
|
"name": "Vaucluse",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D85",
|
||||||
|
"name": "Vendée",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D86",
|
||||||
|
"name": "Vienne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D87",
|
||||||
|
"name": "Haute-Vienne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D88",
|
||||||
|
"name": "Vosges",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D89",
|
||||||
|
"name": "Yonne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D90",
|
||||||
|
"name": "Territoire de Belfort",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D91",
|
||||||
|
"name": "Essonne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D92",
|
||||||
|
"name": "Hauts-de-Seine",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D93",
|
||||||
|
"name": "Seine-Saint-Denis",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D94",
|
||||||
|
"name": "Val-de-Marne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D95",
|
||||||
|
"name": "Val-d'Oise",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+11
-22
@@ -87,14 +87,6 @@ func (o *OmniRig) Connect() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isIC7610 reports whether the connected rig is an IC-7610. OmniRig's generic
|
|
||||||
// Freq property reads the wrong VFO on the 7610 (its Main/Sub model confuses the
|
|
||||||
// stock ini), so we read VFO A explicitly for it instead — matching what Log4OM
|
|
||||||
// shows.
|
|
||||||
func (o *OmniRig) isIC7610() bool {
|
|
||||||
return strings.Contains(strings.ToUpper(o.rigType), "7610")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *OmniRig) Disconnect() {
|
func (o *OmniRig) Disconnect() {
|
||||||
if o.rig != nil {
|
if o.rig != nil {
|
||||||
o.rig.Release()
|
o.rig.Release()
|
||||||
@@ -204,23 +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 {
|
||||||
// IC-7610 quirk: OmniRig's generic Freq reports VFO B (its Main/Sub model
|
case freqA != 0:
|
||||||
// confuses the stock ini), so OpsLog showed the wrong VFO. Read VFO A
|
|
||||||
// explicitly for the 7610 — what the operator actually wants to see.
|
|
||||||
if o.isIC7610() && freqA != 0 {
|
|
||||||
s.FreqHz = freqA
|
s.FreqHz = freqA
|
||||||
}
|
case freqMain != 0:
|
||||||
if s.FreqHz == 0 {
|
s.FreqHz = freqMain
|
||||||
if s.Vfo == "B" || s.Vfo == "BB" {
|
default:
|
||||||
s.FreqHz = freqB
|
s.FreqHz = freqB
|
||||||
} else {
|
|
||||||
s.FreqHz = freqA
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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;
|
||||||
+58
-2
@@ -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)
|
||||||
@@ -813,6 +824,49 @@ func (r *Repo) SetExtra(ctx context.Context, id int64, key, value string) error
|
|||||||
return nil
|
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 {
|
||||||
@@ -2374,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(
|
||||||
@@ -2401,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)
|
||||||
}
|
}
|
||||||
@@ -2598,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-13
@@ -202,33 +202,37 @@ func (a *App) publishLiveStatus() {
|
|||||||
}
|
}
|
||||||
a.liveActMu.Unlock()
|
a.liveActMu.Unlock()
|
||||||
lastQSO := a.liveLastQSOTime() // authoritative (in-memory OR shared DB)
|
lastQSO := a.liveLastQSOTime() // authoritative (in-memory OR shared DB)
|
||||||
// Online = a new contact was logged within the window. An operator who leaves
|
// On air = a new contact was logged within the window. An operator who leaves
|
||||||
// the log open but stops working shows offline after `liveOnlineWindow`; the
|
// the log open but stops working goes offline after `liveOnlineWindow`; the next
|
||||||
// next QSO flips them back on. never-logged (zero time) → offline.
|
// QSO puts them back on. never-logged (zero time) → offline.
|
||||||
online := 0
|
online := !lastQSO.IsZero() && time.Since(lastQSO) < liveOnlineWindow
|
||||||
var lastQSOArg any
|
|
||||||
if !lastQSO.IsZero() {
|
|
||||||
lastQSOArg = lastQSO.UTC()
|
|
||||||
if time.Since(lastQSO) < liveOnlineWindow {
|
|
||||||
online = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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, online, version, last_qso_at, 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), online=VALUES(online), version=VALUES(version), "+
|
"band=VALUES(band), mode=VALUES(mode), online=VALUES(online), version=VALUES(version), "+
|
||||||
"last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()",
|
"last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()",
|
||||||
op, station, freqHz, band, mode, online, appVersion, lastQSOArg)
|
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 online=%d", op, station, freqHz, band, mode, online)
|
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.
|
// LiveStation is one operator's live status for the multi-op "who's on air" widget.
|
||||||
|
|||||||
+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.3"
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user