merge: matrix call mark, Icom scope, KPA chip, SOTA column, awards slots, LoTW download fixes

This commit is contained in:
2026-08-27 23:24:34 +02:00
25 changed files with 539 additions and 91 deletions
+67
View File
@@ -402,6 +402,7 @@ const (
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path" keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
keyExtLoTWStationLoc = "extsvc.lotw.station_location" keyExtLoTWStationLoc = "extsvc.lotw.station_location"
keyExtLoTWAllCalls = "extsvc.lotw.download_all_calls" // download confirmations for EVERY call on the account, not just this profile's
keyExtLoTWForceCall = "extsvc.lotw.force_station_callsign" // override STATION_CALLSIGN at sign time (e.g. F4BPO/P on the F4BPO cert) keyExtLoTWForceCall = "extsvc.lotw.force_station_callsign" // override STATION_CALLSIGN at sign time (e.g. F4BPO/P on the F4BPO cert)
keyExtLoTWKeyPassword = "extsvc.lotw.key_password" keyExtLoTWKeyPassword = "extsvc.lotw.key_password"
keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag" // legacy single flag (migrated to upload_flags) keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag" // legacy single flag (migrated to upload_flags)
@@ -8931,6 +8932,11 @@ func (a *App) clusterEventWorker() {
} }
} }
} }
// SOTA: the summit is in the spot's own text — the SOTA feeds put it
// there — so it costs a regex rather than an API lookup.
if s.SOTARef == "" {
s.SOTARef = cluster.SOTARefFrom(s.Comment)
}
// POTA: tag the spot when the DX station is currently activating a park. // POTA: tag the spot when the DX station is currently activating a park.
if a.pota != nil { if a.pota != nil {
if info, ok := a.pota.Lookup(s.DXCall); ok { if info, ok := a.pota.Lookup(s.DXCall); ok {
@@ -12091,6 +12097,17 @@ func manualRefFor(existing, code string) string {
return "" return ""
} }
// GetLoTWDownloadAllCalls reports whether the LoTW download ignores the
// profile's own call and pulls every callsign on the account.
func (a *App) GetLoTWDownloadAllCalls() bool {
return a.settingOr(keyExtLoTWAllCalls, "") == "1"
}
// SetLoTWDownloadAllCalls stores that choice.
func (a *App) SetLoTWDownloadAllCalls(on bool) {
a.setSetting(keyExtLoTWAllCalls, map[bool]string{true: "1", false: "0"}[on])
}
// DownloadConfirmations pulls confirmed QSOs from a service and updates the // DownloadConfirmations pulls confirmed QSOs from a service and updates the
// matching local QSOs' received status. LoTW only for now (the canonical // matching local QSOs' received status. LoTW only for now (the canonical
// confirmation system); runs in the background emitting the same // confirmation system); runs in the background emitting the same
@@ -12162,7 +12179,30 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
case extsvc.ServiceLoTW: case extsvc.ServiceLoTW:
sinceDate := resolveSince(keyExtLoTWLastDownload) sinceDate := resolveSince(keyExtLoTWLastDownload)
ownCall := a.uploadOwnerCall(extsvc.ServiceLoTW) ownCall := a.uploadOwnerCall(extsvc.ServiceLoTW)
// A LoTW account holds every call its owner operates — F4BPO, F4BPO/P,
// TM2Q — and the download is normally scoped to the profile's own call so
// one profile does not pull another's confirmations. That scope also
// silently hides them: a QSO made as F4BPO/P is confirmed at LoTW and can
// never be downloaded from the F4BPO profile, so it stays unconfirmed here
// for good while the ARRL counts it. Off by default, because the scope is
// right for anyone whose profiles are separate stations.
if a.settingOr(keyExtLoTWAllCalls, "") == "1" {
ownCall = ""
}
callLabel := ownCall callLabel := ownCall
// Unscoped, the report carries every station on the account — including
// the ones belonging to ANOTHER profile's logbook (a Vietnam expedition,
// say). Those confirmations have nothing to match here, and with "add the
// ones not found" ticked they would pour a second log into this one. So
// the station callsigns this logbook actually holds become the filter:
// F4BPO/P is kept because it was worked here, XV9Q is skipped because it
// never was.
var ownStations map[string]bool
if ownCall == "" {
if st, e := a.qso.StationCallsigns(ctx); e == nil && len(st) > 0 {
ownStations = st
}
}
if callLabel == "" { if callLabel == "" {
callLabel = "all callsigns" callLabel = "all callsigns"
} }
@@ -12179,6 +12219,19 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
return return
} }
emit(fmt.Sprintf("LoTW returned %d KB of ADIF", len(adifText)/1024)) emit(fmt.Sprintf("LoTW returned %d KB of ADIF", len(adifText)/1024))
// A report far smaller than the account justifies is the one failure that
// looks like success: LoTW answers 200 with a near-empty ADIF when it
// disagrees with the query (an unknown callsign in qso_owncall, a login
// that half-worked). Show its own header rather than leaving "matched 1 of
// 1" to be read as "you have one confirmation".
if len(adifText) < 4096 {
head := strings.TrimSpace(adifText)
if len(head) > 400 {
head = head[:400]
}
emit("The report is unexpectedly small — what LoTW actually sent:")
emit(" " + strings.Join(strings.Fields(head), " "))
}
keyIDs, kerr := a.qso.DedupeKeyIDs(ctx) keyIDs, kerr := a.qso.DedupeKeyIDs(ctx)
if kerr != nil { if kerr != nil {
emit("Error reading local log: " + kerr.Error()) emit("Error reading local log: " + kerr.Error())
@@ -12196,6 +12249,7 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"}) sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"})
var items []ConfirmationItem var items []ConfirmationItem
var unmatched []string var unmatched []string
skippedOtherStation := 0
perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error { perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
if ctx.Err() != nil { if ctx.Err() != nil {
return ctx.Err() // window closed / superseded return ctx.Err() // window closed / superseded
@@ -12204,6 +12258,16 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
if !ok { if !ok {
return nil return nil
} }
// Another station's confirmation — see ownStations above. Counted so
// the report says how many were left alone rather than silently
// dropping a third of the file.
if ownStations != nil {
st := strings.ToUpper(strings.TrimSpace(rec["station_callsign"]))
if st != "" && !ownStations[st] {
skippedOtherStation++
return nil
}
}
total++ total++
date := rec["qslrdate"] date := rec["qslrdate"]
if date == "" { if date == "" {
@@ -12284,6 +12348,9 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
} else { } else {
emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total)) emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total))
} }
if skippedOtherStation > 0 {
emit(fmt.Sprintf(" (%d confirmation(s) skipped — made under a callsign this logbook has never used)", skippedOtherStation))
}
if byClass > 0 { if byClass > 0 {
// Said out loud rather than folded silently into the total: these // Said out loud rather than folded silently into the total: these
// matched on the mode CLASS, not the mode. LoTW hands back "DATA" for // matched on the mode CLASS, not the mode. LoTW hands back "DATA" for
+7
View File
@@ -174,6 +174,11 @@ type MatrixColors struct {
EntityWorked string `json:"entity_worked"` EntityWorked string `json:"entity_worked"`
NotWorked string `json:"not_worked"` NotWorked string `json:"not_worked"`
CurrentEntry string `json:"current_entry"` CurrentEntry string `json:"current_entry"`
// The dot marking a slot already worked with the callsign in hand. It is a
// mark rather than a background, so it needs its own two colours: it is
// drawn on top of all five of the above and must stay legible over each.
MarkWorked string `json:"mark_worked"`
MarkConfirmed string `json:"mark_confirmed"`
} }
// normMatrixColors keeps only plain hex values. Anything else becomes "" — i.e. // normMatrixColors keeps only plain hex values. Anything else becomes "" — i.e.
@@ -196,6 +201,8 @@ func normMatrixColors(c MatrixColors) MatrixColors {
EntityWorked: clean(c.EntityWorked), EntityWorked: clean(c.EntityWorked),
NotWorked: clean(c.NotWorked), NotWorked: clean(c.NotWorked),
CurrentEntry: clean(c.CurrentEntry), CurrentEntry: clean(c.CurrentEntry),
MarkWorked: clean(c.MarkWorked),
MarkConfirmed: clean(c.MarkConfirmed),
} }
} }
+18 -2
View File
@@ -3,10 +3,26 @@
"version": "0.26.20", "version": "0.26.20",
"date": "", "date": "",
"en": [ "en": [
"Each radio carries its own MY_RIG (Settings → CAT), written on every QSO made with it — ahead of the per-band station in Operating conditions, which says what you planned to use rather than which radio is keying. Left empty, nothing changes." "Each radio carries its own MY_RIG (Settings → CAT), written on every QSO made with it — ahead of the per-band station in Operating conditions, which says what you planned to use rather than which radio is keying. Left empty, nothing changes.",
"Worked-before matrix: a dot in the corner of a cell now means the callsign you are working has already been worked on that slot, whatever colour the entity status gave the cell. Its two colours (worked / confirmed with this callsign) are in Appearance with the other matrix colours.",
"Icom spectrum scope: the command shape (with or without the main/sub selector) is now worked out from the radios own answers instead of a list of models. A radio that has scope control but no waveform stream over CI-V — the IC-7851 — says so in the panel rather than showing a black rectangle.",
"Elecraft KPA: the status-bar chip now shows the amplifier as connected and switches it between OPERATE and STANDBY like the other brands, and an offline KPA no longer calls itself an Acom.",
"Cluster: a SOTA column, read from the summit reference the SOTA feeds put in the spot comment. Clicking the spot fills the QSOs SOTA award reference, as a POTA spot already did. Turn the column on in Columns.",
"Awards: a \"Slots to confirm\" filter and a running count beside the reference total, so the gap between worked and confirmed band-slots — the Challenge difference — can be seen reference by reference instead of only as two numbers.",
"QSL Manager: a QRZ button next to the Paper QSL search, opening the callsign on QRZ.com.",
"LoTW: an \"All my callsigns\" option beside the download. The download is scoped to the profiles own callsign, so a QSO made as a portable or contest call was confirmed at LoTW and never marked here. Confirmations made under a callsign this logbook has never used are skipped, and a suspiciously small report now shows what LoTW actually answered.",
"LoTW download: \"All\" really means all — without a date LoTW answered with a handful of recent confirmations, which looked like a successful download of an empty account. A full account also has time to arrive: the two-minute limit that ended in \"context deadline exceeded\" is now twenty."
], ],
"fr": [ "fr": [
"Chaque radio porte son propre MY_RIG (Réglages → CAT), inscrit sur chaque QSO fait avec elle — avant la station par bande des Conditions de trafic, qui dit ce qui était prévu et non quelle radio émet. Laissé vide, rien ne change." "Chaque radio porte son propre MY_RIG (Réglages → CAT), inscrit sur chaque QSO fait avec elle — avant la station par bande des Conditions de trafic, qui dit ce qui était prévu et non quelle radio émet. Laissé vide, rien ne change.",
"Matrice des contacts : un point dans le coin d'une case indique que l'indicatif en cours a déjà été contacté sur ce créneau, quelle que soit la couleur donnée par le statut de l'entité. Ses deux couleurs (contacté / confirmé avec cet indicatif) se règlent dans Apparence avec les autres couleurs de la matrice.",
"Scope Icom : la forme des commandes (avec ou sans le sélecteur main/sub) est déduite des réponses de la radio au lieu d'une liste de modèles. Une radio qui pilote son scope mais ne l'envoie pas en CI-V — l'IC-7851 — l'indique dans le panneau au lieu d'afficher un rectangle noir.",
"Elecraft KPA : la pastille de la barre d'état montre enfin l'amplificateur comme connecté et bascule OPERATE / STANDBY comme les autres marques, et un KPA hors ligne ne s'annonce plus comme un Acom.",
"Cluster : une colonne SOTA, lue dans la référence de sommet que les flux SOTA mettent dans le commentaire du spot. Cliquer le spot remplit la référence SOTA du QSO, comme le faisait déjà un spot POTA. Colonne à activer dans Colonnes.",
"Awards : un filtre « Slots à confirmer » et un compteur à côté du total de références, pour voir l'écart entre créneaux contactés et confirmés — la différence du Challenge — référence par référence et non plus seulement en deux chiffres.",
"Gestionnaire QSL : un bouton QRZ à côté de la recherche QSL papier, qui ouvre l'indicatif sur QRZ.com.",
"LoTW : une option « Tous mes indicatifs » à côté du téléchargement. Celui-ci est limité à l'indicatif du profil, si bien qu'un QSO fait sous un indicatif portable ou de contest était confirmé chez LoTW sans jamais être marqué ici. Les confirmations faites sous un indicatif que ce carnet n'a jamais utilisé sont ignorées, et un rapport anormalement petit affiche désormais ce que LoTW a réellement répondu.",
"Téléchargement LoTW : « Tout » veut enfin dire tout — sans date, LoTW ne renvoyait qu'une poignée de confirmations récentes, ce qui ressemblait à un téléchargement réussi d'un compte vide. Un compte complet a aussi le temps d'arriver : la limite de deux minutes, qui finissait en « context deadline exceeded », passe à vingt."
] ]
}, },
{ {
+22 -14
View File
@@ -3275,7 +3275,7 @@ export default function App() {
function handleSpotSelect(s: any) { function handleSpotSelect(s: any) {
if (!s?.dx_call?.trim()) return; if (!s?.dx_call?.trim()) return;
onCallsignInput(s.dx_call, { force: true }); onCallsignInput(s.dx_call, { force: true });
applySpotPOTA((s as any).pota_ref); applySpotRefs((s as any).pota_ref, (s as any).sota_ref);
} }
function handleSpotClick(s: any) { function handleSpotClick(s: any) {
@@ -3300,7 +3300,7 @@ export default function App() {
FlexZoomForSpot(m ?? '', s.freq_hz ?? 0).catch(() => {}); FlexZoomForSpot(m ?? '', s.freq_hz ?? 0).catch(() => {});
if (m) applyModeFromSpot(m); if (m) applyModeFromSpot(m);
onCallsignInput(s.dx_call, { force: true }); onCallsignInput(s.dx_call, { force: true });
applySpotPOTA((s as any).pota_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);
} }
@@ -3761,14 +3761,14 @@ export default function App() {
restartRecordingForNewTarget(call); restartRecordingForNewTarget(call);
// The park, like a click in the band map: the radio reports only a // The park, like a click in the band map: the radio reports only a
// callsign, so the backend looks it up again before sending the event. // callsign, so the backend looks it up again before sending the event.
applySpotPOTA(String(p?.pota_ref ?? '')); applySpotRefs(String(p?.pota_ref ?? ''), String(p?.sota_ref ?? ''));
}); });
// Clicking a spot on the ExpertSDR (TCI) panorama fills the call, like Flex. // Clicking a spot on the ExpertSDR (TCI) panorama fills the call, like Flex.
const unsubTciSpot = EventsOn('tci:spot_clicked', (p: any) => { const unsubTciSpot = EventsOn('tci:spot_clicked', (p: any) => {
const call = String(p?.call ?? ''); const call = String(p?.call ?? '');
if (!applyUdpCall(call, true)) return; if (!applyUdpCall(call, true)) return;
restartRecordingForNewTarget(call); restartRecordingForNewTarget(call);
applySpotPOTA(String(p?.pota_ref ?? '')); applySpotRefs(String(p?.pota_ref ?? ''), String(p?.sota_ref ?? ''));
}); });
const unsubBulk = EventsOn('bulkupdate:progress', (p: any) => { const unsubBulk = EventsOn('bulkupdate:progress', (p: any) => {
const total = Number(p?.total ?? 0); const total = Number(p?.total ?? 0);
@@ -4796,13 +4796,17 @@ export default function App() {
} }
wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150); wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150);
} }
// applySpotPOTA sets the QSO's POTA award reference(s) from a clicked spot's // Award references carried by the spot itself: the park a station is
// park ref ("US-4164" or n-fer "US-1,US-2"). Empty ref clears it (fresh // activating, the summit it is on, or both. Written into award_refs the same
// target). Routed to the pota_ref column at save via applyAwardRefs. // way for each, so logging the contact credits it without retyping a
function applySpotPOTA(potaRef?: string) { // reference that was on screen.
const refs = String(potaRef || '') function applySpotRefs(potaRef?: string, sotaRef?: string) {
.split(/[,;]/).map((x) => x.trim().toUpperCase()).filter(Boolean); const split = (v?: string) => String(v || '').split(/[,;]/).map((x) => x.trim().toUpperCase()).filter(Boolean);
setDetails((d) => ({ ...d, award_refs: refs.map((r) => `POTA@${r}`).join(';') })); const refs = [
...split(potaRef).map((r) => `POTA@${r}`),
...split(sotaRef).map((r) => `SOTA@${r}`),
];
setDetails((d) => ({ ...d, award_refs: refs.join(';') }));
} }
function onCallsignInput(v: string, opts?: { force?: boolean }) { function onCallsignInput(v: string, opts?: { force?: boolean }) {
// Programmatic call-sets (force: spot click, UDP, external app) count as // Programmatic call-sets (force: spot click, UDP, external app) count as
@@ -8423,9 +8427,13 @@ export default function App() {
STANDBY, red = offline. CLICK toggles OPERATE STANDBY (optimistic STANDBY, red = offline. CLICK toggles OPERATE STANDBY (optimistic
flip, the 2s poll reconciles); offline click opens the settings. */} flip, the 2s poll reconciles); offline click opens the settings. */}
{ampSts.map((a: any) => { {ampSts.map((a: any) => {
const isPGXL = !a.spe && !a.acom; // Every brand must be listed here. A KPA was not, so its chip
// read the fallback — permanently red on a connected amplifier,
// and clicking it opened the settings instead of switching to
// STANDBY.
const isPGXL = !a.spe && !a.acom && !a.kpa;
const viaFlex = isPGXL && !!flexAmp?.amp_available; const viaFlex = isPGXL && !!flexAmp?.amp_available;
const raw = a.spe ?? a.acom ?? a.pgxl ?? { connected: false }; const raw = a.spe ?? a.acom ?? a.kpa ?? a.pgxl ?? { connected: false };
const connected = !!raw.connected || viaFlex; const connected = !!raw.connected || viaFlex;
const operate = viaFlex ? !!flexAmp.amp_operate : !!raw.operate; const operate = viaFlex ? !!flexAmp.amp_operate : !!raw.operate;
const dot = !connected ? 'bg-danger' : operate ? 'bg-success' : 'bg-warning'; const dot = !connected ? 'bg-danger' : operate ? 'bg-success' : 'bg-warning';
@@ -8435,7 +8443,7 @@ export default function App() {
const want = !operate; const want = !operate;
if (viaFlex) setFlexAmp((f: any) => ({ ...f, amp_operate: want })); if (viaFlex) setFlexAmp((f: any) => ({ ...f, amp_operate: want }));
else setAmpSts((l) => l.map((x: any) => x.id === a.id else setAmpSts((l) => l.map((x: any) => x.id === a.id
? { ...x, spe: x.spe && { ...x.spe, operate: want }, acom: x.acom && { ...x.acom, operate: want }, pgxl: x.pgxl && { ...x.pgxl, operate: want } } ? { ...x, spe: x.spe && { ...x.spe, operate: want }, acom: x.acom && { ...x.acom, operate: want }, kpa: x.kpa && { ...x.kpa, operate: want }, pgxl: x.pgxl && { ...x.pgxl, operate: want } }
: x)); : x));
(viaFlex ? FlexAmpOperate(want) : AmpOperate(a.id, want)).catch(() => {}); (viaFlex ? FlexAmpOperate(want) : AmpOperate(a.id, want)).catch(() => {});
}; };
+1 -1
View File
@@ -194,7 +194,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
</div> </div>
<span className={cn('inline-flex items-center gap-1.5 text-sm', kpa.connected ? 'text-muted-foreground' : 'text-danger')}> <span className={cn('inline-flex items-center gap-1.5 text-sm', kpa.connected ? 'text-muted-foreground' : 'text-danger')}>
<span className={cn('size-2 rounded-full', kpa.connected ? 'bg-success' : 'bg-danger')} /> <span className={cn('size-2 rounded-full', kpa.connected ? 'bg-success' : 'bg-danger')} />
{kpa.connected ? (kpa.tuning ? t('flxp.kpaTuning') : (kpa.power_on ? 'ON' : 'OFF')) : t('flxp.acomOffline')} {kpa.connected ? (kpa.tuning ? t('flxp.kpaTuning') : (kpa.power_on ? 'ON' : 'OFF')) : t('flxp.kpaOffline')}
</span> </span>
{kpa.connected && ( {kpa.connected && (
<span className="text-sm font-mono text-muted-foreground tabular-nums"> <span className="text-sm font-mono text-muted-foreground tabular-nums">
@@ -127,6 +127,12 @@ function MatrixColorsSection() {
<span className="inline-block w-7 h-5 rounded bg-mx-dx-work" /> <span className="inline-block w-7 h-5 rounded bg-mx-dx-work" />
<span className="inline-block w-7 h-5 rounded bg-mx-none" /> <span className="inline-block w-7 h-5 rounded bg-mx-none" />
<span className="inline-block w-7 h-5 rounded bg-mx-none ring-2 ring-mx-cur ring-inset" /> <span className="inline-block w-7 h-5 rounded bg-mx-none ring-2 ring-mx-cur ring-inset" />
<span className="relative inline-block w-7 h-5 rounded bg-mx-dx-conf">
<span className="absolute top-[4px] right-[4px] size-[5px] rounded-full bg-mx-mark-work ring-1 ring-background" />
</span>
<span className="relative inline-block w-7 h-5 rounded bg-mx-dx-conf">
<span className="absolute top-[4px] right-[4px] size-[5px] rounded-full bg-mx-mark-conf ring-1 ring-background" />
</span>
</div> </div>
<button type="button" onClick={reset} <button type="button" onClick={reset}
+40 -4
View File
@@ -65,6 +65,19 @@ function cellStatus(r: AwardRef, band: string): CellStatus {
if (r.bands?.includes(band)) return 'worked'; if (r.bands?.includes(band)) return 'worked';
return 'none'; return 'none';
} }
// slotsToConfirm counts the band-slots worked with this reference and not yet
// confirmed on any of them — the QSLs still outstanding, one per cell showing W.
//
// It is the difference the Challenge line makes visible in the aggregate (1832
// worked against 1554 confirmed) without saying WHERE it is. Counted over the
// bands actually on screen, so it always adds up to the columns in front of the
// operator rather than to a band set they filtered out.
function slotsToConfirm(r: AwardRef, bands: string[]): number {
let n = 0;
for (const b of bands) if (cellStatus(r, b) === 'worked') n++;
return n;
}
const CELL_STYLE: Record<CellStatus, string> = { const CELL_STYLE: Record<CellStatus, string> = {
validated: 'bg-success text-success-foreground', validated: 'bg-success text-success-foreground',
confirmed: 'bg-warning text-warning-foreground', confirmed: 'bg-warning text-warning-foreground',
@@ -112,7 +125,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
const [refSearch, setRefSearch] = useState(''); const [refSearch, setRefSearch] = useState('');
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid'); const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all'); const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf' | 'slots_notconf'>('all');
// Mode filter, stacked ON TOP of the status one. "Worked on CW but not // Mode filter, stacked ON TOP of the status one. "Worked on CW but not
// confirmed" is two questions at once, and answering only one of them is what // confirmed" is two questions at once, and answering only one of them is what
// sends an operator to a spreadsheet. // sends an operator to a spreadsheet.
@@ -304,6 +317,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
if (refFilter === 'worked' && !r.worked) return false; if (refFilter === 'worked' && !r.worked) return false;
if (refFilter === 'notworked' && r.worked) return false; if (refFilter === 'notworked' && r.worked) return false;
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false; if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
// Worked-not-confirmed by SLOT, not by reference: an entity confirmed on
// 20 m still has a 15 m contact waiting for its card, and every filter
// above answers "no" for it because the entity itself is confirmed.
if (refFilter === 'slots_notconf' && slotsToConfirm(r, gridBands) === 0) return false;
if (modeFilter !== 'all' && refFilter !== 'notworked') { if (modeFilter !== 'all' && refFilter !== 'notworked') {
// A reference never worked has no mode, so "not worked" plus a mode is // A reference never worked has no mode, so "not worked" plus a mode is
// a contradiction: the mode filter stands aside rather than emptying // a contradiction: the mode filter stands aside rather than emptying
@@ -339,7 +356,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
} }
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir; return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
}); });
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]); }, [current, refSearch, refFilter, modeFilter, refSort, refSortDir, gridBands]);
// The gap itself, over whatever the other filters left on screen: the number
// of cells an operator would have to turn green to close it.
const slotGap = useMemo(
() => filteredRefs.reduce((n, r) => n + slotsToConfirm(r, gridBands), 0),
[filteredRefs, gridBands],
);
// The group column earns its width only when the list actually carries one // The group column earns its width only when the list actually carries one
// (DXCC prefixes, POTA locations); most custom lists have none. For DXCC the // (DXCC prefixes, POTA locations); most custom lists have none. For DXCC the
@@ -468,7 +492,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
<Input className="h-8 w-56 pl-7 text-sm" placeholder={t('awp.filterReferences')} value={refSearch} onChange={(e) => setRefSearch(e.target.value)} /> <Input className="h-8 w-56 pl-7 text-sm" placeholder={t('awp.filterReferences')} value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
</div> </div>
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm"> <div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
{([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')]] as const).map(([k, label]) => ( {([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')], ['slots_notconf', t('awp.filterSlotsNotCfmd')]] as const).map(([k, label]) => (
<button key={k} onClick={() => setRefFilter(k)} <button key={k} onClick={() => setRefFilter(k)}
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}> className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
{label} {label}
@@ -484,6 +508,11 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
))} ))}
</div> </div>
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span> <span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
{slotGap > 0 && (
<span className="text-xs text-muted-foreground" title={t('awp.slotGapTip')}>
· <span className="font-semibold text-foreground">{slotGap}</span> {t('awp.slotGap')}
</span>
)}
{/* Only for an award scoped to a DXCC entity. "In this award's {/* Only for an award scoped to a DXCC entity. "In this award's
scope but with no reference" needs a scope to be in: on a scope but with no reference" needs a scope to be in: on a
worldwide reference award — POTA, SOTA, IOTA, WWFF — every worldwide reference award — POTA, SOTA, IOTA, WWFF — every
@@ -604,7 +633,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
<td key={b} className="border-b border-l border-border/30 p-0 text-center"> <td key={b} className="border-b border-l border-border/30 p-0 text-center">
{s === 'none' ? <span className="block w-11 h-7" /> : ( {s === 'none' ? <span className="block w-11 h-7" /> : (
<button <button
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110')} className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110',
// Chasing the slots still to confirm, the
// confirmed ones are context, not the answer:
// a row is kept for its W cells and its V
// cells would otherwise be the loudest thing
// on it. Faded rather than hidden — which
// band is already done is worth seeing.
refFilter === 'slots_notconf' && s !== 'worked' && 'opacity-25')}
title={t('awp.cellTitle', { ref: r.ref, band: b })} title={t('awp.cellTitle', { ref: r.ref, band: b })}
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })} onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
>{CELL_LABEL[s]}</button> >{CELL_LABEL[s]}</button>
+49 -8
View File
@@ -81,23 +81,44 @@ const STATUS_CLASSES: Record<string, string> = {
// i18n keys the Appearance panel's colour pickers use, so the two can never // i18n keys the Appearance panel's colour pickers use, so the two can never
// disagree about which green is which. swatch = the background class (or a // disagree about which green is which. swatch = the background class (or a
// special ring marker for the current-entry cell). // special ring marker for the current-entry cell).
const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [ const LEGEND: { swatch: string; ring?: boolean; mark?: string; label: string }[] = [
{ swatch: 'bg-mx-call-conf', label: 'mx.callConf' }, { swatch: 'bg-mx-call-conf', label: 'mx.callConf' },
{ swatch: 'bg-mx-call-work', label: 'mx.callWork' }, { swatch: 'bg-mx-call-work', label: 'mx.callWork' },
{ swatch: 'bg-mx-dx-conf', label: 'mx.dxConf' }, { swatch: 'bg-mx-dx-conf', label: 'mx.dxConf' },
{ swatch: 'bg-mx-dx-work', label: 'mx.dxWork' }, { swatch: 'bg-mx-dx-work', label: 'mx.dxWork' },
{ swatch: 'bg-mx-none', label: 'mx.none' }, { swatch: 'bg-mx-none', label: 'mx.none' },
{ swatch: 'bg-mx-none', ring: true, label: 'mx.current' }, { swatch: 'bg-mx-none', ring: true, label: 'mx.current' },
{ swatch: 'bg-mx-none', mark: 'w', label: 'mx.markWork' },
{ swatch: 'bg-mx-none', mark: 'c', label: 'mx.markConf' },
]; ];
function cellTitle(t: (k: string) => string, band: string, cls: string, status: string, current: boolean): string { // CallMark — "this callsign has already been worked on this slot".
//
// Drawn the same way on every cell, whatever colour the entity status gave it:
// the operator learns one shape and reads it without first working out what the
// background means. Only the fill changes, and only with the callsign's own
// state (worked / confirmed) — never with the entity's. The ring is the theme
// background, which is what keeps the dot legible over all five cell colours.
function CallMark({ state = 'w' }: { state?: string }) {
return (
<span
className={cn(
'pointer-events-none absolute top-[4px] right-[4px] size-[5px] rounded-full ring-1 ring-background',
state === 'c' ? 'bg-mx-mark-conf' : 'bg-mx-mark-work',
)}
/>
);
}
function cellTitle(t: (k: string) => string, band: string, cls: string, status: string, current: boolean, call = ''): string {
const desc = const desc =
status === 'call_c' ? t('mx.tipCallConf') : status === 'call_c' ? t('mx.tipCallConf') :
status === 'call_w' ? t('mx.tipCallWork') : status === 'call_w' ? t('mx.tipCallWork') :
status === 'dxcc_c' ? t('mx.tipDxConf') : status === 'dxcc_c' ? t('mx.tipDxConf') :
status === 'dxcc_w' ? t('mx.tipDxWork') : status === 'dxcc_w' ? t('mx.tipDxWork') :
t('mx.tipNone'); t('mx.tipNone');
return `${band} ${cls}: ${desc}${current ? ' — ' + t('mx.current') : ''}`; const mine = call === 'c' ? t('mx.tipThisCallConf') : call === 'w' ? t('mx.tipThisCall') : '';
return `${band} ${cls}: ${desc}${mine ? ' — ' + mine : ''}${current ? ' — ' + t('mx.current') : ''}`;
} }
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) { export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
@@ -126,6 +147,16 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
return m; return m;
}, [wb]); }, [wb]);
// Worked-with-this-callsign, per cell — carried separately by the backend
// because collapsing it into the status is what hid it.
const callMap = useMemo(() => {
const m = new Map<string, string>();
for (const s of wb?.band_status ?? []) {
if ((s as any).call) m.set(`${s.band}|${s.class}`, (s as any).call);
}
return m;
}, [wb]);
// "Newness" of the current band+mode entry, for the award/DX-chase badges. // "Newness" of the current band+mode entry, for the award/DX-chase badges.
// Derived straight from the entity's real band_status (all bands it was // Derived straight from the entity's real band_status (all bands it was
// worked on — not just the operator's configured column list). // worked on — not just the operator's configured column list).
@@ -308,21 +339,29 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
</th> </th>
{cols.map((b) => { {cols.map((b) => {
const st = statusMap.get(`${b.tag}|${cls}`) ?? ''; const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
// The same cell's other answer: worked with THIS callsign
// here. The status above is the entity's, and a confirmed
// entity outranks a worked call — so chasing a DXpedition,
// the cell could say "confirmed" about a contact made years
// ago and nothing about the one made this morning.
const mine = callMap.get(`${b.tag}|${cls}`) ?? '';
const isCurrent = hasCall && b.tag === currentBand && classCurrent; const isCurrent = hasCall && b.tag === currentBand && classCurrent;
return ( return (
<td <td
key={b.tag} key={b.tag}
title={cellTitle(t, b.tag, cls, st, isCurrent) + (st ? ' — ' + t('mx.tipClick') : '')} title={cellTitle(t, b.tag, cls, st, isCurrent, mine) + (st ? ' — ' + t('mx.tipClick') : '')}
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined} onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
className={cn( className={cn(
'w-[28px] h-[24px] rounded transition-colors p-0', 'relative w-[28px] h-[24px] rounded transition-colors p-0',
st ? STATUS_CLASSES[st] : 'bg-mx-none', st ? STATUS_CLASSES[st] : 'bg-mx-none',
// Only a filled cell has anything to show — an empty one // Only a filled cell has anything to show — an empty one
// stays inert rather than opening a "no QSOs" dialog. // stays inert rather than opening a "no QSOs" dialog.
st && 'cursor-pointer hover:brightness-110', st && 'cursor-pointer hover:brightness-110',
isCurrent && 'ring-2 ring-mx-cur ring-inset', isCurrent && 'ring-2 ring-mx-cur ring-inset',
)} )}
/> >
{mine ? <CallMark state={mine} /> : null}
</td>
); );
})} })}
</tr> </tr>
@@ -337,11 +376,13 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
<span key={l.label} className="flex items-center gap-1.5 text-[10px] text-muted-foreground"> <span key={l.label} className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span <span
className={cn( className={cn(
'inline-block size-3 rounded shrink-0', 'relative inline-block size-3 rounded shrink-0',
l.swatch, l.swatch,
l.ring && 'ring-2 ring-mx-cur ring-inset', l.ring && 'ring-2 ring-mx-cur ring-inset',
)} )}
/> >
{l.mark ? <CallMark state={l.mark} /> : null}
</span>
{t(l.label)} {t(l.label)}
</span> </span>
))} ))}
+11
View File
@@ -45,6 +45,7 @@ export type ClusterSpot = {
raw: string; raw: string;
repeats?: number; repeats?: number;
pota_ref?: string; pota_ref?: string;
sota_ref?: string;
pota_name?: string; pota_name?: string;
}; };
@@ -336,6 +337,16 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
: { color: 'var(--success)' }) as any, : { color: 'var(--success)' }) as any,
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined), tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
}, },
{
// SOTA sits next to POTA and reads the same way. The reference comes from the
// spot's comment, so it is present on the SOTA feeds and empty elsewhere —
// which is why the column is off by default rather than an empty column for
// everyone who does not watch summits.
group: 'Spot', label: t('clg2.c.sota'), colId: 'sota',
headerName: t('clg2.c.sota'), field: 'sota_ref' as any, width: 100, cellClass: 'font-mono',
defaultVisible: false,
cellStyle: () => ({ color: 'var(--success)' }) as any,
},
{ {
group: 'Spot', label: t('clg2.c.freq'), colId: 'freq', group: 'Spot', label: t('clg2.c.freq'), colId: 'freq',
headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono', headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
+8 -1
View File
@@ -292,6 +292,9 @@ function ScopePanadapter() {
const wfRef = useRef<HTMLCanvasElement>(null); // waterfall const wfRef = useRef<HTMLCanvasElement>(null); // waterfall
const peakRef = useRef(160); // running amplitude ceiling for auto-scale const peakRef = useRef(160); // running amplitude ceiling for auto-scale
const holdRef = useRef<number[]>([]); // per-bin peak-hold line const holdRef = useRef<number[]>([]); // per-bin peak-hold line
// Some radios control their scope over CI-V but never stream it (IC-7851).
// Saying so beats a black rectangle, which reads as a bug in OpsLog.
const [unsupported, setUnsupported] = useState(false);
const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune
const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune
const centerRef = useRef(0); // scope centre we last set (for pan ◀/▶) const centerRef = useRef(0); // scope centre we last set (for pan ◀/▶)
@@ -333,6 +336,7 @@ function ScopePanadapter() {
if (!alive) return; if (!alive) return;
try { try {
const sw = await IcomScopeData(); const sw = await IcomScopeData();
if (sw?.unsupported) setUnsupported(true);
if (sw && sw.seq !== lastSeq && sw.amp && sw.amp.length) { if (sw && sw.seq !== lastSeq && sw.amp && sw.amp.length) {
lastSeq = sw.seq; lastSeq = sw.seq;
setFixed(sw.fixed); setFixed(sw.fixed);
@@ -543,7 +547,10 @@ function ScopePanadapter() {
<Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} /> <Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} />
</div> </div>
</div> </div>
{on && ( {on && unsupported && (
<div className="px-3 py-2 text-xs text-muted-foreground">{t('icmp.scopeNoStream')}</div>
)}
{on && !unsupported && (
<div className="p-3"> <div className="p-3">
<div className="rounded-xl overflow-hidden ring-1 ring-info/20 shadow-lg shadow-sky-500/5 bg-[#05070e]"> <div className="rounded-xl overflow-hidden ring-1 ring-info/20 shadow-lg shadow-sky-500/5 bg-[#05070e]">
<canvas ref={canvasRef} onDoubleClick={onDblClick} <canvas ref={canvasRef} onDoubleClick={onDblClick}
+22 -1
View File
@@ -8,7 +8,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App'; import { GetLoTWDownloadAllCalls, SetLoTWDownloadAllCalls, OpenExternalURL, FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { EventsOn } from '../../wailsjs/runtime/runtime'; import { EventsOn } from '../../wailsjs/runtime/runtime';
@@ -255,6 +255,9 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [addNotFound, setAddNotFound] = useState(false); const [addNotFound, setAddNotFound] = useState(false);
// LoTW only: pull the whole account rather than this profile's callsign.
const [lotwAllCalls, setLotwAllCalls] = useState(false);
useEffect(() => { GetLoTWDownloadAllCalls().then((v: boolean) => setLotwAllCalls(!!v)).catch(() => {}); }, []);
// Download date window: 'last' = incremental since last pull, 'date' = from a // Download date window: 'last' = incremental since last pull, 'date' = from a
// chosen date, 'all' = everything. // chosen date, 'all' = everything.
const [sinceMode, setSinceMode] = useState<'last' | 'date' | 'all'>('last'); const [sinceMode, setSinceMode] = useState<'last' | 'date' | 'all'>('last');
@@ -434,6 +437,18 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
{paperBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />} {paperBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
{t('qslm.search')} {t('qslm.search')}
</Button> </Button>
{/* The station's QRZ page, one click away: writing a card means
reading the address, the manager and whether they even want
paper, and all three are on that page. */}
<Button size="sm" variant="outline" className="h-8" disabled={!paperCall.trim()}
title={t('qslm.qrzTitle')}
onClick={() => {
const c = paperCall.trim().toUpperCase();
if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch(() => {});
}}>
<ExternalLink className="size-3.5" />
QRZ
</Button>
<span className="text-[11px] text-muted-foreground self-center">{t('qslm.paperHint')}</span> <span className="text-[11px] text-muted-foreground self-center">{t('qslm.paperHint')}</span>
</> </>
) : ( ) : (
@@ -736,6 +751,12 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} /> <Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
{t('qslm.addNotFound')} {t('qslm.addNotFound')}
</label> </label>
{service === 'lotw' && (
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title={t('qslm.lotwAllCallsTitle')}>
<Checkbox checked={lotwAllCalls} onCheckedChange={(c) => { setLotwAllCalls(!!c); SetLoTWDownloadAllCalls(!!c); }} />
{t('qslm.lotwAllCalls')}
</label>
)}
</>)} </>)}
</div> </div>
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}> <Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
+22 -4
View File
@@ -127,6 +127,15 @@ const en: Dict = {
'mx.tipCallConf': 'This callsign confirmed', 'mx.tipCallWork': 'This callsign worked (not confirmed)', 'mx.tipCallConf': 'This callsign confirmed', 'mx.tipCallWork': 'This callsign worked (not confirmed)',
'mx.tipDxConf': 'Entity confirmed (other callsign)', 'mx.tipDxWork': 'Entity worked (other callsign)', 'mx.tipDxConf': 'Entity confirmed (other callsign)', 'mx.tipDxWork': 'Entity worked (other callsign)',
'mx.tipNone': 'Never worked', 'mx.tipClick': 'click to list the QSOs', 'mx.tipNone': 'Never worked', 'mx.tipClick': 'click to list the QSOs',
'icmp.scopeNoStream': 'This radio does not send its scope over CI-V — its own screen still works.',
'qslm.qrzTitle': 'Open this callsign on QRZ.com',
'qslm.lotwAllCalls': 'All my callsigns',
'qslm.lotwAllCallsTitle': "Download the confirmations of every callsign on the LoTW account, not just this profile's. A QSO made as F4BPO/P or TM2Q is confirmed at LoTW but never reaches an F4BPO profile without this.",
'awp.filterSlotsNotCfmd': 'Slots to confirm', 'awp.slotGap': 'slots to confirm',
'awp.slotGapTip': 'Band-slots worked and not yet confirmed — the difference between the worked and confirmed totals above.',
'mx.markWork': 'This callsign worked', 'mx.markConf': 'This callsign confirmed',
'mx.tipThisCall': 'already worked 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', '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',
@@ -427,7 +436,7 @@ const en: Dict = {
'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Channel {letter} — active', 'tgp.chSelect': 'Make channel {letter} active', 'tgp.chActiveTag': 'active', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypassed', 'tgp.inLine': 'In line', 'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Channel {letter} — active', 'tgp.chSelect': 'Make channel {letter} active', 'tgp.chActiveTag': 'active', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypassed', 'tgp.inLine': 'In line',
'flxp.ritHint': 'RIT — shifts your RECEIVE frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.xitHint': 'XIT — shifts your TRANSMIT frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.ritHint': 'RIT — shifts your RECEIVE frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.xitHint': 'XIT — shifts your TRANSMIT frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.',
'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.rstChaseHint': 'Chase the pile-up: when the CW skimmer marks a report ({m}) on the panadapter, move the TRANSMIT slice there — that is where the DX was listening a second ago. The receive slice never moves. Right-click to change the marker text.', 'flxp.rstChaseMarkerHint': 'The text the skimmer writes for a report — whatever SDC is set to send (599, 5NN…). Several can be given, separated by commas; add the old-report marker to chase those too.', 'flxp.rstChaseOffset': 'off', 'k3.console': 'Elecraft Console', 'k3.waiting': 'Waiting for the radio… (set CAT to Elecraft or Kenwood and connect)', 'k3.rfGain': 'RF gain', 'k3.micGain': 'Mic', 'k3.squelch': 'Squelch', 'k3.filter': 'Filter', 'k3.antenna': 'Antenna', 'k3.clear': 'CLEAR', 'k3.keySpeed': 'Keyer', 'k3.meters': 'Meters', 'k3.levels': 'Levels', 'k3.receive': 'Receive', 'k3.power': 'Power', 'k3.volume': 'Volume', 'k3.refreshHint': 'Re-read the settings from the radio — for when a knob was turned on the front panel.', 'k3.sMeterHint': 'Click to use this reading as the report sent. Raw value from the rig: {raw}.', 'k3.atuHint': 'Put the ATU in line or bypass it (a hold of the K3 ATU switch).', 'k3.tuneHint': 'Start an ATU tuning cycle (K3: a tap of the ATU TUNE button). The exact command sent is written to the log.', 'k3.provisional': 'Meter scaling is provisional: it has not yet been confirmed against a real K3, and the raw readings are written to the log so it can be.', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay', 'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.rstChaseHint': 'Chase the pile-up: when the CW skimmer marks a report ({m}) on the panadapter, move the TRANSMIT slice there — that is where the DX was listening a second ago. The receive slice never moves. Right-click to change the marker text.', 'flxp.rstChaseMarkerHint': 'The text the skimmer writes for a report — whatever SDC is set to send (599, 5NN…). Several can be given, separated by commas; add the old-report marker to chase those too.', 'flxp.rstChaseOffset': 'off', 'k3.console': 'Elecraft Console', 'k3.waiting': 'Waiting for the radio… (set CAT to Elecraft or Kenwood and connect)', 'k3.rfGain': 'RF gain', 'k3.micGain': 'Mic', 'k3.squelch': 'Squelch', 'k3.filter': 'Filter', 'k3.antenna': 'Antenna', 'k3.clear': 'CLEAR', 'k3.keySpeed': 'Keyer', 'k3.meters': 'Meters', 'k3.levels': 'Levels', 'k3.receive': 'Receive', 'k3.power': 'Power', 'k3.volume': 'Volume', 'k3.refreshHint': 'Re-read the settings from the radio — for when a knob was turned on the front panel.', 'k3.sMeterHint': 'Click to use this reading as the report sent. Raw value from the rig: {raw}.', 'k3.atuHint': 'Put the ATU in line or bypass it (a hold of the K3 ATU switch).', 'k3.tuneHint': 'Start an ATU tuning cycle (K3: a tap of the ATU TUNE button). The exact command sent is written to the log.', 'k3.provisional': 'Meter scaling is provisional: it has not yet been confirmed against a real K3, and the raw readings are written to the log so it can be.', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay',
'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.atuTune': 'TUNE', 'flxp.atuTuneHint': 'Start a tuning cycle on the built-in ATU. The radio keys a carrier itself to measure the match.', 'flxp.atuBypass': 'BYPASS', 'flxp.atuBypassHint': 'Take the ATU out of line (straight through).', 'flxp.atuMem': 'MEM', 'flxp.atuMemHint': 'Reuse the stored tuning solution for this frequency instead of tuning again.', 'flxp.atuIdle': 'not tuned', 'flxp.atuTuning': 'tuning…', 'flxp.atuOk': 'tuned', 'flxp.atuFail': 'TUNE FAILED', 'flxp.atuBypassed': 'bypassed', 'flxp.atuAborted': 'aborted', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.voltage': 'VOLTAGE', 'flxp.paTemp': 'PA TEMP', 'flxp.txFilter': 'TX filter', 'flxp.micProfile': 'Mic profile', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER', 'flxp.outputPower': 'OUTPUT POWER', 'flxp.speOffline': 'SPE offline', 'flxp.acomOffline': 'Acom offline', 'flxp.kpaTuning': 'TUNING', 'flxp.kpaClearsFault': 'OPERATE also clears the current fault (except temperature, which clears as it cools)', 'flxp.ampPick': 'Choose which amplifier this card shows', 'flxp.dspV4Hint': 'SmartSDR v4 DSP (8000/Aurora series)', 'flxp.daxHint': 'DAX as the transmit audio source (SmartSDR transmit-bar DAX button) — for WSJT-X & co', 'flxp.rnnHint': 'RNN — AI noise reduction (on/off)', 'flxp.anftHint': 'ANFT — FFT-based automatic notch filter (on/off)', 'flxp.dspNoise': 'Noise', 'flxp.dspMore': 'Show/hide advanced DSP (WNB, v4 NR/notch)', 'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.atuTune': 'TUNE', 'flxp.atuTuneHint': 'Start a tuning cycle on the built-in ATU. The radio keys a carrier itself to measure the match.', 'flxp.atuBypass': 'BYPASS', 'flxp.atuBypassHint': 'Take the ATU out of line (straight through).', 'flxp.atuMem': 'MEM', 'flxp.atuMemHint': 'Reuse the stored tuning solution for this frequency instead of tuning again.', 'flxp.atuIdle': 'not tuned', 'flxp.atuTuning': 'tuning…', 'flxp.atuOk': 'tuned', 'flxp.atuFail': 'TUNE FAILED', 'flxp.atuBypassed': 'bypassed', 'flxp.atuAborted': 'aborted', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.voltage': 'VOLTAGE', 'flxp.paTemp': 'PA TEMP', 'flxp.txFilter': 'TX filter', 'flxp.micProfile': 'Mic profile', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER', 'flxp.outputPower': 'OUTPUT POWER', 'flxp.speOffline': 'SPE offline', 'flxp.acomOffline': 'Acom offline', 'flxp.kpaOffline': 'KPA offline', 'flxp.kpaTuning': 'TUNING', 'flxp.kpaClearsFault': 'OPERATE also clears the current fault (except temperature, which clears as it cools)', 'flxp.ampPick': 'Choose which amplifier this card shows', 'flxp.dspV4Hint': 'SmartSDR v4 DSP (8000/Aurora series)', 'flxp.daxHint': 'DAX as the transmit audio source (SmartSDR transmit-bar DAX button) — for WSJT-X & co', 'flxp.rnnHint': 'RNN — AI noise reduction (on/off)', 'flxp.anftHint': 'ANFT — FFT-based automatic notch filter (on/off)', 'flxp.dspNoise': 'Noise', 'flxp.dspMore': 'Show/hide advanced DSP (WNB, v4 NR/notch)',
'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.scopePanDown': 'Shift scope 50 kHz', 'icmp.scopePanUp': 'Shift scope +50 kHz', 'icmp.scopeCenterVfo': 'Center scope on the current frequency (±50 kHz)', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.apf': 'Audio peak filter (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.bandCurrent': 'The rig is on {b} m', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?', 'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.scopePanDown': 'Shift scope 50 kHz', 'icmp.scopePanUp': 'Shift scope +50 kHz', 'icmp.scopeCenterVfo': 'Center scope on the current frequency (±50 kHz)', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.apf': 'Audio peak filter (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.bandCurrent': 'The rig is on {b} m', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?',
'rst.clickToFill': 'Click to set RST tx from the signal', 'rst.clickToFill': 'Click to set RST tx from the signal',
'qrz.openTitle': 'Open {call} on QRZ.com', 'qrz.openTitle': 'Open {call} on QRZ.com',
@@ -487,7 +496,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.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.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',
@@ -613,6 +622,15 @@ const fr: Dict = {
'mx.tipCallConf': 'Cet indicatif est confirmé', 'mx.tipCallWork': 'Cet indicatif est contacté (non confirmé)', 'mx.tipCallConf': 'Cet indicatif est confirmé', 'mx.tipCallWork': 'Cet indicatif est contacté (non confirmé)',
'mx.tipDxConf': 'Entité confirmée (autre indicatif)', 'mx.tipDxWork': 'Entité contactée (autre indicatif)', 'mx.tipDxConf': 'Entité confirmée (autre indicatif)', 'mx.tipDxWork': 'Entité contactée (autre indicatif)',
'mx.tipNone': 'Jamais contacté', 'mx.tipClick': 'cliquer pour lister les QSO', 'mx.tipNone': 'Jamais contacté', 'mx.tipClick': 'cliquer pour lister les QSO',
'icmp.scopeNoStream': "Cette radio n'envoie pas son scope en CI-V — son propre écran fonctionne toujours.",
'qslm.qrzTitle': 'Ouvrir cet indicatif sur QRZ.com',
'qslm.lotwAllCalls': 'Tous mes indicatifs',
'qslm.lotwAllCallsTitle': "Télécharger les confirmations de tous les indicatifs du compte LoTW, pas seulement celui du profil. Un QSO fait en F4BPO/P ou TM2Q est confirmé chez LoTW mais n'atteint jamais un profil F4BPO sans cette option.",
'awp.filterSlotsNotCfmd': 'Slots à confirmer', 'awp.slotGap': 'slots à confirmer',
'awp.slotGapTip': "Créneaux bande contactés et pas encore confirmés — l'écart entre les totaux contactés et confirmés ci-dessus.",
'mx.markWork': 'Cet indicatif contacté', 'mx.markConf': 'Cet indicatif confirmé',
'mx.tipThisCall': 'déjà contacté 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', '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',
@@ -899,7 +917,7 @@ const fr: Dict = {
'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Canal {letter} — actif', 'tgp.chSelect': 'Activer le canal {letter}', 'tgp.chActiveTag': 'actif', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypass', 'tgp.inLine': 'En ligne', 'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Canal {letter} — actif', 'tgp.chSelect': 'Activer le canal {letter}', 'tgp.chActiveTag': 'actif', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypass', 'tgp.inLine': 'En ligne',
'flxp.ritHint': "RIT — décale uniquement ta fréquence de RÉCEPTION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.xitHint': "XIT — décale uniquement ta fréquence d'ÉMISSION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.ritHint': "RIT — décale uniquement ta fréquence de RÉCEPTION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.xitHint': "XIT — décale uniquement ta fréquence d'ÉMISSION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.",
'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.rstChaseHint': "Chasser le pile-up : quand le skimmer CW marque un report ({m}) sur le panadapter, déplacer la slice d'ÉMISSION dessus — c'est là que le DX écoutait il y a une seconde. La slice de réception ne bouge jamais. Clic droit pour changer le texte du marqueur.", 'flxp.rstChaseMarkerHint': "Le texte que le skimmer écrit pour un report — ce que SDC est réglé à envoyer (599, 5NN…). On peut en mettre plusieurs, séparés par des virgules ; ajoute le marqueur des reports anciens pour les chasser aussi.", 'flxp.rstChaseOffset': 'off', 'k3.console': 'Console Elecraft', 'k3.waiting': 'En attente de la radio… (règle le CAT sur Elecraft ou Kenwood et connecte)', 'k3.rfGain': 'Gain HF', 'k3.micGain': 'Micro', 'k3.squelch': 'Squelch', 'k3.filter': 'Filtre', 'k3.antenna': 'Antenne', 'k3.clear': 'EFFACER', 'k3.keySpeed': 'Manip', 'k3.meters': 'Mesures', 'k3.levels': 'Niveaux', 'k3.receive': 'Réception', 'k3.power': 'Puissance', 'k3.volume': 'Volume', 'k3.refreshHint': "Relire les réglages depuis la radio — quand un bouton a été tourné en façade.", 'k3.sMeterHint': 'Cliquer pour utiliser cette lecture comme report envoyé. Valeur brute de la radio : {raw}.', 'k3.atuHint': "Mettre la boîte d'accord en ligne ou la contourner (maintien de la touche ATU du K3).", 'k3.tuneHint': "Lancer un cycle d'accord de l'ATU (K3 : appui sur la touche ATU TUNE). La commande exacte envoyée est écrite dans le journal.", 'k3.provisional': "L'échelle des mesures est provisoire : elle n'a pas encore été confirmée sur un vrai K3, et les valeurs brutes sont écrites dans le journal pour qu'elle puisse l'être.", 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai', 'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.rstChaseHint': "Chasser le pile-up : quand le skimmer CW marque un report ({m}) sur le panadapter, déplacer la slice d'ÉMISSION dessus — c'est là que le DX écoutait il y a une seconde. La slice de réception ne bouge jamais. Clic droit pour changer le texte du marqueur.", 'flxp.rstChaseMarkerHint': "Le texte que le skimmer écrit pour un report — ce que SDC est réglé à envoyer (599, 5NN…). On peut en mettre plusieurs, séparés par des virgules ; ajoute le marqueur des reports anciens pour les chasser aussi.", 'flxp.rstChaseOffset': 'off', 'k3.console': 'Console Elecraft', 'k3.waiting': 'En attente de la radio… (règle le CAT sur Elecraft ou Kenwood et connecte)', 'k3.rfGain': 'Gain HF', 'k3.micGain': 'Micro', 'k3.squelch': 'Squelch', 'k3.filter': 'Filtre', 'k3.antenna': 'Antenne', 'k3.clear': 'EFFACER', 'k3.keySpeed': 'Manip', 'k3.meters': 'Mesures', 'k3.levels': 'Niveaux', 'k3.receive': 'Réception', 'k3.power': 'Puissance', 'k3.volume': 'Volume', 'k3.refreshHint': "Relire les réglages depuis la radio — quand un bouton a été tourné en façade.", 'k3.sMeterHint': 'Cliquer pour utiliser cette lecture comme report envoyé. Valeur brute de la radio : {raw}.', 'k3.atuHint': "Mettre la boîte d'accord en ligne ou la contourner (maintien de la touche ATU du K3).", 'k3.tuneHint': "Lancer un cycle d'accord de l'ATU (K3 : appui sur la touche ATU TUNE). La commande exacte envoyée est écrite dans le journal.", 'k3.provisional': "L'échelle des mesures est provisoire : elle n'a pas encore été confirmée sur un vrai K3, et les valeurs brutes sont écrites dans le journal pour qu'elle puisse l'être.", 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai',
'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.atuTune': 'ACCORD', 'flxp.atuTuneHint': "Lance un cycle d'accord sur le coupleur intégré. La radio émet elle-même une porteuse pour mesurer l'adaptation.", 'flxp.atuBypass': 'BYPASS', 'flxp.atuBypassHint': 'Sort le coupleur de la ligne (passage direct).', 'flxp.atuMem': 'MEM', 'flxp.atuMemHint': "Réutilise l'accord mémorisé pour cette fréquence au lieu de refaire un cycle.", 'flxp.atuIdle': 'non accordé', 'flxp.atuTuning': 'accord en cours…', 'flxp.atuOk': 'accordé', 'flxp.atuFail': 'ÉCHEC ACCORD', 'flxp.atuBypassed': 'contourné', 'flxp.atuAborted': 'interrompu', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.voltage': 'TENSION', 'flxp.paTemp': 'TEMP PA', 'flxp.txFilter': 'Filtre TX', 'flxp.micProfile': 'Profil micro', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR', 'flxp.outputPower': 'PUISSANCE DE SORTIE', 'flxp.speOffline': 'SPE hors ligne', 'flxp.acomOffline': 'Acom hors ligne', 'flxp.kpaTuning': 'ACCORD', 'flxp.kpaClearsFault': "OPERATE efface aussi le défaut courant (sauf la température, qui s'efface en refroidissant)", 'flxp.ampPick': 'Choisir quel amplificateur cette carte affiche', 'flxp.dspV4Hint': 'DSP SmartSDR v4 (séries 8000/Aurora)', 'flxp.daxHint': "DAX comme source audio d'émission (bouton DAX du bandeau transmit de SmartSDR) — pour WSJT-X & co", 'flxp.rnnHint': 'RNN — réduction de bruit par IA (on/off)', 'flxp.anftHint': 'ANFT — filtre notch automatique FFT (on/off)', 'flxp.dspNoise': 'Bruit', 'flxp.dspMore': 'Afficher/masquer le DSP avancé (WNB, NR/notch v4)', 'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.atuTune': 'ACCORD', 'flxp.atuTuneHint': "Lance un cycle d'accord sur le coupleur intégré. La radio émet elle-même une porteuse pour mesurer l'adaptation.", 'flxp.atuBypass': 'BYPASS', 'flxp.atuBypassHint': 'Sort le coupleur de la ligne (passage direct).', 'flxp.atuMem': 'MEM', 'flxp.atuMemHint': "Réutilise l'accord mémorisé pour cette fréquence au lieu de refaire un cycle.", 'flxp.atuIdle': 'non accordé', 'flxp.atuTuning': 'accord en cours…', 'flxp.atuOk': 'accordé', 'flxp.atuFail': 'ÉCHEC ACCORD', 'flxp.atuBypassed': 'contourné', 'flxp.atuAborted': 'interrompu', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.voltage': 'TENSION', 'flxp.paTemp': 'TEMP PA', 'flxp.txFilter': 'Filtre TX', 'flxp.micProfile': 'Profil micro', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR', 'flxp.outputPower': 'PUISSANCE DE SORTIE', 'flxp.speOffline': 'SPE hors ligne', 'flxp.acomOffline': 'Acom hors ligne', 'flxp.kpaOffline': 'KPA hors ligne', 'flxp.kpaTuning': 'ACCORD', 'flxp.kpaClearsFault': "OPERATE efface aussi le défaut courant (sauf la température, qui s'efface en refroidissant)", 'flxp.ampPick': 'Choisir quel amplificateur cette carte affiche', 'flxp.dspV4Hint': 'DSP SmartSDR v4 (séries 8000/Aurora)', 'flxp.daxHint': "DAX comme source audio d'émission (bouton DAX du bandeau transmit de SmartSDR) — pour WSJT-X & co", 'flxp.rnnHint': 'RNN — réduction de bruit par IA (on/off)', 'flxp.anftHint': 'ANFT — filtre notch automatique FFT (on/off)', 'flxp.dspNoise': 'Bruit', 'flxp.dspMore': 'Afficher/masquer le DSP avancé (WNB, NR/notch v4)',
'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope 50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.bandCurrent': 'Le poste est sur {b} m', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?', 'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope 50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.bandCurrent': 'Le poste est sur {b} m', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?',
'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal', 'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal',
'qrz.openTitle': 'Ouvrir {call} sur QRZ.com', 'qrz.openTitle': 'Ouvrir {call} sur QRZ.com',
@@ -955,7 +973,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.freq': 'Fréq', 'clg2.c.band': 'Bande', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Préf.', 'clg2.c.cqz': 'Zone CQ', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'Zone ITU', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Chemin court (°)', 'clg2.h.sp_deg': 'CC°', 'clg2.c.lp_deg': 'Chemin long (°)', 'clg2.h.lp_deg': 'CL°', 'clg2.c.country': 'Pays', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Locator du spotter', 'clg2.h.locator': 'Loc spotter', 'clg2.c.county': 'Comté US', 'clg2.tipNewCounty': 'NOUVEAU COMTÉ — jamais contacté', 'clg2.tipNewPfx': "NOUVEAU PRÉFIXE — ce préfixe WPX n'a jamais été contacté", 'clg2.c.comment': 'Commentaire', 'clg2.c.received_at': 'Reçu le', 'clg2.h.received_at': 'Reçu UTC', 'clg2.c.raw': 'Brut', 'clg2.newDxcc': 'NOUV DXCC', 'clg2.newBandMode': 'NOUV B+M', 'clg2.newBand': 'NOUV BANDE', 'clg2.newMode': 'NOUV MODE', 'clg2.newSlot': 'NOUV SLOT', 'clg2.newCall': 'CALL NEUF', 'clg2.wkdCall': 'DÉJÀ QSO', 'clg2.newCounty': 'NOUV CTY', 'clg2.newGridUnconf': 'GRID?', 'clg2.newGrid': 'NOUV GRID', 'clg2.c.grid': 'Grid', 'clg2.tipNewGrid': 'NOUVEAU GRID — ce carré n a jamais été contacté (grid entendu dans un CQ sur le lien UDP)', 'clg2.newPfx': "NOUVEAU PFX", 'clg2.newPota': 'NOUV POTA', 'clg2.tipNewDxcc': 'NOUVEAU DXCC : {country}', 'clg2.tipWorkedCall': 'Indicatif déjà contacté', 'clg2.tipNewBandMode': 'NOUVELLE BANDE ET NOUVEAU MODE pour cette entité — aucun des deux na été fait avec elle', 'clg2.tipNewBand': 'NOUVELLE BANDE pour cette entité', 'clg2.tipNewSlotBand': 'NOUVEAU SLOT (mode pas encore contacté sur cette bande)', 'clg2.tipNewMode': 'NOUVEAU MODE (ce mode jamais contacté sur cette entité)', 'clg2.tipNewSlot': 'NOUVEAU SLOT (cette bande+mode pas encore contactée)', 'clg2.tipNewCall': "CALL NEUF — cet indicatif n a jamais été contacté sur cette bande et ce mode (l entité, si)", 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Géo', 'clg2.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'clg2.clearFilters': 'Effacer les filtres', 'clg2.newSpots': '{n} nouveaux spots — cliquer pour reprendre', 'clg2.columns': 'Colonnes', 'clg2.pickerTitle': 'Colonnes du cluster', 'clg2.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau du cluster.', 'clg2.allGroups': 'Tous les groupes :', 'clg2.all': 'tout', 'clg2.none': 'aucun', 'clg2.resetDefaults': 'Réinitialiser', 'clg2.done': 'Terminé', 'clg2.c.time': 'Heure', 'clg2.c.call': 'Indicatif', 'clg2.c.status': 'Statut', 'clg2.c.pota': 'POTA', 'clg2.c.sota': 'SOTA', 'clg2.c.freq': 'Fréq', 'clg2.c.band': 'Bande', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Préf.', 'clg2.c.cqz': 'Zone CQ', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'Zone ITU', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Chemin court (°)', 'clg2.h.sp_deg': 'CC°', 'clg2.c.lp_deg': 'Chemin long (°)', 'clg2.h.lp_deg': 'CL°', 'clg2.c.country': 'Pays', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Locator du spotter', 'clg2.h.locator': 'Loc spotter', 'clg2.c.county': 'Comté US', 'clg2.tipNewCounty': 'NOUVEAU COMTÉ — jamais contacté', 'clg2.tipNewPfx': "NOUVEAU PRÉFIXE — ce préfixe WPX n'a jamais été contacté", 'clg2.c.comment': 'Commentaire', 'clg2.c.received_at': 'Reçu le', 'clg2.h.received_at': 'Reçu UTC', 'clg2.c.raw': 'Brut', 'clg2.newDxcc': 'NOUV DXCC', 'clg2.newBandMode': 'NOUV B+M', 'clg2.newBand': 'NOUV BANDE', 'clg2.newMode': 'NOUV MODE', 'clg2.newSlot': 'NOUV SLOT', 'clg2.newCall': 'CALL NEUF', 'clg2.wkdCall': 'DÉJÀ QSO', 'clg2.newCounty': 'NOUV CTY', 'clg2.newGridUnconf': 'GRID?', 'clg2.newGrid': 'NOUV GRID', 'clg2.c.grid': 'Grid', 'clg2.tipNewGrid': 'NOUVEAU GRID — ce carré n a jamais été contacté (grid entendu dans un CQ sur le lien UDP)', 'clg2.newPfx': "NOUVEAU PFX", 'clg2.newPota': 'NOUV POTA', 'clg2.tipNewDxcc': 'NOUVEAU DXCC : {country}', 'clg2.tipWorkedCall': 'Indicatif déjà contacté', 'clg2.tipNewBandMode': 'NOUVELLE BANDE ET NOUVEAU MODE pour cette entité — aucun des deux na été fait avec elle', 'clg2.tipNewBand': 'NOUVELLE BANDE pour cette entité', 'clg2.tipNewSlotBand': 'NOUVEAU SLOT (mode pas encore contacté sur cette bande)', 'clg2.tipNewMode': 'NOUVEAU MODE (ce mode jamais contacté sur cette entité)', 'clg2.tipNewSlot': 'NOUVEAU SLOT (cette bande+mode pas encore contactée)', 'clg2.tipNewCall': "CALL NEUF — cet indicatif n a jamais été contacté sur cette bande et ce mode (l entité, si)", 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Géo', 'clg2.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'clg2.clearFilters': 'Effacer les filtres', 'clg2.newSpots': '{n} nouveaux spots — cliquer pour reprendre', 'clg2.columns': 'Colonnes', 'clg2.pickerTitle': 'Colonnes du cluster', 'clg2.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau du cluster.', 'clg2.allGroups': 'Tous les groupes :', 'clg2.all': 'tout', 'clg2.none': 'aucun', 'clg2.resetDefaults': 'Réinitialiser', 'clg2.done': 'Terminé',
// Périphériques audio et manipulateur vocal (Préférences → Périphériques audio). // Périphériques audio et manipulateur vocal (Préférences → Périphériques audio).
'aud.refreshDevices': 'Actualiser les périphériques', 'aud.fromRadio': 'Depuis la radio (entrée RX)', 'aud.toRadio': 'Vers la radio (sortie TX)', 'aud.recMic': "Micro d'enregistrement", 'aud.listening': 'Écoute (pré-écoute)', 'aud.refreshDevices': 'Actualiser les périphériques', 'aud.fromRadio': 'Depuis la radio (entrée RX)', 'aud.toRadio': 'Vers la radio (sortie TX)', 'aud.recMic': "Micro d'enregistrement", 'aud.listening': 'Écoute (pré-écoute)',
'aud.phFromRadio': 'Sortie audio du poste → entrée carte son', 'aud.phToRadio': 'Sortie carte son → entrée micro/data du poste', 'aud.phRecMic': 'Votre microphone (enregistrer les messages vocaux)', 'aud.phListening': 'Haut-parleurs locaux pour la pré-écoute', 'aud.phFromRadio': 'Sortie audio du poste → entrée carte son', 'aud.phToRadio': 'Sortie carte son → entrée micro/data du poste', 'aud.phRecMic': 'Votre microphone (enregistrer les messages vocaux)', 'aud.phListening': 'Haut-parleurs locaux pour la pré-écoute',
+6 -1
View File
@@ -14,9 +14,11 @@ export type MatrixColors = {
entity_worked: string; entity_worked: string;
not_worked: string; not_worked: string;
current_entry: string; current_entry: string;
mark_worked: string;
mark_confirmed: string;
}; };
// The six settings fields and the CSS custom property each one drives. Also the // The settings fields and the CSS custom property each one drives. Also the
// display order — the same order the legend under the matrix reads in, so the // display order — the same order the legend under the matrix reads in, so the
// settings panel and the grid can never disagree about which green is which. // settings panel and the grid can never disagree about which green is which.
export const MATRIX_VARS: { key: keyof Omit<MatrixColors, 'enabled'>; cssVar: string; label: string }[] = [ export const MATRIX_VARS: { key: keyof Omit<MatrixColors, 'enabled'>; cssVar: string; label: string }[] = [
@@ -26,12 +28,15 @@ export const MATRIX_VARS: { key: keyof Omit<MatrixColors, 'enabled'>; cssVar: st
{ key: 'entity_worked', cssVar: '--mx-dx-work', label: 'mx.dxWork' }, { key: 'entity_worked', cssVar: '--mx-dx-work', label: 'mx.dxWork' },
{ key: 'not_worked', cssVar: '--mx-none', label: 'mx.none' }, { key: 'not_worked', cssVar: '--mx-none', label: 'mx.none' },
{ key: 'current_entry', cssVar: '--mx-cur', label: 'mx.current' }, { key: 'current_entry', cssVar: '--mx-cur', label: 'mx.current' },
{ key: 'mark_worked', cssVar: '--mx-mark-work', label: 'mx.markWork' },
{ key: 'mark_confirmed', cssVar: '--mx-mark-conf', label: 'mx.markConf' },
]; ];
export const emptyMatrixColors = (): MatrixColors => ({ export const emptyMatrixColors = (): MatrixColors => ({
enabled: false, enabled: false,
call_confirmed: '', call_worked: '', entity_confirmed: '', call_confirmed: '', call_worked: '', entity_confirmed: '',
entity_worked: '', not_worked: '', current_entry: '', entity_worked: '', not_worked: '', current_entry: '',
mark_worked: '', mark_confirmed: '',
}); });
// applyMatrixColors stamps (or clears) the overrides on <html>. Safe to call as // applyMatrixColors stamps (or clears) the overrides on <html>. Safe to call as
+7
View File
@@ -91,6 +91,11 @@
be recoloured on its own without dragging every other warning in the app be recoloured on its own without dragging every other warning in the app
with it (Appearance → matrix colours). */ with it (Appearance → matrix colours). */
--mx-cur: var(--warning); --mx-cur: var(--warning);
/* The "worked with this callsign" dot. Declared ONCE, like --mx-cur: it is
drawn over every one of the five cell colours, so it follows the theme's own
foreground/background pair rather than a per-theme colour of its own. */
--mx-mark-work: var(--foreground);
--mx-mark-conf: var(--foreground);
--scrollbar-thumb: #b8a880; --scrollbar-thumb: #b8a880;
--scrollbar-thumb-hover: #968455; --scrollbar-thumb-hover: #968455;
@@ -981,6 +986,8 @@
--color-mx-dx-work: var(--mx-dx-work); --color-mx-dx-work: var(--mx-dx-work);
--color-mx-none: var(--mx-none); --color-mx-none: var(--mx-none);
--color-mx-cur: var(--mx-cur); --color-mx-cur: var(--mx-cur);
--color-mx-mark-work: var(--mx-mark-work);
--color-mx-mark-conf: var(--mx-mark-conf);
--radius: 0.5rem; --radius: 0.5rem;
+4
View File
@@ -503,6 +503,8 @@ export function GetLiveOpenings():Promise<Array<bandopen.Opening>>;
export function GetLiveStations():Promise<Array<main.LiveStation>>; export function GetLiveStations():Promise<Array<main.LiveStation>>;
export function GetLoTWDownloadAllCalls():Promise<boolean>;
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>; export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
export function GetLogFilePath():Promise<string>; export function GetLogFilePath():Promise<string>;
@@ -1157,6 +1159,8 @@ export function SetKenwoodXIT(arg1:boolean):Promise<void>;
export function SetLinkedAmps(arg1:Array<string>):Promise<void>; export function SetLinkedAmps(arg1:Array<string>):Promise<void>;
export function SetLoTWDownloadAllCalls(arg1:boolean):Promise<void>;
export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise<void>; export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise<void>;
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>; export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
+8
View File
@@ -946,6 +946,10 @@ export function GetLiveStations() {
return window['go']['main']['App']['GetLiveStations'](); return window['go']['main']['App']['GetLiveStations']();
} }
export function GetLoTWDownloadAllCalls() {
return window['go']['main']['App']['GetLoTWDownloadAllCalls']();
}
export function GetLoTWUsersStatus() { export function GetLoTWUsersStatus() {
return window['go']['main']['App']['GetLoTWUsersStatus'](); return window['go']['main']['App']['GetLoTWUsersStatus']();
} }
@@ -2254,6 +2258,10 @@ export function SetLinkedAmps(arg1) {
return window['go']['main']['App']['SetLinkedAmps'](arg1); return window['go']['main']['App']['SetLinkedAmps'](arg1);
} }
export function SetLoTWDownloadAllCalls(arg1) {
return window['go']['main']['App']['SetLoTWDownloadAllCalls'](arg1);
}
export function SetMotorFollow(arg1, arg2, arg3) { export function SetMotorFollow(arg1, arg2, arg3) {
return window['go']['main']['App']['SetMotorFollow'](arg1, arg2, arg3); return window['go']['main']['App']['SetMotorFollow'](arg1, arg2, arg3);
} }
+8
View File
@@ -1196,6 +1196,7 @@ export namespace cat {
low_hz: number; low_hz: number;
high_hz: number; high_hz: number;
fixed: boolean; fixed: boolean;
unsupported: boolean;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new ScopeSweep(source); return new ScopeSweep(source);
@@ -1208,6 +1209,7 @@ export namespace cat {
this.low_hz = source["low_hz"]; this.low_hz = source["low_hz"];
this.high_hz = source["high_hz"]; this.high_hz = source["high_hz"];
this.fixed = source["fixed"]; this.fixed = source["fixed"];
this.unsupported = source["unsupported"];
} }
} }
export class TCIPanelState { export class TCIPanelState {
@@ -3066,6 +3068,8 @@ export namespace main {
entity_worked: string; entity_worked: string;
not_worked: string; not_worked: string;
current_entry: string; current_entry: string;
mark_worked: string;
mark_confirmed: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new MatrixColors(source); return new MatrixColors(source);
@@ -3080,6 +3084,8 @@ export namespace main {
this.entity_worked = source["entity_worked"]; this.entity_worked = source["entity_worked"];
this.not_worked = source["not_worked"]; this.not_worked = source["not_worked"];
this.current_entry = source["current_entry"]; this.current_entry = source["current_entry"];
this.mark_worked = source["mark_worked"];
this.mark_confirmed = source["mark_confirmed"];
} }
} }
@@ -5027,6 +5033,7 @@ export namespace qso {
band: string; band: string;
class: string; class: string;
status: string; status: string;
call?: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new BandStatus(source); return new BandStatus(source);
@@ -5037,6 +5044,7 @@ export namespace qso {
this.band = source["band"]; this.band = source["band"];
this.class = source["class"]; this.class = source["class"];
this.status = source["status"]; this.status = source["status"];
this.call = source["call"];
} }
} }
export class Bucket { export class Bucket {
+6
View File
@@ -674,6 +674,12 @@ type ScopeSweep struct {
LowHz int64 `json:"low_hz"` // left edge frequency (0 when unknown) LowHz int64 `json:"low_hz"` // left edge frequency (0 when unknown)
HighHz int64 `json:"high_hz"` // right edge frequency (0 when unknown) HighHz int64 `json:"high_hz"` // right edge frequency (0 when unknown)
Fixed bool `json:"fixed"` // true = fixed-span mode, false = center-on-VFO Fixed bool `json:"fixed"` // true = fixed-span mode, false = center-on-VFO
// Unsupported: this radio refuses the waveform-output command, so there will
// never be a sweep. The IC-7851 does — its last firmware is from 2016, older
// than the CI-V waveform stream — while still answering the scope's other
// commands. Reported so the panadapter can say so instead of showing a black
// rectangle that looks like a bug in OpsLog.
Unsupported bool `json:"unsupported"`
} }
// IcomState returns the current Icom DSP state, or (zero, false) when the active // IcomState returns the current Icom DSP state, or (zero, false) when the active
+79 -26
View File
@@ -93,15 +93,18 @@ type IcomSerial struct {
// leading main/sub selector byte (IC-7610/9700). scopeAmp is the latest // leading main/sub selector byte (IC-7610/9700). scopeAmp is the latest
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read // reassembled sweep; scopeMu guards it (written by the scope goroutine, read
// via ScopeData from the binding goroutine). // via ScopeData from the binding goroutine).
dualScope bool dualScope bool
scopeMu sync.Mutex // Set when the rig rejects the waveform-output command in both shapes: it has
scopeAmp []byte // no stream to give, and asking again on every enable is noise.
scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame) scopeUnsupported bool
scopeHigh int64 // spectrum right-edge frequency scopeMu sync.Mutex
scopeSeq int scopeAmp []byte
scopeOn bool scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame)
scopeFixed bool // true = fixed-span mode (tracked optimistically) scopeHigh int64 // spectrum right-edge frequency
scopeSeen bool // logged the first sweep's structure once (on-rig verification) scopeSeq int
scopeOn bool
scopeFixed bool // true = fixed-span mode (tracked optimistically)
scopeSeen bool // logged the first sweep's structure once (on-rig verification)
curFreq int64 // last frequency read (for sideband choice) curFreq int64 // last frequency read (for sideband choice)
curModeByte byte // last raw Icom mode byte (for filter re-send) curModeByte byte // last raw Icom mode byte (for filter re-send)
@@ -848,6 +851,19 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
loggedCfg[f.Data[0]] = true loggedCfg[f.Data[0]] = true
applog.Printf("icom scope cfg 0x%02X: data=[% X]", f.Data[0], f.Data) applog.Printf("icom scope cfg 0x%02X: data=[% X]", f.Data[0], f.Data)
} }
// The rig just told us its own layout: a mode/span/edge answer of
// three bytes or more carries the main/sub selector, one of two
// bytes does not. Worth reading, because the SET commands take the
// same shape and several firmwares answer a wrong-shaped set with
// silence rather than a rejection — which is not something the
// retry in execScope can act on.
if f.Data[0] == civ.SubScopeMode && len(f.Data) >= 2 {
if sel := len(f.Data) >= 3; sel != b.dualScope {
applog.Printf("icom scope: the rig answers 0x%02X with %d bytes — using the %s form",
f.Data[0], len(f.Data)-1, map[bool]string{true: "27 xx 00 …", false: "27 xx …"}[sel])
b.dualScope = sel
}
}
continue continue
} }
if rawN < 24 { if rawN < 24 {
@@ -982,6 +998,43 @@ func (b *IcomSerial) assembleSweep(regions map[byte][]byte, total byte) {
} }
} }
// execScope sends a 0x27 SET and, if the rig rejects it, sends it once more in
// the other shape — with or without the leading main/sub selector byte — and
// remembers which one this rig speaks.
//
// The shape used to be decided from the CI-V address, which meant every new
// model was a blank scope until someone reported it: the IC-7851 (0x8E) rejects
// "27 11 01" outright and wants "27 11 00 01", exactly as the IC-7610 does not.
// A rejection is a cheap and unambiguous answer, so ask the rig instead of
// keeping a list. Only the SET commands need this — the waveform parser already
// detects the selector per frame.
func (b *IcomSerial) execScope(what string, sub byte, args ...byte) error {
try := func(sel bool) error {
p := []byte{civ.CmdScope, sub}
if sel {
p = append(p, 0x00) // main scope
}
return b.exec(append(p, args...)...)
}
err := try(b.dualScope)
// Only a REJECTION means "wrong shape". A timeout says nothing (several
// firmwares simply don't ack a 0x27 set), and retrying it in the other shape
// would flip a working rig onto the wrong one.
if err == nil || !strings.Contains(err.Error(), "rejected") {
return err
}
err2 := try(!b.dualScope)
applog.Printf("icom scope: %s rejected in the %s form — the other form gave: %v",
what, map[bool]string{true: "27 xx 00 …", false: "27 xx …"}[b.dualScope], err2)
if err2 == nil {
b.dualScope = !b.dualScope
applog.Printf("icom scope: %s rejected — this rig wants the %s form (selector=%v)",
what, map[bool]string{true: "27 xx 00 …", false: "27 xx …"}[b.dualScope], b.dualScope)
return nil
}
return err
}
// SetScope enables or disables the spectrum scope. Two commands are needed and // SetScope enables or disables the spectrum scope. Two commands are needed and
// RS-BA1 sends both: 0x27 0x10 turns the scope DISPLAY on (without it the rig // RS-BA1 sends both: 0x27 0x10 turns the scope DISPLAY on (without it the rig
// streams nothing — the case when we're remote and can't touch the front panel), // streams nothing — the case when we're remote and can't touch the front panel),
@@ -1000,15 +1053,25 @@ func (b *IcomSerial) SetScope(on bool) error {
// radio, and closing OpsLog (SetScope(false)) blanking a local IC-7300's // radio, and closing OpsLog (SetScope(false)) blanking a local IC-7300's
// screen is exactly the regression this avoids. Some firmwares don't ack a // screen is exactly the regression this avoids. Some firmwares don't ack a
// 0x27 set; a timeout isn't fatal, so log and continue. // 0x27 set; a timeout isn't fatal, so log and continue.
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, 0x01); err != nil { if err := b.execScope("display on", civ.SubScopeOnOff, 0x01); err != nil {
applog.Printf("icom scope: display on ack: %v", err) applog.Printf("icom scope: display on ack: %v", err)
} }
} }
// Waveform data OUTPUT over CI-V: enabled with the scope, and — crucially — // Waveform data OUTPUT over CI-V: enabled with the scope, and — crucially —
// the ONLY thing we switch off on disable, so the radio's own scope display is // the ONLY thing we switch off on disable, so the radio's own scope display is
// left exactly as the operator had it. // left exactly as the operator had it.
if err := b.exec(civ.CmdScope, civ.SubScopeOn, boolByte(on)); err != nil { if err := b.execScope("data output", civ.SubScopeOn, boolByte(on)); err != nil {
applog.Printf("icom scope: output on=%v ack: %v", on, err) applog.Printf("icom scope: output on=%v ack: %v", on, err)
// Rejected in both shapes = the command does not exist on this rig, which
// is a permanent answer and not a bad guess on our part. Remember it: the
// panel can then say so, and we stop asking a radio that has already
// said no.
if strings.Contains(err.Error(), "rejected") {
applog.Printf("icom scope: %s does not stream its scope over CI-V — control commands only", b.model)
b.scopeMu.Lock()
b.scopeUnsupported = true
b.scopeMu.Unlock()
}
} }
b.scopeMu.Lock() b.scopeMu.Lock()
b.scopeOn = on b.scopeOn = on
@@ -1041,13 +1104,7 @@ func (b *IcomSerial) scopeReadCfg() {
// makes the scope follow the VFO, so tuning pans the view left/right. // makes the scope follow the VFO, so tuning pans the view left/right.
func (b *IcomSerial) SetScopeMode(fixed bool) error { func (b *IcomSerial) SetScopeMode(fixed bool) error {
mode := boolByte(fixed) // 0 = center, 1 = fixed (verify on rig via the cfg log) mode := boolByte(fixed) // 0 = center, 1 = fixed (verify on rig via the cfg log)
var payload []byte if err := b.execScope("set mode", civ.SubScopeMode, mode); err != nil {
if b.dualScope {
payload = []byte{civ.CmdScope, civ.SubScopeMode, 0x00, mode}
} else {
payload = []byte{civ.CmdScope, civ.SubScopeMode, mode}
}
if err := b.exec(payload...); err != nil {
applog.Printf("icom scope: set mode fixed=%v ack: %v", fixed, err) applog.Printf("icom scope: set mode fixed=%v ack: %v", fixed, err)
} }
b.scopeMu.Lock() b.scopeMu.Lock()
@@ -1093,13 +1150,8 @@ func (b *IcomSerial) SetScopeEdges(low, high int64) error {
if rangeID == 0 { if rangeID == 0 {
return fmt.Errorf("icom scope: freq out of range") return fmt.Errorf("icom scope: freq out of range")
} }
if b.dualScope { _ = b.execScope("fixed mode", civ.SubScopeMode, 0x01)
_ = b.exec(civ.CmdScope, civ.SubScopeMode, 0x00, 0x01) // fixed mode (main) _ = b.execScope("edge set 1", civ.SubScopeEdge, 0x01)
_ = b.exec(civ.CmdScope, civ.SubScopeEdge, 0x00, 0x01) // activate edge set 1
} else {
_ = b.exec(civ.CmdScope, civ.SubScopeMode, 0x01)
_ = b.exec(civ.CmdScope, civ.SubScopeEdge, 0x01)
}
payload := append([]byte{civ.CmdScope, civ.SubScopeFixEdge, rangeID, 0x01}, civ.FreqToBCD(low)...) payload := append([]byte{civ.CmdScope, civ.SubScopeFixEdge, rangeID, 0x01}, civ.FreqToBCD(low)...)
payload = append(payload, civ.FreqToBCD(high)...) payload = append(payload, civ.FreqToBCD(high)...)
b.scopeMu.Lock() b.scopeMu.Lock()
@@ -1263,7 +1315,8 @@ func (b *IcomSerial) ScopeData() ScopeSweep {
for i, v := range b.scopeAmp { for i, v := range b.scopeAmp {
amp[i] = int(v) amp[i] = int(v)
} }
return ScopeSweep{Amp: amp, Seq: b.scopeSeq, LowHz: b.scopeLow, HighHz: b.scopeHigh, Fixed: b.scopeFixed} return ScopeSweep{Amp: amp, Seq: b.scopeSeq, LowHz: b.scopeLow, HighHz: b.scopeHigh, Fixed: b.scopeFixed,
Unsupported: b.scopeUnsupported}
} }
// exec sends a set command and waits for the rig's OK (FB) / NG (FA) ack. // exec sends a set command and waits for the rig's OK (FB) / NG (FA) ack.
+24 -20
View File
@@ -44,30 +44,30 @@ type ServerConfig struct {
// is emitted to the UI, so the table never has empty country cells // is emitted to the UI, so the table never has empty country cells
// flickering in for a few hundred ms. // flickering in for a few hundred ms.
type Spot struct { type Spot struct {
SourceID int64 `json:"source_id"` // ID of the cluster server this came from SourceID int64 `json:"source_id"` // ID of the cluster server this came from
SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers) SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers)
Spotter string `json:"spotter"` // DE field Spotter string `json:"spotter"` // DE field
// SpotterContinent belongs to the SPOT, not to the DX station: one call is // SpotterContinent belongs to the SPOT, not to the DX station: one call is
// spotted by dozens of skimmers on every continent within a minute. It is // spotted by dozens of skimmers on every continent within a minute. It is
// resolved per spot at ingest for exactly that reason — see the note on the // resolved per spot at ingest for exactly that reason — see the note on the
// spotter-continent filter in App.tsx. // spotter-continent filter in App.tsx.
SpotterContinent string `json:"spotter_continent,omitempty"` SpotterContinent string `json:"spotter_continent,omitempty"`
DXCall string `json:"dx_call"` // the DX station heard DXCall string `json:"dx_call"` // the DX station heard
FreqKHz float64 `json:"freq_khz"` FreqKHz float64 `json:"freq_khz"`
FreqHz int64 `json:"freq_hz"` FreqHz int64 `json:"freq_hz"`
Band string `json:"band,omitempty"` Band string `json:"band,omitempty"`
Comment string `json:"comment,omitempty"` Comment string `json:"comment,omitempty"`
Locator string `json:"locator,omitempty"` // spotter grid (optional) Locator string `json:"locator,omitempty"` // spotter grid (optional)
TimeUTC string `json:"time_utc,omitempty"` TimeUTC string `json:"time_utc,omitempty"`
Country string `json:"country,omitempty"` // DXCC entity name (cty.dat) Country string `json:"country,omitempty"` // DXCC entity name (cty.dat)
Continent string `json:"continent,omitempty"` // 2-letter continent Continent string `json:"continent,omitempty"` // 2-letter continent
CQZone int `json:"cqz,omitempty"` // DXCC entity CQ zone CQZone int `json:"cqz,omitempty"` // DXCC entity CQ zone
ITUZone int `json:"ituz,omitempty"` // DXCC entity ITU zone ITUZone int `json:"ituz,omitempty"` // DXCC entity ITU zone
DistanceKm int `json:"distance_km,omitempty"` // great-circle km from operator's grid DistanceKm int `json:"distance_km,omitempty"` // great-circle km from operator's grid
ShortPath int `json:"sp_deg,omitempty"` // azimuth (deg) short path from operator ShortPath int `json:"sp_deg,omitempty"` // azimuth (deg) short path from operator
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360 LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
ReceivedAt time.Time `json:"received_at"` ReceivedAt time.Time `json:"received_at"`
Raw string `json:"raw"` Raw string `json:"raw"`
// Historical marks a spot recovered from a SH/DX table rather than heard live. // Historical marks a spot recovered from a SH/DX table rather than heard live.
// It belongs in the grid, but must NOT fire alerts or reach the panadapter: // It belongs in the grid, but must NOT fire alerts or reach the panadapter:
// replaying 100 past spots would spam both, and a station spotted three hours // replaying 100 past spots would spam both, and a station spotted three hours
@@ -75,6 +75,10 @@ type Spot struct {
Historical bool `json:"historical,omitempty"` Historical bool `json:"historical,omitempty"`
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app) POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
POTAName string `json:"pota_name,omitempty"` // park name POTAName string `json:"pota_name,omitempty"` // park name
// SOTARef comes from the COMMENT, not from an API: the SOTA clusters put the
// summit in the text of the spot they send ("W9/WI-001"), and there is no
// per-callsign endpoint to ask the way POTA has one.
SOTARef string `json:"sota_ref,omitempty"`
} }
// State enumerates the per-server lifecycle. // State enumerates the per-server lifecycle.
+1 -1
View File
@@ -110,7 +110,7 @@ func TestParseShowDX(t *testing.T) {
// chatter turned into fake spots would be worse than no parser at all. // chatter turned into fake spots would be worse than no parser at all.
func TestParseShowDXRejectsNoise(t *testing.T) { func TestParseShowDXRejectsNoise(t *testing.T) {
noise := []string{ noise := []string{
"DX de F5ABC: 14195.0 EA8DHH CQ DX 1234Z", // the broadcast form: spotRE owns it "DX de F5ABC: 14195.0 EA8DHH CQ DX 1234Z", // the broadcast form: spotRE owns it
"Hello and welcome to the DXSpider cluster", "Hello and welcome to the DXSpider cluster",
"WWV de VE7CC <18Z> : SFI=110, A=16, K=2", "WWV de VE7CC <18Z> : SFI=110, A=16, K=2",
"F4BPO de GB7DXC 12-Jul-2026 2130Z dxspider >", "F4BPO de GB7DXC 12-Jul-2026 2130Z dxspider >",
+21
View File
@@ -0,0 +1,21 @@
package cluster
import "regexp"
// sotaRefRe matches a SOTA summit reference inside a spot comment.
//
// The shape is association/region-NNN — "W9/WI-001", "DM/BM-063", "VK3/VC-014",
// "F/AM-123" — and the association may carry digits. Anchored on both sides so
// a callsign like DL/SP9DPM/P can never be read as one, and deliberately
// narrower than "anything with a slash and a dash": POTA (US-4475) and WWFF
// (DLFF-0001) refs share the comment field and must not be caught here.
var sotaRefRe = regexp.MustCompile(`\b([A-Z0-9]{1,4}(?:/[A-Z0-9]{1,4})?/[A-Z]{2}-[0-9]{3})\b`)
// SOTARefFrom returns the first SOTA reference in a spot comment, or "".
func SOTARefFrom(comment string) string {
m := sotaRefRe.FindStringSubmatch(comment)
if m == nil {
return ""
}
return m[1]
}
+25
View File
@@ -0,0 +1,25 @@
package cluster
import "testing"
func TestSOTARefFrom(t *testing.T) {
// Left column: real comments seen on the SOTA cluster feed.
cases := []struct{ in, want string }{
{"W9/WI-001", "W9/WI-001"},
{"DM/BM-063", "DM/BM-063"},
{"W7Y/TT-122", "W7Y/TT-122"},
{"VK3/VC-014 s2s", "VK3/VC-014"},
{"[SOTA] F/AM-123 cq", "F/AM-123"},
{"", ""},
// The other reference schemes that share this field.
{"POTA US-4475", ""},
{"WWFF DLFF-0001", ""},
// A portable callsign is not a summit.
{"DL/SP9DPM/P calling", ""},
}
for _, c := range cases {
if got := SOTARefFrom(c.in); got != c.want {
t.Errorf("SOTARefFrom(%q) = %q, want %q", c.in, got, c.want)
}
}
}
+17 -4
View File
@@ -41,23 +41,36 @@ func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg Ser
if c := strings.TrimSpace(ownCall); c != "" { if c := strings.TrimSpace(ownCall); c != "" {
q.Set("qso_owncall", c) // restrict to this station callsign q.Set("qso_owncall", c) // restrict to this station callsign
} }
if s := strings.TrimSpace(since); s != "" { // qso_qslsince is ALWAYS sent, even for "everything".
q.Set("qso_qslsince", s) //
// Left out, LoTW does not answer "all confirmations" — it answers with a
// handful of recent ones, which arrives as a 200 and a valid ADIF and reads
// as a successful download of a nearly empty account. Asking from a date
// older than the service itself is the only way to mean "all".
sinceDate := strings.TrimSpace(since)
if sinceDate == "" {
sinceDate = "1945-11-15" // older than any QSO LoTW will accept
} }
q.Set("qso_qslsince", sinceDate)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, lotwReportURL+"?"+q.Encode(), nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, lotwReportURL+"?"+q.Encode(), nil)
if err != nil { if err != nil {
return "", fmt.Errorf("lotw: build request: %w", err) return "", fmt.Errorf("lotw: build request: %w", err)
} }
if client == nil { if client == nil {
client = &http.Client{Timeout: 120 * time.Second} // A full account is tens of megabytes and LoTW builds it slowly — several
// minutes for a log of 30 000 QSOs, all of it before the first byte. The
// old two-minute limit turned that into "context deadline exceeded while
// reading body", which reads as a network fault rather than as "ask for
// less at a time".
client = &http.Client{Timeout: 20 * time.Minute}
} }
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("lotw: request failed: %w", err) return "", fmt.Errorf("lotw: request failed: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024)) body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024*1024))
if err != nil { if err != nil {
return "", fmt.Errorf("lotw: read response: %w", err) return "", fmt.Errorf("lotw: read response: %w", err)
} }
+59 -3
View File
@@ -1901,10 +1901,24 @@ type WorkedBefore struct {
} }
// BandStatus is one cell in the worked-before grid. // BandStatus is one cell in the worked-before grid.
//
// Status is the single highest thing true of the cell, which is what colours
// it. Call is the SAME cell's answer to a different question — "have I worked
// THIS callsign here" — kept separately because the two are asked at the same
// moment and one was hiding the other.
//
// Chasing an expedition, an operator needs both: whether the slot is still
// missing for the entity (does this fill a DXCC hole) and whether this
// expedition has already been worked on it (would this be a dupe). A confirmed
// entity outranks a worked callsign in Status — correctly, for awards — so a
// slot worked with the DX yesterday can read "entity confirmed" and say nothing
// at all about yesterday.
type BandStatus struct { type BandStatus struct {
Band string `json:"band"` // ADIF lowercase band, e.g. "20m" Band string `json:"band"` // ADIF lowercase band, e.g. "20m"
Class string `json:"class"` // "PH" | "CW" | "DIG" Class string `json:"class"` // "PH" | "CW" | "DIG"
Status string `json:"status"` // "call_c" | "call_w" | "dxcc_c" | "dxcc_w" Status string `json:"status"` // "call_c" | "call_w" | "dxcc_c" | "dxcc_w"
// Call is "", "w" (worked with this callsign) or "c" (confirmed with it).
Call string `json:"call,omitempty"`
} }
// Band-status codes, lowest first. The ORDER is the rule: a cell shows the // Band-status codes, lowest first. The ORDER is the rule: a cell shows the
@@ -2222,13 +2236,17 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
// The grid answers "what do I still need on this band and mode", and for // The grid answers "what do I still need on this band and mode", and for
// that question confirmation is the axis that matters: a confirmed entity // that question confirmation is the axis that matters: a confirmed entity
// needs nothing, whoever else was worked afterwards. // needs nothing, whoever else was worked afterwards.
// The two per-callsign columns use the SAME predicate as the callsign count
// above, portable variants included: a cell that counts RI1FJL/1 in "worked
// with this call" and a header that does not would be two answers to one
// question.
// Filter NULL/empty band+mode rows — they'd create a NULL group key // Filter NULL/empty band+mode rows — they'd create a NULL group key
// that Scan into *string can't handle and would error out the whole // that Scan into *string can't handle and would error out the whole
// WorkedBefore call, blanking the matrix in the UI. // WorkedBefore call, blanking the matrix in the UI.
statusRows, err := r.db.QueryContext(ctx, ` statusRows, err := r.db.QueryContext(ctx, `
SELECT band, mode, SELECT band, mode,
MAX(CASE WHEN callsign = ? THEN 1 ELSE 0 END), MAX(CASE WHEN `+pred+` THEN 1 ELSE 0 END),
MAX(CASE WHEN callsign = ? MAX(CASE WHEN `+pred+`
AND (lotw_rcvd IN `+ConfirmedValues+` OR qsl_rcvd IN `+ConfirmedValues+` OR eqsl_rcvd IN `+ConfirmedValues+`) AND (lotw_rcvd IN `+ConfirmedValues+` OR qsl_rcvd IN `+ConfirmedValues+` OR eqsl_rcvd IN `+ConfirmedValues+`)
THEN 1 ELSE 0 END), THEN 1 ELSE 0 END),
MAX(CASE WHEN lotw_rcvd IN `+ConfirmedValues+` OR qsl_rcvd IN `+ConfirmedValues+` OR eqsl_rcvd IN `+ConfirmedValues+` MAX(CASE WHEN lotw_rcvd IN `+ConfirmedValues+` OR qsl_rcvd IN `+ConfirmedValues+` OR eqsl_rcvd IN `+ConfirmedValues+`
@@ -2237,12 +2255,14 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
WHERE dxcc = ? WHERE dxcc = ?
AND band IS NOT NULL AND band != '' AND band IS NOT NULL AND band != ''
AND mode IS NOT NULL AND mode != '' AND mode IS NOT NULL AND mode != ''
GROUP BY band, mode`, wb.Callsign, wb.Callsign, dxcc) GROUP BY band, mode`, append(append(append([]any{}, predArgs...), predArgs...), dxcc)...)
if err != nil { if err != nil {
return wb, fmt.Errorf("band status: %w", err) return wb, fmt.Errorf("band status: %w", err)
} }
type cellKey struct{ band, class string } type cellKey struct{ band, class string }
best := map[cellKey]int{} best := map[cellKey]int{}
// The call's own answer per cell, independent of the ladder above.
callByCell := map[cellKey]string{}
for statusRows.Next() { for statusRows.Next() {
var band, mode string var band, mode string
var callW, callC, dxccConfirmed int var callW, callC, dxccConfirmed int
@@ -2255,12 +2275,21 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
if cur, ok := best[k]; !ok || code > cur { if cur, ok := best[k]; !ok || code > cur {
best[k] = code best[k] = code
} }
// Confirmed beats worked here too, and neither is ever erased by the
// entity: this is only ever about the callsign.
switch {
case callC == 1:
callByCell[k] = "c"
case callW == 1 && callByCell[k] == "":
callByCell[k] = "w"
}
} }
statusRows.Close() statusRows.Close()
codeStr := bandStatusNames codeStr := bandStatusNames
for k, code := range best { for k, code := range best {
wb.BandStatus = append(wb.BandStatus, BandStatus{ wb.BandStatus = append(wb.BandStatus, BandStatus{
Band: k.band, Class: k.class, Status: codeStr[code], Band: k.band, Class: k.class, Status: codeStr[code],
Call: callByCell[k],
}) })
} }
return wb, nil return wb, nil
@@ -2955,6 +2984,33 @@ func DedupeKey(callsign, qsoDateMinute, band, mode string) string {
return strings.ToUpper(callsign) + "|" + qsoDateMinute + "|" + strings.ToLower(band) + "|" + strings.ToUpper(mode) return strings.ToUpper(callsign) + "|" + qsoDateMinute + "|" + strings.ToLower(band) + "|" + strings.ToUpper(mode)
} }
// StationCallsigns lists the distinct station callsigns the logbook was worked
// under, upper-cased and without the blanks.
//
// Used to decide whether a downloaded confirmation belongs to THIS log at all:
// one LoTW account can hold several stations (a home call, a portable, an
// expedition), and a confirmation for a station this logbook has never used is
// somebody else's log — here, another profile's.
func (r *Repo) StationCallsigns(ctx context.Context) (map[string]bool, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT COALESCE(station_callsign,'') FROM qso`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
return nil, err
}
if c = strings.ToUpper(strings.TrimSpace(c)); c != "" {
out[c] = true
}
}
return out, rows.Err()
}
// DedupeKeyIDs returns a map of dedupe key → QSO id, for matching downloaded // DedupeKeyIDs returns a map of dedupe key → QSO id, for matching downloaded
// confirmations back to local QSOs. // confirmations back to local QSOs.
func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) { func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {