Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f667e4a4b | ||
|
|
d5f7e290ab | ||
|
|
f2ba798764 | ||
|
|
d7ee87f22d | ||
|
|
2587e6bcfe | ||
|
|
755392e0fa | ||
|
|
a9fb428a0a | ||
|
|
bbb485b9cf | ||
|
|
3a94a69be3 | ||
|
|
e4028f4bcd | ||
|
|
7c10517bf5 | ||
|
|
cd4e5b1117 | ||
|
|
a13ac7d917 | ||
|
|
4e28098a4e | ||
|
|
52e98a71f4 | ||
|
|
81ff6a9b0e | ||
|
|
be52a23868 | ||
|
|
fc1561804c | ||
|
|
1288b3f998 | ||
|
|
e00488fad8 | ||
|
|
12c61dc35a | ||
|
|
0c0e8b06ba | ||
|
|
1bd3896ca7 | ||
|
|
7e6c0b4f7e | ||
|
|
2cde1a2c27 | ||
|
|
53100eb6c8 | ||
|
|
3b555219d2 | ||
|
|
ba35d4094c | ||
|
|
9bd6d988aa | ||
|
|
daabbc63c7 | ||
|
|
997bc81d5e | ||
|
|
3c59507bc3 | ||
|
|
721c43d569 | ||
|
|
25eda98612 | ||
|
|
0b909a4d63 | ||
|
|
37298afd77 |
@@ -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,6 +743,11 @@ 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
|
||||||
|
|
||||||
|
// WSJT-X decode highlighting (message 13) — see app_wsjt_highlight.go.
|
||||||
|
wsjtHighlightOn atomic.Bool
|
||||||
|
wsjtHLMu sync.Mutex
|
||||||
|
wsjtHLSent map[string]string
|
||||||
watchPattern atomic.Value // auto-contest pattern (string), loaded at startup
|
watchPattern atomic.Value // auto-contest pattern (string), loaded at startup
|
||||||
operating *operating.Repo
|
operating *operating.Repo
|
||||||
udp *udp.Manager
|
udp *udp.Manager
|
||||||
@@ -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.
|
||||||
@@ -19788,6 +19823,16 @@ type SpotStatus struct {
|
|||||||
// 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"`
|
||||||
|
// 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"`
|
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 /
|
||||||
@@ -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,50 @@ 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()
|
||||||
|
// The frontend caches resolved verdicts and only asks about unknown keys —
|
||||||
|
// without this it kept judging half the screen under the OLD rules until a
|
||||||
|
// restart, which read as the new mode simply not working.
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "spotstatus:invalidate")
|
||||||
|
}
|
||||||
|
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 +19968,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 +20041,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 +20052,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 +20390,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 +20440,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 +20507,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 +20567,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 +20587,26 @@ 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.
|
||||||
|
switch out[i].Status {
|
||||||
|
case "new-band-mode", "new-band", "new-mode", "new-slot":
|
||||||
|
if idx.chaseUnconf {
|
||||||
|
if eAll, ok := entities[dxccNum]; ok {
|
||||||
|
_, bAll := eAll.Bands[out[i].Band]
|
||||||
|
_, mAll := eAll.Modes[checkMode]
|
||||||
|
_, sAll := eAll.Slots[out[i].Band][checkMode]
|
||||||
|
// "worked", not "": that is what the verdict function says
|
||||||
|
// when every grain is in the log — the first version
|
||||||
|
// compared against empty and no band/mode/slot need ever
|
||||||
|
// dimmed, however many unconfirmed QSOs stood behind it.
|
||||||
|
if spotEntityStatus(true, bAll, mAll, sAll, out[i].Mode) == "worked" {
|
||||||
|
out[i].UnconfStatus = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, ""
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,66 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"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 grid’s own Chase selector folds into it.",
|
||||||
|
"New FTx menu gathering FT Decodes, the new FT Map and the Grid squares map.",
|
||||||
|
"FT Map: a world map of the live FTx decodes — great-circle arcs from your QTH to every station heard in the last 30 minutes, coloured by band, with the PSK-Reporter palette and a basemap picker.",
|
||||||
|
"Maps: one single world (no more side-by-side copies), the surround follows the theme colour, and zooming stays centred — on the FT Map and the Grid squares map.",
|
||||||
|
"Watchlist: fixed columns in the spot rows, so band, mode and frequency line up instead of drifting with the country name."
|
||||||
|
],
|
||||||
|
"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é s’allume 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 s’affichent en badges atténués : une QSL à chasser, pas un QSO à faire. Le sélecteur Chasse des grilles fusionne dedans.",
|
||||||
|
"Nouveau menu FTx regroupant FT Decodes, la nouvelle FT Map et la carte Grid squares.",
|
||||||
|
"FT Map : une carte du monde des décodages FTx en direct — arcs orthodromiques depuis votre QTH vers chaque station entendue dans les 30 dernières minutes, colorés par bande, avec la palette PSK Reporter et un choix de fond de carte.",
|
||||||
|
"Cartes : un seul monde (fini les copies côte à côte), le pourtour suit la couleur du thème et le zoom reste centré — sur la FT Map et la carte Grid squares.",
|
||||||
|
"Watchlist : colonnes fixes dans les lignes de spots — bande, mode et fréquence s'alignent au lieu de dériver avec le nom du pays."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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 KPA1500’s ATU poll) was tearing the link down every cycle, and each reconnect toggled the serial control lines — which are the KPA500’s 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 decoder’s own window — instead of strongest-first.",
|
||||||
|
"Voice keyer: twelve message slots (F1–F12) 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 : l’ampli 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 l’interrupteur du KPA500. Les lignes sont désormais tenues stables, le silence n’est 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 l’ordre d’arrivée — comme la fenêtre du décodeur — au lieu du plus fort d’abord.",
|
||||||
|
"Manipulateur vocal : douze messages (F1–F12) 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 l’enregistrement 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 K3’s bargraph is relative to a range that flips at 12 W — calibrated against a real radio’s full table, the PC setting picks the range and the bar converts to watts.",
|
||||||
|
"Watchlist: a visual pass toward DXHunter’s 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 decoder’s 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 map’s ±85°, the path still draws.",
|
||||||
|
"WSJT-X / JTDX / MSHV: clicking a spot in a digital mode the decoder speaks (FT8, FT4, JT65…) switches the decoder’s 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 d’une 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": "",
|
||||||
|
|||||||
+122
-6
@@ -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,
|
||||||
@@ -75,6 +75,7 @@ import { SendEQSLModal } from '@/components/qsl/SendEQSLModal';
|
|||||||
import { AutoEQSL } from '@/components/qsl/AutoEQSL';
|
import { AutoEQSL } from '@/components/qsl/AutoEQSL';
|
||||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { SettingsModal } from '@/components/SettingsModal';
|
import { SettingsModal } from '@/components/SettingsModal';
|
||||||
|
import { FTMapPanel } from '@/components/FTMapPanel';
|
||||||
import { FirstRunModal } from '@/components/FirstRunModal';
|
import { FirstRunModal } from '@/components/FirstRunModal';
|
||||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||||
import { BandMap } from '@/components/BandMap';
|
import { BandMap } from '@/components/BandMap';
|
||||||
@@ -1313,6 +1314,17 @@ export default function App() {
|
|||||||
writeUiPref('opslog.gridsTab', '0');
|
writeUiPref('opslog.gridsTab', '0');
|
||||||
setActiveTab((t) => (t === 'grids' ? 'recent' : t));
|
setActiveTab((t) => (t === 'grids' ? 'recent' : t));
|
||||||
}
|
}
|
||||||
|
const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1');
|
||||||
|
function openFtmapTab() {
|
||||||
|
setFtmapTabOpen(true);
|
||||||
|
writeUiPref('opslog.ftmapTab', '1');
|
||||||
|
setActiveTab('ftmap');
|
||||||
|
}
|
||||||
|
function closeFtmapTab() {
|
||||||
|
setFtmapTabOpen(false);
|
||||||
|
writeUiPref('opslog.ftmapTab', '0');
|
||||||
|
setActiveTab((t) => (t === 'ftmap' ? 'recent' : t));
|
||||||
|
}
|
||||||
function openDecodesTab() {
|
function openDecodesTab() {
|
||||||
setDecodesTabOpen(true);
|
setDecodesTabOpen(true);
|
||||||
writeUiPref('opslog.decodesTab', '1');
|
writeUiPref('opslog.decodesTab', '1');
|
||||||
@@ -2088,7 +2100,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 +2240,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 +2587,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 +2688,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 +3368,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 +3456,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 +3609,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 +3651,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 +3682,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 +3730,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 +3762,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 +3781,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 +3858,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 ?? ''));
|
||||||
});
|
});
|
||||||
@@ -3801,7 +3874,16 @@ export default function App() {
|
|||||||
// The DX Call was cleared in WSJT-X / JTDX / MSHV → clear our entry to match.
|
// The DX Call was cleared in WSJT-X / JTDX / MSHV → clear our entry to match.
|
||||||
// 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 unsubStatusInval = EventsOn('spotstatus:invalidate', () => {
|
||||||
|
// The chase rules changed: every resolved verdict is stale. Cleared, and
|
||||||
|
// the flushes re-ask as rows repaint.
|
||||||
|
setSpotStatus({});
|
||||||
|
spotStatusRef.current = {};
|
||||||
|
});
|
||||||
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
|
||||||
@@ -3861,7 +3943,7 @@ export default function App() {
|
|||||||
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
||||||
showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
|
showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
|
||||||
});
|
});
|
||||||
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubTciSpot?.(); unsubProg?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
|
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubStatusInval?.(); unsubFlexSpot?.(); unsubTciSpot?.(); unsubProg?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -4352,6 +4434,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();
|
||||||
@@ -5031,12 +5117,17 @@ export default function App() {
|
|||||||
{ type: 'item', label: t('view.refresh'), action: 'view.refresh', shortcut: 'F5' },
|
{ type: 'item', label: t('view.refresh'), action: 'view.refresh', shortcut: 'F5' },
|
||||||
{ type: 'item', label: t('view.clearFilters'), action: 'view.clearfilters' },
|
{ type: 'item', label: t('view.clearFilters'), action: 'view.clearfilters' },
|
||||||
]},
|
]},
|
||||||
|
// The digital-mode corner gets its own menu: the decode list, its map,
|
||||||
|
// and the grid-square chase all live off the same UDP feed.
|
||||||
|
{ name: 'ftx', label: t('menu.ftx'), items: [
|
||||||
|
{ type: 'item', label: t('dec.tab'), action: 'tools.decodes' },
|
||||||
|
{ type: 'item', label: t('ftmap.tab'), action: 'tools.ftmap' },
|
||||||
|
{ type: 'item', label: t('gsm.title'), action: 'tools.grids' },
|
||||||
|
]},
|
||||||
{ name: 'tools', label: t('menu.tools'), items: [
|
{ name: 'tools', label: t('menu.tools'), items: [
|
||||||
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
||||||
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
||||||
{ type: 'item', label: t('station.title'), action: 'tools.station' },
|
{ type: 'item', label: t('station.title'), action: 'tools.station' },
|
||||||
{ type: 'item', label: t('dec.tab'), action: 'tools.decodes' },
|
|
||||||
{ type: 'item', label: t('gsm.title'), action: 'tools.grids' },
|
|
||||||
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
||||||
@@ -5090,6 +5181,7 @@ export default function App() {
|
|||||||
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
|
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
|
||||||
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
|
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
|
||||||
case 'tools.decodes': openDecodesTab(); break;
|
case 'tools.decodes': openDecodesTab(); break;
|
||||||
|
case 'tools.ftmap': openFtmapTab(); break;
|
||||||
case 'tools.grids': openGridsTab(); break;
|
case 'tools.grids': openGridsTab(); break;
|
||||||
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||||
@@ -5180,7 +5272,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…").
|
||||||
@@ -7721,6 +7813,21 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
|
{ftmapTabOpen && (
|
||||||
|
<TabsTrigger value="ftmap" className="gap-1.5">
|
||||||
|
{t('ftmap.tab')}
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
aria-label="Close FT map"
|
||||||
|
title="Close"
|
||||||
|
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
|
||||||
|
onPointerDown={(e) => { e.stopPropagation(); }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); closeFtmapTab(); }}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
{stationTabOpen && (
|
{stationTabOpen && (
|
||||||
<TabsTrigger value="station" className="gap-1.5">
|
<TabsTrigger value="station" className="gap-1.5">
|
||||||
{t('station.title')}
|
{t('station.title')}
|
||||||
@@ -8319,6 +8426,15 @@ export default function App() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{ftmapTabOpen && (
|
||||||
|
<TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||||
|
{activeTab === 'ftmap' && (
|
||||||
|
<div className="h-full w-full min-h-0 bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
<FTMapPanel decodes={decodes as any} myGrid={station.my_grid} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
{stationTabOpen && (
|
{stationTabOpen && (
|
||||||
<TabsContent value="station" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
<TabsContent value="station" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||||
<StationControlPanel
|
<StationControlPanel
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -308,12 +333,15 @@ function ColResizer({ onResize, onReset }: { onResize: (dx: number) => void; onR
|
|||||||
//
|
//
|
||||||
// Colours match the cluster list and the band map — the same fact must not be
|
// Colours match the cluster list and the band map — the same fact must not be
|
||||||
// amber in one panel and green in the next.
|
// amber in one panel and green in the next.
|
||||||
const ENTITY_BADGE: Record<string, { label: string; cls: string }> = {
|
const ENTITY_BADGE: Record<string, { label: string; cls: string; colour: string }> = {
|
||||||
'new': { label: 'dec.stNew', cls: 'bg-success text-success-foreground' },
|
// colour is the category's own hue, for the UNCONFIRMED rendering: the
|
||||||
'new-band': { label: 'dec.stBand', cls: 'bg-warning text-warning-foreground' },
|
// filled cls puts light text on a filled chip, and dimming that to a
|
||||||
'new-mode': { label: 'dec.stMode', cls: 'bg-info text-info-foreground' },
|
// transparent background left light-on-nothing — an invisible badge.
|
||||||
'new-slot': { label: 'dec.stSlot', cls: 'bg-caution text-caution-foreground' },
|
'new': { label: 'dec.stNew', cls: 'bg-success text-success-foreground', colour: 'var(--success)' },
|
||||||
'new-call': { label: 'dec.stCall', cls: 'bg-muted text-muted-foreground' },
|
'new-band': { label: 'dec.stBand', cls: 'bg-warning text-warning-foreground', colour: 'var(--warning)' },
|
||||||
|
'new-mode': { label: 'dec.stMode', cls: 'bg-info text-info-foreground', colour: 'var(--info)' },
|
||||||
|
'new-slot': { label: 'dec.stSlot', cls: 'bg-caution text-caution-foreground', colour: 'var(--caution)' },
|
||||||
|
'new-call': { label: 'dec.stCall', cls: 'bg-muted text-muted-foreground', colour: 'var(--muted-foreground)' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// entityBadgesFor turns a status into the badges that describe it.
|
// entityBadgesFor turns a status into the badges that describe it.
|
||||||
@@ -324,7 +352,7 @@ const ENTITY_BADGE: Record<string, { label: string; cls: string }> = {
|
|||||||
// passed the BAND and MODE filters and then showed no reason for being there,
|
// passed the BAND and MODE filters and then showed no reason for being there,
|
||||||
// which is precisely what was reported. catsOf has always split the status into
|
// which is precisely what was reported. catsOf has always split the status into
|
||||||
// its two categories; this is the same split, on the screen.
|
// its two categories; this is the same split, on the screen.
|
||||||
function entityBadgesFor(status: string): { label: string; cls: string }[] {
|
function entityBadgesFor(status: string): { label: string; cls: string; colour: string }[] {
|
||||||
if (status === 'new-band-mode') {
|
if (status === 'new-band-mode') {
|
||||||
return [ENTITY_BADGE['new-band'], ENTITY_BADGE['new-mode']];
|
return [ENTITY_BADGE['new-band'], ENTITY_BADGE['new-mode']];
|
||||||
}
|
}
|
||||||
@@ -343,6 +371,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 +527,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 +1126,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 +1152,11 @@ 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',
|
||||||
|
e?.unconf_status ? 'border bg-transparent' : b.cls)}
|
||||||
|
style={e?.unconf_status ? { color: b.colour, borderColor: b.colour, borderStyle: 'dashed', opacity: 0.6 } : undefined}>
|
||||||
{t(b.label)}
|
{t(b.label)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -1123,16 +1166,19 @@ 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}
|
||||||
title={unconf ? t('dec.bgGridUnconfTip') : undefined}
|
title={unconf ? t('dec.unconfTip') : undefined}
|
||||||
className="rounded border px-1 py-px text-[10px] font-semibold uppercase tracking-wide bg-transparent shrink-0"
|
className="rounded border px-1 py-px text-[10px] font-semibold uppercase tracking-wide bg-transparent shrink-0"
|
||||||
style={unconf
|
style={unconf
|
||||||
? { borderColor: c, color: c, opacity: 0.45, borderStyle: 'dashed' }
|
? { borderColor: c, color: c, opacity: 0.45, borderStyle: 'dashed' }
|
||||||
: { borderColor: c, color: c }}>
|
: { borderColor: c, color: c }}>
|
||||||
{unconf ? t('dec.bgGridUnconf') : t(b.label)}
|
{t(b.label)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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 F1–F6
|
// Operating panel for the Digital Voice Keyer — transmits the recorded F1–F12
|
||||||
// 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) {
|
||||||
|
|||||||
@@ -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. */}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { gridToLatLon, greatCirclePoints } from '@/lib/maidenhead';
|
||||||
|
import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
// FT Map — the live decode feed as geography: every station decoded in the
|
||||||
|
// last half hour, an arc from the operator's own square to theirs, coloured by
|
||||||
|
// band the way PSK Reporter taught everyone to read it. Wholly display: the
|
||||||
|
// decode list is the same one the FT decodes tab shows, and a station with no
|
||||||
|
// grid (never sent one in a CQ) simply cannot be placed and is not drawn.
|
||||||
|
//
|
||||||
|
// Performance is a design constraint, not an afterthought: the panel only
|
||||||
|
// exists while its tab is active (the parent unmounts it otherwise), the map
|
||||||
|
// renders with canvas (one <canvas>, not one DOM node per arc), the arcs are
|
||||||
|
// capped, and redraws happen when the DECODE LIST changes — every 15 s in FT8,
|
||||||
|
// not per frame.
|
||||||
|
|
||||||
|
export type FTMapDecode = {
|
||||||
|
call: string;
|
||||||
|
grid?: string;
|
||||||
|
band?: string;
|
||||||
|
snr: number;
|
||||||
|
at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The band palette every PSK Reporter user already knows, near enough.
|
||||||
|
const BAND_COLOURS: Record<string, string> = {
|
||||||
|
'160m': '#7f7f7f', '80m': '#e550e5', '60m': '#00008b', '40m': '#5555ff',
|
||||||
|
'30m': '#62d962', '20m': '#f2c40c', '17m': '#f2f261', '15m': '#cca166',
|
||||||
|
'12m': '#b22222', '10m': '#ff69b4', '6m': '#ff0000', '4m': '#cc0044',
|
||||||
|
'2m': '#ff1493', '70cm': '#999900',
|
||||||
|
};
|
||||||
|
const bandColour = (b?: string) => BAND_COLOURS[(b ?? '').toLowerCase()] || '#9ca3af';
|
||||||
|
|
||||||
|
const MAX_ARCS = 300;
|
||||||
|
const MAX_AGE_MS = 30 * 60_000;
|
||||||
|
|
||||||
|
export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid: string }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const divRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
|
const layerRef = useRef<L.LayerGroup | null>(null);
|
||||||
|
const baseRef = useRef<L.TileLayer | null>(null);
|
||||||
|
const labelsRef = useRef<L.TileLayer | null>(null);
|
||||||
|
const [basemap, setBasemap] = useState<BasemapKey>(() =>
|
||||||
|
(localStorage.getItem('opslog.ftmapBase') as BasemapKey) || 'satellite');
|
||||||
|
|
||||||
|
// The map itself, once.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!divRef.current || mapRef.current) return;
|
||||||
|
// ONE world: noWrap tiles inside hard bounds, no side-by-side copies —
|
||||||
|
// and the space beyond the edge is the theme's own surface (style.css).
|
||||||
|
const m = L.map(divRef.current, {
|
||||||
|
zoomControl: true, attributionControl: true,
|
||||||
|
// No maxBounds: with the world smaller than the window the clamp
|
||||||
|
// dragged every zoom into a corner. noWrap tiles alone keep one world.
|
||||||
|
worldCopyJump: false, preferCanvas: true,
|
||||||
|
center: [25, 0], zoom: 2, minZoom: 2,
|
||||||
|
});
|
||||||
|
mapRef.current = m;
|
||||||
|
layerRef.current = L.layerGroup().addTo(m);
|
||||||
|
return () => { m.remove(); mapRef.current = null; layerRef.current = null; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Basemap follows the picker.
|
||||||
|
useEffect(() => {
|
||||||
|
const m = mapRef.current;
|
||||||
|
if (!m) return;
|
||||||
|
baseRef.current?.remove(); labelsRef.current?.remove();
|
||||||
|
const bm = BASEMAPS[basemap];
|
||||||
|
const opts: L.TileLayerOptions = {
|
||||||
|
maxNativeZoom: bm.maxNativeZoom,
|
||||||
|
noWrap: true,
|
||||||
|
bounds: L.latLngBounds(L.latLng(-85.0511, -180), L.latLng(85.0511, 180)),
|
||||||
|
};
|
||||||
|
baseRef.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
||||||
|
if (bm.labelsUrl) labelsRef.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
|
||||||
|
localStorage.setItem('opslog.ftmapBase', basemap);
|
||||||
|
}, [basemap]);
|
||||||
|
|
||||||
|
// The arcs, redrawn when the decode list changes. Newest last so they paint
|
||||||
|
// on top; opacity falls with age so the map reads as "now" with a memory.
|
||||||
|
useEffect(() => {
|
||||||
|
const layer = layerRef.current;
|
||||||
|
if (!layer) return;
|
||||||
|
layer.clearLayers();
|
||||||
|
const from = gridToLatLon(myGrid);
|
||||||
|
if (!from) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const placed = decodes
|
||||||
|
.filter((d) => d.grid && Date.parse(d.at) > now - MAX_AGE_MS)
|
||||||
|
.slice(-MAX_ARCS);
|
||||||
|
// One line per CALL (its freshest sighting): the same CQer decoded thirty
|
||||||
|
// times in ten minutes is one path on the air, not thirty strokes of it.
|
||||||
|
const byCall = new Map<string, FTMapDecode>();
|
||||||
|
for (const d of placed) byCall.set(d.call.toUpperCase(), d);
|
||||||
|
L.circleMarker([from.lat, from.lon], {
|
||||||
|
radius: 5, color: '#fff', weight: 2, fillColor: '#e11d48', fillOpacity: 1,
|
||||||
|
}).addTo(layer);
|
||||||
|
for (const d of byCall.values()) {
|
||||||
|
const to = gridToLatLon(d.grid!);
|
||||||
|
if (!to) continue;
|
||||||
|
const age = now - Date.parse(d.at);
|
||||||
|
const fade = Math.max(0.15, 1 - age / MAX_AGE_MS);
|
||||||
|
const colour = bandColour(d.band);
|
||||||
|
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48);
|
||||||
|
L.polyline(pts as L.LatLngExpression[], {
|
||||||
|
color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
|
||||||
|
}).addTo(layer);
|
||||||
|
L.circleMarker([to.lat, to.lon], {
|
||||||
|
radius: 3, color: colour, weight: 1, fillColor: colour, fillOpacity: 0.9 * fade,
|
||||||
|
}).bindTooltip(`${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`, { direction: 'top' })
|
||||||
|
.addTo(layer);
|
||||||
|
}
|
||||||
|
}, [decodes, myGrid]);
|
||||||
|
|
||||||
|
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
|
||||||
|
return (
|
||||||
|
<div className="relative h-full w-full min-h-0">
|
||||||
|
<div ref={divRef} className="absolute inset-0 rounded-lg overflow-hidden" />
|
||||||
|
{/* Basemap picker, MainMap's own vocabulary. */}
|
||||||
|
<div className="absolute top-2 left-12 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
|
||||||
|
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
||||||
|
<button key={k} type="button" onClick={() => setBasemap(k)}
|
||||||
|
className={cn('px-2 py-0.5 rounded text-[11px]',
|
||||||
|
basemap === k ? 'bg-primary text-primary-foreground font-semibold' : 'text-muted-foreground hover:bg-muted')}>
|
||||||
|
{BASEMAPS[k].label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Band legend — only the bands actually on screen. */}
|
||||||
|
{bands.length > 0 && (
|
||||||
|
<div className="absolute bottom-2 left-2 z-[1000] flex flex-wrap gap-x-2.5 gap-y-1 rounded-md bg-background/85 backdrop-blur px-2 py-1.5 border border-border">
|
||||||
|
{bands.map((b) => (
|
||||||
|
<span key={b} className="flex items-center gap-1 text-[11px] text-foreground">
|
||||||
|
<span className="inline-block w-3 h-[3px] rounded" style={{ background: bandColour(b) }} />
|
||||||
|
{b.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!gridToLatLon(myGrid) && (
|
||||||
|
<div className="absolute inset-0 z-[1000] flex items-center justify-center pointer-events-none">
|
||||||
|
<span className="rounded-md bg-background/90 border border-border px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
{t('ftmap.noGrid')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -128,15 +128,11 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
|||||||
// impossible on any window wider than it is tall: one step out was already
|
// impossible on any window wider than it is tall: one step out was already
|
||||||
// too far in.
|
// too far in.
|
||||||
minZoom: 0,
|
minZoom: 0,
|
||||||
maxBoundsViscosity: 1, // don't let a drag slide the world off to one side
|
|
||||||
}).setView([20, 0], 2);
|
}).setView([20, 0], 2);
|
||||||
// The whole world, once, whatever the window is shaped like — computed by
|
// The whole world, once, whatever the window is shaped like — computed by
|
||||||
// Leaflet from the container rather than guessed with a zoom number.
|
// Leaflet from the container rather than guessed with a zoom number.
|
||||||
m.fitWorld({ animate: false });
|
m.fitWorld({ animate: false });
|
||||||
// Latitude only: the poles are the edge of the projection and there is
|
|
||||||
// nothing beyond them, while leaving longitude free keeps a drag from
|
|
||||||
// fighting the operator near the date line.
|
|
||||||
m.setMaxBounds(L.latLngBounds(L.latLng(-85, -Infinity), L.latLng(85, Infinity)));
|
|
||||||
mapRef.current = m;
|
mapRef.current = m;
|
||||||
layerRef.current = L.layerGroup().addTo(m);
|
layerRef.current = L.layerGroup().addTo(m);
|
||||||
// Leaflet measures its container ONCE, when the map is created, and never
|
// Leaflet measures its container ONCE, when the map is created, and never
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 (F1–F6).
|
// DVK voice-keyer messages (F1–F6).
|
||||||
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>
|
||||||
) : (
|
) : (
|
||||||
@@ -5411,6 +5423,38 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
{/* Chase switches: off, the marker stops being TOLD, everywhere at
|
{/* Chase switches: off, the marker stops being TOLD, everywhere at
|
||||||
once — badge, colour, filter chip, reference column. A "new band
|
once — badge, colour, filter chip, reference column. A "new band
|
||||||
+ new POTA" spot then reads NEW BAND alone. */}
|
+ new POTA" spot then reads NEW BAND alone. */}
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chasePotaHint')}>
|
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chasePotaHint')}>
|
||||||
<Checkbox checked={chasePotaOn}
|
<Checkbox checked={chasePotaOn}
|
||||||
onCheckedChange={(c) => { const v = !!c; setChasePotaOn(v); writeUiPref('opslog.chasePota', v ? '1' : '0'); }} />
|
onCheckedChange={(c) => { const v = !!c; setChasePotaOn(v); writeUiPref('opslog.chasePota', v ? '1' : '0'); }} />
|
||||||
@@ -5450,19 +5494,11 @@ 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>
|
||||||
|
|
||||||
|
|
||||||
{/* 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 +5752,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 +7205,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" />}
|
||||||
|
|||||||
@@ -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,14 +388,15 @@ 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>
|
{/* Fixed columns: an elastic country made band/mode/freq start wherever the name ended — every row its own ruler. */}
|
||||||
<span className="text-muted-foreground truncate flex-1 min-w-0 max-w-56">{(s as any).country ?? ''}</span>
|
<span className="font-mono font-bold text-info shrink-0 w-24 truncate">{s.dx_call}</span>
|
||||||
<span className="px-1.5 rounded bg-muted shrink-0">{s.band}</span>
|
<span className="text-muted-foreground truncate shrink-0 w-44">{(s as any).country ?? ''}</span>
|
||||||
{mode && <span className="px-1.5 rounded shrink-0" style={{ color: 'var(--chart-5)', background: 'color-mix(in srgb, var(--chart-5) 12%, transparent)' }}>{mode}</span>}
|
<span className="px-1.5 rounded bg-muted shrink-0 w-11 text-center">{s.band}</span>
|
||||||
<span className="font-mono text-muted-foreground shrink-0">{(s.freq_hz / 1e6).toFixed(4)}</span>
|
<span className="px-1.5 rounded shrink-0 w-11 text-center" style={mode ? { color: 'var(--chart-5)', background: 'color-mix(in srgb, var(--chart-5) 12%, transparent)' } : undefined}>{mode || ' '}</span>
|
||||||
|
<span className="font-mono text-muted-foreground shrink-0 w-16 text-right">{(s.freq_hz / 1e6).toFixed(4)}</span>
|
||||||
{badge && chip(badge.color, badge.label)}
|
{badge && chip(badge.color, badge.label)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{done
|
{done
|
||||||
|
|||||||
+18
-18
@@ -25,7 +25,7 @@ const en: Dict = {
|
|||||||
'upd.retry': 'Retry', 'upd.browser': 'Open page', 'upd.checking': 'Checking for updates…', 'upd.upToDate': "You're up to date",
|
'upd.retry': 'Retry', 'upd.browser': 'Open page', 'upd.checking': 'Checking for updates…', 'upd.upToDate': "You're up to date",
|
||||||
'rate.title': 'QSO rate (QSOs/hour) — projected from the last 10 / 60 minutes',
|
'rate.title': 'QSO rate (QSOs/hour) — projected from the last 10 / 60 minutes',
|
||||||
'lotw.feedStale': "ARRL's user list itself was last built on {date}, so this figure may be older than the station", 'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)',
|
'lotw.feedStale': "ARRL's user list itself was last built on {date}, so this figure may be older than the station", 'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)',
|
||||||
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
|
'menu.ftx': 'FTx', 'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
|
||||||
'file.import': 'Import ADIF…', 'file.export': 'Export ADIF…', 'file.exporting': 'Exporting…',
|
'file.import': 'Import ADIF…', 'file.export': 'Export ADIF…', 'file.exporting': 'Exporting…',
|
||||||
'file.exportCabrillo': 'Export Cabrillo…', 'file.deleteAll': 'Delete all QSOs…', 'file.exit': 'Exit',
|
'file.exportCabrillo': 'Export Cabrillo…', 'file.deleteAll': 'Delete all QSOs…', 'file.exit': 'Exit',
|
||||||
'edit.editSel': 'Edit selected QSO…', 'edit.prefs': 'Preferences…',
|
'edit.editSel': 'Edit selected QSO…', 'edit.prefs': 'Preferences…',
|
||||||
@@ -162,7 +162,7 @@ const en: Dict = {
|
|||||||
'mx.tipThisCall': 'already worked with this callsign',
|
'mx.tipThisCall': 'already worked with this callsign',
|
||||||
'mx.tipThisCallConf': 'already confirmed with this callsign',
|
'mx.tipThisCallConf': 'already confirmed with this callsign',
|
||||||
// FTx decodes panel (Tools -> FT decodes)
|
// FTx decodes panel (Tools -> FT decodes)
|
||||||
'dec.tab': 'FT decodes', 'dec.title': 'Decodes', 'dec.cqOnly': 'CQ only',
|
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Set your station grid in Preferences to place the map.', 'dec.tab': 'FT decodes', 'dec.title': 'Decodes', 'dec.cqOnly': 'CQ only',
|
||||||
'dec.allBands': 'All bands', 'dec.allModes': 'All modes', 'toast.qsoLogged': 'QSO logged', 'dec.contsHint': 'Continents: click to keep one or several', 'dec.allConts': 'All continents',
|
'dec.allBands': 'All bands', 'dec.allModes': 'All modes', 'toast.qsoLogged': 'QSO logged', 'dec.contsHint': 'Continents: click to keep one or several', 'dec.allConts': 'All continents',
|
||||||
'dec.minSnrTitle': 'Hide anything weaker than this SNR', 'dec.searchPh': 'Call, grid or message',
|
'dec.minSnrTitle': 'Hide anything weaker than this SNR', 'dec.searchPh': 'Call, grid or message',
|
||||||
'dec.clearFilters': 'Clear', 'dec.live': 'live', 'dec.tx': 'TX',
|
'dec.clearFilters': 'Clear', 'dec.live': 'live', 'dec.tx': 'TX',
|
||||||
@@ -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 F1–F6.', '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 F1–F12.', '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 program’s port, never at one of OpsLog’s 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 (Logger32’s 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 program’s port, never at one of OpsLog’s 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 (Logger32’s 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 decoder’s 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 (F1–F6)', '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 (F1–F12)', '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: ',
|
||||||
@@ -549,7 +549,7 @@ const fr: Dict = {
|
|||||||
'live.stationsTitle': 'Stations on air', 'live.stationsEmpty': 'Aucune station ne reporte pour le moment.', 'live.stationsHide': 'Masquer',
|
'live.stationsTitle': 'Stations on air', 'live.stationsEmpty': 'Aucune station ne reporte pour le moment.', 'live.stationsHide': 'Masquer',
|
||||||
'rate.title': 'Rythme QSO (QSO/heure) — projeté sur les 10 / 60 dernières minutes',
|
'rate.title': 'Rythme QSO (QSO/heure) — projeté sur les 10 / 60 dernières minutes',
|
||||||
'lotw.feedStale': "la liste ARRL elle-même date du {date}, ce chiffre peut donc être plus vieux que la station", 'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)',
|
'lotw.feedStale': "la liste ARRL elle-même date du {date}, ce chiffre peut donc être plus vieux que la station", 'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)',
|
||||||
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
|
'menu.ftx': 'FTx', 'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
|
||||||
'file.import': 'Importer ADIF…', 'file.export': 'Exporter ADIF…', 'file.exporting': 'Export…',
|
'file.import': 'Importer ADIF…', 'file.export': 'Exporter ADIF…', 'file.exporting': 'Export…',
|
||||||
'file.exportCabrillo': 'Exporter Cabrillo…', 'file.deleteAll': 'Supprimer tous les QSO…', 'file.exit': 'Quitter',
|
'file.exportCabrillo': 'Exporter Cabrillo…', 'file.deleteAll': 'Supprimer tous les QSO…', 'file.exit': 'Quitter',
|
||||||
'edit.editSel': 'Éditer le QSO sélectionné…', 'edit.prefs': 'Préférences…',
|
'edit.editSel': 'Éditer le QSO sélectionné…', 'edit.prefs': 'Préférences…',
|
||||||
@@ -682,7 +682,7 @@ const fr: Dict = {
|
|||||||
'mx.tipThisCall': 'déjà contacté avec cet indicatif',
|
'mx.tipThisCall': 'déjà contacté avec cet indicatif',
|
||||||
'mx.tipThisCallConf': 'déjà confirmé avec cet indicatif',
|
'mx.tipThisCallConf': 'déjà confirmé avec cet indicatif',
|
||||||
// Panneau des decodes FTx (Outils -> Decodes FT)
|
// Panneau des decodes FTx (Outils -> Decodes FT)
|
||||||
'dec.tab': 'Decodes FT', 'dec.title': 'Decodes', 'dec.cqOnly': 'CQ seulement',
|
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Renseigne ton locator dans les Préférences pour placer la carte.', 'dec.tab': 'Decodes FT', 'dec.title': 'Decodes', 'dec.cqOnly': 'CQ seulement',
|
||||||
'dec.allBands': 'Toutes bandes', 'dec.allModes': 'Tous modes', 'toast.qsoLogged': 'QSO enregistré', 'dec.contsHint': 'Continents : clique pour en garder un ou plusieurs', 'dec.allConts': 'Tous continents',
|
'dec.allBands': 'Toutes bandes', 'dec.allModes': 'Tous modes', 'toast.qsoLogged': 'QSO enregistré', 'dec.contsHint': 'Continents : clique pour en garder un ou plusieurs', 'dec.allConts': 'Tous continents',
|
||||||
'dec.minSnrTitle': 'Masquer tout ce qui est plus faible que ce rapport', 'dec.searchPh': 'Indicatif, locator ou message',
|
'dec.minSnrTitle': 'Masquer tout ce qui est plus faible que ce rapport', 'dec.searchPh': 'Indicatif, locator ou message',
|
||||||
'dec.clearFilters': 'Effacer', 'dec.live': 'en direct', 'dec.tx': 'TX',
|
'dec.clearFilters': 'Effacer', 'dec.live': 'en direct', 'dec.tx': 'TX',
|
||||||
@@ -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 l’indicatif : 4S trouve 4S7AB. * remplace n’importe quelle suite de caractères et ? exactement un, donc *4S se termine par 4S, *4S* le contient n’importe où, et F?BPO correspond à F4BPO.',
|
'rq.searchPh': 'Chercher un indicatif… 4S · *4S · *4S*', 'rq.searchTip': 'Un mot simple correspond au DÉBUT de l’indicatif : 4S trouve 4S7AB. * remplace n’importe quelle suite de caractères et ? exactement un, donc *4S se termine par 4S, *4S* le contient n’importe 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 c’est é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 d’ici 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 c’est é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 d’ici 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 s’il 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 n’a 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 s’il 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 n’a 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 F1–F6.', '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 F1–F12.', '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 n’afficher 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 n’afficher 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 d’antennes 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 s’y déclare en type « Relais HTTP ».',
|
'udpp.relayInstead': 'Pour un commutateur d’antennes 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 s’y déclare en type « Relais HTTP ».',
|
||||||
'udpp.svcCustomLabel': 'Message personnalisé', 'udpp.svcCustomHint': 'Vous choisissez ce qui le déclenche et ce qu’il dit. Datagramme UDP ou requête HTTP — cette dernière est la façon dont se pilotent la plupart des commutateurs d’antennes.', 'udpp.trigger': 'Déclencheur', 'udpp.trgBand': 'Changement de bande (radio)', 'udpp.trgQso': 'QSO enregistré', 'udpp.trgRotator': 'Commande de rotor', 'udpp.trgLookup': 'Recherche d’indicatif', 'udpp.transport': 'Envoi', 'udpp.transportUdp': 'Message UDP', 'udpp.transportUrl': 'URL (HTTP GET)', 'udpp.url': 'URL', 'udpp.urlHint': 'Les valeurs sont encodées pour l’URL. 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 qu’il dit. Datagramme UDP ou requête HTTP — cette dernière est la façon dont se pilotent la plupart des commutateurs d’antennes.', 'udpp.trigger': 'Déclencheur', 'udpp.trgBand': 'Changement de bande (radio)', 'udpp.trgQso': 'QSO enregistré', 'udpp.trgRotator': 'Commande de rotor', 'udpp.trgLookup': 'Recherche d’indicatif', 'udpp.transport': 'Envoi', 'udpp.transportUdp': 'Message UDP', 'udpp.transportUrl': 'URL (HTTP GET)', 'udpp.url': 'URL', 'udpp.urlHint': 'Les valeurs sont encodées pour l’URL. 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 : c’est ce qui permet de les faire tourner à côté d’OpsLog. À pointer sur le port de l’AUTRE logiciel, jamais sur un port d’écoute d’OpsLog.', 'udpp.svcWsjtLogLabel': 'QSO enregistré WSJT-X', 'udpp.svcWsjtLogHint': 'Annonce chaque QSO enregistré sur l’interface UDP WSJT-X — les deux messages que WSJT-X émet lui-même. Pour tout logger qui écoute là plutôt que l’ADIF 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 : c’est ce qui permet de les faire tourner à côté d’OpsLog. À pointer sur le port de l’AUTRE logiciel, jamais sur un port d’écoute d’OpsLog.', 'udpp.svcWsjtLogLabel': 'QSO enregistré WSJT-X', 'udpp.svcWsjtLogHint': 'Annonce chaque QSO enregistré sur l’interface UDP WSJT-X — les deux messages que WSJT-X émet lui-même. Pour tout logger qui écoute là plutôt que l’ADIF 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 à l’arrivé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 n’a é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 n’a é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 (F1–F6)', '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 (F1–F12)', '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 : ',
|
||||||
|
|||||||
@@ -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' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1086,3 +1086,10 @@
|
|||||||
.overflow-scroll {
|
.overflow-scroll {
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The ground beside the planet, on every map: Leaflet paints its container a
|
||||||
|
hard-coded light grey (#ddd), which reads as a broken tile against any
|
||||||
|
theme. The surround follows the theme's own card surface instead. */
|
||||||
|
.leaflet-container {
|
||||||
|
background: var(--card) !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.4';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+16
@@ -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>;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"];
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ type KenwoodTXState struct {
|
|||||||
// 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"`
|
||||||
|
// PowerW is the transmit power in WATTS, derived from the bargraph and the
|
||||||
|
// 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"`
|
SWR float64 `json:"swr"`
|
||||||
SWRRaw int `json:"swr_raw"`
|
SWRRaw int `json:"swr_raw"`
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -288,6 +293,7 @@ func newServer(cfg Config, out chan<- Event, mgr *Manager) *Server {
|
|||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
out: out,
|
out: out,
|
||||||
mgr: mgr,
|
mgr: mgr,
|
||||||
|
onNewInstance: mgr.onNewInstance,
|
||||||
stop: make(chan struct{}),
|
stop: make(chan struct{}),
|
||||||
done: 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+24
-1
@@ -82,6 +82,7 @@ type Client struct {
|
|||||||
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+196
-71
@@ -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 {
|
|
||||||
args = append(args, id)
|
|
||||||
}
|
|
||||||
_, err := r.db.ExecContext(ctx,
|
_, err := r.db.ExecContext(ctx,
|
||||||
`UPDATE qso SET `+statusCol+` = 'Y', `+dateCol+` = ?, updated_at = ? WHERE id IN (`+ph+`)`,
|
`UPDATE qso SET `+statusCol+` = 'Y', `+dateCol+` = ?, updated_at = ? WHERE id IN (`+ph+`)`,
|
||||||
args...)
|
args...)
|
||||||
|
return 0, err
|
||||||
|
})
|
||||||
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,
|
res, err := r.db.ExecContext(ctx,
|
||||||
"UPDATE qso SET "+column+" = ?, updated_at = ? WHERE id IN ("+strings.Join(ph, ",")+")", args...)
|
"UPDATE qso SET "+column+" = ?, updated_at = ? WHERE id IN ("+ph+")", args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("bulk set %s: %w", column, err)
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
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 = ''"
|
||||||
}
|
}
|
||||||
|
now := db.NowISO()
|
||||||
|
n, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
|
||||||
|
args := append([]any{value, now}, idArgs...)
|
||||||
res, err := r.db.ExecContext(ctx,
|
res, err := r.db.ExecContext(ctx,
|
||||||
`UPDATE qso SET `+set+` WHERE id IN (`+strings.Join(ph, ",")+`)`,
|
`UPDATE qso SET `+set+` WHERE id IN (`+ph+`)`, args...)
|
||||||
args...)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("bulk set %s: %w", column, err)
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
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())
|
|
||||||
for i, id := range ids {
|
|
||||||
ph[i] = "?"
|
|
||||||
args = append(args, id)
|
|
||||||
}
|
}
|
||||||
|
head = append(head, db.NowISO())
|
||||||
|
n, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
|
||||||
|
args := append(append([]any{}, head...), idArgs...)
|
||||||
res, err := r.db.ExecContext(ctx,
|
res, err := r.db.ExecContext(ctx,
|
||||||
`UPDATE qso SET extras_json = `+expr+`, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
|
`UPDATE qso SET extras_json = `+expr+`, updated_at = ? WHERE id IN (`+ph+`)`, args...)
|
||||||
args...)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("bulk set extra %s: %w", adifKey, err)
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
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 {
|
|
||||||
ph[i] = "?"
|
|
||||||
args = append(args, id)
|
|
||||||
}
|
|
||||||
res, err := r.db.ExecContext(ctx,
|
res, err := r.db.ExecContext(ctx,
|
||||||
`UPDATE qso SET freq_hz = ?, band = ?, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
|
`UPDATE qso SET freq_hz = ?, band = ?, updated_at = ? WHERE id IN (`+ph+`)`, args...)
|
||||||
args...)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("bulk set frequency: %w", err)
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
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 {
|
|
||||||
ph[i] = "?"
|
|
||||||
args[i] = id
|
|
||||||
}
|
|
||||||
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 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
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
|
||||||
|
_, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
|
||||||
rows, err := r.db.QueryContext(ctx,
|
rows, err := r.db.QueryContext(ctx,
|
||||||
`SELECT `+selectCols+` FROM qso WHERE id IN (`+ph+`) ORDER BY qso_date ASC, id ASC`, args...)
|
`SELECT `+selectCols+` FROM qso WHERE id IN (`+ph+`)`, idArgs...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("query qso: %w", err)
|
return 0, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
q, err := scanQSO(rows)
|
q, err := scanQSO(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
all = append(all, q)
|
||||||
|
}
|
||||||
|
return 0, rows.Err()
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("query qso: %w", err)
|
||||||
|
}
|
||||||
|
sort.Slice(all, func(i, j int) bool {
|
||||||
|
if !all[i].QSODate.Equal(all[j].QSODate) {
|
||||||
|
return all[i].QSODate.Before(all[j].QSODate)
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -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
@@ -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.4"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user