feat: day batch — upload results surfaced as toasts (silent LoTW failures from the right-click send looked like nothing was sent); live selection count + Select all/Unselect all toggle in the grid toolbar; active filters bypass the row limit (all matches shown, 10k safety cap); NET Control: same right-click menu on worked-before + drag&drop roster<->on-air (drop on roster logs the QSO); compact mode restores previous window size/position; advanced-filter field renamed Station callsign; optional DXCC-style digital-mode grouping (Settings->General) for matrix badges + cluster colouring; changelog 0.20.9 entries (version NOT bumped)
This commit is contained in:
@@ -530,6 +530,13 @@ type App struct {
|
||||
// must be allowed through.
|
||||
shuttingDown bool
|
||||
compact bool // window is in the compact one-row mode (skip saving its tiny size as the normal geometry)
|
||||
// Geometry captured when ENTERING compact mode, restored on the way back —
|
||||
// otherwise leaving compact snapped the window to a hardcoded 1400×900 at
|
||||
// wherever the compact strip had been dragged.
|
||||
preCompactW, preCompactH int
|
||||
preCompactX, preCompactY int
|
||||
preCompactMax bool
|
||||
preCompactValid bool
|
||||
|
||||
// Cached operator location used to compute distance/bearing for
|
||||
// cluster spots. Refreshed on profile activation; zero means
|
||||
@@ -1811,6 +1818,17 @@ func (a *App) ResetDatabaseToDefault() error {
|
||||
// widths, sort…) in the DB settings table under a "ui." namespace, so they
|
||||
// travel with the logbook and survive a reinstall — unlike the WebView's
|
||||
// localStorage. Values are opaque JSON blobs owned by the frontend.
|
||||
// groupDigitalSlots reports the "digital modes count as one" preference
|
||||
// (Settings → General; stored as a portable ui.* pref so the frontend matrix
|
||||
// and this backend cluster logic read the same switch).
|
||||
func (a *App) groupDigitalSlots() bool {
|
||||
if a.settings == nil {
|
||||
return false
|
||||
}
|
||||
v, _ := a.settings.Get(a.ctx, "ui.opslog.groupDigitalSlots")
|
||||
return v == "1"
|
||||
}
|
||||
|
||||
func (a *App) GetUIPref(key string) (string, error) {
|
||||
if a.settings == nil {
|
||||
return "", nil
|
||||
@@ -5204,6 +5222,19 @@ func (a *App) SetCompactMode(on bool) {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
if on && !a.compact {
|
||||
// Capture the normal geometry BEFORE shrinking, so leaving compact puts
|
||||
// the window back exactly where and how big it was.
|
||||
a.preCompactMax = wruntime.WindowIsMaximised(a.ctx)
|
||||
if a.preCompactMax {
|
||||
// Unmaximise first: the size/position of a maximised window are the
|
||||
// monitor's, not the user's chosen geometry.
|
||||
wruntime.WindowUnmaximise(a.ctx)
|
||||
}
|
||||
a.preCompactW, a.preCompactH = wruntime.WindowGetSize(a.ctx)
|
||||
a.preCompactX, a.preCompactY = wruntime.WindowGetPosition(a.ctx)
|
||||
a.preCompactValid = a.preCompactW >= normalMinW && a.preCompactH >= normalMinH
|
||||
}
|
||||
a.compact = on
|
||||
if on {
|
||||
// Lock the window to the compact size by pinning min == max. Without
|
||||
@@ -5218,9 +5249,18 @@ func (a *App) SetCompactMode(on bool) {
|
||||
// Release the lock first (raise the max) before growing back.
|
||||
wruntime.WindowSetMaxSize(a.ctx, maxW, maxH)
|
||||
wruntime.WindowSetMinSize(a.ctx, normalMinW, normalMinH)
|
||||
if a.preCompactValid {
|
||||
wruntime.WindowSetSize(a.ctx, a.preCompactW, a.preCompactH)
|
||||
wruntime.WindowSetPosition(a.ctx, a.preCompactX, a.preCompactY)
|
||||
if a.preCompactMax {
|
||||
wruntime.WindowMaximise(a.ctx)
|
||||
}
|
||||
} else {
|
||||
// Nothing captured (compact since launch) — fall back to the default.
|
||||
wruntime.WindowSetSize(a.ctx, normalW, normalH)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteAllQSO wipes every QSO. Returns the number of rows removed.
|
||||
// The frontend MUST gate this behind a strong confirmation prompt.
|
||||
@@ -8160,6 +8200,12 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
||||
msg = err.Error()
|
||||
}
|
||||
emit("LoTW upload failed: " + msg)
|
||||
// The qslmgr:log console is only visible in the QSL Manager — a failure
|
||||
// triggered from the Recent QSOs right-click was completely silent, which
|
||||
// read as "send to LoTW does nothing". Surface it as a toast too.
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "toast", "LoTW upload failed: "+msg)
|
||||
}
|
||||
} else {
|
||||
for _, id := range ids {
|
||||
a.markExtUploaded(svc, id, "")
|
||||
@@ -8314,6 +8360,16 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
||||
}
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": uploaded, "total": len(ids)})
|
||||
// Always end with a visible summary — the right-click "Send to …" runs in
|
||||
// the background and the per-QSO log lives in the QSL Manager console.
|
||||
label := map[extsvc.Service]string{
|
||||
extsvc.ServiceQRZ: "QRZ.com", extsvc.ServiceClublog: "Club Log", extsvc.ServiceHRDLog: "HRDLog",
|
||||
extsvc.ServiceLoTW: "LoTW", extsvc.ServiceEQSL: "eQSL",
|
||||
}[svc]
|
||||
if label == "" {
|
||||
label = string(svc)
|
||||
}
|
||||
wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("%s: %d/%d QSO uploaded", label, uploaded, len(ids)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13065,7 +13121,14 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
entities, err := a.qso.EntitySlotMap(a.ctx, keyFor)
|
||||
// Optional digital-mode grouping (Settings → General): with it on, FT8/FT4/
|
||||
// RTTY… all count as ONE "DIG" mode, so an FT4 spot on a band where FT8 was
|
||||
// worked shows "worked", not "new-slot" — DXCC-style mode classes.
|
||||
var normMode func(string) string
|
||||
if a.groupDigitalSlots() {
|
||||
normMode = qso.GroupDigitalMode
|
||||
}
|
||||
entities, err := a.qso.EntitySlotMap(a.ctx, keyFor, normMode)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
@@ -13133,12 +13196,17 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
||||
}
|
||||
// Band already worked. If this MODE was never worked on the entity (any
|
||||
// band) → new-mode. If the mode was worked elsewhere but not on THIS
|
||||
// band+mode → new-slot. Otherwise → worked.
|
||||
if _, m := e.Modes[out[i].Mode]; !m {
|
||||
// band+mode → new-slot. Otherwise → worked. The check mode goes through
|
||||
// the same normaliser as the slot map (digital grouping).
|
||||
checkMode := out[i].Mode
|
||||
if normMode != nil {
|
||||
checkMode = normMode(checkMode)
|
||||
}
|
||||
if _, m := e.Modes[checkMode]; !m {
|
||||
out[i].Status = "new-mode"
|
||||
continue
|
||||
}
|
||||
if _, ok := e.Slots[out[i].Band][out[i].Mode]; !ok {
|
||||
if _, ok := e.Slots[out[i].Band][checkMode]; !ok {
|
||||
out[i].Status = "new-slot"
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,4 +1,32 @@
|
||||
[
|
||||
{
|
||||
"version": "0.20.9",
|
||||
"date": "2026-07-22",
|
||||
"en": [
|
||||
"The PGXL amplifier chip and Station Control widget now read the OPERATE state from the FlexRadio (like the Flex panel) — the chip no longer shows STANDBY while the amp is operating, and clicking it toggles the amp through the radio.",
|
||||
"Right-click 'Send to LoTW / QRZ / Club Log…' now reports its result as a notification — a failed upload (e.g. TQSL not configured) was completely silent outside the QSL Manager, which looked like nothing was sent.",
|
||||
"The number of selected QSOs is now always visible at the top of the log grid — no need to open the right-click menu to see it.",
|
||||
"NET Control: the worked-before list gets the same right-click menu as Recent QSOs (update from cty/QRZ/Club Log, send to services, recording e-mail, eQSL, delete).",
|
||||
"Leaving compact mode restores the window's previous size AND position (it used to snap to a fixed size wherever the compact strip had been dragged).",
|
||||
"The advanced filter's 'My callsign' field is renamed 'Station callsign' so it is findable under its ADIF name (it filters STATION_CALLSIGN).",
|
||||
"Filtering now shows EVERY match: the on-screen row limit (Max) only applies to the unfiltered log — a filter matching 200 QSOs displays all 200 even with Max at 100 (safety cap 10,000, with a warning to narrow the filter beyond that).",
|
||||
"New 'Select all' button in the log grid toolbar — selects every displayed row (respecting active column filters) in one click, ready for send-to-LoTW, bulk edit or export; once everything is selected it flips to 'Unselect all'.",
|
||||
"NET Control: drag & drop between the two lists — drag a roster station onto the on-air list to start its QSO, and drag an on-air station onto the roster to log it (same as the Log & end button).",
|
||||
"New option (Settings → General): 'Group digital modes as one (DXCC-style)' — when on, the matrix newness badges and the cluster new/new-band/new-mode/new-slot colouring treat FT8/FT4/RTTY/PSK… as a single Digital mode, matching how DXCC counts; when off (default), each digital mode remains its own potential slot."
|
||||
],
|
||||
"fr": [
|
||||
"La pastille ampli PGXL et le widget Station Control lisent maintenant l'état OPERATE depuis le FlexRadio (comme le panneau Flex) — la pastille n'affiche plus STANDBY quand l'ampli est en service, et un clic bascule l'ampli via la radio.",
|
||||
"Le clic droit « Send to LoTW / QRZ / Club Log… » affiche maintenant son résultat en notification — un envoi échoué (p. ex. TQSL non configuré) était totalement silencieux hors du QSL Manager, comme si rien n'était parti.",
|
||||
"Le nombre de QSO sélectionnés est maintenant toujours visible en haut de la grille du log — plus besoin d'ouvrir le menu clic droit pour le voir.",
|
||||
"NET Control : la liste worked-before dispose du même menu clic droit que QSO récents (mise à jour cty/QRZ/Club Log, envoi aux services, e-mail d'enregistrement, eQSL, suppression).",
|
||||
"Quitter le mode compact restaure la taille ET la position précédentes de la fenêtre (avant, elle reprenait une taille fixe là où la barre compacte avait été déplacée).",
|
||||
"Le champ « Mon indicatif » du filtre avancé est renommé « Station callsign » pour être trouvable sous son nom ADIF (il filtre STATION_CALLSIGN).",
|
||||
"Le filtrage affiche maintenant TOUTES les correspondances : la limite d'affichage (Max) ne s'applique qu'au log non filtré — un filtre qui matche 200 QSO les affiche tous les 200 même avec Max à 100 (plafond de sécurité 10 000, avec un avertissement pour affiner au-delà).",
|
||||
"Nouveau bouton « Tout sélectionner » dans la barre d'outils de la grille — sélectionne toutes les lignes affichées (en respectant les filtres de colonnes actifs) en un clic, prêt pour l'envoi LoTW, le bulk edit ou l'export ; une fois tout sélectionné il devient « Tout désélectionner ».",
|
||||
"NET Control : glisser-déposer entre les deux listes — glisser une station du roster vers la liste on air démarre son QSO, et glisser une station on air vers le roster l'enregistre au log (comme le bouton Logger & terminer).",
|
||||
"Nouvelle option (Réglages → Général) : « Regrouper les modes digitaux en un seul (style DXCC) » — activée, les badges de nouveauté de la matrice et le coloriage cluster new/new-band/new-mode/new-slot traitent FT8/FT4/RTTY/PSK… comme un seul mode Digital, comme le DXCC ; désactivée (défaut), chaque mode digital reste un slot potentiel distinct."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.20.8",
|
||||
"date": "2026-07-21",
|
||||
|
||||
+32
-7
@@ -116,6 +116,9 @@ type ModePreset = ModePresetForm;
|
||||
type WB = WorkedBeforeView;
|
||||
type CATState = Omit<catModels.RigState, 'convertValues'>;
|
||||
|
||||
// Safety cap on how many rows an ACTIVE filter may load (the normal row
|
||||
// threshold is bypassed while filtering — see buildActiveFilter).
|
||||
const FILTERED_LIMIT_CAP = 10000;
|
||||
const DEFAULT_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm','23cm'];
|
||||
const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE'];
|
||||
// Modes the QSO recorder captures (phone only). Mirrors recordableMode() in
|
||||
@@ -1431,13 +1434,21 @@ export default function App() {
|
||||
|
||||
// The full filter sent to the backend = the builder conditions + the quick
|
||||
// callsign search box (always ANDed) + the on-screen row threshold.
|
||||
const buildActiveFilter = useCallback((): QueryFilter => ({
|
||||
// When a filter is ACTIVE the row threshold is lifted to a high safety cap:
|
||||
// the threshold exists to keep the UNFILTERED 30k-QSO log fast, but applying
|
||||
// it to a filter meant "200 matches, only the first 100 shown". A filtered
|
||||
// result set is almost always small, and the cap protects against a
|
||||
// broad filter (e.g. band=20m) pulling half the log.
|
||||
const buildActiveFilter = useCallback((): QueryFilter => {
|
||||
const hasFilter = !!(filterCallsign || (activeFilter.conditions ?? []).length);
|
||||
return {
|
||||
quick_callsign: filterCallsign,
|
||||
conditions: activeFilter.conditions ?? [],
|
||||
match: activeFilter.match ?? 'AND',
|
||||
limit: qsoLimit,
|
||||
limit: hasFilter ? FILTERED_LIMIT_CAP : qsoLimit,
|
||||
offset: 0,
|
||||
}), [filterCallsign, activeFilter, qsoLimit]);
|
||||
};
|
||||
}, [filterCallsign, activeFilter, qsoLimit]);
|
||||
|
||||
// refresh reloads the grid. Returns false on failure. When silent, it doesn't
|
||||
// surface the error — used by the startup retry, since the logbook DB (a remote
|
||||
@@ -3729,7 +3740,12 @@ export default function App() {
|
||||
case 'netcontrol':
|
||||
return (
|
||||
<div className="h-full w-full min-h-0 flex flex-col rounded-lg overflow-hidden border border-border">
|
||||
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes} />
|
||||
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes}
|
||||
qsoMenuHandlers={{
|
||||
onUpdateFromCty: bulkUpdateFromCty, onUpdateFromQRZ: bulkUpdateFromQRZ, onUpdateFromClublog: bulkUpdateFromClublog,
|
||||
onSendTo: bulkSendTo, onSendRecording: bulkSendRecording, onSendEQSL: (ids: number[]) => setEqslQsoId(ids[0] ?? null),
|
||||
onDelete: (ids: number[]) => setDeletingIds(ids),
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
case 'recent':
|
||||
@@ -4796,9 +4812,13 @@ export default function App() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{qsos.length >= qsoLimit && qsos.length < total && (
|
||||
{(activeFilter.conditions?.length || filterCallsign)
|
||||
? (matchCount != null && matchCount > FILTERED_LIMIT_CAP && (
|
||||
<span className="text-warning">{matchCount.toLocaleString('en-US')} matches — only {FILTERED_LIMIT_CAP.toLocaleString('en-US')} shown, narrow the filter (or use the filtered ADIF export).</span>
|
||||
))
|
||||
: (qsos.length >= qsoLimit && qsos.length < total && (
|
||||
<span className="text-warning">Limit reached — raise Max to see more.</span>
|
||||
)}
|
||||
))}
|
||||
<Label className="text-[11px] text-muted-foreground">Max</Label>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -5052,7 +5072,12 @@ export default function App() {
|
||||
tune the rig. */}
|
||||
{netEnabled && (
|
||||
<TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes} />
|
||||
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes}
|
||||
qsoMenuHandlers={{
|
||||
onUpdateFromCty: bulkUpdateFromCty, onUpdateFromQRZ: bulkUpdateFromQRZ, onUpdateFromClublog: bulkUpdateFromClublog,
|
||||
onSendTo: bulkSendTo, onSendRecording: bulkSendRecording, onSendEQSL: (ids: number[]) => setEqslQsoId(ids[0] ?? null),
|
||||
onDelete: (ids: number[]) => setDeletingIds(ids),
|
||||
}} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
|
||||
@@ -109,14 +109,21 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
// "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
|
||||
// worked on — not just the operator's configured column list).
|
||||
// Newness uses the ACTUAL mode (FT8 / FT4 / RTTY…), not the PH/CW/DIG class:
|
||||
// DIG is a group, so FT4 after FT8 is genuinely a new mode. dxcc_band_modes
|
||||
// lists every real (band, mode) the entity was worked on.
|
||||
// By default newness uses the ACTUAL mode (FT8 / FT4 / RTTY…): DIG is a
|
||||
// group, so FT4 after FT8 is genuinely a new mode. The operator can opt into
|
||||
// DXCC-style grouping instead (Settings → General), where all digital modes
|
||||
// count as ONE — then FT4 after FT8 is just "worked".
|
||||
const groupDigital = localStorage.getItem('opslog.groupDigitalSlots') === '1';
|
||||
const normMode = (m: string): string => {
|
||||
const u = (m || '').toUpperCase().trim();
|
||||
if (!groupDigital) return u;
|
||||
return u === '' || u === 'CW' || PHONE_MODES.has(u) ? u : 'DIG';
|
||||
};
|
||||
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
|
||||
const curMode = (currentMode || '').toUpperCase().trim();
|
||||
const curMode = normMode(currentMode);
|
||||
const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode)
|
||||
const modeWorked = !!curMode && bandModes.some((bm) => (bm.mode || '').toUpperCase() === curMode); // …in this exact mode (any band)
|
||||
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && (bm.mode || '').toUpperCase() === curMode);
|
||||
const modeWorked = !!curMode && bandModes.some((bm) => normMode(bm.mode) === curMode); // …in this (normalised) mode (any band)
|
||||
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && normMode(bm.mode) === curMode);
|
||||
// Mutually-exclusive badges, shown only when the entity is worked but this
|
||||
// exact band+mode is NOT yet:
|
||||
// New Band & Mode = both the band AND the mode are new for this entity.
|
||||
|
||||
@@ -42,14 +42,28 @@ function fmtTimeOn(s: any): string {
|
||||
|
||||
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
|
||||
|
||||
// The right-click actions of the main Recent QSOs grid (update from cty/QRZ/
|
||||
// Club Log, send to services, recording e-mail, eQSL, delete) — passed down so
|
||||
// the worked-before grid here offers the SAME context menu on logged QSOs.
|
||||
type QSOMenuHandlers = {
|
||||
onUpdateFromCty?: (ids: number[]) => void;
|
||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||
onUpdateFromClublog?: (ids: number[]) => void;
|
||||
onSendTo?: (service: string, ids: number[]) => void;
|
||||
onSendRecording?: (ids: number[]) => void;
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
onLogged?: () => void;
|
||||
countries?: string[];
|
||||
bands?: string[];
|
||||
modes?: string[];
|
||||
qsoMenuHandlers?: QSOMenuHandlers;
|
||||
};
|
||||
|
||||
export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHandlers }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [nets, setNets] = useState<Net[]>([]);
|
||||
const [selId, setSelId] = useState<string>('');
|
||||
@@ -70,6 +84,34 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
const [looking, setLooking] = useState(false);
|
||||
|
||||
const rosterGrid = useRef<any>(null);
|
||||
// Cross-grid drag & drop: roster row → on-air grid puts the station on air;
|
||||
// on-air row → roster grid logs it (same as the "Log & end" button). AG-Grid
|
||||
// external drop zones need each grid's api + the OTHER grid's container div.
|
||||
const onAirApi = useRef<any>(null);
|
||||
const onAirWrap = useRef<HTMLDivElement | null>(null);
|
||||
const rosterWrap = useRef<HTMLDivElement | null>(null);
|
||||
const dropZonesDone = useRef({ roster: false, onair: false });
|
||||
// Callbacks live in refs so the drop-zone closures (registered once) always
|
||||
// call the CURRENT activate/deactivate, not a stale first-render one.
|
||||
const activateRef = useRef<(call: string) => void>(() => {});
|
||||
const deactivateRef = useRef<(id?: number) => void>(() => {});
|
||||
const wireDropZones = useCallback(() => {
|
||||
const rApi = rosterGrid.current?.api;
|
||||
if (rApi && onAirWrap.current && !dropZonesDone.current.roster) {
|
||||
dropZonesDone.current.roster = true;
|
||||
rApi.addRowDropZone({
|
||||
getContainer: () => onAirWrap.current!,
|
||||
onDragStop: (p: any) => { const call = p.node?.data?.callsign; if (call) activateRef.current(call); },
|
||||
});
|
||||
}
|
||||
if (onAirApi.current && rosterWrap.current && !dropZonesDone.current.onair) {
|
||||
dropZonesDone.current.onair = true;
|
||||
onAirApi.current.addRowDropZone({
|
||||
getContainer: () => rosterWrap.current!,
|
||||
onDragStop: (p: any) => { const id = p.node?.data?.id; if (id != null) deactivateRef.current(id); },
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Worked-before for the clicked station (see if/when we contacted it before).
|
||||
const [wbCall, setWbCall] = useState('');
|
||||
@@ -195,6 +237,10 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
}
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
// Keep the drop-zone closures pointed at the CURRENT handlers.
|
||||
activateRef.current = activate;
|
||||
deactivateRef.current = deactivate;
|
||||
|
||||
// Log EVERYONE still on air (end of net): each draft is written to the logbook
|
||||
// exactly as the single-log button would.
|
||||
async function logAll() {
|
||||
@@ -258,7 +304,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
}
|
||||
|
||||
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
||||
// rowDrag: drag a roster station onto the on-air grid to start its QSO.
|
||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold', rowDrag: true },
|
||||
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
||||
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
||||
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 },
|
||||
@@ -307,12 +354,14 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
{t('ncp.onAirActive')}
|
||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
||||
</div>
|
||||
<div className="flex flex-col min-h-0 flex-1">
|
||||
<div ref={onAirWrap} className="flex flex-col min-h-0 flex-1">
|
||||
<RecentQSOsGrid
|
||||
rows={active}
|
||||
total={active.length}
|
||||
storageKey="net.onair"
|
||||
selectRowSignal={selectRow}
|
||||
rowDragCall
|
||||
onGridApi={(api) => { onAirApi.current = api; wireDropZones(); }}
|
||||
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
|
||||
onRowDoubleClicked={(q) => setEditingDraft(q)}
|
||||
onRowSelected={setSelectedActiveIds}
|
||||
@@ -360,6 +409,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
rows={(wb.entries ?? []) as any}
|
||||
total={wb.count}
|
||||
storageKey="net.wb"
|
||||
{...qsoMenuHandlers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -373,7 +423,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
{t('ncp.netUsersRoster')}
|
||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<div ref={rosterWrap} style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<AgGridReact<Station>
|
||||
ref={rosterGrid}
|
||||
@@ -382,6 +432,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
columnDefs={rosterCols}
|
||||
defaultColDef={defaultColDef}
|
||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||
onGridReady={() => wireDropZones()}
|
||||
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
||||
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
|
||||
animateRows={false}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from 'ag-grid-community';
|
||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Columns3, FilterX } from 'lucide-react';
|
||||
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
||||
import type { QSOForm } from '@/types';
|
||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||
import {
|
||||
@@ -33,6 +33,11 @@ type Props = {
|
||||
// Bump `seq` to programmatically select the single row with this id (e.g. NET
|
||||
// Control auto-advancing to the next on-air station after logging one).
|
||||
selectRowSignal?: { id: number; seq: number };
|
||||
// Show a row-drag handle on the callsign column (NET Control: drag an on-air
|
||||
// row onto the roster to log it). Pair with onGridApi so the parent can
|
||||
// register external drop zones via api.addRowDropZone.
|
||||
rowDragCall?: boolean;
|
||||
onGridApi?: (api: any) => void;
|
||||
// storageKey scopes the persisted column layout/visibility. Omit for the main
|
||||
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
|
||||
// reuse this grid elsewhere with its OWN independent column config.
|
||||
@@ -252,7 +257,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
||||
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
||||
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
||||
|
||||
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
||||
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDragCall, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
@@ -260,6 +265,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
|
||||
// (e.g. the Net panel) keeps its own layout independent of the main log.
|
||||
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
const [selCount, setSelCount] = useState(0); // live selection count shown in the toolbar
|
||||
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
|
||||
|
||||
// Localized column catalog — rebuilt when the language changes.
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||
@@ -319,7 +326,11 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
|
||||
restoringRef.current = true;
|
||||
const base = COL_CATALOG.map((c) => {
|
||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||
return { ...rest, hide: !defaultVisible };
|
||||
const col: ColDef<QSOForm> = { ...rest, hide: !defaultVisible };
|
||||
if (rowDragCall && ((rest as any).colId === 'callsign' || (rest as any).field === 'callsign')) {
|
||||
col.rowDrag = true;
|
||||
}
|
||||
return col;
|
||||
});
|
||||
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
|
||||
colId: `award_${a.code}`,
|
||||
@@ -333,7 +344,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
|
||||
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||
}));
|
||||
return [...base, ...awards];
|
||||
}, [awardCols, COL_CATALOG, t, awardShown]);
|
||||
}, [awardCols, COL_CATALOG, t, awardShown, rowDragCall]);
|
||||
|
||||
// Enforce award-column visibility via the API after the columns (re)appear —
|
||||
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
|
||||
@@ -356,6 +367,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
|
||||
}), []);
|
||||
|
||||
function onGridReady(e: GridReadyEvent) {
|
||||
onGridApi?.(e.api);
|
||||
const local = loadLocal(colStateKey);
|
||||
if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
|
||||
// Fall back to the portable DB copy when the local cache is empty
|
||||
@@ -371,6 +383,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
|
||||
// null when no column filter is active, so "Showing X of Y" reflects them.
|
||||
const reportFilteredCount = useCallback((e: { api?: any }) => {
|
||||
const api = e?.api ?? gridRef.current?.api;
|
||||
if (api?.getDisplayedRowCount) setDispCount(api.getDisplayedRowCount());
|
||||
if (!api || !onFilteredCountChange) return;
|
||||
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
|
||||
}, [onFilteredCountChange]);
|
||||
@@ -400,7 +413,10 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
|
||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||
}
|
||||
function onSelectionChanged() {
|
||||
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
||||
const api = gridRef.current?.api;
|
||||
const sel = (api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
||||
setSelCount(sel.length);
|
||||
setDispCount(api?.getDisplayedRowCount?.() ?? 0);
|
||||
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
||||
}
|
||||
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
|
||||
@@ -480,6 +496,32 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
||||
{/* Live selection count — visible without opening the context menu. */}
|
||||
{selCount > 0 && (
|
||||
<span className="mr-auto text-[11px] font-semibold text-primary tabular-nums px-1.5">
|
||||
{t('rqg.selectedCount', { n: selCount })}
|
||||
</span>
|
||||
)}
|
||||
{/* Select every loaded row that passes the active column filters — so a
|
||||
filtered view can be selected in one click (then send to LoTW, bulk
|
||||
edit, export…). Once everything is selected the same button flips to
|
||||
"Unselect all". */}
|
||||
{(() => {
|
||||
const allSelected = selCount > 0 && dispCount > 0 && selCount >= dispCount;
|
||||
return (
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
||||
title={allSelected ? t('rqg.unselectAllTitle') : t('rqg.selectAllTitle')}
|
||||
onClick={() => {
|
||||
const api: any = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
if (allSelected) api.deselectAll();
|
||||
else if (typeof api.selectAllFiltered === 'function') api.selectAllFiltered();
|
||||
else api.selectAll('filtered');
|
||||
}}>
|
||||
<ListChecks className="size-3.5" /> {allSelected ? t('rqg.unselectAll') : t('rqg.selectAll')}
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||
title={t('rqg.clearFiltersTitle')}>
|
||||
<FilterX className="size-3.5" /> {t('rqg.clearFilters')}
|
||||
|
||||
@@ -1149,6 +1149,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
||||
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
||||
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
|
||||
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
||||
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
|
||||
// Password-encryption (secret vault) state.
|
||||
@@ -4619,6 +4620,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
|
||||
{t('gen.lookupOnBlur')} <span className="text-xs text-muted-foreground">{t('gen.lookupOnBlurHint')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={groupDigital} onCheckedChange={(c) => { const v = !!c; setGroupDigital(v); writeUiPref('opslog.groupDigitalSlots', v ? '1' : '0'); }} />
|
||||
{t('gen.groupDigital')} <span className="text-xs text-muted-foreground">{t('gen.groupDigitalHint')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} />
|
||||
{t('gen.checkUpdates')} <span className="text-xs text-muted-foreground">{t('gen.checkUpdatesHint')}</span>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -25,6 +25,7 @@ const PORTABLE_KEYS = [
|
||||
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
||||
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
||||
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
||||
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
|
||||
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
||||
// Cluster filter selections — restored on reopen.
|
||||
|
||||
+19
-1
@@ -1371,6 +1371,17 @@ func modeClass(mode string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// GroupDigitalMode collapses every digital mode into the single "DIG" bucket
|
||||
// (FT8, FT4, RTTY, PSK… all become one mode) while leaving phone modes and CW
|
||||
// untouched. Used as the optional slot normaliser when the operator prefers
|
||||
// DXCC-style mode classes over per-mode slots (Settings → General).
|
||||
func GroupDigitalMode(mode string) string {
|
||||
if modeClass(mode) == "DIG" {
|
||||
return "DIG"
|
||||
}
|
||||
return strings.ToUpper(mode)
|
||||
}
|
||||
|
||||
// BandMode is a (band, mode) pair used for the NEW SLOT check.
|
||||
type BandMode struct {
|
||||
Band string `json:"band"`
|
||||
@@ -1802,7 +1813,11 @@ type EntitySlot struct {
|
||||
// 0 (unresolvable) skips the QSO.
|
||||
//
|
||||
// One DB scan regardless of input size. Cheap to call per cluster batch.
|
||||
func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int) (map[int]*EntitySlot, error) {
|
||||
//
|
||||
// normMode (nil = identity) maps each QSO's mode before it is stored as a
|
||||
// Modes/Slots key — pass GroupDigitalMode to collapse all digital modes into
|
||||
// one bucket. Callers must normalise their lookup mode the same way.
|
||||
func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int, normMode func(string) string) (map[int]*EntitySlot, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT callsign, coalesce(dxcc,0), lower(coalesce(country,'')), lower(band), upper(mode) FROM qso
|
||||
WHERE band IS NOT NULL AND band != ''
|
||||
@@ -1827,6 +1842,9 @@ func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, store
|
||||
if key == 0 {
|
||||
continue
|
||||
}
|
||||
if normMode != nil {
|
||||
mode = normMode(mode)
|
||||
}
|
||||
e, ok := out[key]
|
||||
if !ok {
|
||||
e = &EntitySlot{
|
||||
|
||||
Reference in New Issue
Block a user