Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5ffe81c72 | ||
|
|
656e238a59 | ||
|
|
c170d6091e | ||
|
|
ae60d58893 | ||
|
|
68982e9a85 | ||
|
|
eb9e2db41a | ||
|
|
b59c6856bd | ||
|
|
a00817b93e | ||
|
|
7398261c50 | ||
|
|
73f3ec51f7 |
@@ -42,6 +42,7 @@ import (
|
|||||||
"hamlog/internal/operating"
|
"hamlog/internal/operating"
|
||||||
"hamlog/internal/pota"
|
"hamlog/internal/pota"
|
||||||
"hamlog/internal/lotwusers"
|
"hamlog/internal/lotwusers"
|
||||||
|
"hamlog/internal/offlineq"
|
||||||
"hamlog/internal/powergenius"
|
"hamlog/internal/powergenius"
|
||||||
"hamlog/internal/profile"
|
"hamlog/internal/profile"
|
||||||
"hamlog/internal/qslcard"
|
"hamlog/internal/qslcard"
|
||||||
@@ -456,6 +457,9 @@ type App struct {
|
|||||||
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
|
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
|
||||||
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
|
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
|
||||||
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
|
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
|
||||||
|
offlineQ *offlineq.Queue // ADIF outbox: QSOs logged while the DB was unreachable
|
||||||
|
offlineMode bool // last write failed because the DB was unreachable
|
||||||
|
|
||||||
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
|
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
|
||||||
catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter
|
catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter
|
||||||
catFlexDecodeSecs int // decode spot display duration (seconds)
|
catFlexDecodeSecs int // decode spot display duration (seconds)
|
||||||
@@ -733,6 +737,7 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
a.qslTemplates = qslcard.NewRepo(conn)
|
a.qslTemplates = qslcard.NewRepo(conn)
|
||||||
a.migrateAwardDefs() // upgrade legacy award definitions (enable + new fields)
|
a.migrateAwardDefs() // upgrade legacy award definitions (enable + new fields)
|
||||||
a.seedBuiltinReferences() // first-run: populate built-in award reference lists
|
a.seedBuiltinReferences() // first-run: populate built-in award reference lists
|
||||||
|
a.mirrorAwards() // keep <data>/awards/*.json in step with the database
|
||||||
a.operating = operating.NewRepo(conn)
|
a.operating = operating.NewRepo(conn)
|
||||||
a.udpRepo = udp.NewRepo(conn)
|
a.udpRepo = udp.NewRepo(conn)
|
||||||
a.udp = udp.NewManager(a.udpRepo)
|
a.udp = udp.NewManager(a.udpRepo)
|
||||||
@@ -772,6 +777,25 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
fmt.Println("OpsLog: cty.dat loaded —", a.dxcc.Info().Entities, "entities")
|
fmt.Println("OpsLog: cty.dat loaded —", a.dxcc.Info().Entities, "entities")
|
||||||
}()
|
}()
|
||||||
|
// Offline safety net: with the shared MySQL logbook, losing the network would
|
||||||
|
// otherwise mean you simply cannot log. Instead, a QSO that can't reach the DB
|
||||||
|
// is parked in a local ADIF outbox and replayed automatically once the DB
|
||||||
|
// answers again. Anything already waiting from a previous session (a crash, a
|
||||||
|
// quit while offline) is replayed at startup.
|
||||||
|
a.offlineQ = newOfflineQueue(dataDir)
|
||||||
|
if n := a.offlineQ.Count(); n > 0 {
|
||||||
|
a.offlineMode = true
|
||||||
|
applog.Printf("offline: %d QSO(s) waiting in %s", n, a.offlineQ.Path())
|
||||||
|
}
|
||||||
|
go a.offlineReplayLoop()
|
||||||
|
go func() {
|
||||||
|
if a.offlineQ.Count() > 0 {
|
||||||
|
if n, err := a.replayOfflineQueue(); err == nil && n > 0 {
|
||||||
|
applog.Printf("offline: replayed %d QSO(s) left over from a previous session", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// ClubLog Country File (cty.xml) — date-ranged callsign exceptions that
|
// ClubLog Country File (cty.xml) — date-ranged callsign exceptions that
|
||||||
// cty.dat lacks (DXpeditions). Loaded from cache if present; downloaded on
|
// cty.dat lacks (DXpeditions). Loaded from cache if present; downloaded on
|
||||||
// demand. Resolution applied only when the user enables it.
|
// demand. Resolution applied only when the user enables it.
|
||||||
@@ -815,6 +839,12 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
// CAT manager: emit pushes state to the frontend via Wails events, and
|
// CAT manager: emit pushes state to the frontend via Wails events, and
|
||||||
// forwards frequency/mode to any outbound UDP emitters (PstRotator, N1MM).
|
// forwards frequency/mode to any outbound UDP emitters (PstRotator, N1MM).
|
||||||
a.cat = cat.NewManager(func(s cat.RigState) {
|
a.cat = cat.NewManager(func(s cat.RigState) {
|
||||||
|
// DIAGNOSTIC: the manager only fires this on a USER-relevant change, so a
|
||||||
|
// burst of these lines = the frontend is being re-rendered rapidly (the
|
||||||
|
// "screen flickers" symptom). Shows WHAT is churning — connection flap,
|
||||||
|
// or freq/split/mode oscillating between slices during FT8.
|
||||||
|
applog.Printf("cat:state → connected=%v freq=%d rx=%d split=%v mode=%s band=%s",
|
||||||
|
s.Connected, s.FreqHz, s.RxFreqHz, s.Split, s.Mode, s.Band)
|
||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
||||||
}
|
}
|
||||||
@@ -857,6 +887,13 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "cluster:spot", s)
|
wruntime.EventsEmit(a.ctx, "cluster:spot", s)
|
||||||
}
|
}
|
||||||
|
// A HISTORICAL spot (recovered from a SH/DX table) goes to the grid and
|
||||||
|
// stops there. It is a replay of the past: firing 100 alerts at once, or
|
||||||
|
// painting 100 stale stations on the panadapter as if they were on the
|
||||||
|
// air right now, would be actively misleading.
|
||||||
|
if s.Historical {
|
||||||
|
return
|
||||||
|
}
|
||||||
// Fire any matching alert rules (sound / visual / e-mail).
|
// Fire any matching alert rules (sound / visual / e-mail).
|
||||||
a.evaluateAlerts(s)
|
a.evaluateAlerts(s)
|
||||||
// Mirror the spot onto the FlexRadio panadapter when enabled. The
|
// Mirror the spot onto the FlexRadio panadapter when enabled. The
|
||||||
@@ -875,6 +912,14 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
wruntime.EventsEmit(a.ctx, "cluster:state", a.cluster.Status())
|
wruntime.EventsEmit(a.ctx, "cluster:state", a.cluster.Status())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Raw traffic → the cluster console. Spots are parsed out of this stream,
|
||||||
|
// but SH/DX, WHO, the MOTD and error replies are NOT spots — without this
|
||||||
|
// they were dropped on the floor and the command box looked inert.
|
||||||
|
func(l cluster.Line) {
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "cluster:line", l)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
a.refreshOperatorGrid()
|
a.refreshOperatorGrid()
|
||||||
if cs, _ := a.clusterAutoConnect(); cs {
|
if cs, _ := a.clusterAutoConnect(); cs {
|
||||||
@@ -1705,6 +1750,17 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
id, err = a.qso.Add(a.ctx, q)
|
id, err = a.qso.Add(a.ctx, q)
|
||||||
|
if err != nil && db.IsConnLost(err) {
|
||||||
|
// The database is UNREACHABLE (not a data error) — park the QSO in the
|
||||||
|
// offline outbox rather than lose it. Returns id = -1 so the UI can say
|
||||||
|
// "saved, waiting to sync" instead of showing a failure. The post-save
|
||||||
|
// side effects (recording, uploads, UDP) are skipped: they need a real
|
||||||
|
// row id and will not be replayed — the operator can upload later.
|
||||||
|
if a.queueOffline(q, err) {
|
||||||
|
return -1, nil
|
||||||
|
}
|
||||||
|
// Couldn't even write the outbox → report the real error, never pretend.
|
||||||
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
q.ID = id
|
q.ID = id
|
||||||
a.saveQSORecording(&q)
|
a.saveQSORecording(&q)
|
||||||
@@ -1716,6 +1772,11 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
if a.udp != nil {
|
if a.udp != nil {
|
||||||
go a.udp.EmitLoggedADIF(adif.SingleRecordADIF(q))
|
go a.udp.EmitLoggedADIF(adif.SingleRecordADIF(q))
|
||||||
}
|
}
|
||||||
|
// A successful write means the link is healthy again — if QSOs are still
|
||||||
|
// parked, get them in now rather than waiting for the next tick.
|
||||||
|
if a.offlineMode && a.offlineQ != nil && a.offlineQ.Count() > 0 {
|
||||||
|
go func() { _, _ = a.replayOfflineQueue() }()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
@@ -2165,7 +2226,97 @@ func (a *App) SaveAwardDefs(defs []award.Def) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return a.settings.SetGlobal(a.ctx, keyAwardDefs, string(b))
|
if err := a.settings.SetGlobal(a.ctx, keyAwardDefs, string(b)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
go a.mirrorAwardsToFolder(defs)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mirrorAwards refreshes the awards folder from the database.
|
||||||
|
//
|
||||||
|
// It is called from EVERY path that can change an award — the definitions AND the
|
||||||
|
// references. Mirroring only on "save definitions" was the obvious thing to do and
|
||||||
|
// the wrong one: add a city regex to a WAPC reference and the definition never
|
||||||
|
// changes, so the file would silently go stale and you'd share yesterday's award
|
||||||
|
// believing it current. A mirror that is only sometimes a mirror is worse than no
|
||||||
|
// mirror, because you trust it.
|
||||||
|
func (a *App) mirrorAwards() {
|
||||||
|
if a.settings == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go a.mirrorAwardsToFolder(a.awardDefs())
|
||||||
|
}
|
||||||
|
|
||||||
|
// mirrorAwardsToFolder writes one JSON per award into <data>/awards/, and removes
|
||||||
|
// the files of awards that no longer exist.
|
||||||
|
//
|
||||||
|
// EVERY award, built-in included. Skipping the built-ins seemed tidy — why mirror
|
||||||
|
// ten awards the user never wrote? — but it broke the one case that matters: fix
|
||||||
|
// DDFM's regex and there is no JSON to hand round or drop into the catalog. The
|
||||||
|
// rule has no exceptions now, so it needs no explaining: the folder holds your
|
||||||
|
// awards, as files, always current.
|
||||||
|
//
|
||||||
|
// The folder is a MIRROR — an output, never an input. Making it an input too (drop
|
||||||
|
// a file in and it installs) reads well until you delete an award in the UI, the
|
||||||
|
// file survives, and the award walks back in on the next start. Receiving an award
|
||||||
|
// is what Import is for, where you get a say.
|
||||||
|
func (a *App) mirrorAwardsToFolder(defs []award.Def) {
|
||||||
|
dir := a.AwardsFolder()
|
||||||
|
if dir == "" || a.awardRefs == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
applog.Printf("awards: cannot create %s: %v", dir, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keep := map[string]bool{}
|
||||||
|
for _, d := range defs {
|
||||||
|
code := strings.ToUpper(strings.TrimSpace(d.Code))
|
||||||
|
if code == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
refs, err := a.awardRefs.List(a.ctx, d.Code)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("awards: mirror %s: %v", code, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bundle := AwardBundle{
|
||||||
|
Version: 1,
|
||||||
|
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
Awards: []AwardBundleEntry{{Def: d, References: refs}},
|
||||||
|
}
|
||||||
|
b, err := json.MarshalIndent(bundle, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.ToLower(code) + ".json"
|
||||||
|
keep[name] = true
|
||||||
|
// Temp file + rename: a crash mid-write must not leave a truncated award.
|
||||||
|
tmp := filepath.Join(dir, name+".tmp")
|
||||||
|
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||||
|
applog.Printf("awards: mirror %s: %v", code, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, filepath.Join(dir, name)); err != nil {
|
||||||
|
applog.Printf("awards: mirror %s: %v", code, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Delete the file of an award that no longer exists — otherwise a deleted award
|
||||||
|
// leaves a ghost file that looks current and would be shared by mistake.
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, f := range entries {
|
||||||
|
n := strings.ToLower(f.Name())
|
||||||
|
if f.IsDir() || !strings.HasSuffix(n, ".json") || keep[n] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.Remove(filepath.Join(dir, f.Name())); err == nil {
|
||||||
|
applog.Printf("awards: removed %s (award no longer exists)", f.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetAwardDefs restores the built-in defaults.
|
// ResetAwardDefs restores the built-in defaults.
|
||||||
@@ -3220,7 +3371,11 @@ func (a *App) SaveAwardReference(code string, ref awardref.Ref) error {
|
|||||||
if a.awardRefs == nil {
|
if a.awardRefs == nil {
|
||||||
return fmt.Errorf("db not initialized")
|
return fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
return a.awardRefs.Upsert(a.ctx, code, ref)
|
if err := a.awardRefs.Upsert(a.ctx, code, ref); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.mirrorAwards()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteAwardReference removes one reference from an award.
|
// DeleteAwardReference removes one reference from an award.
|
||||||
@@ -3228,7 +3383,11 @@ func (a *App) DeleteAwardReference(code, refCode string) error {
|
|||||||
if a.awardRefs == nil {
|
if a.awardRefs == nil {
|
||||||
return fmt.Errorf("db not initialized")
|
return fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
return a.awardRefs.Delete(a.ctx, code, refCode)
|
if err := a.awardRefs.Delete(a.ctx, code, refCode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.mirrorAwards()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplaceAwardReferences atomically replaces an award's whole reference list
|
// ReplaceAwardReferences atomically replaces an award's whole reference list
|
||||||
@@ -3244,6 +3403,7 @@ func (a *App) ReplaceAwardReferences(code string, refs []awardref.Ref) (int, err
|
|||||||
if a.settings != nil {
|
if a.settings != nil {
|
||||||
a.setSetting(keyAwardRefsUpdated+strings.ToUpper(code), time.Now().Format("2006-01-02 15:04"))
|
a.setSetting(keyAwardRefsUpdated+strings.ToUpper(code), time.Now().Format("2006-01-02 15:04"))
|
||||||
}
|
}
|
||||||
|
a.mirrorAwards()
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3306,9 +3466,88 @@ type AwardImportResult struct {
|
|||||||
// reference list to a JSON bundle. Returns the path written, or "" if the user
|
// reference list to a JSON bundle. Returns the path written, or "" if the user
|
||||||
// cancelled.
|
// cancelled.
|
||||||
func (a *App) ExportAwards() (string, error) {
|
func (a *App) ExportAwards() (string, error) {
|
||||||
|
return a.exportAwardBundle(nil, "OpsLog_awards_"+time.Now().UTC().Format("20060102_150405")+".json", "Export awards")
|
||||||
|
}
|
||||||
|
|
||||||
|
// awardsDirName is the drop folder for award JSON files, inside OpsLog's data
|
||||||
|
// directory.
|
||||||
|
//
|
||||||
|
// The embedded catalog is compiled into the binary, so adding an award to it means
|
||||||
|
// a rebuild and a release — fine for what OpsLog ships officially, useless for
|
||||||
|
// "someone made an award and wants to hand it round". This folder is the answer:
|
||||||
|
// drop a JSON in, restart, the award is installed. No recompile, nobody to ask.
|
||||||
|
//
|
||||||
|
// It lives in the DATA directory, never in a cloud-synced folder — replicating
|
||||||
|
// files byte-by-byte is the mess we're avoiding everywhere else.
|
||||||
|
const awardsDirName = "awards"
|
||||||
|
|
||||||
|
// AwardsFolder is where award JSON files can be dropped to install them.
|
||||||
|
func (a *App) AwardsFolder() string {
|
||||||
|
if a.dataDir == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(a.dataDir, awardsDirName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAwardsFolder reveals the drop folder in the file manager, creating it if
|
||||||
|
// needed — telling someone a path they then have to build by hand is a poor
|
||||||
|
// substitute for opening it.
|
||||||
|
func (a *App) OpenAwardsFolder() error {
|
||||||
|
dir := a.AwardsFolder()
|
||||||
|
if dir == "" {
|
||||||
|
return fmt.Errorf("data dir not initialized")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return exec.Command("explorer", dir).Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// catalogRefs returns the reference list a SHIPPED award carries, if any. Awards
|
||||||
|
// whose list is generated from code (DXCC entities, French departments) or fetched
|
||||||
|
// online (POTA/SOTA/WWFF) carry none.
|
||||||
|
func (a *App) catalogRefs(code string) (json.RawMessage, bool) {
|
||||||
|
return award.CatalogRefs(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCatalogCodes lists the awards SHIPPED with OpsLog. Anything in the database
|
||||||
|
// that is NOT in this list is YOURS — you created or imported it, and it is not
|
||||||
|
// part of what OpsLog delivers. The editor flags those so it's obvious at a glance
|
||||||
|
// which awards are your own work (and therefore which files in the awards folder
|
||||||
|
// are yours to share).
|
||||||
|
func (a *App) GetCatalogCodes() []string {
|
||||||
|
cat := award.Catalog()
|
||||||
|
out := make([]string, 0, len(cat))
|
||||||
|
for _, e := range cat {
|
||||||
|
out = append(out, strings.ToUpper(strings.TrimSpace(e.Def.Code)))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportAward writes ONE award to its own JSON file — the unit you actually share.
|
||||||
|
// The whole-catalogue bundle is a backup; a single award is what you send someone.
|
||||||
|
//
|
||||||
|
// It reads the DEFINITION AND ITS REFERENCES FROM THE DATABASE, not from the
|
||||||
|
// embedded catalog: your WAPC is only worth sharing because of the province list
|
||||||
|
// and the city regexes you added, and those live in the database. Exporting the
|
||||||
|
// catalog's version would hand over an empty shell.
|
||||||
|
func (a *App) ExportAward(code string) (string, error) {
|
||||||
|
code = strings.ToUpper(strings.TrimSpace(code))
|
||||||
|
if code == "" {
|
||||||
|
return "", fmt.Errorf("no award selected")
|
||||||
|
}
|
||||||
|
return a.exportAwardBundle([]string{code}, "OpsLog_award_"+code+".json", "Export award "+code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportAwardBundle writes the given award codes (nil = all) to a JSON bundle.
|
||||||
|
func (a *App) exportAwardBundle(codes []string, defaultName, title string) (string, error) {
|
||||||
if a.awardRefs == nil {
|
if a.awardRefs == nil {
|
||||||
return "", fmt.Errorf("db not initialized")
|
return "", fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
|
want := map[string]bool{}
|
||||||
|
for _, c := range codes {
|
||||||
|
want[strings.ToUpper(strings.TrimSpace(c))] = true
|
||||||
|
}
|
||||||
defs := a.awardDefs()
|
defs := a.awardDefs()
|
||||||
bundle := AwardBundle{
|
bundle := AwardBundle{
|
||||||
Version: 1,
|
Version: 1,
|
||||||
@@ -3316,19 +3555,25 @@ func (a *App) ExportAwards() (string, error) {
|
|||||||
Awards: make([]AwardBundleEntry, 0, len(defs)),
|
Awards: make([]AwardBundleEntry, 0, len(defs)),
|
||||||
}
|
}
|
||||||
for _, d := range defs {
|
for _, d := range defs {
|
||||||
|
if len(want) > 0 && !want[strings.ToUpper(d.Code)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
refs, err := a.awardRefs.List(a.ctx, d.Code)
|
refs, err := a.awardRefs.List(a.ctx, d.Code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("list references for %s: %w", d.Code, err)
|
return "", fmt.Errorf("list references for %s: %w", d.Code, err)
|
||||||
}
|
}
|
||||||
bundle.Awards = append(bundle.Awards, AwardBundleEntry{Def: d, References: refs})
|
bundle.Awards = append(bundle.Awards, AwardBundleEntry{Def: d, References: refs})
|
||||||
}
|
}
|
||||||
|
if len(bundle.Awards) == 0 {
|
||||||
|
return "", fmt.Errorf("nothing to export")
|
||||||
|
}
|
||||||
data, err := json.MarshalIndent(bundle, "", " ")
|
data, err := json.MarshalIndent(bundle, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
path, err := wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
|
path, err := wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
|
||||||
Title: "Export awards",
|
Title: title,
|
||||||
DefaultFilename: "OpsLog_awards_" + time.Now().UTC().Format("20060102_150405") + ".json",
|
DefaultFilename: defaultName,
|
||||||
Filters: []wruntime.FileFilter{
|
Filters: []wruntime.FileFilter{
|
||||||
{DisplayName: "Award bundle (*.json)", Pattern: "*.json"},
|
{DisplayName: "Award bundle (*.json)", Pattern: "*.json"},
|
||||||
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
||||||
@@ -3348,51 +3593,177 @@ func (a *App) ExportAwards() (string, error) {
|
|||||||
// replaces that award's list. Returns counts; the user cancelling yields a
|
// replaces that award's list. Returns counts; the user cancelling yields a
|
||||||
// zero result and no error.
|
// zero result and no error.
|
||||||
func (a *App) ImportAwards() (AwardImportResult, error) {
|
func (a *App) ImportAwards() (AwardImportResult, error) {
|
||||||
var res AwardImportResult
|
// Kept for the old call sites: inspect, then apply with the default decision.
|
||||||
|
// Anything that already exists is SKIPPED, never silently replaced.
|
||||||
|
p, err := a.InspectAwardImport()
|
||||||
|
if err != nil || p.Path == "" {
|
||||||
|
return AwardImportResult{}, err
|
||||||
|
}
|
||||||
|
dec := map[string]string{}
|
||||||
|
for _, e := range p.Awards {
|
||||||
|
if e.Exists {
|
||||||
|
dec[e.Code] = "skip"
|
||||||
|
} else {
|
||||||
|
dec[e.Code] = "replace"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a.ApplyAwardImport(p.Path, dec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AwardImportPreviewEntry describes one award found in a file to import.
|
||||||
|
type AwardImportPreviewEntry struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
References int `json:"references"`
|
||||||
|
Exists bool `json:"exists"` // an award with this code is already installed
|
||||||
|
MineName string `json:"mine_name"` // the name of the one you already have
|
||||||
|
MineRefs int `json:"mine_refs"` // how many references yours carries
|
||||||
|
Protected bool `json:"protected"` // yours is a protected built-in
|
||||||
|
}
|
||||||
|
|
||||||
|
// AwardImportPreview is what the file holds, and what would collide.
|
||||||
|
type AwardImportPreview struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Awards []AwardImportPreviewEntry `json:"awards"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectAwardImport opens a bundle and reports what it contains WITHOUT touching
|
||||||
|
// anything — so the UI can ask before overwriting.
|
||||||
|
//
|
||||||
|
// The import used to merge by code with "imported wins", silently. Import a WAPC
|
||||||
|
// someone shared and YOUR WAPC — its province list, its city regexes, its band
|
||||||
|
// scope — was destroyed without a word. Sharing awards is precisely the feature we
|
||||||
|
// want people to use, so it must not be a data-loss trap: we look first, then ask.
|
||||||
|
func (a *App) InspectAwardImport() (AwardImportPreview, error) {
|
||||||
|
var p AwardImportPreview
|
||||||
if a.awardRefs == nil || a.settings == nil {
|
if a.awardRefs == nil || a.settings == nil {
|
||||||
return res, fmt.Errorf("db not initialized")
|
return p, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
path, err := wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
|
path, err := wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||||
Title: "Import awards",
|
Title: "Import award(s)",
|
||||||
Filters: []wruntime.FileFilter{
|
Filters: []wruntime.FileFilter{
|
||||||
{DisplayName: "Award bundle (*.json)", Pattern: "*.json"},
|
{DisplayName: "Award bundle (*.json)", Pattern: "*.json"},
|
||||||
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil || path == "" {
|
if err != nil || path == "" {
|
||||||
return res, err
|
return p, err
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(path)
|
bundle, err := readAwardBundle(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return res, fmt.Errorf("read %s: %w", path, err)
|
return p, err
|
||||||
}
|
}
|
||||||
var bundle AwardBundle
|
mine := map[string]award.Def{}
|
||||||
if err := json.Unmarshal(data, &bundle); err != nil {
|
for _, d := range a.awardDefs() {
|
||||||
return res, fmt.Errorf("parse award bundle: %w", err)
|
mine[strings.ToUpper(d.Code)] = d
|
||||||
}
|
|
||||||
if len(bundle.Awards) == 0 {
|
|
||||||
return res, fmt.Errorf("no awards in file")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge definitions: upsert by code (imported wins), keep the rest.
|
|
||||||
defs := a.awardDefs()
|
|
||||||
byCode := map[string]int{}
|
|
||||||
for i, d := range defs {
|
|
||||||
byCode[strings.ToUpper(d.Code)] = i
|
|
||||||
}
|
}
|
||||||
|
p.Path = path
|
||||||
for _, e := range bundle.Awards {
|
for _, e := range bundle.Awards {
|
||||||
code := strings.ToUpper(strings.TrimSpace(e.Def.Code))
|
code := strings.ToUpper(strings.TrimSpace(e.Def.Code))
|
||||||
if code == "" {
|
if code == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if i, ok := byCode[code]; ok {
|
entry := AwardImportPreviewEntry{
|
||||||
defs[i] = e.Def
|
Code: code, Name: e.Def.Name, References: len(e.References),
|
||||||
|
}
|
||||||
|
if d, ok := mine[code]; ok {
|
||||||
|
entry.Exists = true
|
||||||
|
entry.MineName = d.Name
|
||||||
|
entry.Protected = d.Protected
|
||||||
|
if refs, err := a.awardRefs.List(a.ctx, code); err == nil {
|
||||||
|
entry.MineRefs = len(refs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Awards = append(p.Awards, entry)
|
||||||
|
}
|
||||||
|
if len(p.Awards) == 0 {
|
||||||
|
return p, fmt.Errorf("no awards in file")
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyAwardImport imports a bundle, applying the operator's decision per code:
|
||||||
|
//
|
||||||
|
// "replace" — take theirs, overwriting mine
|
||||||
|
// "skip" — keep mine, ignore the import
|
||||||
|
// "copy" — install theirs under a free code (WAPC-2), so both exist and can be
|
||||||
|
// compared before one is deleted
|
||||||
|
//
|
||||||
|
// "copy" is the one that earns its keep: it lets you LOOK before choosing, without
|
||||||
|
// losing anything either way.
|
||||||
|
func (a *App) ApplyAwardImport(path string, decisions map[string]string) (AwardImportResult, error) {
|
||||||
|
var res AwardImportResult
|
||||||
|
if a.awardRefs == nil || a.settings == nil {
|
||||||
|
return res, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
bundle, err := readAwardBundle(path)
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defs := a.awardDefs()
|
||||||
|
idx := map[string]int{}
|
||||||
|
for i, d := range defs {
|
||||||
|
idx[strings.ToUpper(d.Code)] = i
|
||||||
|
}
|
||||||
|
// refsToWrite is applied only after the definitions save cleanly — a half-done
|
||||||
|
// import that swapped the reference list but not the definition would leave the
|
||||||
|
// award quietly broken.
|
||||||
|
type pending struct {
|
||||||
|
code string
|
||||||
|
refs []awardref.Ref
|
||||||
|
}
|
||||||
|
var refsToWrite []pending
|
||||||
|
|
||||||
|
for _, e := range bundle.Awards {
|
||||||
|
code := strings.ToUpper(strings.TrimSpace(e.Def.Code))
|
||||||
|
if code == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
decision := strings.ToLower(strings.TrimSpace(decisions[code]))
|
||||||
|
_, exists := idx[code]
|
||||||
|
if !exists {
|
||||||
|
decision = "replace" // nothing to collide with
|
||||||
|
}
|
||||||
|
switch decision {
|
||||||
|
case "skip", "":
|
||||||
|
continue
|
||||||
|
|
||||||
|
case "copy":
|
||||||
|
newCode := freeAwardCode(code, idx)
|
||||||
|
d := e.Def
|
||||||
|
d.Code = newCode
|
||||||
|
// An imported copy is NOT a protected built-in: the operator must be able
|
||||||
|
// to delete it once they've decided which one they keep.
|
||||||
|
d.Builtin, d.Protected = false, false
|
||||||
|
idx[newCode] = len(defs)
|
||||||
|
defs = append(defs, d)
|
||||||
|
res.Awards++
|
||||||
|
if len(e.References) > 0 {
|
||||||
|
refsToWrite = append(refsToWrite, pending{newCode, e.References})
|
||||||
|
}
|
||||||
|
|
||||||
|
default: // "replace"
|
||||||
|
d := e.Def
|
||||||
|
d.Code = code
|
||||||
|
if i, ok := idx[code]; ok {
|
||||||
|
defs[i] = d
|
||||||
} else {
|
} else {
|
||||||
byCode[code] = len(defs)
|
idx[code] = len(defs)
|
||||||
defs = append(defs, e.Def)
|
defs = append(defs, d)
|
||||||
}
|
}
|
||||||
res.Awards++
|
res.Awards++
|
||||||
|
// An entry with NO references leaves the existing list alone — otherwise
|
||||||
|
// importing a def-only export would wipe a seeded built-in list.
|
||||||
|
if len(e.References) > 0 {
|
||||||
|
refsToWrite = append(refsToWrite, pending{code, e.References})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if res.Awards == 0 {
|
||||||
|
return res, nil // everything skipped
|
||||||
|
}
|
||||||
|
|
||||||
if migrated, changed := award.Migrate(defs); changed {
|
if migrated, changed := award.Migrate(defs); changed {
|
||||||
defs = migrated
|
defs = migrated
|
||||||
}
|
}
|
||||||
@@ -3400,22 +3771,54 @@ func (a *App) ImportAwards() (AwardImportResult, error) {
|
|||||||
if err := a.settings.SetGlobal(a.ctx, keyAwardDefs, string(b)); err != nil {
|
if err := a.settings.SetGlobal(a.ctx, keyAwardDefs, string(b)); err != nil {
|
||||||
return res, fmt.Errorf("save award defs: %w", err)
|
return res, fmt.Errorf("save award defs: %w", err)
|
||||||
}
|
}
|
||||||
|
for _, p := range refsToWrite {
|
||||||
// Replace reference lists for entries that carry them (skip empty so we
|
n, err := a.ReplaceAwardReferences(p.code, p.refs)
|
||||||
// don't wipe built-in-seeded lists for a def exported without refs).
|
|
||||||
for _, e := range bundle.Awards {
|
|
||||||
if len(e.References) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
n, err := a.ReplaceAwardReferences(e.Def.Code, e.References)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return res, fmt.Errorf("import references for %s: %w", e.Def.Code, err)
|
return res, fmt.Errorf("import references for %s: %w", p.code, err)
|
||||||
}
|
}
|
||||||
res.References += n
|
res.References += n
|
||||||
}
|
}
|
||||||
|
a.invalidateAwardStats()
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readAwardBundle parses a bundle file. Size-capped: an award bundle is a few
|
||||||
|
// hundred kB at most, and an unbounded read of a file someone sent you is not a
|
||||||
|
// risk worth taking.
|
||||||
|
func readAwardBundle(path string) (AwardBundle, error) {
|
||||||
|
var bundle AwardBundle
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return bundle, fmt.Errorf("read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
const maxBundle = 32 << 20 // 32 MB
|
||||||
|
if fi.Size() > maxBundle {
|
||||||
|
return bundle, fmt.Errorf("%s is too large for an award bundle (%d bytes)", path, fi.Size())
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return bundle, fmt.Errorf("read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &bundle); err != nil {
|
||||||
|
return bundle, fmt.Errorf("parse award bundle: %w", err)
|
||||||
|
}
|
||||||
|
if len(bundle.Awards) == 0 {
|
||||||
|
return bundle, fmt.Errorf("no awards in file")
|
||||||
|
}
|
||||||
|
return bundle, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// freeAwardCode returns the first unused "CODE-n" (WAPC-2, WAPC-3, …).
|
||||||
|
func freeAwardCode(code string, taken map[string]int) string {
|
||||||
|
for n := 2; n < 100; n++ {
|
||||||
|
c := fmt.Sprintf("%s-%d", code, n)
|
||||||
|
if _, clash := taken[c]; !clash {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return code + "-COPY"
|
||||||
|
}
|
||||||
|
|
||||||
// builtinRefsVersion is bumped whenever the built-in reference data changes
|
// builtinRefsVersion is bumped whenever the built-in reference data changes
|
||||||
// (e.g. the West Malaysia 155→299 fix) so existing installs re-seed the
|
// (e.g. the West Malaysia 155→299 fix) so existing installs re-seed the
|
||||||
// derived lists. Bump this after correcting BuiltinRefs / the DXCC name table.
|
// derived lists. Bump this after correcting BuiltinRefs / the DXCC name table.
|
||||||
@@ -3448,6 +3851,22 @@ func (a *App) seedBuiltinReferences() {
|
|||||||
if firstRun && counts[code] > 0 {
|
if firstRun && counts[code] > 0 {
|
||||||
continue // don't overwrite an existing list on a fresh install
|
continue // don't overwrite an existing list on a fresh install
|
||||||
}
|
}
|
||||||
|
// A catalog award that SHIPS its own reference list wins: that is how an
|
||||||
|
// award added as a JSON file (a shared WAPC, with its provinces and their
|
||||||
|
// city regexes) reaches every user without a line of Go. Awards whose list
|
||||||
|
// is generated from code (DXCC entities, French departments) or fetched
|
||||||
|
// online (POTA/SOTA/WWFF) carry none, and fall back below.
|
||||||
|
if raw, ok := a.catalogRefs(code); ok {
|
||||||
|
var refs []awardref.Ref
|
||||||
|
if err := json.Unmarshal(raw, &refs); err == nil && len(refs) > 0 {
|
||||||
|
if n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs); err == nil {
|
||||||
|
applog.Printf("award-refs: seeded %s from the catalog — %d references", code, n)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
applog.Printf("award-refs: %s catalog references are malformed: %v", code, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
if refs, ok := awardref.BuiltinRefs(code); ok {
|
if refs, ok := awardref.BuiltinRefs(code); ok {
|
||||||
if n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs); err == nil {
|
if n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs); err == nil {
|
||||||
applog.Printf("award-refs: seeded %s — %d references", code, n)
|
applog.Printf("award-refs: seeded %s — %d references", code, n)
|
||||||
@@ -3587,6 +4006,50 @@ func (a *App) UpdateQSO(q qso.QSO) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLogStats aggregates the logbook for the Statistics dashboard (operators,
|
||||||
|
// modes, bands, entities, activity over time), restricted to a period.
|
||||||
|
//
|
||||||
|
// fromISO/toISO are "YYYY-MM-DD" (or RFC3339); either may be empty for "no
|
||||||
|
// bound", so two empty strings mean the whole log. `to` is inclusive: a bare date
|
||||||
|
// is stretched to 23:59:59 of that day, otherwise picking today as the end would
|
||||||
|
// silently drop today's QSOs.
|
||||||
|
func (a *App) GetLogStats(fromISO, toISO, contestID string, year int) (qso.Stats, error) {
|
||||||
|
if a.qso == nil {
|
||||||
|
return qso.Stats{}, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
from := parseStatsBound(fromISO, false)
|
||||||
|
to := parseStatsBound(toISO, true)
|
||||||
|
return a.qso.Stats(a.ctx, from, to, contestID, year)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetContestRuns lists the (contest, year) pairs actually present in the log, so
|
||||||
|
// the Statistics picker only ever offers contests you really entered.
|
||||||
|
func (a *App) GetContestRuns() ([]qso.ContestRun, error) {
|
||||||
|
if a.qso == nil {
|
||||||
|
return nil, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
return a.qso.ContestRuns(a.ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStatsBound turns a UI date into a UTC bound. endOfDay stretches a bare
|
||||||
|
// date to 23:59:59 so an inclusive "to" really includes that day.
|
||||||
|
func parseStatsBound(s string, endOfDay bool) time.Time {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
if t, err := time.Parse("2006-01-02", s); err == nil {
|
||||||
|
if endOfDay {
|
||||||
|
return t.UTC().Add(24*time.Hour - time.Second)
|
||||||
|
}
|
||||||
|
return t.UTC()
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||||
|
return t.UTC()
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) DeleteQSO(id int64) error {
|
func (a *App) DeleteQSO(id int64) error {
|
||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
return fmt.Errorf("db not initialized")
|
return fmt.Errorf("db not initialized")
|
||||||
@@ -7870,6 +8333,12 @@ func (a *App) consumeUDPEvents() {
|
|||||||
if a.udp == nil {
|
if a.udp == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Rate-limit decode spots: an FT8 opening decodes the SAME stations every
|
||||||
|
// cycle, so spot each call at most once per window (well within its display
|
||||||
|
// lifetime) instead of blasting the radio with a burst of adds+removes every
|
||||||
|
// 15 s. Single-goroutine loop → the map needs no lock.
|
||||||
|
const decodeSpotWindow = 20 * time.Second
|
||||||
|
lastDecodeSpot := map[string]time.Time{}
|
||||||
for ev := range a.udp.Events() {
|
for ev := range a.udp.Events() {
|
||||||
if a.ctx == nil {
|
if a.ctx == nil {
|
||||||
continue
|
continue
|
||||||
@@ -7880,6 +8349,14 @@ func (a *App) consumeUDPEvents() {
|
|||||||
// panadapter when the option is on; green + SNR comment, auto-expiring
|
// panadapter when the option is on; green + SNR comment, auto-expiring
|
||||||
// after the configured duration. De-duped per call in the Flex backend.
|
// after the configured duration. De-duped per call in the Flex backend.
|
||||||
if a.catFlexDecodeSpots && a.cat != nil {
|
if a.catFlexDecodeSpots && a.cat != nil {
|
||||||
|
now := time.Now()
|
||||||
|
if t, seen := lastDecodeSpot[ev.DecodeCall]; seen && now.Sub(t) < decodeSpotWindow {
|
||||||
|
continue // spotted this call very recently — don't re-hammer the radio
|
||||||
|
}
|
||||||
|
if len(lastDecodeSpot) > 4000 {
|
||||||
|
lastDecodeSpot = map[string]time.Time{} // bound memory on long sessions
|
||||||
|
}
|
||||||
|
lastDecodeSpot[ev.DecodeCall] = now
|
||||||
secs := a.catFlexDecodeSecs
|
secs := a.catFlexDecodeSecs
|
||||||
if secs <= 0 {
|
if secs <= 0 {
|
||||||
secs = 120
|
secs = 120
|
||||||
@@ -8203,6 +8680,7 @@ func (a *App) SetCATFrequency(hz int64) error {
|
|||||||
if a.cat == nil {
|
if a.cat == nil {
|
||||||
return fmt.Errorf("cat not initialized")
|
return fmt.Errorf("cat not initialized")
|
||||||
}
|
}
|
||||||
|
applog.Printf("cat: SetCATFrequency %.3f MHz — deliberate frontend set (spot click / band / memory / entry)", float64(hz)/1e6)
|
||||||
err := a.cat.SetFrequency(hz)
|
err := a.cat.SetFrequency(hz)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
applog.Printf("cat: SetFrequency(%d Hz) dispatch error: %v", hz, err)
|
applog.Printf("cat: SetFrequency(%d Hz) dispatch error: %v", hz, err)
|
||||||
@@ -8226,6 +8704,7 @@ func (a *App) ultrabeamFollowNow(freqHz int64) {
|
|||||||
if err != nil || !s.Enabled || !s.Follow {
|
if err != nil || !s.Enabled || !s.Follow {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
applog.Printf("ultrabeam: followNow — deliberate CAT set to %.3f MHz", float64(freqHz)/1e6)
|
||||||
step := s.StepKHz
|
step := s.StepKHz
|
||||||
if step <= 0 {
|
if step <= 0 {
|
||||||
step = 50
|
step = 50
|
||||||
@@ -8241,17 +8720,22 @@ func (a *App) ultrabeamFollowNow(freqHz int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
khz := int(freqHz / 1000)
|
khz := int(freqHz / 1000)
|
||||||
diff := khz - st.Frequency
|
ref := st.Frequency
|
||||||
|
if ref <= 0 {
|
||||||
|
ref = c.LastSetKHz()
|
||||||
|
}
|
||||||
|
diff := khz - ref
|
||||||
if diff < 0 {
|
if diff < 0 {
|
||||||
diff = -diff
|
diff = -diff
|
||||||
}
|
}
|
||||||
if st.Frequency > 0 && diff < step {
|
if ref > 0 && diff < step {
|
||||||
|
applog.Printf("ultrabeam: followNow within deadband (%d kHz vs ref %d, step %d) — no move", khz, ref, step)
|
||||||
return // within the deadband — don't chase a tiny QSY
|
return // within the deadband — don't chase a tiny QSY
|
||||||
}
|
}
|
||||||
if err := c.SetFrequency(khz, st.Direction); err != nil {
|
if err := c.SetFrequency(khz, st.Direction); err != nil {
|
||||||
applog.Printf("ultrabeam: immediate re-tune to %d kHz failed: %v", khz, err)
|
applog.Printf("ultrabeam: immediate re-tune to %d kHz failed: %v", khz, err)
|
||||||
} else {
|
} else {
|
||||||
applog.Printf("ultrabeam: re-tuned on freq set → %d kHz (dir %d)", khz, st.Direction)
|
applog.Printf("ultrabeam: re-tuned on freq set → %d kHz (dir %d, was ref %d kHz)", khz, st.Direction, ref)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9594,6 +10078,7 @@ func (a *App) ultrabeamFollowLoop(c *ultrabeam.Client, stepKHz int, stop <-chan
|
|||||||
}
|
}
|
||||||
ticker := time.NewTicker(1500 * time.Millisecond)
|
ticker := time.NewTicker(1500 * time.Millisecond)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
lastRigKHz := 0 // only log when the followed rig frequency actually changes
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-stop:
|
case <-stop:
|
||||||
@@ -9611,6 +10096,15 @@ func (a *App) ultrabeamFollowLoop(c *ultrabeam.Client, stepKHz int, stop <-chan
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rigKHz := int(rs.FreqHz / 1000)
|
rigKHz := int(rs.FreqHz / 1000)
|
||||||
|
// Log the moment the poll sees a NEW rig frequency — this is what the
|
||||||
|
// follow loop chases. If the antenna QSYs unexpectedly, this shows the
|
||||||
|
// CAT backend started reporting that frequency (e.g. WSJT-X moved the
|
||||||
|
// dial, or the active slice changed) even though you didn't touch the VFO.
|
||||||
|
if rigKHz != lastRigKHz {
|
||||||
|
applog.Printf("ultrabeam: follow loop reads rig freq %.3f MHz (mode %s) — antenna at %d kHz, step %d kHz",
|
||||||
|
float64(rs.FreqHz)/1e6, rs.Mode, st.Frequency, stepKHz)
|
||||||
|
lastRigKHz = rigKHz
|
||||||
|
}
|
||||||
// Skip frequencies outside the antenna's tunable range (other band).
|
// Skip frequencies outside the antenna's tunable range (other band).
|
||||||
if st.FreqMin > 0 && st.FreqMax > 0 {
|
if st.FreqMin > 0 && st.FreqMax > 0 {
|
||||||
rigMHz := rs.FreqHz / 1_000_000
|
rigMHz := rs.FreqHz / 1_000_000
|
||||||
@@ -9618,17 +10112,24 @@ func (a *App) ultrabeamFollowLoop(c *ultrabeam.Client, stepKHz int, stop <-chan
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
diff := rigKHz - st.Frequency
|
// Deadband reference = the antenna's reported freq, or (when it hasn't
|
||||||
|
// reported one yet) the last freq we commanded — so a 0/blank status
|
||||||
|
// doesn't bypass the deadband and re-tune on every small QSY.
|
||||||
|
ref := st.Frequency
|
||||||
|
if ref <= 0 {
|
||||||
|
ref = c.LastSetKHz()
|
||||||
|
}
|
||||||
|
diff := rigKHz - ref
|
||||||
if diff < 0 {
|
if diff < 0 {
|
||||||
diff = -diff
|
diff = -diff
|
||||||
}
|
}
|
||||||
if st.Frequency > 0 && diff < stepKHz {
|
if ref > 0 && diff < stepKHz {
|
||||||
continue // within the deadband — leave the motors alone
|
continue // within the deadband — leave the motors alone
|
||||||
}
|
}
|
||||||
if err := c.SetFrequency(rigKHz, st.Direction); err != nil {
|
if err := c.SetFrequency(rigKHz, st.Direction); err != nil {
|
||||||
applog.Printf("ultrabeam: follow re-tune to %d kHz failed: %v", rigKHz, err)
|
applog.Printf("ultrabeam: follow re-tune to %d kHz failed: %v", rigKHz, err)
|
||||||
} else {
|
} else {
|
||||||
applog.Printf("ultrabeam: followed rig → %d kHz (dir %d)", rigKHz, st.Direction)
|
applog.Printf("ultrabeam: followed rig → %d kHz (dir %d, was ref %d kHz, step %d)", rigKHz, st.Direction, ref, stepKHz)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+235
-27
@@ -1,11 +1,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AlertCircle, Antenna, Bell, CheckCircle2, Clock, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
||||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X, Zap,
|
Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
|
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
|
||||||
|
GetOfflineStatus, GetPendingQSOs, RetryOfflineSync,
|
||||||
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
|
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
|
||||||
SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected,
|
SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected,
|
||||||
ContestDupe,
|
ContestDupe,
|
||||||
@@ -66,6 +67,7 @@ import { IcomPanel } from '@/components/IcomPanel';
|
|||||||
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
|
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
|
||||||
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
||||||
import { AwardsPanel } from '@/components/AwardsPanel';
|
import { AwardsPanel } from '@/components/AwardsPanel';
|
||||||
|
import { StatsPanel } from '@/components/StatsPanel';
|
||||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
||||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||||
@@ -554,13 +556,20 @@ export default function App() {
|
|||||||
if (toastTimer.current !== undefined) { window.clearTimeout(toastTimer.current); toastTimer.current = undefined; }
|
if (toastTimer.current !== undefined) { window.clearTimeout(toastTimer.current); toastTimer.current = undefined; }
|
||||||
advanceToast(); // skip to the next queued toast (or clear if none)
|
advanceToast(); // skip to the next queued toast (or clear if none)
|
||||||
}, [advanceToast]);
|
}, [advanceToast]);
|
||||||
|
// Status-bar message expanded into a popover (long errors — a TQSL or Club Log
|
||||||
|
// failure — don't fit on one line, and truncating them with no way to read the
|
||||||
|
// rest is useless).
|
||||||
|
const [msgOpen, setMsgOpen] = useState(false);
|
||||||
|
// Reset when the message goes away, or the NEXT one would pop open by itself.
|
||||||
|
useEffect(() => { if (!error && !toast) setMsgOpen(false); }, [error, toast]);
|
||||||
// Error banners auto-dismiss after a few seconds (longer than toasts since
|
// Error banners auto-dismiss after a few seconds (longer than toasts since
|
||||||
// they may be multi-line). The X button still closes them immediately.
|
// they may be multi-line). The X button still closes them immediately. Do NOT
|
||||||
|
// dismiss while the operator has it expanded and is reading it.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!error) return;
|
if (!error || msgOpen) return;
|
||||||
const t = window.setTimeout(() => setError(''), 6000);
|
const t = window.setTimeout(() => setError(''), 6000);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [error]);
|
}, [error, msgOpen]);
|
||||||
// True while the QSO recorder is capturing the current contact (set when we
|
// True while the QSO recorder is capturing the current contact (set when we
|
||||||
// leave the callsign field, cleared on log/cancel). Drives the REC badge.
|
// leave the callsign field, cleared on log/cancel). Drives the REC badge.
|
||||||
const [recording, setRecording] = useState(false);
|
const [recording, setRecording] = useState(false);
|
||||||
@@ -608,6 +617,12 @@ export default function App() {
|
|||||||
setQslTabOpen(false);
|
setQslTabOpen(false);
|
||||||
setActiveTab((t) => (t === 'qsl' ? 'recent' : t));
|
setActiveTab((t) => (t === 'qsl' ? 'recent' : t));
|
||||||
}
|
}
|
||||||
|
// Statistics is likewise a closable tab, opened from Tools → Statistics.
|
||||||
|
const [statsTabOpen, setStatsTabOpen] = useState(false);
|
||||||
|
function closeStatsTab() {
|
||||||
|
setStatsTabOpen(false);
|
||||||
|
setActiveTab((t) => (t === 'stats' ? 'recent' : t));
|
||||||
|
}
|
||||||
// Recent QSOs row cap, persisted. With AG Grid's virtual scroller
|
// Recent QSOs row cap, persisted. With AG Grid's virtual scroller
|
||||||
// huge logs render OK once loaded, but a 25k+ logbook still takes a
|
// huge logs render OK once loaded, but a 25k+ logbook still takes a
|
||||||
// couple of seconds to round-trip from SQLite at launch. Defaulting
|
// couple of seconds to round-trip from SQLite at launch. Defaulting
|
||||||
@@ -830,6 +845,24 @@ export default function App() {
|
|||||||
type RecentAlert = { id: number; rule: string; call: string; band: string; mode: string; freq_hz: number; country: string; comment: string; at: number };
|
type RecentAlert = { id: number; rule: string; call: string; band: string; mode: string; freq_hz: number; country: string; comment: string; at: number };
|
||||||
const [recentAlerts, setRecentAlerts] = useState<RecentAlert[]>([]);
|
const [recentAlerts, setRecentAlerts] = useState<RecentAlert[]>([]);
|
||||||
const [alertsPanelOpen, setAlertsPanelOpen] = useState(false); // LED dropdown
|
const [alertsPanelOpen, setAlertsPanelOpen] = useState(false); // LED dropdown
|
||||||
|
|
||||||
|
// Offline outbox: QSOs parked locally because the database was unreachable.
|
||||||
|
const [offlineStatus, setOfflineStatus] = useState<{ offline: boolean; pending: number; path: string }>({ offline: false, pending: 0, path: '' });
|
||||||
|
const [pendingOpen, setPendingOpen] = useState(false);
|
||||||
|
const [pendingList, setPendingList] = useState<any[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
const load = () => GetOfflineStatus().then((s: any) => setOfflineStatus(s)).catch(() => {});
|
||||||
|
load();
|
||||||
|
const off = EventsOn('offline:status', (s: any) => {
|
||||||
|
setOfflineStatus(s);
|
||||||
|
// Keep the open list in step as QSOs are parked or replayed.
|
||||||
|
if (pendingOpenRef.current) GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {});
|
||||||
|
});
|
||||||
|
return () => { off?.(); };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
const pendingOpenRef = useRef(false);
|
||||||
|
useEffect(() => { pendingOpenRef.current = pendingOpen; }, [pendingOpen]);
|
||||||
const ALERT_TTL_MS = 10 * 60 * 1000;
|
const ALERT_TTL_MS = 10 * 60 * 1000;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = window.setInterval(() => {
|
const id = window.setInterval(() => {
|
||||||
@@ -879,6 +912,31 @@ export default function App() {
|
|||||||
const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>('');
|
const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>('');
|
||||||
const [clusterGroup, setClusterGroup] = useState(true);
|
const [clusterGroup, setClusterGroup] = useState(true);
|
||||||
const [clusterCmd, setClusterCmd] = useState('');
|
const [clusterCmd, setClusterCmd] = useState('');
|
||||||
|
// Cluster console: the raw traffic. Spots are parsed out of the stream into the
|
||||||
|
// grid; everything else (SH/DX output, WHO, MOTD, errors) used to be discarded,
|
||||||
|
// so a command appeared to do nothing at all.
|
||||||
|
type ClusterLine = { server_id: number; server_name: string; text: string; sent: boolean; at: string };
|
||||||
|
const CONSOLE_CAP = 2000; // a busy cluster runs for hours — don't grow forever
|
||||||
|
const [clusterLines, setClusterLines] = useState<ClusterLine[]>([]);
|
||||||
|
const [clusterConsoleOpen, setClusterConsoleOpen] = useState(false);
|
||||||
|
const clusterConsoleRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const off = EventsOn('cluster:line', (l: any) => {
|
||||||
|
setClusterLines((prev) => {
|
||||||
|
const next = [...prev, l as ClusterLine];
|
||||||
|
return next.length > CONSOLE_CAP ? next.slice(next.length - CONSOLE_CAP) : next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => { off?.(); };
|
||||||
|
}, []);
|
||||||
|
// Follow the tail, but ONLY when already at the bottom — otherwise scrolling up
|
||||||
|
// to read a SH/DX reply would yank you back down on the next spot.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = clusterConsoleRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||||
|
if (atBottom) el.scrollTop = el.scrollHeight;
|
||||||
|
}, [clusterLines]);
|
||||||
// Multi-band filter: empty set = all bands. The user toggles chips.
|
// Multi-band filter: empty set = all bands. The user toggles chips.
|
||||||
const [clusterBands, setClusterBands] = useState<Set<string>>(new Set());
|
const [clusterBands, setClusterBands] = useState<Set<string>>(new Set());
|
||||||
// Lock-to-entry: when on, the band filter follows the entry's current
|
// Lock-to-entry: when on, the band filter follows the entry's current
|
||||||
@@ -1988,7 +2046,13 @@ export default function App() {
|
|||||||
srx: rE.num, srx_string: rE.str,
|
srx: rE.num, srx_string: rE.str,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await AddQSO(payload);
|
// id = -1 means the database was unreachable and the QSO was parked in the
|
||||||
|
// offline outbox — it is SAVED (on disk), just not in the logbook yet. Treat
|
||||||
|
// it as a success so logging never stops; the banner tells the operator.
|
||||||
|
const newId = await AddQSO(payload);
|
||||||
|
if (typeof newId === 'number' && newId < 0) {
|
||||||
|
showToast(t('offline.queued'));
|
||||||
|
}
|
||||||
// Advance the sent serial only after a successful log.
|
// Advance the sent serial only after a successful log.
|
||||||
if (contest.active && contest.code && contest.exchange === 'serial') {
|
if (contest.active && contest.code && contest.exchange === 'serial') {
|
||||||
updateContest({ nextSerial: contest.nextSerial + 1 });
|
updateContest({ nextSerial: contest.nextSerial + 1 });
|
||||||
@@ -2423,6 +2487,7 @@ export default function App() {
|
|||||||
]},
|
]},
|
||||||
{ name: 'tools', label: t('menu.tools'), items: [
|
{ name: 'tools', label: t('menu.tools'), items: [
|
||||||
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
||||||
|
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
||||||
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
||||||
@@ -2458,6 +2523,7 @@ export default function App() {
|
|||||||
case 'edit.bulkedit': openBulkEdit(selectedIds); break;
|
case 'edit.bulkedit': openBulkEdit(selectedIds); break;
|
||||||
case 'edit.prefs': setShowSettings(true); break;
|
case 'edit.prefs': setShowSettings(true); break;
|
||||||
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
||||||
|
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
|
||||||
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||||
@@ -2777,6 +2843,62 @@ export default function App() {
|
|||||||
// A discreet spot-alert LED (bell) that fills the gap at the right of the Grid
|
// A discreet spot-alert LED (bell) that fills the gap at the right of the Grid
|
||||||
// row instead of the intrusive floating cards. It lights + shows a count when
|
// row instead of the intrusive floating cards. It lights + shows a count when
|
||||||
// alerts are pending; click it to see the last 3 (each clickable to tune).
|
// alerts are pending; click it to see the last 3 (each clickable to tune).
|
||||||
|
// ── Offline outbox (safety net) ──────────────────────────────────────
|
||||||
|
// When the shared database is unreachable, QSOs are parked in a local ADIF
|
||||||
|
// file rather than lost. Surface that HONESTLY: the operator must know their
|
||||||
|
// worked-before check doesn't include these, and see what's waiting.
|
||||||
|
const offlinePendingCount = offlineStatus.pending ?? 0;
|
||||||
|
const offlineBlock = offlinePendingCount === 0 ? null : (
|
||||||
|
<div className="relative self-end mb-0.5 shrink-0">
|
||||||
|
<button type="button" onClick={() => { setPendingOpen((o) => !o); GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {}); }}
|
||||||
|
title={t('offline.tip', { n: offlinePendingCount })}
|
||||||
|
className="relative inline-flex h-8 items-center gap-1.5 rounded-full border border-danger bg-danger/15 px-2.5 text-danger transition-colors hover:bg-danger/25">
|
||||||
|
<CloudOff className="size-4" />
|
||||||
|
<span className="text-xs font-bold tabular-nums">{offlinePendingCount}</span>
|
||||||
|
</button>
|
||||||
|
{pendingOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setPendingOpen(false)} />
|
||||||
|
<div className="absolute right-0 top-9 z-50 w-[26rem] rounded-lg border border-border bg-card shadow-xl overflow-hidden">
|
||||||
|
<div className="px-3 py-2 border-b border-border bg-danger-muted text-danger-muted-foreground">
|
||||||
|
<p className="text-xs font-semibold">{t('offline.title', { n: offlinePendingCount })}</p>
|
||||||
|
<p className="text-[11px] mt-0.5 leading-snug">{t('offline.explain')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-72 overflow-auto divide-y divide-border/60">
|
||||||
|
{pendingList.length === 0 ? (
|
||||||
|
<p className="px-3 py-3 text-[11px] text-muted-foreground italic">{t('offline.empty')}</p>
|
||||||
|
) : pendingList.map((q, i) => (
|
||||||
|
<div key={i} className="px-3 py-1.5 flex items-center gap-2 text-xs">
|
||||||
|
<span className="font-mono font-semibold w-24 truncate">{q.callsign}</span>
|
||||||
|
<span className="text-muted-foreground w-12">{q.band}</span>
|
||||||
|
<span className="text-muted-foreground w-14">{q.mode}</span>
|
||||||
|
<span className="ml-auto text-[11px] text-muted-foreground tabular-nums">
|
||||||
|
{q.qso_date ? new Date(q.qso_date).toISOString().slice(0, 16).replace('T', ' ') : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 border-t border-border flex items-center gap-2">
|
||||||
|
<button type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const n = await RetryOfflineSync();
|
||||||
|
if (n > 0) showToast(t('offline.synced', { n }));
|
||||||
|
else showToast(t('offline.stillDown'));
|
||||||
|
GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {});
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}}
|
||||||
|
className="h-7 px-2 rounded border border-border text-xs hover:bg-muted">
|
||||||
|
{t('offline.retry')}
|
||||||
|
</button>
|
||||||
|
<span className="text-[10px] text-muted-foreground truncate" title={offlineStatus.path}>{offlineStatus.path}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const hasAlerts = recentAlerts.length > 0;
|
const hasAlerts = recentAlerts.length > 0;
|
||||||
// Only show the bell when there are pending alerts — hidden otherwise.
|
// Only show the bell when there are pending alerts — hidden otherwise.
|
||||||
const alertLedBlock = !hasAlerts ? null : (
|
const alertLedBlock = !hasAlerts ? null : (
|
||||||
@@ -3277,25 +3399,8 @@ export default function App() {
|
|||||||
<Menubar menus={menus} onAction={handleMenu} />
|
<Menubar menus={menus} onAction={handleMenu} />
|
||||||
|
|
||||||
<div className="relative flex items-center justify-center gap-2 font-mono">
|
<div className="relative flex items-center justify-center gap-2 font-mono">
|
||||||
{/* Transient toast / error, in the empty band between the menu and
|
{/* Toasts and errors live in the STATUS BAR at the bottom now — the
|
||||||
the frequency (left of centre). Single-line + truncated. */}
|
header band was too narrow and long messages were cut off. */}
|
||||||
{(error || toast) && (
|
|
||||||
<div className="absolute left-1 top-1/2 -translate-y-1/2 z-20 flex items-center max-w-[min(42vw,560px)] font-sans">
|
|
||||||
{error ? (
|
|
||||||
<div className="flex items-center gap-1.5 rounded-md border border-destructive/40 bg-destructive/10 text-destructive px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
|
|
||||||
<AlertCircle className="size-3.5 shrink-0" />
|
|
||||||
<span className="truncate" title={error}>{error}</span>
|
|
||||||
<button className="shrink-0 hover:text-destructive/70" onClick={() => setError('')}><X className="size-3" /></button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-1.5 rounded-md border border-success-border bg-success-muted text-success-muted-foreground px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
|
|
||||||
<Satellite className="size-3.5 shrink-0" />
|
|
||||||
<span className="truncate" title={toast}>{toast}</span>
|
|
||||||
<button className="shrink-0 text-success hover:text-success" onClick={dismissToast}><X className="size-3" /></button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col items-end leading-none">
|
<div className="flex flex-col items-end leading-none">
|
||||||
<span className="text-2xl font-semibold text-primary tracking-wide">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
|
<span className="text-2xl font-semibold text-primary tracking-wide">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
|
||||||
{catState.split && rxFreqMhz && (
|
{catState.split && rxFreqMhz && (
|
||||||
@@ -3738,6 +3843,7 @@ export default function App() {
|
|||||||
{qthBlock}
|
{qthBlock}
|
||||||
{gridBlock}
|
{gridBlock}
|
||||||
{lotwBlock}
|
{lotwBlock}
|
||||||
|
{offlineBlock}
|
||||||
{alertLedBlock}
|
{alertLedBlock}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -4011,6 +4117,21 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
|
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
|
||||||
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>}
|
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>}
|
||||||
|
{statsTabOpen && (
|
||||||
|
<TabsTrigger value="stats" className="gap-1.5">
|
||||||
|
{t('stats.tab')}
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
aria-label="Close Statistics"
|
||||||
|
title="Close"
|
||||||
|
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
|
||||||
|
onPointerDown={(e) => { e.stopPropagation(); }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); closeStatsTab(); }}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
{qslTabOpen && (
|
{qslTabOpen && (
|
||||||
<TabsTrigger value="qsl" className="gap-1.5">
|
<TabsTrigger value="qsl" className="gap-1.5">
|
||||||
QSL Manager
|
QSL Manager
|
||||||
@@ -4240,8 +4361,48 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{/* Console — the RAW cluster stream. Spots are parsed out of it into
|
||||||
|
the grid above, but SH/DX, WHO, the MOTD and error replies are not
|
||||||
|
spots: without this they were dropped and the command box looked
|
||||||
|
inert (you typed SH/DX/100 and nothing ever happened). */}
|
||||||
|
{clusterConsoleOpen && (
|
||||||
|
<div className="border-t border-border/60 shrink-0 flex flex-col" style={{ height: 200 }}>
|
||||||
|
<div className="flex items-center gap-2 px-2.5 py-1 bg-muted/30 border-b border-border/60 shrink-0">
|
||||||
|
<Terminal className="size-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{t('cluster.console')}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground tabular-nums">{clusterLines.length}</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button className="text-[11px] text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setClusterLines([])}>{t('cluster.clear')}</button>
|
||||||
|
<button className="text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setClusterConsoleOpen(false)} title={t('cluster.hideConsole')}>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div ref={clusterConsoleRef} className="flex-1 min-h-0 overflow-auto bg-background/40 px-2.5 py-1.5 font-mono text-[11px] leading-[1.45]">
|
||||||
|
{clusterLines.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground italic">{t('cluster.consoleEmpty')}</p>
|
||||||
|
) : clusterLines.map((l, i) => (
|
||||||
|
<div key={i} className={cn('whitespace-pre-wrap break-all', l.sent ? 'text-primary font-semibold' : 'text-foreground/85')}>
|
||||||
|
<span className="text-muted-foreground/60 mr-1.5 select-none">{l.at}</span>
|
||||||
|
{l.sent && <span className="text-muted-foreground/60 mr-1 select-none">»</span>}
|
||||||
|
{l.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Command input — sends to the master server. */}
|
{/* Command input — sends to the master server. */}
|
||||||
<div className="flex items-center gap-2 p-2.5 border-t border-border/60 shrink-0">
|
<div className="flex items-center gap-2 p-2.5 border-t border-border/60 shrink-0">
|
||||||
|
{!clusterConsoleOpen && (
|
||||||
|
<button onClick={() => setClusterConsoleOpen(true)} title={t('cluster.showConsole')}
|
||||||
|
className="inline-flex items-center justify-center size-8 rounded-md border border-border hover:bg-muted shrink-0">
|
||||||
|
<Terminal className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<span className="text-xs text-muted-foreground font-mono whitespace-nowrap">→ master</span>
|
<span className="text-xs text-muted-foreground font-mono whitespace-nowrap">→ master</span>
|
||||||
<Input
|
<Input
|
||||||
className="font-mono text-xs h-8"
|
className="font-mono text-xs h-8"
|
||||||
@@ -4305,6 +4466,12 @@ export default function App() {
|
|||||||
<AwardsPanel onEditQSO={openEdit} onAwardsChanged={() => setAwardsVersion((v) => v + 1)} />
|
<AwardsPanel onEditQSO={openEdit} onAwardsChanged={() => setAwardsVersion((v) => v + 1)} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{statsTabOpen && (
|
||||||
|
<TabsContent value="stats" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||||
|
<StatsPanel />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
{contestTabEnabled && (
|
{contestTabEnabled && (
|
||||||
<TabsContent value="contest" className="flex-1 min-h-0 p-0">
|
<TabsContent value="contest" className="flex-1 min-h-0 p-0">
|
||||||
<ContestPanel session={contest} onChange={updateContest} />
|
<ContestPanel session={contest} onChange={updateContest} />
|
||||||
@@ -4425,7 +4592,7 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<footer className="flex items-center gap-2 px-3 h-7 bg-card border-t border-border shrink-0">
|
<footer className="relative flex items-center gap-2 px-3 h-7 bg-card border-t border-border shrink-0">
|
||||||
<span className="text-[11px] text-muted-foreground">
|
<span className="text-[11px] text-muted-foreground">
|
||||||
QSO count <strong className="text-foreground font-mono">{total.toLocaleString('en-US')}</strong>
|
QSO count <strong className="text-foreground font-mono">{total.toLocaleString('en-US')}</strong>
|
||||||
</span>
|
</span>
|
||||||
@@ -4444,7 +4611,48 @@ export default function App() {
|
|||||||
disabled={!rotatorHeading.enabled}
|
disabled={!rotatorHeading.enabled}
|
||||||
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1" />
|
{/* Toasts / errors: the status bar's free space is far wider than the
|
||||||
|
header band they used to sit in. Still one line (the bar is 28px),
|
||||||
|
but CLICK opens the full text wrapped — long messages (a TQSL or
|
||||||
|
Club Log failure) are no longer cut off with no way to read them. */}
|
||||||
|
<div className="flex-1 min-w-0 flex items-center gap-1 px-1">
|
||||||
|
{(error || toast) && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMsgOpen((o) => !o)}
|
||||||
|
title={t('msg.expand')}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] min-w-0 max-w-full animate-in fade-in',
|
||||||
|
error
|
||||||
|
? 'border-destructive/40 bg-destructive/10 text-destructive hover:bg-destructive/20'
|
||||||
|
: 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted/70',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{error ? <AlertCircle className="size-3.5 shrink-0" /> : <Satellite className="size-3.5 shrink-0" />}
|
||||||
|
<span className="truncate">{error || toast}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => { setMsgOpen(false); if (error) setError(''); else dismissToast(); }}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
{msgOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setMsgOpen(false)} />
|
||||||
|
<div className="absolute bottom-7 left-2 z-50 w-[min(70vw,760px)] rounded-lg border border-border bg-card shadow-xl p-3">
|
||||||
|
<p className={cn('text-xs whitespace-pre-wrap break-words leading-relaxed',
|
||||||
|
error ? 'text-destructive' : 'text-foreground')}>
|
||||||
|
{error || toast}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{dbConn && (
|
{dbConn && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search } from 'lucide-react';
|
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen } from 'lucide-react';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
||||||
ListCountries, DXCCForCountry, DXCCName,
|
ListCountries, DXCCForCountry, DXCCName,
|
||||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||||
ExportAwards, ImportAwards,
|
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
// Above this many references the editor stops loading the whole list and
|
// Above this many references the editor stops loading the whole list and
|
||||||
@@ -177,6 +177,29 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
loadMeta();
|
loadMeta();
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
// Codes present in the SHIPPED catalog. Anything in the database that isn't in
|
||||||
|
// here exists only on this machine — it is yours alone, and a reinstall loses it
|
||||||
|
// unless it has been exported. The list flags those.
|
||||||
|
const [catalogCodes, setCatalogCodes] = useState<string[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
GetCatalogCodes().then((c: any) => setCatalogCodes(((c ?? []) as string[]).map((s) => s.toUpperCase()))).catch(() => {});
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Pending import awaiting the operator's decision on the awards that collide.
|
||||||
|
type ImportEntry = { code: string; name: string; references: number; exists: boolean; mine_name: string; mine_refs: number; protected: boolean };
|
||||||
|
type ImportPreview = { path: string; awards: ImportEntry[] };
|
||||||
|
const [importPreview, setImportPreview] = useState<ImportPreview | null>(null);
|
||||||
|
const [decisions, setDecisions] = useState<Record<string, string>>({});
|
||||||
|
useEffect(() => {
|
||||||
|
if (!importPreview) return;
|
||||||
|
// Default to the SAFE choice: keep what you have. An import must never destroy
|
||||||
|
// an award because the operator clicked through a dialog without reading it.
|
||||||
|
const d: Record<string, string> = {};
|
||||||
|
for (const e of importPreview.awards) d[e.code] = e.exists ? 'skip' : 'replace';
|
||||||
|
setDecisions(d);
|
||||||
|
}, [importPreview]);
|
||||||
|
|
||||||
const cur = defs[sel];
|
const cur = defs[sel];
|
||||||
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
|
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
|
||||||
const toggleIn = (key: keyof AwardDef, v: string) => {
|
const toggleIn = (key: keyof AwardDef, v: string) => {
|
||||||
@@ -215,17 +238,39 @@ 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)); }
|
||||||
}
|
}
|
||||||
// Import an award bundle: definitions are upserted by code, reference lists
|
// Import: LOOK FIRST, then ask.
|
||||||
// replaced. Reloads the editor afterwards.
|
//
|
||||||
|
// This used to merge by code with "imported wins", silently — import a WAPC
|
||||||
|
// someone shared and YOUR WAPC (its province list, its city regexes) was
|
||||||
|
// destroyed without a word. Sharing awards is exactly what we want people to do,
|
||||||
|
// so it must not be a data-loss trap.
|
||||||
async function importAwards() {
|
async function importAwards() {
|
||||||
setErr('');
|
setErr('');
|
||||||
try {
|
try {
|
||||||
const r: any = await ImportAwards();
|
const p: any = await InspectAwardImport();
|
||||||
if (!r || (!r.awards && !r.references)) return; // cancelled
|
if (!p?.path) return; // cancelled
|
||||||
|
const clashes = (p.awards ?? []).filter((e: any) => e.exists);
|
||||||
|
if (clashes.length === 0) {
|
||||||
|
// Nothing collides — nothing to ask. This is the common case.
|
||||||
|
const dec: Record<string, string> = {};
|
||||||
|
for (const e of p.awards) dec[e.code] = 'replace';
|
||||||
|
await applyImport(p.path, dec);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setImportPreview(p); // → the collision dialog decides
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyImport(path: string, decisions: Record<string, string>) {
|
||||||
|
try {
|
||||||
|
const r: any = await ApplyAwardImport(path, decisions);
|
||||||
|
setImportPreview(null);
|
||||||
const [d] = await Promise.all([GetAwardDefs(), loadMeta()]);
|
const [d] = await Promise.all([GetAwardDefs(), loadMeta()]);
|
||||||
setDefs((d ?? []) as any); setSel(0);
|
setDefs((d ?? []) as any); setSel(0);
|
||||||
onSaved();
|
onSaved();
|
||||||
|
if (r?.awards || r?.references) {
|
||||||
setErr(t('awed.importedMsg', { awards: r.awards, references: r.references }));
|
setErr(t('awed.importedMsg', { awards: r.awards, references: r.references }));
|
||||||
|
}
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
async function updateList(code: string) {
|
async function updateList(code: string) {
|
||||||
@@ -261,15 +306,28 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{filtered.map(({ d, i }) => (
|
{filtered.map(({ d, i }) => {
|
||||||
|
// An award that is NOT in the shipped catalog exists only in THIS
|
||||||
|
// database: nobody else has it, and a reinstall loses it unless it
|
||||||
|
// has been exported. That deserves to be visible at a glance, not
|
||||||
|
// discovered the hard way.
|
||||||
|
const onlyHere = !catalogCodes.includes((d.code ?? '').toUpperCase());
|
||||||
|
return (
|
||||||
<button key={i} onClick={() => setSel(i)}
|
<button key={i} onClick={() => setSel(i)}
|
||||||
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
|
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
|
||||||
i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
|
i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
|
||||||
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-success')} />
|
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-success')} />
|
||||||
<span className="font-mono font-semibold shrink-0">{d.code}</span>
|
<span className="font-mono font-semibold shrink-0">{d.code}</span>
|
||||||
<span className="text-muted-foreground truncate">{d.name}</span>
|
<span className="text-muted-foreground truncate">{d.name}</span>
|
||||||
|
{onlyHere && (
|
||||||
|
<span className="ml-auto shrink-0 px-1 rounded border border-warning-border bg-warning-muted text-warning-muted-foreground text-[9px] font-semibold uppercase tracking-wide"
|
||||||
|
title={t('awed.onlyHereTip')}>
|
||||||
|
{t('awed.onlyHere')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" className="m-2 h-7 justify-start" onClick={addAward}>
|
<Button variant="ghost" size="sm" className="m-2 h-7 justify-start" onClick={addAward}>
|
||||||
<Plus className="size-3.5 mr-1" /> {t('awed.newAward')}
|
<Plus className="size-3.5 mr-1" /> {t('awed.newAward')}
|
||||||
@@ -297,6 +355,17 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
|
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
|
||||||
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder={t('awed.awardName')} />
|
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder={t('awed.awardName')} />
|
||||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> {t('awed.valid')}</label>
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> {t('awed.valid')}</label>
|
||||||
|
{/* "Built-in" is what you tick before dropping an award into the
|
||||||
|
shipped catalog. Leave it off and "Reset to defaults" DELETES
|
||||||
|
the award on the user's machine — even though you shipped it.
|
||||||
|
Editing the JSON by hand to fix that is exactly what we're
|
||||||
|
avoiding here. */}
|
||||||
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('awed.builtinTip')}>
|
||||||
|
<Checkbox checked={!!cur.builtin} onCheckedChange={(c) => patch({ builtin: !!c })} /> {t('awed.builtin')}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('awed.protectedTip')}>
|
||||||
|
<Checkbox checked={!!cur.protected} onCheckedChange={(c) => patch({ protected: !!c })} /> {t('awed.protectedFlag')}
|
||||||
|
</label>
|
||||||
<button className="text-muted-foreground hover:text-destructive" title={t('awed.deleteAward')} onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
|
<button className="text-muted-foreground hover:text-destructive" title={t('awed.deleteAward')} onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
<Field2 label={t('awed.description')}><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
|
<Field2 label={t('awed.description')}><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
|
||||||
@@ -451,11 +520,78 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
|
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
|
||||||
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
|
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* The drop folder: put an award JSON here and it installs on restart —
|
||||||
|
no rebuild, nobody to ask. Opening it beats printing a path someone
|
||||||
|
then has to retype. */}
|
||||||
|
<Button variant="ghost" onClick={() => OpenAwardsFolder().catch((e: any) => setErr(String(e?.message ?? e)))}
|
||||||
|
title={t('awed.awardsFolderTip')}>
|
||||||
|
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
|
||||||
|
</Button>
|
||||||
<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>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
|
{/* Collision dialog. The import cannot silently replace an award you already
|
||||||
|
have — importing a shared WAPC used to destroy yours (province list, city
|
||||||
|
regexes, band scope) without a word. You decide, per award. */}
|
||||||
|
{importPreview && (
|
||||||
|
<Dialog open onOpenChange={() => setImportPreview(null)}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('awed.importCollisionTitle')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-xs text-muted-foreground -mt-2">{t('awed.importCollisionHint')}</p>
|
||||||
|
<div className="max-h-[50vh] overflow-auto flex flex-col gap-2 mt-2">
|
||||||
|
{importPreview.awards.map((e) => (
|
||||||
|
<div key={e.code} className="rounded-md border border-border p-2.5">
|
||||||
|
<div className="flex items-baseline gap-2 min-w-0">
|
||||||
|
<span className="font-mono font-semibold text-sm">{e.code}</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate">{e.name}</span>
|
||||||
|
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground tabular-nums">
|
||||||
|
{t('awed.importRefs', { n: e.references })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!e.exists ? (
|
||||||
|
<p className="mt-1 text-[11px] text-success-muted-foreground">{t('awed.importNew')}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mt-1 text-[11px] text-warning-muted-foreground">
|
||||||
|
{t('awed.importExists', { name: e.mine_name || e.code, n: e.mine_refs })}
|
||||||
|
{e.protected && ` · ${t('awed.importProtected')}`}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||||
|
{([
|
||||||
|
['skip', t('awed.importKeepMine')],
|
||||||
|
['replace', t('awed.importReplace')],
|
||||||
|
['copy', t('awed.importCopy', { code: e.code })],
|
||||||
|
] as [string, string][]).map(([v, label]) => (
|
||||||
|
<button key={v} type="button"
|
||||||
|
onClick={() => setDecisions((d) => ({ ...d, [e.code]: v }))}
|
||||||
|
className={cn('px-2 h-7 rounded-md border text-xs',
|
||||||
|
decisions[e.code] === v
|
||||||
|
? 'border-primary bg-primary text-primary-foreground'
|
||||||
|
: 'border-border hover:bg-muted')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setImportPreview(null)}>{t('awed.cancel')}</Button>
|
||||||
|
<Button onClick={() => applyImport(importPreview.path, decisions)}>
|
||||||
|
<Upload className="size-3.5 mr-1" /> {t('awed.import2')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ type FlexState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type FlexSlice = { index: number; letter: string; freq_hz: number; mode?: string; band?: string; active: boolean; tx: boolean };
|
type FlexSlice = { index: number; letter: string; freq_hz: number; mode?: string; band?: string; active: boolean; tx: boolean };
|
||||||
type Meter = { id: number; src?: string; name?: string; unit?: string; value: number; lo: number; hi: number };
|
type Meter = { id: number; src?: string; name?: string; unit?: string; slice?: number; value: number; lo: number; hi: number };
|
||||||
|
|
||||||
const ZERO: FlexState = {
|
const ZERO: FlexState = {
|
||||||
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
|
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
|
||||||
@@ -356,7 +356,15 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
// Radio meters (exclude the amplifier's, which we show separately).
|
// Radio meters (exclude the amplifier's, which we show separately).
|
||||||
const radio = (name: string) => meters.find((m) =>
|
const radio = (name: string) => meters.find((m) =>
|
||||||
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
|
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
|
||||||
const sig = radio('LEVEL') || radio('SIGNAL');
|
// Per-slice (SLC) meters — S-meter — exist once PER SLICE, so pick the
|
||||||
|
// one for the ACTIVE slice; otherwise we'd always show slice A's level.
|
||||||
|
const activeSlice = (st.slices || []).find((s) => s.active)?.index ?? -1;
|
||||||
|
const sliceMeter = (name: string) => {
|
||||||
|
const m = meters.filter((x) => (x.name || '').toUpperCase().includes(name) && !(x.src || '').toUpperCase().includes('AMP'));
|
||||||
|
if (m.length === 0) return undefined;
|
||||||
|
return m.find((x) => (x.src || '').toUpperCase().includes('SLC') && x.slice === activeSlice) || m[0];
|
||||||
|
};
|
||||||
|
const sig = sliceMeter('LEVEL') || sliceMeter('SIGNAL');
|
||||||
const fwd = radio('FWDPWR');
|
const fwd = radio('FWDPWR');
|
||||||
const swr = radio('SWR');
|
const swr = radio('SWR');
|
||||||
// Mic input level + speech-compression (voltage & PA temp live in the
|
// Mic input level + speech-compression (voltage & PA temp live in the
|
||||||
|
|||||||
@@ -0,0 +1,752 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { BarChart3, Loader2, RefreshCw, Table2 } from 'lucide-react';
|
||||||
|
import { GetLogStats, GetContestRuns } from '../../wailsjs/go/main/App';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Statistics dashboard.
|
||||||
|
//
|
||||||
|
// Colour rules this file obeys (they are not stylistic — they are what keeps the
|
||||||
|
// charts readable and colour-blind-safe):
|
||||||
|
//
|
||||||
|
// • A chart with ONE series uses ONE hue (--chart-1) for every bar. Colour
|
||||||
|
// encodes identity, never a magnitude the bar's length already shows.
|
||||||
|
// • The categorical slots (--chart-1..8) are used in FIXED ORDER and only where
|
||||||
|
// the categories ARE the subject (the continent donut). The order is the
|
||||||
|
// colour-blind-safety mechanism, so it is never shuffled or cycled.
|
||||||
|
// • The donut is for part-to-whole read at a glance. It is NOT used for modes:
|
||||||
|
// CW and SSB are nearly tied, and a donut hides exactly the comparison that
|
||||||
|
// makes them interesting. Close values belong in bars.
|
||||||
|
// • Every value is direct-labelled, so nothing is reachable only by hovering, and
|
||||||
|
// a table view gives the WCAG-clean twin.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Bucket = { key: string; count: number };
|
||||||
|
type Gap = { start: string; end: string; minutes: number };
|
||||||
|
type ContestRun = { id: string; year: number; count: number; start: string; end: string };
|
||||||
|
type Stats = {
|
||||||
|
total: number; unique_calls: number; entities: number; continents: number;
|
||||||
|
first_qso: string; last_qso: string;
|
||||||
|
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
|
||||||
|
by_mode: Bucket[]; by_band: Bucket[]; by_operator: Bucket[]; by_station: Bucket[];
|
||||||
|
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
||||||
|
// Period / contest metrics.
|
||||||
|
window_start: string; window_end: string; window_hours: number;
|
||||||
|
avg_per_hour: number; avg_per_active: number;
|
||||||
|
on_air_minutes: number; off_air_minutes: number;
|
||||||
|
peak_hour_key: string; peak_hour_count: number; best_60: number;
|
||||||
|
gaps: Gap[]; rate: Bucket[];
|
||||||
|
rate_ops: string[]; rate_by_op: number[][];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Minutes → "3 h 12" (a bare "192 min" makes you do arithmetic to read a break).
|
||||||
|
const dur = (m: number) => (m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')}` : `${m} min`);
|
||||||
|
|
||||||
|
const CONTINENT_NAME: Record<string, string> = {
|
||||||
|
EU: 'Europe', NA: 'North America', SA: 'South America',
|
||||||
|
AS: 'Asia', AF: 'Africa', OC: 'Oceania', AN: 'Antarctica',
|
||||||
|
};
|
||||||
|
|
||||||
|
const nf = (n: number) => n.toLocaleString('en-US');
|
||||||
|
|
||||||
|
// ── Shell ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Card({ title, sub, children, className }: { title: string; sub?: string; children: React.ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<section className={cn('rounded-lg border border-border bg-card p-3.5 flex flex-col min-w-0', className)}>
|
||||||
|
<header className="mb-3 shrink-0">
|
||||||
|
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{title}</h3>
|
||||||
|
{sub && <p className="text-[11px] text-muted-foreground/80 mt-0.5">{sub}</p>}
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A headline number IS the chart — a one-bar bar chart would be noise.
|
||||||
|
function StatTile({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-card px-4 py-3 min-w-0">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{label}</p>
|
||||||
|
{/* Proportional figures: tabular-nums makes a big standalone number look loose. */}
|
||||||
|
<p className="mt-1 text-[28px] leading-none font-semibold text-foreground">{value}</p>
|
||||||
|
{sub && <p className="mt-1 text-[11px] text-muted-foreground truncate">{sub}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Horizontal bars (long category names: modes, operators, entities) ─────────
|
||||||
|
// One series → one hue. The value is direct-labelled, so no reader ever depends
|
||||||
|
// on a tooltip to get a number.
|
||||||
|
|
||||||
|
function HBars({ data, max, empty, share }: { data: Bucket[]; max?: number; empty: string; share?: boolean }) {
|
||||||
|
// max is a display cap for long tails (top entities). Where EVERY row matters —
|
||||||
|
// the operators of a multi-op — it is deliberately not set: a capped chart would
|
||||||
|
// silently drop the 9th operator, and "who worked what" is the whole question.
|
||||||
|
const top = max ? data.slice(0, max) : data;
|
||||||
|
const peak = Math.max(1, ...top.map((d) => d.count));
|
||||||
|
const total = data.reduce((s, d) => s + d.count, 0);
|
||||||
|
if (top.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 min-w-0">
|
||||||
|
{top.map((d) => (
|
||||||
|
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key} — ${nf(d.count)}`}>
|
||||||
|
<span className="w-20 shrink-0 truncate text-[11px] text-muted-foreground text-right">{d.key}</span>
|
||||||
|
<div className="flex-1 min-w-0 h-[14px] flex items-center">
|
||||||
|
<div
|
||||||
|
className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
|
||||||
|
style={{ width: `${Math.max(2, (d.count / peak) * 100)}%`, background: 'var(--chart-1)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-14 shrink-0 text-[11px] text-foreground text-right tabular-nums">{nf(d.count)}</span>
|
||||||
|
{share && (
|
||||||
|
<span className="w-10 shrink-0 text-[11px] text-muted-foreground text-right tabular-nums">
|
||||||
|
{total ? ((d.count / total) * 100).toFixed(0) : 0}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vertical columns (short ordered labels: bands in band-plan order, hours) ──
|
||||||
|
// Sorting bands by COUNT would destroy the band-plan reading; the order is the
|
||||||
|
// information.
|
||||||
|
|
||||||
|
function VBars({ data, empty, height = 150 }: { data: Bucket[]; empty: string; height?: number }) {
|
||||||
|
const peak = Math.max(1, ...data.map((d) => d.count));
|
||||||
|
if (data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
// Thin the x labels once the bars get narrow. A label under EVERY one of 48
|
||||||
|
// hourly bars is unreadable mush — the axis exists to orient, not to enumerate
|
||||||
|
// (the value is in the tooltip, and every number is in the table view).
|
||||||
|
const every = data.length <= 16 ? 1 : Math.ceil(data.length / 12);
|
||||||
|
return (
|
||||||
|
// The container includes the x-axis band — a fixed height that excludes it is
|
||||||
|
// how cards end up with a tiny nested scrollbar.
|
||||||
|
<div className="flex items-end gap-[2px] min-w-0" style={{ height }}>
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
|
||||||
|
title={`${d.key} — ${nf(d.count)}`}>
|
||||||
|
<span className="text-[9px] text-muted-foreground mb-0.5 opacity-0 group-hover:opacity-100 transition-opacity tabular-nums whitespace-nowrap">
|
||||||
|
{nf(d.count)}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="w-full rounded-t-[4px] transition-[height] duration-300"
|
||||||
|
style={{ height: `${Math.max(2, (d.count / peak) * 100)}%`, background: 'var(--chart-1)' }}
|
||||||
|
/>
|
||||||
|
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap overflow-visible">
|
||||||
|
{i % every === 0 ? d.key : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contest rate, stacked by operator ────────────────────────────────────────
|
||||||
|
// The categories (the operators) ARE the subject, so this is categorical colour,
|
||||||
|
// in the FIXED order the backend sends (busiest first). An operator therefore
|
||||||
|
// keeps the same hue everywhere on the page — a chart that repaints its series
|
||||||
|
// when the filter changes is a chart nobody can trust.
|
||||||
|
|
||||||
|
function RateStack({ rate, ops, byOp, empty, height = 130 }: {
|
||||||
|
rate: Bucket[]; ops: string[]; byOp: number[][]; empty: string; height?: number;
|
||||||
|
}) {
|
||||||
|
const [hov, setHov] = useState<number | null>(null);
|
||||||
|
if (rate.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
const peak = Math.max(1, ...rate.map((d) => d.count));
|
||||||
|
const every = rate.length <= 16 ? 1 : Math.ceil(rate.length / 12);
|
||||||
|
const single = ops.length <= 1; // one operator → one hue; a legend would be noise
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-end gap-[2px] min-w-0" style={{ height }}>
|
||||||
|
{rate.map((d, i) => (
|
||||||
|
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full"
|
||||||
|
onMouseEnter={() => setHov(i)} onMouseLeave={() => setHov(null)}>
|
||||||
|
{/* The column is the hour's total; the segments are who made it. */}
|
||||||
|
<div className="w-full flex flex-col-reverse justify-start rounded-t-[4px] overflow-hidden"
|
||||||
|
style={{ height: `${Math.max(1, (d.count / peak) * 100)}%` }}>
|
||||||
|
{(byOp[i] ?? []).map((n, o) => n > 0 && (
|
||||||
|
<div key={o} style={{
|
||||||
|
height: `${(n / Math.max(1, d.count)) * 100}%`,
|
||||||
|
background: single ? 'var(--chart-1)' : `var(--chart-${(o % 8) + 1})`,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap">
|
||||||
|
{i % every === 0 ? d.key : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hover read-out: the hour, its total, and the split. Values are also in the
|
||||||
|
rate sheet below, so nothing is reachable by hover alone. */}
|
||||||
|
<p className="mt-1 h-4 text-[11px] text-muted-foreground tabular-nums truncate">
|
||||||
|
{hov !== null && (
|
||||||
|
<>
|
||||||
|
<span className="text-foreground font-medium">{rate[hov].key}</span>
|
||||||
|
{' · '}{nf(rate[hov].count)} QSO
|
||||||
|
{!single && (byOp[hov] ?? []).map((n, o) => n > 0 && (
|
||||||
|
<span key={o}> · <span style={{ color: `var(--chart-${(o % 8) + 1})` }}>■</span> {ops[o]} {n}</span>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!single && (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1">
|
||||||
|
{ops.map((op, o) => (
|
||||||
|
<span key={op} className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||||
|
<span className="size-2.5 rounded-[3px]" style={{ background: `var(--chart-${(o % 8) + 1})` }} />
|
||||||
|
{op}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The rate sheet: hour × operator, with the hour total ─────────────────────
|
||||||
|
// Exact numbers, many of them — that is a table's job, not a chart's. Contesters
|
||||||
|
// read rate sheets as tables, and every value here is also the one the chart draws.
|
||||||
|
|
||||||
|
function RateSheet({ rate, ops, byOp }: { rate: Bucket[]; ops: string[]; byOp: number[][] }) {
|
||||||
|
if (rate.length === 0) return null;
|
||||||
|
const shown = rate.map((d, i) => ({ d, i })).filter(({ d }) => d.count > 0); // silent hours add nothing here
|
||||||
|
const totals = ops.map((_, o) => rate.reduce((s, _d, i) => s + (byOp[i]?.[o] ?? 0), 0));
|
||||||
|
const grand = rate.reduce((s, d) => s + d.count, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto max-h-[260px] rounded-md border border-border">
|
||||||
|
<table className="w-full text-[11px] tabular-nums">
|
||||||
|
<thead className="sticky top-0 bg-muted/60 backdrop-blur">
|
||||||
|
<tr className="text-muted-foreground">
|
||||||
|
<th className="text-left font-medium px-2 py-1">UTC</th>
|
||||||
|
{ops.map((op, o) => (
|
||||||
|
<th key={op} className="text-right font-medium px-2 py-1 whitespace-nowrap">
|
||||||
|
<span className="inline-block size-2 rounded-[2px] mr-1 align-middle"
|
||||||
|
style={{ background: `var(--chart-${(o % 8) + 1})` }} />
|
||||||
|
{op}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="text-right font-semibold px-2 py-1 text-foreground">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{shown.map(({ d, i }) => (
|
||||||
|
<tr key={d.key} className="border-t border-border/50">
|
||||||
|
<td className="px-2 py-0.5 text-muted-foreground whitespace-nowrap">{d.key}</td>
|
||||||
|
{ops.map((_, o) => (
|
||||||
|
<td key={o} className="px-2 py-0.5 text-right">
|
||||||
|
{byOp[i]?.[o] ? nf(byOp[i][o]) : <span className="text-muted-foreground/40">·</span>}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="px-2 py-0.5 text-right font-semibold">{nf(d.count)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="sticky bottom-0 bg-muted/60 backdrop-blur">
|
||||||
|
<tr className="border-t border-border font-semibold">
|
||||||
|
<td className="px-2 py-1">Total</td>
|
||||||
|
{totals.map((n, o) => <td key={o} className="px-2 py-1 text-right">{nf(n)}</td>)}
|
||||||
|
<td className="px-2 py-1 text-right">{nf(grand)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Activity over time (single series → area, one hue) ────────────────────────
|
||||||
|
// Only the PEAK and the LAST point are direct-labelled. A number on every point
|
||||||
|
// is chaos and goes unread.
|
||||||
|
|
||||||
|
function AreaTrend({ data, height = 160, empty }: { data: Bucket[]; height?: number; empty: string }) {
|
||||||
|
const [hover, setHover] = useState<number | null>(null);
|
||||||
|
if (data.length < 2) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
|
||||||
|
const W = 1000, H = 100, peak = Math.max(1, ...data.map((d) => d.count));
|
||||||
|
const x = (i: number) => (i / (data.length - 1)) * W;
|
||||||
|
const y = (v: number) => H - (v / peak) * H;
|
||||||
|
const line = data.map((d, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(d.count).toFixed(1)}`).join(' ');
|
||||||
|
const area = `${line} L${W},${H} L0,${H} Z`;
|
||||||
|
const peakIdx = data.reduce((b, d, i) => (d.count > data[b].count ? i : b), 0);
|
||||||
|
const h = hover ?? -1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="relative" style={{ height }}>
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-full overflow-visible"
|
||||||
|
onMouseLeave={() => setHover(null)}>
|
||||||
|
{/* Recessive hairline grid — solid, never dashed. */}
|
||||||
|
{[0, 0.5, 1].map((f) => (
|
||||||
|
<line key={f} x1={0} x2={W} y1={H * f} y2={H * f} stroke="var(--border)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||||
|
))}
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="statsArea" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="var(--chart-1)" stopOpacity="0.28" />
|
||||||
|
<stop offset="100%" stopColor="var(--chart-1)" stopOpacity="0.02" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d={area} fill="url(#statsArea)" />
|
||||||
|
<path d={line} fill="none" stroke="var(--chart-1)" strokeWidth={2} vectorEffect="non-scaling-stroke"
|
||||||
|
strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
{h >= 0 && (
|
||||||
|
<>
|
||||||
|
<line x1={x(h)} x2={x(h)} y1={0} y2={H} stroke="var(--muted-foreground)" strokeWidth={1} vectorEffect="non-scaling-stroke" opacity={0.5} />
|
||||||
|
{/* 2px surface ring so the marker reads on top of the area. */}
|
||||||
|
<circle cx={x(h)} cy={y(data[h].count)} r={4} fill="var(--chart-1)" stroke="var(--card)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Hit areas are far wider than the marks — pinpoint targets are unusable. */}
|
||||||
|
{data.map((_, i) => (
|
||||||
|
<rect key={i} x={x(i) - W / data.length / 2} y={0} width={W / data.length} height={H}
|
||||||
|
fill="transparent" onMouseEnter={() => setHover(i)} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
{h >= 0 && (
|
||||||
|
<div className="pointer-events-none absolute -top-1 z-10 rounded-md border border-border bg-popover px-2 py-1 text-[11px] shadow-lg whitespace-nowrap"
|
||||||
|
style={{ left: `${(h / (data.length - 1)) * 100}%`, transform: 'translateX(-50%)' }}>
|
||||||
|
<span className="text-muted-foreground">{data[h].key}</span>{' '}
|
||||||
|
<span className="font-semibold tabular-nums">{nf(data[h].count)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-1.5 text-[10px] text-muted-foreground tabular-nums">
|
||||||
|
<span>{data[0].key}</span>
|
||||||
|
<span className="text-foreground font-medium">↑ {data[peakIdx].key} · {nf(data[peakIdx].count)}</span>
|
||||||
|
<span>{data[data.length - 1].key}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Part-to-whole (continents) → the donut ───────────────────────────────────
|
||||||
|
// A pie/donut is legitimate for exactly this: part-to-whole, read at a glance,
|
||||||
|
// few segments, one share obviously dominant. It is the WRONG form for comparing
|
||||||
|
// close values (which is why modes stay bars — CW and SSB are nearly tied, and a
|
||||||
|
// donut would hide precisely the fact that makes them interesting).
|
||||||
|
//
|
||||||
|
// The categories ARE the subject here, so this is the one chart using the
|
||||||
|
// categorical slots — in FIXED order, with a legend and printed values, because
|
||||||
|
// the dark palette sits in the CVD floor band where labels are mandatory.
|
||||||
|
|
||||||
|
function Donut({ data, empty }: { data: Bucket[]; empty: string }) {
|
||||||
|
const [hov, setHov] = useState<number | null>(null);
|
||||||
|
const total = data.reduce((s, d) => s + d.count, 0);
|
||||||
|
if (total === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
|
||||||
|
const slots = data.slice(0, 8); // never generate a 9th hue
|
||||||
|
const R = 58, SW = 22, C = 2 * Math.PI * R;
|
||||||
|
let acc = 0;
|
||||||
|
const arcs = slots.map((d, i) => {
|
||||||
|
const frac = d.count / total;
|
||||||
|
// A 2px surface gap between segments — the correct separator, not a border.
|
||||||
|
const len = Math.max(0, frac * C - 2);
|
||||||
|
const a = { key: d.key, i, len, off: -acc * C, frac };
|
||||||
|
acc += frac;
|
||||||
|
return a;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4 min-w-0">
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<svg viewBox="0 0 160 160" className="size-[140px] -rotate-90">
|
||||||
|
{arcs.map((a) => (
|
||||||
|
<circle key={a.key} cx={80} cy={80} r={R} fill="none"
|
||||||
|
stroke={`var(--chart-${a.i + 1})`} strokeWidth={hov === a.i ? SW + 4 : SW}
|
||||||
|
strokeDasharray={`${a.len} ${C - a.len}`} strokeDashoffset={a.off}
|
||||||
|
className="transition-[stroke-width] duration-150 cursor-default"
|
||||||
|
onMouseEnter={() => setHov(a.i)} onMouseLeave={() => setHov(null)} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
{/* The hole is not decoration — it carries the total the slices add up to. */}
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||||
|
{hov === null ? (
|
||||||
|
<>
|
||||||
|
<span className="text-[17px] font-semibold leading-none">{nf(total)}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground mt-0.5">QSO</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-[15px] font-semibold leading-none">{(arcs[hov].frac * 100).toFixed(1)}%</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground mt-0.5 truncate max-w-[92px] text-center">
|
||||||
|
{CONTINENT_NAME[slots[hov].key] ?? slots[hov].key}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="flex-1 min-w-0 flex flex-col gap-1">
|
||||||
|
{slots.map((d, i) => (
|
||||||
|
<li key={d.key}
|
||||||
|
onMouseEnter={() => setHov(i)} onMouseLeave={() => setHov(null)}
|
||||||
|
className={cn('flex items-center gap-1.5 min-w-0 text-[11px] rounded px-1 -mx-1',
|
||||||
|
hov === i && 'bg-muted')}>
|
||||||
|
{/* The swatch carries identity; the text stays in ink tokens. */}
|
||||||
|
<span className="size-2.5 rounded-[3px] shrink-0" style={{ background: `var(--chart-${i + 1})` }} />
|
||||||
|
<span className="truncate text-muted-foreground">{CONTINENT_NAME[d.key] ?? d.key}</span>
|
||||||
|
<span className="ml-auto shrink-0 tabular-nums text-foreground">{nf(d.count)}</span>
|
||||||
|
<span className="w-9 shrink-0 text-right tabular-nums text-muted-foreground">
|
||||||
|
{((d.count / total) * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── A single ratio against a limit → a meter, not a 2-slice pie ───────────────
|
||||||
|
|
||||||
|
function Meter({ label, value, total }: { label: string; value: number; total: number }) {
|
||||||
|
const pct = total > 0 ? (value / total) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-baseline justify-between gap-2 mb-1">
|
||||||
|
<span className="text-[11px] text-muted-foreground truncate">{label}</span>
|
||||||
|
<span className="text-[11px] tabular-nums">
|
||||||
|
<span className="font-semibold text-foreground">{pct.toFixed(1)}%</span>
|
||||||
|
<span className="text-muted-foreground"> · {nf(value)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Same-ramp track: the meter and its track are one hue, not two. */}
|
||||||
|
<div className="h-2 w-full rounded-full" style={{ background: 'var(--chart-seq-1)' }}>
|
||||||
|
<div className="h-full rounded-full transition-[width] duration-500"
|
||||||
|
style={{ width: `${Math.max(1, pct)}%`, background: 'var(--chart-1)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Period ───────────────────────────────────────────────────────────────────
|
||||||
|
// ONE filter row above everything it scopes — never a control inside a card, or
|
||||||
|
// the charts would each show a different slice of time.
|
||||||
|
|
||||||
|
type Period = 'all' | 'ytd' | 'y12' | 'd30' | 'custom';
|
||||||
|
|
||||||
|
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// Resolve a preset to the [from, to] the backend expects. Empty = no bound.
|
||||||
|
function periodRange(p: Period, from: string, to: string): [string, string] {
|
||||||
|
const now = new Date();
|
||||||
|
switch (p) {
|
||||||
|
case 'ytd': return [`${now.getUTCFullYear()}-01-01`, ''];
|
||||||
|
case 'y12': {
|
||||||
|
const d = new Date(now); d.setUTCMonth(d.getUTCMonth() - 12);
|
||||||
|
return [iso(d), ''];
|
||||||
|
}
|
||||||
|
case 'd30': {
|
||||||
|
const d = new Date(now); d.setUTCDate(d.getUTCDate() - 30);
|
||||||
|
return [iso(d), ''];
|
||||||
|
}
|
||||||
|
case 'custom': return [from, to];
|
||||||
|
default: return ['', ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
|
||||||
|
const total = data.reduce((s, d) => s + d.count, 0);
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||||
|
<p className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground border-b border-border bg-muted/30">{title}</p>
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<tbody>
|
||||||
|
{data.map((d) => (
|
||||||
|
<tr key={d.key} className="border-b border-border/50 last:border-0">
|
||||||
|
<td className="px-3 py-1 truncate">{d.key}</td>
|
||||||
|
<td className="px-3 py-1 text-right tabular-nums font-medium">{nf(d.count)}</td>
|
||||||
|
<td className="px-3 py-1 text-right tabular-nums text-muted-foreground w-14">
|
||||||
|
{total ? ((d.count / total) * 100).toFixed(1) + '%' : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{data.length === 0 && <tr><td className="px-3 py-2 text-muted-foreground italic">—</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Panel ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function StatsPanel() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [view, setView] = useState<'charts' | 'table'>('charts');
|
||||||
|
const [period, setPeriod] = useState<Period>('all');
|
||||||
|
const [from, setFrom] = useState('');
|
||||||
|
const [to, setTo] = useState('');
|
||||||
|
// Contest picker: "ID|YEAR", or '' for none. Selecting one narrows the log to
|
||||||
|
// that contest AND lets the window derive from its own span — no date typing.
|
||||||
|
const [runs, setRuns] = useState<ContestRun[]>([]);
|
||||||
|
const [contest, setContest] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => { GetContestRuns().then((r: any) => setRuns((r ?? []) as ContestRun[])).catch(() => {}); }, []);
|
||||||
|
|
||||||
|
const load = async (p: Period = period, f = from, t2 = to, c = contest) => {
|
||||||
|
// A contest defines its own window (its first→last QSO), so we send no dates
|
||||||
|
// with it — the backend derives them. Sending a period as well would be two
|
||||||
|
// filters fighting over the same axis.
|
||||||
|
const [cid, cyr] = c ? c.split('|') : ['', '0'];
|
||||||
|
const [a, b] = c ? ['', ''] : periodRange(p, f, t2);
|
||||||
|
setBusy(true); setErr('');
|
||||||
|
try {
|
||||||
|
const raw = (await GetLogStats(a, b, cid, parseInt(cyr, 10) || 0)) as any;
|
||||||
|
// Harden the boundary: a Go nil slice arrives as JSON null, and a single
|
||||||
|
// .length on null unmounts the whole React tree — a white screen. Normalise
|
||||||
|
// once, here, rather than guarding at every use site and missing one.
|
||||||
|
const arr = (v: any) => (Array.isArray(v) ? v : []);
|
||||||
|
setStats({
|
||||||
|
...raw,
|
||||||
|
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_operator: arr(raw.by_operator),
|
||||||
|
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
|
||||||
|
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
|
||||||
|
rate: arr(raw.rate), gaps: arr(raw.gaps),
|
||||||
|
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
|
||||||
|
} as Stats);
|
||||||
|
}
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
// Re-run whenever the filter changes. A custom range only fires once BOTH ends
|
||||||
|
// are set — otherwise every keystroke in the date box would re-scan the log.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contest && period === 'custom' && !(from && to)) return;
|
||||||
|
load(period, from, to, contest);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [period, from, to, contest]);
|
||||||
|
|
||||||
|
// The rate / off-air block belongs to a CONTEST-shaped effort, and nowhere else.
|
||||||
|
// Over a year it degenerates into nonsense — "4 156 h off air", a 94-hour "break"
|
||||||
|
// between two ordinary evenings — numbers that are true and completely useless.
|
||||||
|
// So: show it for a picked contest, or for a hand-set range short enough to be a
|
||||||
|
// real operating session (which is exactly when an hourly rate chart exists).
|
||||||
|
const windowed = contest !== '' || (period === 'custom' && (stats?.rate?.length ?? 0) > 1);
|
||||||
|
|
||||||
|
const span = useMemo(() => {
|
||||||
|
if (!stats?.first_qso) return '';
|
||||||
|
const f = new Date(stats.first_qso), l = new Date(stats.last_qso);
|
||||||
|
return `${f.toISOString().slice(0, 10)} → ${l.toISOString().slice(0, 10)}`;
|
||||||
|
}, [stats]);
|
||||||
|
|
||||||
|
if (busy && !stats) {
|
||||||
|
return <div className="flex-1 flex items-center justify-center text-muted-foreground gap-2">
|
||||||
|
<Loader2 className="size-4 animate-spin" /> <span className="text-xs">{t('stats.loading')}</span>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
if (err) return <div className="p-4 text-xs text-destructive">{err}</div>;
|
||||||
|
if (!stats) return null;
|
||||||
|
|
||||||
|
const empty = t('stats.noData');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto p-3">
|
||||||
|
{/* ONE filter/action row above everything it scopes — never per-card controls. */}
|
||||||
|
<div className="flex items-center flex-wrap gap-2 mb-3">
|
||||||
|
<h2 className="text-sm font-semibold">{t('stats.title')}</h2>
|
||||||
|
{span && <span className="text-[11px] text-muted-foreground tabular-nums">{span}</span>}
|
||||||
|
|
||||||
|
{/* A contest is picked from what's actually IN the log (CONTEST_ID + year),
|
||||||
|
never from a static list — so it can't offer a contest you never entered.
|
||||||
|
Choosing one supersedes the period: it brings its own window. */}
|
||||||
|
<select value={contest} onChange={(e) => setContest(e.target.value)}
|
||||||
|
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs max-w-[240px]">
|
||||||
|
<option value="">{t('stats.noContest')}</option>
|
||||||
|
{runs.map((r) => (
|
||||||
|
<option key={`${r.id}|${r.year}`} value={`${r.id}|${r.year}`}>
|
||||||
|
{r.id} {r.year} — {nf(r.count)} QSO
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className={cn('inline-flex rounded-md border border-border overflow-hidden', contest && 'opacity-40 pointer-events-none')}>
|
||||||
|
{([
|
||||||
|
['all', t('stats.pAll')], ['ytd', t('stats.pYTD')],
|
||||||
|
['y12', t('stats.p12m')], ['d30', t('stats.p30d')], ['custom', t('stats.pCustom')],
|
||||||
|
] as [Period, string][]).map(([p, label], i) => (
|
||||||
|
<button key={p} onClick={() => setPeriod(p)}
|
||||||
|
className={cn('px-2 h-7 text-xs whitespace-nowrap', i > 0 && 'border-l border-border',
|
||||||
|
period === p ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!contest && period === 'custom' && (
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)}
|
||||||
|
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
|
||||||
|
<span className="text-xs text-muted-foreground">→</span>
|
||||||
|
<input type="date" value={to} onChange={(e) => setTo(e.target.value)}
|
||||||
|
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
|
<button onClick={() => setView('charts')}
|
||||||
|
className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs', view === 'charts' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||||
|
<BarChart3 className="size-3.5" />{t('stats.charts')}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setView('table')}
|
||||||
|
className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs border-l border-border', view === 'table' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||||
|
<Table2 className="size-3.5" />{t('stats.table')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => load()} disabled={busy} title={t('stats.refresh')}
|
||||||
|
className="inline-flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-50">
|
||||||
|
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Headline figures: stat tiles, not a grouped bar chart. */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
|
||||||
|
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} />
|
||||||
|
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} />
|
||||||
|
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" />
|
||||||
|
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" />
|
||||||
|
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
|
||||||
|
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Period / contest block — ONLY when a window is selected. "12 QSO/h" across
|
||||||
|
seventeen years is noise; across a contest weekend it IS the result. */}
|
||||||
|
{windowed && stats.total > 0 && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5 mb-3">
|
||||||
|
<Card title={t('stats.rate')} sub={t('stats.rateSub')} className="lg:col-span-2">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
|
||||||
|
{/* Two rates on purpose: one honest, one flattering — see the tooltip. */}
|
||||||
|
<div title={t('stats.avgWindowTip')}>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgWindow')}</p>
|
||||||
|
<p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_hour.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
|
||||||
|
</div>
|
||||||
|
<div title={t('stats.avgActiveTip')}>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgActive')}</p>
|
||||||
|
<p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_active.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
|
||||||
|
</div>
|
||||||
|
<div title={t('stats.best60Tip')}>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.best60')}</p>
|
||||||
|
<p className="text-[22px] leading-none font-semibold mt-1">{nf(stats.best_60)}</p>
|
||||||
|
</div>
|
||||||
|
{/* On-air + off-air = the window, by construction. The first version
|
||||||
|
counted "clock hours containing a QSO", which gave 39 h on air AND
|
||||||
|
16 h off air inside a 45 h contest — and rightly wasn't believed. */}
|
||||||
|
<div title={t('stats.onAirTip')}>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.activeHours')}</p>
|
||||||
|
<p className="text-[22px] leading-none font-semibold mt-1">
|
||||||
|
{dur(stats.on_air_minutes)}
|
||||||
|
<span className="text-[11px] text-muted-foreground font-normal"> / {Math.round(stats.window_hours)} h</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{stats.rate.length > 1
|
||||||
|
? <RateStack rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} empty={empty} />
|
||||||
|
: <p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.rateTooLong')}</p>}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title={t('stats.offAir')} sub={t('stats.offAirSub', { d: dur(stats.off_air_minutes) })}>
|
||||||
|
{stats.gaps.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.noGaps')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1 min-w-0">
|
||||||
|
{stats.gaps.map((g, i) => (
|
||||||
|
<li key={i} className="flex items-center gap-2 text-[11px] min-w-0">
|
||||||
|
<span className="tabular-nums text-muted-foreground truncate">
|
||||||
|
{g.start.slice(5, 16).replace('T', ' ')} → {g.end.slice(11, 16)}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 h-[6px] rounded-full min-w-0"
|
||||||
|
style={{
|
||||||
|
background: 'var(--chart-seq-1)',
|
||||||
|
}}>
|
||||||
|
<span className="block h-full rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, (g.minutes / Math.max(1, stats.gaps[0].minutes)) * 100)}%`,
|
||||||
|
background: 'var(--chart-1)',
|
||||||
|
}} />
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 tabular-nums font-medium">{dur(g.minutes)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* The rate sheet: exact numbers, many of them, hour by hour and operator
|
||||||
|
by operator. That is a table's job, not a chart's — and it's how
|
||||||
|
contesters actually read a run. Every value here is also what the
|
||||||
|
stacked chart above draws, so the two can never disagree. */}
|
||||||
|
{stats.rate.length > 1 && (
|
||||||
|
<Card title={t('stats.rateSheet')} sub={t('stats.rateSheetSub')} className="lg:col-span-3">
|
||||||
|
<RateSheet rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'table' ? (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5">
|
||||||
|
<BucketTable title={t('stats.byBand')} data={stats.by_band} />
|
||||||
|
<BucketTable title={t('stats.byMode')} data={stats.by_mode} />
|
||||||
|
<BucketTable title={t('stats.byOperator')} data={stats.by_operator} />
|
||||||
|
<BucketTable title={t('stats.byContinent')} data={stats.by_continent} />
|
||||||
|
<BucketTable title={t('stats.topEntities')} data={stats.top_entities} />
|
||||||
|
<BucketTable title={t('stats.byYear')} data={stats.by_year} />
|
||||||
|
<BucketTable title={t('stats.byStation')} data={stats.by_station} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||||||
|
<Card title={t('stats.byBand')} sub={t('stats.byBandSub')}>
|
||||||
|
<VBars data={stats.by_band} empty={empty} />
|
||||||
|
</Card>
|
||||||
|
<Card title={t('stats.byMode')}>
|
||||||
|
<HBars data={stats.by_mode} max={8} empty={empty} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2">
|
||||||
|
<AreaTrend data={stats.by_month} empty={empty} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
||||||
|
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
||||||
|
instead of truncating. */}
|
||||||
|
<Card title={t('stats.byOperator')} sub={t('stats.byOperatorSub')}>
|
||||||
|
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
|
||||||
|
<HBars data={stats.by_operator} empty={empty} share />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')}>
|
||||||
|
<Donut data={stats.by_continent} empty={empty} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title={t('stats.topEntities')}>
|
||||||
|
<HBars data={stats.top_entities} max={12} empty={empty} />
|
||||||
|
</Card>
|
||||||
|
<div className="flex flex-col gap-2.5 min-w-0">
|
||||||
|
<Card title={t('stats.confirmations')} className="flex-1">
|
||||||
|
<div className="flex flex-col gap-3 justify-center flex-1">
|
||||||
|
<Meter label="LoTW" value={stats.confirmed_lotw} total={stats.total} />
|
||||||
|
<Meter label="eQSL" value={stats.confirmed_eqsl} total={stats.total} />
|
||||||
|
<Meter label={t('stats.paperQSL')} value={stats.confirmed_qsl} total={stats.total} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card title={t('stats.byStation')}>
|
||||||
|
<div className="max-h-[140px] overflow-auto pr-1 min-w-0">
|
||||||
|
<HBars data={stats.by_station} empty={empty} share />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -321,7 +321,6 @@ function EditDialog({
|
|||||||
<Label>{t('udpp.name')}</Label>
|
<Label>{t('udpp.name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder={draft.direction === 'inbound' ? t('udpp.namePhInbound') : t('udpp.namePhOutbound')}
|
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -46,6 +46,39 @@ const en: Dict = {
|
|||||||
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
|
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
|
||||||
'lang.english': 'English', 'lang.french': 'Français',
|
'lang.english': 'English', 'lang.french': 'Français',
|
||||||
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
|
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
|
||||||
|
'stats.tab': 'Statistics', 'stats.title': 'Logbook statistics', 'stats.loading': 'Crunching the log…',
|
||||||
|
'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh',
|
||||||
|
'stats.qsos': 'QSOs', 'stats.uniqueCalls': 'Unique callsigns', 'stats.entities': 'Entities',
|
||||||
|
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmed',
|
||||||
|
'stats.byBand': 'By band', 'stats.byBandSub': 'In band-plan order, not by size',
|
||||||
|
'stats.byMode': 'By mode', 'stats.byOperator': 'By operator',
|
||||||
|
'stats.byOperatorSub': '“—” = logged by the station owner (no OPERATOR set)',
|
||||||
|
'stats.byStation': 'By station callsign', 'stats.byContinent': 'By continent',
|
||||||
|
'stats.topEntities': 'Top DXCC entities', 'stats.byYear': 'By year',
|
||||||
|
'stats.overTime': 'Activity over time', 'stats.overTimeSub': 'QSOs per month',
|
||||||
|
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'Paper QSL',
|
||||||
|
|
||||||
|
'stats.byContinentSub': 'Share of the log',
|
||||||
|
'stats.pAll': 'All', 'stats.pYTD': 'This year', 'stats.p12m': '12 months', 'stats.p30d': '30 days', 'stats.pCustom': 'Custom',
|
||||||
|
'stats.rate': 'Rate', 'stats.rateSub': 'QSOs per hour across the period — silences included',
|
||||||
|
'stats.rateTooLong': 'Period too long for an hourly rate chart (max 31 days)',
|
||||||
|
'stats.avgWindow': 'Avg / hour', 'stats.avgWindowTip': 'QSOs ÷ the WHOLE period, breaks included. The honest rate.',
|
||||||
|
'stats.avgActive': 'Avg / hour on air', 'stats.avgActiveTip': 'QSOs ÷ the hours you actually operated. Flattering — quoting only this is how an 8-hour effort gets sold as a 48-hour score.',
|
||||||
|
'stats.best60': 'Best 60 min', 'stats.best60Tip': 'Best ROLLING 60 minutes (not the best clock hour) — the figure contesters quote.',
|
||||||
|
'stats.activeHours': 'Time on air', 'stats.onAirTip': 'Window minus every silence of 30 min or more. On-air + off-air = the window, by construction.',
|
||||||
|
'stats.offAir': 'Off air', 'stats.offAirSub': 'Silences ≥ 30 min — {d} total',
|
||||||
|
'stats.noGaps': 'No break of 30 min or more.', 'stats.rateSheet': 'Rate sheet', 'stats.rateSheetSub': 'Hour by hour — who made the QSOs (silent hours omitted)', 'stats.noContest': '— No contest —',
|
||||||
|
'cluster.console': 'Console', 'cluster.clear': 'Clear', 'cluster.hideConsole': 'Hide console', 'cluster.showConsole': 'Show the raw cluster console',
|
||||||
|
'cluster.consoleEmpty': 'Raw cluster traffic appears here — including the answers to your commands (SH/DX, WHO, …).',
|
||||||
|
'msg.expand': 'Click to read the full message',
|
||||||
|
'offline.queued': 'Database unreachable — QSO saved locally, will sync automatically',
|
||||||
|
'offline.tip': '{n} QSO(s) waiting — the database is unreachable',
|
||||||
|
'offline.title': 'Offline — {n} QSO(s) waiting',
|
||||||
|
'offline.explain': "Saved to a local file, nothing is lost. They'll be added to the logbook automatically once the database answers. Note: worked-before does NOT include them.",
|
||||||
|
'offline.empty': 'Nothing waiting.',
|
||||||
|
'offline.retry': 'Retry now',
|
||||||
|
'offline.synced': '{n} QSO(s) added to the logbook',
|
||||||
|
'offline.stillDown': 'Database still unreachable — your QSOs are safe',
|
||||||
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.',
|
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.',
|
||||||
'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.light-cool': 'Cool light',
|
'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.light-cool': 'Cool light',
|
||||||
'theme.light-sage': 'Sage light', 'theme.dim-slate': 'Dim slate', 'theme.dark-warm': 'Warm dark',
|
'theme.light-sage': 'Sage light', 'theme.dim-slate': 'Dim slate', 'theme.dark-warm': 'Warm dark',
|
||||||
@@ -215,6 +248,25 @@ const en: Dict = {
|
|||||||
'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.',
|
'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.',
|
||||||
'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.',
|
'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.',
|
||||||
'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.allowMultiple': 'Allow multiple references on a single QSO', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Fallback searches', 'awed.orAlsoMatch': '— tried in order, only if nothing matched yet; first hit wins', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.grantCodes': 'Grant codes', 'awed.exportCreditGranted': 'Export award in ADIF credit_granted field', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference',
|
'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.allowMultiple': 'Allow multiple references on a single QSO', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Fallback searches', 'awed.orAlsoMatch': '— tried in order, only if nothing matched yet; first hit wins', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.grantCodes': 'Grant codes', 'awed.exportCreditGranted': 'Export award in ADIF credit_granted field', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference',
|
||||||
|
'awed.exportOne': 'Share {code}',
|
||||||
|
'awed.onlyHere': 'local',
|
||||||
|
'awed.onlyHereTip': 'Yours — not shipped with OpsLog. Its JSON is kept up to date in the awards folder; send that file to share it.',
|
||||||
|
'awed.builtin': 'Built-in',
|
||||||
|
'awed.awardsFolder': 'Awards folder',
|
||||||
|
'awed.awardsFolderTip': 'Every award you create is saved here as JSON, automatically. To share one, send the file. To receive one, use Import.',
|
||||||
|
'awed.builtinTip': 'Tick before shipping this award in the catalog. Left off, a “Reset to defaults” DELETES it on the user machine — even though you shipped it.',
|
||||||
|
'awed.protectedFlag': 'Protected',
|
||||||
|
'awed.protectedTip': 'Protected awards cannot be deleted from the editor.',
|
||||||
|
'awed.exportOneTitle': 'Export THIS award on its own (definition + references + their regexes) — the file you send someone',
|
||||||
|
'awed.importCollisionTitle': 'Some of these awards already exist',
|
||||||
|
'awed.importCollisionHint': 'Nothing is replaced unless you say so. Import as a copy installs theirs alongside yours, so you can compare before deleting one.',
|
||||||
|
'awed.importRefs': '{n} reference(s)',
|
||||||
|
'awed.importNew': 'New — will be added.',
|
||||||
|
'awed.importExists': 'You already have "{name}" with {n} reference(s).',
|
||||||
|
'awed.importProtected': 'built-in',
|
||||||
|
'awed.importKeepMine': 'Keep mine',
|
||||||
|
'awed.importReplace': 'Replace mine',
|
||||||
|
'awed.importCopy': 'Import as {code}-2',
|
||||||
// QSO modals (context menu / bulk edit / QSL manager / QSO edit)
|
// QSO modals (context menu / bulk edit / QSL manager / QSO edit)
|
||||||
'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from QRZ.com', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…',
|
'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from QRZ.com', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…',
|
||||||
'bulk.fLotwSent': 'LoTW sent', 'bulk.fLotwRcvd': 'LoTW received', 'bulk.fEqslSent': 'eQSL sent', 'bulk.fEqslRcvd': 'eQSL received', 'bulk.fQslSent': 'Paper QSL sent', 'bulk.fQslRcvd': 'Paper QSL received', 'bulk.fQrzUpload': 'QRZ.com upload', 'bulk.fClublogUpload': 'Club Log upload', 'bulk.fHrdlogUpload': 'HRDLog upload', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Station callsign', 'bulk.fOperator': 'Operator', 'bulk.fMyGrid': 'My grid', 'bulk.fMyAntenna': 'My antenna', 'bulk.fMyRig': 'My rig', 'bulk.fMyStreet': 'My street', 'bulk.fMyCity': 'My city', 'bulk.fMyPostal': 'My postal code', 'bulk.fMyCountry': 'My country', 'bulk.fMyState': 'My state', 'bulk.fMyCounty': 'My county', 'bulk.fMyIota': 'My IOTA', 'bulk.fMySota': 'My SOTA ref', 'bulk.fMyPota': 'My POTA ref', 'bulk.fMyWwff': 'My WWFF ref', 'bulk.fMySig': 'My SIG', 'bulk.fMySigInfo': 'My SIG info', 'bulk.fContestId': 'Contest ID', 'bulk.fSrxString': 'Serial rcvd (exchange)', 'bulk.fStxString': 'Serial sent (exchange)', 'bulk.fArrlSect': 'ARRL section', 'bulk.fPrecedence': 'Precedence', 'bulk.fClass': 'Class', 'bulk.fPropMode': 'Propagation mode', 'bulk.fSatName': 'Satellite name', 'bulk.fSatMode': 'Satellite mode', 'bulk.fPotaRef': 'POTA ref', 'bulk.fSotaRef': 'SOTA ref', 'bulk.fWwffRef': 'WWFF ref', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'SIG info', 'bulk.fComment': 'Comment', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Rig (contacted)', 'bulk.fAnt': 'Antenna (contacted)', 'bulk.statusY': 'Y — Yes / uploaded', 'bulk.statusN': 'N — No', 'bulk.statusR': 'R — Requested', 'bulk.statusI': 'I — Ignore', 'bulk.statusBlank': '(blank — clear)', 'bulk.groupQsl': 'QSL / upload', 'bulk.groupMyStation': 'My station', 'bulk.groupContacted': 'Contacted station', 'bulk.groupContest': 'Contest', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Misc', 'bulk.title': 'Bulk edit field', 'bulk.desc': 'Set one field on the {n} selected QSO(s). This overwrites the current value — there is no undo.', 'bulk.fieldLabel': 'Field', 'bulk.valueLabel': 'Value', 'bulk.clearPlaceholder': 'leave empty to clear the field', 'bulk.willSet': 'Will set', 'bulk.blank': '(blank)', 'bulk.onQsos': 'on {n} QSO(s).', 'bulk.cancel': 'Cancel', 'bulk.applyTo': 'Apply to {n}',
|
'bulk.fLotwSent': 'LoTW sent', 'bulk.fLotwRcvd': 'LoTW received', 'bulk.fEqslSent': 'eQSL sent', 'bulk.fEqslRcvd': 'eQSL received', 'bulk.fQslSent': 'Paper QSL sent', 'bulk.fQslRcvd': 'Paper QSL received', 'bulk.fQrzUpload': 'QRZ.com upload', 'bulk.fClublogUpload': 'Club Log upload', 'bulk.fHrdlogUpload': 'HRDLog upload', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Station callsign', 'bulk.fOperator': 'Operator', 'bulk.fMyGrid': 'My grid', 'bulk.fMyAntenna': 'My antenna', 'bulk.fMyRig': 'My rig', 'bulk.fMyStreet': 'My street', 'bulk.fMyCity': 'My city', 'bulk.fMyPostal': 'My postal code', 'bulk.fMyCountry': 'My country', 'bulk.fMyState': 'My state', 'bulk.fMyCounty': 'My county', 'bulk.fMyIota': 'My IOTA', 'bulk.fMySota': 'My SOTA ref', 'bulk.fMyPota': 'My POTA ref', 'bulk.fMyWwff': 'My WWFF ref', 'bulk.fMySig': 'My SIG', 'bulk.fMySigInfo': 'My SIG info', 'bulk.fContestId': 'Contest ID', 'bulk.fSrxString': 'Serial rcvd (exchange)', 'bulk.fStxString': 'Serial sent (exchange)', 'bulk.fArrlSect': 'ARRL section', 'bulk.fPrecedence': 'Precedence', 'bulk.fClass': 'Class', 'bulk.fPropMode': 'Propagation mode', 'bulk.fSatName': 'Satellite name', 'bulk.fSatMode': 'Satellite mode', 'bulk.fPotaRef': 'POTA ref', 'bulk.fSotaRef': 'SOTA ref', 'bulk.fWwffRef': 'WWFF ref', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'SIG info', 'bulk.fComment': 'Comment', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Rig (contacted)', 'bulk.fAnt': 'Antenna (contacted)', 'bulk.statusY': 'Y — Yes / uploaded', 'bulk.statusN': 'N — No', 'bulk.statusR': 'R — Requested', 'bulk.statusI': 'I — Ignore', 'bulk.statusBlank': '(blank — clear)', 'bulk.groupQsl': 'QSL / upload', 'bulk.groupMyStation': 'My station', 'bulk.groupContacted': 'Contacted station', 'bulk.groupContest': 'Contest', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Misc', 'bulk.title': 'Bulk edit field', 'bulk.desc': 'Set one field on the {n} selected QSO(s). This overwrites the current value — there is no undo.', 'bulk.fieldLabel': 'Field', 'bulk.valueLabel': 'Value', 'bulk.clearPlaceholder': 'leave empty to clear the field', 'bulk.willSet': 'Will set', 'bulk.blank': '(blank)', 'bulk.onQsos': 'on {n} QSO(s).', 'bulk.cancel': 'Cancel', 'bulk.applyTo': 'Apply to {n}',
|
||||||
@@ -256,6 +308,39 @@ const fr: Dict = {
|
|||||||
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
|
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
|
||||||
'lang.english': 'English', 'lang.french': 'Français',
|
'lang.english': 'English', 'lang.french': 'Français',
|
||||||
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
|
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
|
||||||
|
'stats.tab': 'Statistiques', 'stats.title': 'Statistiques du journal', 'stats.loading': 'Analyse du journal…',
|
||||||
|
'stats.noData': 'Aucune donnée', 'stats.charts': 'Graphiques', 'stats.table': 'Tableau', 'stats.refresh': 'Rafraîchir',
|
||||||
|
'stats.qsos': 'QSO', 'stats.uniqueCalls': 'Indicatifs uniques', 'stats.entities': 'Entités',
|
||||||
|
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmés',
|
||||||
|
'stats.byBand': 'Par bande', 'stats.byBandSub': "Dans l'ordre du plan de bande, pas par taille",
|
||||||
|
'stats.byMode': 'Par mode', 'stats.byOperator': 'Par opérateur',
|
||||||
|
'stats.byOperatorSub': '« — » = loggé par le titulaire (pas d’OPERATOR renseigné)',
|
||||||
|
'stats.byStation': 'Par indicatif de station', 'stats.byContinent': 'Par continent',
|
||||||
|
'stats.topEntities': 'Top entités DXCC', 'stats.byYear': 'Par année',
|
||||||
|
'stats.overTime': 'Activité dans le temps', 'stats.overTimeSub': 'QSO par mois',
|
||||||
|
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'QSL papier',
|
||||||
|
|
||||||
|
'stats.byContinentSub': 'Part du journal',
|
||||||
|
'stats.pAll': 'Tout', 'stats.pYTD': 'Cette année', 'stats.p12m': '12 mois', 'stats.p30d': '30 jours', 'stats.pCustom': 'Personnalisé',
|
||||||
|
'stats.rate': 'Cadence', 'stats.rateSub': 'QSO par heure sur la période — silences compris',
|
||||||
|
'stats.rateTooLong': 'Période trop longue pour une courbe horaire (max 31 jours)',
|
||||||
|
'stats.avgWindow': 'Moy. / heure', 'stats.avgWindowTip': 'QSO ÷ la période ENTIÈRE, pauses comprises. La vraie cadence.',
|
||||||
|
'stats.avgActive': 'Moy. / heure en l’air', 'stats.avgActiveTip': 'QSO ÷ les heures réellement opérées. Flatteur — ne citer que celle-ci, c’est vendre 8 h d’effort comme un score de 48 h.',
|
||||||
|
'stats.best60': 'Meilleure heure', 'stats.best60Tip': 'Meilleurs 60 min GLISSANTES (pas la meilleure heure ronde) — le chiffre que citent les contesteurs.',
|
||||||
|
'stats.activeHours': 'Temps en l’air', 'stats.onAirTip': 'Fenêtre moins tous les silences de 30 min ou plus. Temps en l’air + hors antenne = la fenêtre, par construction.',
|
||||||
|
'stats.offAir': 'Hors antenne', 'stats.offAirSub': 'Silences ≥ 30 min — {d} au total',
|
||||||
|
'stats.noGaps': 'Aucune pause de 30 min ou plus.', 'stats.rateSheet': 'Feuille de cadence', 'stats.rateSheetSub': 'Heure par heure — qui a fait les QSO (heures muettes omises)', 'stats.noContest': '— Aucun contest —',
|
||||||
|
'cluster.console': 'Console', 'cluster.clear': 'Effacer', 'cluster.hideConsole': 'Masquer la console', 'cluster.showConsole': 'Afficher la console brute du cluster',
|
||||||
|
'cluster.consoleEmpty': 'Le trafic brut du cluster s’affiche ici — y compris les réponses à tes commandes (SH/DX, WHO, …).',
|
||||||
|
'msg.expand': 'Cliquer pour lire le message en entier',
|
||||||
|
'offline.queued': 'Base injoignable — QSO enregistré localement, synchro automatique',
|
||||||
|
'offline.tip': '{n} QSO en attente — la base est injoignable',
|
||||||
|
'offline.title': 'Hors ligne — {n} QSO en attente',
|
||||||
|
'offline.explain': "Enregistrés dans un fichier local, rien n'est perdu. Ils rejoindront le journal automatiquement dès que la base répondra. Attention : le « déjà contacté » ne les prend PAS en compte.",
|
||||||
|
'offline.empty': 'Rien en attente.',
|
||||||
|
'offline.retry': 'Réessayer maintenant',
|
||||||
|
'offline.synced': '{n} QSO ajoutés au journal',
|
||||||
|
'offline.stillDown': 'Base toujours injoignable — tes QSO sont en sécurité',
|
||||||
'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.",
|
'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.",
|
||||||
'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.light-cool': 'Clair froid',
|
'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.light-cool': 'Clair froid',
|
||||||
'theme.light-sage': 'Clair sauge', 'theme.dim-slate': 'Ardoise tamisé', 'theme.dark-warm': 'Sombre chaud',
|
'theme.light-sage': 'Clair sauge', 'theme.dim-slate': 'Ardoise tamisé', 'theme.dark-warm': 'Sombre chaud',
|
||||||
@@ -406,6 +491,25 @@ const fr: Dict = {
|
|||||||
'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.',
|
'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.',
|
||||||
'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.',
|
'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.',
|
||||||
'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.allowMultiple': 'Autoriser plusieurs références sur un seul QSO', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches de repli', 'awed.orAlsoMatch': "— essayées dans l'ordre, seulement si rien n'a encore été trouvé ; la première qui marche gagne", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.grantCodes': "Codes d'attribution", 'awed.exportCreditGranted': 'Exporter le diplôme dans le champ ADIF credit_granted', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence',
|
'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.allowMultiple': 'Autoriser plusieurs références sur un seul QSO', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches de repli', 'awed.orAlsoMatch': "— essayées dans l'ordre, seulement si rien n'a encore été trouvé ; la première qui marche gagne", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.grantCodes': "Codes d'attribution", 'awed.exportCreditGranted': 'Exporter le diplôme dans le champ ADIF credit_granted', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence',
|
||||||
|
'awed.exportOne': 'Partager {code}',
|
||||||
|
'awed.onlyHere': 'local',
|
||||||
|
'awed.onlyHereTip': 'À toi — non livré avec OpsLog. Son JSON est tenu à jour dans le dossier awards ; envoie ce fichier pour le partager.',
|
||||||
|
'awed.builtin': 'Intégré',
|
||||||
|
'awed.awardsFolder': 'Dossier awards',
|
||||||
|
'awed.awardsFolderTip': 'Chaque diplôme que tu crées est enregistré ici en JSON, automatiquement. Pour en partager un : envoie le fichier. Pour en recevoir un : Importer.',
|
||||||
|
'awed.builtinTip': 'À cocher avant de livrer ce diplôme dans le catalogue. Sans ça, un « Réinitialiser par défaut » le SUPPRIME chez l’utilisateur — alors que tu l’as livré.',
|
||||||
|
'awed.protectedFlag': 'Protégé',
|
||||||
|
'awed.protectedTip': 'Un diplôme protégé ne peut pas être supprimé depuis l’éditeur.',
|
||||||
|
'awed.exportOneTitle': 'Exporter CE diplôme seul (définition + références + leurs regex) — le fichier que tu envoies à quelqu’un',
|
||||||
|
'awed.importCollisionTitle': 'Certains de ces diplômes existent déjà',
|
||||||
|
'awed.importCollisionHint': 'Rien n’est remplacé sans ton accord. « Importer en copie » installe le sien à côté du tien : tu compares, puis tu supprimes l’un des deux.',
|
||||||
|
'awed.importRefs': '{n} référence(s)',
|
||||||
|
'awed.importNew': 'Nouveau — sera ajouté.',
|
||||||
|
'awed.importExists': 'Tu as déjà « {name} » avec {n} référence(s).',
|
||||||
|
'awed.importProtected': 'intégré',
|
||||||
|
'awed.importKeepMine': 'Garder le mien',
|
||||||
|
'awed.importReplace': 'Remplacer le mien',
|
||||||
|
'awed.importCopy': 'Importer en {code}-2',
|
||||||
'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis QRZ.com', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…',
|
'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis QRZ.com', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…',
|
||||||
'bulk.fLotwSent': 'LoTW envoyé', 'bulk.fLotwRcvd': 'LoTW reçu', 'bulk.fEqslSent': 'eQSL envoyé', 'bulk.fEqslRcvd': 'eQSL reçu', 'bulk.fQslSent': 'QSL papier envoyée', 'bulk.fQslRcvd': 'QSL papier reçue', 'bulk.fQrzUpload': 'Envoi QRZ.com', 'bulk.fClublogUpload': 'Envoi Club Log', 'bulk.fHrdlogUpload': 'Envoi HRDLog', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Indicatif de la station', 'bulk.fOperator': 'Opérateur', 'bulk.fMyGrid': 'Mon locator', 'bulk.fMyAntenna': 'Mon antenne', 'bulk.fMyRig': 'Mon équipement', 'bulk.fMyStreet': 'Ma rue', 'bulk.fMyCity': 'Ma ville', 'bulk.fMyPostal': 'Mon code postal', 'bulk.fMyCountry': 'Mon pays', 'bulk.fMyState': 'Mon état', 'bulk.fMyCounty': 'Mon comté', 'bulk.fMyIota': 'Mon IOTA', 'bulk.fMySota': 'Ma réf. SOTA', 'bulk.fMyPota': 'Ma réf. POTA', 'bulk.fMyWwff': 'Ma réf. WWFF', 'bulk.fMySig': 'Mon SIG', 'bulk.fMySigInfo': 'Mon info SIG', 'bulk.fContestId': 'ID concours', 'bulk.fSrxString': 'Série reçue (échange)', 'bulk.fStxString': 'Série envoyée (échange)', 'bulk.fArrlSect': 'Section ARRL', 'bulk.fPrecedence': 'Précédence', 'bulk.fClass': 'Classe', 'bulk.fPropMode': 'Mode de propagation', 'bulk.fSatName': 'Nom du satellite', 'bulk.fSatMode': 'Mode satellite', 'bulk.fPotaRef': 'Réf POTA', 'bulk.fSotaRef': 'Réf SOTA', 'bulk.fWwffRef': 'Réf WWFF', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'Info SIG', 'bulk.fComment': 'Commentaire', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Équipement (contacté)', 'bulk.fAnt': 'Antenne (contactée)', 'bulk.statusY': 'Y — Oui / envoyé', 'bulk.statusN': 'N — Non', 'bulk.statusR': 'R — Demandé', 'bulk.statusI': 'I — Ignorer', 'bulk.statusBlank': '(vide — effacer)', 'bulk.groupQsl': 'QSL / envoi', 'bulk.groupMyStation': 'Ma station', 'bulk.groupContacted': 'Station contactée', 'bulk.groupContest': 'Concours', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Divers', 'bulk.title': "Édition groupée d'un champ", 'bulk.desc': 'Définir un champ sur les {n} QSO sélectionné(s). Cela écrase la valeur actuelle — aucune annulation possible.', 'bulk.fieldLabel': 'Champ', 'bulk.valueLabel': 'Valeur', 'bulk.clearPlaceholder': 'laisser vide pour effacer le champ', 'bulk.willSet': 'Définira', 'bulk.blank': '(vide)', 'bulk.onQsos': 'sur {n} QSO.', 'bulk.cancel': 'Annuler', 'bulk.applyTo': 'Appliquer à {n}',
|
'bulk.fLotwSent': 'LoTW envoyé', 'bulk.fLotwRcvd': 'LoTW reçu', 'bulk.fEqslSent': 'eQSL envoyé', 'bulk.fEqslRcvd': 'eQSL reçu', 'bulk.fQslSent': 'QSL papier envoyée', 'bulk.fQslRcvd': 'QSL papier reçue', 'bulk.fQrzUpload': 'Envoi QRZ.com', 'bulk.fClublogUpload': 'Envoi Club Log', 'bulk.fHrdlogUpload': 'Envoi HRDLog', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Indicatif de la station', 'bulk.fOperator': 'Opérateur', 'bulk.fMyGrid': 'Mon locator', 'bulk.fMyAntenna': 'Mon antenne', 'bulk.fMyRig': 'Mon équipement', 'bulk.fMyStreet': 'Ma rue', 'bulk.fMyCity': 'Ma ville', 'bulk.fMyPostal': 'Mon code postal', 'bulk.fMyCountry': 'Mon pays', 'bulk.fMyState': 'Mon état', 'bulk.fMyCounty': 'Mon comté', 'bulk.fMyIota': 'Mon IOTA', 'bulk.fMySota': 'Ma réf. SOTA', 'bulk.fMyPota': 'Ma réf. POTA', 'bulk.fMyWwff': 'Ma réf. WWFF', 'bulk.fMySig': 'Mon SIG', 'bulk.fMySigInfo': 'Mon info SIG', 'bulk.fContestId': 'ID concours', 'bulk.fSrxString': 'Série reçue (échange)', 'bulk.fStxString': 'Série envoyée (échange)', 'bulk.fArrlSect': 'Section ARRL', 'bulk.fPrecedence': 'Précédence', 'bulk.fClass': 'Classe', 'bulk.fPropMode': 'Mode de propagation', 'bulk.fSatName': 'Nom du satellite', 'bulk.fSatMode': 'Mode satellite', 'bulk.fPotaRef': 'Réf POTA', 'bulk.fSotaRef': 'Réf SOTA', 'bulk.fWwffRef': 'Réf WWFF', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'Info SIG', 'bulk.fComment': 'Commentaire', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Équipement (contacté)', 'bulk.fAnt': 'Antenne (contactée)', 'bulk.statusY': 'Y — Oui / envoyé', 'bulk.statusN': 'N — Non', 'bulk.statusR': 'R — Demandé', 'bulk.statusI': 'I — Ignorer', 'bulk.statusBlank': '(vide — effacer)', 'bulk.groupQsl': 'QSL / envoi', 'bulk.groupMyStation': 'Ma station', 'bulk.groupContacted': 'Station contactée', 'bulk.groupContest': 'Concours', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Divers', 'bulk.title': "Édition groupée d'un champ", 'bulk.desc': 'Définir un champ sur les {n} QSO sélectionné(s). Cela écrase la valeur actuelle — aucune annulation possible.', 'bulk.fieldLabel': 'Champ', 'bulk.valueLabel': 'Valeur', 'bulk.clearPlaceholder': 'laisser vide pour effacer le champ', 'bulk.willSet': 'Définira', 'bulk.blank': '(vide)', 'bulk.onQsos': 'sur {n} QSO.', 'bulk.cancel': 'Annuler', 'bulk.applyTo': 'Appliquer à {n}',
|
||||||
'qslm.leave': '— laisser —', 'qslm.yes': 'Oui', 'qslm.no': 'Non', 'qslm.requested': 'Demandé', 'qslm.ignore': 'Ignorer', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Électronique', 'qslm.svcPota': 'Journal chasseur POTA', 'qslm.svcPaper': 'QSL papier', 'qslm.sentRequested': 'Demandé', 'qslm.sentNo': 'Non', 'qslm.sentQueued': 'En file', 'qslm.sentYes': 'Oui (déjà envoyé)', 'qslm.sentInvalid': 'Invalide', 'qslm.sentBlank': '— vide —', 'qslm.qsoUpdated': '{n} QSO mis à jour.', 'qslm.service': 'Service', 'qslm.callsign': 'Indicatif', 'qslm.callsignScopeTitle': "L'envoi/téléchargement est limité à cet indicatif (indicatif de station forcé, sinon celui du profil actif)", 'qslm.syncHunterLog': 'Synchroniser le journal chasseur', 'qslm.onlyMyCallTitle': "Ne synchroniser que les chasses faites sous l'indicatif de votre profil actif — ignorer les QSO faits sous un autre indicatif (ex. XV9Q, NQ2H) absents de ce journal", 'qslm.onlyMyCall': "Uniquement l'indicatif de mon profil", 'qslm.addMissingTitle': "Insérer les contacts du journal chasseur dont l'indicatif n'est pas encore dans votre journal (indicatif/date/bande/mode/parc)", 'qslm.addMissing': 'Ajouter les QSO introuvables à mon journal', 'qslm.potaToken': 'Jeton dans Réglages → Services externes → POTA.', 'qslm.callsignPlaceholder': 'ex. DL1ABC', 'qslm.search': 'Rechercher', 'qslm.paperHint': 'Trouvez un indicatif, puis définissez QSL envoyée/reçue + via + date sur la sélection.', 'qslm.sentStatus': 'Statut envoyé', 'qslm.selectRequired': 'Sélectionner les requis', 'qslm.potaSummaryShort': '{updated} mis à jour · {added} ajoutés · {already} déjà · {unmatched} sans correspondance', 'qslm.potaOtherCall': ' · {n} autre indicatif', 'qslm.paperCount': '{total} QSO · {selected} sélectionné(s)', 'qslm.filter': 'Filtre', 'qslm.filterAll': 'Tous', 'qslm.filterNew': 'Nouveau (tout)', 'qslm.filterNewDxcc': 'Nouveau DXCC', 'qslm.filterNewBand': 'Nouvelle bande', 'qslm.filterNewSlot': 'Nouveau créneau', 'qslm.results': 'Résultats', 'qslm.log': 'Journal', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} trouvé(s) · {selected} sélectionné(s)', 'qslm.paperEmpty': 'Recherchez un indicatif pour lister ses QSO, puis définissez le statut QSL ci-dessous.', 'qslm.potaEmpty': 'Cliquez sur « Synchroniser le journal chasseur » pour récupérer votre journal pota.app et tamponner les références de parc.', 'qslm.potaSyncing': 'Synchronisation avec pota.app…', 'qslm.potaSummary': '{updated} QSO mis à jour · {added} ajoutés au journal · {already} déjà tamponnés · {unmatched} sans correspondance (sur {fetched} entrées du journal chasseur).', 'qslm.potaSkipped': ' {n} chasse(s) faites sous un autre indicatif ont été ignorées', 'qslm.potaKeptOnly': ' (conservé uniquement {call})', 'qslm.potaRescan': 'Relancez le scan du diplôme POTA pour compter les nouvelles références.', 'qslm.thActivator': 'Activateur', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Bande', 'qslm.thPark': 'Parc', 'qslm.thWhyUnmatched': 'Pourquoi sans correspondance', 'qslm.openToFix': 'Ouvrir ce QSO pour le corriger', 'qslm.starting': 'démarrage…', 'qslm.working': 'en cours…', 'qslm.noNewConf': 'Aucune nouvelle confirmation.', 'qslm.noConfMatch': 'Aucune confirmation ne correspond à ce filtre.', 'qslm.thCallsign': 'Indicatif', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Pays', 'qslm.thNew': 'Nouveau ?', 'qslm.newDxcc': 'NOUVEAU DXCC', 'qslm.newBand': 'NOUVELLE BANDE', 'qslm.newSlot': 'NOUVEAU CRÉNEAU', 'qslm.uploadEmpty': 'Choisissez un service + statut envoyé, puis « Sélectionner les requis ».', 'qslm.qslReceived': 'QSL reçue', 'qslm.qslRcvdDateTitle': 'Date de réception QSL', 'qslm.qslSent': 'QSL envoyée', 'qslm.qslSentDateTitle': "Date d'envoi QSL", 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'ex. payé 3€', 'qslm.comment': 'Commentaire', 'qslm.commentPlaceholder': 'commentaire', 'qslm.applyToSelected': 'Appliquer à {n} sélectionné(s)', 'qslm.downloadTitle': 'Récupérer les confirmations du service et mettre à jour le statut reçu', 'qslm.downloadConf': 'Télécharger les confirmations', 'qslm.downloadRangeTitle': "Jusqu'où télécharger", 'qslm.sinceLast': 'Depuis le dernier téléchargement', 'qslm.sinceDate': 'Depuis une date…', 'qslm.sinceAll': 'Tout', 'qslm.sinceDateTitleQrz': 'QRZ : filtre par date de QSO (pas de filtre par date de réception côté serveur)', 'qslm.sinceDateTitleLotw': 'LoTW : confirmations reçues depuis cette date', 'qslm.addNotFoundTitle': "Insérer les QSO confirmés qui ne sont pas encore dans votre journal", 'qslm.addNotFound': 'Ajouter les introuvables', 'qslm.uploadTo': 'Envoyer {n} vers {service}',
|
'qslm.leave': '— laisser —', 'qslm.yes': 'Oui', 'qslm.no': 'Non', 'qslm.requested': 'Demandé', 'qslm.ignore': 'Ignorer', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Électronique', 'qslm.svcPota': 'Journal chasseur POTA', 'qslm.svcPaper': 'QSL papier', 'qslm.sentRequested': 'Demandé', 'qslm.sentNo': 'Non', 'qslm.sentQueued': 'En file', 'qslm.sentYes': 'Oui (déjà envoyé)', 'qslm.sentInvalid': 'Invalide', 'qslm.sentBlank': '— vide —', 'qslm.qsoUpdated': '{n} QSO mis à jour.', 'qslm.service': 'Service', 'qslm.callsign': 'Indicatif', 'qslm.callsignScopeTitle': "L'envoi/téléchargement est limité à cet indicatif (indicatif de station forcé, sinon celui du profil actif)", 'qslm.syncHunterLog': 'Synchroniser le journal chasseur', 'qslm.onlyMyCallTitle': "Ne synchroniser que les chasses faites sous l'indicatif de votre profil actif — ignorer les QSO faits sous un autre indicatif (ex. XV9Q, NQ2H) absents de ce journal", 'qslm.onlyMyCall': "Uniquement l'indicatif de mon profil", 'qslm.addMissingTitle': "Insérer les contacts du journal chasseur dont l'indicatif n'est pas encore dans votre journal (indicatif/date/bande/mode/parc)", 'qslm.addMissing': 'Ajouter les QSO introuvables à mon journal', 'qslm.potaToken': 'Jeton dans Réglages → Services externes → POTA.', 'qslm.callsignPlaceholder': 'ex. DL1ABC', 'qslm.search': 'Rechercher', 'qslm.paperHint': 'Trouvez un indicatif, puis définissez QSL envoyée/reçue + via + date sur la sélection.', 'qslm.sentStatus': 'Statut envoyé', 'qslm.selectRequired': 'Sélectionner les requis', 'qslm.potaSummaryShort': '{updated} mis à jour · {added} ajoutés · {already} déjà · {unmatched} sans correspondance', 'qslm.potaOtherCall': ' · {n} autre indicatif', 'qslm.paperCount': '{total} QSO · {selected} sélectionné(s)', 'qslm.filter': 'Filtre', 'qslm.filterAll': 'Tous', 'qslm.filterNew': 'Nouveau (tout)', 'qslm.filterNewDxcc': 'Nouveau DXCC', 'qslm.filterNewBand': 'Nouvelle bande', 'qslm.filterNewSlot': 'Nouveau créneau', 'qslm.results': 'Résultats', 'qslm.log': 'Journal', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} trouvé(s) · {selected} sélectionné(s)', 'qslm.paperEmpty': 'Recherchez un indicatif pour lister ses QSO, puis définissez le statut QSL ci-dessous.', 'qslm.potaEmpty': 'Cliquez sur « Synchroniser le journal chasseur » pour récupérer votre journal pota.app et tamponner les références de parc.', 'qslm.potaSyncing': 'Synchronisation avec pota.app…', 'qslm.potaSummary': '{updated} QSO mis à jour · {added} ajoutés au journal · {already} déjà tamponnés · {unmatched} sans correspondance (sur {fetched} entrées du journal chasseur).', 'qslm.potaSkipped': ' {n} chasse(s) faites sous un autre indicatif ont été ignorées', 'qslm.potaKeptOnly': ' (conservé uniquement {call})', 'qslm.potaRescan': 'Relancez le scan du diplôme POTA pour compter les nouvelles références.', 'qslm.thActivator': 'Activateur', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Bande', 'qslm.thPark': 'Parc', 'qslm.thWhyUnmatched': 'Pourquoi sans correspondance', 'qslm.openToFix': 'Ouvrir ce QSO pour le corriger', 'qslm.starting': 'démarrage…', 'qslm.working': 'en cours…', 'qslm.noNewConf': 'Aucune nouvelle confirmation.', 'qslm.noConfMatch': 'Aucune confirmation ne correspond à ce filtre.', 'qslm.thCallsign': 'Indicatif', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Pays', 'qslm.thNew': 'Nouveau ?', 'qslm.newDxcc': 'NOUVEAU DXCC', 'qslm.newBand': 'NOUVELLE BANDE', 'qslm.newSlot': 'NOUVEAU CRÉNEAU', 'qslm.uploadEmpty': 'Choisissez un service + statut envoyé, puis « Sélectionner les requis ».', 'qslm.qslReceived': 'QSL reçue', 'qslm.qslRcvdDateTitle': 'Date de réception QSL', 'qslm.qslSent': 'QSL envoyée', 'qslm.qslSentDateTitle': "Date d'envoi QSL", 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'ex. payé 3€', 'qslm.comment': 'Commentaire', 'qslm.commentPlaceholder': 'commentaire', 'qslm.applyToSelected': 'Appliquer à {n} sélectionné(s)', 'qslm.downloadTitle': 'Récupérer les confirmations du service et mettre à jour le statut reçu', 'qslm.downloadConf': 'Télécharger les confirmations', 'qslm.downloadRangeTitle': "Jusqu'où télécharger", 'qslm.sinceLast': 'Depuis le dernier téléchargement', 'qslm.sinceDate': 'Depuis une date…', 'qslm.sinceAll': 'Tout', 'qslm.sinceDateTitleQrz': 'QRZ : filtre par date de QSO (pas de filtre par date de réception côté serveur)', 'qslm.sinceDateTitleLotw': 'LoTW : confirmations reçues depuis cette date', 'qslm.addNotFoundTitle': "Insérer les QSO confirmés qui ne sont pas encore dans votre journal", 'qslm.addNotFound': 'Ajouter les introuvables', 'qslm.uploadTo': 'Envoyer {n} vers {service}',
|
||||||
|
|||||||
@@ -495,6 +495,63 @@
|
|||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Data-viz palette (Statistics dashboard) ────────────────────────────────
|
||||||
|
A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes
|
||||||
|
it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never
|
||||||
|
reorder, recycle, or generate a 9th hue — fold the tail into "Other" instead.
|
||||||
|
The dark column is the same eight hues re-stepped for a dark surface, NOT an
|
||||||
|
automatic inversion.
|
||||||
|
|
||||||
|
--chart-1 is the DEFAULT single-series hue: a chart with one series uses it for
|
||||||
|
every bar. Colour is for identity, never to re-encode a length the bar already
|
||||||
|
shows.
|
||||||
|
|
||||||
|
--chart-seq-* is a ONE-HUE ramp for magnitude (the hour×band heatmap). On light
|
||||||
|
themes it runs light→dark; on dark themes dark→light, so "near zero" always
|
||||||
|
recedes toward the surface instead of glowing. */
|
||||||
|
:root,
|
||||||
|
[data-theme="light-warm"],
|
||||||
|
[data-theme="light-cool"],
|
||||||
|
[data-theme="light-sage"] {
|
||||||
|
--chart-1: #2a78d6; /* blue — default single-series hue */
|
||||||
|
--chart-2: #1baf7a; /* aqua */
|
||||||
|
--chart-3: #eda100; /* yellow */
|
||||||
|
--chart-4: #008300; /* green */
|
||||||
|
--chart-5: #4a3aa7; /* violet */
|
||||||
|
--chart-6: #e34948; /* red */
|
||||||
|
--chart-7: #e87ba4; /* magenta */
|
||||||
|
--chart-8: #eb6834; /* orange */
|
||||||
|
|
||||||
|
--chart-seq-1: #cde2fb;
|
||||||
|
--chart-seq-2: #9ec5f4;
|
||||||
|
--chart-seq-3: #6da7ec;
|
||||||
|
--chart-seq-4: #3987e5;
|
||||||
|
--chart-seq-5: #256abf;
|
||||||
|
--chart-seq-6: #104281;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dim-slate"],
|
||||||
|
[data-theme="dark-warm"],
|
||||||
|
[data-theme="dark-graphite"],
|
||||||
|
[data-theme="high-contrast"] {
|
||||||
|
--chart-1: #3987e5;
|
||||||
|
--chart-2: #199e70;
|
||||||
|
--chart-3: #c98500;
|
||||||
|
--chart-4: #008300;
|
||||||
|
--chart-5: #9085e9;
|
||||||
|
--chart-6: #e66767;
|
||||||
|
--chart-7: #d55181;
|
||||||
|
--chart-8: #d95926;
|
||||||
|
|
||||||
|
/* Reversed: the lowest step must sink toward the dark surface. */
|
||||||
|
--chart-seq-1: #0d366b;
|
||||||
|
--chart-seq-2: #184f95;
|
||||||
|
--chart-seq-3: #256abf;
|
||||||
|
--chart-seq-4: #3987e5;
|
||||||
|
--chart-seq-5: #6da7ec;
|
||||||
|
--chart-seq-6: #9ec5f4;
|
||||||
|
}
|
||||||
|
|
||||||
/* Map Tailwind's color utilities onto the semantic vars above. `inline` makes
|
/* Map Tailwind's color utilities onto the semantic vars above. `inline` makes
|
||||||
the generated utilities reference var(--…) directly, so overriding a var in
|
the generated utilities reference var(--…) directly, so overriding a var in
|
||||||
a [data-theme] block re-skins every utility at runtime. */
|
a [data-theme] block re-skins every utility at runtime. */
|
||||||
|
|||||||
@@ -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.19.4';
|
export const APP_VERSION = '0.19.6';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+22
@@ -34,6 +34,8 @@ export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
|
|||||||
|
|
||||||
export function AntGeniusDeselect(arg1:number):Promise<void>;
|
export function AntGeniusDeselect(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function ApplyAwardImport(arg1:string,arg2:Record<string, string>):Promise<main.AwardImportResult>;
|
||||||
|
|
||||||
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|
||||||
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
||||||
@@ -58,6 +60,8 @@ export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
|
|||||||
|
|
||||||
export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Record<string, string>>>;
|
export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Record<string, string>>>;
|
||||||
|
|
||||||
|
export function AwardsFolder():Promise<string>;
|
||||||
|
|
||||||
export function BrowseExecutable():Promise<string>;
|
export function BrowseExecutable():Promise<string>;
|
||||||
|
|
||||||
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
|
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
|
||||||
@@ -150,6 +154,8 @@ export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter
|
|||||||
|
|
||||||
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>;
|
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
|
export function ExportAward(arg1:string):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>;
|
||||||
@@ -286,6 +292,8 @@ export function GetCATState():Promise<cat.RigState>;
|
|||||||
|
|
||||||
export function GetCWDecoderPitch():Promise<number>;
|
export function GetCWDecoderPitch():Promise<number>;
|
||||||
|
|
||||||
|
export function GetCatalogCodes():Promise<Array<string>>;
|
||||||
|
|
||||||
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||||
|
|
||||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||||
@@ -294,6 +302,8 @@ export function GetClusterAutoConnect():Promise<boolean>;
|
|||||||
|
|
||||||
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
||||||
|
|
||||||
|
export function GetContestRuns():Promise<Array<qso.ContestRun>>;
|
||||||
|
|
||||||
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
|
export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
|
||||||
@@ -326,12 +336,16 @@ export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
|||||||
|
|
||||||
export function GetLogFilePath():Promise<string>;
|
export function GetLogFilePath():Promise<string>;
|
||||||
|
|
||||||
|
export function GetLogStats(arg1:string,arg2:string,arg3:string,arg4:number):Promise<qso.Stats>;
|
||||||
|
|
||||||
export function GetLogbookRevision():Promise<string>;
|
export function GetLogbookRevision():Promise<string>;
|
||||||
|
|
||||||
export function GetLookupSettings():Promise<main.LookupSettings>;
|
export function GetLookupSettings():Promise<main.LookupSettings>;
|
||||||
|
|
||||||
export function GetMySQLSettings():Promise<main.MySQLSettings>;
|
export function GetMySQLSettings():Promise<main.MySQLSettings>;
|
||||||
|
|
||||||
|
export function GetOfflineStatus():Promise<main.OfflineStatus>;
|
||||||
|
|
||||||
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
||||||
|
|
||||||
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
||||||
@@ -340,6 +354,8 @@ export function GetPGXLStatus():Promise<powergenius.Status>;
|
|||||||
|
|
||||||
export function GetPOTAToken():Promise<string>;
|
export function GetPOTAToken():Promise<string>;
|
||||||
|
|
||||||
|
export function GetPendingQSOs():Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||||
|
|
||||||
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||||
@@ -462,6 +478,8 @@ export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<numbe
|
|||||||
|
|
||||||
export function ImportAwards():Promise<main.AwardImportResult>;
|
export function ImportAwards():Promise<main.AwardImportResult>;
|
||||||
|
|
||||||
|
export function InspectAwardImport():Promise<main.AwardImportPreview>;
|
||||||
|
|
||||||
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
||||||
|
|
||||||
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
||||||
@@ -538,6 +556,8 @@ export function NetUpdateActive(arg1:qso.QSO):Promise<void>;
|
|||||||
|
|
||||||
export function OpenADIFFile():Promise<string>;
|
export function OpenADIFFile():Promise<string>;
|
||||||
|
|
||||||
|
export function OpenAwardsFolder():Promise<void>;
|
||||||
|
|
||||||
export function OpenDatabase(arg1:string):Promise<void>;
|
export function OpenDatabase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function OpenExternalURL(arg1:string):Promise<void>;
|
export function OpenExternalURL(arg1:string):Promise<void>;
|
||||||
@@ -624,6 +644,8 @@ export function RestartApp():Promise<void>;
|
|||||||
|
|
||||||
export function RestartQSORecorder():Promise<void>;
|
export function RestartQSORecorder():Promise<void>;
|
||||||
|
|
||||||
|
export function RetryOfflineSync():Promise<number>;
|
||||||
|
|
||||||
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function RotatorPark():Promise<void>;
|
export function RotatorPark():Promise<void>;
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export function AntGeniusDeselect(arg1) {
|
|||||||
return window['go']['main']['App']['AntGeniusDeselect'](arg1);
|
return window['go']['main']['App']['AntGeniusDeselect'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ApplyAwardImport(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['ApplyAwardImport'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function ApplyAwardPreset(arg1, arg2) {
|
export function ApplyAwardPreset(arg1, arg2) {
|
||||||
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
|
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -74,6 +78,10 @@ export function AwardRefsForQSOs(arg1) {
|
|||||||
return window['go']['main']['App']['AwardRefsForQSOs'](arg1);
|
return window['go']['main']['App']['AwardRefsForQSOs'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AwardsFolder() {
|
||||||
|
return window['go']['main']['App']['AwardsFolder']();
|
||||||
|
}
|
||||||
|
|
||||||
export function BrowseExecutable() {
|
export function BrowseExecutable() {
|
||||||
return window['go']['main']['App']['BrowseExecutable']();
|
return window['go']['main']['App']['BrowseExecutable']();
|
||||||
}
|
}
|
||||||
@@ -258,6 +266,10 @@ export function ExportADIFSelected(arg1, arg2, arg3) {
|
|||||||
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3);
|
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ExportAward(arg1) {
|
||||||
|
return window['go']['main']['App']['ExportAward'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function ExportAwards() {
|
export function ExportAwards() {
|
||||||
return window['go']['main']['App']['ExportAwards']();
|
return window['go']['main']['App']['ExportAwards']();
|
||||||
}
|
}
|
||||||
@@ -530,6 +542,10 @@ export function GetCWDecoderPitch() {
|
|||||||
return window['go']['main']['App']['GetCWDecoderPitch']();
|
return window['go']['main']['App']['GetCWDecoderPitch']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetCatalogCodes() {
|
||||||
|
return window['go']['main']['App']['GetCatalogCodes']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetChatHistory(arg1) {
|
export function GetChatHistory(arg1) {
|
||||||
return window['go']['main']['App']['GetChatHistory'](arg1);
|
return window['go']['main']['App']['GetChatHistory'](arg1);
|
||||||
}
|
}
|
||||||
@@ -546,6 +562,10 @@ export function GetClusterStatus() {
|
|||||||
return window['go']['main']['App']['GetClusterStatus']();
|
return window['go']['main']['App']['GetClusterStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetContestRuns() {
|
||||||
|
return window['go']['main']['App']['GetContestRuns']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetCtyDatInfo() {
|
export function GetCtyDatInfo() {
|
||||||
return window['go']['main']['App']['GetCtyDatInfo']();
|
return window['go']['main']['App']['GetCtyDatInfo']();
|
||||||
}
|
}
|
||||||
@@ -610,6 +630,10 @@ export function GetLogFilePath() {
|
|||||||
return window['go']['main']['App']['GetLogFilePath']();
|
return window['go']['main']['App']['GetLogFilePath']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetLogStats(arg1, arg2, arg3, arg4) {
|
||||||
|
return window['go']['main']['App']['GetLogStats'](arg1, arg2, arg3, arg4);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetLogbookRevision() {
|
export function GetLogbookRevision() {
|
||||||
return window['go']['main']['App']['GetLogbookRevision']();
|
return window['go']['main']['App']['GetLogbookRevision']();
|
||||||
}
|
}
|
||||||
@@ -622,6 +646,10 @@ export function GetMySQLSettings() {
|
|||||||
return window['go']['main']['App']['GetMySQLSettings']();
|
return window['go']['main']['App']['GetMySQLSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetOfflineStatus() {
|
||||||
|
return window['go']['main']['App']['GetOfflineStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetOnlineOperators() {
|
export function GetOnlineOperators() {
|
||||||
return window['go']['main']['App']['GetOnlineOperators']();
|
return window['go']['main']['App']['GetOnlineOperators']();
|
||||||
}
|
}
|
||||||
@@ -638,6 +666,10 @@ export function GetPOTAToken() {
|
|||||||
return window['go']['main']['App']['GetPOTAToken']();
|
return window['go']['main']['App']['GetPOTAToken']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetPendingQSOs() {
|
||||||
|
return window['go']['main']['App']['GetPendingQSOs']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetQSLDefaults() {
|
export function GetQSLDefaults() {
|
||||||
return window['go']['main']['App']['GetQSLDefaults']();
|
return window['go']['main']['App']['GetQSLDefaults']();
|
||||||
}
|
}
|
||||||
@@ -882,6 +914,10 @@ export function ImportAwards() {
|
|||||||
return window['go']['main']['App']['ImportAwards']();
|
return window['go']['main']['App']['ImportAwards']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function InspectAwardImport() {
|
||||||
|
return window['go']['main']['App']['InspectAwardImport']();
|
||||||
|
}
|
||||||
|
|
||||||
export function LaunchAutostartProgram(arg1) {
|
export function LaunchAutostartProgram(arg1) {
|
||||||
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1034,6 +1070,10 @@ export function OpenADIFFile() {
|
|||||||
return window['go']['main']['App']['OpenADIFFile']();
|
return window['go']['main']['App']['OpenADIFFile']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function OpenAwardsFolder() {
|
||||||
|
return window['go']['main']['App']['OpenAwardsFolder']();
|
||||||
|
}
|
||||||
|
|
||||||
export function OpenDatabase(arg1) {
|
export function OpenDatabase(arg1) {
|
||||||
return window['go']['main']['App']['OpenDatabase'](arg1);
|
return window['go']['main']['App']['OpenDatabase'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1206,6 +1246,10 @@ export function RestartQSORecorder() {
|
|||||||
return window['go']['main']['App']['RestartQSORecorder']();
|
return window['go']['main']['App']['RestartQSORecorder']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RetryOfflineSync() {
|
||||||
|
return window['go']['main']['App']['RetryOfflineSync']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RotatorGoTo(arg1, arg2) {
|
export function RotatorGoTo(arg1, arg2) {
|
||||||
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -504,6 +504,7 @@ export namespace cat {
|
|||||||
src?: string;
|
src?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
|
slice: number;
|
||||||
value: number;
|
value: number;
|
||||||
lo: number;
|
lo: number;
|
||||||
hi: number;
|
hi: number;
|
||||||
@@ -518,6 +519,7 @@ export namespace cat {
|
|||||||
this.src = source["src"];
|
this.src = source["src"];
|
||||||
this.name = source["name"];
|
this.name = source["name"];
|
||||||
this.unit = source["unit"];
|
this.unit = source["unit"];
|
||||||
|
this.slice = source["slice"];
|
||||||
this.value = source["value"];
|
this.value = source["value"];
|
||||||
this.lo = source["lo"];
|
this.lo = source["lo"];
|
||||||
this.hi = source["hi"];
|
this.hi = source["hi"];
|
||||||
@@ -1265,6 +1267,63 @@ export namespace main {
|
|||||||
this.enabled = source["enabled"];
|
this.enabled = source["enabled"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class AwardImportPreviewEntry {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
references: number;
|
||||||
|
exists: boolean;
|
||||||
|
mine_name: string;
|
||||||
|
mine_refs: number;
|
||||||
|
protected: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AwardImportPreviewEntry(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.code = source["code"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.references = source["references"];
|
||||||
|
this.exists = source["exists"];
|
||||||
|
this.mine_name = source["mine_name"];
|
||||||
|
this.mine_refs = source["mine_refs"];
|
||||||
|
this.protected = source["protected"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class AwardImportPreview {
|
||||||
|
path: string;
|
||||||
|
awards: AwardImportPreviewEntry[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AwardImportPreview(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.path = source["path"];
|
||||||
|
this.awards = this.convertValues(source["awards"], AwardImportPreviewEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class AwardImportResult {
|
export class AwardImportResult {
|
||||||
awards: number;
|
awards: number;
|
||||||
references: number;
|
references: number;
|
||||||
@@ -1826,6 +1885,22 @@ export namespace main {
|
|||||||
this.database = source["database"];
|
this.database = source["database"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class OfflineStatus {
|
||||||
|
offline: boolean;
|
||||||
|
pending: number;
|
||||||
|
path: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new OfflineStatus(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.offline = source["offline"];
|
||||||
|
this.pending = source["pending"];
|
||||||
|
this.path = source["path"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class PGXLSettings {
|
export class PGXLSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
host: string;
|
host: string;
|
||||||
@@ -2904,6 +2979,20 @@ export namespace qso {
|
|||||||
this.status = source["status"];
|
this.status = source["status"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class Bucket {
|
||||||
|
key: string;
|
||||||
|
count: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Bucket(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.key = source["key"];
|
||||||
|
this.count = source["count"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class Condition {
|
export class Condition {
|
||||||
field: string;
|
field: string;
|
||||||
op: string;
|
op: string;
|
||||||
@@ -2920,6 +3009,42 @@ export namespace qso {
|
|||||||
this.value = source["value"];
|
this.value = source["value"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class ContestRun {
|
||||||
|
id: string;
|
||||||
|
year: number;
|
||||||
|
count: number;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ContestRun(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.year = source["year"];
|
||||||
|
this.count = source["count"];
|
||||||
|
this.start = source["start"];
|
||||||
|
this.end = source["end"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Gap {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
minutes: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Gap(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.start = source["start"];
|
||||||
|
this.end = source["end"];
|
||||||
|
this.minutes = source["minutes"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ListFilter {
|
export class ListFilter {
|
||||||
callsign?: string;
|
callsign?: string;
|
||||||
band?: string;
|
band?: string;
|
||||||
@@ -3268,6 +3393,98 @@ export namespace qso {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class Stats {
|
||||||
|
total: number;
|
||||||
|
unique_calls: number;
|
||||||
|
entities: number;
|
||||||
|
continents: number;
|
||||||
|
first_qso: string;
|
||||||
|
last_qso: string;
|
||||||
|
confirmed_lotw: number;
|
||||||
|
confirmed_eqsl: number;
|
||||||
|
confirmed_qsl: number;
|
||||||
|
confirmed_any: number;
|
||||||
|
by_mode: Bucket[];
|
||||||
|
by_band: Bucket[];
|
||||||
|
by_operator: Bucket[];
|
||||||
|
by_station: Bucket[];
|
||||||
|
by_continent: Bucket[];
|
||||||
|
top_entities: Bucket[];
|
||||||
|
by_year: Bucket[];
|
||||||
|
by_month: Bucket[];
|
||||||
|
window_start: string;
|
||||||
|
window_end: string;
|
||||||
|
window_hours: number;
|
||||||
|
avg_per_hour: number;
|
||||||
|
avg_per_active: number;
|
||||||
|
on_air_minutes: number;
|
||||||
|
off_air_minutes: number;
|
||||||
|
peak_hour_key: string;
|
||||||
|
peak_hour_count: number;
|
||||||
|
best_60: number;
|
||||||
|
gaps: Gap[];
|
||||||
|
rate: Bucket[];
|
||||||
|
rate_ops: string[];
|
||||||
|
rate_by_op: number[][];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Stats(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.total = source["total"];
|
||||||
|
this.unique_calls = source["unique_calls"];
|
||||||
|
this.entities = source["entities"];
|
||||||
|
this.continents = source["continents"];
|
||||||
|
this.first_qso = source["first_qso"];
|
||||||
|
this.last_qso = source["last_qso"];
|
||||||
|
this.confirmed_lotw = source["confirmed_lotw"];
|
||||||
|
this.confirmed_eqsl = source["confirmed_eqsl"];
|
||||||
|
this.confirmed_qsl = source["confirmed_qsl"];
|
||||||
|
this.confirmed_any = source["confirmed_any"];
|
||||||
|
this.by_mode = this.convertValues(source["by_mode"], Bucket);
|
||||||
|
this.by_band = this.convertValues(source["by_band"], Bucket);
|
||||||
|
this.by_operator = this.convertValues(source["by_operator"], Bucket);
|
||||||
|
this.by_station = this.convertValues(source["by_station"], Bucket);
|
||||||
|
this.by_continent = this.convertValues(source["by_continent"], Bucket);
|
||||||
|
this.top_entities = this.convertValues(source["top_entities"], Bucket);
|
||||||
|
this.by_year = this.convertValues(source["by_year"], Bucket);
|
||||||
|
this.by_month = this.convertValues(source["by_month"], Bucket);
|
||||||
|
this.window_start = source["window_start"];
|
||||||
|
this.window_end = source["window_end"];
|
||||||
|
this.window_hours = source["window_hours"];
|
||||||
|
this.avg_per_hour = source["avg_per_hour"];
|
||||||
|
this.avg_per_active = source["avg_per_active"];
|
||||||
|
this.on_air_minutes = source["on_air_minutes"];
|
||||||
|
this.off_air_minutes = source["off_air_minutes"];
|
||||||
|
this.peak_hour_key = source["peak_hour_key"];
|
||||||
|
this.peak_hour_count = source["peak_hour_count"];
|
||||||
|
this.best_60 = source["best_60"];
|
||||||
|
this.gaps = this.convertValues(source["gaps"], Gap);
|
||||||
|
this.rate = this.convertValues(source["rate"], Bucket);
|
||||||
|
this.rate_ops = source["rate_ops"];
|
||||||
|
this.rate_by_op = source["rate_by_op"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
export class WorkedBefore {
|
export class WorkedBefore {
|
||||||
callsign: string;
|
callsign: string;
|
||||||
count: number;
|
count: number;
|
||||||
|
|||||||
@@ -120,6 +120,18 @@ func SingleRecordADIF(q qso.QSO) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FullRecordADIF serialises one QSO LOSSLESSLY — including the APP_* extras —
|
||||||
|
// so it can be written out and read back with nothing dropped. Used by the
|
||||||
|
// offline queue: a QSO parked in the safety file must come back identical
|
||||||
|
// (extras carry its queue id, awards refs, ADIF 3.1.7 leftovers…).
|
||||||
|
func FullRecordADIF(q qso.QSO) string {
|
||||||
|
var b strings.Builder
|
||||||
|
bw := bufio.NewWriter(&b)
|
||||||
|
writeRecord(bw, q, true)
|
||||||
|
bw.Flush()
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
// BatchRecordsADIF wraps already-serialised records (each terminated by <EOR>,
|
// BatchRecordsADIF wraps already-serialised records (each terminated by <EOR>,
|
||||||
// e.g. from SingleRecordADIF) in a minimal ADIF document with a standard
|
// e.g. from SingleRecordADIF) in a minimal ADIF document with a standard
|
||||||
// header. Used by file-based batch upload APIs such as Club Log's putlogs.php.
|
// header. Used by file-based batch upload APIs such as Club Log's putlogs.php.
|
||||||
|
|||||||
+111
-19
@@ -13,6 +13,8 @@
|
|||||||
package award
|
package award
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -113,26 +115,116 @@ type OrRule struct {
|
|||||||
Prefix string `json:"prefix,omitempty"` // prepended to each found reference
|
Prefix string `json:"prefix,omitempty"` // prepended to each found reference
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defaults are the built-in awards seeded on first run (then user-editable).
|
// catalogFS holds the built-in award definitions as DATA, not Go code.
|
||||||
func Defaults() []Def {
|
//
|
||||||
// Confirmed = any confirmation (LoTW or paper QSL). Validated = the stricter
|
// An award is data — a field to scan, a pattern, a scope. Coding it in Go meant a
|
||||||
// "electronically verified" tier: LoTW only — a paper QSL confirms but does
|
// recompile and a release for every new one, which is absurd for something that
|
||||||
// NOT validate (matches ARRL/Log4OM). eQSL counts only where the program
|
// changes far more often than the engine that reads it. They now live one JSON per
|
||||||
// accepts it (WAC).
|
// award in catalog/, embedded in the binary: adding an award is adding a file.
|
||||||
lq := []string{"lotw", "qsl"}
|
//
|
||||||
lo := []string{"lotw"}
|
// This is the SEED only. Once a user has awards in their database, that database
|
||||||
return []Def{
|
// is the source of truth — the catalog never overwrites their edits behind their
|
||||||
{Code: "DXCC", Name: "DX Century Club", Type: TypeDXCC, Field: "dxcc", Confirm: lq, Validate: lo, Total: 340, Valid: true, Builtin: true, Protected: true},
|
// back.
|
||||||
{Code: "WAS", Name: "Worked All States", Type: TypeQSOFields, Field: "state", MatchBy: "code", ExactMatch: true, DXCCFilter: []int{291, 110, 6}, Confirm: lq, Validate: lo, Total: 50, Valid: true, Builtin: true, Protected: true},
|
//
|
||||||
{Code: "WAZ", Name: "Worked All Zones (CQ)", Type: TypeQSOFields, Field: "cqz", MatchBy: "code", ExactMatch: true, Confirm: lq, Validate: lo, Total: 40, Valid: true, Builtin: true, Protected: true},
|
//go:embed catalog/*.json
|
||||||
{Code: "WAC", Name: "Worked All Continents", Type: TypeQSOFields, Field: "cont", MatchBy: "code", ExactMatch: true, Confirm: []string{"lotw", "qsl", "eqsl"}, Validate: lo, Total: 6, Valid: true, Builtin: true, Protected: true},
|
var catalogFS embed.FS
|
||||||
{Code: "WPX", Name: "Worked All Prefixes (CQ WPX)", Type: TypeQSOFields, Field: "prefix", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
|
||||||
{Code: "DDFM", Name: "Départements Français Métropolitains", Type: TypeQSOFields, Field: "note", Pattern: `(?i)\b(D\d{1,2}[AB]?)\b`, DXCCFilter: []int{227}, Confirm: lq, Validate: lo, Total: 96, Valid: true, Builtin: true, Protected: true},
|
// CatalogEntry is one award in the catalog: its definition AND its reference list.
|
||||||
{Code: "IOTA", Name: "Islands On The Air", Type: TypeReference, Field: "iota", Dynamic: true, Confirm: []string{"qsl"}, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
//
|
||||||
{Code: "POTA", Name: "Parks On The Air", Type: TypeReference, Field: "pota_ref", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
// The references are the point. An award's definition is a few lines; what makes
|
||||||
{Code: "SOTA", Name: "Summits On The Air", Type: TypeReference, Field: "sota_ref", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
// WAPC worth anything is its 34 provinces and the city regexes attached to them.
|
||||||
{Code: "WWFF", Name: "World Wide Flora & Fauna", Type: TypeReference, Field: "wwff", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
// A catalog that shipped definitions only would hand every user an empty shell —
|
||||||
|
// which is exactly the trap this design is meant to avoid.
|
||||||
|
//
|
||||||
|
// References stay as raw JSON so this package (the matching ENGINE) never has to
|
||||||
|
// import the reference-store package. The caller decodes them.
|
||||||
|
type CatalogEntry struct {
|
||||||
|
Def Def `json:"def"`
|
||||||
|
References json.RawMessage `json:"references,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// catalogFile is what a file in catalog/ may contain. Two shapes are accepted:
|
||||||
|
//
|
||||||
|
// {"def": {...}, "references": [...]} — a catalog entry
|
||||||
|
// {"version":1, "awards":[{"def":…,"references":…}]} — an exported bundle
|
||||||
|
//
|
||||||
|
// Accepting the bundle shape is deliberate: it means an award EXPORTED from the UI
|
||||||
|
// can be dropped straight into catalog/ and shipped to everyone, with no
|
||||||
|
// conversion step. That is the whole loop — create an award, export it, drop it in,
|
||||||
|
// everybody has it.
|
||||||
|
type catalogFile struct {
|
||||||
|
Def Def `json:"def"`
|
||||||
|
References json.RawMessage `json:"references,omitempty"`
|
||||||
|
Awards []CatalogEntry `json:"awards,omitempty"`
|
||||||
|
// A bare Def (the original catalog shape) is still read via the fields above
|
||||||
|
// being empty and Code being set at the top level.
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catalog returns every built-in award: definition + references, sorted by code.
|
||||||
|
//
|
||||||
|
// Sorted because an embed.FS walk is alphabetical and relying on that implicitly is
|
||||||
|
// how a user's award list quietly reorders itself on a rebuild.
|
||||||
|
func Catalog() []CatalogEntry {
|
||||||
|
entries, err := catalogFS.ReadDir("catalog")
|
||||||
|
if err != nil {
|
||||||
|
return nil // embedded: can only fail if the build is broken
|
||||||
|
}
|
||||||
|
out := make([]CatalogEntry, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, err := catalogFS.ReadFile("catalog/" + e.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var f catalogFile
|
||||||
|
if err := json.Unmarshal(b, &f); err != nil {
|
||||||
|
// A malformed file must not take the other awards down with it.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(f.Awards) > 0: // an exported bundle, dropped in as-is
|
||||||
|
for _, a := range f.Awards {
|
||||||
|
if strings.TrimSpace(a.Def.Code) != "" {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case strings.TrimSpace(f.Def.Code) != "": // {"def":…,"references":…}
|
||||||
|
out = append(out, CatalogEntry{Def: f.Def, References: f.References})
|
||||||
|
case strings.TrimSpace(f.Code) != "": // a bare Def
|
||||||
|
var d Def
|
||||||
|
if err := json.Unmarshal(b, &d); err == nil {
|
||||||
|
out = append(out, CatalogEntry{Def: d})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Def.Code < out[j].Def.Code })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults are the built-in award definitions seeded on first run.
|
||||||
|
func Defaults() []Def {
|
||||||
|
cat := Catalog()
|
||||||
|
out := make([]Def, 0, len(cat))
|
||||||
|
for _, e := range cat {
|
||||||
|
out = append(out, e.Def)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogRefs returns the raw JSON reference list a catalog award ships with, if
|
||||||
|
// any. Awards whose list is seeded from code (DXCC entities, French departments)
|
||||||
|
// or fetched online (POTA/SOTA/WWFF) carry none.
|
||||||
|
func CatalogRefs(code string) (json.RawMessage, bool) {
|
||||||
|
code = strings.ToUpper(strings.TrimSpace(code))
|
||||||
|
for _, e := range Catalog() {
|
||||||
|
if strings.ToUpper(e.Def.Code) == code && len(e.References) > 0 {
|
||||||
|
return e.References, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate upgrades award definitions saved before the richer model existed.
|
// Migrate upgrades award definitions saved before the richer model existed.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package award
|
package award
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"sort"
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -378,3 +379,132 @@ func TestComputePredefinedList(t *testing.T) {
|
|||||||
t.Errorf("RAC refs = %d, want 3 (%v)", len(r.Refs), refCodes(r))
|
t.Errorf("RAC refs = %d, want 3 (%v)", len(r.Refs), refCodes(r))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The built-in awards moved from Go code into embedded JSON (catalog/*.json).
|
||||||
|
// A refactor is only a refactor if the behaviour is identical, so pin down what
|
||||||
|
// the catalog MUST still produce — a typo in a JSON file would otherwise silently
|
||||||
|
// disable an award, and nobody would notice until a QSO stopped counting.
|
||||||
|
func TestCatalogDefaults(t *testing.T) {
|
||||||
|
defs := Defaults()
|
||||||
|
byCode := map[string]Def{}
|
||||||
|
for _, d := range defs {
|
||||||
|
byCode[d.Code] = d
|
||||||
|
}
|
||||||
|
|
||||||
|
// The catalog is MEANT to grow — dropping a JSON in is how an award ships. So
|
||||||
|
// assert the ten originals are still there, never that the count is exactly ten:
|
||||||
|
// a test that fails the moment you add an award is a test that teaches you to
|
||||||
|
// ignore it.
|
||||||
|
want := []string{"DDFM", "DXCC", "IOTA", "POTA", "SOTA", "WAC", "WAS", "WAZ", "WPX", "WWFF"}
|
||||||
|
if len(defs) < len(want) {
|
||||||
|
t.Fatalf("catalog has %d awards, want at least the %d built-ins (%v)", len(defs), len(want), byCode)
|
||||||
|
}
|
||||||
|
for _, c := range want {
|
||||||
|
d, ok := byCode[c]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s missing from the embedded catalog", c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Valid=false would hide the award entirely; Builtin=false would let a
|
||||||
|
// "reset to defaults" delete it. Both are silent failures.
|
||||||
|
if !d.Valid || !d.Builtin {
|
||||||
|
t.Errorf("%s: valid=%v builtin=%v — both must be true", c, d.Valid, d.Builtin)
|
||||||
|
}
|
||||||
|
if d.Type == "" || d.Field == "" {
|
||||||
|
t.Errorf("%s: type=%q field=%q — neither may be empty", c, d.Type, d.Field)
|
||||||
|
}
|
||||||
|
if len(d.Confirm) == 0 {
|
||||||
|
t.Errorf("%s: no confirmation sources — nothing would ever count as confirmed", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spot-check the two that carry real matching logic, since a mangled escape in
|
||||||
|
// JSON is exactly the failure this test exists to catch.
|
||||||
|
if got := byCode["DDFM"].Pattern; got != `(?i)\b(D\d{1,2}[AB]?)\b` {
|
||||||
|
t.Errorf("DDFM pattern = %q — the regex did not survive the JSON round-trip", got)
|
||||||
|
}
|
||||||
|
if _, err := compileAwardRE(byCode["DDFM"].Pattern); err != nil {
|
||||||
|
t.Errorf("DDFM pattern does not compile: %v", err)
|
||||||
|
}
|
||||||
|
if got := byCode["WAS"].DXCCFilter; len(got) != 3 || got[0] != 291 {
|
||||||
|
t.Errorf("WAS DXCC filter = %v, want [291 110 6]", got)
|
||||||
|
}
|
||||||
|
if !byCode["WAS"].ExactMatch || byCode["WAS"].MatchBy != "code" {
|
||||||
|
t.Errorf("WAS: exact=%v matchBy=%q, want true/code", byCode["WAS"].ExactMatch, byCode["WAS"].MatchBy)
|
||||||
|
}
|
||||||
|
if !byCode["WPX"].Dynamic {
|
||||||
|
t.Error("WPX must be dynamic — its references aren't a fixed list")
|
||||||
|
}
|
||||||
|
// Deterministic order: an embed walk that reorders would shuffle the user's list.
|
||||||
|
for i := 1; i < len(defs); i++ {
|
||||||
|
if defs[i-1].Code > defs[i].Code {
|
||||||
|
t.Errorf("catalog not sorted by code: %q before %q", defs[i-1].Code, defs[i].Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point of the catalog: an award you EXPORT from the UI can be dropped
|
||||||
|
// straight into catalog/ and shipped to everyone — no conversion step. So the
|
||||||
|
// loader must accept the exported bundle shape, and it must carry the REFERENCES,
|
||||||
|
// because a WAPC without its provinces and their city regexes is an empty shell.
|
||||||
|
func TestCatalogAcceptsExportedBundle(t *testing.T) {
|
||||||
|
// Exactly what ExportAward writes.
|
||||||
|
exported := []byte(`{
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": "2026-07-13T10:00:00Z",
|
||||||
|
"awards": [
|
||||||
|
{
|
||||||
|
"def": {"code":"WAPC","name":"Worked All Provinces of China","valid":true,
|
||||||
|
"type":"QSOFIELDS","field":"address","match_by":"description",
|
||||||
|
"dxcc_filter":[318],"confirm":["lotw","qsl"],"total":34},
|
||||||
|
"references": [
|
||||||
|
{"code":"JS","name":"Jiangsu","pattern":"\\bJiangyin\\b","valid":true},
|
||||||
|
{"code":"ZJ","name":"Zhejiang","valid":true}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
var f catalogFile
|
||||||
|
if err := json.Unmarshal(exported, &f); err != nil {
|
||||||
|
t.Fatalf("an exported bundle must parse as a catalog file: %v", err)
|
||||||
|
}
|
||||||
|
if len(f.Awards) != 1 {
|
||||||
|
t.Fatalf("got %d awards, want 1", len(f.Awards))
|
||||||
|
}
|
||||||
|
e := f.Awards[0]
|
||||||
|
if e.Def.Code != "WAPC" || e.Def.Field != "address" || e.Def.MatchBy != "description" {
|
||||||
|
t.Errorf("definition lost in the round-trip: %+v", e.Def)
|
||||||
|
}
|
||||||
|
if len(e.References) == 0 {
|
||||||
|
t.Fatal("references dropped — this is the whole value of sharing an award")
|
||||||
|
}
|
||||||
|
// The per-reference regex must survive: it is what makes the award actually work.
|
||||||
|
var refs []struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Pattern string `json:"pattern"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(e.References, &refs); err != nil {
|
||||||
|
t.Fatalf("references not decodable: %v", err)
|
||||||
|
}
|
||||||
|
if len(refs) != 2 || refs[0].Code != "JS" || refs[0].Pattern != `\bJiangyin\b` {
|
||||||
|
t.Errorf("reference regex did not survive: %+v", refs)
|
||||||
|
}
|
||||||
|
if _, err := compileAwardRE(refs[0].Pattern); err != nil {
|
||||||
|
t.Errorf("the shipped regex does not compile: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single malformed file in the catalog must not take the other awards down with
|
||||||
|
// it — one bad JSON should cost you one award, not all of them.
|
||||||
|
func TestCatalogSurvivesOneBadFile(t *testing.T) {
|
||||||
|
if len(Catalog()) < 10 {
|
||||||
|
t.Fatalf("catalog has %d entries, want the 10 built-ins", len(Catalog()))
|
||||||
|
}
|
||||||
|
var f catalogFile
|
||||||
|
if err := json.Unmarshal([]byte(`{ this is not json `), &f); err == nil {
|
||||||
|
t.Error("expected a parse error on malformed JSON")
|
||||||
|
}
|
||||||
|
// Catalog() skips unparseable files rather than returning nil, so the others
|
||||||
|
// still load. (Verified structurally: the loader `continue`s on error.)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "DDFM",
|
||||||
|
"name": "Départements Français Métropolitains",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "note",
|
||||||
|
"pattern": "(?i)\\b(D\\d{1,2}[AB]?)\\b",
|
||||||
|
"dxcc_filter": [
|
||||||
|
227
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 96,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "DXCC",
|
||||||
|
"name": "DX Century Club",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "DXCC",
|
||||||
|
"field": "dxcc",
|
||||||
|
"pattern": "",
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 340,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "IOTA",
|
||||||
|
"name": "Islands On The Air",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "REFERENCE",
|
||||||
|
"field": "iota",
|
||||||
|
"pattern": "",
|
||||||
|
"dynamic": true,
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "POTA",
|
||||||
|
"name": "Parks On The Air",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "REFERENCE",
|
||||||
|
"field": "pota_ref",
|
||||||
|
"pattern": "",
|
||||||
|
"dynamic": true,
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "SOTA",
|
||||||
|
"name": "Summits On The Air",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "REFERENCE",
|
||||||
|
"field": "sota_ref",
|
||||||
|
"pattern": "",
|
||||||
|
"dynamic": true,
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WAC",
|
||||||
|
"name": "Worked All Continents",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "cont",
|
||||||
|
"match_by": "code",
|
||||||
|
"exact_match": true,
|
||||||
|
"pattern": "",
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl",
|
||||||
|
"eqsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 6,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": "2026-07-13T15:44:38Z",
|
||||||
|
"awards": [
|
||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WAJA",
|
||||||
|
"name": "Worked All Japanese Prefectures",
|
||||||
|
"description": "Worked All Japanese Prefectures",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"valid_from": "1970-01-01",
|
||||||
|
"ref_display": "name",
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "description",
|
||||||
|
"pattern": "",
|
||||||
|
"or_rules": [
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "pattern"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dxcc_filter": [
|
||||||
|
339
|
||||||
|
],
|
||||||
|
"valid_bands": [
|
||||||
|
"80m",
|
||||||
|
"40m",
|
||||||
|
"20m",
|
||||||
|
"15m",
|
||||||
|
"10m",
|
||||||
|
"160m"
|
||||||
|
],
|
||||||
|
"emission": [
|
||||||
|
"CW",
|
||||||
|
"PHONE",
|
||||||
|
"DIGITAL"
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"code": "1",
|
||||||
|
"name": "Hokkaido",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "10",
|
||||||
|
"name": "Gunma",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "11",
|
||||||
|
"name": "Saitama",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "12",
|
||||||
|
"name": "Chiba",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "13",
|
||||||
|
"name": "Tokyo",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\bTok[iy]o\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "14",
|
||||||
|
"name": "Kanagawa",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "15",
|
||||||
|
"name": "Niigata",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "16",
|
||||||
|
"name": "Toyama",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "17",
|
||||||
|
"name": "Ishikawa",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "18",
|
||||||
|
"name": "Fukui",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "19",
|
||||||
|
"name": "Yamanashi",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "2",
|
||||||
|
"name": "Aomori",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "20",
|
||||||
|
"name": "Nagano",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "21",
|
||||||
|
"name": "Gifu",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "22",
|
||||||
|
"name": "Shizuoka",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "23",
|
||||||
|
"name": "Aichi",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "24",
|
||||||
|
"name": "Mie",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "25",
|
||||||
|
"name": "Shiga",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "26",
|
||||||
|
"name": "Kyoto",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "27",
|
||||||
|
"name": "Osaka",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "28",
|
||||||
|
"name": "Hyogo",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "29",
|
||||||
|
"name": "Nara",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "3",
|
||||||
|
"name": "Iwate",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "30",
|
||||||
|
"name": "Wakayama",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "31",
|
||||||
|
"name": "Tottori",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "32",
|
||||||
|
"name": "Shimane",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "33",
|
||||||
|
"name": "Okayama",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "34",
|
||||||
|
"name": "Hiroshima",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "35",
|
||||||
|
"name": "Yamaguchi",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "36",
|
||||||
|
"name": "Tokushima",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "37",
|
||||||
|
"name": "Kagawa",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "38",
|
||||||
|
"name": "Ehime",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "39",
|
||||||
|
"name": "Kochi",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "4",
|
||||||
|
"name": "Miyagi",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "40",
|
||||||
|
"name": "Fukuoka",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "41",
|
||||||
|
"name": "Saga",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "42",
|
||||||
|
"name": "Nagasaki",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "43",
|
||||||
|
"name": "Kumamoto",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "44",
|
||||||
|
"name": "Oita",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "45",
|
||||||
|
"name": "Miyazaki",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "46",
|
||||||
|
"name": "Kagoshima",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "47",
|
||||||
|
"name": "Okinawa",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "5",
|
||||||
|
"name": "Akita",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "6",
|
||||||
|
"name": "Yamagata",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "7",
|
||||||
|
"name": "Fukushima",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "8",
|
||||||
|
"name": "Ibaraki",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "9",
|
||||||
|
"name": "Tochigi",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": "2026-07-13T15:43:49Z",
|
||||||
|
"awards": [
|
||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WAPC",
|
||||||
|
"name": "Worked All Provinces of China",
|
||||||
|
"description": "Worked All Provinces of China",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"url": "http://www.mulandxc.com/index/wapc_medal_app",
|
||||||
|
"valid_from": "2012-04-21",
|
||||||
|
"valid_to": "9999-01-31",
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "address",
|
||||||
|
"match_by": "description",
|
||||||
|
"pattern": "",
|
||||||
|
"or_rules": [
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "pattern"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dxcc_filter": [
|
||||||
|
318,
|
||||||
|
152,
|
||||||
|
321
|
||||||
|
],
|
||||||
|
"valid_bands": [
|
||||||
|
"80m",
|
||||||
|
"40m",
|
||||||
|
"20m",
|
||||||
|
"15m",
|
||||||
|
"10m"
|
||||||
|
],
|
||||||
|
"emission": [
|
||||||
|
"CW",
|
||||||
|
"PHONE",
|
||||||
|
"DIGITAL"
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"code": "AH",
|
||||||
|
"name": "Anhui",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BJ",
|
||||||
|
"name": "Beijing",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CQ",
|
||||||
|
"name": "Chongqing",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FJ",
|
||||||
|
"name": "Fujian",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GD",
|
||||||
|
"name": "Guangdong",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GS",
|
||||||
|
"name": "Gansu",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GX",
|
||||||
|
"name": "Guangxi",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GZ",
|
||||||
|
"name": "Guizhou",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA",
|
||||||
|
"name": "Henan",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HB",
|
||||||
|
"name": "Hubei",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HE",
|
||||||
|
"name": "Hebei",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HI",
|
||||||
|
"name": "Hainan",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HK",
|
||||||
|
"name": "Hong Kong",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HL",
|
||||||
|
"name": "Heilongjiang",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HN",
|
||||||
|
"name": "Hunan",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "JL",
|
||||||
|
"name": "Jilin",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "JS",
|
||||||
|
"name": "Jiangsu",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\bJiangyin\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "JX",
|
||||||
|
"name": "Jiangxi",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LN",
|
||||||
|
"name": "Liaoning",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MO",
|
||||||
|
"name": "Macau",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\bMaca[uo]\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NM",
|
||||||
|
"name": "NeiMongol",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NX",
|
||||||
|
"name": "Ningxia",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QH",
|
||||||
|
"name": "Qinghai",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SC",
|
||||||
|
"name": "Sichuan",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SD",
|
||||||
|
"name": "Shandong",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SH",
|
||||||
|
"name": "Shanghai",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SN",
|
||||||
|
"name": "Shaanxi",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SX",
|
||||||
|
"name": "Shanxi",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TJ",
|
||||||
|
"name": "Tianjin",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TW",
|
||||||
|
"name": "Taiwan",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "XJ",
|
||||||
|
"name": "Xinjiang",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "XZ",
|
||||||
|
"name": "Xizang",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "YN",
|
||||||
|
"name": "Yunnan",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ZJ",
|
||||||
|
"name": "Zhejiang",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WAS",
|
||||||
|
"name": "Worked All States",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "state",
|
||||||
|
"match_by": "code",
|
||||||
|
"exact_match": true,
|
||||||
|
"pattern": "",
|
||||||
|
"dxcc_filter": [
|
||||||
|
291,
|
||||||
|
110,
|
||||||
|
6
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 50,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WAZ",
|
||||||
|
"name": "Worked All Zones (CQ)",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "cqz",
|
||||||
|
"match_by": "code",
|
||||||
|
"exact_match": true,
|
||||||
|
"pattern": "",
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 40,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WPX",
|
||||||
|
"name": "Worked All Prefixes (CQ WPX)",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "prefix",
|
||||||
|
"pattern": "",
|
||||||
|
"dynamic": true,
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WWFF",
|
||||||
|
"name": "World Wide Flora & Fauna",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "REFERENCE",
|
||||||
|
"field": "wwff",
|
||||||
|
"pattern": "",
|
||||||
|
"dynamic": true,
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -330,6 +330,7 @@ type FlexMeter struct {
|
|||||||
Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP…
|
Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP…
|
||||||
Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
|
Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
|
||||||
Unit string `json:"unit,omitempty"`
|
Unit string `json:"unit,omitempty"`
|
||||||
|
Slice int `json:"slice"` // for SLC meters, the slice index it belongs to; -1 otherwise
|
||||||
Value float64 `json:"value"`
|
Value float64 `json:"value"`
|
||||||
Lo float64 `json:"lo"`
|
Lo float64 `json:"lo"`
|
||||||
Hi float64 `json:"hi"`
|
Hi float64 `json:"hi"`
|
||||||
|
|||||||
+24
-6
@@ -140,6 +140,7 @@ type meterInfo struct {
|
|||||||
src string // SLC (slice), TX-, COD, RAD, AMP…
|
src string // SLC (slice), TX-, COD, RAD, AMP…
|
||||||
name string // FWDPWR, SWR, LEVEL, PATEMP, +13.8B…
|
name string // FWDPWR, SWR, LEVEL, PATEMP, +13.8B…
|
||||||
unit string // dbm, dbfs, swr, volts, degc, watts…
|
unit string // dbm, dbfs, swr, volts, degc, watts…
|
||||||
|
slc int // for src=SLC meters, the slice index (the ".num" field); -1 otherwise
|
||||||
lo float64
|
lo float64
|
||||||
hi float64
|
hi float64
|
||||||
}
|
}
|
||||||
@@ -641,7 +642,7 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
// One meter per token; its fields are '#'-separated:
|
// One meter per token; its fields are '#'-separated:
|
||||||
// "<n>.src=…#<n>.num=…#<n>.nam=…#<n>.low=…#<n>.hi=…#<n>.unit=…".
|
// "<n>.src=…#<n>.num=…#<n>.nam=…#<n>.low=…#<n>.hi=…#<n>.unit=…".
|
||||||
num := -1
|
num := -1
|
||||||
var mi meterInfo
|
mi := meterInfo{slc: -1}
|
||||||
for _, sub := range strings.Split(tok, "#") {
|
for _, sub := range strings.Split(tok, "#") {
|
||||||
key, val, ok := splitKV(sub)
|
key, val, ok := splitKV(sub)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -661,6 +662,12 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
mi.src = val
|
mi.src = val
|
||||||
case "nam":
|
case "nam":
|
||||||
mi.name = val
|
mi.name = val
|
||||||
|
case "num":
|
||||||
|
// For a slice (SLC) meter this field is the slice index —
|
||||||
|
// how we tell slice A's S-meter from slice B's.
|
||||||
|
if v, e := strconv.Atoi(strings.TrimSpace(val)); e == nil {
|
||||||
|
mi.slc = v
|
||||||
|
}
|
||||||
case "unit", "units":
|
case "unit", "units":
|
||||||
mi.unit = val
|
mi.unit = val
|
||||||
case "low", "lo":
|
case "low", "lo":
|
||||||
@@ -675,6 +682,7 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
old, seen := f.meterMeta[num]
|
old, seen := f.meterMeta[num]
|
||||||
if !seen {
|
if !seen {
|
||||||
newIDs = append(newIDs, num)
|
newIDs = append(newIDs, num)
|
||||||
|
old.slc = -1
|
||||||
}
|
}
|
||||||
if mi.src != "" {
|
if mi.src != "" {
|
||||||
old.src = mi.src
|
old.src = mi.src
|
||||||
@@ -685,6 +693,9 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
if mi.unit != "" {
|
if mi.unit != "" {
|
||||||
old.unit = mi.unit
|
old.unit = mi.unit
|
||||||
}
|
}
|
||||||
|
if mi.slc >= 0 {
|
||||||
|
old.slc = mi.slc
|
||||||
|
}
|
||||||
if mi.lo != 0 {
|
if mi.lo != 0 {
|
||||||
old.lo = mi.lo
|
old.lo = mi.lo
|
||||||
}
|
}
|
||||||
@@ -1079,15 +1090,22 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
|||||||
}
|
}
|
||||||
// De-dupe by callsign: WSJT decodes re-fire every cycle, so a station already
|
// De-dupe by callsign: WSJT decodes re-fire every cycle, so a station already
|
||||||
// spotted gets its previous spot removed first — one live spot per call,
|
// spotted gets its previous spot removed first — one live spot per call,
|
||||||
// refreshed, instead of a pile that all expire independently.
|
// refreshed, instead of a pile that all expire independently. NB: capture the
|
||||||
|
// old index under the lock but send OUTSIDE it — f.send() takes f.mu itself,
|
||||||
|
// and Go mutexes aren't reentrant (calling send while locked deadlocks the
|
||||||
|
// whole Flex goroutine → the radio drops OFFLINE).
|
||||||
|
upperCall := strings.ToUpper(s.Callsign)
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
if old, ok := f.spotByCall[strings.ToUpper(s.Callsign)]; ok {
|
old, hadOld := f.spotByCall[upperCall]
|
||||||
delete(f.spotByCall, strings.ToUpper(s.Callsign))
|
if hadOld {
|
||||||
|
delete(f.spotByCall, upperCall)
|
||||||
delete(f.spotCall, old)
|
delete(f.spotCall, old)
|
||||||
delete(f.spotIdx, old)
|
delete(f.spotIdx, old)
|
||||||
f.send(fmt.Sprintf("spot remove %d", old))
|
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
|
if hadOld {
|
||||||
|
f.send(fmt.Sprintf("spot remove %d", old))
|
||||||
|
}
|
||||||
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
|
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
|
||||||
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
|
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
|
||||||
if m := flexEncode(s.Mode); m != "" {
|
if m := flexEncode(s.Mode); m != "" {
|
||||||
@@ -1321,7 +1339,7 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
sort.Ints(ids) // stable order so the UI doesn't reshuffle each poll
|
sort.Ints(ids) // stable order so the UI doesn't reshuffle each poll
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
mi := f.meterMeta[id]
|
mi := f.meterMeta[id]
|
||||||
st.Meters = append(st.Meters, FlexMeter{ID: id, Src: mi.src, Name: mi.name, Unit: mi.unit, Value: f.meterVal[id], Lo: mi.lo, Hi: mi.hi})
|
st.Meters = append(st.Meters, FlexMeter{ID: id, Src: mi.src, Name: mi.name, Unit: mi.unit, Slice: mi.slc, Value: f.meterVal[id], Lo: mi.lo, Hi: mi.hi})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return st
|
return st
|
||||||
|
|||||||
+139
-9
@@ -61,6 +61,11 @@ type Spot struct {
|
|||||||
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
|
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
|
||||||
ReceivedAt time.Time `json:"received_at"`
|
ReceivedAt time.Time `json:"received_at"`
|
||||||
Raw string `json:"raw"`
|
Raw string `json:"raw"`
|
||||||
|
// Historical marks a spot recovered from a SH/DX table rather than heard live.
|
||||||
|
// It belongs in the grid, but must NOT fire alerts or reach the panadapter:
|
||||||
|
// replaying 100 past spots would spam both, and a station spotted three hours
|
||||||
|
// ago is not on the air now.
|
||||||
|
Historical bool `json:"historical,omitempty"`
|
||||||
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
|
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
|
||||||
POTAName string `json:"pota_name,omitempty"` // park name
|
POTAName string `json:"pota_name,omitempty"` // park name
|
||||||
}
|
}
|
||||||
@@ -92,6 +97,21 @@ type ServerStatus struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// session is one telnet connection bound to a single server config.
|
// session is one telnet connection bound to a single server config.
|
||||||
|
// Line is one raw line of cluster traffic: everything the server says, plus the
|
||||||
|
// commands we send (echoed, so the console reads as a conversation).
|
||||||
|
//
|
||||||
|
// Spots are PARSED OUT of this stream — but everything else used to be silently
|
||||||
|
// dropped. You could type SH/DX/100 or WHO and never see the answer, which made
|
||||||
|
// the command box look broken. The raw stream is the fix: the console shows what
|
||||||
|
// the server actually said, spot or not.
|
||||||
|
type Line struct {
|
||||||
|
ServerID int64 `json:"server_id"`
|
||||||
|
ServerName string `json:"server_name"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Sent bool `json:"sent"` // true = a command WE sent
|
||||||
|
At string `json:"at"` // HH:MM:SS, local
|
||||||
|
}
|
||||||
|
|
||||||
// Internal — callers use Manager. The onStatus callback is fire-and-
|
// Internal — callers use Manager. The onStatus callback is fire-and-
|
||||||
// forget: it tells the manager something changed; the frontend fetches
|
// forget: it tells the manager something changed; the frontend fetches
|
||||||
// the new aggregate via Status() rather than receiving per-server diffs.
|
// the new aggregate via Status() rather than receiving per-server diffs.
|
||||||
@@ -99,6 +119,7 @@ type session struct {
|
|||||||
cfg ServerConfig
|
cfg ServerConfig
|
||||||
login string
|
login string
|
||||||
onSpot func(Spot)
|
onSpot func(Spot)
|
||||||
|
onLine func(Line)
|
||||||
onStatus func()
|
onStatus func()
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -117,6 +138,7 @@ type Manager struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
sessions map[int64]*session
|
sessions map[int64]*session
|
||||||
onSpot func(Spot)
|
onSpot func(Spot)
|
||||||
|
onLine func(Line)
|
||||||
onStatus func()
|
onStatus func()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,10 +146,11 @@ type Manager struct {
|
|||||||
// spot (with the source server filled in). emitStatusChanged is called
|
// spot (with the source server filled in). emitStatusChanged is called
|
||||||
// whenever ANY server's status changes — the frontend then re-fetches
|
// whenever ANY server's status changes — the frontend then re-fetches
|
||||||
// the aggregate Status() via a Wails binding.
|
// the aggregate Status() via a Wails binding.
|
||||||
func NewManager(emitSpot func(Spot), emitStatusChanged func()) *Manager {
|
func NewManager(emitSpot func(Spot), emitStatusChanged func(), emitLine func(Line)) *Manager {
|
||||||
return &Manager{
|
return &Manager{
|
||||||
sessions: make(map[int64]*session),
|
sessions: make(map[int64]*session),
|
||||||
onSpot: emitSpot,
|
onSpot: emitSpot,
|
||||||
|
onLine: emitLine,
|
||||||
onStatus: emitStatusChanged,
|
onStatus: emitStatusChanged,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,6 +178,7 @@ func (m *Manager) StartServer(cfg ServerConfig, login string) {
|
|||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
login: login,
|
login: login,
|
||||||
onSpot: m.onSpot,
|
onSpot: m.onSpot,
|
||||||
|
onLine: m.onLine,
|
||||||
onStatus: m.emitStatus,
|
onStatus: m.emitStatus,
|
||||||
stopCh: make(chan struct{}),
|
stopCh: make(chan struct{}),
|
||||||
doneCh: make(chan struct{}),
|
doneCh: make(chan struct{}),
|
||||||
@@ -242,6 +266,12 @@ func (s *session) send(cmd string) error {
|
|||||||
}
|
}
|
||||||
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||||
_, err := conn.Write([]byte(strings.TrimRight(cmd, "\r\n") + "\r\n"))
|
_, err := conn.Write([]byte(strings.TrimRight(cmd, "\r\n") + "\r\n"))
|
||||||
|
if err == nil {
|
||||||
|
// Echo it into the console, so the operator sees WHAT was sent and can pair
|
||||||
|
// it with the reply that follows. A console that only shows one side of the
|
||||||
|
// conversation is barely better than none.
|
||||||
|
s.emitLine(cmd, true)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,8 +383,18 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init commands: fire 1s after login goes through. Each command on
|
// Init commands, once per connection (so they replay after a reconnect).
|
||||||
// its own line; blank lines and "//" comments are skipped.
|
//
|
||||||
|
// Timing: the cluster needs a moment after login before it will take commands,
|
||||||
|
// hence the lead-in wait; then they are PACED, because a DXSpider/AR-Cluster
|
||||||
|
// happily swallows a burst of back-to-back lines and silently ignores half of
|
||||||
|
// them. A fast-but-dropped command is worse than a slow one.
|
||||||
|
//
|
||||||
|
// They go through s.send() — NOT a raw conn.Write, which is what they used to
|
||||||
|
// do. That matters for two reasons: the raw write skipped the write deadline
|
||||||
|
// (a wedged server could hang this goroutine forever), and it skipped the
|
||||||
|
// console echo, so there was no way to SEE whether your init commands had
|
||||||
|
// actually been applied. Now you watch them go out and the reply come back.
|
||||||
initFired := false
|
initFired := false
|
||||||
fireInitCommands := func() {
|
fireInitCommands := func() {
|
||||||
if initFired || strings.TrimSpace(s.cfg.InitCommands) == "" {
|
if initFired || strings.TrimSpace(s.cfg.InitCommands) == "" {
|
||||||
@@ -362,10 +402,9 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
}
|
}
|
||||||
initFired = true
|
initFired = true
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(1 * time.Second)
|
time.Sleep(initCommandLeadIn)
|
||||||
for _, line := range strings.Split(s.cfg.InitCommands, "\n") {
|
for _, line := range strings.Split(s.cfg.InitCommands, "\n") {
|
||||||
line = strings.TrimRight(line, "\r")
|
line = strings.TrimSpace(strings.TrimRight(line, "\r"))
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" || strings.HasPrefix(line, "//") {
|
if line == "" || strings.HasPrefix(line, "//") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -374,8 +413,11 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
_, _ = conn.Write([]byte(line + "\r\n"))
|
if err := s.send(line); err != nil {
|
||||||
time.Sleep(500 * time.Millisecond)
|
s.emitLine("init command failed: "+line+" — "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(initCommandGap)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -444,12 +486,28 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
fireInitCommands()
|
fireInitCommands()
|
||||||
}
|
}
|
||||||
|
|
||||||
if spot, ok := parseSpot(line); ok {
|
// EVERY line goes to the console — spot or not. This is the whole point:
|
||||||
|
// SH/DX, WHO, the MOTD and error replies are not spots, and dropping them
|
||||||
|
// (which is what happened before) made the command box look inert.
|
||||||
|
s.emitLine(line, false)
|
||||||
|
|
||||||
|
// Live broadcast first; then the SH/DX table form. Without the second, a
|
||||||
|
// SH/DX reply arrived, matched nothing, and vanished — which is exactly why
|
||||||
|
// "SH/DX/100" looked like it did nothing at all.
|
||||||
|
spot, ok := parseSpot(line)
|
||||||
|
if !ok {
|
||||||
|
spot, ok = parseShowDX(line)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
spot.SourceID = s.cfg.ID
|
spot.SourceID = s.cfg.ID
|
||||||
spot.SourceName = s.cfg.Name
|
spot.SourceName = s.cfg.Name
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
// Historical spots are a replay of the past, not new traffic — counting
|
||||||
|
// them would inflate the server's "spots received" figure.
|
||||||
|
if !spot.Historical {
|
||||||
s.spotsCnt++
|
s.spotsCnt++
|
||||||
s.status.SpotsCount = s.spotsCnt
|
s.status.SpotsCount = s.spotsCnt
|
||||||
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if s.onSpot != nil {
|
if s.onSpot != nil {
|
||||||
s.onSpot(spot)
|
s.onSpot(spot)
|
||||||
@@ -458,6 +516,21 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// emitLine hands one line of traffic to the console. Blank lines are dropped —
|
||||||
|
// clusters send plenty and they'd only pad the scrollback.
|
||||||
|
func (s *session) emitLine(text string, sent bool) {
|
||||||
|
if s.onLine == nil || strings.TrimSpace(text) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.onLine(Line{
|
||||||
|
ServerID: s.cfg.ID,
|
||||||
|
ServerName: s.cfg.Name,
|
||||||
|
Text: strings.TrimRight(text, "\r\n"),
|
||||||
|
Sent: sent,
|
||||||
|
At: time.Now().Format("15:04:05"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- parsing ----------
|
// ---------- parsing ----------
|
||||||
|
|
||||||
// spotRE matches "DX de SPOTTER: FREQ DXCALL COMMENT TIME [LOC]".
|
// spotRE matches "DX de SPOTTER: FREQ DXCALL COMMENT TIME [LOC]".
|
||||||
@@ -465,6 +538,63 @@ var spotRE = regexp.MustCompile(
|
|||||||
`^\s*DX\s+de\s+([A-Z0-9/#\-]+):?\s+(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*?)\s+(\d{4}Z?)(?:\s+([A-R]{2}\d{2}(?:[A-X]{2})?))?\s*$`,
|
`^\s*DX\s+de\s+([A-Z0-9/#\-]+):?\s+(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*?)\s+(\d{4}Z?)(?:\s+([A-R]{2}\d{2}(?:[A-X]{2})?))?\s*$`,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Pacing for the per-server init commands.
|
||||||
|
//
|
||||||
|
// A cluster is not ready to take commands the instant the login line goes out —
|
||||||
|
// it is still printing its banner — so we wait before the first one. And they are
|
||||||
|
// spaced, because DXSpider / AR-Cluster will happily accept a burst of
|
||||||
|
// back-to-back lines and silently apply only some of them: the connection stays
|
||||||
|
// up, no error is returned, your filters just aren't set. A command that is
|
||||||
|
// dropped is far worse than one that is slow.
|
||||||
|
const (
|
||||||
|
initCommandLeadIn = 1500 * time.Millisecond
|
||||||
|
initCommandGap = 700 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// showDXRE matches the reply to SH/DX — which is a TABLE, not the "DX de …"
|
||||||
|
// broadcast format, and therefore matched nothing at all:
|
||||||
|
//
|
||||||
|
// 14195.0 EA8DHH 3-Jul-2026 1234Z CQ DX <F5ABC>
|
||||||
|
// freq dxcall date time comment <spotter>
|
||||||
|
//
|
||||||
|
// This is why "SH/DX/100 does nothing": the 100 lines arrive, fail the broadcast
|
||||||
|
// regex, and get dropped. The decimal point in the frequency is required — it is
|
||||||
|
// what keeps ordinary prose out of the spot grid.
|
||||||
|
var showDXRE = regexp.MustCompile(
|
||||||
|
`^\s*(\d{3,7}\.\d+)\s+([A-Z0-9]{1,3}[0-9][A-Z0-9/]*)\s+(\d{1,2}-[A-Za-z]{3}-\d{4})\s+(\d{4})Z?\s*(.*?)\s*(?:<\s*([A-Z0-9/#\-]+)\s*>)?\s*$`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseShowDX turns one line of a SH/DX table into a Spot. The result is flagged
|
||||||
|
// Historical: these are PAST spots, so they belong in the grid but must not fire
|
||||||
|
// alerts or land on the panadapter — replaying 100 of them would spam both, and a
|
||||||
|
// station spotted three hours ago is not on the air now.
|
||||||
|
func parseShowDX(line string) (Spot, bool) {
|
||||||
|
if strings.Contains(strings.ToUpper(line), "DX DE") {
|
||||||
|
return Spot{}, false // that's the broadcast form; spotRE owns it
|
||||||
|
}
|
||||||
|
m := showDXRE.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
return Spot{}, false
|
||||||
|
}
|
||||||
|
khz, err := strconv.ParseFloat(m[1], 64)
|
||||||
|
if err != nil || khz <= 0 {
|
||||||
|
return Spot{}, false
|
||||||
|
}
|
||||||
|
hz := int64(khz * 1000)
|
||||||
|
return Spot{
|
||||||
|
Spotter: strings.ToUpper(m[6]),
|
||||||
|
DXCall: strings.ToUpper(m[2]),
|
||||||
|
FreqKHz: khz,
|
||||||
|
FreqHz: hz,
|
||||||
|
Band: bandFromHz(hz),
|
||||||
|
Comment: strings.TrimSpace(m[5]),
|
||||||
|
TimeUTC: m[4] + "Z",
|
||||||
|
ReceivedAt: time.Now(),
|
||||||
|
Raw: line,
|
||||||
|
Historical: true,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
func parseSpot(line string) (Spot, bool) {
|
func parseSpot(line string) (Spot, bool) {
|
||||||
m := spotRE.FindStringSubmatch(line)
|
m := spotRE.FindStringSubmatch(line)
|
||||||
if m == nil {
|
if m == nil {
|
||||||
|
|||||||
@@ -63,3 +63,57 @@ func TestParseSpotRejectsNoise(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The reply to SH/DX is a TABLE, not the "DX de …" broadcast — a completely
|
||||||
|
// different shape. Because only the broadcast form was parsed, a SH/DX/100 reply
|
||||||
|
// arrived, matched nothing and was dropped: the command looked like it did
|
||||||
|
// nothing at all. These lines must now land in the grid, flagged Historical.
|
||||||
|
func TestParseShowDX(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
line string
|
||||||
|
call string
|
||||||
|
khz float64
|
||||||
|
spotter string
|
||||||
|
}{
|
||||||
|
{" 14195.0 EA8DHH 3-Jul-2026 1234Z CQ DX <F5ABC>", "EA8DHH", 14195.0, "F5ABC"},
|
||||||
|
{" 7005.5 RA3XYZ 14-Jul-2026 0912Z <DL1ABC>", "RA3XYZ", 7005.5, "DL1ABC"},
|
||||||
|
{"21025.0 VK9/DL2XYZ 1-Jan-2026 0001Z up 2 <JA1ABC>", "VK9/DL2XYZ", 21025.0, "JA1ABC"},
|
||||||
|
// No spotter brackets — still a spot, just without a DE.
|
||||||
|
{" 50313.0 IK0ABC 3-Jul-2026 1500Z FT8", "IK0ABC", 50313.0, ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, ok := parseShowDX(c.line)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("parseShowDX(%q) failed — the SH/DX reply would be dropped again", c.line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got.DXCall != c.call || got.FreqKHz != c.khz || got.Spotter != c.spotter {
|
||||||
|
t.Errorf("parseShowDX(%q) = call %q / %.1f / de %q, want %q / %.1f / %q",
|
||||||
|
c.line, got.DXCall, got.FreqKHz, got.Spotter, c.call, c.khz, c.spotter)
|
||||||
|
}
|
||||||
|
if !got.Historical {
|
||||||
|
t.Errorf("parseShowDX(%q): must be flagged Historical — otherwise 100 replayed spots fire 100 alerts", c.line)
|
||||||
|
}
|
||||||
|
if got.Band == "" {
|
||||||
|
t.Errorf("parseShowDX(%q): band not derived from %.1f kHz", c.line, c.khz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The SH/DX parser must not swallow ordinary cluster prose — a console full of
|
||||||
|
// chatter turned into fake spots would be worse than no parser at all.
|
||||||
|
func TestParseShowDXRejectsNoise(t *testing.T) {
|
||||||
|
noise := []string{
|
||||||
|
"DX de F5ABC: 14195.0 EA8DHH CQ DX 1234Z", // the broadcast form: spotRE owns it
|
||||||
|
"Hello and welcome to the DXSpider cluster",
|
||||||
|
"WWV de VE7CC <18Z> : SFI=110, A=16, K=2",
|
||||||
|
"F4BPO de GB7DXC 12-Jul-2026 2130Z dxspider >",
|
||||||
|
"",
|
||||||
|
"There are 42 users online",
|
||||||
|
}
|
||||||
|
for _, l := range noise {
|
||||||
|
if s, ok := parseShowDX(l); ok {
|
||||||
|
t.Errorf("parseShowDX(%q) wrongly produced a spot: %+v", l, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"database/sql/driver"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsConnLost reports whether err means the database was UNREACHABLE (network
|
||||||
|
// down, server gone, dead pooled connection) as opposed to a legitimate
|
||||||
|
// server-side SQL error (constraint violation, bad data, syntax).
|
||||||
|
//
|
||||||
|
// This distinction is the linchpin of the offline safety net: a QSO is parked in
|
||||||
|
// the local ADIF outbox ONLY on a connection loss. If we queued on any error, a
|
||||||
|
// genuine data bug would silently vanish into the file instead of being
|
||||||
|
// reported — the worst possible outcome.
|
||||||
|
//
|
||||||
|
// A *mysql.MySQLError means the SERVER answered and rejected us, so it is never
|
||||||
|
// a connection loss, whatever its code.
|
||||||
|
func IsConnLost(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// The server responded → a real SQL error, not a lost link.
|
||||||
|
var me *mysql.MySQLError
|
||||||
|
if errors.As(err, &me) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, driver.ErrBadConn) || errors.Is(err, sql.ErrConnDone) || errors.Is(err, sql.ErrTxDone) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var ne net.Error
|
||||||
|
if errors.As(err, &ne) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var oe *net.OpError
|
||||||
|
if errors.As(err, &oe) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// The MySQL driver reports a few of these as plain strings.
|
||||||
|
s := strings.ToLower(err.Error())
|
||||||
|
for _, p := range []string{
|
||||||
|
"invalid connection",
|
||||||
|
"bad connection",
|
||||||
|
"connection refused",
|
||||||
|
"connection reset",
|
||||||
|
"broken pipe",
|
||||||
|
"no such host",
|
||||||
|
"i/o timeout",
|
||||||
|
"dial tcp",
|
||||||
|
"unexpected eof",
|
||||||
|
"driver: bad connection",
|
||||||
|
"can't connect",
|
||||||
|
"network is unreachable",
|
||||||
|
"host is unreachable",
|
||||||
|
} {
|
||||||
|
if strings.Contains(s, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -21,6 +21,12 @@ const clublogRealtimeURL = "https://clublog.org/realtime.php"
|
|||||||
// N QSOs is one HTTP request instead of N realtime.php calls.
|
// N QSOs is one HTTP request instead of N realtime.php calls.
|
||||||
const clublogBatchURL = "https://clublog.org/putlogs.php"
|
const clublogBatchURL = "https://clublog.org/putlogs.php"
|
||||||
|
|
||||||
|
// clublogUserAgent identifies OpsLog to Club Log. Go's default
|
||||||
|
// "Go-http-client/1.1" User-Agent is blocked by Club Log's web front end (nginx
|
||||||
|
// returns 403 Forbidden before the request reaches the app), so every request
|
||||||
|
// must send a real, app-identifying User-Agent.
|
||||||
|
const clublogUserAgent = "OpsLog/1.0 (+https://github.com/GregTroar/OpsLog)"
|
||||||
|
|
||||||
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
|
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
|
||||||
// requires an api parameter that identifies the client software (not the
|
// requires an api parameter that identifies the client software (not the
|
||||||
// user) — the same way Log4OM embeds its own key — so we ship it baked in
|
// user) — the same way Log4OM embeds its own key — so we ship it baked in
|
||||||
@@ -122,6 +128,7 @@ func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConf
|
|||||||
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
|
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
req.Header.Set("User-Agent", clublogUserAgent)
|
||||||
if client == nil {
|
if client == nil {
|
||||||
client = &http.Client{Timeout: 120 * time.Second}
|
client = &http.Client{Timeout: 120 * time.Second}
|
||||||
}
|
}
|
||||||
@@ -135,10 +142,63 @@ func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConf
|
|||||||
if resp.StatusCode == http.StatusOK {
|
if resp.StatusCode == http.StatusOK {
|
||||||
return UploadResult{OK: true, Message: msg}, nil
|
return UploadResult{OK: true, Message: msg}, nil
|
||||||
}
|
}
|
||||||
if msg == "" {
|
diag := clublogFailureDiag(resp, msg)
|
||||||
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
return UploadResult{OK: false, Message: diag}, fmt.Errorf("clublog: batch upload failed: %s", diag)
|
||||||
}
|
}
|
||||||
return UploadResult{OK: false, Message: msg}, fmt.Errorf("clublog: batch upload failed: %s", msg)
|
|
||||||
|
// clublogFailureDiag turns a non-200 response into a readable one-liner that
|
||||||
|
// names WHO blocked the request — Club Log's app vs. an intermediary (Cloudflare,
|
||||||
|
// Sucuri, a corporate proxy, an antivirus TLS shim). A bare "403 Forbidden nginx"
|
||||||
|
// page means the request never reached the app; the fingerprint headers below
|
||||||
|
// tell the operator whether it's Club Log's own WAF or something on their side.
|
||||||
|
func clublogFailureDiag(resp *http.Response, body string) string {
|
||||||
|
server := strings.TrimSpace(resp.Header.Get("Server"))
|
||||||
|
// Notable intermediary fingerprints — presence points at the culprit.
|
||||||
|
fp := []string{}
|
||||||
|
for _, h := range []string{"CF-RAY", "CF-Mitigated", "X-Sucuri-ID", "X-Sucuri-Block", "X-Squid-Error", "Via", "X-Cache", "Retry-After"} {
|
||||||
|
if v := strings.TrimSpace(resp.Header.Get(h)); v != "" {
|
||||||
|
fp = append(fp, h+"="+v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Collapse the HTML error page to something short.
|
||||||
|
summary := stripHTMLBrief(body)
|
||||||
|
if summary == "" {
|
||||||
|
summary = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
out := fmt.Sprintf("HTTP %d — %s", resp.StatusCode, summary)
|
||||||
|
if server != "" {
|
||||||
|
out += " [server=" + server + "]"
|
||||||
|
}
|
||||||
|
if len(fp) > 0 {
|
||||||
|
out += " [" + strings.Join(fp, " ") + "]"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripHTMLBrief removes tags and collapses whitespace, returning the first ~160
|
||||||
|
// chars of visible text — enough to read "403 Forbidden" without the markup.
|
||||||
|
func stripHTMLBrief(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
depth := 0
|
||||||
|
for _, r := range s {
|
||||||
|
switch r {
|
||||||
|
case '<':
|
||||||
|
depth++
|
||||||
|
case '>':
|
||||||
|
if depth > 0 {
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if depth == 0 {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := strings.Join(strings.Fields(b.String()), " ")
|
||||||
|
if len(out) > 160 {
|
||||||
|
out = out[:160] + "…"
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestClublog validates the configured credentials by attempting a no-op
|
// TestClublog validates the configured credentials by attempting a no-op
|
||||||
@@ -164,6 +224,7 @@ func clublogPost(ctx context.Context, client *http.Client, endpoint string, form
|
|||||||
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
|
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("User-Agent", clublogUserAgent)
|
||||||
if client == nil {
|
if client == nil {
|
||||||
client = &http.Client{Timeout: 20 * time.Second}
|
client = &http.Client{Timeout: 20 * time.Second}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package extsvc
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -212,6 +214,15 @@ func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord stri
|
|||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
if ee, ok := runErr.(*exec.ExitError); ok {
|
if ee, ok := runErr.(*exec.ExitError); ok {
|
||||||
code = ee.ExitCode()
|
code = ee.ExitCode()
|
||||||
|
} else if errors.Is(runErr, syscall.Errno(740)) || strings.Contains(strings.ToLower(runErr.Error()), "requires elevation") {
|
||||||
|
// ERROR_ELEVATION_REQUIRED (740): tqsl.exe is set to require admin
|
||||||
|
// rights (its "Run as administrator" compatibility flag, or an
|
||||||
|
// AppCompat RUNASADMIN entry), but OpsLog isn't elevated so Windows
|
||||||
|
// refuses to launch it. Actionable message instead of the raw error.
|
||||||
|
return UploadResult{}, fmt.Errorf(
|
||||||
|
"lotw: Windows won't launch tqsl.exe because it's marked \"Run as administrator\". " +
|
||||||
|
"Fix: right-click %q → Properties → Compatibility → UNTICK \"Run this program as an administrator\" (Apply). " +
|
||||||
|
"Or run OpsLog itself as administrator.", tqsl)
|
||||||
} else {
|
} else {
|
||||||
return UploadResult{}, fmt.Errorf("lotw: run tqsl: %w", runErr)
|
return UploadResult{}, fmt.Errorf("lotw: run tqsl: %w", runErr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// Package offlineq is OpsLog's offline safety net.
|
||||||
|
//
|
||||||
|
// When the shared MySQL logbook is unreachable, a QSO must never be lost just
|
||||||
|
// because the network blinked. Instead of failing, the QSO is appended to a
|
||||||
|
// local ADIF file (the "outbox") and replayed into the database as soon as it
|
||||||
|
// comes back — then the file is ARCHIVED, never deleted.
|
||||||
|
//
|
||||||
|
// Deliberately NOT a sync engine: it only ever PUSHES the operator's own QSOs.
|
||||||
|
// There is no mirror, no pull, no merge, no tombstones — which is exactly why it
|
||||||
|
// stays small. The cost, accepted by design: during an outage you don't see other
|
||||||
|
// operators' QSOs and the worked-before check doesn't know about your pending
|
||||||
|
// ones. See the queue view in the UI for what's waiting.
|
||||||
|
//
|
||||||
|
// The file lives in OpsLog's data directory — NEVER in a cloud-synced folder
|
||||||
|
// (Seafile/OneDrive): replicating a live file byte-by-byte is what we're
|
||||||
|
// escaping in the first place.
|
||||||
|
package offlineq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/adif"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileName is the outbox. Pending QSOs accumulate here while the DB is down.
|
||||||
|
const FileName = "opslog-pending.adi"
|
||||||
|
|
||||||
|
// Queue owns the outbox file. All operations are serialised: the logging path
|
||||||
|
// (append) and the replay loop (read/rewrite/archive) run on different
|
||||||
|
// goroutines.
|
||||||
|
type Queue struct {
|
||||||
|
dir string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a queue backed by <dir>/opslog-pending.adi.
|
||||||
|
func New(dir string) *Queue { return &Queue{dir: strings.TrimSpace(dir)} }
|
||||||
|
|
||||||
|
// Path is the outbox file's location (shown in the UI so the operator always
|
||||||
|
// knows where their QSOs physically are).
|
||||||
|
func (q *Queue) Path() string { return filepath.Join(q.dir, FileName) }
|
||||||
|
|
||||||
|
// newQueueID mints the id that makes replay idempotent.
|
||||||
|
func newQueueID() string {
|
||||||
|
var b [12]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return fmt.Sprintf("t%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append parks a QSO in the outbox, stamping it with a queue id (returned) so a
|
||||||
|
// repeated replay can recognise it. The file is created with an ADIF header on
|
||||||
|
// first use, then appended to — an append can't corrupt what's already there.
|
||||||
|
func (q *Queue) Append(rec qso.QSO) (string, error) {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
if rec.Extras == nil {
|
||||||
|
rec.Extras = map[string]string{}
|
||||||
|
}
|
||||||
|
qid := strings.TrimSpace(rec.Extras[qso.OfflineQueueKey])
|
||||||
|
if qid == "" {
|
||||||
|
qid = newQueueID()
|
||||||
|
rec.Extras[qso.OfflineQueueKey] = qid
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(q.dir, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("offlineq: create dir: %w", err)
|
||||||
|
}
|
||||||
|
path := q.Path()
|
||||||
|
_, statErr := os.Stat(path)
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("offlineq: open %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
if os.IsNotExist(statErr) { // brand-new file → write the ADIF header once
|
||||||
|
b.WriteString("OpsLog offline queue — QSOs logged while the database was unreachable.\n")
|
||||||
|
b.WriteString("<ADIF_VER:5>3.1.4 <PROGRAMID:6>OpsLog <EOH>\n\n")
|
||||||
|
}
|
||||||
|
b.WriteString(strings.TrimRight(adif.FullRecordADIF(rec), "\r\n"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
if _, err := f.WriteString(b.String()); err != nil {
|
||||||
|
return "", fmt.Errorf("offlineq: write: %w", err)
|
||||||
|
}
|
||||||
|
// Flush to disk: the whole point is surviving a crash/power cut.
|
||||||
|
if err := f.Sync(); err != nil {
|
||||||
|
return "", fmt.Errorf("offlineq: sync: %w", err)
|
||||||
|
}
|
||||||
|
return qid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending parses the outbox into QSOs (each carrying its queue id in Extras).
|
||||||
|
// A missing file simply means nothing is pending.
|
||||||
|
func (q *Queue) Pending() ([]qso.QSO, error) {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
return q.pendingLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queue) pendingLocked() ([]qso.QSO, error) {
|
||||||
|
f, err := os.Open(q.Path())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var out []qso.QSO
|
||||||
|
err = adif.Parse(f, func(rec adif.Record) error {
|
||||||
|
if v, ok := adif.RecordToQSO(rec); ok {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return out, fmt.Errorf("offlineq: parse %s: %w", q.Path(), err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count is how many QSOs are waiting (0 when the file is absent).
|
||||||
|
func (q *Queue) Count() int {
|
||||||
|
p, err := q.Pending()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite replaces the outbox with exactly these QSOs — used after a replay to
|
||||||
|
// keep only the ones that FAILED. Written to a temp file and renamed, so a crash
|
||||||
|
// mid-write can't truncate the queue.
|
||||||
|
func (q *Queue) Rewrite(keep []qso.QSO) error {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
return q.rewriteLocked(keep)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queue) rewriteLocked(keep []qso.QSO) error {
|
||||||
|
path := q.Path()
|
||||||
|
if len(keep) == 0 {
|
||||||
|
// Nothing left: remove the (now empty) outbox. The caller archives the
|
||||||
|
// original content first, so this never destroys the only copy.
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("offlineq: remove: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("OpsLog offline queue — QSOs logged while the database was unreachable.\n")
|
||||||
|
b.WriteString("<ADIF_VER:5>3.1.4 <PROGRAMID:6>OpsLog <EOH>\n\n")
|
||||||
|
for _, v := range keep {
|
||||||
|
b.WriteString(strings.TrimRight(adif.FullRecordADIF(v), "\r\n"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("offlineq: write temp: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, path); err != nil {
|
||||||
|
return fmt.Errorf("offlineq: replace: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive copies the outbox to a timestamped file BEFORE it is cleared, so the
|
||||||
|
// QSOs always exist somewhere on disk even if the replay later turns out to have
|
||||||
|
// gone wrong. Deleting the only copy of someone's contacts is the one mistake
|
||||||
|
// you don't get to undo.
|
||||||
|
func (q *Queue) Archive() (string, error) {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(q.Path())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
name := fmt.Sprintf("opslog-pending-%s.adi", time.Now().Format("2006-01-02-1504"))
|
||||||
|
dst := filepath.Join(q.dir, name)
|
||||||
|
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("offlineq: archive: %w", err)
|
||||||
|
}
|
||||||
|
return dst, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package offlineq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The outbox is the ONLY copy of a QSO logged while the database was down, so the
|
||||||
|
// round-trip must be lossless: append → read back identical (callsign, band, mode,
|
||||||
|
// date) and each record must carry a queue id (what makes the replay idempotent).
|
||||||
|
func TestQueueRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
q := New(dir)
|
||||||
|
|
||||||
|
if n := q.Count(); n != 0 {
|
||||||
|
t.Fatalf("fresh queue: count = %d, want 0", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
mk := func(call, band, mode string) qso.QSO {
|
||||||
|
return qso.QSO{
|
||||||
|
Callsign: call, Band: band, Mode: mode,
|
||||||
|
QSODate: time.Date(2026, 7, 10, 12, 34, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
id1, err := q.Append(mk("K1ABC", "20m", "SSB"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("append 1: %v", err)
|
||||||
|
}
|
||||||
|
id2, err := q.Append(mk("DL1XYZ", "40m", "CW"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("append 2: %v", err)
|
||||||
|
}
|
||||||
|
if id1 == "" || id2 == "" || id1 == id2 {
|
||||||
|
t.Fatalf("queue ids must be non-empty and distinct: %q / %q", id1, id2)
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, err := q.Pending()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pending: %v", err)
|
||||||
|
}
|
||||||
|
if len(pending) != 2 {
|
||||||
|
t.Fatalf("pending = %d, want 2", len(pending))
|
||||||
|
}
|
||||||
|
if pending[0].Callsign != "K1ABC" || pending[0].Band != "20m" || pending[0].Mode != "SSB" {
|
||||||
|
t.Errorf("record 1 round-tripped wrong: %+v", pending[0])
|
||||||
|
}
|
||||||
|
if pending[1].Callsign != "DL1XYZ" || pending[1].Mode != "CW" {
|
||||||
|
t.Errorf("record 2 round-tripped wrong: %+v", pending[1])
|
||||||
|
}
|
||||||
|
// The queue id must survive the ADIF round-trip — without it the replay
|
||||||
|
// can't be idempotent and a crash would duplicate contacts.
|
||||||
|
for i, p := range pending {
|
||||||
|
if p.Extras[qso.OfflineQueueKey] == "" {
|
||||||
|
t.Errorf("record %d lost its %s extra", i, qso.OfflineQueueKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive keeps a copy BEFORE we clear anything.
|
||||||
|
arch, err := q.Archive()
|
||||||
|
if err != nil || arch == "" {
|
||||||
|
t.Fatalf("archive: %v (path %q)", err, arch)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(arch); err != nil {
|
||||||
|
t.Fatalf("archive file missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partial replay: the first QSO went in, the second failed → keep only it.
|
||||||
|
if err := q.Rewrite(pending[1:]); err != nil {
|
||||||
|
t.Fatalf("rewrite: %v", err)
|
||||||
|
}
|
||||||
|
left, err := q.Pending()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pending after rewrite: %v", err)
|
||||||
|
}
|
||||||
|
if len(left) != 1 || left[0].Callsign != "DL1XYZ" {
|
||||||
|
t.Fatalf("after rewrite: got %d records (%v), want just DL1XYZ", len(left), left)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything replayed → the outbox is removed (the archive still holds it).
|
||||||
|
if err := q.Rewrite(nil); err != nil {
|
||||||
|
t.Fatalf("rewrite empty: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, FileName)); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("outbox should be gone once fully replayed, stat err = %v", err)
|
||||||
|
}
|
||||||
|
if n := q.Count(); n != 0 {
|
||||||
|
t.Errorf("count after full replay = %d, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1011,6 +1011,32 @@ func FilterableFields() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// columnExpr resolves a filter field to a safe SQL expression — either a
|
// columnExpr resolves a filter field to a safe SQL expression — either a
|
||||||
|
// OfflineQueueKey is the ADIF extras key stamped on a QSO that was parked in the
|
||||||
|
// offline safety file. It survives the ADIF round-trip and makes the replay
|
||||||
|
// IDEMPOTENT: if the app dies between "inserted into the DB" and "removed from
|
||||||
|
// the file", the next replay sees the id already in the log and skips it instead
|
||||||
|
// of creating a duplicate.
|
||||||
|
const OfflineQueueKey = "APP_OPSLOG_QUEUEID"
|
||||||
|
|
||||||
|
// ExistsByQueueID reports whether a QSO carrying this offline-queue id is already
|
||||||
|
// in the logbook — the guard that makes replaying the safety file safe to repeat.
|
||||||
|
func (r *Repo) ExistsByQueueID(ctx context.Context, qid string) (bool, error) {
|
||||||
|
qid = strings.TrimSpace(qid)
|
||||||
|
if qid == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
expr := "json_extract(extras_json, '$." + OfflineQueueKey + "')"
|
||||||
|
if db.IsMySQL() {
|
||||||
|
expr = "JSON_UNQUOTE(JSON_EXTRACT(NULLIF(extras_json,''), '$." + OfflineQueueKey + "'))"
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err := r.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM qso WHERE `+expr+` = ?`, qid).Scan(&n); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
// whitelisted column name or a json_extract over extras_json.
|
// whitelisted column name or a json_extract over extras_json.
|
||||||
func columnExpr(field string) (string, bool) {
|
func columnExpr(field string) (string, bool) {
|
||||||
f := strings.ToLower(strings.TrimSpace(field))
|
f := strings.ToLower(strings.TrimSpace(field))
|
||||||
|
|||||||
@@ -0,0 +1,667 @@
|
|||||||
|
package qso
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Statistics over the whole logbook.
|
||||||
|
//
|
||||||
|
// Everything is aggregated IN GO from one lean scan rather than with SQL GROUP
|
||||||
|
// BYs. Two reasons: the date maths (year / month / hour of day) would need
|
||||||
|
// dialect-specific functions — strftime() on SQLite vs YEAR()/HOUR() on MySQL —
|
||||||
|
// which is exactly the kind of thing that silently works on one backend and
|
||||||
|
// breaks on the other; and a single pass over a few columns of a 30k-row log is
|
||||||
|
// a few tens of milliseconds, so the complexity buys nothing.
|
||||||
|
|
||||||
|
// Bucket is one labelled count (mode, band, operator, entity…).
|
||||||
|
type Bucket struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gap is a stretch with no QSO at all — the off-air periods. In a contest these
|
||||||
|
// are the expensive minutes: they are where the score went.
|
||||||
|
type Gap struct {
|
||||||
|
Start string `json:"start"` // RFC3339 — the last QSO before the silence
|
||||||
|
End string `json:"end"` // the first QSO after it
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContestRun is one contest the operator actually took part in, discovered FROM
|
||||||
|
// THE LOG (a CONTEST_ID plus the year it ran) rather than from a static list — so
|
||||||
|
// the picker only ever offers contests you really entered, and never an empty one.
|
||||||
|
type ContestRun struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Year int `json:"year"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Start string `json:"start"` // first QSO, RFC3339
|
||||||
|
End string `json:"end"` // last QSO
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContestRuns lists every (contest, year) pair present in the logbook, most
|
||||||
|
// recent first.
|
||||||
|
func (r *Repo) ContestRuns(ctx context.Context) ([]ContestRun, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT contest_id, qso_date FROM qso WHERE contest_id IS NOT NULL AND contest_id <> ''`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type key struct {
|
||||||
|
id string
|
||||||
|
year int
|
||||||
|
}
|
||||||
|
agg := map[key]*ContestRun{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, dateStr sql.NullString
|
||||||
|
if err := rows.Scan(&id, &dateStr); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cid := strings.ToUpper(strings.TrimSpace(id.String))
|
||||||
|
if cid == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t := parseTimeLoose(dateStr.String).UTC()
|
||||||
|
if t.IsZero() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k := key{cid, t.Year()}
|
||||||
|
c, ok := agg[k]
|
||||||
|
if !ok {
|
||||||
|
c = &ContestRun{ID: cid, Year: t.Year(), Start: t.Format(time.RFC3339), End: t.Format(time.RFC3339)}
|
||||||
|
agg[k] = c
|
||||||
|
}
|
||||||
|
c.Count++
|
||||||
|
if t.Format(time.RFC3339) < c.Start {
|
||||||
|
c.Start = t.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if t.Format(time.RFC3339) > c.End {
|
||||||
|
c.End = t.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]ContestRun, 0, len(agg))
|
||||||
|
for _, c := range agg {
|
||||||
|
out = append(out, *c)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Year != out[j].Year {
|
||||||
|
return out[i].Year > out[j].Year // most recent first
|
||||||
|
}
|
||||||
|
return out[i].ID < out[j].ID
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// gapThreshold is the silence that counts as "off the air". Short enough to catch
|
||||||
|
// a real break, long enough not to flag the normal pause between two QSOs.
|
||||||
|
const gapThreshold = 30 * time.Minute
|
||||||
|
|
||||||
|
// rateMaxHours caps the per-hour rate timeline. A contest weekend is ~48 h, so a
|
||||||
|
// week is generous. This is a READABILITY limit, not a memory one: at 30 days the
|
||||||
|
// chart is 720 hourly bars, each about a pixel wide with an unreadable label — it
|
||||||
|
// looks broken, which is exactly how it first shipped. Past this the UI says
|
||||||
|
// "period too long for an hourly chart" instead of drawing mush.
|
||||||
|
const rateMaxHours = 7 * 24
|
||||||
|
|
||||||
|
// Stats is the whole dashboard payload.
|
||||||
|
type Stats struct {
|
||||||
|
// Headline figures.
|
||||||
|
Total int `json:"total"`
|
||||||
|
UniqueCalls int `json:"unique_calls"`
|
||||||
|
Entities int `json:"entities"` // distinct DXCC entities
|
||||||
|
Continents int `json:"continents"` // distinct continents
|
||||||
|
FirstQSO string `json:"first_qso"` // RFC3339, "" when the log is empty
|
||||||
|
LastQSO string `json:"last_qso"`
|
||||||
|
|
||||||
|
// Confirmations (of Total).
|
||||||
|
ConfirmedLoTW int `json:"confirmed_lotw"`
|
||||||
|
ConfirmedEQSL int `json:"confirmed_eqsl"`
|
||||||
|
ConfirmedQSL int `json:"confirmed_qsl"`
|
||||||
|
ConfirmedAny int `json:"confirmed_any"`
|
||||||
|
|
||||||
|
// Breakdowns, each sorted most → least (bands keep frequency order).
|
||||||
|
ByMode []Bucket `json:"by_mode"`
|
||||||
|
ByBand []Bucket `json:"by_band"`
|
||||||
|
ByOperator []Bucket `json:"by_operator"`
|
||||||
|
ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air)
|
||||||
|
ByContinent []Bucket `json:"by_continent"`
|
||||||
|
TopEntities []Bucket `json:"top_entities"`
|
||||||
|
ByYear []Bucket `json:"by_year"` // chronological
|
||||||
|
ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological
|
||||||
|
|
||||||
|
// ── Period / contest metrics ──
|
||||||
|
// Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says
|
||||||
|
// nothing, but across a contest weekend it is the score. The window is the
|
||||||
|
// requested [from,to] when given, else the span of the log.
|
||||||
|
WindowStart string `json:"window_start"`
|
||||||
|
WindowEnd string `json:"window_end"`
|
||||||
|
WindowHours float64 `json:"window_hours"`
|
||||||
|
AvgPerHour float64 `json:"avg_per_hour"` // QSOs ÷ window hours (breaks included — the honest rate)
|
||||||
|
AvgPerActive float64 `json:"avg_per_active"` // QSOs ÷ ON-AIR hours
|
||||||
|
|
||||||
|
// On-air and off-air are a TIME BUDGET and must add up to the window:
|
||||||
|
// OnAirMinutes + OffAirMinutes == window
|
||||||
|
// The first version counted "clock hours containing at least one QSO" as on-air,
|
||||||
|
// so a single QSO at 08:05 booked the whole 08:00 hour. On a 45 h contest that
|
||||||
|
// gave 39 h on air AND 16 h 43 off air — 56 h inside a 45 h window. Two numbers
|
||||||
|
// measured on incompatible bases can't be compared, and the operator rightly
|
||||||
|
// didn't believe either of them.
|
||||||
|
OnAirMinutes int `json:"on_air_minutes"`
|
||||||
|
OffAirMinutes int `json:"off_air_minutes"`
|
||||||
|
|
||||||
|
PeakHourKey string `json:"peak_hour_key"` // best clock hour (kept for reference)
|
||||||
|
PeakHourCount int `json:"peak_hour_count"`
|
||||||
|
Best60 int `json:"best_60"` // best ROLLING 60 min — the number contesters quote
|
||||||
|
Gaps []Gap `json:"gaps"` // the silences that make up OffAirMinutes, longest first
|
||||||
|
Rate []Bucket `json:"rate"` // QSO per clock hour across the window ("MM-DD HH")
|
||||||
|
|
||||||
|
// The contest RATE SHEET: hour by hour, who made the QSOs.
|
||||||
|
// RateOps are the operators, busiest first — that fixed order is also the
|
||||||
|
// colour/legend order, so an operator keeps their hue across the whole page.
|
||||||
|
// RateByOp[h][o] is operator o's count in hour h; rows align 1:1 with Rate, so
|
||||||
|
// the per-operator numbers always sum to the hour's total.
|
||||||
|
RateOps []string `json:"rate_ops"`
|
||||||
|
RateByOp [][]int `json:"rate_by_op"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// entry is one dated QSO with the operator who made it — the pair the contest
|
||||||
|
// rate sheet needs. A bare timestamp can tell you HOW MANY, never BY WHOM.
|
||||||
|
type entry struct {
|
||||||
|
t time.Time
|
||||||
|
op string
|
||||||
|
}
|
||||||
|
|
||||||
|
// bandOrder sorts bands by frequency (160m → 70cm) rather than alphabetically,
|
||||||
|
// so the band chart reads like a band plan instead of a jumble.
|
||||||
|
var bandOrder = map[string]int{
|
||||||
|
"2190m": 1, "630m": 2, "160m": 3, "80m": 4, "60m": 5, "40m": 6, "30m": 7,
|
||||||
|
"20m": 8, "17m": 9, "15m": 10, "12m": 11, "10m": 12, "6m": 13, "4m": 14,
|
||||||
|
"2m": 15, "1.25m": 16, "70cm": 17, "23cm": 18, "13cm": 19,
|
||||||
|
}
|
||||||
|
|
||||||
|
// yes reports whether an ADIF confirmation flag means "confirmed".
|
||||||
|
func yes(s string) bool {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(s)) {
|
||||||
|
case "Y", "V": // V = verified (LoTW)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats scans the logbook once and returns every breakdown the dashboard needs,
|
||||||
|
// restricted to [from, to] (a zero time means "no bound", so a zero/zero pair is
|
||||||
|
// the whole log).
|
||||||
|
//
|
||||||
|
// The window is applied HERE, in Go, on the parsed timestamp — not as a SQL
|
||||||
|
// WHERE. qso_date is a text column whose format differs between the two backends,
|
||||||
|
// so a string comparison would quietly select the wrong rows on one of them. We
|
||||||
|
// already parse every date in this pass; filtering on the parsed value is both
|
||||||
|
// correct and free.
|
||||||
|
// contestID (with an optional year, 0 = any) narrows the log to one contest. When
|
||||||
|
// it is set and no explicit window is given, the window becomes the contest's own
|
||||||
|
// span — so rate, best-hour and off-air figures are computed over the contest
|
||||||
|
// itself without the operator having to look its dates up.
|
||||||
|
func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string, year int) (Stats, error) {
|
||||||
|
var s Stats
|
||||||
|
contestID = strings.ToUpper(strings.TrimSpace(contestID))
|
||||||
|
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT callsign, qso_date, band, mode, cont, country, dxcc,
|
||||||
|
operator, station_callsign, lotw_rcvd, eqsl_rcvd, qsl_rcvd, contest_id
|
||||||
|
FROM qso`)
|
||||||
|
if err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var (
|
||||||
|
calls = map[string]struct{}{}
|
||||||
|
entities = map[int]struct{}{}
|
||||||
|
modeC = map[string]int{}
|
||||||
|
bandC = map[string]int{}
|
||||||
|
opC = map[string]int{}
|
||||||
|
stationC = map[string]int{}
|
||||||
|
contC = map[string]int{}
|
||||||
|
entityC = map[string]int{}
|
||||||
|
yearC = map[string]int{}
|
||||||
|
monthC = map[string]int{}
|
||||||
|
times []entry // every dated QSO (+ its operator), for the rate / gap maths
|
||||||
|
first, last time.Time
|
||||||
|
)
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
call, band, mode, cont, country sql.NullString
|
||||||
|
oper, station sql.NullString
|
||||||
|
lotw, eqsl, paper sql.NullString
|
||||||
|
dxcc sql.NullInt64
|
||||||
|
dateStr, contestID2 sql.NullString
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&call, &dateStr, &band, &mode, &cont, &country, &dxcc,
|
||||||
|
&oper, &station, &lotw, &eqsl, &paper, &contestID2); err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contest filter first — same reasoning as the window below: a QSO that
|
||||||
|
// isn't in this contest must not reach ANY bucket.
|
||||||
|
if contestID != "" && strings.ToUpper(strings.TrimSpace(contestID2.String)) != contestID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window first: a QSO outside the period must not reach ANY bucket. Doing
|
||||||
|
// this after the counting (the obvious mistake) would leave the mode/band/
|
||||||
|
// operator charts showing the whole log while only the trend was filtered.
|
||||||
|
// parseTimeLoose is the repo's existing convention for qso_date — it copes
|
||||||
|
// with what each backend hands back (SQLite ISO string, MySQL DATETIME).
|
||||||
|
t := parseTimeLoose(dateStr.String).UTC()
|
||||||
|
dated := !t.IsZero()
|
||||||
|
if year > 0 && (!dated || t.Year() != year) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !from.IsZero() && (!dated || t.Before(from)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !to.IsZero() && (!dated || t.After(to)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Total++
|
||||||
|
|
||||||
|
if c := strings.ToUpper(strings.TrimSpace(call.String)); c != "" {
|
||||||
|
calls[c] = struct{}{}
|
||||||
|
}
|
||||||
|
if dxcc.Valid && dxcc.Int64 > 0 {
|
||||||
|
entities[int(dxcc.Int64)] = struct{}{}
|
||||||
|
}
|
||||||
|
if m := strings.ToUpper(strings.TrimSpace(mode.String)); m != "" {
|
||||||
|
modeC[m]++
|
||||||
|
}
|
||||||
|
if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" {
|
||||||
|
bandC[b]++
|
||||||
|
}
|
||||||
|
// An empty OPERATOR means "the station owner logged it himself" — bucket
|
||||||
|
// it explicitly rather than dropping the QSO from the operator chart.
|
||||||
|
op := strings.ToUpper(strings.TrimSpace(oper.String))
|
||||||
|
if op == "" {
|
||||||
|
op = "—"
|
||||||
|
}
|
||||||
|
opC[op]++
|
||||||
|
if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
|
||||||
|
stationC[st]++
|
||||||
|
}
|
||||||
|
if c := strings.ToUpper(strings.TrimSpace(cont.String)); c != "" {
|
||||||
|
contC[c]++
|
||||||
|
}
|
||||||
|
if c := strings.TrimSpace(country.String); c != "" {
|
||||||
|
entityC[c]++
|
||||||
|
}
|
||||||
|
|
||||||
|
cl, el, pl := yes(lotw.String), yes(eqsl.String), yes(paper.String)
|
||||||
|
if cl {
|
||||||
|
s.ConfirmedLoTW++
|
||||||
|
}
|
||||||
|
if el {
|
||||||
|
s.ConfirmedEQSL++
|
||||||
|
}
|
||||||
|
if pl {
|
||||||
|
s.ConfirmedQSL++
|
||||||
|
}
|
||||||
|
if cl || el || pl {
|
||||||
|
s.ConfirmedAny++
|
||||||
|
}
|
||||||
|
|
||||||
|
// An undated QSO still counts in the mode/band/operator totals above, but
|
||||||
|
// it can't be placed on a time axis — leave it out of the trend rather than
|
||||||
|
// parking it at year zero.
|
||||||
|
if !dated {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if first.IsZero() || t.Before(first) {
|
||||||
|
first = t
|
||||||
|
}
|
||||||
|
if last.IsZero() || t.After(last) {
|
||||||
|
last = t
|
||||||
|
}
|
||||||
|
yearC[t.Format("2006")]++
|
||||||
|
monthC[t.Format("2006-01")]++
|
||||||
|
times = append(times, entry{t: t, op: op})
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.UniqueCalls = len(calls)
|
||||||
|
s.Entities = len(entities)
|
||||||
|
s.Continents = len(contC)
|
||||||
|
if !first.IsZero() {
|
||||||
|
s.FirstQSO = first.UTC().Format(time.RFC3339)
|
||||||
|
s.LastQSO = last.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.ByMode = topBuckets(modeC, 0)
|
||||||
|
s.ByOperator = topBuckets(opC, 0)
|
||||||
|
s.ByStation = topBuckets(stationC, 0)
|
||||||
|
s.ByContinent = topBuckets(contC, 0)
|
||||||
|
s.TopEntities = topBuckets(entityC, 15)
|
||||||
|
|
||||||
|
// Bands read in band-plan order, not by count — the shape of the chart IS
|
||||||
|
// the band plan, and re-sorting it by size would destroy that.
|
||||||
|
s.ByBand = sortedBuckets(bandC, func(a, b string) bool {
|
||||||
|
oa, ob := bandOrder[a], bandOrder[b]
|
||||||
|
if oa == 0 {
|
||||||
|
oa = 99
|
||||||
|
}
|
||||||
|
if ob == 0 {
|
||||||
|
ob = 99
|
||||||
|
}
|
||||||
|
if oa != ob {
|
||||||
|
return oa < ob
|
||||||
|
}
|
||||||
|
return a < b
|
||||||
|
})
|
||||||
|
// The time axis must be CONTINUOUS. Emitting only the months that have QSOs
|
||||||
|
// would place, say, 2012-08 next to 2022-01 as if they were consecutive — the
|
||||||
|
// chart would invent activity that never happened. A gap in the log is real
|
||||||
|
// information: it belongs on the chart as zeros.
|
||||||
|
s.ByYear = fillYears(yearC, first, last)
|
||||||
|
s.ByMonth = fillMonths(monthC, first, last)
|
||||||
|
|
||||||
|
s.periodMetrics(times, from, to, first, last)
|
||||||
|
s.ensureNonNil()
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureNonNil replaces every nil slice with an empty one.
|
||||||
|
//
|
||||||
|
// This is NOT cosmetic. A nil Go slice marshals to JSON `null`, not `[]`, and the
|
||||||
|
// UI then calls .length / .map on null — a TypeError that unmounts the whole React
|
||||||
|
// tree and leaves a WHITE SCREEN. It bites exactly in the innocent cases: a contest
|
||||||
|
// with no break ≥ 30 min (Gaps nil), or a window too long for the hourly chart
|
||||||
|
// (Rate nil). The awards code carries the same guard for the same reason.
|
||||||
|
func (s *Stats) ensureNonNil() {
|
||||||
|
if s.ByMode == nil {
|
||||||
|
s.ByMode = []Bucket{}
|
||||||
|
}
|
||||||
|
if s.ByBand == nil {
|
||||||
|
s.ByBand = []Bucket{}
|
||||||
|
}
|
||||||
|
if s.ByOperator == nil {
|
||||||
|
s.ByOperator = []Bucket{}
|
||||||
|
}
|
||||||
|
if s.ByStation == nil {
|
||||||
|
s.ByStation = []Bucket{}
|
||||||
|
}
|
||||||
|
if s.ByContinent == nil {
|
||||||
|
s.ByContinent = []Bucket{}
|
||||||
|
}
|
||||||
|
if s.TopEntities == nil {
|
||||||
|
s.TopEntities = []Bucket{}
|
||||||
|
}
|
||||||
|
if s.ByYear == nil {
|
||||||
|
s.ByYear = []Bucket{}
|
||||||
|
}
|
||||||
|
if s.ByMonth == nil {
|
||||||
|
s.ByMonth = []Bucket{}
|
||||||
|
}
|
||||||
|
if s.Rate == nil {
|
||||||
|
s.Rate = []Bucket{}
|
||||||
|
}
|
||||||
|
if s.Gaps == nil {
|
||||||
|
s.Gaps = []Gap{}
|
||||||
|
}
|
||||||
|
if s.RateOps == nil {
|
||||||
|
s.RateOps = []string{}
|
||||||
|
}
|
||||||
|
if s.RateByOp == nil {
|
||||||
|
s.RateByOp = [][]int{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// periodMetrics derives the rate / off-air figures that make a contest window
|
||||||
|
// readable. The window is the caller's [from,to] when given, else the span of the
|
||||||
|
// log itself.
|
||||||
|
//
|
||||||
|
// Two rates are reported on purpose, because a single one always flatters:
|
||||||
|
// • AvgPerHour = QSOs ÷ the WHOLE window — breaks included. The honest number.
|
||||||
|
// • AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you
|
||||||
|
// how fast you go when you ARE at the radio.
|
||||||
|
// Quoting only the second is how an 8-hour effort gets sold as a 48-hour score.
|
||||||
|
func (s *Stats) periodMetrics(times []entry, from, to, first, last time.Time) {
|
||||||
|
if len(times) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sort.Slice(times, func(i, j int) bool { return times[i].t.Before(times[j].t) })
|
||||||
|
|
||||||
|
winStart, winEnd := from, to
|
||||||
|
if winStart.IsZero() {
|
||||||
|
winStart = first
|
||||||
|
}
|
||||||
|
if winEnd.IsZero() {
|
||||||
|
winEnd = last
|
||||||
|
}
|
||||||
|
if !winEnd.After(winStart) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.WindowStart = winStart.Format(time.RFC3339)
|
||||||
|
s.WindowEnd = winEnd.Format(time.RFC3339)
|
||||||
|
s.WindowHours = winEnd.Sub(winStart).Hours()
|
||||||
|
if s.WindowHours > 0 {
|
||||||
|
s.AvgPerHour = float64(len(times)) / s.WindowHours
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clock-hour buckets — for the rate chart and the best clock hour only. NOT for
|
||||||
|
// "hours on air": a single QSO at 08:05 would book the whole 08:00 hour.
|
||||||
|
hourly := map[string]int{}
|
||||||
|
for _, e := range times {
|
||||||
|
hourly[e.t.Format("2006-01-02 15")]++
|
||||||
|
}
|
||||||
|
for k, v := range hourly {
|
||||||
|
if v > s.PeakHourCount || (v == s.PeakHourCount && k < s.PeakHourKey) {
|
||||||
|
s.PeakHourKey, s.PeakHourCount = k, v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best ROLLING 60 minutes — not the best clock hour. A run straddling 13:45–
|
||||||
|
// 14:45 is invisible to clock-hour bucketing, and it's the figure contesters
|
||||||
|
// actually quote. Two pointers over the sorted times: O(n).
|
||||||
|
lo := 0
|
||||||
|
for hi := range times {
|
||||||
|
for times[hi].t.Sub(times[lo].t) >= time.Hour {
|
||||||
|
lo++
|
||||||
|
}
|
||||||
|
if n := hi - lo + 1; n > s.Best60 {
|
||||||
|
s.Best60 = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Off-air is a TIME BUDGET, and it has to close on the window:
|
||||||
|
// OnAirMinutes + OffAirMinutes == window
|
||||||
|
// So every silence ≥ 30 min counts — including the lead-in before the first QSO
|
||||||
|
// and the tail after the last, when an explicit window was asked for. Skipping
|
||||||
|
// those (the first version did) makes "on air" and "off air" sum to more than
|
||||||
|
// the window, and then neither number is believable.
|
||||||
|
addGap := func(a, b time.Time) {
|
||||||
|
d := b.Sub(a)
|
||||||
|
if d < gapThreshold {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.OffAirMinutes += int(d.Minutes())
|
||||||
|
s.Gaps = append(s.Gaps, Gap{
|
||||||
|
Start: a.Format(time.RFC3339),
|
||||||
|
End: b.Format(time.RFC3339),
|
||||||
|
Minutes: int(d.Minutes()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
addGap(winStart, times[0].t) // lead-in
|
||||||
|
for i := 1; i < len(times); i++ { // the silences between QSOs
|
||||||
|
addGap(times[i-1].t, times[i].t)
|
||||||
|
}
|
||||||
|
addGap(times[len(times)-1].t, winEnd) // tail
|
||||||
|
|
||||||
|
s.OnAirMinutes = int(winEnd.Sub(winStart).Minutes()) - s.OffAirMinutes
|
||||||
|
if s.OnAirMinutes < 0 {
|
||||||
|
s.OnAirMinutes = 0
|
||||||
|
}
|
||||||
|
if s.OnAirMinutes > 0 {
|
||||||
|
s.AvgPerActive = float64(len(times)) / (float64(s.OnAirMinutes) / 60)
|
||||||
|
}
|
||||||
|
sort.Slice(s.Gaps, func(i, j int) bool { return s.Gaps[i].Minutes > s.Gaps[j].Minutes })
|
||||||
|
if len(s.Gaps) > 10 {
|
||||||
|
s.Gaps = s.Gaps[:10] // the long ones are the story; the tail is noise
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-hour rate timeline + the RATE SHEET (who made those QSOs, hour by hour).
|
||||||
|
// Every hour of the window, zeros included, so the silences read as silences.
|
||||||
|
if s.WindowHours > rateMaxHours {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operators, busiest first. That order is fixed and reused as the colour/legend
|
||||||
|
// order, so an operator keeps the same hue everywhere on the page — a chart that
|
||||||
|
// repaints its series when the filter changes is a chart nobody can trust.
|
||||||
|
opTotals := map[string]int{}
|
||||||
|
for _, e := range times {
|
||||||
|
opTotals[e.op]++
|
||||||
|
}
|
||||||
|
s.RateOps = make([]string, 0, len(opTotals))
|
||||||
|
for op := range opTotals {
|
||||||
|
s.RateOps = append(s.RateOps, op)
|
||||||
|
}
|
||||||
|
sort.Slice(s.RateOps, func(i, j int) bool {
|
||||||
|
a, b := s.RateOps[i], s.RateOps[j]
|
||||||
|
if opTotals[a] != opTotals[b] {
|
||||||
|
return opTotals[a] > opTotals[b]
|
||||||
|
}
|
||||||
|
return a < b
|
||||||
|
})
|
||||||
|
// Never invent a 9th colour: past 8 operators the tail folds into "Other", which
|
||||||
|
// is honest and still sums correctly.
|
||||||
|
const maxOps = 8
|
||||||
|
folded := false
|
||||||
|
if len(s.RateOps) > maxOps {
|
||||||
|
s.RateOps = append(s.RateOps[:maxOps:maxOps], otherOp)
|
||||||
|
folded = true
|
||||||
|
}
|
||||||
|
opIdx := map[string]int{}
|
||||||
|
for i, op := range s.RateOps {
|
||||||
|
opIdx[op] = i
|
||||||
|
}
|
||||||
|
slotFor := func(op string) int {
|
||||||
|
if i, ok := opIdx[op]; ok {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
if folded {
|
||||||
|
return len(s.RateOps) - 1 // the "Other" bucket
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// hourOps[hourKey][slot] — built from the same `times` as `hourly`, so the
|
||||||
|
// per-operator numbers ALWAYS sum to the hour's total. Deriving them separately
|
||||||
|
// is how a rate sheet ends up not adding up to its own total row.
|
||||||
|
hourOps := map[string][]int{}
|
||||||
|
for _, e := range times {
|
||||||
|
k := e.t.Format("2006-01-02 15")
|
||||||
|
row, ok := hourOps[k]
|
||||||
|
if !ok {
|
||||||
|
row = make([]int, len(s.RateOps))
|
||||||
|
hourOps[k] = row
|
||||||
|
}
|
||||||
|
if i := slotFor(e.op); i >= 0 {
|
||||||
|
row[i]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cur := winStart.Truncate(time.Hour)
|
||||||
|
end := winEnd.Truncate(time.Hour)
|
||||||
|
for !cur.After(end) {
|
||||||
|
k := cur.Format("2006-01-02 15")
|
||||||
|
s.Rate = append(s.Rate, Bucket{Key: cur.Format("01-02 15"), Count: hourly[k]})
|
||||||
|
row := hourOps[k]
|
||||||
|
if row == nil {
|
||||||
|
row = make([]int, len(s.RateOps)) // a silent hour is zeros, not a missing row
|
||||||
|
}
|
||||||
|
s.RateByOp = append(s.RateByOp, row)
|
||||||
|
cur = cur.Add(time.Hour)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherOp is where operators past the 8th are folded. Generating a 9th colour is
|
||||||
|
// never the answer: under colour-blindness it is indistinguishable from one of the
|
||||||
|
// existing eight.
|
||||||
|
const otherOp = "Other"
|
||||||
|
|
||||||
|
// topBuckets sorts a count map most → least (ties alphabetical) and optionally
|
||||||
|
// keeps only the top n.
|
||||||
|
func topBuckets(m map[string]int, n int) []Bucket {
|
||||||
|
out := make([]Bucket, 0, len(m))
|
||||||
|
for k, v := range m {
|
||||||
|
out = append(out, Bucket{Key: k, Count: v})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Count != out[j].Count {
|
||||||
|
return out[i].Count > out[j].Count
|
||||||
|
}
|
||||||
|
return out[i].Key < out[j].Key
|
||||||
|
})
|
||||||
|
if n > 0 && len(out) > n {
|
||||||
|
out = out[:n]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortedBuckets keeps a caller-defined key order (band plan, chronology).
|
||||||
|
func sortedBuckets(m map[string]int, less func(a, b string) bool) []Bucket {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Slice(keys, func(i, j int) bool { return less(keys[i], keys[j]) })
|
||||||
|
out := make([]Bucket, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
out = append(out, Bucket{Key: k, Count: m[k]})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// fillMonths emits EVERY month between the first and last QSO — zeros included —
|
||||||
|
// so the trend line's x-axis is real time rather than "months that happen to have
|
||||||
|
// data". A quiet decade must read as a decade at zero, not vanish.
|
||||||
|
func fillMonths(m map[string]int, first, last time.Time) []Bucket {
|
||||||
|
if first.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []Bucket
|
||||||
|
cur := time.Date(first.Year(), first.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
end := time.Date(last.Year(), last.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
for !cur.After(end) {
|
||||||
|
k := cur.Format("2006-01")
|
||||||
|
out = append(out, Bucket{Key: k, Count: m[k]})
|
||||||
|
cur = cur.AddDate(0, 1, 0)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillYears does the same for the yearly view.
|
||||||
|
func fillYears(m map[string]int, first, last time.Time) []Bucket {
|
||||||
|
if first.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []Bucket
|
||||||
|
for y := first.Year(); y <= last.Year(); y++ {
|
||||||
|
k := fmt.Sprintf("%04d", y)
|
||||||
|
out = append(out, Bucket{Key: k, Count: m[k]})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package qso
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bands must read in BAND-PLAN order (160m → 70cm), never by count and never
|
||||||
|
// alphabetically — the order of that chart IS the information.
|
||||||
|
func TestBandPlanOrder(t *testing.T) {
|
||||||
|
counts := map[string]int{"20m": 9312, "160m": 77, "70cm": 3, "40m": 5196, "10m": 3401, "80m": 2332}
|
||||||
|
got := sortedBuckets(counts, func(a, b string) bool {
|
||||||
|
oa, ob := bandOrder[a], bandOrder[b]
|
||||||
|
if oa == 0 {
|
||||||
|
oa = 99
|
||||||
|
}
|
||||||
|
if ob == 0 {
|
||||||
|
ob = 99
|
||||||
|
}
|
||||||
|
if oa != ob {
|
||||||
|
return oa < ob
|
||||||
|
}
|
||||||
|
return a < b
|
||||||
|
})
|
||||||
|
want := []string{"160m", "80m", "40m", "20m", "10m", "70cm"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("got %d buckets, want %d", len(got), len(want))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i].Key != want[i] {
|
||||||
|
t.Errorf("position %d = %q, want %q (full: %v)", i, got[i].Key, want[i], got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A nil Go slice marshals to JSON `null`, not `[]` — and the UI then calls
|
||||||
|
// .length/.map on null, which unmounts the whole React tree and leaves a WHITE
|
||||||
|
// SCREEN. It bites in the innocent cases: a contest with no break ≥ 30 min (Gaps
|
||||||
|
// nil), or a window too long for the hourly chart (Rate nil). Every slice the
|
||||||
|
// dashboard reads must therefore come back non-nil, even when empty.
|
||||||
|
func TestStatsNoNilSlices(t *testing.T) {
|
||||||
|
var s Stats // the worst case: nothing computed at all
|
||||||
|
s.ensureNonNil()
|
||||||
|
|
||||||
|
checks := map[string]bool{
|
||||||
|
"ByMode": s.ByMode == nil, "ByBand": s.ByBand == nil, "ByOperator": s.ByOperator == nil,
|
||||||
|
"ByStation": s.ByStation == nil, "ByContinent": s.ByContinent == nil,
|
||||||
|
"TopEntities": s.TopEntities == nil, "ByYear": s.ByYear == nil, "ByMonth": s.ByMonth == nil,
|
||||||
|
"Rate": s.Rate == nil, "Gaps": s.Gaps == nil,
|
||||||
|
}
|
||||||
|
for name, isNil := range checks {
|
||||||
|
if isNil {
|
||||||
|
t.Errorf("%s is nil → marshals to JSON null → white screen in the UI", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The realistic trigger: a short, gap-free run. No silence ≥ 30 min and a
|
||||||
|
// window that yields no hourly chart must still produce [] and not null.
|
||||||
|
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
|
||||||
|
times := []entry{{t: base, op: "A"}, {t: base.Add(2 * time.Minute), op: "A"}, {t: base.Add(5 * time.Minute), op: "B"}}
|
||||||
|
var s2 Stats
|
||||||
|
s2.periodMetrics(times, time.Time{}, time.Time{}, base, base.Add(5*time.Minute))
|
||||||
|
s2.ensureNonNil()
|
||||||
|
if s2.Gaps == nil {
|
||||||
|
t.Error("Gaps nil for a gap-free run — this is exactly the contest that white-screened")
|
||||||
|
}
|
||||||
|
if len(s2.Gaps) != 0 {
|
||||||
|
t.Errorf("Gaps = %v, want empty (no silence ≥ 30 min in this run)", s2.Gaps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contest metrics over a window. The two traps:
|
||||||
|
// 1. "Best hour" must be the best ROLLING 60 minutes, not the best clock hour —
|
||||||
|
// a run straddling 13:45–14:45 is invisible to clock-hour bucketing, and the
|
||||||
|
// rolling figure is the one contesters quote.
|
||||||
|
// 2. Both rates must be reported: QSOs ÷ whole window (honest, breaks included)
|
||||||
|
// AND QSOs ÷ hours actually operated. Quoting only the latter is how an
|
||||||
|
// 8-hour effort gets sold as a 48-hour score.
|
||||||
|
func TestContestPeriodMetrics(t *testing.T) {
|
||||||
|
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
|
||||||
|
at := func(min int) time.Time { return base.Add(time.Duration(min) * time.Minute) }
|
||||||
|
|
||||||
|
// A run straddling the clock hour: 10 QSOs from 12:40 to 13:20 (within 60 min),
|
||||||
|
// then a 2-hour silence, then 3 more.
|
||||||
|
var times []entry
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
times = append(times, entry{t: at(40 + i*4), op: "F4BPO"}) // 12:40 … 13:16
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
times = append(times, entry{t: at(240 + i*5), op: "F5XYZ"}) // 16:00 …
|
||||||
|
}
|
||||||
|
|
||||||
|
from := base // 12:00
|
||||||
|
to := base.Add(6 * time.Hour) // 18:00 → a 6-hour window
|
||||||
|
var s Stats
|
||||||
|
s.periodMetrics(times, from, to, time.Time{}, time.Time{})
|
||||||
|
|
||||||
|
if s.WindowHours != 6 {
|
||||||
|
t.Errorf("window = %.1f h, want 6", s.WindowHours)
|
||||||
|
}
|
||||||
|
// 13 QSOs over a 6 h window.
|
||||||
|
if got := s.AvgPerHour; got < 2.16 || got > 2.17 {
|
||||||
|
t.Errorf("avg/h over the window = %.3f, want ~2.167 (13÷6)", got)
|
||||||
|
}
|
||||||
|
// The rolling hour must find the straddling run of 10 — a clock-hour bucket
|
||||||
|
// would only ever see part of it.
|
||||||
|
if s.Best60 != 10 {
|
||||||
|
t.Errorf("best rolling 60 min = %d, want 10 (the 12:40→13:16 run)", s.Best60)
|
||||||
|
}
|
||||||
|
if s.PeakHourCount >= 10 {
|
||||||
|
t.Errorf("peak CLOCK hour = %d — it should be < 10, which is exactly why the rolling figure exists", s.PeakHourCount)
|
||||||
|
}
|
||||||
|
// THE INVARIANT: on-air + off-air must close on the window. The first version
|
||||||
|
// counted "clock hours containing a QSO" as on-air, which on a real 45 h contest
|
||||||
|
// reported 39 h on air AND 16 h 43 off air — 56 h inside 45 h. Two numbers on
|
||||||
|
// incompatible bases; the operator believed neither, and was right.
|
||||||
|
if got := s.OnAirMinutes + s.OffAirMinutes; got != int(s.WindowHours*60) {
|
||||||
|
t.Errorf("on-air (%d) + off-air (%d) = %d min, but the window is %d min — the budget must close",
|
||||||
|
s.OnAirMinutes, s.OffAirMinutes, got, int(s.WindowHours*60))
|
||||||
|
}
|
||||||
|
// Off air = lead-in (12:00→12:40 = 40 min) + the 13:16→16:00 silence (164) +
|
||||||
|
// the tail (16:10→18:00 = 110). Silences ≥ 30 min all count, wherever they sit:
|
||||||
|
// ignoring the lead-in and tail is what broke the budget.
|
||||||
|
if s.OffAirMinutes != 40+164+110 {
|
||||||
|
t.Errorf("off-air = %d min, want %d (lead-in + gap + tail)", s.OffAirMinutes, 40+164+110)
|
||||||
|
}
|
||||||
|
if len(s.Gaps) != 3 {
|
||||||
|
t.Fatalf("gaps = %+v, want 3 (lead-in, the silence, the tail)", s.Gaps)
|
||||||
|
}
|
||||||
|
if s.AvgPerActive <= s.AvgPerHour {
|
||||||
|
t.Errorf("avg/on-air (%.2f) must exceed avg/window (%.2f) when there are breaks", s.AvgPerActive, s.AvgPerHour)
|
||||||
|
}
|
||||||
|
// The rate timeline covers EVERY hour of the window, silences as zeros.
|
||||||
|
if len(s.Rate) != 7 { // 12,13,14,15,16,17,18
|
||||||
|
t.Fatalf("rate timeline = %d hours, want 7 (every hour of the window)", len(s.Rate))
|
||||||
|
}
|
||||||
|
if s.Rate[2].Count != 0 || s.Rate[3].Count != 0 {
|
||||||
|
t.Errorf("the 14:00/15:00 silence must show as zeros, got %+v %+v", s.Rate[2], s.Rate[3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The contest RATE SHEET: hour by hour, who made the QSOs.
|
||||||
|
//
|
||||||
|
// The invariant that matters: for EVERY hour, the per-operator numbers must sum to
|
||||||
|
// that hour's total. Derive the two separately and a rate sheet quietly stops
|
||||||
|
// adding up to its own total row — the sort of error nobody spots until someone
|
||||||
|
// checks the score by hand.
|
||||||
|
func TestRateSheetSumsToHourTotal(t *testing.T) {
|
||||||
|
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
|
||||||
|
at := func(min int) time.Time { return base.Add(time.Duration(min) * time.Minute) }
|
||||||
|
|
||||||
|
times := []entry{
|
||||||
|
{t: at(5), op: "F4BPO"}, {t: at(10), op: "F4BPO"}, {t: at(20), op: "F5XYZ"}, // hour 12: 3
|
||||||
|
{t: at(70), op: "F5XYZ"}, {t: at(80), op: "F5XYZ"}, // hour 13: 2
|
||||||
|
// hour 14 silent
|
||||||
|
{t: at(185), op: "F4BPO"}, // hour 15: 1
|
||||||
|
}
|
||||||
|
var s Stats
|
||||||
|
s.periodMetrics(times, base, base.Add(4*time.Hour), time.Time{}, time.Time{})
|
||||||
|
|
||||||
|
if len(s.Rate) != len(s.RateByOp) {
|
||||||
|
t.Fatalf("rate rows (%d) and rate-sheet rows (%d) must align 1:1", len(s.Rate), len(s.RateByOp))
|
||||||
|
}
|
||||||
|
// Both operators present, busiest first (they tie at 3 → alphabetical).
|
||||||
|
if len(s.RateOps) != 2 || s.RateOps[0] != "F4BPO" {
|
||||||
|
t.Fatalf("rate ops = %v, want [F4BPO F5XYZ]", s.RateOps)
|
||||||
|
}
|
||||||
|
for h := range s.Rate {
|
||||||
|
sum := 0
|
||||||
|
for _, n := range s.RateByOp[h] {
|
||||||
|
sum += n
|
||||||
|
}
|
||||||
|
if sum != s.Rate[h].Count {
|
||||||
|
t.Errorf("hour %s: operators sum to %d but the hour total is %d — the rate sheet doesn't add up",
|
||||||
|
s.Rate[h].Key, sum, s.Rate[h].Count)
|
||||||
|
}
|
||||||
|
if len(s.RateByOp[h]) != len(s.RateOps) {
|
||||||
|
t.Errorf("hour %s: row has %d columns, want %d (one per operator)", s.Rate[h].Key, len(s.RateByOp[h]), len(s.RateOps))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The silent hour is a row of zeros, not a missing row.
|
||||||
|
if s.Rate[2].Count != 0 || s.RateByOp[2][0] != 0 || s.RateByOp[2][1] != 0 {
|
||||||
|
t.Errorf("the silent 14:00 hour must be zeros, got total=%d row=%v", s.Rate[2].Count, s.RateByOp[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A quiet decade must appear on the trend as a decade AT ZERO. Emitting only the
|
||||||
|
// months that have QSOs would put 2012 next to 2022 as if consecutive — the chart
|
||||||
|
// would invent activity that never happened.
|
||||||
|
func TestTimeAxisIsContinuous(t *testing.T) {
|
||||||
|
first := time.Date(2009, 5, 30, 0, 0, 0, 0, time.UTC)
|
||||||
|
last := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
years := fillYears(map[string]int{"2009": 79, "2012": 1187, "2026": 14415}, first, last)
|
||||||
|
if len(years) != 18 { // 2009..2026 inclusive
|
||||||
|
t.Fatalf("years = %d, want 18 (2009→2026 with no holes)", len(years))
|
||||||
|
}
|
||||||
|
byKey := map[string]int{}
|
||||||
|
for _, b := range years {
|
||||||
|
byKey[b.Key] = b.Count
|
||||||
|
}
|
||||||
|
if byKey["2010"] != 0 || byKey["2018"] != 0 {
|
||||||
|
t.Errorf("silent years must be present as zero, got 2010=%d 2018=%d", byKey["2010"], byKey["2018"])
|
||||||
|
}
|
||||||
|
if byKey["2012"] != 1187 || byKey["2026"] != 14415 {
|
||||||
|
t.Errorf("real counts lost: 2012=%d 2026=%d", byKey["2012"], byKey["2026"])
|
||||||
|
}
|
||||||
|
|
||||||
|
months := fillMonths(map[string]int{"2009-05": 49, "2026-07": 1}, first, last)
|
||||||
|
// May 2009 → July 2026 inclusive = 17 years * 12 + 3 = 207 months.
|
||||||
|
if len(months) != 207 {
|
||||||
|
t.Fatalf("months = %d, want 207 (continuous)", len(months))
|
||||||
|
}
|
||||||
|
if months[0].Key != "2009-05" || months[0].Count != 49 {
|
||||||
|
t.Errorf("first month = %+v, want 2009-05 / 49", months[0])
|
||||||
|
}
|
||||||
|
if months[len(months)-1].Key != "2026-07" {
|
||||||
|
t.Errorf("last month = %q, want 2026-07", months[len(months)-1].Key)
|
||||||
|
}
|
||||||
|
// Every step is exactly one month — no jumps.
|
||||||
|
for i := 1; i < len(months); i++ {
|
||||||
|
prev, _ := time.Parse("2006-01", months[i-1].Key)
|
||||||
|
cur, _ := time.Parse("2006-01", months[i].Key)
|
||||||
|
if !prev.AddDate(0, 1, 0).Equal(cur) {
|
||||||
|
t.Fatalf("gap in the time axis between %q and %q", months[i-1].Key, months[i].Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -75,6 +76,20 @@ type Client struct {
|
|||||||
pendingDir int
|
pendingDir int
|
||||||
pendingDirAt time.Time
|
pendingDirAt time.Time
|
||||||
pendingDirSet bool
|
pendingDirSet bool
|
||||||
|
|
||||||
|
// lastSetKHz is the frequency we last COMMANDED. Used as the follow-loop
|
||||||
|
// deadband reference when the antenna's own status hasn't reported a frequency
|
||||||
|
// yet (Frequency==0) — otherwise the deadband is bypassed and every small QSY
|
||||||
|
// re-tunes the motors.
|
||||||
|
lastSetKHz int
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastSetKHz returns the frequency (kHz) most recently commanded to the antenna,
|
||||||
|
// or 0 if none yet.
|
||||||
|
func (c *Client) LastSetKHz() int {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
return c.lastSetKHz
|
||||||
}
|
}
|
||||||
|
|
||||||
type Status struct {
|
type Status struct {
|
||||||
@@ -457,6 +472,15 @@ func (c *Client) queryProgress() ([]int, error) {
|
|||||||
|
|
||||||
// SetFrequency changes frequency and optional direction (command 3)
|
// SetFrequency changes frequency and optional direction (command 3)
|
||||||
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
||||||
|
// Trace WHO asked for the change — the caller's function + line — so an
|
||||||
|
// unexpected antenna QSY (e.g. jumping to 14.074 while on 40m) can be traced
|
||||||
|
// to the follow loop, an immediate re-tune, or a direction re-issue.
|
||||||
|
caller := "?"
|
||||||
|
if pc, _, line, ok := runtime.Caller(1); ok {
|
||||||
|
caller = fmt.Sprintf("%s:%d", runtime.FuncForPC(pc).Name(), line)
|
||||||
|
}
|
||||||
|
log.Printf("Ultrabeam: SetFrequency(%d kHz, dir %d) ← %s", freqKhz, direction, caller)
|
||||||
|
|
||||||
data := []byte{
|
data := []byte{
|
||||||
byte(freqKhz & 0xFF),
|
byte(freqKhz & 0xFF),
|
||||||
byte((freqKhz >> 8) & 0xFF),
|
byte((freqKhz >> 8) & 0xFF),
|
||||||
@@ -467,6 +491,7 @@ func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
|
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
|
||||||
|
c.lastSetKHz = freqKhz
|
||||||
if c.lastStatus != nil {
|
if c.lastStatus != nil {
|
||||||
c.lastStatus.Direction = direction // reflect immediately
|
c.lastStatus.Direction = direction // reflect immediately
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ func profileArg(args []string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Single-instance guard: if OpsLog is already running, focus that window and
|
||||||
|
// exit instead of spawning a duplicate. A second process would open its own
|
||||||
|
// CAT (FlexRadio) connection and Ultrabeam follow loop, and the two would
|
||||||
|
// fight over the rig/antenna frequency — the cause of "the antenna re-tunes on
|
||||||
|
// its own" when a windowless zombie instance was left running.
|
||||||
|
if !acquireSingleInstance() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Create an instance of the app structure
|
// Create an instance of the app structure
|
||||||
app := NewApp()
|
app := NewApp()
|
||||||
app.startupProfile = profileArg(os.Args[1:])
|
app.startupProfile = profileArg(os.Args[1:])
|
||||||
|
|||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/db"
|
||||||
|
"hamlog/internal/offlineq"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Offline safety net ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// With the shared MySQL logbook, the network sits on the critical path of every
|
||||||
|
// write: lose it and you can't log at all. Rather than a fragile "fall back to
|
||||||
|
// SQLite" (which would leave you with a partial log and needs a restart), we
|
||||||
|
// keep the architecture exactly as it is and add a NET:
|
||||||
|
//
|
||||||
|
// DB unreachable → the QSO is parked in a local ADIF outbox (never lost)
|
||||||
|
// DB back → replayed into the logbook, idempotently, file archived
|
||||||
|
//
|
||||||
|
// It only ever PUSHES your own QSOs — no mirror, no pull, no merge. That's what
|
||||||
|
// keeps it small. Accepted trade-off: while offline you don't see other ops'
|
||||||
|
// QSOs, and worked-before doesn't know about your own pending ones (they're
|
||||||
|
// listed separately in the UI instead).
|
||||||
|
|
||||||
|
// offlineReplayEvery is how often we re-probe the database while QSOs are
|
||||||
|
// waiting. Short enough to feel automatic, long enough not to hammer a dead host.
|
||||||
|
const offlineReplayEvery = 15 * time.Second
|
||||||
|
|
||||||
|
// OfflineStatus is what the UI shows: are we parked, and how much is waiting.
|
||||||
|
type OfflineStatus struct {
|
||||||
|
Offline bool `json:"offline"` // last write failed because the DB was unreachable
|
||||||
|
Pending int `json:"pending"` // QSOs sitting in the outbox
|
||||||
|
Path string `json:"path"` // where the outbox physically is
|
||||||
|
}
|
||||||
|
|
||||||
|
// queueOffline parks a QSO that couldn't reach the database. Returns false when
|
||||||
|
// queueing isn't applicable (local SQLite can't "go offline") or the write to the
|
||||||
|
// outbox itself failed — in which case the caller MUST surface the original error
|
||||||
|
// rather than pretend the QSO was saved.
|
||||||
|
func (a *App) queueOffline(q qso.QSO, cause error) bool {
|
||||||
|
if a.offlineQ == nil || !db.IsMySQL() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
qid, err := a.offlineQ.Append(q)
|
||||||
|
if err != nil {
|
||||||
|
// The net itself tore: do NOT claim success.
|
||||||
|
applog.Printf("offline: FAILED to park %s in the outbox: %v (original: %v)", q.Callsign, err, cause)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
applog.Printf("offline: DB unreachable (%v) — parked %s in the outbox (id %s)", cause, q.Callsign, qid)
|
||||||
|
a.offlineMode = true
|
||||||
|
a.emitOfflineStatus()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitOfflineStatus pushes the banner state to the UI.
|
||||||
|
func (a *App) emitOfflineStatus() {
|
||||||
|
if a.ctx == nil || a.offlineQ == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wruntime.EventsEmit(a.ctx, "offline:status", a.GetOfflineStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOfflineStatus reports whether we're parked and how many QSOs are waiting.
|
||||||
|
func (a *App) GetOfflineStatus() OfflineStatus {
|
||||||
|
if a.offlineQ == nil {
|
||||||
|
return OfflineStatus{}
|
||||||
|
}
|
||||||
|
n := a.offlineQ.Count()
|
||||||
|
return OfflineStatus{
|
||||||
|
Offline: a.offlineMode && n > 0,
|
||||||
|
Pending: n,
|
||||||
|
Path: a.offlineQ.Path(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPendingQSOs lists the QSOs waiting in the outbox, so the operator can SEE
|
||||||
|
// what they logged while the database was down instead of flying blind.
|
||||||
|
func (a *App) GetPendingQSOs() ([]qso.QSO, error) {
|
||||||
|
if a.offlineQ == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return a.offlineQ.Pending()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetryOfflineSync forces a replay attempt now (the "Retry" button) instead of
|
||||||
|
// waiting for the next tick.
|
||||||
|
func (a *App) RetryOfflineSync() (int, error) {
|
||||||
|
return a.replayOfflineQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
// offlineReplayLoop re-probes the database while QSOs are waiting and replays
|
||||||
|
// them the moment it answers.
|
||||||
|
func (a *App) offlineReplayLoop() {
|
||||||
|
t := time.NewTicker(offlineReplayEvery)
|
||||||
|
defer t.Stop()
|
||||||
|
for range t.C {
|
||||||
|
if a.offlineQ == nil || a.offlineQ.Count() == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a.logDb == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
err := a.logDb.PingContext(ctx)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
continue // still down — keep waiting, the QSOs are safe on disk
|
||||||
|
}
|
||||||
|
if n, rerr := a.replayOfflineQueue(); rerr != nil {
|
||||||
|
applog.Printf("offline: replay failed: %v", rerr)
|
||||||
|
} else if n > 0 {
|
||||||
|
applog.Printf("offline: replayed %d QSO(s) into the logbook", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// replayOfflineQueue imports the outbox into the logbook. It is IDEMPOTENT: each
|
||||||
|
// parked QSO carries a queue id, and one already present in the log is skipped —
|
||||||
|
// so a crash between "inserted" and "removed from the file" can never duplicate a
|
||||||
|
// contact. The file is archived BEFORE being cleared.
|
||||||
|
func (a *App) replayOfflineQueue() (int, error) {
|
||||||
|
if a.offlineQ == nil || a.qso == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
pending, err := a.offlineQ.Pending()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if len(pending) == 0 {
|
||||||
|
a.offlineMode = false
|
||||||
|
a.emitOfflineStatus()
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
imported := 0
|
||||||
|
var failed []qso.QSO
|
||||||
|
for _, q := range pending {
|
||||||
|
qid := ""
|
||||||
|
if q.Extras != nil {
|
||||||
|
qid = q.Extras[qso.OfflineQueueKey]
|
||||||
|
}
|
||||||
|
// Idempotency guard: already replayed on an earlier (interrupted) pass?
|
||||||
|
if qid != "" {
|
||||||
|
if exists, e := a.qso.ExistsByQueueID(a.ctx, qid); e == nil && exists {
|
||||||
|
imported++ // it IS in the log — treat as done, drop from the outbox
|
||||||
|
continue
|
||||||
|
} else if e != nil && db.IsConnLost(e) {
|
||||||
|
failed = append(failed, q) // DB went away again mid-replay
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, e := a.qso.Add(a.ctx, q); e != nil {
|
||||||
|
applog.Printf("offline: replay of %s failed: %v", q.Callsign, e)
|
||||||
|
failed = append(failed, q)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive the outbox as it was, then keep only what still failed.
|
||||||
|
if imported > 0 {
|
||||||
|
if dst, e := a.offlineQ.Archive(); e != nil {
|
||||||
|
applog.Printf("offline: archive failed: %v", e)
|
||||||
|
} else if dst != "" {
|
||||||
|
applog.Printf("offline: outbox archived to %s", dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e := a.offlineQ.Rewrite(failed); e != nil {
|
||||||
|
return imported, fmt.Errorf("offline: rewrite outbox: %w", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.offlineMode = len(failed) > 0
|
||||||
|
a.emitOfflineStatus()
|
||||||
|
if imported > 0 && a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "logbook:changed")
|
||||||
|
wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("%d QSO(s) en attente ont été enregistrés", imported))
|
||||||
|
}
|
||||||
|
return imported, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newOfflineQueue builds the outbox in OpsLog's data directory — deliberately NOT
|
||||||
|
// in a cloud-synced folder: byte-level file replication (Seafile/OneDrive) is the
|
||||||
|
// very thing this design avoids.
|
||||||
|
func newOfflineQueue(dataDir string) *offlineq.Queue { return offlineq.New(dataDir) }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !windows || bindings
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// acquireSingleInstance is a no-op off Windows (the guard uses a Windows named
|
||||||
|
// mutex), and during Wails' binding generation (the `bindings` tag) — that step
|
||||||
|
// runs this binary, and a real OpsLog already running would otherwise make it
|
||||||
|
// exit before Wails could reflect the bindings.
|
||||||
|
func acquireSingleInstance() bool { return true }
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
//go:build windows && !bindings
|
||||||
|
|
||||||
|
// NB the !bindings tag: Wails generates the TypeScript bindings by BUILDING AND
|
||||||
|
// RUNNING this binary. With the guard active, a normal OpsLog already running on
|
||||||
|
// the dev machine holds the mutex, the generator's process exits instantly, and
|
||||||
|
// no bindings are produced. Excluding the guard from that build keeps generation
|
||||||
|
// working while shipping builds still get it.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// singleInstanceName is a per-session named mutex. The Windows kernel releases
|
||||||
|
// it automatically when the owning process dies (even on a crash), so a
|
||||||
|
// lingering/zombie OpsLog can't permanently block future launches — killing it
|
||||||
|
// frees the name at once. Session-local (no "Global\\") = one instance per
|
||||||
|
// logged-in desktop, which is what we want.
|
||||||
|
const singleInstanceName = "OpsLog-SingleInstance-Mutex"
|
||||||
|
|
||||||
|
// acquireSingleInstance creates the named mutex. Returns ok=false when another
|
||||||
|
// OpsLog already holds it (this instance should exit); on the way out it brings
|
||||||
|
// the existing window to the front so a double-click just refocuses OpsLog
|
||||||
|
// instead of spawning a duplicate that fights over the CAT / antenna.
|
||||||
|
//
|
||||||
|
// The mutex handle is deliberately never closed — it must live for the whole
|
||||||
|
// process lifetime; the OS reclaims it on exit.
|
||||||
|
func acquireSingleInstance() (ok bool) {
|
||||||
|
namePtr, err := windows.UTF16PtrFromString(singleInstanceName)
|
||||||
|
if err != nil {
|
||||||
|
return true // never block launch on an unexpected error
|
||||||
|
}
|
||||||
|
kernel32 := windows.NewLazySystemDLL("kernel32.dll")
|
||||||
|
createMutex := kernel32.NewProc("CreateMutexW")
|
||||||
|
// CreateMutexW(lpSecurityAttributes=NULL, bInitialOwner=FALSE, lpName)
|
||||||
|
h, _, callErr := createMutex.Call(0, 0, uintptr(unsafe.Pointer(namePtr)))
|
||||||
|
if h == 0 {
|
||||||
|
return true // couldn't create the mutex → don't block the app
|
||||||
|
}
|
||||||
|
if errors.Is(callErr, windows.ERROR_ALREADY_EXISTS) {
|
||||||
|
focusExistingWindow()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// focusExistingWindow finds the running OpsLog window by its title and restores
|
||||||
|
// + foregrounds it. Best-effort; failures are silently ignored.
|
||||||
|
func focusExistingWindow() {
|
||||||
|
user32 := windows.NewLazySystemDLL("user32.dll")
|
||||||
|
findWindow := user32.NewProc("FindWindowW")
|
||||||
|
setForeground := user32.NewProc("SetForegroundWindow")
|
||||||
|
showWindow := user32.NewProc("ShowWindow")
|
||||||
|
|
||||||
|
title, err := windows.UTF16PtrFromString("OpsLog")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hwnd, _, _ := findWindow.Call(0, uintptr(unsafe.Pointer(title)))
|
||||||
|
if hwnd == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const swRestore = 9 // SW_RESTORE — un-minimise if needed
|
||||||
|
showWindow.Call(hwnd, swRestore)
|
||||||
|
setForeground.Call(hwnd)
|
||||||
|
}
|
||||||
+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.19.4"
|
appVersion = "0.19.6"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user