Compare commits

...
25 Commits
Author SHA1 Message Date
rouggy cd4e5b1117 i18n: the global chase block's labels, dropped by the failed batch edit 2026-08-30 21:23:17 +02:00
rouggy a13ac7d917 test: the bulk-edit guard learns the integer My-station fields
my_dxcc/my_cq_zone/my_itu_zone go through BulkSetIntField, not the string
column map — the guard flagged them as unwritable ever since they were
added, which is the drift it exists to catch, one package over.
2026-08-30 21:03:03 +02:00
rouggy 4e28098a4e feat(chase): the global hunt — new, or new plus unconfirmed, every category
The gap between worked and confirmed becomes visible everywhere. A Chase
setting on the DX Cluster page picks the mode; in new-plus-unconfirmed the
verdicts for DXCC/band/mode/slot, prefix, county and state are judged against
CONFIRMED-only ledgers built from the operator's chosen sources (LoTW, card,
eQSL, QRZ.com — HRDLog has no confirmation field in ADIF to offer), and a
need that exists only because a QSL never arrived is flagged Unconf: the
cluster and decode badges render it dimmed, the convention the grid hunt
introduced. The grid's own Chase selector folds into the global one; its
square-matching scope stays where it was, being grid-specific. One snapshot
key change rebuilds the cache when any of it flips.
2026-08-30 21:02:34 +02:00
rouggy 52e98a71f4 feat(decodes): NEW STATE — badge and filter
The WAS gap made visible: a worked-states set joins the cluster status cache
(distinct states over the three US entities), the status entries carry a
NewState flag wherever the state resolves (log county first, ULS licence
second), and the decode panel gains the badge in its status column plus the
filter chip alongside NEW/BAND/MODE/GRID. Colour from the shared marker
palette, so the cluster can adopt it later without a second hue.
2026-08-30 20:44:58 +02:00
rouggy 81ff6a9b0e style(decodes): a gap between the state badge and its name 2026-08-30 20:38:50 +02:00
rouggy be52a23868 feat(decodes): a State column — badge plus full name
Between the locator and the country, per its operator: the two-letter badge
anchors the eye and the spelled-out name answers it — 'SD' alone is a quiz
for a European. The status-cell badge from the first pass moves here.
2026-08-30 20:36:45 +02:00
rouggy fc1561804c style(decodes): the US state is a proper badge
Bigger and boxed, per its operator — the plain grey text vanished into the
column it was meant to anchor.
2026-08-30 20:34:02 +02:00
rouggy 1288b3f998 feat(decodes): the two-letter US state in the status column
Plain text, not a badge — it is a fact, not an action — placed first so a
WAS chaser can scan the column edge. Shown whenever the status cache knows
the state (log county data or the licence address).
2026-08-30 20:31:05 +02:00
rouggy e00488fad8 chore: release v0.27.3 2026-08-30 20:19:39 +02:00
rouggy 12c61dc35a feat(dvk): delete a message — recording and label together
A deleted message is a free slot, per its operator; keeping the label made it
look half-deleted.
2026-08-30 20:15:45 +02:00
rouggy 0c0e8b06ba feat(dvk): twelve slots; perf: Settings pauses the heavy streams
The voice keyer grows to F1-F12 — the UI was already data-driven, so the
count constant, the F-key range and the labels are the whole change.

And the preferences stop stuttering on a busy station: every spot batch,
decode batch and CAT snapshot re-rendered the entire App tree behind the
dialog — cluster grid, decodes panel, thousands of nodes — and the pointer
janked over the very panel the operator was trying to use. While Settings is
open the flushes park their batches in the pending refs (bounded) and CAT
repaints at most every two seconds; closing the dialog drains everything.
2026-08-30 20:09:44 +02:00
rouggy 1bd3896ca7 feat(decodes): arrival order within a period, not strongest-first
The panel now mirrors the decoder's own window line for line — the operator
compares the two side by side, and the SNR sort scrambled that
correspondence. The report is still right there in its column.
2026-08-30 19:26:52 +02:00
rouggy 7e6c0b4f7e fix(kpa): stop switching the KPA500 off, and answer its operator instantly
Three faults, one report. The slow poll asks ^TP — the KPA1500's ATU, which
a KPA500 (no ATU) never answers — and ask() dropped the whole connection on
any read timeout: a two-second stall and a reconnect every slow cycle, which
is why buttons lagged and the status read wrong. Worse, every serial reopen
toggled DTR/RTS — and those lines are the KPA500's POWER SWITCH (that is how
the Elecraft utility turns it on), so the amplifier obediently switched off
twenty seconds after its operator pressed nothing but Standby.

Silence is no longer a dead link (write errors still are), ^TP is never asked
again after one silence, the control lines are asserted once and held, and
the baud field becomes a list of the rates these amplifiers actually speak.
2026-08-30 19:14:35 +02:00
rouggy 2cde1a2c27 chore: release v0.27.2 2026-08-30 16:06:51 +02:00
rouggy 53100eb6c8 debug(rigctld): always trace a client's first six commands
A connect that lasts 50 ms leaves nothing to say which answer the client
disliked — WSJT-X refusing to attach to the shared CAT was undiagnosable
without the full wire trace. The handshake is now always logged; steady-state
polling stays behind the trace switch.
2026-08-30 15:55:58 +02:00
rouggy 3b555219d2 style(i18n): trim the FR follow-mode hint, and fix its double-escaped accents 2026-08-30 15:38:14 +02:00
rouggy ba35d4094c style(i18n): trim the follow-mode hint to its first sentence 2026-08-30 15:36:33 +02:00
rouggy 9bd6d988aa feat(udp): spot clicks switch the decoder's mode
Configure (message 15), mode field only, every other field sent as
no-change: an FT4 spot clicked while WSJT-X sits in FT8 lands the operator
ready to decode instead of staring at gibberish. Sent to every instance
heard this session — one already in the right mode treats it as a no-op —
and only for modes the decoder actually speaks (FT8/FT4/JT65/JT9/MSK144/
Q65/FST4); CW and SSB are none of its business. Toggle in the Connections
panel, on by default.
2026-08-30 15:33:07 +02:00
rouggy daabbc63c7 fix(map): Zoom DX clamps its frame to Mercator's edge
The great-circle arc to a polar entity (Franz Josef Land) peaks near 88°N;
fitting the raw arc points framed a band of tile-less white above 85°, where
Web Mercator ends. The camera's bounds are clamped to ±85° — the line still
draws to wherever it goes, only the framing stays where there is a map.
2026-08-30 15:30:30 +02:00
rouggy 997bc81d5e fix(udp): edge-detect the decoder's DX Call stream
WSJT-X restates the same DX Call in every Status packet, and a spot clicked
in OpsLog put that call into the last-UDP memory (deliberately, for another
case) — so the guard let the decoder's next restatement straight through and
the entry snapped back to old news. The decoder's stream now only counts when
its value CHANGES; a clear over there resets the edge so re-selecting the
same station still lands. Explicit picks (N1MM, remote, spot relays) are
untouched.
2026-08-30 15:29:07 +02:00
rouggy 3c59507bc3 feat(udp): Highlight Callsign and Replay — the decoder becomes log-aware
Message 13 paints callsigns in WSJT-X/JTDX's own Band Activity window with
verdicts from the same cluster status cache that colours the spot grid:
watchlist pink, new DXCC green, new band for its entity orange. Deduplicated
per instance+call+verdict, one datagram each; a verdict that lapses (the
operator worked them) clears that call, and turning the option off clears
everything via the protocol's CLEARALL!. Off by default, switch in the
Connections panel.

Message 7 asks a program heard for the first time this session to replay the
decodes already on its screen, so the FT decodes panel starts full instead of
empty until the next period. Replayed lines arrive marked not-new and are
shown but never auto-answered — the auto-caller now checks, on top of its
30-second freshness gate.
2026-08-30 15:25:58 +02:00
rouggy 721c43d569 style(settings): the Connections panel drops its two explainer paragraphs
Same cleanup as the General and DX Cluster panels: the section speaks for
itself, and the per-service hints remain where the choice is made.
2026-08-30 15:09:08 +02:00
rouggy 25eda98612 style(watchlist): a pass toward DXHunter's look
The operator preferred the original's aesthetics. Adopted: the signature pink
callsign (a fixed identity colour, like the cluster palette), counters as
coloured pills, a quieter card surface with a hover, a stronger needed bar on
spot rows, and the lightning bolt back on the DXpedition badge. Everything
else stays on theme tokens.
2026-08-30 13:35:37 +02:00
rouggy 0b909a4d63 fix(elecraft): the K3 power meter converts to real watts
The operator measured the whole table: 10 W showed 83, 12 W showed 100, 13 W
showed 8 — the bargraph is relative to a meter RANGE that flips at 12 W
(0-12 QRP, 0-120 above). A bar percentage was never watts; with the PC
setting choosing the range, it converts, and the console prints the watts
beside the bar.
2026-08-30 13:31:34 +02:00
rouggy 37298afd77 fix(qso): every by-ids statement is chunked — 168k QSOs is a selection too
One placeholder per id in a single IN (…) hits SQLite's bound-variable cap:
bulk field set died at 168 000 QSOs with 'too many SQL variables' (10 000
passed). One chunker now serves bulk set (text/int/extra/frequency), the
delete, the post-upload markers and the export-selection iterator — the last
collecting and sorting once at the end so its chronological contract holds
across chunks. 500 ids a statement keeps every backend far from any limit.
2026-08-30 13:13:22 +02:00
27 changed files with 1209 additions and 182 deletions
+177 -16
View File
@@ -159,6 +159,8 @@ const (
keyCATIcomNetUser = "cat.icom.net.user" // Icom network: Network User1 ID keyCATIcomNetUser = "cat.icom.net.user" // Icom network: Network User1 ID
keyCATIcomNetPass = "cat.icom.net.pass" // Icom network: Network User1 password keyCATIcomNetPass = "cat.icom.net.pass" // Icom network: Network User1 password
keyCATIcomNetAudio = "cat.icom.net.audio" // Icom network: stream RX audio on 50003 (experimental) keyCATIcomNetAudio = "cat.icom.net.audio" // Icom network: stream RX audio on 50003 (experimental)
keyChaseMode = "chase.mode" // "new" | "new_unconfirmed" — the global hunt, every category
keyChaseConfirm = "chase.confirm" // CSV of confirmation sources: lotw,card,eqsl,qrz
keyAudioMonitorOn = "audio.monitor.on" // play the network RX audio through the Listening device (the stream itself stays open for the recorder either way) keyAudioMonitorOn = "audio.monitor.on" // play the network RX audio through the Listening device (the stream itself stays open for the recorder either way)
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2) keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2)
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001) keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
@@ -741,10 +743,15 @@ type App struct {
watchlist *watchlist.Store // Tools → Watchlist (global watchlist.json) watchlist *watchlist.Store // Tools → Watchlist (global watchlist.json)
watchAlertMu sync.Mutex // throttles watchlist alerts… watchAlertMu sync.Mutex // throttles watchlist alerts…
watchAlertAt map[string]time.Time // …per entry watchAlertAt map[string]time.Time // …per entry
watchPattern atomic.Value // auto-contest pattern (string), loaded at startup
operating *operating.Repo // WSJT-X decode highlighting (message 13) — see app_wsjt_highlight.go.
udp *udp.Manager wsjtHighlightOn atomic.Bool
udpRepo *udp.Repo wsjtHLMu sync.Mutex
wsjtHLSent map[string]string
watchPattern atomic.Value // auto-contest pattern (string), loaded at startup
operating *operating.Repo
udp *udp.Manager
udpRepo *udp.Repo
// Program id of the last decoding application that reported its status. // Program id of the last decoding application that reported its status.
// Halt Tx is routed by id, and the panel's Halt button must work even when // Halt Tx is routed by id, and the panel's Halt button must work even when
// nothing is transmitting at that instant — so the id is remembered from // nothing is transmitting at that instant — so the id is remembered from
@@ -1205,6 +1212,11 @@ func (a *App) startup(ctx context.Context) {
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)
// A program heard for the first time is asked to replay the decodes already
// on its screen, so the FT decodes panel starts full instead of waiting a
// period. Replayed decodes arrive marked not-new and are shown but never
// auto-answered.
a.udp.SetOnNewInstance(func(id string) { _ = a.udp.SendReplay(id) })
go a.consumeUDPEvents() go a.consumeUDPEvents()
a.cache = lookup.NewCache(conn, 30*24*time.Hour) a.cache = lookup.NewCache(conn, 30*24*time.Hour)
a.lookup = lookup.NewManager(a.cache) a.lookup = lookup.NewManager(a.cache)
@@ -1444,6 +1456,7 @@ func (a *App) startup(ctx context.Context) {
a.pota = pota.New(func(format string, args ...any) { applog.Printf(format, args...) }) a.pota = pota.New(func(format string, args ...any) { applog.Printf(format, args...) })
a.startWatchlistClubLog() a.startWatchlistClubLog()
a.watchPattern.Store(strings.ToUpper(strings.TrimSpace(a.settingOr(keyWatchlistContestPattern, "")))) a.watchPattern.Store(strings.ToUpper(strings.TrimSpace(a.settingOr(keyWatchlistContestPattern, ""))))
a.wsjtHighlightOn.Store(a.settingOr(keyWsjtHighlight, "0") == "1")
go a.pota.Run(a.ctx) go a.pota.Run(a.ctx)
// DX Cluster (multi-server): the spot callback enriches each spot // DX Cluster (multi-server): the spot callback enriches each spot
@@ -10228,7 +10241,7 @@ func titleEntity(s string) string {
// the configured "Recording mic", transmit via "To Radio", preview via // the configured "Recording mic", transmit via "To Radio", preview via
// "Listening". // "Listening".
const dvkSlots = 6 const dvkSlots = 12
// DVKMessage is one voice-keyer slot for the UI. // DVKMessage is one voice-keyer slot for the UI.
type DVKMessage struct { type DVKMessage struct {
@@ -10289,6 +10302,22 @@ func (a *App) GetDVKMessages() []DVKMessage {
return out return out
} }
// DVKDelete removes a slot's recording AND its label — a deleted message is a
// free slot, per its operator.
func (a *App) DVKDelete(slot int) error {
if slot < 1 || slot > dvkSlots {
return fmt.Errorf("bad slot")
}
if err := os.Remove(a.dvkPath(slot)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("delete message %d: %w", slot, err)
}
if a.settings != nil {
_ = a.settings.Set(a.ctx, dvkLabelKey(slot), "")
}
applog.Printf("dvk: message F%d deleted (label cleared)", slot)
return nil
}
// SetDVKLabel renames a voice-keyer slot. // SetDVKLabel renames a voice-keyer slot.
func (a *App) SetDVKLabel(slot int, label string) error { func (a *App) SetDVKLabel(slot int, label string) error {
if a.settings == nil { if a.settings == nil {
@@ -13905,7 +13934,13 @@ func (a *App) consumeUDPEvents() {
"low_conf": ev.DecodeLowConf, "low_conf": ev.DecodeLowConf,
"mode_raw": ev.DecodeModeRaw, "mode_raw": ev.DecodeModeRaw,
"msg_raw": ev.DecodeMsgRaw, "msg_raw": ev.DecodeMsgRaw,
// false on a Replay's resent history — shown, never auto-answered.
"is_new": ev.DecodeIsNew,
}) })
// Log-aware colour in the decoder's own window (see
// app_wsjt_highlight.go). After the emit: painting must never delay
// the panel.
a.maybeHighlightDecode(ev.ProgramID, ev.DecodeCall, bandForHz(ev.DecodeFreqHz))
// A WSJT-X decode (heard station). Render it on the FlexRadio // A WSJT-X decode (heard station). Render it on the FlexRadio
// 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.
@@ -19786,9 +19821,19 @@ type SpotStatus struct {
// County and State are that resolved county, so the cluster can show it as a // County and State are that resolved county, so the cluster can show it as a
// column instead of only flagging it. Free: the ULS lookup that decides // column instead of only flagging it. Free: the ULS lookup that decides
// NewCounty already has them in hand. // NewCounty already has them in hand.
County string `json:"county,omitempty"` County string `json:"county,omitempty"`
State string `json:"state,omitempty"` State string `json:"state,omitempty"`
NewPOTA bool `json:"new_pota"` // NewState says this US state has never been worked — the WAS gap the
// decode panel's filter chases. Orthogonal to Status, like NewCounty.
NewState bool `json:"new_state"`
// The Unconf flags say the matching need exists ONLY because the contact
// was never confirmed (under the operator's chosen QSL sources): a QSL to
// chase, not a QSO to make. The badges render them dashed, the grid way.
UnconfStatus bool `json:"unconf_status,omitempty"`
UnconfPfx bool `json:"unconf_pfx,omitempty"`
UnconfCty bool `json:"unconf_cty,omitempty"`
UnconfState bool `json:"unconf_state,omitempty"`
NewPOTA bool `json:"new_pota"`
// Grid is the 4-character square this station announced in a CQ on the UDP // Grid is the 4-character square this station announced in a CQ on the UDP
// link, and NewGrid says that square has never been worked. Both are empty / // link, and NewGrid says that square has never been worked. Both are empty /
// false for any station this receiver has not decoded — a DX-cluster line // false for any station this receiver has not decoded — a DX-cluster line
@@ -19836,6 +19881,17 @@ type clusterStatusCache struct {
workedCallSlots map[string]struct{} workedCallSlots map[string]struct{}
workedCallSlotsDig map[string]struct{} // same set, digital modes folded to DIG // nil unless the "same slot" option is on workedCallSlotsDig map[string]struct{} // same set, digital modes folded to DIG // nil unless the "same slot" option is on
workedCounties map[string]struct{} workedCounties map[string]struct{}
workedStates map[string]struct{}
// The CONFIRMED-only ledgers, built when the global hunt is
// "new + unconfirmed": verdicts are judged against these, and a need that
// exists only because a contact was never confirmed is flagged Unconf so
// its badge can say "a QSL to chase", not "a QSO to make".
chaseUnconf bool
chaseKey string
entitiesConf map[int]*qso.EntitySlot
workedPfxConf map[string]struct{}
workedCountiesConf map[string]struct{}
workedStatesConf map[string]struct{}
// callCounties holds callsign → "STATE,County" for stations already logged // callCounties holds callsign → "STATE,County" for stations already logged
// with a county, so a spot shows the county the entry panel showed rather // with a county, so a spot shows the county the entry panel showed rather
// than the one derived from the licence ZIP. See qso.CallCounties. // than the one derived from the licence ZIP. See qso.CallCounties.
@@ -19856,6 +19912,44 @@ type clusterStatusCache struct {
slotHighlight bool // (same: the slot index is built for either) slotHighlight bool // (same: the slot index is built for either)
} }
// ChaseSettings is the global hunt: does "worked but never confirmed" still
// count as something to chase, and which QSL systems count as confirmation.
type ChaseSettings struct {
Mode string `json:"mode"` // "new" | "new_unconfirmed"
Sources []string `json:"sources"` // subset of lotw,card,eqsl,qrz
}
// GetChaseSettings returns the global hunt settings.
func (a *App) GetChaseSettings() ChaseSettings {
mode := a.settingOr(keyChaseMode, "new")
if mode != "new_unconfirmed" {
mode = "new"
}
csv := a.settingOr(keyChaseConfirm, "lotw,card,eqsl")
var src []string
for _, part := range strings.Split(csv, ",") {
if part = strings.TrimSpace(part); part != "" {
src = append(src, part)
}
}
return ChaseSettings{Mode: mode, Sources: src}
}
// SaveChaseSettings stores them and drops the status snapshot so the next
// batch is judged under the new rules.
func (a *App) SaveChaseSettings(cs ChaseSettings) error {
mode := cs.Mode
if mode != "new_unconfirmed" {
mode = "new"
}
a.setSetting(keyChaseMode, mode)
a.setSetting(keyChaseConfirm, strings.Join(cs.Sources, ","))
a.clusterStatusMu.Lock()
a.clusterStatusIdx = nil
a.clusterStatusMu.Unlock()
return nil
}
// clusterStatusMaps returns the cached worked-index snapshot, building it once // clusterStatusMaps returns the cached worked-index snapshot, building it once
// per logbook change (invalidated by invalidateAwardStats) or when a setting // per logbook change (invalidated by invalidateAwardStats) or when a setting
// that shapes the maps flips. This turns the per-batch full-logbook scans into // that shapes the maps flips. This turns the per-batch full-logbook scans into
@@ -19868,16 +19962,23 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
// holding the status mutex across that is how two subsystems deadlock. // holding the status mutex across that is how two subsystems deadlock.
gs := a.GetGridScopeSettings() gs := a.GetGridScopeSettings()
gridScopeNow := lookupGridScope(gs.Scope) gridScopeNow := lookupGridScope(gs.Scope)
gridHuntNow := gs.Hunt // The grid hunt FOLLOWS the global chase mode now — the per-grid selector
// grew into the global option, and two switches for one idea is one too
// many. (Same vocabulary: "new" / "new_unconfirmed".)
chase := a.GetChaseSettings()
gridHuntNow := chase.Mode
chaseKeyNow := chase.Mode + "|" + strings.Join(chase.Sources, ",")
a.clusterStatusMu.Lock() a.clusterStatusMu.Lock()
defer a.clusterStatusMu.Unlock() defer a.clusterStatusMu.Unlock()
if c := a.clusterStatusIdx; c != nil && c.groupDigital == groupDigital && c.sameSlot == sameSlot && if c := a.clusterStatusIdx; c != nil && c.groupDigital == groupDigital && c.sameSlot == sameSlot &&
c.slotHighlight == slotHighlight && c.gridScope == gridScopeNow && c.gridHunt == gridHuntNow { c.slotHighlight == slotHighlight && c.gridScope == gridScopeNow && c.gridHunt == gridHuntNow &&
c.chaseKey == chaseKeyNow {
return c return c
} }
c := &clusterStatusCache{ c := &clusterStatusCache{
groupDigital: groupDigital, sameSlot: sameSlot, slotHighlight: slotHighlight, groupDigital: groupDigital, sameSlot: sameSlot, slotHighlight: slotHighlight,
gridScope: gridScopeNow, gridHunt: gridHuntNow, gridScope: gridScopeNow, gridHunt: gridHuntNow,
chaseUnconf: chase.Mode == "new_unconfirmed", chaseKey: chaseKeyNow,
} }
if a.qso == nil { if a.qso == nil {
a.clusterStatusIdx = c a.clusterStatusIdx = c
@@ -19934,6 +20035,7 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
// Orthogonal dimensions: worked US counties (for the ULS callsign→county // Orthogonal dimensions: worked US counties (for the ULS callsign→county
// lookup) and worked POTA parks. // lookup) and worked POTA parks.
c.workedCounties, _ = a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey) c.workedCounties, _ = a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
c.workedStates, _ = a.qso.WorkedStateKeys(a.ctx)
c.callCounties, _ = a.qso.CallCounties(a.ctx) c.callCounties, _ = a.qso.CallCounties(a.ctx)
c.workedPOTA, _ = a.qso.WorkedPOTARefs(a.ctx) c.workedPOTA, _ = a.qso.WorkedPOTARefs(a.ctx)
// One more DISTINCT scan when the snapshot is rebuilt, then pure map lookups // One more DISTINCT scan when the snapshot is rebuilt, then pure map lookups
@@ -19944,6 +20046,19 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
// extra query. Derived rather than read from the stored PFX column: that // extra query. Derived rather than read from the stored PFX column: that
// column is only filled when an import supplied it, and deriving keeps this // column is only filled when an import supplied it, and deriving keeps this
// in step with the WPX award, which does the same thing. // in step with the WPX award, which does the same thing.
if c.chaseUnconf {
pred := qso.ConfirmSourcesPredicate(chase.Sources)
c.entitiesConf, _ = a.qso.EntitySlotMapPred(a.ctx, keyFor, c.normMode, pred)
c.workedCountiesConf, _ = a.qso.WorkedCountyKeysPred(a.ctx, award.USCountyKey, pred)
c.workedStatesConf, _ = a.qso.WorkedStateKeysPred(a.ctx, pred)
confCalls, _ := a.qso.WorkedCallsignsPred(a.ctx, pred)
c.workedPfxConf = make(map[string]struct{}, len(confCalls))
for call := range confCalls {
if pfx := award.WPXPrefix(call); pfx != "" {
c.workedPfxConf[pfx] = struct{}{}
}
}
}
c.workedPfx = make(map[string]struct{}, len(c.workedCalls)) c.workedPfx = make(map[string]struct{}, len(c.workedCalls))
for call := range c.workedCalls { for call := range c.workedCalls {
if p := award.WPXPrefix(call); p != "" { if p := award.WPXPrefix(call); p != "" {
@@ -20269,6 +20384,13 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
workedCounties := idx.workedCounties workedCounties := idx.workedCounties
workedPOTA := idx.workedPOTA workedPOTA := idx.workedPOTA
workedPfx := idx.workedPfx workedPfx := idx.workedPfx
// The hunt decides the ledger: "new + unconfirmed" judges every category
// against the CONFIRMED sets, and the all-QSO sets then say whether a need
// is a fresh one or a missing QSL (the Unconf flags).
judgeEntities, judgePfx, judgeCounties, judgeStates := entities, workedPfx, workedCounties, idx.workedStates
if idx.chaseUnconf {
judgeEntities, judgePfx, judgeCounties, judgeStates = idx.entitiesConf, idx.workedPfxConf, idx.workedCountiesConf, idx.workedStatesConf
}
normMode := idx.normMode normMode := idx.normMode
sameSlot := idx.sameSlot sameSlot := idx.sameSlot
for i, q := range spots { for i, q := range spots {
@@ -20312,8 +20434,11 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
// NEW PFX: the spot's CQ WPX prefix, never worked before. // NEW PFX: the spot's CQ WPX prefix, never worked before.
if p := award.WPXPrefix(q.Call); p != "" { if p := award.WPXPrefix(q.Call); p != "" {
out[i].Pfx = p out[i].Pfx = p
if _, done := workedPfx[p]; !done { if _, done := judgePfx[p]; !done {
out[i].NewPfx = true out[i].NewPfx = true
if _, everWorked := workedPfx[p]; everWorked {
out[i].UnconfPfx = true
}
} }
} }
// NEW POTA: the spot's tagged park, never worked before. // NEW POTA: the spot's tagged park, never worked before.
@@ -20376,19 +20501,39 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
if cnty, ok := idx.callCounties[q.Call]; ok { if cnty, ok := idx.callCounties[q.Call]; ok {
st, name, _ := strings.Cut(cnty, ",") st, name, _ := strings.Cut(cnty, ",")
out[i].State, out[i].County = st, name out[i].State, out[i].County = st, name
// Logged means worked, so this can never be a new county — but say if st != "" {
// so through the same key the flag below uses, not by assumption. if _, done := judgeStates[strings.ToUpper(st)]; !done {
out[i].NewState = true
if _, ever := idx.workedStates[strings.ToUpper(st)]; ever {
out[i].UnconfState = true
}
}
}
if key := award.USCountyKey(st, name); key != "" { if key := award.USCountyKey(st, name); key != "" {
if _, done := workedCounties[key]; !done { if _, done := judgeCounties[key]; !done {
out[i].NewCounty = true out[i].NewCounty = true
if _, ever := workedCounties[key]; ever {
out[i].UnconfCty = true
}
} }
} }
} else if a.uls != nil { } else if a.uls != nil {
if loc, ok := a.uls.Resolve(q.Call); ok { if loc, ok := a.uls.Resolve(q.Call); ok {
out[i].County, out[i].State = loc.County, loc.State out[i].County, out[i].State = loc.County, loc.State
if loc.State != "" {
if _, done := judgeStates[strings.ToUpper(loc.State)]; !done {
out[i].NewState = true
if _, ever := idx.workedStates[strings.ToUpper(loc.State)]; ever {
out[i].UnconfState = true
}
}
}
if key := award.USCountyKey(loc.State, loc.County); key != "" { if key := award.USCountyKey(loc.State, loc.County); key != "" {
if _, done := workedCounties[key]; !done { if _, done := judgeCounties[key]; !done {
out[i].NewCounty = true out[i].NewCounty = true
if _, ever := workedCounties[key]; ever {
out[i].UnconfCty = true
}
} }
} }
} }
@@ -20416,9 +20561,12 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
if dxccNum == 0 { if dxccNum == 0 {
continue // can't resolve the spot's entity number → don't guess continue // can't resolve the spot's entity number → don't guess
} }
e, worked := entities[dxccNum] e, worked := judgeEntities[dxccNum]
if !worked { if !worked {
out[i].Status = "new" out[i].Status = "new"
if _, ever := entities[dxccNum]; ever {
out[i].UnconfStatus = true
}
continue continue
} }
// The check mode goes through the same normaliser as the slot map // The check mode goes through the same normaliser as the slot map
@@ -20433,6 +20581,19 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
_, haveSlot := e.Slots[out[i].Band][checkMode] _, haveSlot := e.Slots[out[i].Band][checkMode]
out[i].Status = spotEntityStatus(true, haveBand, haveMode, haveSlot, out[i].Mode) out[i].Status = spotEntityStatus(true, haveBand, haveMode, haveSlot, out[i].Mode)
// A need judged against the confirmed ledger that the all-QSO ledger
// says was already worked is a missing QSL, and its badge should read
// as one. Judged at the SAME grain as the status itself.
if idx.chaseUnconf && out[i].Status != "" {
if eAll, ok := entities[dxccNum]; ok {
_, bAll := eAll.Bands[out[i].Band]
_, mAll := eAll.Modes[checkMode]
_, sAll := eAll.Slots[out[i].Band][checkMode]
if spotEntityStatus(true, bAll, mAll, sAll, out[i].Mode) == "" {
out[i].UnconfStatus = true
}
}
}
} }
return out return out
} }
+155
View File
@@ -0,0 +1,155 @@
package main
// Log-aware colours in WSJT-X / JTDX's own Band Activity window (message 13),
// the way JTAlert paints them: a decode of a watchlist member, a new DXCC or a
// new band for its entity is highlighted where the operator is actually
// looking. The verdicts come from the same cluster status cache that colours
// the spot grid, so the two windows can never disagree.
import (
"strings"
"hamlog/internal/applog"
"hamlog/internal/dxcc"
udp "hamlog/internal/integrations/udp"
)
const (
keyWsjtHighlight = "udp.wsjt.highlight"
keyWsjtFollowMode = "udp.wsjt.followmode" // spot clicks switch the decoder's mode
)
// wsjtModes are the modes a Configure message can meaningfully ask for — the
// decoder's own vocabulary. Anything else (CW, SSB, RTTY) is none of its
// business and is not sent.
var wsjtModes = map[string]bool{
"FT8": true, "FT4": true, "JT65": true, "JT9": true,
"MSK144": true, "Q65": true, "FST4": true, "JS8": false, // JS8Call speaks another protocol
}
// GetWsjtFollowMode reports whether spot clicks retune the decoder's mode.
func (a *App) GetWsjtFollowMode() bool {
return a.settingOr(keyWsjtFollowMode, "1") == "1"
}
// SetWsjtFollowMode flips it.
func (a *App) SetWsjtFollowMode(on bool) {
v := "0"
if on {
v = "1"
}
a.setSetting(keyWsjtFollowMode, v)
}
// ConfigureDecoderMode asks the connected decoders to switch mode — called by
// the frontend after a spot click has tuned the radio. A no-op for modes the
// decoder does not speak, and when the option is off or nothing is connected.
func (a *App) ConfigureDecoderMode(mode string) {
mode = strings.ToUpper(strings.TrimSpace(mode))
if a.udp == nil || !wsjtModes[mode] || !a.GetWsjtFollowMode() {
return
}
a.udp.SendConfigureMode(mode)
}
// The palette. Fixed colours, not theme tokens — they are painted into another
// application's window, which has no idea what theme OpsLog wears.
var (
hlWatchlist = udp.RGB{R: 244, G: 114, B: 182} // the watchlist pink
hlNewDXCC = udp.RGB{R: 22, G: 130, B: 60} // green
hlNewBand = udp.RGB{R: 226, G: 122, B: 24} // orange
hlWhite = udp.RGB{R: 255, G: 255, B: 255}
hlBlack = udp.RGB{R: 20, G: 20, B: 20}
)
// GetWsjtHighlight reports whether decode highlighting is on.
func (a *App) GetWsjtHighlight() bool {
return a.settingOr(keyWsjtHighlight, "0") == "1"
}
// SetWsjtHighlight turns decode highlighting on or off. Turning it OFF also
// clears every instruction OpsLog installed in the running applications — a
// disabled option that leaves stale colours behind looks broken, not disabled.
func (a *App) SetWsjtHighlight(on bool) {
v := "0"
if on {
v = "1"
}
a.setSetting(keyWsjtHighlight, v)
a.wsjtHighlightOn.Store(on)
if !on && a.udp != nil {
for _, inst := range a.udp.Instances() {
_ = a.udp.SendClearHighlights(inst)
}
a.wsjtHLMu.Lock()
a.wsjtHLSent = map[string]string{}
a.wsjtHLMu.Unlock()
applog.Printf("wsjt highlight: off — cleared in every instance")
}
}
// maybeHighlightDecode paints one decoded callsign in the instance that heard
// it, when the option is on and the verdict is worth a colour. De-duplicated
// per instance+call+verdict: a station CQing all evening is decoded four times
// a minute, and the instruction only needs to be said once.
func (a *App) maybeHighlightDecode(instance, call, band string) {
if !a.wsjtHighlightOn.Load() || a.udp == nil || call == "" || instance == "" {
return
}
bg, fg, verdict := a.decodeHighlightVerdict(call, band)
key := instance + "|" + strings.ToUpper(call) + "|" + band
a.wsjtHLMu.Lock()
if a.wsjtHLSent == nil {
a.wsjtHLSent = map[string]string{}
}
if len(a.wsjtHLSent) > 4000 { // bounded; a long session just re-says a few
a.wsjtHLSent = map[string]string{}
}
prev, had := a.wsjtHLSent[key]
if had && prev == verdict {
a.wsjtHLMu.Unlock()
return
}
a.wsjtHLSent[key] = verdict
a.wsjtHLMu.Unlock()
if verdict == "" {
// Was highlighted under an earlier verdict and no longer deserves it
// (the operator just worked them): clear that one callsign.
if had && prev != "" {
_ = a.udp.SendHighlight(instance, call, nil, nil, false)
}
return
}
_ = a.udp.SendHighlight(instance, call, bg, fg, false)
}
// decodeHighlightVerdict ranks a callsign: watchlist beats new-DXCC beats
// new-band; anything else is "no colour". The empty verdict doubles as the
// clear signal in maybeHighlightDecode.
func (a *App) decodeHighlightVerdict(call, band string) (bg, fg *udp.RGB, verdict string) {
if a.watchlist != nil {
if _, ok := a.watchlist.Match(call); ok {
c := hlWatchlist
f := hlBlack
return &c, &f, "watchlist"
}
}
c := a.clusterStatusMaps()
if a.dxcc != nil {
if m, ok := a.dxcc.Lookup(call); ok && m.Entity != nil {
num := dxcc.EntityDXCC(m.Entity.Name)
ent := c.entities[num]
if ent == nil {
bgc, fgc := hlNewDXCC, hlWhite
return &bgc, &fgc, "new-dxcc"
}
if band != "" {
if _, workedBand := ent.Bands[strings.ToLower(band)]; !workedBand {
bgc, fgc := hlNewBand, hlBlack
return &bgc, &fgc, "new-band"
}
}
}
}
return nil, nil, ""
}
+6
View File
@@ -33,6 +33,12 @@ func TestBulkEditFieldsAreWritable(t *testing.T) {
id := m[1] id := m[1]
// Handled before the column map: freq takes a numeric path (freq_hz + // Handled before the column map: freq takes a numeric path (freq_hz +
// band together) and the extras live in extras_json. // band together) and the extras live in extras_json.
// The integer My-station fields take their own numeric path
// (BulkSetIntField + bulkEditableIntCols in the repo), added after this
// guard was written — which is exactly the drift it exists to catch.
if id == "my_dxcc" || id == "my_cq_zone" || id == "my_itu_zone" {
continue
}
if id == "freq" || qso.IsBulkEditableExtra(id) { if id == "freq" || qso.IsBulkEditableExtra(id) {
continue continue
} }
+54
View File
@@ -1,4 +1,58 @@
[ [
{
"version": "0.27.4",
"date": "",
"en": [
"FT decodes: a State column between the locator and the country — the two-letter badge plus the full name — for the WAS chasers.",
"FT decodes: a NEW STATE badge and filter — a US state never worked lights up in the status column, and the filter chip shows only those. The WAS chase is complete.",
"The hunt goes global: a “Chase” setting (DX Cluster page) decides for EVERY category — DXCC, band, mode, slot, prefix, county, state, grid — whether “worked but never confirmed” still counts as something to chase, judged against the confirmation sources you pick (LoTW, QSL card, eQSL, QRZ.com). Such needs show as dimmed badges: a QSL to chase, not a QSO to make. The grids own Chase selector folds into it."
],
"fr": [
"FT decodes : une colonne État entre le locator et le pays — le badge deux lettres plus le nom complet — pour les chasseurs de WAS.",
"FT decodes : un badge et un filtre NOUVEL ÉTAT — un état US jamais contacté sallume dans la colonne statut, et la puce de filtre ne montre que ceux-là. La chasse WAS est complète.",
"La chasse devient globale : un réglage « Chasse » (page DX Cluster) décide pour TOUTES les catégories — DXCC, bande, mode, slot, préfixe, comté, état, grille — si « contacté mais jamais confirmé » reste à chasser, jugé selon les sources de confirmation choisies (LoTW, carte QSL, eQSL, QRZ.com). Ces besoins saffichent en badges atténués : une QSL à chasser, pas un QSO à faire. Le sélecteur Chasse des grilles fusionne dedans."
]
},
{
"version": "0.27.3",
"date": "",
"en": [
"KPA500: the amplifier no longer switches itself off and commands respond instantly. A command this model does not know (the KPA1500s ATU poll) was tearing the link down every cycle, and each reconnect toggled the serial control lines — which are the KPA500s power switch. The lines are now held steady, silence is not treated as a dead link, and the baud is picked from a list.",
"FT decodes: within a period, decodes are listed in arrival order — mirroring the decoders own window — instead of strongest-first.",
"Voice keyer: twelve message slots (F1F12) instead of six.",
"Preferences open smoothly on a busy station: while the dialog is open, cluster spots, FT decodes and CAT snapshots queue quietly instead of repainting the whole window behind it — everything catches up the moment it closes.",
"Voice keyer: a delete button per message — removes the recording and clears the label."
],
"fr": [
"KPA500 : lampli ne s’éteint plus tout seul et les commandes répondent instantanément. Une commande inconnue de ce modèle (le poll ATU du KPA1500) détruisait le lien à chaque cycle, et chaque reconnexion basculait les lignes de contrôle série — qui sont linterrupteur du KPA500. Les lignes sont désormais tenues stables, le silence nest plus traité comme un lien mort, et le baud se choisit dans une liste.",
"FT decodes : dans une période, les décodages sont listés dans lordre darrivée — comme la fenêtre du décodeur — au lieu du plus fort dabord.",
"Manipulateur vocal : douze messages (F1F12) au lieu de six.",
"Les Préférences restent fluides sur une station chargée : dialogue ouvert, les spots cluster, les décodages FT et les instantanés CAT patientent en file au lieu de repeindre toute la fenêtre derrière — tout se rattrape à la fermeture.",
"Manipulateur vocal : un bouton supprimer par message — efface lenregistrement et le libellé."
]
},
{
"version": "0.27.2",
"date": "",
"en": [
"Bulk operations work on any size of selection — setting a field, fixing frequencies, deleting, marking uploads and exporting the selection all failed with “too many SQL variables” past a few tens of thousands of QSOs. Statements are now issued in slices.",
"Elecraft console: the power meter reads in real watts. The K3s bargraph is relative to a range that flips at 12 W — calibrated against a real radios full table, the PC setting picks the range and the bar converts to watts.",
"Watchlist: a visual pass toward DXHunters look — pink callsigns, counter pills, quieter cards with a hover, the ⚡ back on the DXpedition badge.",
"WSJT-X / JTDX: OpsLog can highlight decodes in the decoders own Band Activity window from your log — watchlist members pink, new DXCC green, new band orange (option in Settings → Connections). And a freshly-started decoder is asked to replay its on-screen decodes, so the FT decodes panel starts full.",
"WSJT-X / JTDX / MSHV: only a CHANGED DX Call updates the entry — the decoder re-broadcasts the same call endlessly, and it kept overwriting a spot clicked in OpsLog.",
"Map: Zoom DX toward a polar entity no longer frames a band of blank white above the top of the world — the camera stays within the maps ±85°, the path still draws.",
"WSJT-X / JTDX / MSHV: clicking a spot in a digital mode the decoder speaks (FT8, FT4, JT65…) switches the decoders mode too — option in Settings → Connections, on by default."
],
"fr": [
"Les opérations groupées fonctionnent quelle que soit la taille de la sélection — définir un champ, corriger des fréquences, supprimer, marquer les uploads et exporter la sélection échouaient avec « too many SQL variables » au-delà de quelques dizaines de milliers de QSO. Les requêtes sont désormais émises par tranches.",
"Console Elecraft : le wattmètre lit en vrais watts. Le bargraph du K3 est relatif à une gamme qui bascule à 12 W — calibré sur la table complète dune vraie radio, le réglage PC choisit la gamme et la barre se convertit en watts.",
"Watchlist : une passe visuelle vers le look DXHunter — indicatifs roses, compteurs en pastilles, cartes plus feutrées avec survol, le ⚡ de retour sur le badge DXpedition.",
"WSJT-X / JTDX : OpsLog peut surligner les décodages dans la fenêtre Band Activity du décodeur selon votre log — watchlist en rose, nouveau DXCC en vert, nouvelle bande en orange (option dans Réglages → Connections). Et un décodeur fraîchement détecté rejoue ses décodages à l’écran, donc le panneau FT decodes démarre plein.",
"WSJT-X / JTDX / MSHV : seul un DX Call qui CHANGE met à jour la saisie — le décodeur rediffuse le même call sans fin, et il écrasait un spot cliqué dans OpsLog.",
"Carte : Zoom DX vers une entité polaire ne cadre plus une bande blanche au-dessus du haut du monde — la caméra reste dans les ±85° de la carte, le trajet se dessine toujours.",
"WSJT-X / JTDX / MSHV : cliquer un spot dans un mode numérique que le décodeur parle (FT8, FT4, JT65…) change aussi le mode du décodeur — option dans Réglages → Connections, activée par défaut."
]
},
{ {
"version": "0.27.1", "version": "0.27.1",
"date": "", "date": "",
+71 -3
View File
@@ -33,7 +33,7 @@ import {
GetSolarData, GetSolarData,
GetQSORate, GetQSORate,
LoTWUserInfo, LoTWUserInfo,
OperatingDefaultForBand, ActiveRadioMyRig, OperatingDefaultForBand, ActiveRadioMyRig, ConfigureDecoderMode,
LogUDPLoggedADIF, LogUDPLoggedADIF,
ListCountries, ListCountries,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus, GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
@@ -2088,7 +2088,7 @@ export default function App() {
// worked_slot must be carried explicitly like every other field: this map is // worked_slot must be carried explicitly like every other field: this map is
// assembled field by field, so a backend flag that nobody copies here simply // assembled field by field, so a backend flag that nobody copies here simply
// never reaches the panels — silently, since the extra key is just dropped. // never reaches the panels — silently, since the extra key is just dropped.
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; worked_slot?: boolean; new_county?: boolean; county?: string; state?: string; lotw?: boolean; spotter_continent?: string; grid?: string; new_grid?: boolean; new_pota?: boolean; new_pfx?: boolean; pfx?: string }>>({}); const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; worked_slot?: boolean; new_county?: boolean; county?: string; state?: string; lotw?: boolean; spotter_continent?: string; grid?: string; new_grid?: boolean; new_state?: boolean; new_pota?: boolean; unconf_status?: boolean; unconf_pfx?: boolean; unconf_cty?: boolean; unconf_state?: boolean; new_pfx?: boolean; pfx?: string }>>({});
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots // Live mirror of spotStatus so the incoming-spot buffer can tell which slots
// still need resolving without re-subscribing the cluster:spot listener. // still need resolving without re-subscribing the cluster:spot listener.
const spotStatusRef = useRef(spotStatus); const spotStatusRef = useRef(spotStatus);
@@ -2228,6 +2228,15 @@ export default function App() {
const [bulkEditIds, setBulkEditIds] = useState<number[]>([]); const [bulkEditIds, setBulkEditIds] = useState<number[]>([]);
const [bulkEditOpen, setBulkEditOpen] = useState(false); const [bulkEditOpen, setBulkEditOpen] = useState(false);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
// While the Settings dialog is open, the spot/decode flushes and the CAT
// snapshot stream are PAUSED (data keeps accumulating in the pending refs).
// Every flush re-renders the whole App tree behind the dialog — cluster
// grid, decodes panel, thousands of nodes — and with a busy cluster plus
// two decoders the pointer visibly stuttered over the preferences.
const showSettingsRef = useRef(false);
useEffect(() => { showSettingsRef.current = showSettings; }, [showSettings]);
const flushSpotsRef = useRef<() => void>(() => {});
const flushDecodesRef = useRef<() => void>(() => {});
// Re-read the "beam on map" toggle when Preferences closes (it's edited there). // Re-read the "beam on map" toggle when Preferences closes (it's edited there).
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]); useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
useEffect(() => { if (!showSettings) setRotorCompact(localStorage.getItem('opslog.rotorCompact') === '1'); }, [showSettings]); useEffect(() => { if (!showSettings) setRotorCompact(localStorage.getItem('opslog.rotorCompact') === '1'); }, [showSettings]);
@@ -2566,6 +2575,10 @@ export default function App() {
for (const d of decodes) { for (const d of decodes) {
const seenKey = `${d.call}|${d.ms ?? d.at}|${d.instance ?? ''}`; const seenKey = `${d.call}|${d.ms ?? d.at}|${d.instance ?? ''}`;
if (autoSeenRef.current.has(seenKey)) continue; if (autoSeenRef.current.has(seenKey)) continue;
// A Replay's resent history is display-only: answering a line the far
// end already dropped would fail anyway, and doing it at startup — the
// moment replays arrive — would be a transmitter firing on old news.
if ((d as any).is_new === false) { autoSeenRef.current.add(seenKey); continue; }
// Only decodes from the CURRENT period are worth answering: replying to a // Only decodes from the CURRENT period are worth answering: replying to a
// slot that has closed asks the far end to match a decode it has dropped. // slot that has closed asks the far end to match a decode it has dropped.
if (now - Date.parse(d.at) > 30_000) { autoSeenRef.current.add(seenKey); continue; } if (now - Date.parse(d.at) > 30_000) { autoSeenRef.current.add(seenKey); continue; }
@@ -2663,6 +2676,11 @@ export default function App() {
// "the field still shows the previous broadcast" (safe to update) from "the // "the field still shows the previous broadcast" (safe to update) from "the
// user has typed a different call" (must not clobber). // user has typed a different call" (must not clobber).
const lastUdpCallRef = useRef(''); const lastUdpCallRef = useRef('');
// Edge detection for the DECODER'S stream: WSJT-X/JTDX/MSHV re-broadcast the
// same DX Call in every Status packet, seconds apart, forever. Applying each
// one meant a spot clicked in OpsLog was overwritten moments later by the
// decoder restating old news. Only a CHANGE in this stream is an event.
const lastWsjtEdgeRef = useRef('');
// When the entered callsign turns out to be worked-before, jump to the // When the entered callsign turns out to be worked-before, jump to the
// Worked-before tab so the history is front-and-centre. Only once per call, // Worked-before tab so the history is front-and-centre. Only once per call,
@@ -3338,6 +3356,10 @@ export default function App() {
void tuneRigCAT(s.freq_hz, m).then(() => window.setTimeout(zoom, 300)); void tuneRigCAT(s.freq_hz, m).then(() => window.setTimeout(zoom, 300));
} else zoom(); } else zoom();
if (m) applyModeFromSpot(m); if (m) applyModeFromSpot(m);
// And the DECODER follows too: an FT4 spot clicked while WSJT-X sits in
// FT8 switches its mode (Configure, message 15). The backend filters —
// only modes the decoder speaks, only when the option is on.
if (m) ConfigureDecoderMode(m).catch(() => {});
onCallsignInput(s.dx_call, { force: true }); onCallsignInput(s.dx_call, { force: true });
applySpotRefs((s as any).pota_ref, (s as any).sota_ref); applySpotRefs((s as any).pota_ref, (s as any).sota_ref);
if (s.dx_call?.trim()) restartRecordingForNewTarget(s.dx_call); if (s.dx_call?.trim()) restartRecordingForNewTarget(s.dx_call);
@@ -3422,7 +3444,15 @@ export default function App() {
// Apply a CAT snapshot to the entry strip (freq/band/mode), unless the user // Apply a CAT snapshot to the entry strip (freq/band/mode), unless the user
// just typed something (freeze window) or locked a field. Shared by the live // just typed something (freeze window) or locked a field. Shared by the live
// cat:state event and the startup poll below. // cat:state event and the startup poll below.
const lastCatWhileSettingsRef = useRef(0);
function applyCatState(s: CATState) { function applyCatState(s: CATState) {
// Behind the Settings dialog nobody reads a frequency four times a second;
// each snapshot re-renders the whole App tree under the pointer.
if (showSettingsRef.current) {
const now = Date.now();
if (now - lastCatWhileSettingsRef.current < 2000) return;
lastCatWhileSettingsRef.current = now;
}
setCatState(s); setCatState(s);
if (!s?.connected) return; if (!s?.connected) return;
// A snapshot arriving during the freeze used to be DROPPED, and that lost the // A snapshot arriving during the freeze used to be DROPPED, and that lost the
@@ -3567,11 +3597,19 @@ export default function App() {
// Commit the staged spots: resolve the status for any slot we don't know yet // Commit the staged spots: resolve the status for any slot we don't know yet
// FIRST, then insert the rows — so they appear with the right badge already // FIRST, then insert the rows — so they appear with the right badge already
// painted instead of flashing plain text then flipping to a pill. // painted instead of flashing plain text then flipping to a pill.
// eslint-disable-next-line prefer-const
const flushPendingSpots = async () => { const flushPendingSpots = async () => {
pendingSpotTimer.current = undefined; pendingSpotTimer.current = undefined;
// Settings open: leave everything queued (bounded) and repaint nothing.
if (showSettingsRef.current) {
const cap = spotsCapRef.current;
if (pendingSpotsRef.current.length > cap) pendingSpotsRef.current = pendingSpotsRef.current.slice(-cap);
return;
}
const batch = pendingSpotsRef.current; const batch = pendingSpotsRef.current;
pendingSpotsRef.current = []; pendingSpotsRef.current = [];
if (batch.length === 0) return; if (batch.length === 0) return;
// (registered below so closing Settings can drain the queue)
// Resolve unknown statuses before the rows go in. // Resolve unknown statuses before the rows go in.
try { try {
const known = spotStatusRef.current; const known = spotStatusRef.current;
@@ -3601,6 +3639,11 @@ export default function App() {
worked_call: !!(r as any).worked_call, worked_call: !!(r as any).worked_call,
worked_slot: !!(r as any).worked_slot, worked_slot: !!(r as any).worked_slot,
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state, new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state,
new_state: !!(r as any).new_state,
unconf_status: !!(r as any).unconf_status,
unconf_pfx: !!(r as any).unconf_pfx,
unconf_cty: !!(r as any).unconf_cty,
unconf_state: !!(r as any).unconf_state,
new_pota: !!(r as any).new_pota, new_pota: !!(r as any).new_pota,
new_pfx: !!(r as any).new_pfx, new_pfx: !!(r as any).new_pfx,
pfx: (r as any).pfx, pfx: (r as any).pfx,
@@ -3627,6 +3670,7 @@ export default function App() {
return next.length > cap ? next.slice(0, cap) : next; return next.length > cap ? next.slice(0, cap) : next;
}); });
}; };
flushSpotsRef.current = () => { void flushPendingSpots(); };
const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => { const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
// Stage the spot; a short timer resolves its status then commits it. // Stage the spot; a short timer resolves its status then commits it.
pendingSpotsRef.current.push(sp); pendingSpotsRef.current.push(sp);
@@ -3674,6 +3718,10 @@ export default function App() {
// decodes panel and plain worked in the cluster list two seconds later. // decodes panel and plain worked in the cluster list two seconds later.
const flushDecodes = async () => { const flushDecodes = async () => {
pendingDecodeTimer.current = undefined; pendingDecodeTimer.current = undefined;
if (showSettingsRef.current) {
if (pendingDecodesRef.current.length > 3000) pendingDecodesRef.current = pendingDecodesRef.current.slice(-3000);
return;
}
const batch = pendingDecodesRef.current; const batch = pendingDecodesRef.current;
pendingDecodesRef.current = []; pendingDecodesRef.current = [];
if (batch.length === 0) return; if (batch.length === 0) return;
@@ -3702,6 +3750,11 @@ export default function App() {
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, new_county: !!(r as any).new_county, lotw: !!(r as any).lotw,
grid: (r as any).grid, new_grid: !!(r as any).new_grid, grid: (r as any).grid, new_grid: !!(r as any).new_grid,
county: (r as any).county, state: (r as any).state, county: (r as any).county, state: (r as any).state,
new_state: !!(r as any).new_state,
unconf_status: !!(r as any).unconf_status,
unconf_pfx: !!(r as any).unconf_pfx,
unconf_cty: !!(r as any).unconf_cty,
unconf_state: !!(r as any).unconf_state,
new_pota: !!(r as any).new_pota, new_pota: !!(r as any).new_pota,
new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx, new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx,
}; };
@@ -3716,6 +3769,7 @@ export default function App() {
return next; return next;
}); });
}; };
flushDecodesRef.current = () => { void flushDecodes(); };
const unsubDecode = EventsOn('udp:decode', (d: DecodeRow) => { const unsubDecode = EventsOn('udp:decode', (d: DecodeRow) => {
pendingDecodesRef.current.push(d); pendingDecodesRef.current.push(d);
@@ -3792,6 +3846,13 @@ export default function App() {
// Anything that isn't WSJT-X (N1MM, ADIF, a panadapter/cluster click relayed // Anything that isn't WSJT-X (N1MM, ADIF, a panadapter/cluster click relayed
// over UDP…) is an explicit pick → force it over an existing call. // over UDP…) is an explicit pick → force it over an existing call.
const force = String(p?.service ?? '').toLowerCase() !== 'wsjt'; const force = String(p?.service ?? '').toLowerCase() !== 'wsjt';
if (!force) {
// The decoder's stream: same value as last time = no edge = no update.
// Only a changed DX Call is the operator doing something over there.
const upper = String(p?.call ?? '').trim().toUpperCase();
if (upper && upper === lastWsjtEdgeRef.current) return;
lastWsjtEdgeRef.current = upper;
}
// External app moved to a new station → fresh recording for the new target. // External app moved to a new station → fresh recording for the new target.
if (applyUdpCall(p?.call, force)) restartRecordingForNewTarget(String(p?.call ?? '')); if (applyUdpCall(p?.call, force)) restartRecordingForNewTarget(String(p?.call ?? ''));
}); });
@@ -3802,6 +3863,9 @@ export default function App() {
// Only when something is actually in the entry, so an idle digital app doesn't // Only when something is actually in the entry, so an idle digital app doesn't
// wipe a call being typed by hand. // wipe a call being typed by hand.
const unsubClear = EventsOn('udp:clear_call', () => { const unsubClear = EventsOn('udp:clear_call', () => {
// The decoder cleared its DX Call: the next call it announces — even the
// same one re-selected — is a fresh edge.
lastWsjtEdgeRef.current = '';
if (callsignRef.current?.value?.trim() || callsign.trim()) resetEntry(); if (callsignRef.current?.value?.trim() || callsign.trim()) resetEntry();
}); });
// Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call // Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call
@@ -4352,6 +4416,10 @@ export default function App() {
// The stable wrapper the dialog actually receives. // The stable wrapper the dialog actually receives.
const openQSOFromSettings = useCallback((id: number) => openEditRef.current(id), []); const openQSOFromSettings = useCallback((id: number) => openEditRef.current(id), []);
const closeSettings = useCallback(() => { const closeSettings = useCallback(() => {
// Synchronously: the ref effect runs after the next render, and flushing
// through a still-true ref would hit the pause gate again.
showSettingsRef.current = false;
window.setTimeout(() => { flushSpotsRef.current(); flushDecodesRef.current(); }, 50);
setShowSettings(false); setShowSettings(false);
setSettingsSection(undefined); setSettingsSection(undefined);
refreshChaseNew(); refreshChaseNew();
@@ -5180,7 +5248,7 @@ export default function App() {
if (dvkActiveRef.current) { if (dvkActiveRef.current) {
// Voice keyer: plain F1..F6 transmit the message; Ctrl+F1..F5 → tabs. // Voice keyer: plain F1..F6 transmit the message; Ctrl+F1..F5 → tabs.
if (mod && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; } if (mod && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; }
if (plain && n <= 6) { e.preventDefault(); dvkPlayRef.current(n); return; } if (plain && n <= 12) { e.preventDefault(); dvkPlayRef.current(n); return; }
return; return;
} }
// No keyer: plain F1..F5 switch the detail tab (labels read "F1…"). // No keyer: plain F1..F5 switch the detail tab (labels read "F1…").
+8 -3
View File
@@ -65,6 +65,9 @@ export type SpotStatusEntry = {
state?: string; state?: string;
new_pota?: boolean; new_pota?: boolean;
new_pfx?: boolean; new_pfx?: boolean;
unconf_status?: boolean;
unconf_pfx?: boolean;
unconf_cty?: boolean;
pfx?: string; pfx?: string;
// lotw: the DX uploads to LoTW, per ARRL user list. Inert until downloaded. // lotw: the DX uploads to LoTW, per ARRL user list. Inert until downloaded.
lotw?: boolean; lotw?: boolean;
@@ -304,13 +307,15 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
: s?.status === 'new-slot' ? t('clg2.newSlot') : s?.status === 'new-slot' ? t('clg2.newSlot')
: s?.status === 'new-call' ? t('clg2.newCall') : s?.status === 'new-call' ? t('clg2.newCall')
: t('clg2.wkdCall'); : t('clg2.wkdCall');
parts.push({ text: label, color: main }); // Dimmed when the need is only a missing confirmation — the grid's
// own convention, now spoken by every category.
parts.push(s?.unconf_status ? { text: label, color: main, dim: true } : { text: label, color: main });
} }
// Colours from lib/spotMarkers — shared with the band map so a marker is // Colours from lib/spotMarkers — shared with the band map so a marker is
// never one colour here and another there. // never one colour here and another there.
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county') }); if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county'), dim: !!s?.unconf_cty });
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: markerColour('new_pota') }); if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: markerColour('new_pota') });
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx') }); if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx'), dim: !!s?.unconf_pfx });
// Worked-but-unconfirmed is a QSL to chase, not a QSO to make. Same hue // Worked-but-unconfirmed is a QSL to chase, not a QSO to make. Same hue
// held back, so it reads as "less" of the same thing rather than a // held back, so it reads as "less" of the same thing rather than a
// different fact — and the label says which. // different fact — and the label says which.
+49 -8
View File
@@ -72,8 +72,14 @@ type StatusEntry = {
new_pota?: boolean; new_pota?: boolean;
new_pfx?: boolean; new_pfx?: boolean;
new_grid?: boolean; new_grid?: boolean;
new_state?: boolean;
unconf_status?: boolean;
unconf_pfx?: boolean;
unconf_cty?: boolean;
unconf_state?: boolean;
// "new" = never worked, "unconf" = worked and awaiting a confirmation. // "new" = never worked, "unconf" = worked and awaiting a confirmation.
grid_state?: string; grid_state?: string;
state?: string;
grid?: string; grid?: string;
lotw?: boolean; lotw?: boolean;
}; };
@@ -112,7 +118,7 @@ interface Props {
// //
// All off means no filtering at all: this is a decode LOG first, and a panel // All off means no filtering at all: this is a decode LOG first, and a panel
// that starts by hiding most of the band would be lying about what is on it. // that starts by hiding most of the band would be lying about what is on it.
type NewCat = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid' | 'pota' | 'cty'; type NewCat = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid' | 'pota' | 'cty' | 'state';
const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [ const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [
{ key: 'dxcc', label: 'dec.stNew', colour: 'var(--success)' }, { key: 'dxcc', label: 'dec.stNew', colour: 'var(--success)' },
@@ -123,6 +129,7 @@ const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [
{ key: 'grid', label: 'dec.bgGrid', colour: markerColour('new_grid') }, { key: 'grid', label: 'dec.bgGrid', colour: markerColour('new_grid') },
{ key: 'pfx', label: 'dec.bgPfx', colour: markerColour('new_pfx') }, { key: 'pfx', label: 'dec.bgPfx', colour: markerColour('new_pfx') },
{ key: 'cty', label: 'dec.bgCounty', colour: markerColour('new_county') }, { key: 'cty', label: 'dec.bgCounty', colour: markerColour('new_county') },
{ key: 'state', label: 'dec.bgState', colour: markerColour('new_state') },
]; ];
// catsOf lists everything a decode is new for. A station can be several at once // catsOf lists everything a decode is new for. A station can be several at once
@@ -143,6 +150,7 @@ function catsOf(e: StatusEntry | undefined): Set<NewCat> {
if (e.new_grid) out.add('grid'); if (e.new_grid) out.add('grid');
if (e.new_pfx) out.add('pfx'); if (e.new_pfx) out.add('pfx');
if (e.new_county) out.add('cty'); if (e.new_county) out.add('cty');
if (e.new_state) out.add('state');
return out; return out;
} }
@@ -227,7 +235,7 @@ const CELL_LAST = 'flex items-center min-w-0 px-2 gap-1 overflow-hidden';
// One declaration per column, in display order: the header, the widths and the // One declaration per column, in display order: the header, the widths and the
// resize handles all read from this, so a column cannot be resized in the header // resize handles all read from this, so a column cannot be resized in the header
// and stay the old width in the body. // and stay the old width in the body.
type ColKey = 'time' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'country' | 'status'; type ColKey = 'time' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'state' | 'country' | 'status';
const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [ const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
{ key: 'time', tkey: 'dec.colTime', def: 64, min: 44 }, { key: 'time', tkey: 'dec.colTime', def: 64, min: 44 },
{ key: 'snr', tkey: 'dec.colSnr', def: 50, min: 36 }, { key: 'snr', tkey: 'dec.colSnr', def: 50, min: 36 },
@@ -241,9 +249,26 @@ const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
// The grid was carried and shown nowhere: it drives the NEW GRID badge, and // The grid was carried and shown nowhere: it drives the NEW GRID badge, and
// an operator chasing squares could see the verdict but never the square. // an operator chasing squares could see the verdict but never the square.
{ key: 'grid', tkey: 'dec.colGrid', def: 62, min: 46 }, { key: 'grid', tkey: 'dec.colGrid', def: 62, min: 46 },
// For the WAS chasers: the badge carries the two letters, the name spells
// them out — "SD" alone is a quiz for a European.
{ key: 'state', tkey: 'dec.colState', def: 120, min: 56 },
{ key: 'country', tkey: 'dec.colCountry', def: 140, min: 70 }, { key: 'country', tkey: 'dec.colCountry', def: 140, min: 70 },
{ key: 'status', tkey: 'dec.colStatus', def: 186, min: 80 }, { key: 'status', tkey: 'dec.colStatus', def: 186, min: 80 },
]; ];
const US_STATES: Record<string, string> = {
AL: 'Alabama', AK: 'Alaska', AZ: 'Arizona', AR: 'Arkansas', CA: 'California',
CO: 'Colorado', CT: 'Connecticut', DE: 'Delaware', FL: 'Florida', GA: 'Georgia',
HI: 'Hawaii', ID: 'Idaho', IL: 'Illinois', IN: 'Indiana', IA: 'Iowa',
KS: 'Kansas', KY: 'Kentucky', LA: 'Louisiana', ME: 'Maine', MD: 'Maryland',
MA: 'Massachusetts', MI: 'Michigan', MN: 'Minnesota', MS: 'Mississippi',
MO: 'Missouri', MT: 'Montana', NE: 'Nebraska', NV: 'Nevada', NH: 'New Hampshire',
NJ: 'New Jersey', NM: 'New Mexico', NY: 'New York', NC: 'North Carolina',
ND: 'North Dakota', OH: 'Ohio', OK: 'Oklahoma', OR: 'Oregon', PA: 'Pennsylvania',
RI: 'Rhode Island', SC: 'South Carolina', SD: 'South Dakota', TN: 'Tennessee',
TX: 'Texas', UT: 'Utah', VT: 'Vermont', VA: 'Virginia', WA: 'Washington',
WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming', DC: 'District of Columbia',
};
const COL_MAX = 600; const COL_MAX = 600;
const COLW_KEY = 'opslog.decodeColWidths'; const COLW_KEY = 'opslog.decodeColWidths';
@@ -343,6 +368,7 @@ function entityBadgesFor(status: string): { label: string; cls: string }[] {
const EXTRA_BADGES: { key: keyof StatusEntry; marker: SpotMarkerKey; label: string }[] = [ const EXTRA_BADGES: { key: keyof StatusEntry; marker: SpotMarkerKey; label: string }[] = [
{ key: 'new_pota', marker: 'new_pota', label: 'dec.bgPota' }, { key: 'new_pota', marker: 'new_pota', label: 'dec.bgPota' },
{ key: 'new_grid', marker: 'new_grid', label: 'dec.bgGrid' }, { key: 'new_grid', marker: 'new_grid', label: 'dec.bgGrid' },
{ key: 'new_state', marker: 'new_state', label: 'dec.bgState' },
{ key: 'new_pfx', marker: 'new_pfx', label: 'dec.bgPfx' }, { key: 'new_pfx', marker: 'new_pfx', label: 'dec.bgPfx' },
{ key: 'new_county', marker: 'new_county', label: 'dec.bgCounty' }, { key: 'new_county', marker: 'new_county', label: 'dec.bgCounty' },
]; ];
@@ -498,10 +524,11 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
// the same way it was grouped. // the same way it was grouped.
tr: trSeconds(g.decodes[0]?.mode ?? g.tx[0]?.mode, g.decodes[0]?.tr_period), tr: trSeconds(g.decodes[0]?.mode ?? g.tx[0]?.mode, g.decodes[0]?.tr_period),
tx: g.tx, tx: g.tx,
// Strongest first inside a period: the eye should land on what is // ARRIVAL order inside a period, per the operator: it mirrors the
// workable, and time within a slot means nothing — they were all // decoder's own window line for line, which makes the two screens
// transmitting simultaneously. // comparable at a glance — the strongest-first sort scrambled that
decodes: g.decodes.sort((x, y) => y.snr - x.snr), // correspondence, and SNR is right there in its column anyway.
decodes: g.decodes,
})); }));
} }
@@ -1096,6 +1123,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
<span className="truncate">{d.grid || e?.grid || ''}</span> <span className="truncate">{d.grid || e?.grid || ''}</span>
</span> </span>
<span className={cn(CELL, 'gap-1.5')}>
{e?.state && (
<>
<span className="rounded px-1.5 py-px text-[11px] font-bold bg-info/15 text-info border border-info/40 shrink-0">{e.state}</span>
<span className="text-[11px] text-muted-foreground truncate">{US_STATES[e.state.toUpperCase()] ?? ''}</span>
</>
)}
</span>
<span className={cn(CELL, 'text-[11px] text-muted-foreground')}> <span className={cn(CELL, 'text-[11px] text-muted-foreground')}>
<span className="truncate">{e?.country ?? ''}</span> <span className="truncate">{e?.country ?? ''}</span>
</span> </span>
@@ -1113,7 +1149,9 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
</span> </span>
)} )}
{entities.map((b) => ( {entities.map((b) => (
<span key={b.label} className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0', b.cls)}> <span key={b.label}
title={e?.unconf_status ? t('dec.unconfTip') : undefined}
className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0', b.cls, e?.unconf_status && 'opacity-50')}>
{t(b.label)} {t(b.label)}
</span> </span>
))} ))}
@@ -1123,7 +1161,10 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
// Same hue, held back — the badge reads as "less" of the // Same hue, held back — the badge reads as "less" of the
// same thing, and its label says which. One badge for both // same thing, and its label says which. One badge for both
// is what made a station worked an hour ago read as new. // is what made a station worked an hour ago read as new.
const unconf = b.key === 'new_grid' && e?.grid_state === 'unconf'; const unconf = (b.key === 'new_grid' && e?.grid_state === 'unconf')
|| (b.key === 'new_state' && !!e?.unconf_state)
|| (b.key === 'new_county' && !!e?.unconf_cty)
|| (b.key === 'new_pfx' && !!e?.unconf_pfx);
const c = markerColour(b.marker); const c = markerColour(b.marker);
return ( return (
<span key={b.key as string} <span key={b.key as string}
+1 -1
View File
@@ -19,7 +19,7 @@ type Props = {
phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked
}; };
// Operating panel for the Digital Voice Keyer — transmits the recorded F1F6 // Operating panel for the Digital Voice Keyer — transmits the recorded F1F12
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in // voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
// the reserved area. Recording/labeling lives in Settings → Audio. // the reserved area. Recording/labeling lives in Settings → Audio.
export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) { export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) {
+3 -2
View File
@@ -18,7 +18,7 @@ type KenwoodState = {
available: boolean; model?: string; elecraft: boolean; mode?: string; data_sub?: string; available: boolean; model?: string; elecraft: boolean; mode?: string; data_sub?: string;
transmitting: boolean; split: boolean; split_tx_hz?: number; transmitting: boolean; split: boolean; split_tx_hz?: number;
s_meter: number; s_meter_raw: number; s_meter: number; s_meter_raw: number;
power_meter: number; swr: number; swr_raw: number; power_meter: number; power_w?: number; swr: number; swr_raw: number;
rf_power: number; af_gain: number; rf_gain: number; mic_gain: number; squelch: number; rf_power: number; af_gain: number; rf_gain: number; mic_gain: number; squelch: number;
preamp: boolean; att: boolean; nb: boolean; nr: boolean; agc?: string; preamp: boolean; att: boolean; nb: boolean; nr: boolean; agc?: string;
filter_hz: number; antenna: number; rit: boolean; xit: boolean; rit_offset: number; key_speed: number; filter_hz: number; antenna: number; rit: boolean; xit: boolean; rit_offset: number; key_speed: number;
@@ -222,7 +222,8 @@ export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) =>
onReportRST(sMeterRST(sp.s, sp.over, view.mode)); onReportRST(sMeterRST(sp.s, sp.over, view.mode));
}} }}
title={t('k3.sMeterHint', { raw: String(view.s_meter_raw) })} /> title={t('k3.sMeterHint', { raw: String(view.s_meter_raw) })} />
<MeterBar label="PWR" value={view.transmitting ? view.power_meter : 0} lo={0} hi={100} accent="#0ea5e9" /> <MeterBar label="PWR" value={view.transmitting ? view.power_meter : 0} lo={0} hi={100} accent="#0ea5e9"
display={view.transmitting && view.elecraft ? `${view.power_w ?? 0} W` : undefined} />
{/* 0 means "not measured", and it must not render as a perfect 1.0: {/* 0 means "not measured", and it must not render as a perfect 1.0:
a match that looks ideal on an antenna nobody has measured is the one a match that looks ideal on an antenna nobody has measured is the one
reading that can cost a radio. */} reading that can cost a radio. */}
+8 -2
View File
@@ -370,8 +370,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
if (autoZoom) { if (autoZoom) {
if (from && to && arcPts) { if (from && to && arcPts) {
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]); // Latitudes clamped to Mercator's edge (±85°): the arc to a polar
arcPts.forEach((p) => bounds.extend(p as L.LatLngExpression)); // entity (Franz Josef Land) peaks near 88°N, and fitting the raw
// points framed a band of tile-less white above the top of the world.
// The line itself still draws to wherever it goes — only the CAMERA
// stays where there is a map to show.
const clamp = (lat: number) => Math.max(-85, Math.min(85, lat));
const bounds = L.latLngBounds([[clamp(from.lat), from.lon], [clamp(to.lat), to.lon]]);
arcPts.forEach((p) => bounds.extend([clamp(p[0]), p[1]] as L.LatLngExpression));
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 }); wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
} else if (to) { } else if (to) {
wm.setView([to.lat, to.lon], 3); wm.setView([to.lat, to.lon], 3);
+58 -17
View File
@@ -9,7 +9,8 @@ import {
import { import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider, GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
GetListsSettings, SaveListsSettings, GetListsSettings, SaveListsSettings,
GetCATSettings, SaveCATSettings, GetRadios, SaveRadios, SetActiveRadio, ActiveRadioID, DiscoverFlexRadios, GetCATSettings, SaveCATSettings, GetRadios, SaveRadios, SetActiveRadio, ActiveRadioID, DiscoverFlexRadios, DVKDelete,
GetChaseSettings, SaveChaseSettings,
GetAudioMonitorPref, GetAudioMonitorPref,
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile, ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop, GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
@@ -1718,6 +1719,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// DVK voice-keyer messages (F1F6). // DVK voice-keyer messages (F1F6).
type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number }; type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
type DVKStat = { recording: boolean; playing: boolean; rec_slot: number }; type DVKStat = { recording: boolean; playing: boolean; rec_slot: number };
const [chaseCfg, setChaseCfg] = useState<{ mode: string; sources: string[] }>({ mode: 'new', sources: ['lotw', 'card', 'eqsl'] });
useEffect(() => { GetChaseSettings().then((c: any) => setChaseCfg({ mode: c?.mode ?? 'new', sources: c?.sources ?? ['lotw', 'card', 'eqsl'] })).catch(() => {}); }, []);
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]); const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 }); const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
const [dvkErr, setDvkErr] = useState(''); const [dvkErr, setDvkErr] = useState('');
@@ -4416,8 +4419,17 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Baud</Label> <Label>Baud</Label>
<Input type="number" min={1200} value={amp.baud} {/* A list, not a free number: the KPA500 report that
onChange={(e) => patchAmp(i, { baud: parseInt(e.target.value) || 115200 })} className="font-mono" /> began this had its operator wondering whether a typed
baud was the whole problem. These are the rates the
supported amplifiers actually speak. */}
<select value={String(amp.baud)}
onChange={(e) => patchAmp(i, { baud: parseInt(e.target.value) })}
className="h-9 w-full px-2 rounded-md border border-border bg-background text-sm font-mono">
{[4800, 9600, 19200, 38400, 57600, 115200].map((b) => (
<option key={b} value={String(b)}>{b}</option>
))}
</select>
</div> </div>
</div> </div>
) : ( ) : (
@@ -5450,19 +5462,42 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('gsc.hunt')}</span>
<Select value={gridScope.hunt} onValueChange={(v) => saveGridScope({ ...gridScope, hunt: v })}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="new">{t('gsc.huntNew')}</SelectItem>
<SelectItem value="new_unconfirmed">{t('gsc.huntUnconf')}</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
</div> </div>
{/* The GLOBAL hunt: every category (DXCC, band, mode, slot, prefix,
county, state, grid) judged as new-only or new-plus-unconfirmed,
against the confirmation sources the operator trusts. */}
<div className="rounded-lg border border-border p-3 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('chg.mode')}</span>
<Select value={chaseCfg.mode} onValueChange={(v) => { const next = { ...chaseCfg, mode: v }; setChaseCfg(next); void SaveChaseSettings(next as any); }}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="new">{t('gsc.huntNew')}</SelectItem>
<SelectItem value="new_unconfirmed">{t('gsc.huntUnconf')}</SelectItem>
</SelectContent>
</Select>
</div>
{chaseCfg.mode === 'new_unconfirmed' && (
<div className="flex items-center gap-4 flex-wrap pl-2">
<span className="text-xs text-muted-foreground">{t('chg.sources')}</span>
{([['lotw', 'LoTW'], ['card', t('chg.card')], ['eqsl', 'eQSL'], ['qrz', 'QRZ.com']] as const).map(([k, label]) => (
<label key={k} className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox checked={chaseCfg.sources.includes(k)}
onCheckedChange={(c) => {
const sources = c ? [...chaseCfg.sources, k] : chaseCfg.sources.filter((x) => x !== k);
const next = { ...chaseCfg, sources };
setChaseCfg(next); void SaveChaseSettings(next as any);
}} />
{label}
</label>
))}
</div>
)}
</div>
{/* Chase new its own option, NOT nested under grid chasing. Chasing {/* Chase new its own option, NOT nested under grid chasing. Chasing
squares and chasing entities are different wants; they only share squares and chasing entities are different wants; they only share
the PSK Reporter feed, which either one brings up. */} the PSK Reporter feed, which either one brings up. */}
@@ -5716,10 +5751,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
function UDPIntegrationsPanelWrapper() { function UDPIntegrationsPanelWrapper() {
return ( return (
<> <>
<SectionHeader <SectionHeader title={t('sec.udp')} />
title={t('sec.udp')}
hint={t('udp.hint')}
/>
<UDPIntegrationsPanel onError={(m) => setErr(m)} /> <UDPIntegrationsPanel onError={(m) => setErr(m)} />
</> </>
); );
@@ -7172,6 +7204,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
> >
{dvkStat.playing ? t('aud.stop') : t('aud.play')} {dvkStat.playing ? t('aud.stop') : t('aud.play')}
</Button> </Button>
<Button
type="button"
variant="outline" size="sm" className="h-8 w-9 shrink-0 px-0 text-danger hover:text-danger"
title={t('aud.deleteMsg')}
disabled={!m.has_audio || dvkStat.recording || dvkStat.playing}
onClick={() => DVKDelete(m.slot).then(reloadDvk).catch((err) => setDvkErr(String(err?.message ?? err)))}
>
<Trash2 className="size-3.5" />
</Button>
</div> </div>
); );
})} })}
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react'; import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
import { import {
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations, ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
GetWsjtHighlight, SetWsjtHighlight, GetWsjtFollowMode, SetWsjtFollowMode,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -158,6 +159,12 @@ const TRIGGERS = [
type Props = { onError: (msg: string) => void }; type Props = { onError: (msg: string) => void };
export function UDPIntegrationsPanel({ onError }: Props) { export function UDPIntegrationsPanel({ onError }: Props) {
const [highlightOn, setHighlightOn] = useState(false);
const [followMode, setFollowMode] = useState(true);
useEffect(() => {
GetWsjtHighlight().then((v) => setHighlightOn(!!v)).catch(() => {});
GetWsjtFollowMode().then((v) => setFollowMode(!!v)).catch(() => {});
}, []);
const { t } = useI18n(); const { t } = useI18n();
const [items, setItems] = useState<UDPConfig[]>([]); const [items, setItems] = useState<UDPConfig[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -229,10 +236,24 @@ export function UDPIntegrationsPanel({ onError }: Props) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed"> {/* Log-aware colours in WSJT-X / JTDX's own window — lives HERE because
{t('udpp.intro')} this panel is where the WSJT-X link is configured. */}
</div> <label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl">
<Checkbox checked={highlightOn}
onCheckedChange={(c) => { setHighlightOn(!!c); void SetWsjtHighlight(!!c); }} />
<span>
{t('udpp.highlight')}
<span className="block text-[11px] text-muted-foreground">{t('udpp.highlightHint')}</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl">
<Checkbox checked={followMode}
onCheckedChange={(c) => { setFollowMode(!!c); void SetWsjtFollowMode(!!c); }} />
<span>
{t('udpp.followMode')}
<span className="block text-[11px] text-muted-foreground">{t('udpp.followModeHint')}</span>
</span>
</label>
<Section <Section
title={t('udpp.inboundTitle')} title={t('udpp.inboundTitle')}
icon={<ArrowDownToLine className="size-4" />} icon={<ArrowDownToLine className="size-4" />}
+17 -14
View File
@@ -247,15 +247,18 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
<div className="flex flex-col min-h-0 flex-1 gap-2 p-2 w-full max-w-5xl mx-auto"> <div className="flex flex-col min-h-0 flex-1 gap-2 p-2 w-full max-w-5xl mx-auto">
{/* Header: the counters alone, centred — they are the tab's headline. {/* Header: the counters alone, centred — they are the tab's headline.
Everything one INTERACTS with lives on the second row. */} Everything one INTERACTS with lives on the second row. */}
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center justify-center gap-2.5 text-xs text-muted-foreground">
<Eye className="size-4 text-primary shrink-0" /> <Eye className="size-4 text-primary shrink-0" />
<span> <span className="flex items-center gap-1.5">{t('wl.cTotal')}
{t('wl.cTotal')} <b className="text-foreground">{counters.total}</b> <b className="px-2 py-0.5 rounded bg-muted text-foreground">{counters.total}</b></span>
<span className="mx-1.5 opacity-50">|</span> <span className="opacity-40">|</span>
{t('wl.cActive')} <b className="text-info">{counters.active}</b> <span className="flex items-center gap-1.5">{t('wl.cActive')}
<span className="mx-1.5 opacity-50">|</span> <b className="px-2 py-0.5 rounded text-info border border-info/30 bg-info/10">{counters.active}</b></span>
{t('wl.cNeeded')} <b className="text-warning">{counters.needed}</b> <span className="opacity-40">|</span>
</span> <span className="flex items-center gap-1.5">{t('wl.cNeeded')}
<b className={cn('px-2 py-0.5 rounded border', counters.needed > 0
? 'text-warning border-warning/40 bg-warning/10'
: 'text-muted-foreground border-border bg-muted/40')}>{counters.needed}</b></span>
</div> </div>
{/* toolbar */} {/* toolbar */}
<div className="flex items-start justify-between gap-x-3 gap-y-1.5 flex-wrap"> <div className="flex items-start justify-between gap-x-3 gap-y-1.5 flex-wrap">
@@ -328,11 +331,11 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
const list = neededOnly ? all.filter((s) => !workedFor(e, s)) : all; const list = neededOnly ? all.filter((s) => !workedFor(e, s)) : all;
return ( return (
<div key={e.callsign} <div key={e.callsign}
className={cn('rounded-lg border bg-card p-3', className={cn('rounded-lg border bg-card/70 p-3 transition-colors hover:bg-accent/20',
needed > 0 ? 'border-warning/50' : 'border-border', needed > 0 ? 'border-warning/40' : 'border-border/70',
e.isContest && 'border-l-4 border-l-warning')}> e.isContest && 'border-l-4 border-l-warning')}>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-lg font-bold font-mono text-primary">{e.callsign}</span> <span className="text-lg font-bold font-mono" style={{ color: '#f472b6' }}>{e.callsign}</span>
{isOnAir(e) && chip('var(--danger)', t('wl.onAir'), 'animate-pulse')} {isOnAir(e) && chip('var(--danger)', t('wl.onAir'), 'animate-pulse')}
{e.isContest && ( {e.isContest && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border" <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border"
@@ -340,7 +343,7 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
<Trophy className="size-3" /> {t('wl.contest')} <Trophy className="size-3" /> {t('wl.contest')}
</span> </span>
)} )}
{e.isExpedition && chip('var(--chart-5)', t('wl.expedition'))} {e.isExpedition && chip('var(--chart-5)', '⚡ ' + t('wl.expedition'))}
{e.clubLogTotalQSOs > 0 && <span className="text-[11px] text-muted-foreground">{e.clubLogTotalQSOs.toLocaleString()} QSOs{e.clubLogQSOs24h > 0 ? ` · ${e.clubLogQSOs24h}/24h` : ''}</span>} {e.clubLogTotalQSOs > 0 && <span className="text-[11px] text-muted-foreground">{e.clubLogTotalQSOs.toLocaleString()} QSOs{e.clubLogQSOs24h > 0 ? ` · ${e.clubLogQSOs24h}/24h` : ''}</span>}
{e.clubLogHasOQRS && chip('var(--success)', 'OQRS')} {e.clubLogHasOQRS && chip('var(--success)', 'OQRS')}
{e.clubLogLiveStream && ( {e.clubLogLiveStream && (
@@ -385,8 +388,8 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
onClick={() => onSpotSelect?.(s)} onClick={() => onSpotSelect?.(s)}
onDoubleClick={() => onSpotClick?.(s)} onDoubleClick={() => onSpotClick?.(s)}
title={t('wl.spotTip')} title={t('wl.spotTip')}
className={cn('w-full flex items-center gap-2 px-2 py-1.5 rounded text-[11px] bg-muted/40 hover:bg-muted text-left', className={cn('w-full flex items-center gap-2 px-2 py-1.5 rounded text-[11px] bg-muted/25 hover:bg-muted/70 transition-colors text-left',
!done && 'border-l-2 border-warning')}> !done && 'border-l-[3px] border-warning')}>
{done && <span className='font-bold shrink-0 text-success'></span>} {done && <span className='font-bold shrink-0 text-success'></span>}
<span className="font-mono font-bold text-info shrink-0">{s.dx_call}</span> <span className="font-mono font-bold text-info shrink-0">{s.dx_call}</span>
<span className="text-muted-foreground truncate flex-1 min-w-0 max-w-56">{(s as any).country ?? ''}</span> <span className="text-muted-foreground truncate flex-1 min-w-0 max-w-56">{(s as any).country ?? ''}</span>
+14 -14
View File
@@ -176,8 +176,8 @@ const en: Dict = {
'dec.colFreq': 'Freq', 'dec.colFreqTitle': 'Audio offset inside the passband (Hz)', 'dec.colFreq': 'Freq', 'dec.colFreqTitle': 'Audio offset inside the passband (Hz)',
'dec.txNow': 'Transmitting', 'dec.txIdle': 'Transmit', 'dec.working': 'calling', 'dec.toYou': 'to you', 'dec.txNow': 'Transmitting', 'dec.txIdle': 'Transmit', 'dec.working': 'calling', 'dec.toYou': 'to you',
'dec.txUnknown': 'transmitting — text not reported', 'dec.txNothing': 'nothing being sent', 'dec.txUnknown': 'transmitting — text not reported', 'dec.txNothing': 'nothing being sent',
'dec.colTime': 'Time', 'dec.colSnr': 'SNR', 'dec.colMsg': 'Message', 'dec.bgGridUnconf': 'GRID?', 'dec.bgGridUnconfTip': 'This square is worked but not yet confirmed — a QSL to chase, not a QSO to make.', 'dec.colGrid': 'Grid', 'dec.colCountry': 'Country', 'dec.colBand': 'Band', 'dec.colMode': 'Mode', 'dec.colStatus': 'Status', 'dec.wkd': 'Wkd', 'dec.colTime': 'Time', 'dec.colSnr': 'SNR', 'dec.colMsg': 'Message', 'dec.bgGridUnconf': 'GRID?', 'dec.bgGridUnconfTip': 'This square is worked but not yet confirmed — a QSL to chase, not a QSO to make.', 'dec.colGrid': 'Grid', 'dec.colState': 'State', 'dec.colCountry': 'Country', 'dec.colBand': 'Band', 'dec.colMode': 'Mode', 'dec.colStatus': 'Status', 'dec.stateTip': 'US state', 'dec.wkd': 'Wkd',
'dec.bgPota': 'POTA', 'dec.bgGrid': 'GRID', 'dec.bgPfx': 'PFX', 'dec.bgCounty': 'CTY', 'dec.bgPota': 'POTA', 'dec.bgGrid': 'GRID', 'dec.bgPfx': 'PFX', 'dec.bgState': 'New State', 'dec.bgCounty': 'CTY',
'dec.stNew': 'NEW', 'dec.stBand': 'BAND', 'dec.stMode': 'MODE', 'dec.stSlot': 'SLOT', 'dec.stCall': 'CALL', 'dec.stNew': 'NEW', 'dec.stBand': 'BAND', 'dec.stMode': 'MODE', 'dec.stSlot': 'SLOT', 'dec.stCall': 'CALL',
'dec.empty': 'Nothing decoded yet. Decodes arrive from WSJT-X, JTDX or MSHV over the inbound UDP link (Settings -> UDP).', 'dec.empty': 'Nothing decoded yet. Decodes arrive from WSJT-X, JTDX or MSHV over the inbound UDP link (Settings -> UDP).',
'dec.emptyFiltered': 'No decode matches these filters.', 'dec.emptyFiltered': 'No decode matches these filters.',
@@ -344,7 +344,7 @@ const en: Dict = {
'clu.slotHighlight': 'Colour the stations not worked on this band and mode', 'clu.slotHighlight': 'Colour the stations not worked on this band and mode',
'clu.slotHighlightHint': '(by callsign, whatever the entity status says)', 'clu.slotHighlightHint': '(by callsign, whatever the entity status says)',
'rq.searchPh': 'Search callsign… 4S · *4S · *4S*', 'rq.searchTip': 'A plain word matches the START of a callsign: 4S finds 4S7AB. * is any run of characters and ? is exactly one, so *4S ends with 4S, *4S* contains it anywhere, and F?BPO matches F4BPO.', 'rq.searchPh': 'Search callsign… 4S · *4S · *4S*', 'rq.searchTip': 'A plain word matches the START of a callsign: 4S finds 4S7AB. * is any run of characters and ? is exactly one, so *4S ends with 4S, *4S* contains it anywhere, and F?BPO matches F4BPO.',
'gsc.scope': 'Match a square by', 'gsc.hunt': 'Chase', 'gsc.huntNew': 'New — never worked', 'gsc.huntUnconf': 'New and unconfirmed', 'gsc.scope_band_digi': 'This band + any digital mode', 'gsc.scope_band_mode': 'This band + this exact mode', 'gsc.scope_band_ftx': 'This band + any FT mode (FT8/FT4/FT2)', 'gsc.scope_mix_digi': 'Any band + any digital mode', 'gsc.scope_mix_mode': 'Any band + this exact mode', 'gsc.scope_mix_ftx': 'Any band + any FT mode (FT8/FT4/FT2)', 'gsc.hint': 'Decides when a square stops being NEW. Narrower means more squares to chase: per band and per exact mode is the most demanding, any band and any digital mode the least. Chasing unconfirmed as well keeps a square wanted until a QSL, LoTW or eQSL confirmation arrives — it is still missing from the award until then.', 'chg.mode': 'Chase', 'chg.sources': 'Confirmed by', 'chg.card': 'QSL card', 'dec.unconfTip': 'Worked but not confirmed — a QSL to chase', 'gsc.scope': 'Match a square by', 'gsc.hunt': 'Chase', 'gsc.huntNew': 'New — never worked', 'gsc.huntUnconf': 'New and unconfirmed', 'gsc.scope_band_digi': 'This band + any digital mode', 'gsc.scope_band_mode': 'This band + this exact mode', 'gsc.scope_band_ftx': 'This band + any FT mode (FT8/FT4/FT2)', 'gsc.scope_mix_digi': 'Any band + any digital mode', 'gsc.scope_mix_mode': 'Any band + this exact mode', 'gsc.scope_mix_ftx': 'Any band + any FT mode (FT8/FT4/FT2)', 'gsc.hint': 'Decides when a square stops being NEW. Narrower means more squares to chase: per band and per exact mode is the most demanding, any band and any digital mode the least. Chasing unconfirmed as well keeps a square wanted until a QSL, LoTW or eQSL confirmation arrives — it is still missing from the award until then.',
'gsm.basemap': 'Basemap', 'gsm.title': 'Grid squares', 'gsm.all': 'All', 'gsm.phone': 'Phone', 'gsm.cw': 'CW', 'gsm.digital': 'Digital', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmed', 'gsm.worked': 'worked', 'gsm.colConfirmed': 'Colour for confirmed squares', 'gsm.colWorked': 'Colour for worked (unconfirmed) squares', 'gsm.colReset': 'Back to the theme colours', 'gsm.refresh': 'Recount from the log', 'gsm.count': '{n} squares · {c} confirmed', 'gsm.basemap': 'Basemap', 'gsm.title': 'Grid squares', 'gsm.all': 'All', 'gsm.phone': 'Phone', 'gsm.cw': 'CW', 'gsm.digital': 'Digital', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmed', 'gsm.worked': 'worked', 'gsm.colConfirmed': 'Colour for confirmed squares', 'gsm.colWorked': 'Colour for worked (unconfirmed) squares', 'gsm.colReset': 'Back to the theme colours', 'gsm.refresh': 'Recount from the log', 'gsm.count': '{n} squares · {c} confirmed',
'bo.nearKm': 'Count receivers within', 'bo.nearKmHint': 'A report proves YOUR path only if it was collected near you. Smaller is more local but leaves fewer receivers listening — too small and the watch has nothing to look at. 300 km borrows a whole region; 100 km suits 2 m, where a duct is narrow.', 'bo.nearKm': 'Count receivers within', 'bo.nearKmHint': 'A report proves YOUR path only if it was collected near you. Smaller is more local but leaves fewer receivers listening — too small and the watch has nothing to look at. 300 km borrows a whole region; 100 km suits 2 m, where a duct is narrow.',
'bo.open': 'open', 'bo.liveTip': '{band} is open — {n} stations, ~{km} km, {sector}{season}. Click for the band map.', 'bo.enable': 'Watch for band openings', 'bo.enableHint': '(10, 12, 6, 4 and 2 m. Switching this on adds the two RBN nodes and subscribes to the PSK Reporter feed — the detection needs far more ears than a cluster can give it.)', 'bo.feedUp': 'PSK Reporter feed up — {n} decodes seen', 'bo.feedDown': 'PSK Reporter feed down — needs your station grid, and a moment to connect', 'clu.spotTtl': 'Spot lifetime', 'clu.spotTtlNever': 'Keep', 'clu.spotMax': 'Spots kept', 'clu.spotMaxHint': '', 'clu.spotTtlHint': 'minutes — 0 keeps them.', 'clu.chasePota': 'Chase POTA', 'clu.chasePotaHint': 'Off: no NEW POTA badge or filter, and the POTA column stays empty — a new-band + new-POTA spot reads NEW BAND alone.', 'clu.chaseSota': 'Chase SOTA', 'clu.chaseSotaHint': 'Off: the SOTA column stays empty.', 'clu.chaseGrids': 'Chase new grids', 'clu.chaseGridsHint': '(learns locators from your own WSJT-X decodes AND from PSK Reporter, and keeps them in their own database so the cluster shows them from the first second)', 'clu.chaseGridsStat': '{n} locators known — {p} waiting to be written', 'clu.workedSameSlot': 'Already worked only on the same slot', 'bo.open': 'open', 'bo.liveTip': '{band} is open — {n} stations, ~{km} km, {sector}{season}. Click for the band map.', 'bo.enable': 'Watch for band openings', 'bo.enableHint': '(10, 12, 6, 4 and 2 m. Switching this on adds the two RBN nodes and subscribes to the PSK Reporter feed — the detection needs far more ears than a cluster can give it.)', 'bo.feedUp': 'PSK Reporter feed up — {n} decodes seen', 'bo.feedDown': 'PSK Reporter feed down — needs your station grid, and a moment to connect', 'clu.spotTtl': 'Spot lifetime', 'clu.spotTtlNever': 'Keep', 'clu.spotMax': 'Spots kept', 'clu.spotMaxHint': '', 'clu.spotTtlHint': 'minutes — 0 keeps them.', 'clu.chasePota': 'Chase POTA', 'clu.chasePotaHint': 'Off: no NEW POTA badge or filter, and the POTA column stays empty — a new-band + new-POTA spot reads NEW BAND alone.', 'clu.chaseSota': 'Chase SOTA', 'clu.chaseSotaHint': 'Off: the SOTA column stays empty.', 'clu.chaseGrids': 'Chase new grids', 'clu.chaseGridsHint': '(learns locators from your own WSJT-X decodes AND from PSK Reporter, and keeps them in their own database so the cluster shows them from the first second)', 'clu.chaseGridsStat': '{n} locators known — {p} waiting to be written', 'clu.workedSameSlot': 'Already worked only on the same slot',
@@ -449,7 +449,7 @@ const en: Dict = {
'wkp.cwSpeed': 'CW speed (WPM)', 'wkp.faster': 'Faster', 'wkp.slower': 'Slower', 'wkp.cwText': 'CW text', 'wkp.sendOnTypeHint': 'Key each character live as you type (backspace removes un-sent chars)', 'wkp.sendOnType': 'send on type', 'wkp.phLive': 'Type — sent live…', 'wkp.phEnter': 'Type and press Enter to send…', 'wkp.clear': 'Clear', 'wkp.send': 'Send', 'wkp.abort': 'Abort (clear keyer buffer)', 'wkp.stop': 'Stop', 'wkp.cwSpeed': 'CW speed (WPM)', 'wkp.faster': 'Faster', 'wkp.slower': 'Slower', 'wkp.cwText': 'CW text', 'wkp.sendOnTypeHint': 'Key each character live as you type (backspace removes un-sent chars)', 'wkp.sendOnType': 'send on type', 'wkp.phLive': 'Type — sent live…', 'wkp.phEnter': 'Type and press Enter to send…', 'wkp.clear': 'Clear', 'wkp.send': 'Send', 'wkp.abort': 'Abort (clear keyer buffer)', 'wkp.stop': 'Stop',
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL", 'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL",
'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}', 'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}',
'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Repeat a CQ-labelled message on a timer until you stop it or play another slot', 'dvkp.gap': 'Gap', 'dvkp.notPhone': 'The voice keyer only transmits on a phone mode (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message', 'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Repeat a CQ-labelled message on a timer until you stop it or play another slot', 'dvkp.gap': 'Gap', 'dvkp.notPhone': 'The voice keyer only transmits on a phone mode (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1F12.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message',
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band', 'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band',
'ampw.title': 'Amplifier', 'ampw.all': 'All amplifiers', 'ampw.pick': 'Which amplifier the widget shows', 'ampw.title': 'Amplifier', 'ampw.all': 'All amplifiers', 'ampw.pick': 'Which amplifier the widget shows',
'ampw.showHint': 'Amplifier · click to show', 'ampw.hideHint': 'Amplifier — shown · click to hide', 'ampw.showHint': 'Amplifier · click to show', 'ampw.hideHint': 'Amplifier — shown · click to hide',
@@ -471,7 +471,7 @@ const en: Dict = {
'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Worked before', 'ncp.wbHint': 'Click a station (on air or roster) to see prior QSOs', 'ncp.wbNone': 'No prior QSO with', 'ncp.wbFirst': 'first', 'ncp.wbLast': 'last', 'ncp.wbResize': 'Drag to resize', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'mic-pass order · ⬆⬇ to reorder · double-click → edit · "Log & end" to save', 'ncp.moveUp': 'Move up the mic-pass order', 'ncp.moveDown': 'Move down the mic-pass order', 'ncp.logEndSelected': 'Log & end selected', 'ncp.logAll': 'Log everyone ({n})', 'ncp.logAllConfirm': 'Log all {n} on-air station(s) to the logbook?', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET', 'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Worked before', 'ncp.wbHint': 'Click a station (on air or roster) to see prior QSOs', 'ncp.wbNone': 'No prior QSO with', 'ncp.wbFirst': 'first', 'ncp.wbLast': 'last', 'ncp.wbResize': 'Drag to resize', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'mic-pass order · ⬆⬇ to reorder · double-click → edit · "Log & end" to save', 'ncp.moveUp': 'Move up the mic-pass order', 'ncp.moveDown': 'Move down the mic-pass order', 'ncp.logEndSelected': 'Log & end selected', 'ncp.logAll': 'Log everyone ({n})', 'ncp.logAllConfirm': 'Log all {n} on-air station(s) to the logbook?', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET',
'udpp.relayInstead': 'For an antenna switch or a relay board, use Station Control → relays instead: it holds the state, reads the boards at startup and does not re-switch while you tune inside a band. A home-made switch is the “HTTP relay” type there.', 'udpp.relayInstead': 'For an antenna switch or a relay board, use Station Control → relays instead: it holds the state, reads the boards at startup and does not re-switch while you tune inside a band. A home-made switch is the “HTTP relay” type there.',
'udpp.svcCustomLabel': 'Custom message', 'udpp.svcCustomHint': 'You choose what fires it and what it says. A UDP datagram or an HTTP request — the latter is how most antenna switches are driven.', 'udpp.trigger': 'Fires on', 'udpp.trgBand': 'Band change (radio)', 'udpp.trgQso': 'QSO logged', 'udpp.trgRotator': 'Rotator command', 'udpp.trgLookup': 'Callsign lookup', 'udpp.transport': 'Sends as', 'udpp.transportUdp': 'UDP message', 'udpp.transportUrl': 'URL (HTTP GET)', 'udpp.url': 'URL', 'udpp.urlHint': 'Values are URL-encoded. Credentials may be included as http://user:pass@host/… — stored as typed, so keep it to your own network.', 'udpp.template': 'Message', 'udpp.lineEnd': 'Line end', 'udpp.lineEndNone': 'None', 'udpp.fieldsAvailable': 'Fields for this trigger', 'udpp.fieldsHint': 'Anything else renders empty.', 'udpp.svcCustomLabel': 'Custom message', 'udpp.svcCustomHint': 'You choose what fires it and what it says. A UDP datagram or an HTTP request — the latter is how most antenna switches are driven.', 'udpp.trigger': 'Fires on', 'udpp.trgBand': 'Band change (radio)', 'udpp.trgQso': 'QSO logged', 'udpp.trgRotator': 'Rotator command', 'udpp.trgLookup': 'Callsign lookup', 'udpp.transport': 'Sends as', 'udpp.transportUdp': 'UDP message', 'udpp.transportUrl': 'URL (HTTP GET)', 'udpp.url': 'URL', 'udpp.urlHint': 'Values are URL-encoded. Credentials may be included as http://user:pass@host/… — stored as typed, so keep it to your own network.', 'udpp.template': 'Message', 'udpp.lineEnd': 'Line end', 'udpp.lineEndNone': 'None', 'udpp.fieldsAvailable': 'Fields for this trigger', 'udpp.fieldsHint': 'Anything else renders empty.',
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcWsjtRelayLabel': 'Relay the WSJT-X stream', 'udpp.svcWsjtRelayHint': 'Re-sends every datagram received from WSJT-X / JTDX / MSHV, byte for byte, to another program — JTAlert, GridTracker, a second logger. The sender only talks to one address, so this is what lets them run alongside OpsLog. Point it at the OTHER programs port, never at one of OpsLogs own.', 'udpp.svcWsjtLogLabel': 'WSJT-X logged QSO', 'udpp.svcWsjtLogHint': 'Announces each logged QSO on the WSJT-X UDP interface — both messages WSJT-X itself sends. For any logger that listens there rather than for plain-text ADIF (Logger32s additional UDP sockets, for one).', 'udpp.svcDbLabel': 'ADIF Message', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.svcPstLabel': 'PstRotator frequency', 'udpp.svcPstHint': 'Sends the rig frequency as <PST><FREQUENCY> whenever it changes — set PstRotatorAz tracker to DXLog.net (default port 12040).', 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (freq + mode)', 'udpp.svcN1mmRadioHint': 'Broadcasts the rig frequency/mode as N1MM Logger+ RadioInfo XML on every change — consumed by PstRotator (N1MM tracker) and many other tools.', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'Connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save', 'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcWsjtRelayLabel': 'Relay the WSJT-X stream', 'udpp.svcWsjtRelayHint': 'Re-sends every datagram received from WSJT-X / JTDX / MSHV, byte for byte, to another program — JTAlert, GridTracker, a second logger. The sender only talks to one address, so this is what lets them run alongside OpsLog. Point it at the OTHER programs port, never at one of OpsLogs own.', 'udpp.svcWsjtLogLabel': 'WSJT-X logged QSO', 'udpp.svcWsjtLogHint': 'Announces each logged QSO on the WSJT-X UDP interface — both messages WSJT-X itself sends. For any logger that listens there rather than for plain-text ADIF (Logger32s additional UDP sockets, for one).', 'udpp.svcDbLabel': 'ADIF Message', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.svcPstLabel': 'PstRotator frequency', 'udpp.svcPstHint': 'Sends the rig frequency as <PST><FREQUENCY> whenever it changes — set PstRotatorAz tracker to DXLog.net (default port 12040).', 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (freq + mode)', 'udpp.svcN1mmRadioHint': 'Broadcasts the rig frequency/mode as N1MM Logger+ RadioInfo XML on every change — consumed by PstRotator (N1MM tracker) and many other tools.', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'Connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.highlight': 'Highlight decodes in WSJT-X / JTDX', 'udpp.highlightHint': 'Colours callsigns in the decoders own Band Activity window from your log: watchlist members pink, a new DXCC green, a new band for its entity orange. Applied live as decodes arrive.', 'udpp.followMode': 'Switch the decoder\u2019s mode from spots', 'udpp.followModeHint': 'Clicking an FT4 spot while WSJT-X / JTDX sits in FT8 switches its mode too (Configure message).', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save',
'fltb.fCallsign': 'Callsign', 'fltb.fCreated': 'Added to the log on', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'Paper QSL sent status', 'fltb.fQslSentDate': 'Paper QSL sent date', 'fltb.fQslRcvd': 'Paper QSL received status', 'fltb.fQslRcvdDate': 'Paper QSL received date', 'fltb.fQslSentVia': 'QSL sent via', 'fltb.fQslRcvdVia': 'QSL rcvd via', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent status', 'fltb.fLotwSentDate': 'LoTW sent date', 'fltb.fLotwRcvd': 'LoTW received status', 'fltb.fLotwRcvdDate': 'LoTW received date', 'fltb.fEqslSent': 'eQSL sent status', 'fltb.fEqslSentDate': 'eQSL sent date', 'fltb.fEqslRcvd': 'eQSL received status', 'fltb.fEqslRcvdDate': 'eQSL received date', 'fltb.fQrzSent': 'QRZ.com sent status', 'fltb.fQrzSentDate': 'QRZ.com sent date', 'fltb.fQrzRcvd': 'QRZ.com received status', 'fltb.fQrzRcvdDate': 'QRZ.com received date', 'fltb.fClublogSent': 'Club Log sent status', 'fltb.fClublogSentDate': 'Club Log sent date', 'fltb.fHrdlogSent': 'HRDLog sent status', 'fltb.fHrdlogSentDate': 'HRDLog sent date', 'fltb.fHamlogSent': 'HAMLOG.online sent', 'fltb.fHamlogSentDate': 'HAMLOG.online sent date', 'fltb.fHamlogRcvd': 'HAMLOG.online received', 'fltb.fHamlogRcvdDate': 'HAMLOG.online received date', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Station callsign (my call)', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opIn': 'is one of', 'fltb.opNotIn': 'is none of', 'fltb.listPh': '2m, 70cm — comma separated', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.presetSaved': 'Filter “{name}” saved', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close', 'fltb.fCallsign': 'Callsign', 'fltb.fCreated': 'Added to the log on', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'Paper QSL sent status', 'fltb.fQslSentDate': 'Paper QSL sent date', 'fltb.fQslRcvd': 'Paper QSL received status', 'fltb.fQslRcvdDate': 'Paper QSL received date', 'fltb.fQslSentVia': 'QSL sent via', 'fltb.fQslRcvdVia': 'QSL rcvd via', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent status', 'fltb.fLotwSentDate': 'LoTW sent date', 'fltb.fLotwRcvd': 'LoTW received status', 'fltb.fLotwRcvdDate': 'LoTW received date', 'fltb.fEqslSent': 'eQSL sent status', 'fltb.fEqslSentDate': 'eQSL sent date', 'fltb.fEqslRcvd': 'eQSL received status', 'fltb.fEqslRcvdDate': 'eQSL received date', 'fltb.fQrzSent': 'QRZ.com sent status', 'fltb.fQrzSentDate': 'QRZ.com sent date', 'fltb.fQrzRcvd': 'QRZ.com received status', 'fltb.fQrzRcvdDate': 'QRZ.com received date', 'fltb.fClublogSent': 'Club Log sent status', 'fltb.fClublogSentDate': 'Club Log sent date', 'fltb.fHrdlogSent': 'HRDLog sent status', 'fltb.fHrdlogSentDate': 'HRDLog sent date', 'fltb.fHamlogSent': 'HAMLOG.online sent', 'fltb.fHamlogSentDate': 'HAMLOG.online sent date', 'fltb.fHamlogRcvd': 'HAMLOG.online received', 'fltb.fHamlogRcvdDate': 'HAMLOG.online received date', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Station callsign (my call)', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opIn': 'is one of', 'fltb.opNotIn': 'is none of', 'fltb.listPh': '2m, 70cm — comma separated', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.presetSaved': 'Filter “{name}” saved', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close',
'detp.propAS': 'Aircraft Scatter', 'detp.propAUR': 'Aurora', 'detp.propAUE': 'Aurora-E', 'detp.propBS': 'Back Scatter', 'detp.propEME': 'Earth-Moon-Earth', 'detp.propES': 'Sporadic E', 'detp.propFAI': 'Field Aligned Irregularities', 'detp.propF2': 'F2 Reflection', 'detp.propGWAVE': 'Ground Wave', 'detp.propINTERNET': 'Internet-assisted', 'detp.propION': 'Ionoscatter', 'detp.propLOS': 'Line of Sight', 'detp.propMS': 'Meteor Scatter', 'detp.propRPT': 'Terrestrial / atmospheric repeater', 'detp.propRS': 'Rain Scatter', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-Equatorial', 'detp.propTR': 'Tropospheric Ducting', 'detp.pathShort': 'Short Path', 'detp.pathLong': 'Long Path', 'detp.pathGrayline': 'Grayline', 'detp.pathOther': 'Other', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Awards', 'detp.tabMy': 'My', 'detp.tabExtended': 'Extended', 'detp.statePref': 'State / pref', 'detp.county': 'County', 'detp.newCounty': 'NEW', 'detp.newCountyTip': 'County never worked before', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ', 'detp.ituZone': 'ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimuth LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Address', 'detp.qslMessage': 'QSL message', 'detp.qslVia': 'QSL via (manager)', 'detp.detected': 'Detected — this contact will count for:', 'detp.ambiguous': 'Ambiguous — pick one:', 'detp.azimuth': 'Azimuth (°)', 'detp.elevation': 'Elevation (°)', 'detp.txPower': 'TX power (W)', 'detp.satelliteMode': 'Satellite mode', 'detp.antPath': 'Ant. path', 'detp.propagation': 'Propagation', 'detp.rig': 'Rig', 'detp.antenna': 'Antenna', 'detp.satName': 'Satellite name', 'detp.contestId': 'Contest ID', 'detp.rcvdExchangePh': 'rcvd exchange', 'detp.sentExchangePh': 'sent exchange', 'detp.contactedEmail': 'Contacted email', 'detp.contactedWeb': 'Website', 'detp.propAS': 'Aircraft Scatter', 'detp.propAUR': 'Aurora', 'detp.propAUE': 'Aurora-E', 'detp.propBS': 'Back Scatter', 'detp.propEME': 'Earth-Moon-Earth', 'detp.propES': 'Sporadic E', 'detp.propFAI': 'Field Aligned Irregularities', 'detp.propF2': 'F2 Reflection', 'detp.propGWAVE': 'Ground Wave', 'detp.propINTERNET': 'Internet-assisted', 'detp.propION': 'Ionoscatter', 'detp.propLOS': 'Line of Sight', 'detp.propMS': 'Meteor Scatter', 'detp.propRPT': 'Terrestrial / atmospheric repeater', 'detp.propRS': 'Rain Scatter', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-Equatorial', 'detp.propTR': 'Tropospheric Ducting', 'detp.pathShort': 'Short Path', 'detp.pathLong': 'Long Path', 'detp.pathGrayline': 'Grayline', 'detp.pathOther': 'Other', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Awards', 'detp.tabMy': 'My', 'detp.tabExtended': 'Extended', 'detp.statePref': 'State / pref', 'detp.county': 'County', 'detp.newCounty': 'NEW', 'detp.newCountyTip': 'County never worked before', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ', 'detp.ituZone': 'ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimuth LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Address', 'detp.qslMessage': 'QSL message', 'detp.qslVia': 'QSL via (manager)', 'detp.detected': 'Detected — this contact will count for:', 'detp.ambiguous': 'Ambiguous — pick one:', 'detp.azimuth': 'Azimuth (°)', 'detp.elevation': 'Elevation (°)', 'detp.txPower': 'TX power (W)', 'detp.satelliteMode': 'Satellite mode', 'detp.antPath': 'Ant. path', 'detp.propagation': 'Propagation', 'detp.rig': 'Rig', 'detp.antenna': 'Antenna', 'detp.satName': 'Satellite name', 'detp.contestId': 'Contest ID', 'detp.rcvdExchangePh': 'rcvd exchange', 'detp.sentExchangePh': 'sent exchange', 'detp.contactedEmail': 'Contacted email', 'detp.contactedWeb': 'Website',
// Awards (ref picker / ref selector / awards panel / award editor) // Awards (ref picker / ref selector / awards panel / award editor)
@@ -521,7 +521,7 @@ const en: Dict = {
'wbg.awardTip': '{name} — reference this QSO counts for', 'wbg.typeCall': 'Type a callsign in the entry strip to see prior contacts.', 'wbg.checking': 'checking…', 'wbg.new': 'NEW', 'wbg.noPriorPre': 'No prior QSO with ', 'wbg.noPriorPost': '.', 'wbg.workedBefore': 'Worked before', 'wbg.first': 'First:', 'wbg.last': 'Last:', 'wbg.dxcc': 'DXCC:', 'wbg.entityQsos': '{n} entity QSOs', 'wbg.clearFiltersTitle': 'Clear all column filters', 'wbg.clearFilters': 'Clear filters', 'wbg.columns': 'Columns', 'wbg.olderQsos': '+ {n} older QSOs (not shown — capped for performance)', 'wbg.pickerTitle': 'Worked-before columns', 'wbg.pickerDesc': 'Pick the columns you want visible in the Worked-before table.', 'wbg.allGroups': 'All groups:', 'wbg.all': 'all', 'wbg.none': 'none', 'wbg.grpAwards': 'Awards', 'wbg.resetDefaults': 'Reset to defaults', 'wbg.done': 'Done', 'wbg.awardTip': '{name} — reference this QSO counts for', 'wbg.typeCall': 'Type a callsign in the entry strip to see prior contacts.', 'wbg.checking': 'checking…', 'wbg.new': 'NEW', 'wbg.noPriorPre': 'No prior QSO with ', 'wbg.noPriorPost': '.', 'wbg.workedBefore': 'Worked before', 'wbg.first': 'First:', 'wbg.last': 'Last:', 'wbg.dxcc': 'DXCC:', 'wbg.entityQsos': '{n} entity QSOs', 'wbg.clearFiltersTitle': 'Clear all column filters', 'wbg.clearFilters': 'Clear filters', 'wbg.columns': 'Columns', 'wbg.olderQsos': '+ {n} older QSOs (not shown — capped for performance)', 'wbg.pickerTitle': 'Worked-before columns', 'wbg.pickerDesc': 'Pick the columns you want visible in the Worked-before table.', 'wbg.allGroups': 'All groups:', 'wbg.all': 'all', 'wbg.none': 'none', 'wbg.grpAwards': 'Awards', 'wbg.resetDefaults': 'Reset to defaults', 'wbg.done': 'Done',
'chn.title': 'Chase new', 'chn.close': 'Hide the panel', 'chn.filterHint': 'Show or hide this kind', 'chn.allFiltered': 'Everything heard is filtered out — turn a category back on above.', 'chn.toggle': 'Chase new', 'chn.count': '{n} heard', 'chn.loading': 'Waiting for the feed…', 'chn.empty': 'Nothing new being decoded near you right now.', 'chn.digitalOnly': 'PSK Reporter — digital modes only, heard within ~300 km of you.', 'chn.option': 'Chase new (PSK Reporter)', 'chn.optionHelp': 'Lists stations being decoded near you that are new against your log — new entity, band, mode, slot, prefix or square. Digital modes only.', 'chn.show': 'Chase new panel', 'chn.title': 'Chase new', 'chn.close': 'Hide the panel', 'chn.filterHint': 'Show or hide this kind', 'chn.allFiltered': 'Everything heard is filtered out — turn a category back on above.', 'chn.toggle': 'Chase new', 'chn.count': '{n} heard', 'chn.loading': 'Waiting for the feed…', 'chn.empty': 'Nothing new being decoded near you right now.', 'chn.digitalOnly': 'PSK Reporter — digital modes only, heard within ~300 km of you.', 'chn.option': 'Chase new (PSK Reporter)', 'chn.optionHelp': 'Lists stations being decoded near you that are new against your log — new entity, band, mode, slot, prefix or square. Digital modes only.', 'chn.show': 'Chase new panel',
'clg2.allFiltered': '{n} spots received, none shown — your filters are hiding them all.', 'clg2.activeFilters': 'Active:', 'clg2.clearAllFilters': 'Clear every filter', 'clg2.fBandLock': 'band locked to the rig', 'clg2.fBands': 'bands {list}', 'clg2.fModeLock': 'mode locked to the rig', 'clg2.fModes': 'modes {list}', 'clg2.fStatus': 'status chips', 'clg2.fHideWorked': 'hide worked', 'clg2.fLotwOnly': 'LoTW users only', 'clg2.fSpotterCont': 'spotter continent', 'clg2.fSource': 'one source node', 'clg2.fSearch': 'search “{q}”', 'clg2.allFiltered': '{n} spots received, none shown — your filters are hiding them all.', 'clg2.activeFilters': 'Active:', 'clg2.clearAllFilters': 'Clear every filter', 'clg2.fBandLock': 'band locked to the rig', 'clg2.fBands': 'bands {list}', 'clg2.fModeLock': 'mode locked to the rig', 'clg2.fModes': 'modes {list}', 'clg2.fStatus': 'status chips', 'clg2.fHideWorked': 'hide worked', 'clg2.fLotwOnly': 'LoTW users only', 'clg2.fSpotterCont': 'spotter continent', 'clg2.fSource': 'one source node', 'clg2.fSearch': 'search “{q}”',
'clg2.c.time': 'Time', 'clg2.c.call': 'Call', 'clg2.c.status': 'Status', 'clg2.c.pota': 'POTA', 'clg2.c.sota': 'SOTA', 'clg2.c.freq': 'Freq', 'clg2.c.band': 'Band', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Pfx', 'clg2.c.cqz': 'CQ Zone', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'ITU Zone', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Short path (°)', 'clg2.h.sp_deg': 'SP°', 'clg2.c.lp_deg': 'Long path (°)', 'clg2.h.lp_deg': 'LP°', 'clg2.c.country': 'Country', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Spotter locator', 'clg2.h.locator': 'Spotter loc', 'clg2.c.county': 'US County', 'clg2.tipNewCounty': 'NEW COUNTY — never worked', 'clg2.tipNewPfx': 'NEW PREFIX — this WPX prefix has never been worked', 'clg2.c.comment': 'Comment', 'clg2.c.received_at': 'Received at', 'clg2.h.received_at': 'Received UTC', 'clg2.c.raw': 'Raw', 'clg2.newDxcc': 'NEW DXCC', 'clg2.newBandMode': 'NEW B+M', 'clg2.newBand': 'NEW BAND', 'clg2.newMode': 'NEW MODE', 'clg2.newSlot': 'NEW SLOT', 'clg2.newCall': 'NEW CALL', 'clg2.wkdCall': 'WKD CALL', 'clg2.newCounty': 'NEW CTY', 'clg2.newGridUnconf': 'GRID?', 'clg2.newGrid': 'NEW GRID', 'clg2.c.grid': 'Grid', 'clg2.tipNewGrid': 'NEW GRID — this square has never been worked (grid heard in a CQ on the UDP link)', 'clg2.newPfx': "NEW PFX", 'clg2.newPota': 'NEW POTA', 'clg2.tipNewDxcc': 'NEW DXCC: {country}', 'clg2.tipWorkedCall': 'Already worked this call', 'clg2.tipNewBandMode': 'NEW BAND AND NEW MODE for this entity — neither has been worked with it', 'clg2.tipNewBand': 'NEW BAND for this entity', 'clg2.tipNewSlotBand': 'NEW SLOT (mode not yet worked on this band)', 'clg2.tipNewMode': 'NEW MODE (this mode never worked on this entity)', 'clg2.tipNewSlot': 'NEW SLOT (this band+mode not yet worked)', 'clg2.tipNewCall': 'NEW CALL — this callsign has never been worked on this band and mode (the entity has)', 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Geo', 'clg2.clearFiltersTitle': 'Clear all column filters', 'clg2.clearFilters': 'Clear filters', 'clg2.newSpots': '{n} new spots — click to resume', 'clg2.columns': 'Columns', 'clg2.pickerTitle': 'Cluster columns', 'clg2.pickerDesc': 'Pick the columns you want visible in the Cluster table.', 'clg2.allGroups': 'All groups:', 'clg2.all': 'all', 'clg2.none': 'none', 'clg2.resetDefaults': 'Reset to defaults', 'clg2.done': 'Done', 'clg2.c.time': 'Time', 'clg2.c.call': 'Call', 'clg2.c.status': 'Status', 'clg2.c.pota': 'POTA', 'clg2.c.sota': 'SOTA', 'clg2.c.freq': 'Freq', 'clg2.c.band': 'Band', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Pfx', 'clg2.c.cqz': 'CQ Zone', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'ITU Zone', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Short path (°)', 'clg2.h.sp_deg': 'SP°', 'clg2.c.lp_deg': 'Long path (°)', 'clg2.h.lp_deg': 'LP°', 'clg2.c.country': 'Country', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Spotter locator', 'clg2.h.locator': 'Spotter loc', 'clg2.c.county': 'US County', 'clg2.tipNewCounty': 'NEW COUNTY — never worked', 'clg2.tipNewPfx': 'NEW PREFIX — this WPX prefix has never been worked', 'clg2.c.comment': 'Comment', 'clg2.c.received_at': 'Received at', 'clg2.h.received_at': 'Received UTC', 'clg2.c.raw': 'Raw', 'clg2.newDxcc': 'NEW DXCC', 'clg2.newBandMode': 'NEW B+M', 'clg2.newBand': 'NEW BAND', 'clg2.newMode': 'NEW MODE', 'clg2.newSlot': 'NEW SLOT', 'clg2.newCall': 'NEW CALL', 'clg2.wkdCall': 'WKD CALL', 'clg2.newCounty': 'NEW CTY', 'clg2.newGridUnconf': 'GRID?', 'clg2.newState': 'New State', 'clg2.newGrid': 'NEW GRID', 'clg2.c.grid': 'Grid', 'clg2.tipNewGrid': 'NEW GRID — this square has never been worked (grid heard in a CQ on the UDP link)', 'clg2.newPfx': "NEW PFX", 'clg2.newPota': 'NEW POTA', 'clg2.tipNewDxcc': 'NEW DXCC: {country}', 'clg2.tipWorkedCall': 'Already worked this call', 'clg2.tipNewBandMode': 'NEW BAND AND NEW MODE for this entity — neither has been worked with it', 'clg2.tipNewBand': 'NEW BAND for this entity', 'clg2.tipNewSlotBand': 'NEW SLOT (mode not yet worked on this band)', 'clg2.tipNewMode': 'NEW MODE (this mode never worked on this entity)', 'clg2.tipNewSlot': 'NEW SLOT (this band+mode not yet worked)', 'clg2.tipNewCall': 'NEW CALL — this callsign has never been worked on this band and mode (the entity has)', 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Geo', 'clg2.clearFiltersTitle': 'Clear all column filters', 'clg2.clearFilters': 'Clear filters', 'clg2.newSpots': '{n} new spots — click to resume', 'clg2.columns': 'Columns', 'clg2.pickerTitle': 'Cluster columns', 'clg2.pickerDesc': 'Pick the columns you want visible in the Cluster table.', 'clg2.allGroups': 'All groups:', 'clg2.all': 'all', 'clg2.none': 'none', 'clg2.resetDefaults': 'Reset to defaults', 'clg2.done': 'Done',
// Audio devices & voice keyer (Preferences → Audio devices). // Audio devices & voice keyer (Preferences → Audio devices).
'aud.refreshDevices': 'Refresh devices', 'aud.fromRadio': 'From Radio (RX in)', 'aud.toRadio': 'To Radio (TX out)', 'aud.recMic': 'Recording mic', 'aud.listening': 'Listening (preview)', 'aud.refreshDevices': 'Refresh devices', 'aud.fromRadio': 'From Radio (RX in)', 'aud.toRadio': 'To Radio (TX out)', 'aud.recMic': 'Recording mic', 'aud.listening': 'Listening (preview)',
'aud.phFromRadio': 'Rig audio output → soundcard input', 'aud.phToRadio': 'Soundcard output → rig mic/data in', 'aud.phRecMic': 'Your microphone (record voice-keyer messages)', 'aud.phListening': 'Local speakers for preview', 'aud.phFromRadio': 'Rig audio output → soundcard input', 'aud.phToRadio': 'Soundcard output → rig mic/data in', 'aud.phRecMic': 'Your microphone (record voice-keyer messages)', 'aud.phListening': 'Local speakers for preview',
@@ -535,7 +535,7 @@ const en: Dict = {
'aud.preroll': 'Pre-roll (seconds)', 'aud.format': 'File format', 'aud.wav': 'WAV (lossless, larger)', 'aud.mp3': 'MP3 (compressed, small)', 'aud.preroll': 'Pre-roll (seconds)', 'aud.format': 'File format', 'aud.wav': 'WAV (lossless, larger)', 'aud.mp3': 'MP3 (compressed, small)',
'aud.fromLevel': 'From Radio level', 'aud.txLevel': 'Voice keyer level', 'aud.txLevelHint': 'Level of the recorded messages sent to the radio. Raise it if your voice keyer is much quieter than your microphone; Play previews at this same level. If the radio transmits almost nothing, its modulation source is still the front microphone: on an FTDX10 set MENU → SSB MOD SOURCE to REAR (the USB input).', 'aud.micLevel': 'Mic level', 'aud.qsoPlayLevel': 'QSO playback level', 'aud.levelHint': 'If your voice is louder than the station, lower Mic level.', 'aud.fromLevel': 'From Radio level', 'aud.txLevel': 'Voice keyer level', 'aud.txLevelHint': 'Level of the recorded messages sent to the radio. Raise it if your voice keyer is much quieter than your microphone; Play previews at this same level. If the radio transmits almost nothing, its modulation source is still the front microphone: on an FTDX10 set MENU → SSB MOD SOURCE to REAR (the USB input).', 'aud.micLevel': 'Mic level', 'aud.qsoPlayLevel': 'QSO playback level', 'aud.levelHint': 'If your voice is louder than the station, lower Mic level.',
'aud.autoSend': 'Auto-send the recording to the station by e-mail when I log a QSO', 'aud.autoSend': 'Auto-send the recording to the station by e-mail when I log a QSO',
'aud.dvkTitle': 'Voice keyer messages (F1F6)', 'aud.pttMethod': 'PTT method', 'aud.pttNone': 'None (VOX)', 'aud.pttCat': 'CAT (the radio link)', 'aud.pttRts': 'Serial RTS', 'aud.pttDtr': 'Serial DTR', 'aud.dvkTitle': 'Voice keyer messages (F1F12)', 'aud.deleteMsg': 'Delete this message', 'aud.pttMethod': 'PTT method', 'aud.pttNone': 'None (VOX)', 'aud.pttCat': 'CAT (the radio link)', 'aud.pttRts': 'Serial RTS', 'aud.pttDtr': 'Serial DTR',
'aud.testPtt': 'Test PTT', 'aud.pttPort': 'PTT COM port', 'aud.pickPort': 'Pick a COM port', 'aud.selectPort': '— select —', 'aud.refresh': 'Refresh', 'aud.testPtt': 'Test PTT', 'aud.pttPort': 'PTT COM port', 'aud.pickPort': 'Pick a COM port', 'aud.selectPort': '— select —', 'aud.refresh': 'Refresh',
'aud.msgPlaceholder': 'Message {n} label (CQ, report, 73…)', 'aud.holdRec': '● Hold to rec', 'aud.recordingNow': '● Recording…', 'aud.play': '▶ Play', 'aud.stop': '■ Stop', 'aud.msgPlaceholder': 'Message {n} label (CQ, report, 73…)', 'aud.holdRec': '● Hold to rec', 'aud.recordingNow': '● Recording…', 'aud.play': '▶ Play', 'aud.stop': '■ Stop',
'aud.errPttTest': 'PTT test: ', 'aud.errRecord': 'Record: ', 'aud.errSave': 'Save: ', 'aud.errPlay': 'Play: ', 'aud.errPttTest': 'PTT test: ', 'aud.errRecord': 'Record: ', 'aud.errSave': 'Save: ', 'aud.errPlay': 'Play: ',
@@ -696,8 +696,8 @@ const fr: Dict = {
'dec.colFreq': 'Freq', 'dec.colFreqTitle': 'Decalage audio dans la bande passante (Hz)', 'dec.colFreq': 'Freq', 'dec.colFreqTitle': 'Decalage audio dans la bande passante (Hz)',
'dec.txNow': 'En emission', 'dec.txIdle': 'Emission', 'dec.working': 'appelle', 'dec.toYou': 'pour toi', 'dec.txNow': 'En emission', 'dec.txIdle': 'Emission', 'dec.working': 'appelle', 'dec.toYou': 'pour toi',
'dec.txUnknown': 'en émission — texte non communiqué', 'dec.txNothing': 'rien en cours d’émission', 'dec.txUnknown': 'en émission — texte non communiqué', 'dec.txNothing': 'rien en cours d’émission',
'dec.colTime': 'Heure', 'dec.colSnr': 'SNR', 'dec.colMsg': 'Message', 'dec.bgGridUnconf': 'GRID?', 'dec.bgGridUnconfTip': 'Ce carré est contacté mais pas encore confirmé — une QSL à relancer, pas un QSO à faire.', 'dec.colGrid': 'Locator', 'dec.colCountry': 'Pays', 'dec.colBand': 'Bande', 'dec.colMode': 'Mode', 'dec.colStatus': 'Statut', 'dec.wkd': 'Fait', 'dec.colTime': 'Heure', 'dec.colSnr': 'SNR', 'dec.colMsg': 'Message', 'dec.bgGridUnconf': 'GRID?', 'dec.bgGridUnconfTip': 'Ce carré est contacté mais pas encore confirmé — une QSL à relancer, pas un QSO à faire.', 'dec.colGrid': 'Locator', 'dec.colState': 'État', 'dec.colCountry': 'Pays', 'dec.colBand': 'Bande', 'dec.colMode': 'Mode', 'dec.colStatus': 'Statut', 'dec.stateTip': 'État US', 'dec.wkd': 'Fait',
'dec.bgPota': 'POTA', 'dec.bgGrid': 'LOC', 'dec.bgPfx': 'PFX', 'dec.bgCounty': 'CTY', 'dec.bgPota': 'POTA', 'dec.bgGrid': 'LOC', 'dec.bgPfx': 'PFX', 'dec.bgState': 'Nouvel État', 'dec.bgCounty': 'CTY',
'dec.stNew': 'NOUV', 'dec.stBand': 'BANDE', 'dec.stMode': 'MODE', 'dec.stSlot': 'SLOT', 'dec.stCall': 'IND', 'dec.stNew': 'NOUV', 'dec.stBand': 'BANDE', 'dec.stMode': 'MODE', 'dec.stSlot': 'SLOT', 'dec.stCall': 'IND',
'dec.empty': "Aucun decode pour l'instant. Ils arrivent de WSJT-X, JTDX ou MSHV par le lien UDP entrant (Reglages -> UDP).", 'dec.empty': "Aucun decode pour l'instant. Ils arrivent de WSJT-X, JTDX ou MSHV par le lien UDP entrant (Reglages -> UDP).",
'dec.emptyFiltered': 'Aucun decode ne correspond a ces filtres.', 'dec.emptyFiltered': 'Aucun decode ne correspond a ces filtres.',
@@ -858,7 +858,7 @@ const fr: Dict = {
'clu.slotHighlight': 'Colorer les stations non contactées sur cette bande et ce mode', 'clu.slotHighlight': 'Colorer les stations non contactées sur cette bande et ce mode',
'clu.slotHighlightHint': "(par indicatif, quel que soit le statut de l'entité)", 'clu.slotHighlightHint': "(par indicatif, quel que soit le statut de l'entité)",
'rq.searchPh': 'Chercher un indicatif… 4S · *4S · *4S*', 'rq.searchTip': 'Un mot simple correspond au DÉBUT de lindicatif : 4S trouve 4S7AB. * remplace nimporte quelle suite de caractères et ? exactement un, donc *4S se termine par 4S, *4S* le contient nimporte où, et F?BPO correspond à F4BPO.', 'rq.searchPh': 'Chercher un indicatif… 4S · *4S · *4S*', 'rq.searchTip': 'Un mot simple correspond au DÉBUT de lindicatif : 4S trouve 4S7AB. * remplace nimporte quelle suite de caractères et ? exactement un, donc *4S se termine par 4S, *4S* le contient nimporte où, et F?BPO correspond à F4BPO.',
'gsc.scope': 'Carré déjà fait selon', 'gsc.hunt': 'Chasser', 'gsc.huntNew': 'Nouveau — jamais contacté', 'gsc.huntUnconf': 'Nouveau et non confirmé', 'gsc.scope_band_digi': 'Cette bande + tout mode numérique', 'gsc.scope_band_mode': 'Cette bande + ce mode exact', 'gsc.scope_band_ftx': 'Cette bande + tout mode FT (FT8/FT4/FT2)', 'gsc.scope_mix_digi': 'Toutes bandes + tout mode numérique', 'gsc.scope_mix_mode': 'Toutes bandes + ce mode exact', 'gsc.scope_mix_ftx': 'Toutes bandes + tout mode FT (FT8/FT4/FT2)', 'gsc.hint': 'Détermine quand un carré cesse d’être NEW. Plus cest étroit, plus il y a de carrés à chasser : par bande et par mode exact est le plus exigeant, toutes bandes et tout numérique le moins. Chasser aussi les non confirmés garde un carré recherché jusqu’à une confirmation QSL, LoTW ou eQSL — il manque toujours au diplôme dici là.', 'chg.mode': 'Chasse', 'chg.sources': 'Confirmé par', 'chg.card': 'Carte QSL', 'dec.unconfTip': 'Contacté mais non confirmé — une QSL à chasser', 'gsc.scope': 'Carré déjà fait selon', 'gsc.hunt': 'Chasser', 'gsc.huntNew': 'Nouveau — jamais contacté', 'gsc.huntUnconf': 'Nouveau et non confirmé', 'gsc.scope_band_digi': 'Cette bande + tout mode numérique', 'gsc.scope_band_mode': 'Cette bande + ce mode exact', 'gsc.scope_band_ftx': 'Cette bande + tout mode FT (FT8/FT4/FT2)', 'gsc.scope_mix_digi': 'Toutes bandes + tout mode numérique', 'gsc.scope_mix_mode': 'Toutes bandes + ce mode exact', 'gsc.scope_mix_ftx': 'Toutes bandes + tout mode FT (FT8/FT4/FT2)', 'gsc.hint': 'Détermine quand un carré cesse d’être NEW. Plus cest étroit, plus il y a de carrés à chasser : par bande et par mode exact est le plus exigeant, toutes bandes et tout numérique le moins. Chasser aussi les non confirmés garde un carré recherché jusqu’à une confirmation QSL, LoTW ou eQSL — il manque toujours au diplôme dici là.',
'gsm.basemap': 'Fond de carte', 'gsm.title': 'Carrés locator', 'gsm.all': 'Tout', 'gsm.phone': 'Phonie', 'gsm.cw': 'CW', 'gsm.digital': 'Numérique', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmés', 'gsm.worked': 'contactés', 'gsm.colConfirmed': 'Couleur des carrés confirmés', 'gsm.colWorked': 'Couleur des carrés contactés (non confirmés)', 'gsm.colReset': 'Revenir aux couleurs du thème', 'gsm.refresh': 'Recompter depuis le journal', 'gsm.count': '{n} carrés · {c} confirmés', 'gsm.basemap': 'Fond de carte', 'gsm.title': 'Carrés locator', 'gsm.all': 'Tout', 'gsm.phone': 'Phonie', 'gsm.cw': 'CW', 'gsm.digital': 'Numérique', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmés', 'gsm.worked': 'contactés', 'gsm.colConfirmed': 'Couleur des carrés confirmés', 'gsm.colWorked': 'Couleur des carrés contactés (non confirmés)', 'gsm.colReset': 'Revenir aux couleurs du thème', 'gsm.refresh': 'Recompter depuis le journal', 'gsm.count': '{n} carrés · {c} confirmés',
'bo.nearKm': 'Compter les récepteurs à moins de', 'bo.nearKmHint': 'Un report ne prouve TON chemin que sil a été collecté près de chez toi. Plus petit est plus local, mais laisse moins de récepteurs à l’écoute — trop petit, la veille na plus rien à observer. 300 km emprunte les oreilles de toute une région ; 100 km convient au 2 m, où un conduit est étroit.', 'bo.nearKm': 'Compter les récepteurs à moins de', 'bo.nearKmHint': 'Un report ne prouve TON chemin que sil a été collecté près de chez toi. Plus petit est plus local, mais laisse moins de récepteurs à l’écoute — trop petit, la veille na plus rien à observer. 300 km emprunte les oreilles de toute une région ; 100 km convient au 2 m, où un conduit est étroit.',
'bo.open': 'ouvert', 'bo.liveTip': '{band} est ouvert — {n} stations, ~{km} km, {sector}{season}. Cliquer pour le bandmap.', 'bo.enable': 'Surveiller les ouvertures de bande', 'bo.enableHint': "(10, 12, 6, 4 et 2 m. Activer ajoute les deux nœuds RBN et souscrit au flux PSK Reporter — la détection a besoin de bien plus d oreilles qu un cluster ne peut en fournir.)", 'bo.feedUp': 'Flux PSK Reporter actif — {n} décodages vus', 'bo.feedDown': 'Flux PSK Reporter inactif — il faut ton locator, et un instant pour se connecter', 'clu.workedSameSlot': 'Déjà contacté seulement sur le même slot', 'bo.open': 'ouvert', 'bo.liveTip': '{band} est ouvert — {n} stations, ~{km} km, {sector}{season}. Cliquer pour le bandmap.', 'bo.enable': 'Surveiller les ouvertures de bande', 'bo.enableHint': "(10, 12, 6, 4 et 2 m. Activer ajoute les deux nœuds RBN et souscrit au flux PSK Reporter — la détection a besoin de bien plus d oreilles qu un cluster ne peut en fournir.)", 'bo.feedUp': 'Flux PSK Reporter actif — {n} décodages vus', 'bo.feedDown': 'Flux PSK Reporter inactif — il faut ton locator, et un instant pour se connecter', 'clu.workedSameSlot': 'Déjà contacté seulement sur le même slot',
@@ -955,7 +955,7 @@ const fr: Dict = {
'wkp.cwSpeed': 'Vitesse CW (WPM)', 'wkp.faster': 'Plus rapide', 'wkp.slower': 'Plus lent', 'wkp.cwText': 'Texte CW', 'wkp.sendOnTypeHint': 'Manipule chaque caractère en direct à la frappe (retour arrière supprime les caractères non émis)', 'wkp.sendOnType': 'émission à la frappe', 'wkp.phLive': 'Tape — émis en direct…', 'wkp.phEnter': 'Tape et appuie sur Entrée pour émettre…', 'wkp.clear': 'Effacer', 'wkp.send': 'Émettre', 'wkp.abort': 'Interrompre (vider le tampon du manipulateur)', 'wkp.stop': 'Stop', 'wkp.cwSpeed': 'Vitesse CW (WPM)', 'wkp.faster': 'Plus rapide', 'wkp.slower': 'Plus lent', 'wkp.cwText': 'Texte CW', 'wkp.sendOnTypeHint': 'Manipule chaque caractère en direct à la frappe (retour arrière supprime les caractères non émis)', 'wkp.sendOnType': 'émission à la frappe', 'wkp.phLive': 'Tape — émis en direct…', 'wkp.phEnter': 'Tape et appuie sur Entrée pour émettre…', 'wkp.clear': 'Effacer', 'wkp.send': 'Émettre', 'wkp.abort': 'Interrompre (vider le tampon du manipulateur)', 'wkp.stop': 'Stop',
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL", 'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL",
'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}', 'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}',
'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Répète un message libellé CQ à intervalle régulier jusqu\'à l\'arrêt ou la lecture d\'un autre slot', 'dvkp.gap': 'Intervalle', 'dvkp.notPhone': 'Le manipulateur vocal n\'émet qu\'en mode phonie (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message', 'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Répète un message libellé CQ à intervalle régulier jusqu\'à l\'arrêt ou la lecture d\'un autre slot', 'dvkp.gap': 'Intervalle', 'dvkp.notPhone': 'Le manipulateur vocal n\'émet qu\'en mode phonie (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1F12.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message',
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour nafficher que la bande courante', 'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour nafficher que la bande courante',
'ampw.title': 'Amplificateur', 'ampw.all': 'Tous les amplis', 'ampw.pick': 'Ampli affiché par le widget', 'ampw.title': 'Amplificateur', 'ampw.all': 'Tous les amplis', 'ampw.pick': 'Ampli affiché par le widget',
'ampw.showHint': 'Amplificateur · cliquer pour afficher', 'ampw.hideHint': 'Amplificateur — affiché · cliquer pour masquer', 'ampw.showHint': 'Amplificateur · cliquer pour afficher', 'ampw.hideHint': 'Amplificateur — affiché · cliquer pour masquer',
@@ -976,7 +976,7 @@ const fr: Dict = {
'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Déjà contacté', 'ncp.wbHint': 'Cliquer une station (on air ou roster) pour voir les QSO précédents', 'ncp.wbNone': 'Aucun QSO précédent avec', 'ncp.wbFirst': 'premier', 'ncp.wbLast': 'dernier', 'ncp.wbResize': 'Glisser pour redimensionner', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'ordre de passage du micro · ⬆⬇ pour réordonner · double-clic → éditer · « Logger & terminer »', 'ncp.moveUp': "Monter dans l'ordre de passage", 'ncp.moveDown': "Descendre dans l'ordre de passage", 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.logAll': 'Logger tout le monde ({n})', 'ncp.logAllConfirm': 'Logger les {n} station(s) on air dans le logbook ?', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET', 'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Déjà contacté', 'ncp.wbHint': 'Cliquer une station (on air ou roster) pour voir les QSO précédents', 'ncp.wbNone': 'Aucun QSO précédent avec', 'ncp.wbFirst': 'premier', 'ncp.wbLast': 'dernier', 'ncp.wbResize': 'Glisser pour redimensionner', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'ordre de passage du micro · ⬆⬇ pour réordonner · double-clic → éditer · « Logger & terminer »', 'ncp.moveUp': "Monter dans l'ordre de passage", 'ncp.moveDown': "Descendre dans l'ordre de passage", 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.logAll': 'Logger tout le monde ({n})', 'ncp.logAllConfirm': 'Logger les {n} station(s) on air dans le logbook ?', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET',
'udpp.relayInstead': 'Pour un commutateur dantennes ou une carte de relais, préférez Station Control → relais : il tient l’état, relit les cartes au démarrage et ne recommute pas quand vous bougez dans la même bande. Un commutateur fait main sy déclare en type « Relais HTTP ».', 'udpp.relayInstead': 'Pour un commutateur dantennes ou une carte de relais, préférez Station Control → relais : il tient l’état, relit les cartes au démarrage et ne recommute pas quand vous bougez dans la même bande. Un commutateur fait main sy déclare en type « Relais HTTP ».',
'udpp.svcCustomLabel': 'Message personnalisé', 'udpp.svcCustomHint': 'Vous choisissez ce qui le déclenche et ce quil dit. Datagramme UDP ou requête HTTP — cette dernière est la façon dont se pilotent la plupart des commutateurs dantennes.', 'udpp.trigger': 'Déclencheur', 'udpp.trgBand': 'Changement de bande (radio)', 'udpp.trgQso': 'QSO enregistré', 'udpp.trgRotator': 'Commande de rotor', 'udpp.trgLookup': 'Recherche dindicatif', 'udpp.transport': 'Envoi', 'udpp.transportUdp': 'Message UDP', 'udpp.transportUrl': 'URL (HTTP GET)', 'udpp.url': 'URL', 'udpp.urlHint': 'Les valeurs sont encodées pour lURL. Les identifiants peuvent s’écrire http://user:pass@hôte/… — stockés tels quels, à réserver à votre réseau local.', 'udpp.template': 'Message', 'udpp.lineEnd': 'Fin de ligne', 'udpp.lineEndNone': 'Aucune', 'udpp.fieldsAvailable': 'Champs de ce déclencheur', 'udpp.fieldsHint': 'Tout autre champ rendra du vide.', 'udpp.svcCustomLabel': 'Message personnalisé', 'udpp.svcCustomHint': 'Vous choisissez ce qui le déclenche et ce quil dit. Datagramme UDP ou requête HTTP — cette dernière est la façon dont se pilotent la plupart des commutateurs dantennes.', 'udpp.trigger': 'Déclencheur', 'udpp.trgBand': 'Changement de bande (radio)', 'udpp.trgQso': 'QSO enregistré', 'udpp.trgRotator': 'Commande de rotor', 'udpp.trgLookup': 'Recherche dindicatif', 'udpp.transport': 'Envoi', 'udpp.transportUdp': 'Message UDP', 'udpp.transportUrl': 'URL (HTTP GET)', 'udpp.url': 'URL', 'udpp.urlHint': 'Les valeurs sont encodées pour lURL. Les identifiants peuvent s’écrire http://user:pass@hôte/… — stockés tels quels, à réserver à votre réseau local.', 'udpp.template': 'Message', 'udpp.lineEnd': 'Fin de ligne', 'udpp.lineEndNone': 'Aucune', 'udpp.fieldsAvailable': 'Champs de ce déclencheur', 'udpp.fieldsHint': 'Tout autre champ rendra du vide.',
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcWsjtRelayLabel': 'Relayer le flux WSJT-X', 'udpp.svcWsjtRelayHint': 'Réémet chaque datagramme reçu de WSJT-X / JTDX / MSHV, octet pour octet, vers un autre logiciel — JTAlert, GridTracker, un second carnet. L’émetteur ne parle qu’à une seule adresse : cest ce qui permet de les faire tourner à côté dOpsLog. À pointer sur le port de lAUTRE logiciel, jamais sur un port d’écoute dOpsLog.', 'udpp.svcWsjtLogLabel': 'QSO enregistré WSJT-X', 'udpp.svcWsjtLogHint': 'Annonce chaque QSO enregistré sur linterface UDP WSJT-X — les deux messages que WSJT-X émet lui-même. Pour tout logger qui écoute là plutôt que lADIF en texte brut (les sockets UDP supplémentaires de Logger32, par exemple).', 'udpp.svcDbLabel': "ADIF Message", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.svcPstLabel': 'Fréquence PstRotator', 'udpp.svcPstHint': "Envoie la fréquence du poste en <PST><FREQUENCY> à chaque changement — règle le tracker de PstRotatorAz sur DXLog.net (port 12040 par défaut).", 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (fréq + mode)', 'udpp.svcN1mmRadioHint': "Diffuse la fréquence/mode du poste en XML RadioInfo N1MM Logger+ à chaque changement — lu par PstRotator (tracker N1MM) et beaucoup d'autres outils.", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer', 'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcWsjtRelayLabel': 'Relayer le flux WSJT-X', 'udpp.svcWsjtRelayHint': 'Réémet chaque datagramme reçu de WSJT-X / JTDX / MSHV, octet pour octet, vers un autre logiciel — JTAlert, GridTracker, un second carnet. L’émetteur ne parle qu’à une seule adresse : cest ce qui permet de les faire tourner à côté dOpsLog. À pointer sur le port de lAUTRE logiciel, jamais sur un port d’écoute dOpsLog.', 'udpp.svcWsjtLogLabel': 'QSO enregistré WSJT-X', 'udpp.svcWsjtLogHint': 'Annonce chaque QSO enregistré sur linterface UDP WSJT-X — les deux messages que WSJT-X émet lui-même. Pour tout logger qui écoute là plutôt que lADIF en texte brut (les sockets UDP supplémentaires de Logger32, par exemple).', 'udpp.svcDbLabel': "ADIF Message", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.svcPstLabel': 'Fréquence PstRotator', 'udpp.svcPstHint': "Envoie la fréquence du poste en <PST><FREQUENCY> à chaque changement — règle le tracker de PstRotatorAz sur DXLog.net (port 12040 par défaut).", 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (fréq + mode)', 'udpp.svcN1mmRadioHint': "Diffuse la fréquence/mode du poste en XML RadioInfo N1MM Logger+ à chaque changement — lu par PstRotator (tracker N1MM) et beaucoup d'autres outils.", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.highlight': 'Surligner les décodages dans WSJT-X / JTDX', 'udpp.highlightHint': 'Colore les indicatifs dans la fenêtre Band Activity du décodeur selon votre log : watchlist en rose, nouveau DXCC en vert, nouvelle bande pour son entité en orange. Appliqué en direct à larrivée des décodages.', 'udpp.followMode': 'Changer le mode du décodeur depuis les spots', 'udpp.followModeHint': 'Cliquer un spot FT4 pendant que WSJT-X / JTDX est en FT8 change aussi son mode.', 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer',
'fltb.fCallsign': 'Callsign', 'fltb.fCreated': 'Ajouté au journal le', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'Paper QSL sent status', 'fltb.fQslSentDate': 'Paper QSL sent date', 'fltb.fQslRcvd': 'Paper QSL received status', 'fltb.fQslRcvdDate': 'Paper QSL received date', 'fltb.fQslSentVia': 'QSL envoyée via', 'fltb.fQslRcvdVia': 'QSL reçue via', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent status', 'fltb.fLotwSentDate': 'LoTW sent date', 'fltb.fLotwRcvd': 'LoTW received status', 'fltb.fLotwRcvdDate': 'LoTW received date', 'fltb.fEqslSent': 'eQSL sent status', 'fltb.fEqslSentDate': 'eQSL sent date', 'fltb.fEqslRcvd': 'eQSL received status', 'fltb.fEqslRcvdDate': 'eQSL received date', 'fltb.fQrzSent': 'QRZ.com sent status', 'fltb.fQrzSentDate': 'QRZ.com sent date', 'fltb.fQrzRcvd': 'QRZ.com received status', 'fltb.fQrzRcvdDate': 'QRZ.com received date', 'fltb.fClublogSent': 'Club Log sent status', 'fltb.fClublogSentDate': 'Club Log sent date', 'fltb.fHrdlogSent': 'HRDLog sent status', 'fltb.fHrdlogSentDate': 'HRDLog sent date', 'fltb.fHamlogSent': 'HAMLOG.online envoyé', 'fltb.fHamlogSentDate': "HAMLOG.online date d'envoi", 'fltb.fHamlogRcvd': 'HAMLOG.online reçu', 'fltb.fHamlogRcvdDate': 'HAMLOG.online date de réception', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Station callsign (my call)', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opIn': 'est parmi', 'fltb.opNotIn': 'n est pas parmi', 'fltb.listPh': '2m, 70cm — séparés par des virgules', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.presetSaved': 'Filtre « {name} » enregistré', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer', 'fltb.fCallsign': 'Callsign', 'fltb.fCreated': 'Ajouté au journal le', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'Paper QSL sent status', 'fltb.fQslSentDate': 'Paper QSL sent date', 'fltb.fQslRcvd': 'Paper QSL received status', 'fltb.fQslRcvdDate': 'Paper QSL received date', 'fltb.fQslSentVia': 'QSL envoyée via', 'fltb.fQslRcvdVia': 'QSL reçue via', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent status', 'fltb.fLotwSentDate': 'LoTW sent date', 'fltb.fLotwRcvd': 'LoTW received status', 'fltb.fLotwRcvdDate': 'LoTW received date', 'fltb.fEqslSent': 'eQSL sent status', 'fltb.fEqslSentDate': 'eQSL sent date', 'fltb.fEqslRcvd': 'eQSL received status', 'fltb.fEqslRcvdDate': 'eQSL received date', 'fltb.fQrzSent': 'QRZ.com sent status', 'fltb.fQrzSentDate': 'QRZ.com sent date', 'fltb.fQrzRcvd': 'QRZ.com received status', 'fltb.fQrzRcvdDate': 'QRZ.com received date', 'fltb.fClublogSent': 'Club Log sent status', 'fltb.fClublogSentDate': 'Club Log sent date', 'fltb.fHrdlogSent': 'HRDLog sent status', 'fltb.fHrdlogSentDate': 'HRDLog sent date', 'fltb.fHamlogSent': 'HAMLOG.online envoyé', 'fltb.fHamlogSentDate': "HAMLOG.online date d'envoi", 'fltb.fHamlogRcvd': 'HAMLOG.online reçu', 'fltb.fHamlogRcvdDate': 'HAMLOG.online date de réception', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Station callsign (my call)', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opIn': 'est parmi', 'fltb.opNotIn': 'n est pas parmi', 'fltb.listPh': '2m, 70cm — séparés par des virgules', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.presetSaved': 'Filtre « {name} » enregistré', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer',
'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.newCounty': 'NOUV', 'detp.newCountyTip': 'Comté jamais contacté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'CQ', 'detp.ituZone': 'ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via (manager)', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.ambiguous': 'Ambigu — choisissez :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact', 'detp.contactedWeb': 'Site web', 'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.newCounty': 'NOUV', 'detp.newCountyTip': 'Comté jamais contacté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'CQ', 'detp.ituZone': 'ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via (manager)', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.ambiguous': 'Ambigu — choisissez :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact', 'detp.contactedWeb': 'Site web',
'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.', 'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.',
@@ -1023,7 +1023,7 @@ const fr: Dict = {
'wbg.awardTip': '{name} — référence comptée pour ce QSO', 'wbg.typeCall': 'Saisissez un indicatif dans la barre pour voir les contacts précédents.', 'wbg.checking': 'vérification…', 'wbg.new': 'NOUVEAU', 'wbg.noPriorPre': 'Aucun QSO précédent avec ', 'wbg.noPriorPost': '.', 'wbg.workedBefore': 'Déjà contacté', 'wbg.first': 'Premier :', 'wbg.last': 'Dernier :', 'wbg.dxcc': 'DXCC :', 'wbg.entityQsos': '{n} QSO avec cette entité', 'wbg.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'wbg.clearFilters': 'Effacer les filtres', 'wbg.columns': 'Colonnes', 'wbg.olderQsos': '+ {n} QSO plus anciens (non affichés — limités pour la performance)', 'wbg.pickerTitle': 'Colonnes « Déjà contacté »', 'wbg.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau « Déjà contacté ».', 'wbg.allGroups': 'Tous les groupes :', 'wbg.all': 'tout', 'wbg.none': 'aucun', 'wbg.grpAwards': 'Diplômes', 'wbg.resetDefaults': 'Réinitialiser', 'wbg.done': 'Terminé', 'wbg.awardTip': '{name} — référence comptée pour ce QSO', 'wbg.typeCall': 'Saisissez un indicatif dans la barre pour voir les contacts précédents.', 'wbg.checking': 'vérification…', 'wbg.new': 'NOUVEAU', 'wbg.noPriorPre': 'Aucun QSO précédent avec ', 'wbg.noPriorPost': '.', 'wbg.workedBefore': 'Déjà contacté', 'wbg.first': 'Premier :', 'wbg.last': 'Dernier :', 'wbg.dxcc': 'DXCC :', 'wbg.entityQsos': '{n} QSO avec cette entité', 'wbg.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'wbg.clearFilters': 'Effacer les filtres', 'wbg.columns': 'Colonnes', 'wbg.olderQsos': '+ {n} QSO plus anciens (non affichés — limités pour la performance)', 'wbg.pickerTitle': 'Colonnes « Déjà contacté »', 'wbg.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau « Déjà contacté ».', 'wbg.allGroups': 'Tous les groupes :', 'wbg.all': 'tout', 'wbg.none': 'aucun', 'wbg.grpAwards': 'Diplômes', 'wbg.resetDefaults': 'Réinitialiser', 'wbg.done': 'Terminé',
'chn.title': 'Chasse au nouveau', 'chn.close': 'Masquer le panneau', 'chn.filterHint': 'Afficher ou masquer ce type', 'chn.allFiltered': 'Tout ce qui est entendu est filtré — réactivez une catégorie ci-dessus.', 'chn.toggle': 'Chasse au nouveau', 'chn.count': '{n} entendus', 'chn.loading': 'En attente du flux…', 'chn.empty': 'Rien de nouveau décodé près de vous pour le moment.', 'chn.digitalOnly': 'PSK Reporter — modes numériques uniquement, entendus à moins de ~300 km.', 'chn.option': 'Chasse au nouveau (PSK Reporter)', 'chn.optionHelp': 'Liste les stations décodées près de chez vous qui sont nouvelles par rapport à votre log — entité, bande, mode, créneau, préfixe ou carré. Modes numériques uniquement.', 'chn.show': 'Panneau chasse au nouveau', 'chn.title': 'Chasse au nouveau', 'chn.close': 'Masquer le panneau', 'chn.filterHint': 'Afficher ou masquer ce type', 'chn.allFiltered': 'Tout ce qui est entendu est filtré — réactivez une catégorie ci-dessus.', 'chn.toggle': 'Chasse au nouveau', 'chn.count': '{n} entendus', 'chn.loading': 'En attente du flux…', 'chn.empty': 'Rien de nouveau décodé près de vous pour le moment.', 'chn.digitalOnly': 'PSK Reporter — modes numériques uniquement, entendus à moins de ~300 km.', 'chn.option': 'Chasse au nouveau (PSK Reporter)', 'chn.optionHelp': 'Liste les stations décodées près de chez vous qui sont nouvelles par rapport à votre log — entité, bande, mode, créneau, préfixe ou carré. Modes numériques uniquement.', 'chn.show': 'Panneau chasse au nouveau',
'clg2.allFiltered': '{n} spots reçus, aucun affiché — vos filtres les masquent tous.', 'clg2.activeFilters': 'Actifs :', 'clg2.clearAllFilters': 'Effacer tous les filtres', 'clg2.fBandLock': 'bande verrouillée sur la radio', 'clg2.fBands': 'bandes {list}', 'clg2.fModeLock': 'mode verrouillé sur la radio', 'clg2.fModes': 'modes {list}', 'clg2.fStatus': 'pastilles de statut', 'clg2.fHideWorked': 'masquer les contactés', 'clg2.fLotwOnly': 'utilisateurs LoTW uniquement', 'clg2.fSpotterCont': 'continent du spotteur', 'clg2.fSource': 'un seul nœud source', 'clg2.fSearch': 'recherche « {q} »', 'clg2.allFiltered': '{n} spots reçus, aucun affiché — vos filtres les masquent tous.', 'clg2.activeFilters': 'Actifs :', 'clg2.clearAllFilters': 'Effacer tous les filtres', 'clg2.fBandLock': 'bande verrouillée sur la radio', 'clg2.fBands': 'bandes {list}', 'clg2.fModeLock': 'mode verrouillé sur la radio', 'clg2.fModes': 'modes {list}', 'clg2.fStatus': 'pastilles de statut', 'clg2.fHideWorked': 'masquer les contactés', 'clg2.fLotwOnly': 'utilisateurs LoTW uniquement', 'clg2.fSpotterCont': 'continent du spotteur', 'clg2.fSource': 'un seul nœud source', 'clg2.fSearch': 'recherche « {q} »',
'clg2.c.time': 'Heure', 'clg2.c.call': 'Indicatif', 'clg2.c.status': 'Statut', 'clg2.c.pota': 'POTA', 'clg2.c.sota': 'SOTA', 'clg2.c.freq': 'Fréq', 'clg2.c.band': 'Bande', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Préf.', 'clg2.c.cqz': 'Zone CQ', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'Zone ITU', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Chemin court (°)', 'clg2.h.sp_deg': 'CC°', 'clg2.c.lp_deg': 'Chemin long (°)', 'clg2.h.lp_deg': 'CL°', 'clg2.c.country': 'Pays', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Locator du spotter', 'clg2.h.locator': 'Loc spotter', 'clg2.c.county': 'Comté US', 'clg2.tipNewCounty': 'NOUVEAU COMTÉ — jamais contacté', 'clg2.tipNewPfx': "NOUVEAU PRÉFIXE — ce préfixe WPX n'a jamais été contacté", 'clg2.c.comment': 'Commentaire', 'clg2.c.received_at': 'Reçu le', 'clg2.h.received_at': 'Reçu UTC', 'clg2.c.raw': 'Brut', 'clg2.newDxcc': 'NOUV DXCC', 'clg2.newBandMode': 'NOUV B+M', 'clg2.newBand': 'NOUV BANDE', 'clg2.newMode': 'NOUV MODE', 'clg2.newSlot': 'NOUV SLOT', 'clg2.newCall': 'CALL NEUF', 'clg2.wkdCall': 'DÉJÀ QSO', 'clg2.newCounty': 'NOUV CTY', 'clg2.newGridUnconf': 'GRID?', 'clg2.newGrid': 'NOUV GRID', 'clg2.c.grid': 'Grid', 'clg2.tipNewGrid': 'NOUVEAU GRID — ce carré n a jamais été contacté (grid entendu dans un CQ sur le lien UDP)', 'clg2.newPfx': "NOUVEAU PFX", 'clg2.newPota': 'NOUV POTA', 'clg2.tipNewDxcc': 'NOUVEAU DXCC : {country}', 'clg2.tipWorkedCall': 'Indicatif déjà contacté', 'clg2.tipNewBandMode': 'NOUVELLE BANDE ET NOUVEAU MODE pour cette entité — aucun des deux na été fait avec elle', 'clg2.tipNewBand': 'NOUVELLE BANDE pour cette entité', 'clg2.tipNewSlotBand': 'NOUVEAU SLOT (mode pas encore contacté sur cette bande)', 'clg2.tipNewMode': 'NOUVEAU MODE (ce mode jamais contacté sur cette entité)', 'clg2.tipNewSlot': 'NOUVEAU SLOT (cette bande+mode pas encore contactée)', 'clg2.tipNewCall': "CALL NEUF — cet indicatif n a jamais été contacté sur cette bande et ce mode (l entité, si)", 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Géo', 'clg2.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'clg2.clearFilters': 'Effacer les filtres', 'clg2.newSpots': '{n} nouveaux spots — cliquer pour reprendre', 'clg2.columns': 'Colonnes', 'clg2.pickerTitle': 'Colonnes du cluster', 'clg2.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau du cluster.', 'clg2.allGroups': 'Tous les groupes :', 'clg2.all': 'tout', 'clg2.none': 'aucun', 'clg2.resetDefaults': 'Réinitialiser', 'clg2.done': 'Terminé', 'clg2.c.time': 'Heure', 'clg2.c.call': 'Indicatif', 'clg2.c.status': 'Statut', 'clg2.c.pota': 'POTA', 'clg2.c.sota': 'SOTA', 'clg2.c.freq': 'Fréq', 'clg2.c.band': 'Bande', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Préf.', 'clg2.c.cqz': 'Zone CQ', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'Zone ITU', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Chemin court (°)', 'clg2.h.sp_deg': 'CC°', 'clg2.c.lp_deg': 'Chemin long (°)', 'clg2.h.lp_deg': 'CL°', 'clg2.c.country': 'Pays', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Locator du spotter', 'clg2.h.locator': 'Loc spotter', 'clg2.c.county': 'Comté US', 'clg2.tipNewCounty': 'NOUVEAU COMTÉ — jamais contacté', 'clg2.tipNewPfx': "NOUVEAU PRÉFIXE — ce préfixe WPX n'a jamais été contacté", 'clg2.c.comment': 'Commentaire', 'clg2.c.received_at': 'Reçu le', 'clg2.h.received_at': 'Reçu UTC', 'clg2.c.raw': 'Brut', 'clg2.newDxcc': 'NOUV DXCC', 'clg2.newBandMode': 'NOUV B+M', 'clg2.newBand': 'NOUV BANDE', 'clg2.newMode': 'NOUV MODE', 'clg2.newSlot': 'NOUV SLOT', 'clg2.newCall': 'CALL NEUF', 'clg2.wkdCall': 'DÉJÀ QSO', 'clg2.newCounty': 'NOUV CTY', 'clg2.newGridUnconf': 'GRID?', 'clg2.newState': 'Nouvel État', 'clg2.newGrid': 'NOUV GRID', 'clg2.c.grid': 'Grid', 'clg2.tipNewGrid': 'NOUVEAU GRID — ce carré n a jamais été contacté (grid entendu dans un CQ sur le lien UDP)', 'clg2.newPfx': "NOUVEAU PFX", 'clg2.newPota': 'NOUV POTA', 'clg2.tipNewDxcc': 'NOUVEAU DXCC : {country}', 'clg2.tipWorkedCall': 'Indicatif déjà contacté', 'clg2.tipNewBandMode': 'NOUVELLE BANDE ET NOUVEAU MODE pour cette entité — aucun des deux na été fait avec elle', 'clg2.tipNewBand': 'NOUVELLE BANDE pour cette entité', 'clg2.tipNewSlotBand': 'NOUVEAU SLOT (mode pas encore contacté sur cette bande)', 'clg2.tipNewMode': 'NOUVEAU MODE (ce mode jamais contacté sur cette entité)', 'clg2.tipNewSlot': 'NOUVEAU SLOT (cette bande+mode pas encore contactée)', 'clg2.tipNewCall': "CALL NEUF — cet indicatif n a jamais été contacté sur cette bande et ce mode (l entité, si)", 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Géo', 'clg2.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'clg2.clearFilters': 'Effacer les filtres', 'clg2.newSpots': '{n} nouveaux spots — cliquer pour reprendre', 'clg2.columns': 'Colonnes', 'clg2.pickerTitle': 'Colonnes du cluster', 'clg2.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau du cluster.', 'clg2.allGroups': 'Tous les groupes :', 'clg2.all': 'tout', 'clg2.none': 'aucun', 'clg2.resetDefaults': 'Réinitialiser', 'clg2.done': 'Terminé',
// Périphériques audio et manipulateur vocal (Préférences → Périphériques audio). // Périphériques audio et manipulateur vocal (Préférences → Périphériques audio).
'aud.refreshDevices': 'Actualiser les périphériques', 'aud.fromRadio': 'Depuis la radio (entrée RX)', 'aud.toRadio': 'Vers la radio (sortie TX)', 'aud.recMic': "Micro d'enregistrement", 'aud.listening': 'Écoute (pré-écoute)', 'aud.refreshDevices': 'Actualiser les périphériques', 'aud.fromRadio': 'Depuis la radio (entrée RX)', 'aud.toRadio': 'Vers la radio (sortie TX)', 'aud.recMic': "Micro d'enregistrement", 'aud.listening': 'Écoute (pré-écoute)',
'aud.phFromRadio': 'Sortie audio du poste → entrée carte son', 'aud.phToRadio': 'Sortie carte son → entrée micro/data du poste', 'aud.phRecMic': 'Votre microphone (enregistrer les messages vocaux)', 'aud.phListening': 'Haut-parleurs locaux pour la pré-écoute', 'aud.phFromRadio': 'Sortie audio du poste → entrée carte son', 'aud.phToRadio': 'Sortie carte son → entrée micro/data du poste', 'aud.phRecMic': 'Votre microphone (enregistrer les messages vocaux)', 'aud.phListening': 'Haut-parleurs locaux pour la pré-écoute',
@@ -1037,7 +1037,7 @@ const fr: Dict = {
'aud.preroll': 'Pré-enregistrement (secondes)', 'aud.format': 'Format de fichier', 'aud.wav': 'WAV (sans perte, plus volumineux)', 'aud.mp3': 'MP3 (compressé, léger)', 'aud.preroll': 'Pré-enregistrement (secondes)', 'aud.format': 'Format de fichier', 'aud.wav': 'WAV (sans perte, plus volumineux)', 'aud.mp3': 'MP3 (compressé, léger)',
'aud.fromLevel': 'Niveau depuis la radio', 'aud.txLevel': 'Niveau du voice keyer', 'aud.txLevelHint': "Niveau des messages enregistrés envoyés à la radio. Augmentez-le si votre voice keyer est bien plus faible que votre micro ; Lire fait entendre ce même niveau. Si la radio n'émet presque rien, c'est que sa source de modulation est restée le micro de façade : sur un FTDX10, réglez MENU → SSB MOD SOURCE sur REAR (l'entrée USB).", 'aud.micLevel': 'Niveau micro', 'aud.qsoPlayLevel': 'Niveau de relecture QSO', 'aud.levelHint': 'Si votre voix est plus forte que la station, baissez le niveau micro.', 'aud.fromLevel': 'Niveau depuis la radio', 'aud.txLevel': 'Niveau du voice keyer', 'aud.txLevelHint': "Niveau des messages enregistrés envoyés à la radio. Augmentez-le si votre voice keyer est bien plus faible que votre micro ; Lire fait entendre ce même niveau. Si la radio n'émet presque rien, c'est que sa source de modulation est restée le micro de façade : sur un FTDX10, réglez MENU → SSB MOD SOURCE sur REAR (l'entrée USB).", 'aud.micLevel': 'Niveau micro', 'aud.qsoPlayLevel': 'Niveau de relecture QSO', 'aud.levelHint': 'Si votre voix est plus forte que la station, baissez le niveau micro.',
'aud.autoSend': "Envoyer automatiquement l'enregistrement à la station par e-mail lorsque j'enregistre un QSO", 'aud.autoSend': "Envoyer automatiquement l'enregistrement à la station par e-mail lorsque j'enregistre un QSO",
'aud.dvkTitle': 'Messages du manipulateur vocal (F1F6)', 'aud.pttMethod': 'Méthode PTT', 'aud.pttNone': 'Aucune (VOX)', 'aud.pttCat': 'CAT (la liaison radio)', 'aud.pttRts': 'RTS série', 'aud.pttDtr': 'DTR série', 'aud.dvkTitle': 'Messages du manipulateur vocal (F1F12)', 'aud.deleteMsg': 'Supprimer ce message', 'aud.pttMethod': 'Méthode PTT', 'aud.pttNone': 'Aucune (VOX)', 'aud.pttCat': 'CAT (la liaison radio)', 'aud.pttRts': 'RTS série', 'aud.pttDtr': 'DTR série',
'aud.testPtt': 'Tester le PTT', 'aud.pttPort': 'Port COM du PTT', 'aud.pickPort': 'Choisir un port COM', 'aud.selectPort': '— choisir —', 'aud.refresh': 'Actualiser', 'aud.testPtt': 'Tester le PTT', 'aud.pttPort': 'Port COM du PTT', 'aud.pickPort': 'Choisir un port COM', 'aud.selectPort': '— choisir —', 'aud.refresh': 'Actualiser',
'aud.msgPlaceholder': 'Libellé du message {n} (CQ, report, 73…)', 'aud.holdRec': '● Maintenir pour enreg.', 'aud.recordingNow': '● Enregistrement…', 'aud.play': '▶ Lire', 'aud.stop': '■ Arrêter', 'aud.msgPlaceholder': 'Libellé du message {n} (CQ, report, 73…)', 'aud.holdRec': '● Maintenir pour enreg.', 'aud.recordingNow': '● Enregistrement…', 'aud.play': '▶ Lire', 'aud.stop': '■ Arrêter',
'aud.errPttTest': 'Test PTT : ', 'aud.errRecord': 'Enregistrement : ', 'aud.errSave': 'Sauvegarde : ', 'aud.errPlay': 'Lecture : ', 'aud.errPttTest': 'Test PTT : ', 'aud.errRecord': 'Enregistrement : ', 'aud.errSave': 'Sauvegarde : ', 'aud.errPlay': 'Lecture : ',
+2 -1
View File
@@ -18,7 +18,7 @@
// grid magenta — the last hue in the categorical set that is not already // grid magenta — the last hue in the categorical set that is not already
// spoken for here and does not read as a status; a grid is // spoken for here and does not read as a status; a grid is
// never urgent the way a new entity is // never urgent the way a new entity is
export type SpotMarkerKey = 'new_pota' | 'new_county' | 'new_pfx' | 'worked_call' | 'new_grid'; export type SpotMarkerKey = 'new_pota' | 'new_county' | 'new_pfx' | 'worked_call' | 'new_grid' | 'new_state';
export type SpotMarker = { export type SpotMarker = {
key: SpotMarkerKey; key: SpotMarkerKey;
@@ -32,6 +32,7 @@ export const SPOT_MARKERS: SpotMarker[] = [
{ key: 'new_county', colour: 'var(--chart-5)', labelKey: 'clg2.newCounty' }, { key: 'new_county', colour: 'var(--chart-5)', labelKey: 'clg2.newCounty' },
{ key: 'new_pfx', colour: 'var(--caution)', labelKey: 'clg2.newPfx' }, { key: 'new_pfx', colour: 'var(--caution)', labelKey: 'clg2.newPfx' },
{ key: 'new_grid', colour: 'var(--chart-7)', labelKey: 'clg2.newGrid' }, { key: 'new_grid', colour: 'var(--chart-7)', labelKey: 'clg2.newGrid' },
{ key: 'new_state', colour: 'var(--chart-3)', labelKey: 'clg2.newState' },
{ key: 'worked_call', colour: 'var(--info)', labelKey: 'clg2.wkdCall' }, { key: 'worked_call', colour: 'var(--info)', labelKey: 'clg2.wkdCall' },
]; ];
+1 -1
View File
@@ -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.27.1'; export const APP_VERSION = '0.27.3';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+16
View File
@@ -142,6 +142,8 @@ export function ComputeQSOAwardRefs(arg1:qso.QSO):Promise<Array<main.QSOAwardRef
export function ComputeStationInfo(arg1:string,arg2:string):Promise<main.StationInfoComputed>; export function ComputeStationInfo(arg1:string,arg2:string):Promise<main.StationInfoComputed>;
export function ConfigureDecoderMode(arg1:string):Promise<void>;
export function ConnectAllClusters():Promise<void>; export function ConnectAllClusters():Promise<void>;
export function ConnectClusterServer(arg1:number):Promise<void>; export function ConnectClusterServer(arg1:number):Promise<void>;
@@ -158,6 +160,8 @@ export function CreateDatabase(arg1:string):Promise<void>;
export function DVKCancelRecord():Promise<void>; export function DVKCancelRecord():Promise<void>;
export function DVKDelete(arg1:number):Promise<void>;
export function DVKPlay(arg1:number):Promise<void>; export function DVKPlay(arg1:number):Promise<void>;
export function DVKPreview(arg1:number):Promise<void>; export function DVKPreview(arg1:number):Promise<void>;
@@ -448,6 +452,8 @@ export function GetChaseNewGrids():Promise<boolean>;
export function GetChaseNewSpots():Promise<Array<main.ChaseNewSpot>>; export function GetChaseNewSpots():Promise<Array<main.ChaseNewSpot>>;
export function GetChaseSettings():Promise<main.ChaseSettings>;
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>;
@@ -620,6 +626,10 @@ export function GetWinkeyerStatus():Promise<winkeyer.Status>;
export function GetWorkedCallVariants():Promise<boolean>; export function GetWorkedCallVariants():Promise<boolean>;
export function GetWsjtFollowMode():Promise<boolean>;
export function GetWsjtHighlight():Promise<boolean>;
export function GetYaesuBandAntennas():Promise<Record<string, number>>; export function GetYaesuBandAntennas():Promise<Record<string, number>>;
export function GetYaesuState():Promise<cat.YaesuTXState>; export function GetYaesuState():Promise<cat.YaesuTXState>;
@@ -1020,6 +1030,8 @@ export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
export function SaveCabrilloFile():Promise<string>; export function SaveCabrilloFile():Promise<string>;
export function SaveChaseSettings(arg1:main.ChaseSettings):Promise<void>;
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>; export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>; export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
@@ -1246,6 +1258,10 @@ export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
export function SetWorkedCallVariants(arg1:boolean):Promise<void>; export function SetWorkedCallVariants(arg1:boolean):Promise<void>;
export function SetWsjtFollowMode(arg1:boolean):Promise<void>;
export function SetWsjtHighlight(arg1:boolean):Promise<void>;
export function SetYaesuAFGain(arg1:number):Promise<void>; export function SetYaesuAFGain(arg1:number):Promise<void>;
export function SetYaesuAGC(arg1:string):Promise<void>; export function SetYaesuAGC(arg1:string):Promise<void>;
+32
View File
@@ -222,6 +222,10 @@ export function ComputeStationInfo(arg1, arg2) {
return window['go']['main']['App']['ComputeStationInfo'](arg1, arg2); return window['go']['main']['App']['ComputeStationInfo'](arg1, arg2);
} }
export function ConfigureDecoderMode(arg1) {
return window['go']['main']['App']['ConfigureDecoderMode'](arg1);
}
export function ConnectAllClusters() { export function ConnectAllClusters() {
return window['go']['main']['App']['ConnectAllClusters'](); return window['go']['main']['App']['ConnectAllClusters']();
} }
@@ -254,6 +258,10 @@ export function DVKCancelRecord() {
return window['go']['main']['App']['DVKCancelRecord'](); return window['go']['main']['App']['DVKCancelRecord']();
} }
export function DVKDelete(arg1) {
return window['go']['main']['App']['DVKDelete'](arg1);
}
export function DVKPlay(arg1) { export function DVKPlay(arg1) {
return window['go']['main']['App']['DVKPlay'](arg1); return window['go']['main']['App']['DVKPlay'](arg1);
} }
@@ -834,6 +842,10 @@ export function GetChaseNewSpots() {
return window['go']['main']['App']['GetChaseNewSpots'](); return window['go']['main']['App']['GetChaseNewSpots']();
} }
export function GetChaseSettings() {
return window['go']['main']['App']['GetChaseSettings']();
}
export function GetChatHistory(arg1) { export function GetChatHistory(arg1) {
return window['go']['main']['App']['GetChatHistory'](arg1); return window['go']['main']['App']['GetChatHistory'](arg1);
} }
@@ -1178,6 +1190,14 @@ export function GetWorkedCallVariants() {
return window['go']['main']['App']['GetWorkedCallVariants'](); return window['go']['main']['App']['GetWorkedCallVariants']();
} }
export function GetWsjtFollowMode() {
return window['go']['main']['App']['GetWsjtFollowMode']();
}
export function GetWsjtHighlight() {
return window['go']['main']['App']['GetWsjtHighlight']();
}
export function GetYaesuBandAntennas() { export function GetYaesuBandAntennas() {
return window['go']['main']['App']['GetYaesuBandAntennas'](); return window['go']['main']['App']['GetYaesuBandAntennas']();
} }
@@ -1978,6 +1998,10 @@ export function SaveCabrilloFile() {
return window['go']['main']['App']['SaveCabrilloFile'](); return window['go']['main']['App']['SaveCabrilloFile']();
} }
export function SaveChaseSettings(arg1) {
return window['go']['main']['App']['SaveChaseSettings'](arg1);
}
export function SaveClusterServer(arg1) { export function SaveClusterServer(arg1) {
return window['go']['main']['App']['SaveClusterServer'](arg1); return window['go']['main']['App']['SaveClusterServer'](arg1);
} }
@@ -2430,6 +2454,14 @@ export function SetWorkedCallVariants(arg1) {
return window['go']['main']['App']['SetWorkedCallVariants'](arg1); return window['go']['main']['App']['SetWorkedCallVariants'](arg1);
} }
export function SetWsjtFollowMode(arg1) {
return window['go']['main']['App']['SetWsjtFollowMode'](arg1);
}
export function SetWsjtHighlight(arg1) {
return window['go']['main']['App']['SetWsjtHighlight'](arg1);
}
export function SetYaesuAFGain(arg1) { export function SetYaesuAFGain(arg1) {
return window['go']['main']['App']['SetYaesuAFGain'](arg1); return window['go']['main']['App']['SetYaesuAFGain'](arg1);
} }
+26
View File
@@ -1083,6 +1083,7 @@ export namespace cat {
s_meter: number; s_meter: number;
s_meter_raw: number; s_meter_raw: number;
power_meter: number; power_meter: number;
power_w: number;
swr: number; swr: number;
swr_raw: number; swr_raw: number;
rf_power: number; rf_power: number;
@@ -1120,6 +1121,7 @@ export namespace cat {
this.s_meter = source["s_meter"]; this.s_meter = source["s_meter"];
this.s_meter_raw = source["s_meter_raw"]; this.s_meter_raw = source["s_meter_raw"];
this.power_meter = source["power_meter"]; this.power_meter = source["power_meter"];
this.power_w = source["power_w"];
this.swr = source["swr"]; this.swr = source["swr"];
this.swr_raw = source["swr_raw"]; this.swr_raw = source["swr_raw"];
this.rf_power = source["rf_power"]; this.rf_power = source["rf_power"];
@@ -2428,6 +2430,20 @@ export namespace main {
this.at = source["at"]; this.at = source["at"];
} }
} }
export class ChaseSettings {
mode: string;
sources: string[];
static createFrom(source: any = {}) {
return new ChaseSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.mode = source["mode"];
this.sources = source["sources"];
}
}
export class ChatMessage { export class ChatMessage {
id: number; id: number;
operator: string; operator: string;
@@ -3955,6 +3971,11 @@ export namespace main {
new_county: boolean; new_county: boolean;
county?: string; county?: string;
state?: string; state?: string;
new_state: boolean;
unconf_status?: boolean;
unconf_pfx?: boolean;
unconf_cty?: boolean;
unconf_state?: boolean;
new_pota: boolean; new_pota: boolean;
grid?: string; grid?: string;
new_grid: boolean; new_grid: boolean;
@@ -3981,6 +4002,11 @@ export namespace main {
this.new_county = source["new_county"]; this.new_county = source["new_county"];
this.county = source["county"]; this.county = source["county"];
this.state = source["state"]; this.state = source["state"];
this.new_state = source["new_state"];
this.unconf_status = source["unconf_status"];
this.unconf_pfx = source["unconf_pfx"];
this.unconf_cty = source["unconf_cty"];
this.unconf_state = source["unconf_state"];
this.new_pota = source["new_pota"]; this.new_pota = source["new_pota"];
this.grid = source["grid"]; this.grid = source["grid"];
this.new_grid = source["new_grid"]; this.new_grid = source["new_grid"];
+17 -3
View File
@@ -52,9 +52,15 @@ type KenwoodTXState struct {
SMeterRaw int `json:"s_meter_raw"` SMeterRaw int `json:"s_meter_raw"`
// PowerMeter is 0-100 while transmitting. SWR is the ratio; 0 means "not // PowerMeter is 0-100 while transmitting. SWR is the ratio; 0 means "not
// measured", NOT a perfect match. // measured", NOT a perfect match.
PowerMeter int `json:"power_meter"` PowerMeter int `json:"power_meter"`
SWR float64 `json:"swr"` // PowerW is the transmit power in WATTS, derived from the bargraph and the
SWRRaw int `json:"swr_raw"` // meter's RANGE. The K3's bar is relative to a range that flips at 12 W —
// calibrated against a real one: 10 W showed 83 (10/12), 100 W showed 83
// too (100/120). The bar alone never was watts; with the PC setting to
// pick the range, it converts. 0 while receiving.
PowerW int `json:"power_w"`
SWR float64 `json:"swr"`
SWRRaw int `json:"swr_raw"`
RFPower int `json:"rf_power"` // watts, the PC setting RFPower int `json:"rf_power"` // watts, the PC setting
AFGain int `json:"af_gain"` // 0-100 AFGain int `json:"af_gain"` // 0-100
@@ -162,6 +168,7 @@ func (k *Kenwood) readPanel(mode string, split bool, txHz int64, txNow bool) {
// Cleared, not frozen: a power bar left standing after the carrier drops // Cleared, not frozen: a power bar left standing after the carrier drops
// reads as a live transmission. // reads as a live transmission.
k.panel.PowerMeter = 0 k.panel.PowerMeter = 0
k.panel.PowerW = 0
k.panel.SWR, k.panel.SWRRaw = 0, 0 k.panel.SWR, k.panel.SWRRaw = 0, 0
k.powerPeak, k.swrPeak = meterPeak{}, meterPeak{} k.powerPeak, k.swrPeak = meterPeak{}, meterPeak{}
// The S-meter only means anything while receiving. // The S-meter only means anything while receiving.
@@ -341,6 +348,13 @@ func (k *Kenwood) readTXMeters() {
defer func() { k.noLatch = false }() defer func() { k.noLatch = false }()
if v, ok := k.askNum("BG;", "BG", 2); ok { if v, ok := k.askNum("BG;", "BG", 2); ok {
k.panel.PowerMeter = k.powerPeak.update(kenwoodBargraphPercent(v), now) k.panel.PowerMeter = k.powerPeak.update(kenwoodBargraphPercent(v), now)
if k.elecraft {
scale := 120
if k.panel.RFPower > 0 && k.panel.RFPower <= 12 {
scale = 12 // the K3's QRP range
}
k.panel.PowerW = k.panel.PowerMeter * scale / 100
}
} }
// SW; — SETTLED, from Elecraft's own release note: three digits, tenths of a // SW; — SETTLED, from Elecraft's own release note: three digits, tenths of a
// ratio. "SW023;" is 2.3:1, and "SW999;" is the 99.9:1 it reports instead of // ratio. "SW023;" is 2.3:1, and "SW999;" is the 99.9:1 it reports instead of
+34 -5
View File
@@ -156,6 +156,8 @@ type Event struct {
DecodeModeRaw string DecodeModeRaw string
// DecodeMsgRaw is the message as sent, untrimmed — see DecodeModeRaw. // DecodeMsgRaw is the message as sent, untrimmed — see DecodeModeRaw.
DecodeMsgRaw string DecodeMsgRaw string
// DecodeIsNew is false on the history a Replay resends: display-only lines.
DecodeIsNew bool
// ProgramID is the sending application's own id ("WSJT-X", "MSHV", or // ProgramID is the sending application's own id ("WSJT-X", "MSHV", or
// "WSJT-X - 2" for a second instance started with --rig-name). It is what // "WSJT-X - 2" for a second instance started with --rig-name). It is what
// tells two receivers apart on one multicast group — and it is the address a // tells two receivers apart on one multicast group — and it is the address a
@@ -212,6 +214,9 @@ type Server struct {
// lastFrom is the address each program's packets arrive from — where a Reply // lastFrom is the address each program's packets arrive from — where a Reply
// has to be sent. See SendReply. // has to be sent. See SendReply.
lastFrom map[string]*net.UDPAddr lastFrom map[string]*net.UDPAddr
// onNewInstance fires (off the read loop) the first time a program id is
// heard on this listener — the hook the startup replay hangs from.
onNewInstance func(programID string)
// instLabel names each running application, keyed by id AND sending address. // instLabel names each running application, keyed by id AND sending address.
// //
// WSJT-X requires --rig-name for a second instance, so its ids differ. MSHV // WSJT-X requires --rig-name for a second instance, so its ids differ. MSHV
@@ -285,11 +290,12 @@ func describePacket(pkt []byte) string {
func newServer(cfg Config, out chan<- Event, mgr *Manager) *Server { func newServer(cfg Config, out chan<- Event, mgr *Manager) *Server {
return &Server{ return &Server{
cfg: cfg, cfg: cfg,
out: out, out: out,
mgr: mgr, mgr: mgr,
stop: make(chan struct{}), onNewInstance: mgr.onNewInstance,
done: make(chan struct{}), stop: make(chan struct{}),
done: make(chan struct{}),
} }
} }
@@ -515,13 +521,23 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
// must go to the sender's own address, never to the group. // must go to the sender's own address, never to the group.
s.mu.Lock() s.mu.Lock()
inst := s.instanceLabel(w.ProgramID, remote) inst := s.instanceLabel(w.ProgramID, remote)
newInstance := false
if inst != "" && remote != nil { if inst != "" && remote != nil {
if s.lastFrom == nil { if s.lastFrom == nil {
s.lastFrom = map[string]*net.UDPAddr{} s.lastFrom = map[string]*net.UDPAddr{}
} }
if _, known := s.lastFrom[inst]; !known {
newInstance = true
}
s.lastFrom[inst] = remote s.lastFrom[inst] = remote
} }
onNew := s.onNewInstance
s.mu.Unlock() s.mu.Unlock()
// A program just heard for the first time this session: tell the app, so
// it can ask for a replay of the decodes already on that program's screen.
if newInstance && onNew != nil {
go onNew(inst)
}
// Status carries the current dial frequency; remember it so Decode audio // Status carries the current dial frequency; remember it so Decode audio
// offsets can be turned into RF frequencies for the panadapter. // offsets can be turned into RF frequencies for the panadapter.
if w.FreqHz > 0 && !w.IsDecode { if w.FreqHz > 0 && !w.IsDecode {
@@ -580,6 +596,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
ev.DecodeModeRaw = w.Mode ev.DecodeModeRaw = w.Mode
ev.DecodeMsg = w.DecodeMsg ev.DecodeMsg = w.DecodeMsg
ev.DecodeMsgRaw = w.DecodeMsgRaw ev.DecodeMsgRaw = w.DecodeMsgRaw
ev.DecodeIsNew = w.DecodeIsNew
ev.DecodeAt = decodeTime(w.DecodeMsSinceMidnight) ev.DecodeAt = decodeTime(w.DecodeMsSinceMidnight)
ev.DecodeTRPeriod = tr ev.DecodeTRPeriod = tr
ev.DecodeDial = dial ev.DecodeDial = dial
@@ -803,6 +820,10 @@ type Manager struct {
repo *Repo repo *Repo
out chan Event out chan Event
// onNewInstance is copied onto every inbound listener as it starts; see
// Server.onNewInstance.
onNewInstance func(programID string)
// noADIFOnce keeps the "nothing to forward to" note to one line a session // noADIFOnce keeps the "nothing to forward to" note to one line a session
// rather than one per QSO logged. // rather than one per QSO logged.
noADIFOnce sync.Once noADIFOnce sync.Once
@@ -940,3 +961,11 @@ func (m *Manager) StopAll() {
s.close() s.close()
} }
} }
// SetOnNewInstance installs the first-sighting hook. Call before Reload so
// listeners are born with it.
func (m *Manager) SetOnNewInstance(fn func(programID string)) {
m.mu.Lock()
m.onNewInstance = fn
m.mu.Unlock()
}
@@ -0,0 +1,47 @@
package udp
import (
"bytes"
"encoding/binary"
"hamlog/internal/applog"
)
// WSJT-X Configure (message 15) — change the decoder's settings remotely. Used
// for ONE thing here: clicking an FT4 spot while the decoder sits in FT8
// switches its mode too, so the operator lands ready to decode instead of
// staring at a band of gibberish. Every other field is sent as "no change"
// (empty strings, max-quint32), per the protocol.
const wsjtMsgConfigure = 15
// EncodeConfigureMode builds a Configure datagram that changes only the mode.
func EncodeConfigureMode(programID, mode string) []byte {
const noChange32 = ^uint32(0)
var b bytes.Buffer
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMagic))
_ = binary.Write(&b, binary.BigEndian, uint32(2))
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMsgConfigure))
writeQString(&b, programID)
writeQString(&b, mode) // Mode
_ = binary.Write(&b, binary.BigEndian, noChange32) // Frequency Tolerance — no change
writeQString(&b, "") // Submode — no change
_ = binary.Write(&b, binary.BigEndian, uint8(0)) // Fast Mode — off (right for every HF mode)
_ = binary.Write(&b, binary.BigEndian, noChange32) // T/R Period — no change
_ = binary.Write(&b, binary.BigEndian, noChange32) // Rx DF — no change
writeQString(&b, "") // DX Call — no change
writeQString(&b, "") // DX Grid — no change
_ = binary.Write(&b, binary.BigEndian, uint8(0)) // Generate Messages — no
return b.Bytes()
}
// SendConfigureMode asks every decoder heard this session to switch mode.
// Sent to all instances rather than one: the spot click does not say which
// decoder the operator is looking at, and a second instance already in the
// right mode treats the message as a no-op.
func (m *Manager) SendConfigureMode(mode string) {
for _, inst := range m.Instances() {
if err := m.sendToInstance(inst, EncodeConfigureMode(inst, mode), "configure-mode"); err == nil {
applog.Printf("udp: asked %q to switch to %s", inst, mode)
}
}
}
+146
View File
@@ -0,0 +1,146 @@
package udp
import (
"bytes"
"encoding/binary"
"fmt"
"strings"
"hamlog/internal/applog"
)
// WSJT-X Highlight Callsign (13) and Replay (7) — the two halves of making the
// Band Activity window log-aware.
//
// Highlight paints a callsign in the decoding application's own window with the
// colours OpsLog chooses — new DXCC, new band, a watchlist member — the way
// JTAlert does. Replay asks a freshly-discovered instance to resend the decodes
// it already has on screen, so the FT decodes panel starts full instead of
// empty until the next period.
const (
wsjtMsgReplay = 7
wsjtMsgHighlight = 13
)
// RGB is one highlight colour. A nil *RGB means "invalid QColor", which is the
// protocol's way of saying "remove the highlight".
type RGB struct{ R, G, B uint8 }
// writeQColor serializes a QColor as QDataStream does: a spec byte (1 = RGB,
// 0 = invalid) followed by five 16-bit channels (alpha, red, green, blue, pad),
// each 8-bit value doubled into 16 bits the way Qt stores them.
func writeQColor(b *bytes.Buffer, c *RGB) {
if c == nil {
b.WriteByte(0) // invalid — clears the highlight
for i := 0; i < 5; i++ {
_ = binary.Write(b, binary.BigEndian, uint16(0))
}
return
}
b.WriteByte(1) // spec = RGB
wide := func(v uint8) uint16 { return uint16(v) * 0x101 }
_ = binary.Write(b, binary.BigEndian, uint16(0xFFFF)) // alpha, opaque
_ = binary.Write(b, binary.BigEndian, wide(c.R))
_ = binary.Write(b, binary.BigEndian, wide(c.G))
_ = binary.Write(b, binary.BigEndian, wide(c.B))
_ = binary.Write(b, binary.BigEndian, uint16(0)) // pad
}
// EncodeHighlight builds a Highlight Callsign datagram. bg/fg nil = invalid
// colour; both nil clears the callsign's highlight.
func EncodeHighlight(programID, callsign string, bg, fg *RGB, lastPeriodOnly bool) []byte {
var b bytes.Buffer
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMagic))
_ = binary.Write(&b, binary.BigEndian, uint32(2))
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMsgHighlight))
writeQString(&b, programID)
writeQString(&b, callsign)
writeQColor(&b, bg)
writeQColor(&b, fg)
var last uint8
if lastPeriodOnly {
last = 1
}
_ = binary.Write(&b, binary.BigEndian, last)
return b.Bytes()
}
// EncodeReplay builds a Replay datagram — "resend what your window holds".
func EncodeReplay(programID string) []byte {
var b bytes.Buffer
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMagic))
_ = binary.Write(&b, binary.BigEndian, uint32(2))
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMsgReplay))
writeQString(&b, programID)
return b.Bytes()
}
// sendToInstance routes a raw datagram to the application that owns programID,
// the same way SendReply does: to the address its packets actually arrive from.
func (m *Manager) sendToInstance(programID string, pkt []byte, what string) error {
if strings.TrimSpace(programID) == "" {
return fmt.Errorf("no application id")
}
m.mu.Lock()
servers := make([]*Server, 0, len(m.inbound))
for _, s := range m.inbound {
servers = append(servers, s)
}
m.mu.Unlock()
for _, s := range servers {
conn, addr := s.replyTarget(programID)
if conn == nil || addr == nil {
continue
}
if _, err := conn.WriteToUDP(pkt, addr); err != nil {
return fmt.Errorf("send %s to %s at %s: %w", what, programID, addr, err)
}
return nil
}
return fmt.Errorf("no packet has arrived from %q yet", programID)
}
// SendHighlight paints (or clears) one callsign in the given instance.
func (m *Manager) SendHighlight(programID, callsign string, bg, fg *RGB, lastPeriodOnly bool) error {
return m.sendToInstance(programID, EncodeHighlight(programID, callsign, bg, fg, lastPeriodOnly), "highlight")
}
// SendClearHighlights removes every highlighting instruction OpsLog installed
// in the instance. "CLEARALL!" is the protocol's own magic callsign for it.
func (m *Manager) SendClearHighlights(programID string) error {
return m.sendToInstance(programID, EncodeHighlight(programID, "CLEARALL!", nil, nil, false), "clear-highlights")
}
// SendReplay asks the instance to resend its on-screen decodes.
func (m *Manager) SendReplay(programID string) error {
err := m.sendToInstance(programID, EncodeReplay(programID), "replay")
if err == nil {
applog.Printf("udp: replay requested from %q — its existing decodes will arrive marked not-new", programID)
}
return err
}
// Instances lists every program id a packet has arrived from, for "clear the
// highlights everywhere" and the startup replay.
func (m *Manager) Instances() []string {
m.mu.Lock()
servers := make([]*Server, 0, len(m.inbound))
for _, s := range m.inbound {
servers = append(servers, s)
}
m.mu.Unlock()
seen := map[string]struct{}{}
var out []string
for _, s := range servers {
s.mu.Lock()
for id := range s.lastFrom {
if _, dup := seen[id]; !dup {
seen[id] = struct{}{}
out = append(out, id)
}
}
s.mu.Unlock()
}
return out
}
+27 -4
View File
@@ -79,9 +79,10 @@ type Config struct {
type Client struct { type Client struct {
cfg Config cfg Config
mu sync.Mutex // serialises the connection: one question at a time mu sync.Mutex // serialises the connection: one question at a time
conn io.ReadWriteCloser conn io.ReadWriteCloser
rd *bufio.Reader rd *bufio.Reader
skipTP bool // ^TP went unanswered once — a KPA500, no ATU; never ask again
statusMu sync.RWMutex statusMu sync.RWMutex
status Status status Status
@@ -183,6 +184,13 @@ func (c *Client) connectLocked() error {
if err != nil { if err != nil {
return fmt.Errorf("cannot open %s: %w", c.cfg.ComPort, err) return fmt.Errorf("cannot open %s: %w", c.cfg.ComPort, err)
} }
// The KPA500 is POWER-CONTROLLED by these lines: the Elecraft utility
// switches the amplifier on by raising them. Held asserted, once, and
// never touched again — reconnect cycles that toggled them were
// switching a KPA500 OFF twenty seconds after its operator pressed
// nothing but Standby.
_ = p.SetDTR(true)
_ = p.SetRTS(true)
_ = p.SetReadTimeout(ioTimeout) _ = p.SetReadTimeout(ioTimeout)
c.conn = p c.conn = p
} }
@@ -214,7 +222,13 @@ func (c *Client) ask(cmd string) (string, error) {
// the frame. // the frame.
line, err := c.rd.ReadString(';') line, err := c.rd.ReadString(';')
if err != nil { if err != nil {
c.dropLocked() // NOT dropped. A command this model simply does not know (^TP is the
// KPA1500's ATU — a KPA500 never answers it) is silence, not a dead
// link, and dropping here tore the connection down on every slow poll
// cycle: two seconds of stalled commands, a reconnect, and a DTR
// toggle the amplifier read as the off switch. Nothing arrived, so
// nothing is left to desynchronise the next exchange. Write errors —
// the genuinely dead link — still drop, above.
return "", fmt.Errorf("no answer to %s: %w", cmd, err) return "", fmt.Errorf("no answer to %s: %w", cmd, err)
} }
return strings.TrimSpace(line), nil return strings.TrimSpace(line), nil
@@ -364,12 +378,21 @@ func (c *Client) pollOnce(n uint64) {
c.statusMu.Unlock() c.statusMu.Unlock()
} }
} }
if c.skipTP {
return
}
if reply, err := c.ask("^TP;"); err == nil { if reply, err := c.ask("^TP;"); err == nil {
if v, err := parseInt(reply, "^TP"); err == nil { if v, err := parseInt(reply, "^TP"); err == nil {
c.statusMu.Lock() c.statusMu.Lock()
c.status.Tuning = v == 1 c.status.Tuning = v == 1
c.statusMu.Unlock() c.statusMu.Unlock()
} }
} else {
// One silence is the model's answer for good: a KPA500 has no ATU and
// will never answer ^TP — asking again every cycle cost a two-second
// stall each time.
c.skipTP = true
applog.Printf("kpa: ^TP unanswered — no ATU on this model, not asking again")
} }
} }
+207 -82
View File
@@ -722,15 +722,14 @@ func (r *Repo) MarkUploadedBatch(ctx context.Context, statusCol, dateCol, date s
if len(ids) == 0 { if len(ids) == 0 {
return nil return nil
} }
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",") now := db.NowISO()
args := make([]any, 0, len(ids)+2) _, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
args = append(args, date, db.NowISO()) args := append([]any{date, now}, idArgs...)
for _, id := range ids { _, err := r.db.ExecContext(ctx,
args = append(args, id) `UPDATE qso SET `+statusCol+` = 'Y', `+dateCol+` = ?, updated_at = ? WHERE id IN (`+ph+`)`,
} args...)
_, err := r.db.ExecContext(ctx, return 0, err
`UPDATE qso SET `+statusCol+` = 'Y', `+dateCol+` = ?, updated_at = ? WHERE id IN (`+ph+`)`, })
args...)
if err != nil { if err != nil {
return fmt.Errorf("mark uploaded batch (%d): %w", len(ids), err) return fmt.Errorf("mark uploaded batch (%d): %w", len(ids), err)
} }
@@ -872,6 +871,35 @@ var bulkEditableCols = map[string]bool{
// own path, not the text one: the columns are nullable integers, and while // own path, not the text one: the columns are nullable integers, and while
// SQLite would coerce "14" quietly, a shared MySQL logbook would not — and an // SQLite would coerce "14" quietly, a shared MySQL logbook would not — and an
// empty string is NULL here, never "". // empty string is NULL here, never "".
// bulkByIDChunks runs one UPDATE per slice of ids, small enough for SQLite's
// bound-variable cap: the single IN (…) with one placeholder per id worked at
// 10 000 QSOs and failed at 168 000 with "too many SQL variables". Each call
// gets the placeholder string and the id arguments for its slice; affected
// rows are summed. 500 per statement keeps every backend far from any limit
// while costing a few hundred statements on the largest logs.
func bulkByIDChunks(ctx context.Context, ids []int64, run func(ph string, idArgs []any) (int64, error)) (int64, error) {
const chunk = 500
var total int64
for start := 0; start < len(ids); start += chunk {
end := start + chunk
if end > len(ids) {
end = len(ids)
}
part := ids[start:end]
ph := strings.Repeat("?,", len(part)-1) + "?"
args := make([]any, len(part))
for i, id := range part {
args[i] = id
}
n, err := run(ph, args)
if err != nil {
return total, err
}
total += n
}
return total, nil
}
var bulkEditableIntCols = map[string]bool{ var bulkEditableIntCols = map[string]bool{
"my_dxcc": true, "my_dxcc": true,
"my_cq_zone": true, "my_cq_zone": true,
@@ -886,23 +914,23 @@ func (r *Repo) BulkSetIntField(ctx context.Context, ids []int64, column string,
if len(ids) == 0 { if len(ids) == 0 {
return 0, nil return 0, nil
} }
ph := make([]string, len(ids))
args := make([]any, 0, len(ids)+2)
var val any var val any
if v != nil { if v != nil {
val = *v val = *v
} }
args = append(args, val, db.NowISO()) now := db.NowISO()
for i, id := range ids { n, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
ph[i] = "?" args := append([]any{val, now}, idArgs...)
args = append(args, id) res, err := r.db.ExecContext(ctx,
} "UPDATE qso SET "+column+" = ?, updated_at = ? WHERE id IN ("+ph+")", args...)
res, err := r.db.ExecContext(ctx, if err != nil {
"UPDATE qso SET "+column+" = ?, updated_at = ? WHERE id IN ("+strings.Join(ph, ",")+")", args...) return 0, err
}
return res.RowsAffected()
})
if err != nil { if err != nil {
return 0, fmt.Errorf("bulk set %s: %w", column, err) return n, fmt.Errorf("bulk set %s: %w", column, err)
} }
n, _ := res.RowsAffected()
return n, nil return n, nil
} }
@@ -913,13 +941,6 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
if len(ids) == 0 { if len(ids) == 0 {
return 0, nil return 0, nil
} }
ph := make([]string, len(ids))
args := make([]any, 0, len(ids)+2)
args = append(args, value, db.NowISO())
for i, id := range ids {
ph[i] = "?"
args = append(args, id)
}
set := column + " = ?, updated_at = ?" set := column + " = ?, updated_at = ?"
if column == "mode" { if column == "mode" {
// A submode belongs to the mode it was recorded under. Left behind, it // A submode belongs to the mode it was recorded under. Left behind, it
@@ -928,13 +949,19 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
// only outcome that leaves the row meaning what the operator asked for. // only outcome that leaves the row meaning what the operator asked for.
set += ", submode = ''" set += ", submode = ''"
} }
res, err := r.db.ExecContext(ctx, now := db.NowISO()
`UPDATE qso SET `+set+` WHERE id IN (`+strings.Join(ph, ",")+`)`, n, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
args...) args := append([]any{value, now}, idArgs...)
res, err := r.db.ExecContext(ctx,
`UPDATE qso SET `+set+` WHERE id IN (`+ph+`)`, args...)
if err != nil {
return 0, err
}
return res.RowsAffected()
})
if err != nil { if err != nil {
return 0, fmt.Errorf("bulk set %s: %w", column, err) return n, fmt.Errorf("bulk set %s: %w", column, err)
} }
n, _ := res.RowsAffected()
return n, nil return n, nil
} }
@@ -995,26 +1022,26 @@ func (r *Repo) BulkSetExtra(ctx context.Context, ids []int64, adifKey, value str
if len(ids) == 0 { if len(ids) == 0 {
return 0, nil return 0, nil
} }
ph := make([]string, len(ids)) head := []any{}
args := make([]any, 0, len(ids)+2)
expr := `json_set(COALESCE(extras_json, '{}'), '$.` + adifKey + `', ?)` expr := `json_set(COALESCE(extras_json, '{}'), '$.` + adifKey + `', ?)`
if value == "" { if value == "" {
expr = `json_remove(COALESCE(extras_json, '{}'), '$.` + adifKey + `')` expr = `json_remove(COALESCE(extras_json, '{}'), '$.` + adifKey + `')`
} else { } else {
args = append(args, value) head = append(head, value)
} }
args = append(args, db.NowISO()) head = append(head, db.NowISO())
for i, id := range ids { n, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
ph[i] = "?" args := append(append([]any{}, head...), idArgs...)
args = append(args, id) res, err := r.db.ExecContext(ctx,
} `UPDATE qso SET extras_json = `+expr+`, updated_at = ? WHERE id IN (`+ph+`)`, args...)
res, err := r.db.ExecContext(ctx, if err != nil {
`UPDATE qso SET extras_json = `+expr+`, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`, return 0, err
args...) }
return res.RowsAffected()
})
if err != nil { if err != nil {
return 0, fmt.Errorf("bulk set extra %s: %w", adifKey, err) return n, fmt.Errorf("bulk set extra %s: %w", adifKey, err)
} }
n, _ := res.RowsAffected()
return n, nil return n, nil
} }
@@ -1026,20 +1053,19 @@ func (r *Repo) BulkSetFrequency(ctx context.Context, ids []int64, freqHz int64,
if len(ids) == 0 { if len(ids) == 0 {
return 0, nil return 0, nil
} }
ph := make([]string, len(ids)) now := db.NowISO()
args := make([]any, 0, len(ids)+3) n, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
args = append(args, freqHz, band, db.NowISO()) args := append([]any{freqHz, band, now}, idArgs...)
for i, id := range ids { res, err := r.db.ExecContext(ctx,
ph[i] = "?" `UPDATE qso SET freq_hz = ?, band = ?, updated_at = ? WHERE id IN (`+ph+`)`, args...)
args = append(args, id) if err != nil {
} return 0, err
res, err := r.db.ExecContext(ctx, }
`UPDATE qso SET freq_hz = ?, band = ?, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`, return res.RowsAffected()
args...) })
if err != nil { if err != nil {
return 0, fmt.Errorf("bulk set frequency: %w", err) return n, fmt.Errorf("bulk set frequency: %w", err)
} }
n, _ := res.RowsAffected()
return n, nil return n, nil
} }
@@ -1201,17 +1227,16 @@ func (r *Repo) DeleteMany(ctx context.Context, ids []int64) (int64, error) {
if len(ids) == 0 { if len(ids) == 0 {
return 0, nil return 0, nil
} }
ph := make([]string, len(ids)) n, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
args := make([]any, len(ids)) res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id IN (`+ph+`)`, idArgs...)
for i, id := range ids { if err != nil {
ph[i] = "?" return 0, err
args[i] = id }
} return res.RowsAffected()
res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id IN (`+strings.Join(ph, ",")+`)`, args...) })
if err != nil { if err != nil {
return 0, fmt.Errorf("delete qsos: %w", err) return n, fmt.Errorf("delete qsos: %w", err)
} }
n, _ := res.RowsAffected()
return n, nil return n, nil
} }
@@ -1701,27 +1726,42 @@ func (r *Repo) IterateByIDs(ctx context.Context, ids []int64, fn func(QSO) error
if len(ids) == 0 { if len(ids) == 0 {
return nil return nil
} }
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",") // Chunked like every other by-ids statement (the one-placeholder-per-id IN
args := make([]any, len(ids)) // died at 168k with "too many SQL variables") — and because each chunk is
for i, id := range ids { // only locally ordered, the rows are collected and sorted once at the end
args[i] = id // so the chronological contract holds across chunks.
} var all []QSO
rows, err := r.db.QueryContext(ctx, _, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
`SELECT `+selectCols+` FROM qso WHERE id IN (`+ph+`) ORDER BY qso_date ASC, id ASC`, args...) rows, err := r.db.QueryContext(ctx,
`SELECT `+selectCols+` FROM qso WHERE id IN (`+ph+`)`, idArgs...)
if err != nil {
return 0, err
}
defer rows.Close()
for rows.Next() {
q, err := scanQSO(rows)
if err != nil {
return 0, err
}
all = append(all, q)
}
return 0, rows.Err()
})
if err != nil { if err != nil {
return fmt.Errorf("query qso: %w", err) return fmt.Errorf("query qso: %w", err)
} }
defer rows.Close() sort.Slice(all, func(i, j int) bool {
for rows.Next() { if !all[i].QSODate.Equal(all[j].QSODate) {
q, err := scanQSO(rows) return all[i].QSODate.Before(all[j].QSODate)
if err != nil {
return err
} }
return all[i].ID < all[j].ID
})
for _, q := range all {
if err := fn(q); err != nil { if err := fn(q); err != nil {
return err return err
} }
} }
return rows.Err() return nil
} }
// GridKey builds the lookup key for the worked-grid index. // GridKey builds the lookup key for the worked-grid index.
@@ -2617,10 +2657,20 @@ type EntitySlot struct {
// Modes/Slots key — pass GroupDigitalMode to collapse all digital modes into // Modes/Slots key — pass GroupDigitalMode to collapse all digital modes into
// one bucket. Callers must normalise their lookup mode the same way. // one bucket. Callers must normalise their lookup mode the same way.
func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int, normMode func(string) string) (map[int]*EntitySlot, error) { func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int, normMode func(string) string) (map[int]*EntitySlot, error) {
return r.EntitySlotMapPred(ctx, keyFor, normMode, "")
}
// EntitySlotMapPred is EntitySlotMap over only the QSOs matching pred — the
// confirmed-only ledger the "chase unconfirmed too" mode judges against.
func (r *Repo) EntitySlotMapPred(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int, normMode func(string) string, pred string) (map[int]*EntitySlot, error) {
where := ""
if pred != "" {
where = " AND " + pred
}
rows, err := r.db.QueryContext(ctx, rows, err := r.db.QueryContext(ctx,
`SELECT callsign, coalesce(dxcc,0), lower(coalesce(country,'')), lower(band), upper(mode) FROM qso `SELECT callsign, coalesce(dxcc,0), lower(coalesce(country,'')), lower(band), upper(mode) FROM qso
WHERE band IS NOT NULL AND band != '' WHERE band IS NOT NULL AND band != ''
AND mode IS NOT NULL AND mode != ''`) AND mode IS NOT NULL AND mode != ''`+where)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -2670,8 +2720,18 @@ func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, store
// One pass, used by the cluster spot colouring to flag "already worked this // One pass, used by the cluster spot colouring to flag "already worked this
// exact call" regardless of band/mode — Log4OM/RUMlogNG-style call highlight. // exact call" regardless of band/mode — Log4OM/RUMlogNG-style call highlight.
func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error) { func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error) {
return r.WorkedCallsignsPred(ctx, "")
}
// WorkedCallsignsPred restricts the callsign ledger to QSOs matching pred —
// the confirmed-prefix (WPX) chase reads its prefixes off this set.
func (r *Repo) WorkedCallsignsPred(ctx context.Context, pred string) (map[string]struct{}, error) {
where := ""
if pred != "" {
where = " AND " + pred
}
rows, err := r.db.QueryContext(ctx, rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT upper(callsign) FROM qso WHERE callsign != ''`) `SELECT DISTINCT upper(callsign) FROM qso WHERE callsign != ''`+where)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -2692,9 +2752,18 @@ func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error)
// the award package here). Only US-entity QSOs (DXCC 291/110/6) with a county // the award package here). Only US-entity QSOs (DXCC 291/110/6) with a county
// are considered. Empty keys (unresolvable state/county) are skipped. // are considered. Empty keys (unresolvable state/county) are skipped.
func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty string) string) (map[string]struct{}, error) { func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty string) string) (map[string]struct{}, error) {
return r.WorkedCountyKeysPred(ctx, keyFn, "")
}
// WorkedCountyKeysPred restricts the county ledger to QSOs matching pred.
func (r *Repo) WorkedCountyKeysPred(ctx context.Context, keyFn func(state, cnty string) string, pred string) (map[string]struct{}, error) {
where := ""
if pred != "" {
where = " AND " + pred
}
rows, err := r.db.QueryContext(ctx, rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT COALESCE(state,''), COALESCE(cnty,'') FROM qso `SELECT DISTINCT COALESCE(state,''), COALESCE(cnty,'') FROM qso
WHERE dxcc IN (291,110,6) AND cnty IS NOT NULL AND cnty != ''`) WHERE dxcc IN (291,110,6) AND cnty IS NOT NULL AND cnty != ''`+where)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -2712,6 +2781,38 @@ func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty stri
return out, rows.Err() return out, rows.Err()
} }
// WorkedStateKeys returns the set of US states already worked, uppercased —
// the WAS ledger the cluster and decode panels judge NEW STATE against.
func (r *Repo) WorkedStateKeys(ctx context.Context) (map[string]struct{}, error) {
return r.WorkedStateKeysPred(ctx, "")
}
// WorkedStateKeysPred restricts the state ledger to QSOs matching pred.
func (r *Repo) WorkedStateKeysPred(ctx context.Context, pred string) (map[string]struct{}, error) {
where := ""
if pred != "" {
where = " AND " + pred
}
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT UPPER(COALESCE(state,'')) FROM qso
WHERE dxcc IN (291,110,6) AND state IS NOT NULL AND state != ''`+where)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]struct{}, 64)
for rows.Next() {
var st string
if err := rows.Scan(&st); err != nil {
return nil, err
}
if st != "" {
out[st] = struct{}{}
}
}
return out, rows.Err()
}
// CountQSLViaRouting counts the QSOs whose qsl_via holds a routing method // CountQSLViaRouting counts the QSOs whose qsl_via holds a routing method
// instead of a manager, per isRouting. // instead of a manager, per isRouting.
// //
@@ -3322,6 +3423,30 @@ type SlotStats struct {
// only way those two agree. // only way those two agree.
const ConfirmedValues = "('Y','V')" const ConfirmedValues = "('Y','V')"
// ConfirmSourcesPredicate builds the SQL that says "this QSO is confirmed",
// from the operator's chosen sources — the same choice the award engine's
// fixed three (LoTW, card, eQSL) used to hard-code. QRZ.com's confirmation is
// its download status; HRDLog has no confirmation field in ADIF at all, which
// is why it cannot be offered. Empty input falls back to the classic three.
func ConfirmSourcesPredicate(sources []string) string {
cols := map[string]string{
"lotw": "lotw_rcvd",
"card": "qsl_rcvd",
"eqsl": "eqsl_rcvd",
"qrz": "qrzcom_qso_download_status",
}
var parts []string
for _, src := range sources {
if col, ok := cols[strings.ToLower(strings.TrimSpace(src))]; ok {
parts = append(parts, col+" IN "+ConfirmedValues)
}
}
if len(parts) == 0 {
return "(lotw_rcvd IN " + ConfirmedValues + " OR qsl_rcvd IN " + ConfirmedValues + " OR eqsl_rcvd IN " + ConfirmedValues + ")"
}
return "(" + strings.Join(parts, " OR ") + ")"
}
// GetSlotStats computes the worked/confirmed slot and DXCC tallies in one pass. // GetSlotStats computes the worked/confirmed slot and DXCC tallies in one pass.
// "Confirmed" = LoTW or paper QSL received (the award-valid sources). // "Confirmed" = LoTW or paper QSL received (the award-valid sources).
func (r *Repo) GetSlotStats(ctx context.Context) (SlotStats, error) { func (r *Repo) GetSlotStats(ctx context.Context) (SlotStats, error) {
+7 -1
View File
@@ -246,6 +246,11 @@ func (s *Server) serve(c net.Conn) {
s.releasePTT(fmt.Sprintf("client %s left", c.RemoteAddr())) s.releasePTT(fmt.Sprintf("client %s left", c.RemoteAddr()))
}() }()
s.log("rigctld: client connected from %s", c.RemoteAddr()) s.log("rigctld: client connected from %s", c.RemoteAddr())
// The HANDSHAKE is always traced — the first few commands are where a
// client decides to stay or hang up, and a connect that lasted 50 ms left
// nothing in the log to say which answer it disliked. Steady-state polling
// stays behind the CAT trace switch.
traced := 0
r := bufio.NewReader(c) r := bufio.NewReader(c)
w := bufio.NewWriter(c) w := bufio.NewWriter(c)
for { for {
@@ -265,7 +270,8 @@ func (s *Server) serve(c net.Conn) {
// that preceded it — the one thing needed to tell whether OpsLog answered // that preceded it — the one thing needed to tell whether OpsLog answered
// something the client could not accept. Behind the same switch as the CAT // something the client could not accept. Behind the same switch as the CAT
// wire trace: this is one line per poll and would drown an ordinary log. // wire trace: this is one line per poll and would drown an ordinary log.
if req != "" && cat.CIVTraceEnabled() { if req != "" && (traced < 6 || cat.CIVTraceEnabled()) {
traced++
s.log("rigctld: %s → %q ⇒ %q", c.RemoteAddr(), req, strings.TrimRight(resp, "\r\n")) s.log("rigctld: %s → %q ⇒ %q", c.RemoteAddr(), req, strings.TrimRight(resp, "\r\n"))
} }
if resp != "" { if resp != "" {
+1 -1
View File
@@ -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.27.1" appVersion = "0.27.3"
// 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.