Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cde1a2c27 | ||
|
|
53100eb6c8 | ||
|
|
3b555219d2 | ||
|
|
ba35d4094c | ||
|
|
9bd6d988aa | ||
|
|
daabbc63c7 | ||
|
|
997bc81d5e | ||
|
|
3c59507bc3 | ||
|
|
721c43d569 | ||
|
|
25eda98612 | ||
|
|
0b909a4d63 | ||
|
|
37298afd77 |
@@ -741,10 +741,15 @@ type App struct {
|
||||
watchlist *watchlist.Store // Tools → Watchlist (global watchlist.json)
|
||||
watchAlertMu sync.Mutex // throttles watchlist alerts…
|
||||
watchAlertAt map[string]time.Time // …per entry
|
||||
watchPattern atomic.Value // auto-contest pattern (string), loaded at startup
|
||||
operating *operating.Repo
|
||||
udp *udp.Manager
|
||||
udpRepo *udp.Repo
|
||||
|
||||
// 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
|
||||
operating *operating.Repo
|
||||
udp *udp.Manager
|
||||
udpRepo *udp.Repo
|
||||
// Program id of the last decoding application that reported its status.
|
||||
// Halt Tx is routed by id, and the panel's Halt button must work even when
|
||||
// nothing is transmitting at that instant — so the id is remembered from
|
||||
@@ -1205,6 +1210,11 @@ func (a *App) startup(ctx context.Context) {
|
||||
a.operating = operating.NewRepo(conn)
|
||||
a.udpRepo = udp.NewRepo(conn)
|
||||
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()
|
||||
a.cache = lookup.NewCache(conn, 30*24*time.Hour)
|
||||
a.lookup = lookup.NewManager(a.cache)
|
||||
@@ -1444,6 +1454,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
a.pota = pota.New(func(format string, args ...any) { applog.Printf(format, args...) })
|
||||
a.startWatchlistClubLog()
|
||||
a.watchPattern.Store(strings.ToUpper(strings.TrimSpace(a.settingOr(keyWatchlistContestPattern, ""))))
|
||||
a.wsjtHighlightOn.Store(a.settingOr(keyWsjtHighlight, "0") == "1")
|
||||
go a.pota.Run(a.ctx)
|
||||
|
||||
// DX Cluster (multi-server): the spot callback enriches each spot
|
||||
@@ -13905,7 +13916,13 @@ func (a *App) consumeUDPEvents() {
|
||||
"low_conf": ev.DecodeLowConf,
|
||||
"mode_raw": ev.DecodeModeRaw,
|
||||
"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
|
||||
// panadapter when the option is on; green + SNR comment, auto-expiring
|
||||
// after the configured duration. De-duped per call in the Flex backend.
|
||||
|
||||
@@ -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, ""
|
||||
}
|
||||
@@ -1,4 +1,26 @@
|
||||
[
|
||||
{
|
||||
"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",
|
||||
"date": "",
|
||||
|
||||
+24
-1
@@ -33,7 +33,7 @@ import {
|
||||
GetSolarData,
|
||||
GetQSORate,
|
||||
LoTWUserInfo,
|
||||
OperatingDefaultForBand, ActiveRadioMyRig,
|
||||
OperatingDefaultForBand, ActiveRadioMyRig, ConfigureDecoderMode,
|
||||
LogUDPLoggedADIF,
|
||||
ListCountries,
|
||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
|
||||
@@ -2566,6 +2566,10 @@ export default function App() {
|
||||
for (const d of decodes) {
|
||||
const seenKey = `${d.call}|${d.ms ?? d.at}|${d.instance ?? ''}`;
|
||||
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
|
||||
// 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; }
|
||||
@@ -2663,6 +2667,11 @@ export default function App() {
|
||||
// "the field still shows the previous broadcast" (safe to update) from "the
|
||||
// user has typed a different call" (must not clobber).
|
||||
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
|
||||
// Worked-before tab so the history is front-and-centre. Only once per call,
|
||||
@@ -3338,6 +3347,10 @@ export default function App() {
|
||||
void tuneRigCAT(s.freq_hz, m).then(() => window.setTimeout(zoom, 300));
|
||||
} else zoom();
|
||||
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 });
|
||||
applySpotRefs((s as any).pota_ref, (s as any).sota_ref);
|
||||
if (s.dx_call?.trim()) restartRecordingForNewTarget(s.dx_call);
|
||||
@@ -3792,6 +3805,13 @@ export default function App() {
|
||||
// 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.
|
||||
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.
|
||||
if (applyUdpCall(p?.call, force)) restartRecordingForNewTarget(String(p?.call ?? ''));
|
||||
});
|
||||
@@ -3802,6 +3822,9 @@ export default function App() {
|
||||
// Only when something is actually in the entry, so an idle digital app doesn't
|
||||
// wipe a call being typed by hand.
|
||||
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();
|
||||
});
|
||||
// Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call
|
||||
|
||||
@@ -18,7 +18,7 @@ type KenwoodState = {
|
||||
available: boolean; model?: string; elecraft: boolean; mode?: string; data_sub?: string;
|
||||
transmitting: boolean; split: boolean; split_tx_hz?: 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;
|
||||
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;
|
||||
@@ -222,7 +222,8 @@ export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) =>
|
||||
onReportRST(sMeterRST(sp.s, sp.over, view.mode));
|
||||
}}
|
||||
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:
|
||||
a match that looks ideal on an antenna nobody has measured is the one
|
||||
reading that can cost a radio. */}
|
||||
|
||||
@@ -370,8 +370,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
|
||||
if (autoZoom) {
|
||||
if (from && to && arcPts) {
|
||||
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
||||
arcPts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
||||
// Latitudes clamped to Mercator's edge (±85°): the arc to a polar
|
||||
// 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 });
|
||||
} else if (to) {
|
||||
wm.setView([to.lat, to.lon], 3);
|
||||
|
||||
@@ -5716,10 +5716,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
function UDPIntegrationsPanelWrapper() {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title={t('sec.udp')}
|
||||
hint={t('udp.hint')}
|
||||
/>
|
||||
<SectionHeader title={t('sec.udp')} />
|
||||
<UDPIntegrationsPanel onError={(m) => setErr(m)} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
|
||||
import {
|
||||
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
|
||||
GetWsjtHighlight, SetWsjtHighlight, GetWsjtFollowMode, SetWsjtFollowMode,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -158,6 +159,12 @@ const TRIGGERS = [
|
||||
type Props = { onError: (msg: string) => void };
|
||||
|
||||
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 [items, setItems] = useState<UDPConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -229,10 +236,24 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
||||
{t('udpp.intro')}
|
||||
</div>
|
||||
|
||||
{/* Log-aware colours in WSJT-X / JTDX's own window — lives HERE because
|
||||
this panel is where the WSJT-X link is configured. */}
|
||||
<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
|
||||
title={t('udpp.inboundTitle')}
|
||||
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">
|
||||
{/* Header: the counters alone, centred — they are the tab's headline.
|
||||
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" />
|
||||
<span>
|
||||
{t('wl.cTotal')} <b className="text-foreground">{counters.total}</b>
|
||||
<span className="mx-1.5 opacity-50">|</span>
|
||||
{t('wl.cActive')} <b className="text-info">{counters.active}</b>
|
||||
<span className="mx-1.5 opacity-50">|</span>
|
||||
{t('wl.cNeeded')} <b className="text-warning">{counters.needed}</b>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">{t('wl.cTotal')}
|
||||
<b className="px-2 py-0.5 rounded bg-muted text-foreground">{counters.total}</b></span>
|
||||
<span className="opacity-40">|</span>
|
||||
<span className="flex items-center gap-1.5">{t('wl.cActive')}
|
||||
<b className="px-2 py-0.5 rounded text-info border border-info/30 bg-info/10">{counters.active}</b></span>
|
||||
<span className="opacity-40">|</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>
|
||||
{/* toolbar */}
|
||||
<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;
|
||||
return (
|
||||
<div key={e.callsign}
|
||||
className={cn('rounded-lg border bg-card p-3',
|
||||
needed > 0 ? 'border-warning/50' : 'border-border',
|
||||
className={cn('rounded-lg border bg-card/70 p-3 transition-colors hover:bg-accent/20',
|
||||
needed > 0 ? 'border-warning/40' : 'border-border/70',
|
||||
e.isContest && 'border-l-4 border-l-warning')}>
|
||||
<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')}
|
||||
{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"
|
||||
@@ -340,7 +343,7 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
||||
<Trophy className="size-3" /> {t('wl.contest')}
|
||||
</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.clubLogHasOQRS && chip('var(--success)', 'OQRS')}
|
||||
{e.clubLogLiveStream && (
|
||||
@@ -385,8 +388,8 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
||||
onClick={() => onSpotSelect?.(s)}
|
||||
onDoubleClick={() => onSpotClick?.(s)}
|
||||
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',
|
||||
!done && 'border-l-2 border-warning')}>
|
||||
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-[3px] border-warning')}>
|
||||
{done && <span className='font-bold shrink-0 text-success'>✓</span>}
|
||||
<span className="font-mono font-bold text-info shrink-0">{s.dx_call}</span>
|
||||
<span className="text-muted-foreground truncate flex-1 min-w-0 max-w-56">{(s as any).country ?? ''}</span>
|
||||
|
||||
@@ -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',
|
||||
'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.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',
|
||||
'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)
|
||||
@@ -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',
|
||||
'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.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',
|
||||
'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.',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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).
|
||||
export const APP_VERSION = '0.27.1';
|
||||
export const APP_VERSION = '0.27.2';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+10
@@ -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 ConfigureDecoderMode(arg1:string):Promise<void>;
|
||||
|
||||
export function ConnectAllClusters():Promise<void>;
|
||||
|
||||
export function ConnectClusterServer(arg1:number):Promise<void>;
|
||||
@@ -620,6 +622,10 @@ export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||
|
||||
export function GetWorkedCallVariants():Promise<boolean>;
|
||||
|
||||
export function GetWsjtFollowMode():Promise<boolean>;
|
||||
|
||||
export function GetWsjtHighlight():Promise<boolean>;
|
||||
|
||||
export function GetYaesuBandAntennas():Promise<Record<string, number>>;
|
||||
|
||||
export function GetYaesuState():Promise<cat.YaesuTXState>;
|
||||
@@ -1246,6 +1252,10 @@ export function SetWinkeyerTrace(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 SetYaesuAGC(arg1:string):Promise<void>;
|
||||
|
||||
@@ -222,6 +222,10 @@ export function 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() {
|
||||
return window['go']['main']['App']['ConnectAllClusters']();
|
||||
}
|
||||
@@ -1178,6 +1182,14 @@ export function 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() {
|
||||
return window['go']['main']['App']['GetYaesuBandAntennas']();
|
||||
}
|
||||
@@ -2430,6 +2442,14 @@ export function 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) {
|
||||
return window['go']['main']['App']['SetYaesuAFGain'](arg1);
|
||||
}
|
||||
|
||||
@@ -1083,6 +1083,7 @@ export namespace cat {
|
||||
s_meter: number;
|
||||
s_meter_raw: number;
|
||||
power_meter: number;
|
||||
power_w: number;
|
||||
swr: number;
|
||||
swr_raw: number;
|
||||
rf_power: number;
|
||||
@@ -1120,6 +1121,7 @@ export namespace cat {
|
||||
this.s_meter = source["s_meter"];
|
||||
this.s_meter_raw = source["s_meter_raw"];
|
||||
this.power_meter = source["power_meter"];
|
||||
this.power_w = source["power_w"];
|
||||
this.swr = source["swr"];
|
||||
this.swr_raw = source["swr_raw"];
|
||||
this.rf_power = source["rf_power"];
|
||||
|
||||
@@ -52,9 +52,15 @@ type KenwoodTXState struct {
|
||||
SMeterRaw int `json:"s_meter_raw"`
|
||||
// PowerMeter is 0-100 while transmitting. SWR is the ratio; 0 means "not
|
||||
// measured", NOT a perfect match.
|
||||
PowerMeter int `json:"power_meter"`
|
||||
SWR float64 `json:"swr"`
|
||||
SWRRaw int `json:"swr_raw"`
|
||||
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"`
|
||||
SWRRaw int `json:"swr_raw"`
|
||||
|
||||
RFPower int `json:"rf_power"` // watts, the PC setting
|
||||
AFGain int `json:"af_gain"` // 0-100
|
||||
@@ -162,6 +168,7 @@ func (k *Kenwood) readPanel(mode string, split bool, txHz int64, txNow bool) {
|
||||
// Cleared, not frozen: a power bar left standing after the carrier drops
|
||||
// reads as a live transmission.
|
||||
k.panel.PowerMeter = 0
|
||||
k.panel.PowerW = 0
|
||||
k.panel.SWR, k.panel.SWRRaw = 0, 0
|
||||
k.powerPeak, k.swrPeak = meterPeak{}, meterPeak{}
|
||||
// The S-meter only means anything while receiving.
|
||||
@@ -341,6 +348,13 @@ func (k *Kenwood) readTXMeters() {
|
||||
defer func() { k.noLatch = false }()
|
||||
if v, ok := k.askNum("BG;", "BG", 2); ok {
|
||||
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
|
||||
// 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
|
||||
// DecodeMsgRaw is the message as sent, untrimmed — see DecodeModeRaw.
|
||||
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
|
||||
// "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
|
||||
@@ -212,6 +214,9 @@ type Server struct {
|
||||
// lastFrom is the address each program's packets arrive from — where a Reply
|
||||
// has to be sent. See SendReply.
|
||||
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.
|
||||
//
|
||||
// WSJT-X requires --rig-name for a second instance, so its ids differ. MSHV
|
||||
@@ -285,11 +290,12 @@ func describePacket(pkt []byte) string {
|
||||
|
||||
func newServer(cfg Config, out chan<- Event, mgr *Manager) *Server {
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
out: out,
|
||||
mgr: mgr,
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
cfg: cfg,
|
||||
out: out,
|
||||
mgr: mgr,
|
||||
onNewInstance: mgr.onNewInstance,
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,13 +521,23 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
// must go to the sender's own address, never to the group.
|
||||
s.mu.Lock()
|
||||
inst := s.instanceLabel(w.ProgramID, remote)
|
||||
newInstance := false
|
||||
if inst != "" && remote != nil {
|
||||
if s.lastFrom == nil {
|
||||
s.lastFrom = map[string]*net.UDPAddr{}
|
||||
}
|
||||
if _, known := s.lastFrom[inst]; !known {
|
||||
newInstance = true
|
||||
}
|
||||
s.lastFrom[inst] = remote
|
||||
}
|
||||
onNew := s.onNewInstance
|
||||
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
|
||||
// offsets can be turned into RF frequencies for the panadapter.
|
||||
if w.FreqHz > 0 && !w.IsDecode {
|
||||
@@ -580,6 +596,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
ev.DecodeModeRaw = w.Mode
|
||||
ev.DecodeMsg = w.DecodeMsg
|
||||
ev.DecodeMsgRaw = w.DecodeMsgRaw
|
||||
ev.DecodeIsNew = w.DecodeIsNew
|
||||
ev.DecodeAt = decodeTime(w.DecodeMsSinceMidnight)
|
||||
ev.DecodeTRPeriod = tr
|
||||
ev.DecodeDial = dial
|
||||
@@ -803,6 +820,10 @@ type Manager struct {
|
||||
repo *Repo
|
||||
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
|
||||
// rather than one per QSO logged.
|
||||
noADIFOnce sync.Once
|
||||
@@ -940,3 +961,11 @@ func (m *Manager) StopAll() {
|
||||
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
|
||||
}
|
||||
+119
-79
@@ -722,15 +722,14 @@ func (r *Repo) MarkUploadedBatch(ctx context.Context, statusCol, dateCol, date s
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
|
||||
args := make([]any, 0, len(ids)+2)
|
||||
args = append(args, date, db.NowISO())
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET `+statusCol+` = 'Y', `+dateCol+` = ?, updated_at = ? WHERE id IN (`+ph+`)`,
|
||||
args...)
|
||||
now := db.NowISO()
|
||||
_, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
|
||||
args := append([]any{date, now}, idArgs...)
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET `+statusCol+` = 'Y', `+dateCol+` = ?, updated_at = ? WHERE id IN (`+ph+`)`,
|
||||
args...)
|
||||
return 0, err
|
||||
})
|
||||
if err != nil {
|
||||
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
|
||||
// SQLite would coerce "14" quietly, a shared MySQL logbook would not — and an
|
||||
// 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{
|
||||
"my_dxcc": true,
|
||||
"my_cq_zone": true,
|
||||
@@ -886,23 +914,23 @@ func (r *Repo) BulkSetIntField(ctx context.Context, ids []int64, column string,
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
ph := make([]string, len(ids))
|
||||
args := make([]any, 0, len(ids)+2)
|
||||
var val any
|
||||
if v != nil {
|
||||
val = *v
|
||||
}
|
||||
args = append(args, val, db.NowISO())
|
||||
for i, id := range ids {
|
||||
ph[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
"UPDATE qso SET "+column+" = ?, updated_at = ? WHERE id IN ("+strings.Join(ph, ",")+")", args...)
|
||||
now := db.NowISO()
|
||||
n, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
|
||||
args := append([]any{val, now}, idArgs...)
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
"UPDATE qso SET "+column+" = ?, updated_at = ? WHERE id IN ("+ph+")", args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bulk set %s: %w", column, err)
|
||||
return n, fmt.Errorf("bulk set %s: %w", column, err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -913,13 +941,6 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
|
||||
if len(ids) == 0 {
|
||||
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 = ?"
|
||||
if column == "mode" {
|
||||
// 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.
|
||||
set += ", submode = ''"
|
||||
}
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET `+set+` WHERE id IN (`+strings.Join(ph, ",")+`)`,
|
||||
args...)
|
||||
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,
|
||||
`UPDATE qso SET `+set+` WHERE id IN (`+ph+`)`, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bulk set %s: %w", column, err)
|
||||
return n, fmt.Errorf("bulk set %s: %w", column, err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -995,26 +1022,26 @@ func (r *Repo) BulkSetExtra(ctx context.Context, ids []int64, adifKey, value str
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
ph := make([]string, len(ids))
|
||||
args := make([]any, 0, len(ids)+2)
|
||||
head := []any{}
|
||||
expr := `json_set(COALESCE(extras_json, '{}'), '$.` + adifKey + `', ?)`
|
||||
if value == "" {
|
||||
expr = `json_remove(COALESCE(extras_json, '{}'), '$.` + adifKey + `')`
|
||||
} 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)
|
||||
}
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET extras_json = `+expr+`, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
|
||||
args...)
|
||||
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,
|
||||
`UPDATE qso SET extras_json = `+expr+`, updated_at = ? WHERE id IN (`+ph+`)`, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bulk set extra %s: %w", adifKey, err)
|
||||
return n, fmt.Errorf("bulk set extra %s: %w", adifKey, err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -1026,20 +1053,19 @@ func (r *Repo) BulkSetFrequency(ctx context.Context, ids []int64, freqHz int64,
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
ph := make([]string, len(ids))
|
||||
args := make([]any, 0, len(ids)+3)
|
||||
args = append(args, freqHz, band, db.NowISO())
|
||||
for i, id := range ids {
|
||||
ph[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET freq_hz = ?, band = ?, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
|
||||
args...)
|
||||
now := db.NowISO()
|
||||
n, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
|
||||
args := append([]any{freqHz, band, now}, idArgs...)
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET freq_hz = ?, band = ?, updated_at = ? WHERE id IN (`+ph+`)`, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bulk set frequency: %w", err)
|
||||
return n, fmt.Errorf("bulk set frequency: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -1201,17 +1227,16 @@ func (r *Repo) DeleteMany(ctx context.Context, ids []int64) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
ph := make([]string, len(ids))
|
||||
args := make([]any, len(ids))
|
||||
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...)
|
||||
n, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id IN (`+ph+`)`, idArgs...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete qsos: %w", err)
|
||||
return n, fmt.Errorf("delete qsos: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -1701,27 +1726,42 @@ func (r *Repo) IterateByIDs(ctx context.Context, ids []int64, fn func(QSO) error
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
|
||||
args := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
args[i] = id
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT `+selectCols+` FROM qso WHERE id IN (`+ph+`) ORDER BY qso_date ASC, id ASC`, args...)
|
||||
// Chunked like every other by-ids statement (the one-placeholder-per-id IN
|
||||
// died at 168k with "too many SQL variables") — and because each chunk is
|
||||
// only locally ordered, the rows are collected and sorted once at the end
|
||||
// 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,
|
||||
`SELECT `+selectCols+` FROM qso WHERE id IN (`+ph+`)`, idArgs...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
q, err := scanQSO(rows)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
all = append(all, q)
|
||||
}
|
||||
return 0, rows.Err()
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("query qso: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
q, err := scanQSO(rows)
|
||||
if err != nil {
|
||||
return 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 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
return nil
|
||||
}
|
||||
|
||||
// GridKey builds the lookup key for the worked-grid index.
|
||||
|
||||
@@ -246,6 +246,11 @@ func (s *Server) serve(c net.Conn) {
|
||||
s.releasePTT(fmt.Sprintf("client %s left", 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)
|
||||
w := bufio.NewWriter(c)
|
||||
for {
|
||||
@@ -265,7 +270,8 @@ func (s *Server) serve(c net.Conn) {
|
||||
// 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
|
||||
// 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"))
|
||||
}
|
||||
if resp != "" {
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.27.1"
|
||||
appVersion = "0.27.2"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user