diff --git a/app.go b/app.go index 68813ec..d9f85f7 100644 --- a/app.go +++ b/app.go @@ -5372,6 +5372,21 @@ var awardBandPlan = []struct { {"2mm", 134000000000, 149000000000}, {"1mm", 241000000000, 250000000000}, } +// bandOrder ranks a band name by frequency, for sorting a list of them. +// +// The band plan is already written low to high, so its index IS the order — +// which beats sorting the names, where 10m lands between 1.25m and 12m. +// Anything not in the plan sorts last rather than first: an unrecognised band +// is almost always a typo in an imported file, and it belongs at the bottom. +func bandOrder(name string) int { + name = strings.ToLower(strings.TrimSpace(name)) + for i, b := range awardBandPlan { + if b.name == name { + return i + } + } + return len(awardBandPlan) +} func bandForHz(hz int64) string { for _, b := range awardBandPlan { if hz >= b.lo && hz <= b.hi { @@ -7556,34 +7571,88 @@ func (a *App) SendDecodeFreeText(instance, text string, send bool) error { // GridSquares returns the 4-character Maidenhead squares in the log, with // whether each is confirmed. // -// modeClass scopes it the way the rest of the app does: "DIGI", "CW", "PHONE", -// or "" for every mode. "DIGI" is the one this was built for — a map of the -// squares worked on FT8/FT4 answers "where have I actually been heard" in a way -// no list of callsigns does. +// mode scopes it the way the rest of the app does — "DIGI", "CW", "PHONE", or +// "" for every mode — but it also takes ONE mode's own name, which is what an +// operator actually wants here: "digital" put a contest RTTY square beside an +// FT8 one and called them the same answer, when the question behind this map is +// where a particular mode has been heard. +// +// band and satName narrow it further, both empty meaning no restriction. The +// satellite is the reason this map is worth filtering at all for anyone chasing +// grids through a bird: a VHF square worked terrestrially and one worked through +// AO-91 are not the same achievement, and nothing else told them apart. // // Confirmed means LoTW, a card or eQSL — the same three the award engine counts, // so a square cannot be green here and unconfirmed in the Awards panel. -func (a *App) GridSquares(modeClass string) ([]qso.GridSquare, error) { +func (a *App) GridSquares(mode, band, satName string) ([]qso.GridSquare, error) { if a.qso == nil { return nil, fmt.Errorf("db not initialized") } - want := strings.ToUpper(strings.TrimSpace(modeClass)) - keep := func(mode string) bool { + want := strings.ToUpper(strings.TrimSpace(mode)) + wantBand := strings.ToLower(strings.TrimSpace(band)) + wantSat := strings.ToUpper(strings.TrimSpace(satName)) + keepMode := func(r qso.GridSquareRow) bool { switch want { case "", "ALL": return true case "FTX": - // The FT family alone, which is narrower than digital and usually the - // honest answer beside an FTx panel: a square worked on RTTY in a - // contest is not a square worked on FT8. - return ftxModes[strings.ToUpper(strings.TrimSpace(mode))] + // Retired from the UI, kept because a stored preference may still say + // it: the FT family alone, narrower than digital. + return ftxModes[r.Mode] || ftxModes[r.Submode] + case "PHONE", "CW", "DIGI": + return award.ModeClass(r.Mode) == want default: - return award.ModeClass(mode) == want + // One named mode. Matched against the SUBMODE as well, because ADIF + // files PSK63 under PSK: a log carrying the pair would answer nothing + // at all to the only name the operator recognises. + return r.Mode == want || r.Submode == want } } + keep := func(r qso.GridSquareRow) bool { + if !keepMode(r) { + return false + } + if wantBand != "" && wantBand != "all" && r.Band != wantBand { + return false + } + if wantSat != "" && wantSat != "ALL" && r.SatName != wantSat { + return false + } + return true + } return a.qso.GridSquares(a.ctx, keep) } +// GridSquareChoices is what the grid map's three filters can offer: the modes, +// bands and satellites the squares in the log were actually worked on. +// +// Read from the log, not from a list here, so a filter never offers a choice +// with nothing behind it and never omits one with something. FT2 is the case +// that settled it: it is not a registered ADIF mode yet, and hardcoding the +// list meant either leaving out operators already using it or shipping a mode +// that is not official — a query does neither. +type GridSquareChoices struct { + Modes []string `json:"modes"` + Bands []string `json:"bands"` + Satellites []string `json:"satellites"` +} + +func (a *App) GridSquareChoices() (GridSquareChoices, error) { + if a.qso == nil { + return GridSquareChoices{}, fmt.Errorf("db not initialized") + } + modes, bands, sats, err := a.qso.GridSquareChoices(a.ctx) + if err != nil { + return GridSquareChoices{}, err + } + // Bands in frequency order, the way every other band list in the app reads; + // modes and satellites alphabetically, there being no other order for them. + sort.Slice(bands, func(i, j int) bool { return bandOrder(bands[i]) < bandOrder(bands[j]) }) + sort.Strings(modes) + sort.Strings(sats) + return GridSquareChoices{Modes: modes, Bands: bands, Satellites: sats}, nil +} + // SetCompactMode toggles a tiny always-on-top window that exposes just the // QSO entry — useful when running on a single screen alongside WSJT-X, // JT-Alert or the cluster. diff --git a/app_sat_track_test.go b/app_sat_track_test.go index cf4559f..ef3f28d 100644 --- a/app_sat_track_test.go +++ b/app_sat_track_test.go @@ -1,6 +1,7 @@ package main import ( + "sort" "testing" "hamlog/internal/sat" @@ -158,3 +159,16 @@ func TestFlexBandAntKeyIsUppercased(t *testing.T) { } } } + +// A band list sorted as strings puts 10m between 1.25m and 12m, which is why +// the plan's own index is the order. +func TestBandOrderIsByFrequency(t *testing.T) { + got := []string{"70cm", "10m", "160m", "2m", "20m", "banana"} + sort.Slice(got, func(i, j int) bool { return bandOrder(got[i]) < bandOrder(got[j]) }) + want := []string{"160m", "20m", "10m", "2m", "70cm", "banana"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("sorted %v, want %v", got, want) + } + } +} diff --git a/changelog.json b/changelog.json index e78d1ee..80b38d1 100644 --- a/changelog.json +++ b/changelog.json @@ -10,7 +10,8 @@ "A frequency OpsLog got wrong can now be corrected on a station that already has the satellite file. Until now the file was copied out on the first run and was the operator's from then on, so a mistake we shipped — LilacSat-2 with no FM transponder — had become their data and could never be mended. The plan now adds what is missing, brings up to date what was never edited, and leaves alone what was: an entry you corrected by hand outranks anything shipped, and the log names the ones it stood down on. The first run after this cannot tell the two apart, so it takes the shipped plan and copies your file to satellites.json.bak first — after that your edits are recognised precisely and survive every release.", "The per-band antennas are applied to the satellite slices at last. The map is keyed by the band name in capitals, the tracker looked its two bands up in lower case, and so it read no antenna at all out of a table the operator had filled in — a 70 cm downlink stayed on the 2 m transverter. The log now names the band and the antenna it resolved, so a setting never made can be told apart from a lookup that missed.", "Tracking now shows where the satellite is beside where the antenna points: azimuth, elevation, distance and altitude, in the same strip as the two frequencies. The elevation goes dim below the horizon, so a bird still being followed on its way up cannot be read as workable.", - "The two satellite lists in the settings are sorted by name, numerically — AO-27, AO-91, AO-123. The left one came in the order the frequency file happens to be written and the right one in the order they were clicked, so finding one bird among sixteen meant reading all sixteen. A satellite renamed on getting its OSCAR number, as LILACSAT-2 became LO-90, is also recognised in a followed list that still holds the old name instead of being listed as having no elements." + "The two satellite lists in the settings are sorted by name, numerically — AO-27, AO-91, AO-123. The left one came in the order the frequency file happens to be written and the right one in the order they were clicked, so finding one bird among sixteen meant reading all sixteen. A satellite renamed on getting its OSCAR number, as LILACSAT-2 became LO-90, is also recognised in a followed list that still holds the old name instead of being listed as having no elements.", + "The grid-square map filters by one mode, by band and by satellite. The FTx button is gone: it grouped FT8, FT4 and FT2 together, while the question the map answers is where a single mode has been heard. The named modes are read from the log itself, so each one is offered only if there is something behind it — and FT2 appears for whoever is already using it, with nothing to change here the day it becomes a registered mode. The band list is the station's own plus anything worked outside it, and the satellite list only appears once a square has been worked through a bird." ], "fr": [ "SteppIR : un bouton Calibrer, à côté de Rétracter. Il amène chaque élément en butée pour que le contrôleur retrouve son zéro — le remède à une antenne qui s’accorde à la mauvaise longueur après une coupure en pleine course, après avoir poussé les éléments à la main, ou après un moteur qui a glissé. Cela prend plusieurs minutes et l’antenne est inutilisable jusqu’à la fin : une confirmation est donc demandée. Les contrôleurs Ultrabeam n’ont pas cette commande et le disent, au lieu de faire semblant. Rétracter les éléments existait déjà et s’explique maintenant : c’est la position de rangement, et le prochain accord les fait ressortir tout seuls.", @@ -20,7 +21,8 @@ "Une fréquence qu’OpsLog avait fausse peut désormais être corrigée sur une station qui possède déjà le fichier satellites. Jusqu’ici ce fichier était copié au premier lancement puis appartenait à l’opérateur, si bien qu’une erreur de notre part — LilacSat-2 sans transpondeur FM — devenait sa donnée et ne pouvait plus être réparée. Le plan ajoute maintenant ce qui manque, met à jour ce qui n’a jamais été modifié, et laisse intact ce qui l’a été : une entrée corrigée à la main prime sur tout ce qui est livré, et le journal nomme celles devant lesquelles il s’est effacé. Le premier lancement après ce changement ne peut pas distinguer les deux : il prend donc le plan livré et copie d’abord votre fichier en satellites.json.bak — ensuite vos modifications sont reconnues précisément et survivent à chaque version.", "Les antennes par bande sont enfin appliquées aux slices satellite. La table est indexée par le nom de bande en majuscules, le suivi cherchait ses deux bandes en minuscules, et il ne lisait donc aucune antenne dans un tableau que l’opérateur avait rempli — une descente 70 cm restait sur le transverter 2 m. Le journal indique désormais la bande et l’antenne retenues, pour distinguer un réglage jamais fait d’une recherche qui a échoué.", "Le suivi affiche désormais où est le satellite à côté de là où pointe l’antenne : azimut, élévation, distance et altitude, dans le même bandeau que les deux fréquences. L’élévation s’estompe sous l’horizon, pour qu’un satellite encore suivi avant son lever ne soit pas pris pour un satellite travaillable.", - "Les deux listes de satellites des réglages sont triées par nom, en tenant compte des nombres — AO-27, AO-91, AO-123. Celle de gauche suivait l’ordre du fichier de fréquences et celle de droite l’ordre des clics : retrouver un satellite parmi seize obligeait à lire les seize. Un satellite renommé lors de l’attribution de son numéro OSCAR, comme LILACSAT-2 devenu LO-90, est aussi reconnu dans une liste de suivis qui porte encore l’ancien nom, au lieu d’y apparaître sans éléments." + "Les deux listes de satellites des réglages sont triées par nom, en tenant compte des nombres — AO-27, AO-91, AO-123. Celle de gauche suivait l’ordre du fichier de fréquences et celle de droite l’ordre des clics : retrouver un satellite parmi seize obligeait à lire les seize. Un satellite renommé lors de l’attribution de son numéro OSCAR, comme LILACSAT-2 devenu LO-90, est aussi reconnu dans une liste de suivis qui porte encore l’ancien nom, au lieu d’y apparaître sans éléments.", + "La carte des carrés locator se filtre par mode précis, par bande et par satellite. Le bouton FTx disparaît : il regroupait FT8, FT4 et FT2, alors que la question à laquelle répond cette carte est de savoir où un seul mode a été entendu. Les modes proposés sont lus dans le journal, donc chacun n’apparaît que s’il y a quelque chose derrière — et FT2 est proposé à qui l’utilise déjà, sans rien à changer ici le jour où il deviendra un mode officiel. La liste des bandes est celle de la station, complétée par ce qui a été travaillé en dehors, et la liste des satellites n’apparaît qu’une fois un carré travaillé par satellite." ] }, { diff --git a/frontend/src/components/GridSquareMap.tsx b/frontend/src/components/GridSquareMap.tsx index 200cdba..fbc1b83 100644 --- a/frontend/src/components/GridSquareMap.tsx +++ b/frontend/src/components/GridSquareMap.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { Loader2, RefreshCw } from 'lucide-react'; -import { GridSquares } from '../../wailsjs/go/main/App'; +import { GridSquares, GridSquareChoices, GetListsSettings } from '../../wailsjs/go/main/App'; import { gridSquareBounds, gridToLatLon } from '@/lib/maidenhead'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; @@ -47,20 +47,27 @@ function cssColour(token: string, fallback: string): string { } catch { return fallback; } } -// Mode scope. The names come straight from the backend's own classes ("ALL", -// "PHONE", "CW", "DIGI") plus FTX, which is narrower than digital and usually -// the honest one beside an FTx panel — a square worked on RTTY in a contest is -// not a square worked on FT8. +// Mode scope. The four broad classes the rest of the app uses, as buttons — +// and then any single mode the log actually holds, from the dropdown beside +// them. +// +// There used to be an FTx button here, lumping FT8, FT4 and FT2 together. It +// was the wrong grain in both directions: "digital" already put a contest RTTY +// square beside an FT8 one, and FTx then put FT8 beside FT4, when the question +// this map answers is where ONE mode has been heard. The specific modes are +// read from the log rather than listed here, so FT2 is offered to an operator +// already using it and needs no change here the day it becomes registered. const SCOPES = [ { key: 'ALL', label: 'gsm.all' }, { key: 'PHONE', label: 'gsm.phone' }, { key: 'CW', label: 'gsm.cw' }, { key: 'DIGI', label: 'gsm.digital' }, - { key: 'FTX', label: 'gsm.ftx' }, ] as const; -type ScopeKey = typeof SCOPES[number]['key']; +type ScopeKey = string; const SCOPE_KEY = 'opslog.gridMapScope'; +const BAND_KEY = 'opslog.gridMapBand'; +const SAT_KEY = 'opslog.gridMapSat'; // Chosen fill colours. Empty means "follow the theme", which is the default and // stays the default: the tokens already track the four themes, and freezing a // hex at first run would leave a dark-theme map painted in the light palette. @@ -83,9 +90,22 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam const [squares, setSquares] = useState(null); const [busy, setBusy] = useState(false); const [err, setErr] = useState(''); - const [scope, setScope] = useState( - () => (SCOPES.some((s) => s.key === localStorage.getItem(SCOPE_KEY)) - ? (localStorage.getItem(SCOPE_KEY) as ScopeKey) : 'DIGI')); + // The stored scope is taken as given rather than checked against SCOPES: it + // may legitimately be a mode name now, and the backend answers a mode nothing + // was worked on with no squares rather than an error. + const [scope, setScope] = useState(() => { + const v = localStorage.getItem(SCOPE_KEY) || 'DIGI'; + // FTX was a button until the named modes replaced it. Left as it was, no + // control would show it selected while the map stayed filtered by it. + return v === 'FTX' ? 'DIGI' : v; + }); + const [band, setBand] = useState(() => localStorage.getItem(BAND_KEY) ?? ''); + const [sat, setSat] = useState(() => localStorage.getItem(SAT_KEY) ?? ''); + // What the three filters can offer. The modes and satellites are the ones the + // squares were actually worked on; the bands are the station's own list too, + // so a band configured but not yet worked is still there to ask about. + const [choices, setChoices] = useState<{ modes: string[]; bands: string[]; satellites: string[] }>( + { modes: [], bands: [], satellites: [] }); // This map's own imagery. It shared the world map's key until they were // separated, so a choice made back then is inherited rather than reset. @@ -102,17 +122,53 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam return () => obs.disconnect(); }, []); - const load = async (sc: ScopeKey = scope) => { + const load = async (sc: ScopeKey = scope, bd: string = band, st: string = sat) => { setBusy(true); setErr(''); try { - const r = (await GridSquares(sc)) as any; + const r = (await GridSquares(sc, bd, st)) as any; setSquares((Array.isArray(r) ? r : []) as Square[]); } catch (e: any) { setErr(String(e?.message ?? e)); setSquares([]); } finally { setBusy(false); } }; - useEffect(() => { void load(scope); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [scope]); + useEffect(() => { void load(scope, band, sat); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [scope, band, sat]); + + // Loaded once: what the filters can offer changes only when the log does, and + // the refresh button reloads it alongside the squares. + const loadChoices = async () => { + try { + const c: any = await GridSquareChoices(); + let bands: string[] = (c?.bands ?? []) as string[]; + try { + const ls: any = await GetListsSettings(); + const have = new Set(bands.map((b) => b.toLowerCase())); + // Union, the station's own list first: a configured band with nothing + // worked on it is still a fair question, and a band worked but never + // configured must not become unreachable. + const extra = ((ls?.bands ?? []) as string[]) + .map((b) => String(b).toLowerCase()) + .filter((b) => b && !have.has(b)); + bands = [...extra, ...bands]; + } catch { /* the log's own bands are enough */ } + setChoices({ + modes: (c?.modes ?? []) as string[], + bands, + satellites: (c?.satellites ?? []) as string[], + }); + } catch { /* the class buttons still work without it */ } + }; + useEffect(() => { void loadChoices(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, []); + + // Only the modes NOT already a button: listing SSB and CW again would be two + // controls giving one answer. + const namedModes = useMemo( + () => choices.modes.filter((m) => !SCOPES.some((c) => c.key === m.toUpperCase())), + [choices.modes]); + const pick = (key: string, v: string, set: (v: string) => void) => { + set(v); + try { localStorage.setItem(key, v); } catch { /* quota */ } + }; // One-time map creation. preferCanvas: a busy digital log is a few thousand // rectangles, and as SVG that is a few thousand DOM nodes to lay out on every @@ -244,13 +300,45 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
{SCOPES.map((s, i) => ( ))}
+ {/* One named mode, where the FTx button used to be. It shares the scope + with the buttons rather than filtering on top of them: mode is one + question, and two controls that both answer it is how a map ends up + showing PHONE ∩ FT8, which is empty. Picking a mode here therefore + un-picks the buttons, and vice versa. */} + {namedModes.length > 0 && ( + + )} + {choices.bands.length > 0 && ( + + )} + {/* Only for a station that has worked one. A satellite dropdown on a + purely terrestrial log is a control that can only ever be empty. */} + {choices.satellites.length > 0 && ( + + )} {t('gsm.count', { n: stats.total, c: stats.confirmed })} @@ -287,7 +375,7 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam className="text-[11px] text-muted-foreground hover:text-foreground px-1">↺ )} - diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 7a8905a..b97fb61 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -392,7 +392,7 @@ const en: Dict = { 'clu.slotHighlightHint': '(by callsign, whatever the entity status says)', 'rq.searchPh': 'Search callsign… 4S · *4S · *4S*', 'rq.searchTip': 'A plain word matches the START of a callsign: 4S finds 4S7AB. * is any run of characters and ? is exactly one, so *4S ends with 4S, *4S* contains it anywhere, and F?BPO matches F4BPO.', 'chg.mode': 'Chase', 'chg.sources': 'Confirmed by', 'chg.card': 'QSL card', 'dec.unconfTip': 'Worked but not confirmed — a QSL to chase', 'gsc.scope': 'Match a square by', 'gsc.hunt': 'Chase', 'gsc.huntNew': 'New — never worked', 'gsc.huntUnconf': 'New and unconfirmed', 'gsc.scope_band_digi': 'This band + any digital mode', 'gsc.scope_band_mode': 'This band + this exact mode', 'gsc.scope_band_ftx': 'This band + any FT mode (FT8/FT4/FT2)', 'gsc.scope_mix_digi': 'Any band + any digital mode', 'gsc.scope_mix_mode': 'Any band + this exact mode', 'gsc.scope_mix_ftx': 'Any band + any FT mode (FT8/FT4/FT2)', 'gsc.hint': 'Decides when a square stops being NEW. Narrower means more squares to chase: per band and per exact mode is the most demanding, any band and any digital mode the least. Chasing unconfirmed as well keeps a square wanted until a QSL, LoTW or eQSL confirmation arrives — it is still missing from the award until then.', - 'gsm.basemap': 'Basemap', 'gsm.title': 'Grid squares', 'gsm.all': 'All', 'gsm.phone': 'Phone', 'gsm.cw': 'CW', 'gsm.digital': 'Digital', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmed', 'gsm.worked': 'worked', 'gsm.colConfirmed': 'Colour for confirmed squares', 'gsm.colWorked': 'Colour for worked (unconfirmed) squares', 'gsm.colReset': 'Back to the theme colours', 'gsm.refresh': 'Recount from the log', 'gsm.count': '{n} squares · {c} confirmed', + 'gsm.basemap': 'Basemap', 'gsm.title': 'Grid squares', 'gsm.all': 'All', 'gsm.phone': 'Phone', 'gsm.cw': 'CW', 'gsm.digital': 'Digital', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmed', 'gsm.worked': 'worked', 'gsm.colConfirmed': 'Colour for confirmed squares', 'gsm.colWorked': 'Colour for worked (unconfirmed) squares', 'gsm.colReset': 'Back to the theme colours', 'gsm.refresh': 'Recount from the log', 'gsm.count': '{n} squares · {c} confirmed', 'gsm.oneMode': 'One mode', 'gsm.band': 'Band', 'gsm.allBands': 'All bands', 'gsm.satellite': 'Satellite', 'gsm.allSats': 'All satellites', 'bo.nearKm': 'Count receivers within', 'bo.nearKmHint': 'A report proves YOUR path only if it was collected near you. Smaller is more local but leaves fewer receivers listening — too small and the watch has nothing to look at. 300 km borrows a whole region and 100 km suits 2 m, where a duct is narrow; where stations are far apart — VK, ZL, much of North America — 1000 to 2000 km may be what it takes to find any receivers at all.', 'bo.open': 'open', 'bo.liveTip': '{band} is open — {n} stations, ~{km} km, {sector}{season}. Click for the band map.', 'bo.enable': 'Watch for band openings', 'bo.enableHint': '(10, 12, 6, 4 and 2 m. Switching this on adds the two RBN nodes and subscribes to the PSK Reporter feed — the detection needs far more ears than a cluster can give it.)', 'bo.feedUp': 'PSK Reporter feed up — {n} decodes seen', 'bo.feedDown': 'PSK Reporter feed down — needs your station grid, and a moment to connect', 'clu.spotTtl': 'Spot lifetime', 'clu.spotTtlNever': 'Keep', 'clu.spotMax': 'Spots kept', 'clu.spotMaxHint': '', 'clu.spotTtlHint': 'minutes — 0 keeps them.', 'clu.pillConnect': 'Click to connect', 'clu.pillDisconnect': 'Click to disconnect', 'clu.chasePota': 'Chase POTA', 'clu.chasePotaHint': 'Off: no NEW POTA badge or filter, and the POTA column stays empty — a new-band + new-POTA spot reads NEW BAND alone.', 'clu.chaseSota': 'Chase SOTA', 'clu.chaseSotaHint': 'Off: the SOTA column stays empty.', 'clu.chaseCounty': 'Chase US counties', 'clu.chaseCountyHint': 'Off: no NEW COUNTY badge or filter in the cluster.', 'clu.chaseState': 'Chase US states', 'clu.chaseStateHint': 'Off: no NEW STATE badge or filter.', 'clu.chasePfx': 'Chase new prefixes', 'clu.chasePfxHint': 'Off: no NEW PFX badge or filter in the cluster.', 'clu.chaseGrids': 'Chase new grids', 'clu.chaseGridsHint': '(learns locators from your own WSJT-X decodes AND from PSK Reporter, and keeps them in their own database so the cluster shows them from the first second)', 'clu.chaseGridsStat': '{n} locators known — {p} waiting to be written', 'clu.workedSameSlot': 'Already worked only on the same slot', 'clu.macros': 'Command buttons', 'clu.macrosHint': 'A named button beside the cluster command box. Leave the command empty and the button is not shown.', @@ -1027,7 +1027,7 @@ const fr: Dict = { 'clu.slotHighlightHint': "(par indicatif, quel que soit le statut de l'entité)", 'rq.searchPh': 'Chercher un indicatif… 4S · *4S · *4S*', 'rq.searchTip': 'Un mot simple correspond au DÉBUT de l’indicatif : 4S trouve 4S7AB. * remplace n’importe quelle suite de caractères et ? exactement un, donc *4S se termine par 4S, *4S* le contient n’importe où, et F?BPO correspond à F4BPO.', 'chg.mode': 'Chasse', 'chg.sources': 'Confirmé par', 'chg.card': 'Carte QSL', 'dec.unconfTip': 'Contacté mais non confirmé — une QSL à chasser', 'gsc.scope': 'Carré déjà fait selon', 'gsc.hunt': 'Chasser', 'gsc.huntNew': 'Nouveau — jamais contacté', 'gsc.huntUnconf': 'Nouveau et non confirmé', 'gsc.scope_band_digi': 'Cette bande + tout mode numérique', 'gsc.scope_band_mode': 'Cette bande + ce mode exact', 'gsc.scope_band_ftx': 'Cette bande + tout mode FT (FT8/FT4/FT2)', 'gsc.scope_mix_digi': 'Toutes bandes + tout mode numérique', 'gsc.scope_mix_mode': 'Toutes bandes + ce mode exact', 'gsc.scope_mix_ftx': 'Toutes bandes + tout mode FT (FT8/FT4/FT2)', 'gsc.hint': 'Détermine quand un carré cesse d’être NEW. Plus c’est étroit, plus il y a de carrés à chasser : par bande et par mode exact est le plus exigeant, toutes bandes et tout numérique le moins. Chasser aussi les non confirmés garde un carré recherché jusqu’à une confirmation QSL, LoTW ou eQSL — il manque toujours au diplôme d’ici là.', - 'gsm.basemap': 'Fond de carte', 'gsm.title': 'Carrés locator', 'gsm.all': 'Tout', 'gsm.phone': 'Phonie', 'gsm.cw': 'CW', 'gsm.digital': 'Numérique', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmés', 'gsm.worked': 'contactés', 'gsm.colConfirmed': 'Couleur des carrés confirmés', 'gsm.colWorked': 'Couleur des carrés contactés (non confirmés)', 'gsm.colReset': 'Revenir aux couleurs du thème', 'gsm.refresh': 'Recompter depuis le journal', 'gsm.count': '{n} carrés · {c} confirmés', + 'gsm.basemap': 'Fond de carte', 'gsm.title': 'Carrés locator', 'gsm.all': 'Tout', 'gsm.phone': 'Phonie', 'gsm.cw': 'CW', 'gsm.digital': 'Numérique', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmés', 'gsm.worked': 'contactés', 'gsm.colConfirmed': 'Couleur des carrés confirmés', 'gsm.colWorked': 'Couleur des carrés contactés (non confirmés)', 'gsm.colReset': 'Revenir aux couleurs du thème', 'gsm.refresh': 'Recompter depuis le journal', 'gsm.count': '{n} carrés · {c} confirmés', 'gsm.oneMode': 'Un mode', 'gsm.band': 'Bande', 'gsm.allBands': 'Toutes les bandes', 'gsm.satellite': 'Satellite', 'gsm.allSats': 'Tous les satellites', 'bo.nearKm': 'Compter les récepteurs à moins de', 'bo.nearKmHint': 'Un report ne prouve TON chemin que s’il a été collecté près de chez toi. Plus petit est plus local, mais laisse moins de récepteurs à l’écoute — trop petit, la veille n’a plus rien à observer. 300 km emprunte les oreilles de toute une région et 100 km convient au 2 m, où un conduit est étroit ; là où les stations sont très dispersées — VK, ZL, une bonne partie de l’Amérique du Nord — 1000 à 2000 km sont parfois nécessaires pour trouver le moindre récepteur.', 'bo.open': 'ouvert', 'bo.liveTip': '{band} est ouvert — {n} stations, ~{km} km, {sector}{season}. Cliquer pour le bandmap.', 'bo.enable': 'Surveiller les ouvertures de bande', 'bo.enableHint': "(10, 12, 6, 4 et 2 m. Activer ajoute les deux nœuds RBN et souscrit au flux PSK Reporter — la détection a besoin de bien plus d oreilles qu un cluster ne peut en fournir.)", 'bo.feedUp': 'Flux PSK Reporter actif — {n} décodages vus', 'bo.feedDown': 'Flux PSK Reporter inactif — il faut ton locator, et un instant pour se connecter', 'clu.workedSameSlot': 'Déjà contacté seulement sur le même slot', 'clu.macros': 'Boutons de commande', 'clu.macrosHint': 'Un bouton nommé à côté du champ de commande du cluster. Laisse la commande vide et le bouton n’est pas affiché.', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 7aee2fd..5379e7e 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -685,7 +685,9 @@ export function GetYaesuBandAntennas():Promise>; export function GetYaesuState():Promise; -export function GridSquares(arg1:string):Promise>; +export function GridSquareChoices():Promise; + +export function GridSquares(arg1:string,arg2:string,arg3:string):Promise>; export function HaltAutoCall():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index cba8d68..f9dc7ef 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -1302,8 +1302,12 @@ export function GetYaesuState() { return window['go']['main']['App']['GetYaesuState'](); } -export function GridSquares(arg1) { - return window['go']['main']['App']['GridSquares'](arg1); +export function GridSquareChoices() { + return window['go']['main']['App']['GridSquareChoices'](); +} + +export function GridSquares(arg1, arg2, arg3) { + return window['go']['main']['App']['GridSquares'](arg1, arg2, arg3); } export function HaltAutoCall() { diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index ee18cfb..993e1cf 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -3075,6 +3075,22 @@ export namespace main { return a; } } + export class GridSquareChoices { + modes: string[]; + bands: string[]; + satellites: string[]; + + static createFrom(source: any = {}) { + return new GridSquareChoices(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.modes = source["modes"]; + this.bands = source["bands"]; + this.satellites = source["satellites"]; + } + } export class HamlogCfmResult { total: number; confirmed: number; diff --git a/internal/qso/qso.go b/internal/qso/qso.go index dbf58e3..b5b9bc1 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -1860,9 +1860,19 @@ type GridSquare struct { // return false to drop a QSO. Aggregation to 4 characters happens HERE rather // than in SQL — the column holds 4, 6 and 8-character grids, and lower(substr) // in SQL would differ between SQLite and MySQL for no gain. -func (r *Repo) GridSquares(ctx context.Context, keep func(mode string) bool) ([]GridSquare, error) { +// GridSquareRow is one contact as the grid map decides whether to keep it. +// +// Submode is here beside Mode because the mode an operator filters by is not +// always the one in MODE: ADIF puts PSK63 in SUBMODE with PSK above it, so a +// filter that read MODE alone offered "PSK" for a log full of PSK63. +type GridSquareRow struct { + Mode, Submode, Band, SatName string +} + +func (r *Repo) GridSquares(ctx context.Context, keep func(GridSquareRow) bool) ([]GridSquare, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT COALESCE(grid,''), UPPER(COALESCE(mode,'')), LOWER(COALESCE(band,'')), + SELECT COALESCE(grid,''), UPPER(COALESCE(mode,'')), UPPER(COALESCE(submode,'')), + LOWER(COALESCE(band,'')), UPPER(COALESCE(sat_name,'')), COALESCE(lotw_rcvd,''), COALESCE(qsl_rcvd,''), COALESCE(eqsl_rcvd,'') FROM qso WHERE grid IS NOT NULL AND grid != '' @@ -1873,11 +1883,11 @@ func (r *Repo) GridSquares(ctx context.Context, keep func(mode string) bool) ([] defer rows.Close() out := map[string]*GridSquare{} for rows.Next() { - var grid, mode, band, lotw, card, eqsl string - if err := rows.Scan(&grid, &mode, &band, &lotw, &card, &eqsl); err != nil { + var grid, mode, submode, band, satName, lotw, card, eqsl string + if err := rows.Scan(&grid, &mode, &submode, &band, &satName, &lotw, &card, &eqsl); err != nil { return nil, err } - if keep != nil && !keep(mode) { + if keep != nil && !keep(GridSquareRow{Mode: mode, Submode: submode, Band: band, SatName: satName}) { continue } g := strings.ToUpper(strings.TrimSpace(grid)) @@ -1914,6 +1924,51 @@ func (r *Repo) GridSquares(ctx context.Context, keep func(mode string) bool) ([] return list, nil } +// GridSquareChoices is every mode, band and satellite that the squares on the +// map were actually worked on. +// +// Taken from the log rather than from a list in the code, so a filter can only +// ever offer something there is something to see behind — and so a mode that +// does not exist yet needs no change here the day an operator starts using it. +// The mode is the SUBMODE when there is one: PSK63 is the answer, not PSK. +func (r *Repo) GridSquareChoices(ctx context.Context) (modes, bands, sats []string, err error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT DISTINCT UPPER(COALESCE(mode,'')), UPPER(COALESCE(submode,'')), + LOWER(COALESCE(band,'')), UPPER(COALESCE(sat_name,'')) + FROM qso + WHERE grid IS NOT NULL AND grid != ''`) + if err != nil { + return nil, nil, nil, fmt.Errorf("query grid choices: %w", err) + } + defer rows.Close() + seenM, seenB, seenS := map[string]bool{}, map[string]bool{}, map[string]bool{} + for rows.Next() { + var mode, submode, band, sat string + if err := rows.Scan(&mode, &submode, &band, &sat); err != nil { + return nil, nil, nil, err + } + if m := strings.TrimSpace(submode); m != "" { + mode = m + } + if mode = strings.TrimSpace(mode); mode != "" && !seenM[mode] { + seenM[mode] = true + modes = append(modes, mode) + } + if band = strings.TrimSpace(band); band != "" && !seenB[band] { + seenB[band] = true + bands = append(bands, band) + } + if sat = strings.TrimSpace(sat); sat != "" && !seenS[sat] { + seenS[sat] = true + sats = append(sats, sat) + } + } + if err := rows.Err(); err != nil { + return nil, nil, nil, err + } + return modes, bands, sats, nil +} + // BandSlotQSOs returns every contact on one band that belongs to a slot of the // entry matrix: the exact callsign, or any callsign in the same DXCC entity. // Mode is NOT filtered here — the class (phone / CW / digital) is a derived