Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b24f880d62 | ||
|
|
ef71ddb648 | ||
|
|
0143a84cee | ||
|
|
82721110ed | ||
|
|
aec363b152 | ||
|
|
9a21c936b1 |
@@ -12212,7 +12212,10 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
|
|||||||
emit(fmt.Sprintf("Downloading all LoTW confirmations for %s…", callLabel))
|
emit(fmt.Sprintf("Downloading all LoTW confirmations for %s…", callLabel))
|
||||||
}
|
}
|
||||||
emit(fmt.Sprintf("Window: since=%q → resolved=%q (scope owncall=%q)", since, sinceDate, ownCall))
|
emit(fmt.Sprintf("Window: since=%q → resolved=%q (scope owncall=%q)", since, sinceDate, ownCall))
|
||||||
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, sinceDate, ownCall)
|
// The report arrives over minutes, and a window that says nothing while it
|
||||||
|
// does is indistinguishable from one that has hung — which is what it was
|
||||||
|
// being reported as. Every half-megabyte, say how much has landed.
|
||||||
|
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, sinceDate, ownCall, emit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
emit("Download failed: " + err.Error())
|
emit("Download failed: " + err.Error())
|
||||||
done(matched, total)
|
done(matched, total)
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.26.21",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Distances can be shown in miles (Settings → General): the cluster and Recent QSOs columns, the map path box, the rotator buttons and the band-opening list all follow, and the column headers name the unit.",
|
||||||
|
"LoTW download: the report is now counted in megabytes as it arrives, LoTW’s \"busy\" answer (HTTP 503) is retried twice instead of failing, and a transfer that stops moving for two minutes says so rather than showing \"working\" indefinitely."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Les distances peuvent s'afficher en miles (Réglages → Général) : les colonnes du cluster et des QSO récents, l'encart du tracé sur la carte, les boutons du rotor et la liste des ouvertures suivent, et l'unité est indiquée dans les en-têtes de colonne.",
|
||||||
|
"Téléchargement LoTW : le rapport est compté en mégaoctets au fur et à mesure, la réponse « occupé » de LoTW (HTTP 503) est retentée deux fois au lieu d'échouer, et un transfert qui n'avance plus pendant deux minutes le dit au lieu d'afficher « en cours » indéfiniment."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.26.20",
|
"version": "0.26.20",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
|
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
|
||||||
import { ListRadios, SetActiveRadio } from '../wailsjs/go/main/App';
|
import { ListRadios, SetActiveRadio } from '../wailsjs/go/main/App';
|
||||||
|
import { formatDistance } from '@/lib/units';
|
||||||
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
|
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
|
||||||
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
||||||
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
@@ -6419,7 +6420,7 @@ export default function App() {
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => p && goto(p.bearingShort, 'SP')}
|
onClick={() => p && goto(p.bearingShort, 'SP')}
|
||||||
title={p
|
title={p
|
||||||
? `Rotate short-path · ${Math.round(p.distanceShort).toLocaleString()} km`
|
? `Rotate short-path · ${formatDistance(p.distanceShort)}`
|
||||||
: (station.my_grid ? 'No remote grid' : 'Set your station grid in Preferences')}
|
: (station.my_grid ? 'No remote grid' : 'Set your station grid in Preferences')}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center gap-1 px-2 py-0.5 transition-colors',
|
'inline-flex items-center gap-1 px-2 py-0.5 transition-colors',
|
||||||
@@ -6435,7 +6436,7 @@ export default function App() {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => p && goto(p.bearingLong, 'LP')}
|
onClick={() => p && goto(p.bearingLong, 'LP')}
|
||||||
title={p ? `Rotate long-path · ${Math.round(p.distanceLong).toLocaleString()} km` : ''}
|
title={p ? `Rotate long-path · ${formatDistance(p.distanceLong)}` : ''}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-1.5 py-0.5 border-l border-info-border text-[10px] transition-colors',
|
'px-1.5 py-0.5 border-l border-info-border text-[10px] transition-colors',
|
||||||
disabled
|
disabled
|
||||||
@@ -8570,7 +8571,7 @@ export default function App() {
|
|||||||
"1.5k" and then appended the unit, giving "1.5kkm" — and even
|
"1.5k" and then appended the unit, giving "1.5kkm" — and even
|
||||||
written correctly, "1.5k km" makes a reader do arithmetic to
|
written correctly, "1.5k km" makes a reader do arithmetic to
|
||||||
recover a number that was four characters long to begin with. */}
|
recover a number that was four characters long to begin with. */}
|
||||||
<span className="font-mono opacity-80">{o.median_km} km</span>
|
<span className="font-mono opacity-80">{formatDistance(o.median_km)}</span>
|
||||||
{/* Out of season is the one an operator must not learn last, so it
|
{/* Out of season is the one an operator must not learn last, so it
|
||||||
earns a mark on the badge rather than a line in the tooltip. */}
|
earns a mark on the badge rather than a line in the tooltip. */}
|
||||||
{!o.in_season && <span className="opacity-90">!</span>}
|
{!o.in_season && <span className="opacity-90">!</span>}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
// new is being decoded on FT8/FT4/JS8 near here — not that the band is dead.
|
// new is being decoded on FT8/FT4/JS8 near here — not that the band is dead.
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Radar, Loader2, X } from 'lucide-react';
|
import { Radar, Loader2, X } from 'lucide-react';
|
||||||
|
import { formatDistance } from '@/lib/units';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { markerColour } from '@/lib/spotMarkers';
|
import { markerColour } from '@/lib/spotMarkers';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -186,7 +187,7 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
|
|||||||
title={[
|
title={[
|
||||||
s.country,
|
s.country,
|
||||||
s.grid,
|
s.grid,
|
||||||
s.dist_km ? `${s.dist_km} km` : '',
|
s.dist_km ? formatDistance(s.dist_km) : '',
|
||||||
s.freq_hz ? `${(s.freq_hz / 1000).toFixed(1)} kHz` : '',
|
s.freq_hz ? `${(s.freq_hz / 1000).toFixed(1)} kHz` : '',
|
||||||
].filter(Boolean).join(' · ')}
|
].filter(Boolean).join(' · ')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
|||||||
import { markerColour } from '@/lib/spotMarkers';
|
import { markerColour } from '@/lib/spotMarkers';
|
||||||
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
||||||
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
||||||
|
import { distanceUnit, distanceValue, subscribeDistanceUnit } from '@/lib/units';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||||
@@ -443,8 +444,13 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Geo', label: t('clg2.c.distance_km'), colId: 'distance_km',
|
group: 'Geo', label: t('clg2.c.distance_km'), colId: 'distance_km',
|
||||||
headerName: t('clg2.h.distance_km'), field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
|
// The header carries the unit, so the cells stay bare numbers and the
|
||||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
// column still sorts on the km the backend sent — converting the VALUE
|
||||||
|
// would sort miles as if they were kilometres either way, but it would
|
||||||
|
// also round twice.
|
||||||
|
headerName: t('clg2.h.distance_km') + ' (' + distanceUnit() + ')',
|
||||||
|
field: 'distance_km' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
|
valueFormatter: (p) => p.value ? String(distanceValue(p.value)) : '',
|
||||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -523,7 +529,9 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect }: Pro
|
|||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
|
||||||
// Localized column catalog — rebuilt when the language changes.
|
// Localized column catalog — rebuilt when the language changes.
|
||||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
const [distUnit, setDistUnit] = useState(distanceUnit);
|
||||||
|
useEffect(() => subscribeDistanceUnit(() => setDistUnit(distanceUnit())), []);
|
||||||
|
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t, distUnit]);
|
||||||
|
|
||||||
// A rebuild makes AG Grid re-apply every colDef hide/width DEFAULT and fire the
|
// A rebuild makes AG Grid re-apply every colDef hide/width DEFAULT and fire the
|
||||||
// matching column events. Without this guard those events were persisted, so a
|
// matching column events. Without this guard those events were persisted, so a
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'leaflet/dist/leaflet.css';
|
|||||||
import { nightPolygon } from '../lib/greyline';
|
import { nightPolygon } from '../lib/greyline';
|
||||||
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { formatDistance } from '@/lib/units';
|
||||||
|
|
||||||
// Persisted free-pan view of the world map (when auto-zoom is off).
|
// Persisted free-pan view of the world map (when auto-zoom is off).
|
||||||
function loadMapView(): { lat: number; lon: number; zoom: number } | null {
|
function loadMapView(): { lat: number; lon: number; zoom: number } | null {
|
||||||
@@ -446,8 +447,8 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
</button>
|
</button>
|
||||||
{path && (
|
{path && (
|
||||||
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
||||||
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
|
<div><span className="text-muted-foreground">Dist</span> {formatDistance(path.distanceShort)}
|
||||||
<span className="text-muted-foreground"> · LP</span> {Math.round(path.distanceLong).toLocaleString()} km</div>
|
<span className="text-muted-foreground"> · LP</span> {formatDistance(path.distanceLong)}</div>
|
||||||
<div><span className="text-muted-foreground">Az SP</span> {Math.round(path.bearingShort)}°
|
<div><span className="text-muted-foreground">Az SP</span> {Math.round(path.bearingShort)}°
|
||||||
<span className="text-muted-foreground"> · LP</span> {Math.round(path.bearingLong)}°</div>
|
<span className="text-muted-foreground"> · LP</span> {Math.round(path.bearingLong)}°</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { formatDateTimeUTC, formatDateOnly, getDateFormat, subscribeDateFormat }
|
|||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
|
import { distanceUnit, distanceValue, subscribeDistanceUnit } from '@/lib/units';
|
||||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
@@ -177,8 +178,9 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
|||||||
{ group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
{ group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||||
// Derived, not stored: computed from the two locations at display time, like
|
// Derived, not stored: computed from the two locations at display time, like
|
||||||
// the cluster grid's own distance column.
|
// the cluster grid's own distance column.
|
||||||
{ group: 'Contacted', label: t('rqg.c.distance_km'), colId: 'distance_km', headerName: t('rqg.h.distance_km'), width: 90, type: 'rightAligned', cellClass: 'font-mono',
|
{ group: 'Contacted', label: t('rqg.c.distance_km'), colId: 'distance_km',
|
||||||
valueGetter: (p) => qsoDistanceKm(p.data, myGrid),
|
headerName: t('rqg.h.distance_km') + ' (' + distanceUnit() + ')', width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
|
valueGetter: (p) => { const km = qsoDistanceKm(p.data, myGrid); return km ? distanceValue(km) : km; },
|
||||||
comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
||||||
{ group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 },
|
{ group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 },
|
||||||
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
||||||
@@ -335,7 +337,9 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
// inside the column definitions, so nothing else would notice.
|
// inside the column definitions, so nothing else would notice.
|
||||||
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
||||||
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
||||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
|
const [distUnit, setDistUnit] = useState(distanceUnit);
|
||||||
|
useEffect(() => subscribeDistanceUnit(() => setDistUnit(distanceUnit())), []);
|
||||||
|
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt, distUnit]);
|
||||||
|
|
||||||
// Right-click: if the clicked row isn't already part of the selection,
|
// Right-click: if the clicked row isn't already part of the selection,
|
||||||
// select just it; then open the bulk-action menu on the whole selection.
|
// select just it; then open the bulk-action menu on the whole selection.
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { setUseMiles } from '@/lib/units';
|
||||||
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
|
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
|
||||||
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
||||||
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
|
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
|
||||||
@@ -1747,6 +1748,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
||||||
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
||||||
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
|
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
|
||||||
|
const [milesUnit, setMilesUnit] = useState(() => localStorage.getItem('opslog.distanceMiles') === '1');
|
||||||
const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
|
const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
|
||||||
// Declared HERE and not in ClusterPanel: that renderer is called as a plain
|
// Declared HERE and not in ClusterPanel: that renderer is called as a plain
|
||||||
// function by the PANELS map, so it must stay hooks-free.
|
// function by the PANELS map, so it must stay hooks-free.
|
||||||
@@ -7221,6 +7223,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
<Checkbox checked={groupDigital} onCheckedChange={(c) => { const v = !!c; setGroupDigital(v); writeUiPref('opslog.groupDigitalSlots', v ? '1' : '0'); }} />
|
<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>
|
{t('gen.groupDigital')} <span className="text-xs text-muted-foreground">{t('gen.groupDigitalHint')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
{/* Distances are computed in km everywhere and converted at display
|
||||||
|
time — see lib/units. Changing this repaints the columns that
|
||||||
|
already carry a distance; nothing stored moves. */}
|
||||||
|
<Checkbox checked={milesUnit} onCheckedChange={(c) => { const v = !!c; setMilesUnit(v); setUseMiles(v); }} />
|
||||||
|
{t('gen.miles')} <span className="text-xs text-muted-foreground">{t('gen.milesHint')}</span>
|
||||||
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<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'); }} />
|
<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>
|
{t('gen.checkUpdates')} <span className="text-xs text-muted-foreground">{t('gen.checkUpdatesHint')}</span>
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ const en: Dict = {
|
|||||||
'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.',
|
'icmp.scopeNoStream': 'This radio does not send its scope over CI-V — its own screen still works.',
|
||||||
|
'gen.miles': 'Distances in miles', 'gen.milesHint': '(instead of kilometres)',
|
||||||
'qslm.qrzTitle': 'Open this callsign on QRZ.com',
|
'qslm.qrzTitle': 'Open this callsign on QRZ.com',
|
||||||
'qslm.lotwAllCalls': 'All my callsigns',
|
'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.",
|
'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.",
|
||||||
@@ -623,6 +624,7 @@ const fr: Dict = {
|
|||||||
'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.",
|
'icmp.scopeNoStream': "Cette radio n'envoie pas son scope en CI-V — son propre écran fonctionne toujours.",
|
||||||
|
'gen.miles': 'Distances en miles', 'gen.milesHint': '(au lieu des kilomètres)',
|
||||||
'qslm.qrzTitle': 'Ouvrir cet indicatif sur QRZ.com',
|
'qslm.qrzTitle': 'Ouvrir cet indicatif sur QRZ.com',
|
||||||
'qslm.lotwAllCalls': 'Tous mes indicatifs',
|
'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.",
|
'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.",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
|
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
|
||||||
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||||
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||||
|
'opslog.distanceMiles', // distances shown in statute miles rather than km
|
||||||
'opslog.activeTab', // last selected tab
|
'opslog.activeTab', // last selected tab
|
||||||
'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares
|
'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares
|
||||||
'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}
|
'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Distance units.
|
||||||
|
//
|
||||||
|
// Everything is COMPUTED in kilometres — the great-circle maths, the backend's
|
||||||
|
// distance_km on a spot, the map's path lengths — and converted once, here, at
|
||||||
|
// display time. Storing miles anywhere would mean two sources of truth for the
|
||||||
|
// same number and a rounding error that grows with every hop.
|
||||||
|
//
|
||||||
|
// The preference is portable (see lib/uiPref): an operator who works in miles
|
||||||
|
// works in miles on every machine they copy their folder to.
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
|
export const KEY_MILES = 'opslog.distanceMiles';
|
||||||
|
const KM_PER_MILE = 1.609344; // statute miles, the ones a US licence is used in
|
||||||
|
|
||||||
|
export function useMiles(): boolean {
|
||||||
|
try { return localStorage.getItem(KEY_MILES) === '1'; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUseMiles(on: boolean): void {
|
||||||
|
writeUiPref(KEY_MILES, on ? '1' : '0');
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribeDistanceUnit notifies on a change. The grids capture the unit inside
|
||||||
|
// their column definitions (header text and formatter both), so without this a
|
||||||
|
// toggle would only show up on the next language change or restart.
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
export function subscribeDistanceUnit(fn: () => void): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => { listeners.delete(fn); };
|
||||||
|
}
|
||||||
|
|
||||||
|
// distanceValue converts a distance in km to the operator's unit, rounded to a
|
||||||
|
// whole unit — the precision the inputs actually justify (a 4-character grid is
|
||||||
|
// a square tens of kilometres wide).
|
||||||
|
export function distanceValue(km: number): number {
|
||||||
|
if (!isFinite(km)) return 0;
|
||||||
|
return Math.round(useMiles() ? km / KM_PER_MILE : km);
|
||||||
|
}
|
||||||
|
|
||||||
|
// distanceUnit is the short label: "km" or "mi".
|
||||||
|
export function distanceUnit(): string {
|
||||||
|
return useMiles() ? 'mi' : 'km';
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatDistance is value + unit, thousands-separated: "12 345 km".
|
||||||
|
export function formatDistance(km: number): string {
|
||||||
|
return `${distanceValue(km).toLocaleString()} ${distanceUnit()}`;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.26.20';
|
export const APP_VERSION = '0.26.21';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
+139
-10
@@ -12,6 +12,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -20,6 +21,67 @@ import (
|
|||||||
// document of the user's QSOs (optionally only confirmed ones).
|
// document of the user's QSOs (optionally only confirmed ones).
|
||||||
const lotwReportURL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
|
const lotwReportURL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
|
||||||
|
|
||||||
|
const (
|
||||||
|
// How long LoTW may take to START answering. It builds the whole report
|
||||||
|
// before sending anything, so this is the slow part of a big account.
|
||||||
|
lotwHeaderTimeout = 10 * time.Minute
|
||||||
|
// How long the transfer may stall once it HAS started. A download that has
|
||||||
|
// not moved in this long is not slow, it is dead — and saying so beats a
|
||||||
|
// progress window that sits at "working" until someone gives up.
|
||||||
|
lotwIdleTimeout = 2 * time.Minute
|
||||||
|
lotwMaxBytes = 256 * 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
// readWithProgress reads the body in chunks, reporting the running total and
|
||||||
|
// failing fast on a stall.
|
||||||
|
//
|
||||||
|
// Reported as it arrives rather than at the end: an 18 MB report over a slow
|
||||||
|
// link is minutes of silence otherwise, which is indistinguishable from a hang —
|
||||||
|
// and that is exactly what operators were reporting.
|
||||||
|
func say(note func(string), msg string) {
|
||||||
|
if note != nil {
|
||||||
|
note(msg)
|
||||||
|
}
|
||||||
|
LogSink("%s", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readWithProgress(ctx context.Context, r io.Reader, note func(string)) ([]byte, error) {
|
||||||
|
var (
|
||||||
|
out []byte
|
||||||
|
total int64
|
||||||
|
last = time.Now()
|
||||||
|
buf = make([]byte, 64*1024)
|
||||||
|
next = int64(256 * 1024) // first report early — proof it is moving
|
||||||
|
)
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
n, err := r.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
out = append(out, buf[:n]...)
|
||||||
|
total += int64(n)
|
||||||
|
last = time.Now()
|
||||||
|
if total >= next {
|
||||||
|
say(note, fmt.Sprintf(" … %.1f MB received", float64(total)/(1024*1024)))
|
||||||
|
next = total + 512*1024
|
||||||
|
}
|
||||||
|
if total >= lotwMaxBytes {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == io.EOF {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if time.Since(last) > lotwIdleTimeout {
|
||||||
|
return nil, fmt.Errorf("the transfer stalled after %d KB — LoTW stopped sending", total/1024)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DownloadLoTWConfirmations fetches confirmed QSOs from LoTW as ADIF text.
|
// DownloadLoTWConfirmations fetches confirmed QSOs from LoTW as ADIF text.
|
||||||
// Uses the LoTW *website* login (Username/Password), not the TQSL cert. When
|
// Uses the LoTW *website* login (Username/Password), not the TQSL cert. When
|
||||||
// since is non-empty (YYYY-MM-DD) only confirmations received since then are
|
// since is non-empty (YYYY-MM-DD) only confirmations received since then are
|
||||||
@@ -27,7 +89,7 @@ const lotwReportURL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
|
|||||||
// non-empty, only confirmations for that station callsign are returned (an
|
// non-empty, only confirmations for that station callsign are returned (an
|
||||||
// LoTW account holds every call you operate — F4BPO, F4BPO/P, TM2Q — so this
|
// LoTW account holds every call you operate — F4BPO, F4BPO/P, TM2Q — so this
|
||||||
// scopes the pull to the active profile's call).
|
// scopes the pull to the active profile's call).
|
||||||
func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since, ownCall string) (string, error) {
|
func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since, ownCall string, note func(string)) (string, error) {
|
||||||
user := strings.TrimSpace(cfg.Username)
|
user := strings.TrimSpace(cfg.Username)
|
||||||
if user == "" || cfg.Password == "" {
|
if user == "" || cfg.Password == "" {
|
||||||
return "", fmt.Errorf("lotw: website login (username/password) not set")
|
return "", fmt.Errorf("lotw: website login (username/password) not set")
|
||||||
@@ -57,20 +119,87 @@ func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg Ser
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("lotw: build request: %w", err)
|
return "", fmt.Errorf("lotw: build request: %w", err)
|
||||||
}
|
}
|
||||||
|
// Named, because LoTW's front end throttles unidentified clients harder than
|
||||||
|
// it throttles known ones, and an operator reporting a 503 deserves a request
|
||||||
|
// that says who is asking.
|
||||||
|
req.Header.Set("User-Agent", "OpsLog")
|
||||||
if client == nil {
|
if client == nil {
|
||||||
// A full account is tens of megabytes and LoTW builds it slowly — several
|
// NO overall deadline. A full account is tens of megabytes and LoTW spends
|
||||||
// minutes for a log of 30 000 QSOs, all of it before the first byte. The
|
// minutes building it before the first byte; a total timeout turns a slow
|
||||||
// old two-minute limit turned that into "context deadline exceeded while
|
// but healthy download into "context deadline exceeded", and a longer one
|
||||||
// reading body", which reads as a network fault rather than as "ask for
|
// turns a dead connection into a window that says "working" for twenty
|
||||||
// less at a time".
|
// minutes. What matters is not how long it takes but whether it is still
|
||||||
client = &http.Client{Timeout: 20 * time.Minute}
|
// moving — see the idle watchdog below.
|
||||||
|
client = &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
ResponseHeaderTimeout: lotwHeaderTimeout,
|
||||||
|
TLSHandshakeTimeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// LoTW answers 503 when it is busy, which for a report covering more than a
|
||||||
|
// few days is often — other loggers get the same answer and simply ask
|
||||||
|
// again. Three tries, spaced, and each one said out loud: an operator whose
|
||||||
|
// download takes four minutes because the ARRL is loaded should be able to
|
||||||
|
// see that rather than guess it.
|
||||||
|
// LoTW sends nothing at all until the whole report is built — minutes for a
|
||||||
|
// large account. That silence was the entire complaint: a window saying
|
||||||
|
// "working" with no way to tell a busy server from a dead one. Count it out
|
||||||
|
// loud until the first byte.
|
||||||
|
beat := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
start := time.Now()
|
||||||
|
tick := time.NewTicker(15 * time.Second)
|
||||||
|
defer tick.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-beat:
|
||||||
|
return
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
say(note, fmt.Sprintf(" … still waiting for LoTW to build the report (%.0f s)", time.Since(start).Seconds()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// Stopped where the WAIT ends, not where the function does: deferred, the
|
||||||
|
// heartbeat went on counting between the megabyte lines and read as if the
|
||||||
|
// report were still being built while it was already arriving.
|
||||||
|
stopBeat := sync.OnceFunc(func() { close(beat) })
|
||||||
|
defer stopBeat()
|
||||||
|
|
||||||
|
var resp *http.Response
|
||||||
|
for attempt := 1; ; attempt++ {
|
||||||
|
resp, err = client.Do(req) //nolint:bodyclose // closed below or in the retry
|
||||||
|
if err == nil && resp.StatusCode != http.StatusServiceUnavailable &&
|
||||||
|
resp.StatusCode != http.StatusBadGateway && resp.StatusCode != http.StatusGatewayTimeout {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if attempt >= 3 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
wait := time.Duration(attempt*20) * time.Second
|
||||||
|
if resp != nil {
|
||||||
|
say(note, fmt.Sprintf("LoTW is busy (HTTP %d) — asking again in %s…", resp.StatusCode, wait))
|
||||||
|
resp.Body.Close()
|
||||||
|
} else {
|
||||||
|
say(note, fmt.Sprintf("LoTW did not answer (%v) — asking again in %s…", err, wait))
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
case <-time.After(wait):
|
||||||
|
}
|
||||||
|
req = req.Clone(ctx)
|
||||||
}
|
}
|
||||||
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, 256*1024*1024))
|
stopBeat()
|
||||||
|
say(note, fmt.Sprintf("LoTW answered (HTTP %d) — receiving…", resp.StatusCode))
|
||||||
|
body, err := readWithProgress(ctx, resp.Body, note)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("lotw: read response: %w", err)
|
return "", fmt.Errorf("lotw: read response: %w", err)
|
||||||
}
|
}
|
||||||
@@ -378,7 +507,7 @@ func TestLoTW(cfg ServiceConfig, stationDataPath string) (string, error) {
|
|||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if _, err := DownloadLoTWConfirmations(ctx, nil, cfg, "2099-01-01", ""); err != nil {
|
if _, err := DownloadLoTWConfirmations(ctx, nil, cfg, "2099-01-01", "", nil); err != nil {
|
||||||
return "", fmt.Errorf("%s — but the DOWNLOAD login failed: %w", up, err)
|
return "", fmt.Errorf("%s — but the DOWNLOAD login failed: %w", up, err)
|
||||||
}
|
}
|
||||||
return up + ". Download login accepted.", nil
|
return up + ". Download login accepted.", nil
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.26.20"
|
appVersion = "0.26.21"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user