Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9846354bf9 |
@@ -9121,6 +9121,49 @@ func (a *App) GetLogFilePath() string {
|
||||
return applog.Path()
|
||||
}
|
||||
|
||||
// TailLogFile returns the END of the diagnostic log — the last maxBytes, cut
|
||||
// back to a line boundary so the first line is never a fragment.
|
||||
//
|
||||
// The tail, not the file: it rotates at 10 MB, and the live viewer polls this
|
||||
// every second. Shipping megabytes across the Wails bridge on a timer would cost
|
||||
// more than the whole rest of the UI.
|
||||
func (a *App) TailLogFile(maxBytes int) (string, error) {
|
||||
p := applog.Path()
|
||||
if p == "" {
|
||||
return "", fmt.Errorf("log file not initialised")
|
||||
}
|
||||
if maxBytes <= 0 || maxBytes > 4*1024*1024 {
|
||||
maxBytes = 256 * 1024
|
||||
}
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
start := int64(0)
|
||||
if fi.Size() > int64(maxBytes) {
|
||||
start = fi.Size() - int64(maxBytes)
|
||||
}
|
||||
if _, err := f.Seek(start, io.SeekStart); err != nil {
|
||||
return "", err
|
||||
}
|
||||
buf, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := string(buf)
|
||||
if start > 0 {
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = s[i+1:]
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// UILog lets the frontend write to the same diagnostic log as the backend.
|
||||
//
|
||||
// Frontend-only logic (entry-strip auto-fill, debounce ordering, which field the
|
||||
@@ -11377,13 +11420,18 @@ func (a *App) consumeUDPEvents() {
|
||||
// after the configured duration. De-duped per call in the Flex backend.
|
||||
if a.catFlexDecodeSpots && a.cat != nil {
|
||||
now := time.Now()
|
||||
if t, seen := lastDecodeSpot[ev.DecodeCall]; seen && now.Sub(t) < decodeSpotWindow {
|
||||
// Key by call AND 100 kHz slot, not by call alone: with two
|
||||
// receivers running (50.313 and 50.400, say) a station heard on
|
||||
// both is two legitimate spots on two panadapters, and a
|
||||
// call-only key threw the second away.
|
||||
key := fmt.Sprintf("%s@%d", ev.DecodeCall, ev.DecodeFreqHz/100_000)
|
||||
if t, seen := lastDecodeSpot[key]; seen && now.Sub(t) < decodeSpotWindow {
|
||||
continue // spotted this call very recently — don't re-hammer the radio
|
||||
}
|
||||
if len(lastDecodeSpot) > 4000 {
|
||||
lastDecodeSpot = map[string]time.Time{} // bound memory on long sessions
|
||||
}
|
||||
lastDecodeSpot[ev.DecodeCall] = now
|
||||
lastDecodeSpot[key] = now
|
||||
secs := a.catFlexDecodeSecs
|
||||
if secs <= 0 {
|
||||
secs = 120
|
||||
|
||||
@@ -1,4 +1,24 @@
|
||||
[
|
||||
{
|
||||
"version": "0.23.2",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Lookup: the locator now keeps whichever is more precise. A 4-character square from QRZ or HamQTH no longer overwrites the full 6-character locator confirmed on a previous contact.",
|
||||
"LoTW badge: when ARRL's own user list has not been rebuilt for a while, the tooltip says so with its date — a station shown as \"uploaded 7 days ago\" may simply be sitting in a frozen list.",
|
||||
"FlexRadio decode spots: with two WSJT-X instances on two slices, decodes are no longer placed on the wrong panadapter, and a station heard on both is now spotted on both.",
|
||||
"DX cluster: a spot glued to the login prompt is no longer dropped. Clusters that print the prompt without a newline cost you the first spot after connecting.",
|
||||
"Help → Diagnostic log: read the log live, without leaving OpsLog. Filter the lines, and follow the end or scroll back freely.",
|
||||
"Entity stats: the DX station's sunrise and sunset (UTC) now show on the right, next to Most Wanted. Midnight sun and polar night are named as such."
|
||||
],
|
||||
"fr": [
|
||||
"Recherche : le locator garde désormais le plus précis des deux. Un carré en 4 caractères venant de QRZ ou HamQTH n'écrase plus le locator complet en 6 caractères confirmé lors d'un contact précédent.",
|
||||
"Badge LoTW : quand la liste ARRL elle-même n'a pas été régénérée depuis un moment, l'infobulle le dit avec sa date — une station annoncée « dernier envoi il y a 7 jours » peut simplement figurer dans une liste figée.",
|
||||
"Spots de décodes FlexRadio : avec deux instances WSJT-X sur deux slices, les décodes ne s'affichent plus sur le mauvais panadapter, et une station entendue sur les deux est désormais spottée sur les deux.",
|
||||
"Cluster DX : un spot collé à l'invite de connexion n'est plus perdu. Les clusters qui affichent leur invite sans retour à la ligne coûtaient le premier spot après la connexion.",
|
||||
"Aide → Journal de diagnostic : lire le journal en direct sans quitter OpsLog. Filtrage des lignes, et suivi de la fin qu'on peut relâcher pour remonter.",
|
||||
"Statistiques d'entité : le lever et le coucher du soleil de la station DX (UTC) s'affichent à droite, à côté du Most Wanted. Soleil de minuit et nuit polaire sont nommés."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.23.1",
|
||||
"date": "",
|
||||
|
||||
+26
-6
@@ -120,6 +120,7 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { pathBetween, pathBetweenLatLon, gridToLatLon, latLonToGrid } from '@/lib/maidenhead';
|
||||
import { flagURL } from '@/lib/flags';
|
||||
import { LogViewer } from '@/components/LogViewer';
|
||||
|
||||
type QSO = QSOForm;
|
||||
type ImportResult = adifModels.ImportResult;
|
||||
@@ -547,7 +548,7 @@ export default function App() {
|
||||
}, []);
|
||||
// LoTW-user lookup for the entered callsign (from the cached ARRL activity list),
|
||||
// debounced. Drives a colour-coded LoTW badge by last-upload recency.
|
||||
const [lotwInfo, setLotwInfo] = useState<{ is_user: boolean; last_upload?: string; days_ago: number } | null>(null);
|
||||
const [lotwInfo, setLotwInfo] = useState<{ is_user: boolean; last_upload?: string; days_ago: number; feed_date?: string; feed_stale?: boolean } | null>(null);
|
||||
useEffect(() => {
|
||||
const c = callsign.trim();
|
||||
if (!c) { setLotwInfo(null); return; }
|
||||
@@ -1547,6 +1548,7 @@ export default function App() {
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
// "What's new": the changelog for the version(s) since the operator last ran,
|
||||
// shown once on the first launch after an update (EN/FR per the UI language).
|
||||
const [showLogViewer, setShowLogViewer] = useState(false);
|
||||
const [whatsNew, setWhatsNew] = useState<Array<{ version: string; date: string; en: string[]; fr: string[] }> | null>(null);
|
||||
// Which language the What's-new modal shows — defaults to the UI language but
|
||||
// toggleable, so a French operator running the English UI can still read it.
|
||||
@@ -3293,10 +3295,16 @@ export default function App() {
|
||||
if (!ue.has('name') && empty(r?.name) && last.name) setName(last.name);
|
||||
if (!ue.has('qth') && empty(r?.qth) && last.qth) setQth(last.qth);
|
||||
if (!ue.has('country') && empty(r?.country) && last.country) setCountry(last.country);
|
||||
// Grid: a REAL provider grid always wins. Otherwise the last QSO's precise
|
||||
// locator beats the coarse cty.dat entity centroid the provider block set — so
|
||||
// a French call resolves to its real JNxx, not the country's JN16 centroid.
|
||||
const adoptLastGrid = !ue.has('grid') && empty(r?.grid) && !!last.grid;
|
||||
// Grid: the provider (QRZ / HamQTH) leads, but only while it is at least as
|
||||
// PRECISE as what we already worked. It used to lead whenever it answered at
|
||||
// all, so a 4-character square — which is what a provider returns when the
|
||||
// station only published a rough locator, and what the cty.dat entity
|
||||
// centroid gives — overwrote the full 6-character locator confirmed on a
|
||||
// previous contact. Comparing lengths keeps the finer of the two, whichever
|
||||
// side it came from.
|
||||
const provGrid = String(r?.grid ?? '').trim();
|
||||
const lastGrid = String(last.grid ?? '').trim();
|
||||
const adoptLastGrid = !ue.has('grid') && !!lastGrid && lastGrid.length > provGrid.length;
|
||||
if (adoptLastGrid) setGrid(last.grid);
|
||||
setDetails((d) => {
|
||||
// When we adopt the last QSO's locator, take its coordinates too (derive them
|
||||
@@ -3618,6 +3626,8 @@ export default function App() {
|
||||
{ type: 'item' as const, label: sendingLog ? t('help.sendLogBusy') : t('help.sendLog'), action: 'help.sendlog', disabled: sendingLog },
|
||||
] : []),
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: t('logview.title'), action: 'help.log' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: t('help.donate'), action: 'help.donate', accent: true },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
||||
@@ -3651,6 +3661,7 @@ export default function App() {
|
||||
case 'tools.refreshCty': refreshCtyDat(); break;
|
||||
case 'tools.downloadRefs': downloadRefs(); break;
|
||||
case 'help.about': setShowAbout(true); checkUpdateNow(); break;
|
||||
case 'help.log': setShowLogViewer(true); break;
|
||||
case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
|
||||
case 'help.sendlog': sendLogToDeveloper(); break;
|
||||
// Opens in the system browser, NOT the app WebView: a payment page must show
|
||||
@@ -4059,8 +4070,14 @@ export default function App() {
|
||||
const cls = d >= 0 && d < 7 ? 'border-success text-success bg-success/15'
|
||||
: d < 30 ? 'border-warning text-warning bg-warning/15'
|
||||
: 'border-danger text-danger bg-danger/15';
|
||||
// ARRL regenerates lotw-user-activity.csv on its own schedule and has been
|
||||
// seen frozen for a week (2026-08-02: newest line 2026-07-27). "Uploaded 7
|
||||
// days ago" then says nothing about the station, so the tooltip names the
|
||||
// day the feed itself was built whenever that day is not recent.
|
||||
const tip = t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })
|
||||
+ (lotwInfo.feed_stale && lotwInfo.feed_date ? ' — ' + t('lotw.feedStale', { date: lotwInfo.feed_date }) : '');
|
||||
return (
|
||||
<div className="shrink-0" title={t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })}>
|
||||
<div className="shrink-0" title={tip}>
|
||||
<span className={cn('inline-flex items-center rounded-md border px-1.5 py-1 text-[9px] font-extrabold tracking-wide leading-none', cls)}>
|
||||
LoTW
|
||||
</span>
|
||||
@@ -5248,6 +5265,9 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live diagnostic log (Help → …). Polls only while open. */}
|
||||
<LogViewer open={showLogViewer} onOpenChange={setShowLogViewer} />
|
||||
|
||||
{/* What's new — shown once on the first launch after an update. */}
|
||||
{whatsNew && (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={() => setWhatsNew(null)}>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Star, Radio } from 'lucide-react';
|
||||
import { Star, Radio, Sunrise, Sunset } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sunTimes } from '@/lib/sun';
|
||||
import type { WorkedBeforeView } from '@/types';
|
||||
|
||||
type WorkedBefore = WorkedBeforeView;
|
||||
@@ -13,6 +14,10 @@ interface Props {
|
||||
currentMode: string;
|
||||
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
|
||||
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
|
||||
// DX station coordinates, for its sunrise/sunset. Optional: many spots resolve
|
||||
// to an entity with no position at all, and the block simply does not appear.
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
}
|
||||
|
||||
// Compact column label for a band tag: keep the classic V/U for 2m/70cm,
|
||||
@@ -84,7 +89,7 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
|
||||
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
||||
}
|
||||
|
||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true }: Props) {
|
||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon }: Props) {
|
||||
// Columns from the operator's configured bands (so the matrix shows only the
|
||||
// bands they actually use), falling back to the built-in default set.
|
||||
const cols = useMemo(
|
||||
@@ -151,6 +156,36 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
</Badge>
|
||||
) : null;
|
||||
|
||||
// Sunrise / sunset AT THE DX, in UTC. A glance tells you whether the path is
|
||||
// about to open or close on their side, which is the reason to look at a DX
|
||||
// station's grey line at all. Recomputed only when the position changes.
|
||||
const sun = useMemo(
|
||||
() => (lat == null || lon == null ? null : sunTimes(new Date(), lat, lon)),
|
||||
[lat, lon],
|
||||
);
|
||||
const sunBlock = sun ? (
|
||||
<div className="ml-auto flex items-center gap-3 text-xs shrink-0"
|
||||
title="Sunrise / sunset at the DX station (UTC)">
|
||||
{sun.polarDay ? (
|
||||
<span className="font-semibold text-warning">midnight sun</span>
|
||||
) : sun.polarNight ? (
|
||||
<span className="font-semibold text-info">polar night</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex items-center gap-1">
|
||||
<Sunrise className="size-3.5 text-warning" />
|
||||
<span className="font-mono tabular-nums">{sun.rise || '—'}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Sunset className="size-3.5 text-info" />
|
||||
<span className="font-mono tabular-nums">{sun.set || '—'}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">UTC</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
@@ -158,7 +193,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-[220px]">
|
||||
<div className="flex items-center gap-2 min-w-[220px] flex-1">
|
||||
{newOne ? (
|
||||
<>
|
||||
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
|
||||
@@ -207,6 +242,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
Type a callsign to see entity stats
|
||||
</span>
|
||||
)}
|
||||
{sunBlock}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -215,6 +215,15 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
||||
}
|
||||
return null;
|
||||
}, [operatorGrid, remoteGrid, details.lat, details.lon]);
|
||||
// Where the DX actually is, for its sunrise/sunset. The locator wins when we
|
||||
// have one — it is what the operator can see and check — and the provider's
|
||||
// raw coordinates cover entities that resolve through cty.dat with no grid.
|
||||
const dxLL = useMemo(() => {
|
||||
const byGrid = gridToLatLon(remoteGrid);
|
||||
if (byGrid) return byGrid;
|
||||
if (details.lat != null && details.lon != null) return { lat: details.lat, lon: details.lon };
|
||||
return null;
|
||||
}, [remoteGrid, details.lat, details.lon]);
|
||||
const fmtDeg = (n: number) => `${Math.round(n)}°`;
|
||||
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
|
||||
|
||||
@@ -263,7 +272,8 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
||||
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
|
||||
{open === 'stats' && (
|
||||
<div className="px-3 py-2.5">
|
||||
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
|
||||
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''}
|
||||
lat={dxLL?.lat} lon={dxLL?.lon} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { TailLogFile, GetLogFilePath } from '../../wailsjs/go/main/App';
|
||||
|
||||
// LogViewer shows the tail of opslog.log and keeps it current while open.
|
||||
//
|
||||
// This exists because diagnosing a problem used to mean asking the operator to
|
||||
// find a file in their data directory, open it in a text editor, and re-open it
|
||||
// after every attempt. Half the round-trips in a bug report were spent on that.
|
||||
//
|
||||
// Three behaviours matter more than they look:
|
||||
// • Auto-scroll is a checkbox the operator owns, AND it releases itself the
|
||||
// moment they scroll up — otherwise the line they are reading is yanked away
|
||||
// a second later, which defeats the one thing this window is for. Scrolling
|
||||
// back to the bottom re-arms it, so following is never a dead end.
|
||||
// • The filter keeps only matching LINES rather than highlighting inside a
|
||||
// 256 KB blob: with "cluster" or "acom" typed in, the window becomes exactly
|
||||
// the subsystem trace you would have grepped for.
|
||||
// • It polls only while open. A closed dialog costs nothing.
|
||||
const TAIL_BYTES = 256 * 1024;
|
||||
const POLL_MS = 1000;
|
||||
|
||||
export function LogViewer({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
||||
const { t } = useI18n();
|
||||
const [text, setText] = useState('');
|
||||
const [path, setPath] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
// Shown next to the checkbox so a QUIET log still proves it is being polled —
|
||||
// without it there is no way to tell 'nothing new' from 'window is dead'.
|
||||
const [lastPull, setLastPull] = useState('');
|
||||
const boxRef = useRef<HTMLPreElement>(null);
|
||||
const autoRef = useRef(true);
|
||||
useEffect(() => { autoRef.current = autoScroll; }, [autoScroll]);
|
||||
|
||||
// stick pins the view to the bottom on the NEXT frame. Setting scrollTop
|
||||
// straight away is not enough while the dialog is still animating in: the box
|
||||
// has no height yet, scrollHeight is wrong, and the scroll silently does
|
||||
// nothing — leaving the operator staring at the top of a 256 KB buffer, i.e.
|
||||
// at lines from hours ago, which looks exactly like a frozen window.
|
||||
const stick = useCallback(() => {
|
||||
if (!autoRef.current) return;
|
||||
requestAnimationFrame(() => {
|
||||
const el = boxRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pull = useCallback(() => {
|
||||
TailLogFile(TAIL_BYTES).then((s: any) => {
|
||||
setText(String(s ?? ''));
|
||||
setLastPull(new Date().toLocaleTimeString());
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setAutoScroll(true);
|
||||
autoRef.current = true;
|
||||
GetLogFilePath().then((p: any) => setPath(String(p ?? ''))).catch(() => {});
|
||||
pull();
|
||||
// Re-assert the bottom after the open animation has settled. A quiet log
|
||||
// never changes, so the content-driven scroll below would never fire again
|
||||
// and the first attempt would be the only one.
|
||||
const t1 = window.setTimeout(stick, 120);
|
||||
const t2 = window.setTimeout(stick, 450);
|
||||
const id = window.setInterval(pull, POLL_MS);
|
||||
return () => { window.clearInterval(id); window.clearTimeout(t1); window.clearTimeout(t2); };
|
||||
}, [open, pull, stick]);
|
||||
|
||||
const all = useMemo(() => text.split('\n'), [text]);
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return all;
|
||||
return all.filter((l) => l.toLowerCase().includes(q));
|
||||
}, [all, query]);
|
||||
|
||||
// Stick to the bottom after each refresh, but only while auto-scroll is on.
|
||||
useEffect(() => { if (open) stick(); }, [shown, open, stick]);
|
||||
|
||||
// Scrolling away from the bottom releases auto-scroll; coming back re-arms it.
|
||||
function onScroll() {
|
||||
const el = boxRef.current;
|
||||
if (!el) return;
|
||||
setAutoScroll(el.scrollHeight - el.scrollTop - el.clientHeight < 24);
|
||||
}
|
||||
|
||||
const body = shown.join('\n');
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('logview.title')}</DialogTitle>
|
||||
<DialogDescription className="font-mono text-[11px] break-all">{path}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="h-8 flex-1 font-mono text-xs" value={query} placeholder={t('logview.searchPh')}
|
||||
onChange={(e) => setQuery(e.target.value)} />
|
||||
{query.trim() !== '' && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
|
||||
{t('logview.matches', { n: shown.length, total: all.length })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<pre ref={boxRef} onScroll={onScroll}
|
||||
className="h-[60vh] overflow-auto rounded-md border border-border bg-muted/30 p-2 text-[11px] leading-snug font-mono whitespace-pre-wrap break-all">
|
||||
{body.trim() === '' ? t('logview.empty') : body}
|
||||
</pre>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<Checkbox checked={autoScroll} onCheckedChange={(c) => {
|
||||
const on = !!c;
|
||||
setAutoScroll(on);
|
||||
if (on) { const el = boxRef.current; if (el) el.scrollTop = el.scrollHeight; }
|
||||
}} />
|
||||
{t('logview.autoScroll')}
|
||||
</label>
|
||||
{lastPull && <span className="text-xs text-muted-foreground tabular-nums">{t('logview.updated', { time: lastPull })}</span>}
|
||||
<div className="flex-1" />
|
||||
<Button variant="outline" size="sm" onClick={() => navigator.clipboard?.writeText(body)}>
|
||||
{t('logview.copy')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => { setQuery(''); setAutoScroll(true); pull(); }}>
|
||||
{t('logview.toEnd')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ const en: Dict = {
|
||||
'upd.restartNote': 'OpsLog will restart on the new version.',
|
||||
'upd.retry': 'Retry', 'upd.browser': 'Open page', 'upd.checking': 'Checking for updates…', 'upd.upToDate': "You're up to date",
|
||||
'rate.title': 'QSO rate (QSOs/hour) — projected from the last 10 / 60 minutes',
|
||||
'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)',
|
||||
'lotw.feedStale': "ARRL's user list itself was last built on {date}, so this figure may be older than the station", 'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)',
|
||||
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
|
||||
'file.import': 'Import ADIF…', 'file.export': 'Export ADIF…', 'file.exporting': 'Exporting…',
|
||||
'file.exportCabrillo': 'Export Cabrillo…', 'file.deleteAll': 'Delete all QSOs…', 'file.exit': 'Exit',
|
||||
@@ -40,7 +40,7 @@ const en: Dict = {
|
||||
'cwd.tipOff': 'CW decoder · click to enable (decodes RX audio in CW mode)',
|
||||
'tools.net': 'NET Control', 'tools.alerts': 'Alert management…', 'tools.contest': 'Contest mode',
|
||||
'alert.tuneHint': 'Click to tune the rig to this spot (freq + mode) and fill the call', 'alert.dismiss': 'Dismiss', 'alert.pending': '{n} recent spot alert(s) — click to view', 'alert.noneShort': 'No recent alerts', 'alert.recent': 'Recent alerts', 'alert.clear': 'Clear',
|
||||
'menu.help': 'Help', 'prof.switchTitle': 'Switch station profile', 'prof.manage': 'Manage profiles…', 'help.about': 'About OpsLog', 'help.donate': '♥ Support OpsLog (donate)', 'tools.duplicates': 'Find duplicates…',
|
||||
'menu.help': 'Help', 'prof.switchTitle': 'Switch station profile', 'prof.manage': 'Manage profiles…', 'logview.title': "Diagnostic log", 'logview.empty': "(log is empty)", 'logview.searchPh': "Filter lines — e.g. cluster, acom, cat", 'logview.matches': "{n} of {total} lines", 'logview.autoScroll': "Follow the end", 'logview.updated': "refreshed {time}", 'logview.copy': "Copy", 'logview.toEnd': "Jump to end", 'help.about': 'About OpsLog', 'help.donate': '♥ Support OpsLog (donate)', 'tools.duplicates': 'Find duplicates…',
|
||||
'help.sendLog': 'Send log to F4BPO', 'help.sendLogBusy': 'Sending log…',
|
||||
'help.sendLogOk': 'Log sent to F4BPO — thanks, 73!', 'help.sendLogFail': 'Could not send log: {err}',
|
||||
// Duplicates modal
|
||||
@@ -445,7 +445,7 @@ const fr: Dict = {
|
||||
'live.offlineTip': 'Hors ligne — aucun QSO loggé depuis 5 minutes',
|
||||
'live.stationsTitle': 'Stations on air', 'live.stationsEmpty': 'Aucune station ne reporte pour le moment.', 'live.stationsHide': 'Masquer',
|
||||
'rate.title': 'Rythme QSO (QSO/heure) — projeté sur les 10 / 60 dernières minutes',
|
||||
'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)',
|
||||
'lotw.feedStale': "la liste ARRL elle-même date du {date}, ce chiffre peut donc être plus vieux que la station", 'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)',
|
||||
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
|
||||
'file.import': 'Importer ADIF…', 'file.export': 'Exporter ADIF…', 'file.exporting': 'Export…',
|
||||
'file.exportCabrillo': 'Exporter Cabrillo…', 'file.deleteAll': 'Supprimer tous les QSO…', 'file.exit': 'Quitter',
|
||||
@@ -461,7 +461,7 @@ const fr: Dict = {
|
||||
'cwd.tipOff': 'Décodeur CW · clic pour activer (décode l’audio RX en mode CW)',
|
||||
'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.contest': 'Mode contest',
|
||||
'alert.tuneHint': 'Cliquer pour accorder la radio sur ce spot (fréq + mode) et remplir l\'indicatif', 'alert.dismiss': 'Fermer', 'alert.pending': '{n} alerte(s) de spot récente(s) — cliquer pour voir', 'alert.noneShort': 'Aucune alerte récente', 'alert.recent': 'Alertes récentes', 'alert.clear': 'Effacer',
|
||||
'menu.help': 'Aide', 'prof.switchTitle': 'Changer de profil de station', 'prof.manage': 'Gérer les profils…', 'help.about': 'À propos d\'OpsLog', 'help.donate': '♥ Soutenir OpsLog (don)', 'tools.duplicates': 'Trouver les doublons…',
|
||||
'menu.help': 'Aide', 'prof.switchTitle': 'Changer de profil de station', 'prof.manage': 'Gérer les profils…', 'logview.title': "Journal de diagnostic", 'logview.empty': "(journal vide)", 'logview.searchPh': "Filtrer les lignes — ex. cluster, acom, cat", 'logview.matches': "{n} lignes sur {total}", 'logview.autoScroll': "Suivre la fin", 'logview.updated': "actualisé {time}", 'logview.copy': "Copier", 'logview.toEnd': "Aller à la fin", 'help.about': 'À propos d\'OpsLog', 'help.donate': '♥ Soutenir OpsLog (don)', 'tools.duplicates': 'Trouver les doublons…',
|
||||
'help.sendLog': 'Envoyer le journal à F4BPO', 'help.sendLogBusy': 'Envoi du journal…',
|
||||
'help.sendLogOk': 'Journal envoyé à F4BPO — merci, 73 !', 'help.sendLogFail': 'Échec de l\'envoi du journal : {err}',
|
||||
'dup.title': 'Trouver les doublons', 'dup.scanning': 'Analyse du journal…', 'dup.none': 'Aucun doublon trouvé. 🎉',
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Sunrise / sunset for a station, in UTC.
|
||||
//
|
||||
// Built on greyline.ts's sunPosition() rather than on a second implementation of
|
||||
// the solar equations: the map's terminator and the entry strip's sunrise must
|
||||
// never disagree, and there is a whole comment block over there about a sign
|
||||
// error that produced six months of plausible-looking wrong answers. One source
|
||||
// of astronomy, one place to be wrong.
|
||||
//
|
||||
// Everything is UTC. Amateur radio runs on UTC, the log is in UTC, and a local
|
||||
// time would raise the question "local to whom — me or the DX?".
|
||||
import { sunPosition } from './greyline';
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
// Standard altitude of the Sun's centre at rise/set: half a solar diameter below
|
||||
// the horizon plus atmospheric refraction.
|
||||
const H0 = -0.833;
|
||||
|
||||
// solarAltitude returns the Sun's elevation in degrees at a place and instant.
|
||||
function solarAltitude(date: Date, latDeg: number, lonDeg: number): number {
|
||||
const { dec, gha } = sunPosition(date);
|
||||
const ha = (gha + lonDeg) * DEG; // local hour angle
|
||||
const d = dec * DEG;
|
||||
const lat = latDeg * DEG;
|
||||
return Math.asin(Math.sin(lat) * Math.sin(d) + Math.cos(lat) * Math.cos(d) * Math.cos(ha)) / DEG;
|
||||
}
|
||||
|
||||
export type SunTimes = {
|
||||
rise: string; // "HH:MM" UTC, "" when the Sun does not rise that day
|
||||
set: string;
|
||||
polarDay: boolean; // Sun stays up all day
|
||||
polarNight: boolean; // Sun never comes up
|
||||
};
|
||||
|
||||
// sunTimes scans the UTC day a minute at a time and reports where the Sun
|
||||
// crosses the rise/set altitude.
|
||||
//
|
||||
// A scan, not the closed-form hour-angle formula, because the closed form needs
|
||||
// special-casing for the polar cases and for days where the declination shifts
|
||||
// enough to matter, and it silently returns NaN rather than saying "no sunrise
|
||||
// here today". 1440 evaluations is nothing, and the failure mode is honest: no
|
||||
// crossing found means no sunrise, which is a real answer above the Arctic
|
||||
// circle. The crossing minute is then refined by linear interpolation, so the
|
||||
// result is accurate to well under a minute despite the coarse scan.
|
||||
export function sunTimes(date: Date, latDeg: number, lonDeg: number): SunTimes {
|
||||
const out: SunTimes = { rise: '', set: '', polarDay: false, polarNight: false };
|
||||
if (!isFinite(latDeg) || !isFinite(lonDeg)) return out;
|
||||
|
||||
const start = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
||||
const minute = 60000;
|
||||
let prev = solarAltitude(new Date(start), latDeg, lonDeg);
|
||||
const anyUp = prev > H0;
|
||||
let anyDown = prev <= H0;
|
||||
let sawUp = anyUp;
|
||||
|
||||
for (let i = 1; i <= 1440; i++) {
|
||||
const alt = solarAltitude(new Date(start + i * minute), latDeg, lonDeg);
|
||||
if (alt > H0) sawUp = true; else anyDown = true;
|
||||
if (prev <= H0 && alt > H0 && !out.rise) {
|
||||
out.rise = fmt(start + (i - 1 + frac(prev, alt)) * minute);
|
||||
} else if (prev > H0 && alt <= H0 && !out.set) {
|
||||
out.set = fmt(start + (i - 1 + frac(prev, alt)) * minute);
|
||||
}
|
||||
prev = alt;
|
||||
}
|
||||
if (!out.rise && !out.set) {
|
||||
out.polarDay = sawUp && !anyDown;
|
||||
out.polarNight = !sawUp;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// frac is where between two samples the altitude crosses H0.
|
||||
function frac(a: number, b: number): number {
|
||||
const d = b - a;
|
||||
return d === 0 ? 0 : (H0 - a) / d;
|
||||
}
|
||||
|
||||
function fmt(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.23.1';
|
||||
export const APP_VERSION = '0.23.2';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+2
@@ -989,6 +989,8 @@ export function SwitchCATRig(arg1:number):Promise<void>;
|
||||
|
||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||
|
||||
export function TailLogFile(arg1:number):Promise<string>;
|
||||
|
||||
export function TestCloudlogUpload():Promise<string>;
|
||||
|
||||
export function TestClublogUpload():Promise<string>;
|
||||
|
||||
@@ -1926,6 +1926,10 @@ export function SyncPOTAHunterLog(arg1, arg2) {
|
||||
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function TailLogFile(arg1) {
|
||||
return window['go']['main']['App']['TailLogFile'](arg1);
|
||||
}
|
||||
|
||||
export function TestCloudlogUpload() {
|
||||
return window['go']['main']['App']['TestCloudlogUpload']();
|
||||
}
|
||||
|
||||
@@ -1442,6 +1442,8 @@ export namespace lotwusers {
|
||||
is_user: boolean;
|
||||
last_upload?: string;
|
||||
days_ago: number;
|
||||
feed_date?: string;
|
||||
feed_stale: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Info(source);
|
||||
@@ -1452,6 +1454,8 @@ export namespace lotwusers {
|
||||
this.is_user = source["is_user"];
|
||||
this.last_upload = source["last_upload"];
|
||||
this.days_ago = source["days_ago"];
|
||||
this.feed_date = source["feed_date"];
|
||||
this.feed_stale = source["feed_stale"];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -648,6 +648,18 @@ func parseShowDX(line string) (Spot, bool) {
|
||||
}
|
||||
|
||||
func parseSpot(line string) (Spot, bool) {
|
||||
// A cluster prints its prompt WITHOUT a trailing newline, so the first spot
|
||||
// to arrive gets glued onto it and the line reads
|
||||
// "login: DX de YO2CK-#: 21074.0 PY4PTO FT8 …". The anchor then fails
|
||||
// and the spot is dropped — silently, apart from an UNPARSED log line.
|
||||
//
|
||||
// Cutting everything ahead of "DX de" rather than un-anchoring the pattern:
|
||||
// an un-anchored match would also accept a talk/announce line that merely
|
||||
// QUOTES a spot, and start injecting other people's conversations as real
|
||||
// spots.
|
||||
if i := strings.Index(line, "DX de"); i > 0 {
|
||||
line = line[i:]
|
||||
}
|
||||
m := spotRE.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
return Spot{}, false
|
||||
|
||||
@@ -14,6 +14,12 @@ func TestParseSpot(t *testing.T) {
|
||||
`DX de DK0SWL: 14195.5 W1AW CQ Field Day 1745Z`,
|
||||
"W1AW", 14195.5, "20m", "",
|
||||
},
|
||||
{
|
||||
// The cluster prints its prompt with no trailing newline, so the
|
||||
// first spot arrives glued to it. Seen live on 2026-08-02.
|
||||
`login: DX de YO2CK-#: 21074.0 PY4PTO FT8 -16 dB 1733Z`,
|
||||
"PY4PTO", 21074.0, "15m", "",
|
||||
},
|
||||
{
|
||||
`DX de F4XYZ-#: 7074.0 3DA0RU FT8 -10 0823Z JN18`,
|
||||
"3DA0RU", 7074.0, "40m", "JN18",
|
||||
|
||||
@@ -86,7 +86,13 @@ type Server struct {
|
||||
stopped bool
|
||||
mu sync.Mutex
|
||||
|
||||
lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets
|
||||
// WSJT: dial frequency from the last Status, added to Decode audio offsets —
|
||||
// keyed by the WSJT-X instance id, NOT one value per listener. Two instances
|
||||
// share one multicast group, so a single value was overwritten by whichever
|
||||
// sent Status last: decodes from the 50.313 receiver were placed on the
|
||||
// 50.400 panadapter. WSJT-X requires --rig-name for a second instance, so the
|
||||
// id is distinct whenever there is more than one.
|
||||
dialHz map[string]int64
|
||||
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
|
||||
}
|
||||
|
||||
@@ -211,15 +217,21 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
// offsets can be turned into RF frequencies for the panadapter.
|
||||
if w.FreqHz > 0 && !w.IsDecode {
|
||||
s.mu.Lock()
|
||||
s.lastDialHz = w.FreqHz
|
||||
if s.dialHz == nil {
|
||||
s.dialHz = map[string]int64{}
|
||||
}
|
||||
s.dialHz[w.ProgramID] = w.FreqHz
|
||||
s.mu.Unlock()
|
||||
}
|
||||
if w.IsDecode {
|
||||
s.mu.Lock()
|
||||
dial := s.lastDialHz
|
||||
dial := s.dialHz[w.ProgramID]
|
||||
s.mu.Unlock()
|
||||
if dial <= 0 {
|
||||
return // no dial freq yet (Status not seen) → can't place the spot
|
||||
// No Status from THIS instance yet. Guessing with another
|
||||
// instance's dial is what produced the wrong-panadapter spots,
|
||||
// so drop the decode and wait — Status arrives every second.
|
||||
return
|
||||
}
|
||||
ev.DecodeCall = w.DecodeCall
|
||||
ev.DecodeFreqHz = dial + w.DeltaFreqHz
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# QRZ.com XML API — what a free account returns, and what a subscription adds
|
||||
|
||||
Captured 2026-08-02 on F4BPO's two accounts, same callsign looked up twice
|
||||
within a minute. Kept because the difference is the answer to a question that
|
||||
comes back regularly: *"someone told me their logger gets the 6-character
|
||||
locator from QRZ without paying"*. It does not.
|
||||
|
||||
## Free account — `SubExp: non-subscriber`
|
||||
|
||||
```xml
|
||||
<Callsign>
|
||||
<call>F4BPO</call>
|
||||
<fname>Gregory</fname>
|
||||
<name>Salaun</name>
|
||||
<addr2>Veigy-Foncenex</addr2>
|
||||
<country>France</country>
|
||||
</Callsign>
|
||||
<Session>
|
||||
<SubExp>non-subscriber</SubExp>
|
||||
<Message>A subscription is required to access the complete record.</Message>
|
||||
</Session>
|
||||
```
|
||||
|
||||
**Five fields.** No `grid`, no `lat`/`lon`, no `dxcc`, no zones, no email, no
|
||||
street, no image.
|
||||
|
||||
## Subscriber — `SubExp: Sat Jul 3 21:45:38 2027`
|
||||
|
||||
```xml
|
||||
<Callsign>
|
||||
<call>F4BPO</call> <dxcc>227</dxcc> <nickname>Greg</nickname>
|
||||
<fname>Gregory</fname> <name>Salaun</name>
|
||||
<addr2>Veigy-Foncenex</addr2><zip>74140</zip> <country>France</country>
|
||||
<lat>46.103333</lat> <lon>6.290000</lon> <grid>JN36dc</grid>
|
||||
<ccode>97</ccode> <land>France</land> <class>2</class>
|
||||
<codes>E</codes> <qslmgr>Lotw, Buro, Direct</qslmgr>
|
||||
<email>…</email> <u_views>56966</u_views>
|
||||
<bio>4414</bio> <biodate>2026-07-08 15:30:58</biodate>
|
||||
<image>https://cdn-xml.qrz.com/o/f4bpo/F4BPO_Greg_png.jpg</image>
|
||||
<imageinfo>800:1200:221041</imageinfo>
|
||||
<moddate>2025-08-24 19:55:31</moddate>
|
||||
<eqsl>0</eqsl> <mqsl>1</mqsl> <lotw>1</lotw>
|
||||
<cqzone>14</cqzone> <ituzone>27</ituzone>
|
||||
<geoloc>user</geoloc> <name_fmt>Gregory "Greg" Salaun</name_fmt>
|
||||
<serial>1000134</serial>
|
||||
</Callsign>
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
- **A free QRZ account can never fill the locator, the coordinates, the zones
|
||||
or the e-mail.** If someone reports a logger doing it on a free account, they
|
||||
are either using HamQTH (free, returns a 6-character `grid`) or they hold an
|
||||
XML subscription without realising it — the QRZ.com account and the "XML
|
||||
Logbook Data" subscription are sold separately. `SubExp` in the session block
|
||||
settles it in one request.
|
||||
- **Profile edits take a few minutes to reach the XML feed.** Changing the grid
|
||||
on the website and looking the callsign up immediately returns the OLD value,
|
||||
which looks exactly like a stale cache. Re-test after a few minutes before
|
||||
suspecting the logger. (This cost an hour on 2026-08-02.)
|
||||
|
||||
## Fields OpsLog does not use yet
|
||||
|
||||
`qrz.go` maps call, name, addr1/2, zip, state, county, country, grid, lat, lon,
|
||||
dxcc, cqzone, ituzone, cont, email, qslmgr and image. Left on the table:
|
||||
|
||||
- **`geoloc`** — `user` when the operator entered the position by hand, versus a
|
||||
geocoded value derived from the postal address. This is a direct statement of
|
||||
how much the coordinates and the locator can be trusted, and it is exactly
|
||||
the question the grid-precision rule in App.tsx tries to answer by comparing
|
||||
string lengths. Worth wiring if the locator ever misbehaves again.
|
||||
- **`eqsl` / `mqsl` / `lotw`** — the station's QSL preferences as flags, more
|
||||
reliable than parsing the free-text `qslmgr`.
|
||||
- `nickname` / `name_fmt`, `class`, `moddate`, `bio`.
|
||||
@@ -29,6 +29,13 @@ type Info struct {
|
||||
IsUser bool `json:"is_user"`
|
||||
LastUpload string `json:"last_upload,omitempty"` // YYYY-MM-DD
|
||||
DaysAgo int `json:"days_ago"` // days since last upload (-1 if unknown)
|
||||
// FeedDate is the newest upload date anywhere in ARRL's file, i.e. when ARRL
|
||||
// last regenerated it, and FeedStale says that date is more than two days old.
|
||||
// Without them "last uploaded 7 days ago" is unreadable: it may mean the
|
||||
// station went quiet, or that the FEED has been frozen for a week — which is
|
||||
// exactly what ARRL served on 2026-08-02, its newest line dated 2026-07-27.
|
||||
FeedDate string `json:"feed_date,omitempty"`
|
||||
FeedStale bool `json:"feed_stale"`
|
||||
}
|
||||
|
||||
// Manager holds the parsed list + cache location.
|
||||
@@ -36,6 +43,7 @@ type Manager struct {
|
||||
mu sync.RWMutex
|
||||
users map[string]time.Time // UPPER(callsign) → last-upload date (UTC)
|
||||
updated time.Time // when the cache was last refreshed
|
||||
newest time.Time // newest upload date in the file = when ARRL built it
|
||||
dir string
|
||||
client *http.Client
|
||||
}
|
||||
@@ -100,6 +108,7 @@ func (m *Manager) Download(ctx context.Context) (int, error) {
|
||||
// parse loads the CSV bytes into the map and returns the count.
|
||||
func (m *Manager) parse(data []byte) int {
|
||||
users := make(map[string]time.Time, 1<<20)
|
||||
var newest time.Time
|
||||
sc := bufio.NewScanner(bytes.NewReader(data))
|
||||
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
@@ -120,16 +129,29 @@ func (m *Manager) parse(data []byte) int {
|
||||
continue
|
||||
}
|
||||
users[call] = t
|
||||
if t.After(newest) {
|
||||
newest = t
|
||||
}
|
||||
}
|
||||
if len(users) == 0 {
|
||||
return 0
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.users = users
|
||||
m.newest = newest
|
||||
m.mu.Unlock()
|
||||
return len(users)
|
||||
}
|
||||
|
||||
// feedInfo fills the feed-age fields shared by every Lookup result.
|
||||
func (m *Manager) feedInfo(i Info) Info {
|
||||
if !m.newest.IsZero() {
|
||||
i.FeedDate = m.newest.Format("2006-01-02")
|
||||
i.FeedStale = time.Since(m.newest) > 48*time.Hour
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// Lookup reports whether callsign is a LoTW user and how long ago it uploaded.
|
||||
// Tries the exact call, then (for portable calls like "EA8/DL1ABC" or "F5ABC/P")
|
||||
// the longest slash-separated segment — the base call LoTW is keyed on.
|
||||
@@ -154,13 +176,13 @@ func (m *Manager) Lookup(callsign string) Info {
|
||||
t, ok = m.users[base]
|
||||
}
|
||||
if !ok {
|
||||
return Info{DaysAgo: -1}
|
||||
return m.feedInfo(Info{DaysAgo: -1})
|
||||
}
|
||||
days := int(time.Since(t).Hours() / 24)
|
||||
if days < 0 {
|
||||
days = 0
|
||||
}
|
||||
return Info{IsUser: true, LastUpload: t.Format("2006-01-02"), DaysAgo: days}
|
||||
return m.feedInfo(Info{IsUser: true, LastUpload: t.Format("2006-01-02"), DaysAgo: days})
|
||||
}
|
||||
|
||||
// Count returns how many callsigns are loaded.
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.23.1"
|
||||
appVersion = "0.23.2"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user