fix: download lotw only for current callsign in case

of mixed logs (tm2q & f4bpo in same log)
This commit is contained in:
2026-06-18 12:34:53 +02:00
parent e1f1ab4922
commit cc0f9ffc64
5 changed files with 82 additions and 32 deletions
+48 -29
View File
@@ -5243,12 +5243,17 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
switch svc { switch svc {
case extsvc.ServiceLoTW: case extsvc.ServiceLoTW:
sinceDate := resolveSince(keyExtLoTWLastDownload) sinceDate := resolveSince(keyExtLoTWLastDownload)
if sinceDate != "" { ownCall := a.uploadOwnerCall(extsvc.ServiceLoTW)
emit("Downloading LoTW confirmations received since " + sinceDate + "…") callLabel := ownCall
} else { if callLabel == "" {
emit("Downloading all LoTW confirmations") callLabel = "all callsigns"
} }
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, sinceDate) if sinceDate != "" {
emit(fmt.Sprintf("Downloading LoTW confirmations for %s received since %s…", callLabel, sinceDate))
} else {
emit(fmt.Sprintf("Downloading all LoTW confirmations for %s…", callLabel))
}
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, sinceDate, ownCall)
if err != nil { if err != nil {
emit("Download failed: " + err.Error()) emit("Download failed: " + err.Error())
done(matched, total) done(matched, total)
@@ -5713,34 +5718,14 @@ func (a *App) stationCallOf(id int64) string {
return strings.ToUpper(strings.TrimSpace(q.StationCallsign)) return strings.ToUpper(strings.TrimSpace(q.StationCallsign))
} }
// closeUploadIDs returns the QSO ids to upload to a service at app close, // uploadOwnerCall returns the callsign OpsLog signs/uploads/downloads as for a
// scanning the whole logbook: LoTW matches the configured sent-status set // service in the active profile: the configured Force/owner callsign, else the
// (N/R), QRZ/Club Log return anything not yet "Y". This is what lets an // active profile's callsign. "" when nothing is known (don't scope by call).
// imported ADIF (old QSOs still unsent) flush on close. func (a *App) uploadOwnerCall(svc extsvc.Service) string {
func (a *App) closeUploadIDs(svc extsvc.Service) []int64 {
if a.qso == nil {
return nil
}
col := uploadColumnFor(string(svc))
if col == "" {
return nil
}
cfg := a.loadExternalServices() cfg := a.loadExternalServices()
// owner is the callsign this logbook signs/uploads as. Each external
// logbook belongs to ONE call, so in a mixed-call DB (F4BPO, F4BPO/P, TM2Q)
// we must only sweep the QSOs that belong to it — otherwise TM2Q QSOs would
// be signed under the F4BPO certificate, or pushed to the wrong QRZ/Club Log
// logbook. Prefer the configured force/owner call; fall back to the active
// profile's callsign.
var statuses []string
owner := "" owner := ""
switch svc { switch svc {
case extsvc.ServiceLoTW: case extsvc.ServiceLoTW:
statuses = cfg.LoTW.UploadFlags
if len(statuses) == 0 {
return nil
}
owner = cfg.LoTW.ForceStationCallsign owner = cfg.LoTW.ForceStationCallsign
case extsvc.ServiceQRZ: case extsvc.ServiceQRZ:
owner = cfg.QRZ.ForceStationCallsign owner = cfg.QRZ.ForceStationCallsign
@@ -5753,6 +5738,40 @@ func (a *App) closeUploadIDs(svc extsvc.Service) []int64 {
owner = strings.ToUpper(strings.TrimSpace(p.Callsign)) owner = strings.ToUpper(strings.TrimSpace(p.Callsign))
} }
} }
return owner
}
// UploadCallsign exposes uploadOwnerCall to the UI so the QSL Manager can show
// which of the operator's callsigns a download/upload targets in this profile.
func (a *App) UploadCallsign(service string) string {
return a.uploadOwnerCall(extsvc.Service(service))
}
// closeUploadIDs returns the QSO ids to upload to a service at app close,
// scanning the whole logbook: LoTW matches the configured sent-status set
// (N/R), QRZ/Club Log return anything not yet "Y". This is what lets an
// imported ADIF (old QSOs still unsent) flush on close.
func (a *App) closeUploadIDs(svc extsvc.Service) []int64 {
if a.qso == nil {
return nil
}
col := uploadColumnFor(string(svc))
if col == "" {
return nil
}
// owner is the callsign this logbook signs/uploads as. Each external
// logbook belongs to ONE call, so in a mixed-call DB (F4BPO, F4BPO/P, TM2Q)
// we must only sweep the QSOs that belong to it — otherwise TM2Q QSOs would
// be signed under the F4BPO certificate, or pushed to the wrong QRZ/Club Log
// logbook.
var statuses []string
if svc == extsvc.ServiceLoTW {
statuses = a.loadExternalServices().LoTW.UploadFlags
if len(statuses) == 0 {
return nil
}
}
owner := a.uploadOwnerCall(svc)
cands, err := a.qso.ListUploadCandidates(a.ctx, col, statuses) cands, err := a.qso.ListUploadCandidates(a.ctx, col, statuses)
if err != nil { if err != nil {
+20 -1
View File
@@ -6,7 +6,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL } from '../../wailsjs/go/main/App'; import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { EventsOn } from '../../wailsjs/runtime/runtime'; import { EventsOn } from '../../wailsjs/runtime/runtime';
@@ -77,6 +77,14 @@ export function fmtQslDate(s?: string): string {
// and download confirmations, while the rest of the app stays usable. // and download confirmations, while the rest of the app stays usable.
export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) { export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
const [service, setService] = useState('lotw'); const [service, setService] = useState('lotw');
// The callsign this profile signs/uploads/downloads as for the selected
// service (Force station callsign, else the profile call). Shown so the user
// knows WHICH of their calls a download/upload targets in a mixed-call log.
const [uploadCall, setUploadCall] = useState('');
useEffect(() => {
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
}, [service]);
const [potaSyncing, setPotaSyncing] = useState(false); const [potaSyncing, setPotaSyncing] = useState(false);
const [potaRes, setPotaRes] = useState<POTASync | null>(null); const [potaRes, setPotaRes] = useState<POTASync | null>(null);
const [potaErr, setPotaErr] = useState(''); const [potaErr, setPotaErr] = useState('');
@@ -251,6 +259,17 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent> <SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
</Select> </Select>
</div> </div>
{uploadCall && (
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Callsign</label>
<span
className="h-8 inline-flex items-center rounded border border-border bg-muted/40 px-2 font-mono text-sm font-semibold"
title="Upload/download is scoped to this callsign (Force station callsign, else the active profile's call)"
>
{uploadCall}
</span>
</div>
)}
{service === 'pota' ? ( {service === 'pota' ? (
<> <>
<Button size="sm" className="h-8" onClick={syncPota} disabled={potaSyncing}> <Button size="sm" className="h-8" onClick={syncPota} disabled={potaSyncing}>
+2
View File
@@ -501,6 +501,8 @@ export function UpdateQSOsFromCty(arg1:Array<number>):Promise<number>;
export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>; export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>;
export function UploadCallsign(arg1:string):Promise<string>;
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>; export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
export function WinkeyerBackspace():Promise<void>; export function WinkeyerBackspace():Promise<void>;
+4
View File
@@ -974,6 +974,10 @@ export function UpdateQSOsFromQRZ(arg1) {
return window['go']['main']['App']['UpdateQSOsFromQRZ'](arg1); return window['go']['main']['App']['UpdateQSOsFromQRZ'](arg1);
} }
export function UploadCallsign(arg1) {
return window['go']['main']['App']['UploadCallsign'](arg1);
}
export function UploadQSOsManual(arg1, arg2) { export function UploadQSOsManual(arg1, arg2) {
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2); return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
} }
+8 -2
View File
@@ -21,8 +21,11 @@ const lotwReportURL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
// DownloadLoTWConfirmations fetches confirmed QSOs from LoTW as ADIF text. // DownloadLoTWConfirmations fetches confirmed QSOs from LoTW as ADIF text.
// Uses the LoTW *website* login (Username/Password), not the TQSL cert. When // Uses the LoTW *website* login (Username/Password), not the TQSL cert. When
// since is non-empty (YYYY-MM-DD) only confirmations received since then are // since is non-empty (YYYY-MM-DD) only confirmations received since then are
// returned — used for incremental "Last download" updates. // returned — used for incremental "Last download" updates. When ownCall is
func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since string) (string, error) { // non-empty, only confirmations for that station callsign are returned (an
// LoTW account holds every call you operate — F4BPO, F4BPO/P, TM2Q — so this
// scopes the pull to the active profile's call).
func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since, ownCall string) (string, error) {
user := strings.TrimSpace(cfg.Username) user := strings.TrimSpace(cfg.Username)
if user == "" || cfg.Password == "" { if user == "" || cfg.Password == "" {
return "", fmt.Errorf("lotw: website login (username/password) not set") return "", fmt.Errorf("lotw: website login (username/password) not set")
@@ -33,6 +36,9 @@ func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg Ser
q.Set("qso_query", "1") q.Set("qso_query", "1")
q.Set("qso_qsl", "yes") // only QSLed (confirmed) records q.Set("qso_qsl", "yes") // only QSLed (confirmed) records
q.Set("qso_qsldetail", "yes") // include QSL_RCVD / QSLRDATE detail q.Set("qso_qsldetail", "yes") // include QSL_RCVD / QSLRDATE detail
if c := strings.TrimSpace(ownCall); c != "" {
q.Set("qso_owncall", c) // restrict to this station callsign
}
if s := strings.TrimSpace(since); s != "" { if s := strings.TrimSpace(since); s != "" {
q.Set("qso_qslsince", s) q.Set("qso_qslsince", s)
} }