bug
This commit is contained in:
@@ -448,6 +448,9 @@ func (a *App) startup(ctx context.Context) {
|
||||
if _, err := applog.Init(dataDir); err != nil {
|
||||
fmt.Println("OpsLog: log init:", err)
|
||||
}
|
||||
// Route CAT/OmniRig debug lines into the unified app log (they used to go
|
||||
// to a separate cat.log in the old HamLog folder, which users couldn't find).
|
||||
cat.LogSink = applog.Printf
|
||||
applog.Printf("startup: data dir = %s", dataDir)
|
||||
conn, err := db.Open(a.dbPath)
|
||||
if err != nil {
|
||||
@@ -544,11 +547,12 @@ func (a *App) startup(ctx context.Context) {
|
||||
// from settings and host callbacks to build ADIF, stamp the upload
|
||||
// status and surface errors to the UI.
|
||||
a.extsvc = extsvc.NewManager(extsvc.Deps{
|
||||
BuildADIF: a.buildUploadADIF,
|
||||
MarkUploaded: a.markExtUploaded,
|
||||
NotifyError: a.notifyExtError,
|
||||
ShouldUpload: a.extShouldUpload,
|
||||
Logf: applog.Printf,
|
||||
BuildADIF: a.buildUploadADIF,
|
||||
MarkUploaded: a.markExtUploaded,
|
||||
NotifyError: a.notifyExtError,
|
||||
ShouldUpload: a.extShouldUpload,
|
||||
StationCallOf: a.stationCallOf,
|
||||
Logf: applog.Printf,
|
||||
})
|
||||
a.extsvc.SetConfig(a.loadExternalServices())
|
||||
|
||||
@@ -998,6 +1002,13 @@ func (a *App) ListCountries() []string {
|
||||
return a.dxcc.EntityNames()
|
||||
}
|
||||
|
||||
// DXCCForCountry returns the ADIF DXCC entity number for a country/entity
|
||||
// name (as listed by ListCountries), or 0 if unknown. The QSO editor uses it
|
||||
// to keep the read-only DXCC field in sync when the user picks a Country.
|
||||
func (a *App) DXCCForCountry(name string) int {
|
||||
return dxcc.EntityDXCC(name)
|
||||
}
|
||||
|
||||
// ComputeStationInfo resolves a station's structured metadata from the
|
||||
// callsign (via cty.dat) and grid (via Maidenhead → lat/lon). The
|
||||
// frontend calls this whenever Callsign or Grid changes in the Station
|
||||
@@ -2424,6 +2435,19 @@ func (a *App) buildUploadADIF(id int64, forceCall string) (string, bool) {
|
||||
return adif.SingleRecordADIF(q), true
|
||||
}
|
||||
|
||||
// stationCallOf returns the QSO's STATION_CALLSIGN (upper-cased), used by the
|
||||
// uploader to verify a QSO belongs to the target logbook's callsign.
|
||||
func (a *App) stationCallOf(id int64) string {
|
||||
if a.qso == nil {
|
||||
return ""
|
||||
}
|
||||
q, err := a.qso.GetByID(a.ctx, id)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(strings.TrimSpace(q.StationCallsign))
|
||||
}
|
||||
|
||||
// extShouldUpload reports whether a QSO is eligible for upload to a service,
|
||||
// based on its sent status. QRZ/Club Log upload anything not yet "Y"; LoTW
|
||||
// uploads only QSOs whose lotw_sent matches the configured Upload flag
|
||||
@@ -2438,9 +2462,17 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
|
||||
}
|
||||
switch svc {
|
||||
case extsvc.ServiceQRZ:
|
||||
return !strings.EqualFold(q.QRZComUploadStatus, "Y")
|
||||
if strings.EqualFold(q.QRZComUploadStatus, "Y") {
|
||||
applog.Printf("extsvc: QSO %d not eligible for qrz — QRZComUploadStatus already %q (set Confirmations default to N to upload)", id, q.QRZComUploadStatus)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case extsvc.ServiceClublog:
|
||||
return !strings.EqualFold(q.ClublogUploadStatus, "Y")
|
||||
if strings.EqualFold(q.ClublogUploadStatus, "Y") {
|
||||
applog.Printf("extsvc: QSO %d not eligible for clublog — ClublogUploadStatus already %q (set Confirmations default to N to upload)", id, q.ClublogUploadStatus)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case extsvc.ServiceLoTW:
|
||||
flag := "R"
|
||||
if a.settings != nil {
|
||||
@@ -2952,7 +2984,11 @@ func (a *App) SetCATFrequency(hz int64) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.SetFrequency(hz)
|
||||
err := a.cat.SetFrequency(hz)
|
||||
if err != nil {
|
||||
applog.Printf("cat: SetFrequency(%d Hz) dispatch error: %v", hz, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// SetCATMode sets the rig's mode. ADIF mode names (SSB / CW / FT8 / …) are
|
||||
@@ -2961,7 +2997,11 @@ func (a *App) SetCATMode(mode string) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.SetMode(mode)
|
||||
err := a.cat.SetMode(mode)
|
||||
if err != nil {
|
||||
applog.Printf("cat: SetMode(%q) dispatch error: %v", mode, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// SwitchCATRig hot-swaps the active OmniRig slot (Rig1 ↔ Rig2) without
|
||||
|
||||
+57
-12
@@ -8,7 +8,7 @@ import {
|
||||
AddQSO, ListQSO, CountQSO,
|
||||
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF,
|
||||
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
|
||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ,
|
||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UploadQSOsManual,
|
||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||
GetStartupStatus,
|
||||
WorkedBefore,
|
||||
@@ -555,6 +555,18 @@ export default function App() {
|
||||
const [pendingImportPath, setPendingImportPath] = useState<string | null>(null);
|
||||
const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip');
|
||||
const [importApplyCty, setImportApplyCty] = useState(true);
|
||||
// QRZ profile photo lightbox (full-size, in-app — not the browser).
|
||||
const [photoModal, setPhotoModal] = useState<string | null>(null);
|
||||
// Esc closes the lightbox. Capture-phase + stopImmediatePropagation so the
|
||||
// global ESC handler (which resets the entry) doesn't also fire.
|
||||
useEffect(() => {
|
||||
if (!photoModal) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { e.stopImmediatePropagation(); e.preventDefault(); setPhotoModal(null); }
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, [photoModal]);
|
||||
|
||||
// === Lookup + WB ===
|
||||
const [lookupResult, setLookupResult] = useState<LookupResult | null>(null);
|
||||
@@ -1083,6 +1095,16 @@ export default function App() {
|
||||
try { await afterBulkUpdate(await UpdateQSOsFromQRZ(ids as any), 'from QRZ.com'); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
// Right-click "Send to QRZ.com / Club Log / LoTW": uploads the selected QSOs
|
||||
// on demand (regardless of their current upload status). Runs in the
|
||||
// background; qslmgr:done refreshes the grid when finished.
|
||||
async function bulkSendTo(service: string, ids: number[]) {
|
||||
if (ids.length === 0) return;
|
||||
const label = service === 'qrz' ? 'QRZ.com' : service === 'clublog' ? 'Club Log' : service === 'lotw' ? 'LoTW' : service;
|
||||
showToast(`Uploading ${ids.length} QSO${ids.length > 1 ? 's' : ''} to ${label}…`);
|
||||
try { await UploadQSOsManual(service, ids as any); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
function askDelete(id: number) {
|
||||
const q = qsos.find((x) => x.id === id);
|
||||
if (q) setDeletingQSO(q);
|
||||
@@ -1273,9 +1295,6 @@ export default function App() {
|
||||
{ name: 'tools', label: 'Tools', items: [
|
||||
{ type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: 'Callsign lookup settings…', action: 'tools.lookup' },
|
||||
{ type: 'item', label: 'CAT interface…', action: 'tools.cat' },
|
||||
{ type: 'item', label: 'Rotator…', action: 'tools.rotator' },
|
||||
{ type: 'item', label: wkEnabled ? '✓ WinKeyer CW keyer' : 'WinKeyer CW keyer', action: 'tools.winkeyer' },
|
||||
{ type: 'separator' },
|
||||
// Maintenance — bumped here while we only have one entry. Will move
|
||||
@@ -1298,9 +1317,6 @@ export default function App() {
|
||||
case 'edit.delete': if (selectedId !== null) askDelete(selectedId); break;
|
||||
case 'edit.prefs': setShowSettings(true); break;
|
||||
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
||||
case 'tools.lookup': setSettingsSection('lookup'); setShowSettings(true); break;
|
||||
case 'tools.cat': setSettingsSection('cat'); setShowSettings(true); break;
|
||||
case 'tools.rotator': setSettingsSection('rotator'); setShowSettings(true); break;
|
||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||
case 'tools.refreshCty': refreshCtyDat(); break;
|
||||
}
|
||||
@@ -1389,7 +1405,10 @@ export default function App() {
|
||||
tabIndex={-1}
|
||||
onClick={() => {
|
||||
const c = callsign.trim().toUpperCase();
|
||||
OpenExternalURL(`https://www.qrz.com/db/${encodeURIComponent(c)}`)
|
||||
// Encode each segment but keep the '/' literal — QRZ's URL is
|
||||
// /db/5Z4/MM0ZBH, not /db/5Z4%2FMM0ZBH (which 404s).
|
||||
const path = c.split('/').map(encodeURIComponent).join('/');
|
||||
OpenExternalURL(`https://www.qrz.com/db/${path}`)
|
||||
.catch((err) => setError(String(err?.message ?? err)));
|
||||
}}
|
||||
title="Open this callsign on QRZ.com"
|
||||
@@ -1800,6 +1819,31 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QRZ profile photo lightbox — full size, in-app. Click anywhere or
|
||||
press Esc to close; click the image itself doesn't close. */}
|
||||
{photoModal && (
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-stone-900/70 backdrop-blur-sm p-6 animate-in fade-in"
|
||||
onClick={() => setPhotoModal(null)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-4 right-4 rounded-md bg-white/10 hover:bg-white/20 text-white p-1.5"
|
||||
onClick={() => setPhotoModal(null)}
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
<img
|
||||
src={photoModal}
|
||||
alt="profile full size"
|
||||
className="max-h-full max-w-full object-contain rounded-lg shadow-2xl"
|
||||
referrerPolicy="no-referrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transient success toast (bottom-right). */}
|
||||
{toast && (
|
||||
<div className="fixed bottom-4 right-4 z-[100] flex items-center gap-2 rounded-lg border border-emerald-300 bg-emerald-50 text-emerald-800 px-3.5 py-2 text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2">
|
||||
@@ -1966,9 +2010,9 @@ export default function App() {
|
||||
<div className={cn('min-w-0 flex items-center', wkEnabled ? 'shrink-0' : 'flex-1')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => lookupResult.image_url && OpenExternalURL(lookupResult.image_url).catch((err) => setError(String(err?.message ?? err)))}
|
||||
onClick={() => lookupResult.image_url && setPhotoModal(lookupResult.image_url)}
|
||||
className="rounded-lg border border-border overflow-hidden hover:border-primary/60 transition-colors bg-muted/20"
|
||||
title="Open full-size on QRZ.com"
|
||||
title="Click to view full size"
|
||||
>
|
||||
<img
|
||||
src={lookupResult.image_url}
|
||||
@@ -2101,6 +2145,7 @@ export default function App() {
|
||||
onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
onUpdateFromCty={bulkUpdateFromCty}
|
||||
onUpdateFromQRZ={bulkUpdateFromQRZ}
|
||||
onSendTo={bulkSendTo}
|
||||
onRowSelected={(id) => setSelectedId(id)}
|
||||
/>
|
||||
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
|
||||
@@ -2432,7 +2477,7 @@ export default function App() {
|
||||
|
||||
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||
<WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} />
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onSendTo={bulkSendTo} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Opened on demand from Tools → QSL Manager; closable via the
|
||||
@@ -2526,7 +2571,7 @@ export default function App() {
|
||||
})()}
|
||||
|
||||
{editingQSO && (
|
||||
<QSOEditModal qso={editingQSO} onSave={onModalSave} onDelete={onModalDelete} onClose={() => setEditingQSO(null)} />
|
||||
<QSOEditModal qso={editingQSO} onSave={onModalSave} onDelete={onModalDelete} onClose={() => setEditingQSO(null)} countries={countries} />
|
||||
)}
|
||||
|
||||
<SendSpotModal
|
||||
|
||||
@@ -499,7 +499,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
</div>
|
||||
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
||||
scroll · ctrl+wheel = zoom · ◎ = jump to rig
|
||||
{hidden > 0 && <span className="text-amber-600"> · {hidden} data spot{hidden > 1 ? 's' : ''} hidden (top {MAX_VISIBLE_SPOTS} shown)</span>}
|
||||
{hidden > 0 && <span className="text-amber-600"> · {hidden} FT8/FT4 spot{hidden > 1 ? 's' : ''} hidden — top {MAX_VISIBLE_SPOTS} kept (CW/SSB all shown)</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Globe2, RefreshCw } from 'lucide-react';
|
||||
import { Globe2, RefreshCw, Upload } from 'lucide-react';
|
||||
|
||||
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
||||
|
||||
@@ -8,12 +8,19 @@ type Props = {
|
||||
onClose: () => void;
|
||||
onUpdateFromCty: (ids: number[]) => void;
|
||||
onUpdateFromQRZ: (ids: number[]) => void;
|
||||
onSendTo?: (service: string, ids: number[]) => void;
|
||||
};
|
||||
|
||||
const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
||||
{ service: 'qrz', label: 'Send to QRZ.com' },
|
||||
{ service: 'clublog', label: 'Send to Club Log' },
|
||||
{ service: 'lotw', label: 'Send to LoTW' },
|
||||
];
|
||||
|
||||
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
||||
// menu is an Enterprise feature, so this is a plain floating menu driven by
|
||||
// onCellContextMenu. Closes on any outside click, scroll or Escape.
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ }: Props) {
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onSendTo }: Props) {
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const close = () => onClose();
|
||||
@@ -34,7 +41,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
const n = menu.ids.length;
|
||||
// Keep the menu on-screen near the cursor.
|
||||
const x = Math.min(menu.x, window.innerWidth - 248);
|
||||
const y = Math.min(menu.y, window.innerHeight - 110);
|
||||
const y = Math.min(menu.y, window.innerHeight - (onSendTo ? 230 : 110));
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -59,6 +66,22 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
<RefreshCw className="size-4 text-sky-600" />
|
||||
<span>Update from QRZ.com</span>
|
||||
</button>
|
||||
|
||||
{onSendTo && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
{UPLOAD_TARGETS.map((t) => (
|
||||
<button
|
||||
key={t.service}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onSendTo(t.service, menu.ids); onClose(); }}
|
||||
>
|
||||
<Upload className="size-4 text-emerald-600" />
|
||||
<span>{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Trash2, Search, Loader2 } from 'lucide-react';
|
||||
import { LookupCallsign } from '../../wailsjs/go/main/App';
|
||||
import { LookupCallsign, DXCCForCountry } from '../../wailsjs/go/main/App';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { flagURL } from '@/lib/flags';
|
||||
import type { QSOForm } from '@/types';
|
||||
@@ -63,6 +64,11 @@ const CONFIRMATIONS: ConfDef[] = [
|
||||
// Colour-coded status cell for the confirmation grid.
|
||||
function StatusCell({ value }: { value?: string }) {
|
||||
const v = (value || '').toUpperCase();
|
||||
// Empty = no value set yet → show a neutral dash, NOT "No" (which is the
|
||||
// explicit "N" status). Mirrors the dropdown, which shows "—" for empty.
|
||||
if (v === '') {
|
||||
return <span className="block text-center text-[11px] text-muted-foreground">—</span>;
|
||||
}
|
||||
const label = v === 'Y' ? 'Yes' : v === 'R' ? 'Requested' : v === 'I' ? 'Ignore' : v === 'M' ? 'Modified' : 'No';
|
||||
const cls = v === 'Y' ? 'bg-emerald-600 text-white'
|
||||
: v === 'R' ? 'bg-orange-400 text-white'
|
||||
@@ -76,6 +82,7 @@ interface Props {
|
||||
onSave: (q: QSO) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onClose: () => void;
|
||||
countries?: string[];
|
||||
}
|
||||
|
||||
function toLocalISO(d: any): string {
|
||||
@@ -138,7 +145,7 @@ function QslSelect({ value, onChange }: { value?: string; onChange: (v: string)
|
||||
);
|
||||
}
|
||||
|
||||
export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
||||
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }: Props) {
|
||||
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
|
||||
// Frequencies are edited as kHz + Hz (Log4OM style) and recombined on save.
|
||||
const splitHz = (hz?: number) => hz
|
||||
@@ -163,6 +170,16 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
||||
setDraft((d) => ({ ...d, [key]: value }));
|
||||
}
|
||||
|
||||
// Country drives the DXCC entity number (ADIF). The DXCC field is read-only;
|
||||
// picking a Country resolves and stamps its DXCC# so they can't diverge.
|
||||
async function onCountryChange(v: string) {
|
||||
set('country', v);
|
||||
try {
|
||||
const n = await DXCCForCountry(v);
|
||||
set('dxcc', (n && n > 0 ? n : undefined) as any);
|
||||
} catch { /* leave DXCC as-is if resolution fails */ }
|
||||
}
|
||||
|
||||
// Re-run a callsign lookup (QRZ/HamQTH + cty.dat) and merge the result into
|
||||
// the draft — handy after correcting the callsign. Only overwrites the
|
||||
// lookup-derived fields; leaves call/band/mode/RST/dates alone.
|
||||
@@ -270,7 +287,6 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
||||
<TabsTrigger value="contest">Contest</TabsTrigger>
|
||||
<TabsTrigger value="sat">Sat / Prop</TabsTrigger>
|
||||
<TabsTrigger value="mystation">My Station</TabsTrigger>
|
||||
<TabsTrigger value="notes">Notes</TabsTrigger>
|
||||
<TabsTrigger value="extras">
|
||||
Extras
|
||||
{extrasCount > 0 && (
|
||||
@@ -331,14 +347,15 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">Country</Label>
|
||||
<Input value={draft.country ?? ''} onChange={(e) => set('country', e.target.value)} className="flex-1" />
|
||||
<Combobox value={draft.country ?? ''} options={countries} placeholder="Country"
|
||||
onChange={onCountryChange} className="flex-1" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">ITU</Label>
|
||||
<Input type="number" value={draft.ituz ?? ''} onChange={(e) => set('ituz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
|
||||
<Label>CQ</Label>
|
||||
<Input type="number" value={draft.cqz ?? ''} onChange={(e) => set('cqz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
|
||||
<Input type="number" value={draft.dxcc ?? ''} onChange={(e) => set('dxcc', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center bg-muted/40" title="DXCC entity #" />
|
||||
<Input type="number" value={draft.dxcc ?? ''} readOnly tabIndex={-1} className="font-mono w-16 text-center bg-muted/60 text-muted-foreground cursor-not-allowed" title="DXCC entity # — set automatically from Country" />
|
||||
{flagURL(draft.dxcc) && <img src={flagURL(draft.dxcc)} alt="" className="h-4 rounded-[2px] border border-border/50" referrerPolicy="no-referrer" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -368,11 +385,6 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
||||
</div>
|
||||
<div><Label>Comment</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></div>
|
||||
<div><Label>Note</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></div>
|
||||
<div><Label>Contest</Label><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></div>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col flex-1"><Label>Sent</Label><Input value={draft.stx_string ?? (draft.stx != null ? String(draft.stx) : '')} onChange={(e) => set('stx_string', e.target.value)} /></div>
|
||||
<div className="flex flex-col flex-1"><Label>Received</Label><Input value={draft.srx_string ?? (draft.srx != null ? String(draft.srx) : '')} onChange={(e) => set('srx_string', e.target.value)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -495,7 +507,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
||||
<F label="Operator" span={3}><Input value={draft.operator ?? ''} onChange={(e) => set('operator', e.target.value)} /></F>
|
||||
<F label="My grid"><Input value={draft.my_grid ?? ''} onChange={(e) => set('my_grid', e.target.value)} /></F>
|
||||
<F label="Grid ext"><Input value={draft.my_gridsquare_ext ?? ''} onChange={(e) => set('my_gridsquare_ext', e.target.value)} /></F>
|
||||
<F label="Country" span={2}><Input value={draft.my_country ?? ''} onChange={(e) => set('my_country', e.target.value)} /></F>
|
||||
<F label="Country" span={2}><Combobox value={draft.my_country ?? ''} options={countries} placeholder="Country" onChange={(v) => set('my_country', v)} /></F>
|
||||
<F label="State"><Input value={draft.my_state ?? ''} onChange={(e) => set('my_state', e.target.value)} /></F>
|
||||
<F label="County"><Input value={draft.my_cnty ?? ''} onChange={(e) => set('my_cnty', e.target.value)} /></F>
|
||||
<F label="DXCC"><Input type="number" value={draft.my_dxcc ?? ''} onChange={(e) => set('my_dxcc', intOrUndef(e.target.value) as any)} /></F>
|
||||
@@ -514,13 +526,6 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notes" className="mt-0">
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="Comment" span={6}><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></F>
|
||||
<F label="Notes" span={6}><Textarea rows={6} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></F>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="extras" className="mt-0 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
ADIF fields not promoted to first-class columns. One per line:{' '}
|
||||
|
||||
@@ -49,6 +49,7 @@ type Props = {
|
||||
onRowSelected?: (id: number | null) => void;
|
||||
onUpdateFromCty?: (ids: number[]) => void;
|
||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||
onSendTo?: (service: string, ids: number[]) => void;
|
||||
};
|
||||
|
||||
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
|
||||
@@ -141,14 +142,19 @@ export const COL_CATALOG: ColEntry[] = [
|
||||
{ group: 'eQSL', label: 'eQSL sent date', colId: 'eqsl_sent_date', headerName: 'eQSL S date', field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
|
||||
// ── Uploads ──
|
||||
{ group: 'Uploads', label: 'ClubLog upload date', colId: 'clublog_qso_upload_date', headerName: 'ClubLog date', field: 'clublog_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'ClubLog upload status', colId: 'clublog_qso_upload_status', headerName: 'ClubLog status', field: 'clublog_qso_upload_status' as any, width: 110 },
|
||||
{ group: 'Uploads', label: 'HRDLog upload date', colId: 'hrdlog_qso_upload_date', headerName: 'HRDLog date', field: 'hrdlog_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'HRDLog upload status', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog status', field: 'hrdlog_qso_upload_status' as any, width: 110 },
|
||||
{ group: 'Uploads', label: 'QRZ.com upload date', colId: 'qrzcom_qso_upload_date', headerName: 'QRZ.com date', field: 'qrzcom_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'QRZ.com upload status', colId: 'qrzcom_qso_upload_status', headerName: 'QRZ.com status', field: 'qrzcom_qso_upload_status' as any, width: 110 },
|
||||
{ group: 'Uploads', label: 'QRZ.com confirmed', colId: 'qrzcom_qso_download_status', headerName: 'QRZ.com cfm', field: 'qrzcom_qso_download_status' as any, width: 100 },
|
||||
// ── Uploads (online logbooks) ──
|
||||
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
||||
// for QRZ only, a "download status/date" (= it came back confirmed). We
|
||||
// relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have
|
||||
// NO rcvd field in ADIF — they're upload-only, so only "sent" is shown.
|
||||
{ group: 'Uploads', label: 'ClubLog sent', colId: 'clublog_qso_upload_status', headerName: 'ClubLog sent', field: 'clublog_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: 'ClubLog sent date', colId: 'clublog_qso_upload_date', headerName: 'ClubLog S date', field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'HRDLog sent', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog sent', field: 'hrdlog_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: 'HRDLog sent date', colId: 'hrdlog_qso_upload_date', headerName: 'HRDLog S date', field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'QRZ.com sent', colId: 'qrzcom_qso_upload_status', headerName: 'QRZ.com sent', field: 'qrzcom_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: 'QRZ.com rcvd', colId: 'qrzcom_qso_download_status', headerName: 'QRZ.com rcvd', field: 'qrzcom_qso_download_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: 'QRZ.com sent date', colId: 'qrzcom_qso_upload_date', headerName: 'QRZ.com S date', field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'QRZ.com rcvd date', colId: 'qrzcom_qso_download_date', headerName: 'QRZ.com R date', field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
|
||||
// ── Contest ──
|
||||
{ group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 },
|
||||
@@ -201,7 +207,7 @@ export const GROUP_ORDER = [
|
||||
'Contest', 'Propagation', 'My station', 'Misc',
|
||||
];
|
||||
|
||||
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ }: Props) {
|
||||
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onSendTo }: Props) {
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
@@ -344,6 +350,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
||||
onClose={() => setMenu(null)}
|
||||
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
||||
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
||||
onSendTo={onSendTo}
|
||||
/>
|
||||
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
|
||||
@@ -173,7 +173,7 @@ const TREE: TreeNode[] = [
|
||||
{ kind: 'item', label: 'Modes & default RST', id: 'lists-modes' },
|
||||
]},
|
||||
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
|
||||
{ kind: 'item', label: 'UDP integrations (WSJT-X, JTDX, MSHV…)', id: 'udp' },
|
||||
{ kind: 'item', label: 'UDP integrations', id: 'udp' },
|
||||
{ kind: 'item', label: 'Database', id: 'database' },
|
||||
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
|
||||
],
|
||||
@@ -1862,13 +1862,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Upload status fields (Clublog / HRDLog / QRZ.com) are stamped on every QSO; "N" is typical when you want OpsLog to track which QSOs still need to be uploaded.
|
||||
"Sent" = the QSO's upload status to the service; "N" is typical so OpsLog tracks which QSOs still need uploading. Club Log & HRDLog are upload-only (no "rcvd" in ADIF); QRZ.com also has "Rcvd" = confirmed back.
|
||||
</div>
|
||||
{/* Clublog */}
|
||||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||
<Label className="text-sm font-medium pb-1.5">Clublog</Label>
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Upload status</Label>
|
||||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Sent</Label>
|
||||
{renderSelect('clublog_status', FULL_OPTIONS)}
|
||||
</div>
|
||||
<div />
|
||||
@@ -1877,7 +1877,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||
<Label className="text-sm font-medium pb-1.5">HRDLog</Label>
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Upload status</Label>
|
||||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Sent</Label>
|
||||
{renderSelect('hrdlog_status', FULL_OPTIONS)}
|
||||
</div>
|
||||
<div />
|
||||
@@ -1886,11 +1886,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||
<Label className="text-sm font-medium pb-1.5">QRZ.com</Label>
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Upload status</Label>
|
||||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Sent</Label>
|
||||
{renderSelect('qrzcom_status', FULL_OPTIONS)}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Confirmed</Label>
|
||||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Rcvd</Label>
|
||||
{renderSelect('qrzcom_confirmed', FULL_OPTIONS)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,6 +49,7 @@ type Props = {
|
||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||
onUpdateFromCty?: (ids: number[]) => void;
|
||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||
onSendTo?: (service: string, ids: number[]) => void;
|
||||
};
|
||||
|
||||
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v2';
|
||||
@@ -61,7 +62,7 @@ function fmtDate(s: any): string {
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ }: Props) {
|
||||
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onSendTo }: Props) {
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
@@ -232,6 +233,7 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
onClose={() => setMenu(null)}
|
||||
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
||||
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
||||
onSendTo={onSendTo}
|
||||
/>
|
||||
|
||||
{count > entries.length && (
|
||||
|
||||
Vendored
+2
@@ -30,6 +30,8 @@ export function CountQSO():Promise<number>;
|
||||
|
||||
export function CreateDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function DXCCForCountry(arg1:string):Promise<number>;
|
||||
|
||||
export function DeleteAllQSO():Promise<number>;
|
||||
|
||||
export function DeleteClusterServer(arg1:number):Promise<void>;
|
||||
|
||||
@@ -38,6 +38,10 @@ export function CreateDatabase(arg1) {
|
||||
return window['go']['main']['App']['CreateDatabase'](arg1);
|
||||
}
|
||||
|
||||
export function DXCCForCountry(arg1) {
|
||||
return window['go']['main']['App']['DXCCForCountry'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteAllQSO() {
|
||||
return window['go']['main']['App']['DeleteAllQSO']();
|
||||
}
|
||||
|
||||
@@ -339,6 +339,15 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
||||
if hz, ok := parseFreqHz(rec["freq_rx"]); ok {
|
||||
q.FreqRXHz = &hz
|
||||
}
|
||||
// RX defaults to TX when the ADIF omits split info: an empty BAND_RX /
|
||||
// FREQ_RX means the contact wasn't cross-band/split, so RX = TX.
|
||||
if q.BandRX == "" {
|
||||
q.BandRX = q.Band
|
||||
}
|
||||
if q.FreqRXHz == nil && q.FreqHz != nil {
|
||||
v := *q.FreqHz
|
||||
q.FreqRXHz = &v
|
||||
}
|
||||
|
||||
q.RSTSent = rec["rst_sent"]
|
||||
q.RSTRcvd = rec["rst_rcvd"]
|
||||
|
||||
+30
-12
@@ -6,20 +6,38 @@ import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// debugLog writes CAT debug events to %APPDATA%/HamLog/cat.log so users can
|
||||
// diagnose mode/freq mismatches without rebuilding with -windowsconsole.
|
||||
//
|
||||
// Initialised lazily on first use. Falls back to the standard library
|
||||
// default logger (stderr, usually invisible in a Wails GUI build) if the
|
||||
// log file can't be opened.
|
||||
var debugLog = openDebugLog()
|
||||
// LogSink, when set by the host app at startup, receives every CAT debug
|
||||
// line so they land in the unified app log (opslog.log) alongside the rest
|
||||
// of OpsLog's diagnostics. Until it's wired we fall back to a dedicated
|
||||
// cat.log file so early-startup lines aren't lost.
|
||||
var LogSink func(format string, args ...any)
|
||||
|
||||
func openDebugLog() *log.Logger {
|
||||
// catLogger forwards Printf either to the host LogSink (preferred) or to a
|
||||
// local file/stderr fallback. Keeps the call sites (debugLog.Printf(...))
|
||||
// unchanged.
|
||||
type catLogger struct{ fallback *log.Logger }
|
||||
|
||||
func (c *catLogger) Printf(format string, args ...any) {
|
||||
if LogSink != nil {
|
||||
LogSink("cat: "+format, args...)
|
||||
return
|
||||
}
|
||||
if c.fallback != nil {
|
||||
c.fallback.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// debugLog writes CAT debug events so users can diagnose mode/freq mismatches
|
||||
// without rebuilding with a console. Once LogSink is set, lines flow into the
|
||||
// main opslog.log.
|
||||
var debugLog = &catLogger{fallback: openFallbackLog()}
|
||||
|
||||
func openFallbackLog() *log.Logger {
|
||||
base, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return log.Default()
|
||||
}
|
||||
dir := filepath.Join(base, "HamLog")
|
||||
dir := filepath.Join(base, "OpsLog")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return log.Default()
|
||||
}
|
||||
@@ -31,12 +49,12 @@ func openDebugLog() *log.Logger {
|
||||
return log.New(f, "", log.LstdFlags|log.Lmicroseconds)
|
||||
}
|
||||
|
||||
// DebugLogPath returns the path the cat.log file would be opened at, for
|
||||
// surfacing in the UI / docs.
|
||||
// DebugLogPath returns where the fallback cat.log lives, for surfacing in the
|
||||
// UI / docs. When LogSink is wired, CAT lines are in the main app log instead.
|
||||
func DebugLogPath() string {
|
||||
base, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(base, "HamLog", "cat.log")
|
||||
return filepath.Join(base, "OpsLog", "cat.log")
|
||||
}
|
||||
|
||||
+80
-24
@@ -160,42 +160,98 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
||||
|
||||
func (o *OmniRig) SetFrequency(hz int64) error {
|
||||
if o.rig == nil {
|
||||
debugLog.Printf("OmniRig.SetFrequency(%d): NOT CONNECTED", hz)
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
// OmniRig Freq is a Long (int32). Validate to avoid silent truncation.
|
||||
if hz < 0 || hz > 0x7fffffff {
|
||||
debugLog.Printf("OmniRig.SetFrequency(%d): out of int32 range", hz)
|
||||
return fmt.Errorf("frequency out of OmniRig int32 range")
|
||||
}
|
||||
hz32 := int32(hz)
|
||||
|
||||
// Pick the right OmniRig property. Many rig .ini files only define a
|
||||
// WRITE command for FreqA/FreqB but not the generic Freq — in which case
|
||||
// PutProperty(Freq) silently succeeds but the rig never moves. Write to
|
||||
// the active VFO's specific property when we know it; fall back to Freq.
|
||||
prop := "FreqA"
|
||||
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
|
||||
switch omniRigVfo(vfoVar.Val) {
|
||||
case "B", "BB", "BA":
|
||||
prop = "FreqB"
|
||||
case "A", "AA", "AB":
|
||||
prop = "FreqA"
|
||||
}
|
||||
// Log the rig's writable-params, status and VFO state up front so a
|
||||
// friend's session shows exactly what OmniRig reports for their rig.
|
||||
status, statusStr, rigType := int64(-1), "", ""
|
||||
if v, err := oleutil.GetProperty(o.rig, "Status"); err == nil {
|
||||
status = v.Val
|
||||
}
|
||||
debugLog.Printf("OmniRig.SetFrequency(%d Hz / %.6f MHz) → %s", hz, float64(hz)/1e6, prop)
|
||||
if _, err := oleutil.PutProperty(o.rig, prop, hz32); err != nil {
|
||||
debugLog.Printf("OmniRig.SetFrequency(%s) error: %v — falling back to Freq", prop, err)
|
||||
if _, err2 := oleutil.PutProperty(o.rig, "Freq", hz32); err2 != nil {
|
||||
debugLog.Printf("OmniRig.SetFrequency(Freq) also failed: %v", err2)
|
||||
return err2
|
||||
}
|
||||
if v, err := oleutil.GetProperty(o.rig, "StatusStr"); err == nil {
|
||||
statusStr = v.ToString()
|
||||
}
|
||||
if v, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
|
||||
rigType = v.ToString()
|
||||
}
|
||||
rawVfo, vfo := int64(-1), ""
|
||||
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
|
||||
rawVfo = vfoVar.Val
|
||||
vfo = omniRigVfo(vfoVar.Val)
|
||||
} else {
|
||||
debugLog.Printf("OmniRig.SetFrequency: Vfo read error: %v", err)
|
||||
}
|
||||
split := int64(0)
|
||||
if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil {
|
||||
split = v.Val
|
||||
}
|
||||
// What can this rig's .ini actually write? OmniRig exposes a WriteableParams
|
||||
// bitmask — if FreqA/FreqB/Freq bits are missing, the write is a silent no-op.
|
||||
writeable := int64(-1)
|
||||
if v, err := oleutil.GetProperty(o.rig, "WriteableParams"); err == nil {
|
||||
writeable = v.Val
|
||||
}
|
||||
debugLog.Printf("OmniRig.SetFrequency(%d Hz / %.6f MHz): rig=%q status=%d(%s) vfo=%q(raw=%d) split=%d writeableParams=0x%X",
|
||||
hz, float64(hz)/1e6, rigType, status, statusStr, vfo, rawVfo, split, writeable)
|
||||
|
||||
// Pick the active VFO's specific property. Many rig .ini files only define
|
||||
// a WRITE command for FreqA/FreqB but not the generic Freq.
|
||||
prop := "FreqA"
|
||||
switch vfo {
|
||||
case "B", "BB", "BA":
|
||||
prop = "FreqB"
|
||||
case "A", "AA", "AB":
|
||||
prop = "FreqA"
|
||||
}
|
||||
|
||||
// Read back the active VFO freq after a short delay so the log shows
|
||||
// whether the rig actually moved. Useful when the .ini accepts the write
|
||||
// silently but the rig doesn't honour it (wrong WRITE command etc.).
|
||||
if fv, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
||||
debugLog.Printf("OmniRig.Freq immediately after Put = %d Hz", fv.Val)
|
||||
wroteOK := false
|
||||
if _, err := oleutil.PutProperty(o.rig, prop, hz32); err != nil {
|
||||
debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s) error: %v", prop, err)
|
||||
} else {
|
||||
debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s, %d) OK", prop, hz32)
|
||||
wroteOK = true
|
||||
}
|
||||
|
||||
// Belt-and-suspenders: when NOT in split, also write the generic Freq.
|
||||
// Icom .ini files commonly honour Freq (CI-V "set operating frequency")
|
||||
// but ignore FreqA/FreqB, so the rig changed mode but never moved — this
|
||||
// is exactly the IC-9100 "mode changes, freq doesn't" symptom.
|
||||
if split == 0 {
|
||||
if _, err := oleutil.PutProperty(o.rig, "Freq", hz32); err != nil {
|
||||
debugLog.Printf("OmniRig.SetFrequency: PutProperty(Freq) error: %v", err)
|
||||
if !wroteOK {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
debugLog.Printf("OmniRig.SetFrequency: PutProperty(Freq, %d) OK", hz32)
|
||||
}
|
||||
} else if !wroteOK {
|
||||
return fmt.Errorf("OmniRig: could not write %s and split is on (won't touch generic Freq)", prop)
|
||||
}
|
||||
|
||||
// Read back all three immediately. OmniRig is async (the CAT command is
|
||||
// queued + sent over serial), so these may still show the OLD value for
|
||||
// one poll cycle — but if they NEVER change in the next poll, the rig
|
||||
// isn't honouring the write (wrong .ini WRITE command for this model).
|
||||
fa, fb, fg := int64(-1), int64(-1), int64(-1)
|
||||
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
|
||||
fa = v.Val
|
||||
}
|
||||
if v, err := oleutil.GetProperty(o.rig, "FreqB"); err == nil {
|
||||
fb = v.Val
|
||||
}
|
||||
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
||||
fg = v.Val
|
||||
}
|
||||
debugLog.Printf("OmniRig.SetFrequency: readback FreqA=%d FreqB=%d Freq=%d (target %d)", fa, fb, fg, hz)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package extsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -9,6 +10,29 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// baseCall extracts the operator's base callsign from a possibly-affixed call:
|
||||
// for slashed forms (F4BPO/P, FW/F4BPO, 9A/F4BPO/P) it returns the longest
|
||||
// token, which is the real call; otherwise the call itself. Upper-cased.
|
||||
func baseCall(s string) string {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
if !strings.Contains(s, "/") {
|
||||
return s
|
||||
}
|
||||
best := ""
|
||||
for _, part := range strings.Split(s, "/") {
|
||||
if len(part) > len(best) {
|
||||
best = part
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// sameBaseCall reports whether two callsigns belong to the same operator,
|
||||
// ignoring portable prefixes/suffixes (F4BPO/P == F4BPO, FW/F4BPO == F4BPO).
|
||||
func sameBaseCall(a, b string) bool {
|
||||
return baseCall(a) == baseCall(b)
|
||||
}
|
||||
|
||||
// Deps are the host-app callbacks the Manager needs. Keeping them as
|
||||
// function fields decouples extsvc from the qso/adif/settings packages and
|
||||
// keeps the upload-scheduling logic testable.
|
||||
@@ -33,6 +57,11 @@ type Deps struct {
|
||||
// Upload flag ("N" or "R"), à la Log4OM. Returning false skips the QSO.
|
||||
ShouldUpload func(svc Service, id int64) bool
|
||||
|
||||
// StationCallOf returns the QSO's STATION_CALLSIGN. Used to guard against
|
||||
// uploading a QSO into a logbook for a different callsign (the force-call
|
||||
// option would otherwise silently relabel it). "" → no station call known.
|
||||
StationCallOf func(id int64) string
|
||||
|
||||
// Logf is an optional diagnostic logger.
|
||||
Logf func(format string, args ...any)
|
||||
}
|
||||
@@ -236,6 +265,33 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Station-callsign guard. Each logbook belongs to one callsign:
|
||||
// QRZ/LoTW → the ForceStationCallsign (the call this logbook signs as)
|
||||
// Club Log → the logbook Callsign param
|
||||
// If the QSO's own STATION_CALLSIGN is a DIFFERENT operator, uploading
|
||||
// would push it into the wrong logbook (and the force-call option would
|
||||
// silently relabel it). Block it with a clear error. Portable variants of
|
||||
// the SAME call (F4BPO/P, FW/F4BPO…) are allowed.
|
||||
owner := ""
|
||||
switch svc {
|
||||
case ServiceQRZ, ServiceLoTW:
|
||||
owner = cfg.ForceStationCallsign
|
||||
case ServiceClublog:
|
||||
owner = cfg.Callsign
|
||||
}
|
||||
if owner != "" && m.deps.StationCallOf != nil {
|
||||
qcall := m.deps.StationCallOf(id)
|
||||
if qcall != "" && !sameBaseCall(qcall, owner) {
|
||||
err := fmt.Errorf("station callsign %s does not match %s logbook %s — not uploaded",
|
||||
strings.ToUpper(qcall), svc, strings.ToUpper(owner))
|
||||
m.logf("extsvc: %s upload of QSO %d BLOCKED: %v", svc, id, err)
|
||||
if m.deps.NotifyError != nil {
|
||||
m.deps.NotifyError(svc, id, err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
|
||||
+15
-2
@@ -175,7 +175,7 @@ func TestQRZ(ctx context.Context, client *http.Client, apiKey string) (string, e
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("qrz: bad response: %w", err)
|
||||
}
|
||||
status := strings.ToUpper(strings.TrimSpace(vals.Get("STATUS")))
|
||||
status := qrzStatusField(vals)
|
||||
if status == "AUTH" || status == "FAIL" {
|
||||
reason := strings.TrimSpace(vals.Get("REASON"))
|
||||
if reason == "" {
|
||||
@@ -201,7 +201,11 @@ func parseQRZResponse(body string) (UploadResult, error) {
|
||||
if err != nil {
|
||||
return UploadResult{}, fmt.Errorf("qrz: bad response %q: %w", body, err)
|
||||
}
|
||||
status := strings.ToUpper(strings.TrimSpace(vals.Get("STATUS")))
|
||||
// The QRZ Logbook API returns the outcome in RESULT (=OK/FAIL/AUTH).
|
||||
// Accept STATUS as a fallback for robustness, but RESULT is the real
|
||||
// field — reading only STATUS made every INSERT (incl. successful ones)
|
||||
// look like it failed with an empty status.
|
||||
status := qrzStatusField(vals)
|
||||
reason := strings.TrimSpace(vals.Get("REASON"))
|
||||
logID := strings.TrimSpace(vals.Get("LOGID"))
|
||||
|
||||
@@ -222,6 +226,15 @@ func parseQRZResponse(body string) (UploadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// qrzStatusField returns the QRZ outcome code, preferring RESULT (the
|
||||
// Logbook API's real field) and falling back to STATUS.
|
||||
func qrzStatusField(vals url.Values) string {
|
||||
if v := strings.ToUpper(strings.TrimSpace(vals.Get("RESULT"))); v != "" {
|
||||
return v
|
||||
}
|
||||
return strings.ToUpper(strings.TrimSpace(vals.Get("STATUS")))
|
||||
}
|
||||
|
||||
// isDuplicateReason recognises the various phrasings QRZ uses when a QSO is
|
||||
// already present.
|
||||
func isDuplicateReason(reason string) bool {
|
||||
|
||||
Reference in New Issue
Block a user