fix(labels): the manager's address, not the DX's — and one PDF, just opened

Three corrections from the first real session. Routed via a manager, the box
was prefilled with the DX's own address — the one thing that must not go on
that envelope. It now starts empty and the QRZ fetch fills in the MANAGER's;
switching the routing recomputes the box unless the operator has typed in it,
because a hand-checked address is not the app's to replace.

A fetch that came back with no street was overwriting a reviewed address with a
bare country — the cty.dat fallback dressed as an answer. It now refuses to
touch the box and says nothing was found. And the prefill no longer stacks the
same town three times (address + QTH + country all carrying it).

The output is ONE PDF for the whole session, each page at its own label size,
written to the temp dir and opened straight in the viewer — no save dialog: the
file is a print run, not a document to keep.
This commit is contained in:
2026-08-28 20:07:36 +02:00
parent 5ee0ade54b
commit 1d7633484b
7 changed files with 143 additions and 85 deletions
@@ -4,9 +4,10 @@
// callsign — several QSOs of the same station share one card.
// 2. REVIEW each recipient: routing (direct / bureau / via manager), the
// address checked and edited by hand, a QRZ fetch to fill it.
// 3. PRINT one PDF per label kind — a roll printer holds ONE stock at a
// time, so QSO labels, addresses and return labels are separate
// files — then mark the contacts sent (date + via) in the log.
// 3. PRINT ONE PDF holding every label of the session (each page at its
// own physical size), opened straight in the system viewer — the
// operator prints from there — then mark the contacts sent
// (date + via) in the log.
//
// The pages are rasterised by the designer's own renderer at the stock's dpi:
// what the designer previewed is what the PDF carries.
@@ -22,7 +23,7 @@ import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import {
LabelPaperQueue, LabelListStocks, LabelListTemplates, LabelGetTemplate,
LabelExportPDF, LookupCallsignFresh, BulkUpdateQSL, GetActiveProfile,
LabelOpenPDF, LookupCallsignFresh, BulkUpdateQSL, GetActiveProfile,
} from '../../../wailsjs/go/main/App';
import type { LabelSample, LabelStock, LabelTemplate } from './labelTypes';
import { rasterize } from './labelRender';
@@ -38,6 +39,10 @@ interface Station {
routing: Routing;
via: string; // manager callsign when routing = via
address: string; // multiline, the text that will be printed — verbatim
// Whose address the box holds: the DX's prefill, the manager's fetch, or the
// operator's own edit. Routing changes recompute the first two and must never
// touch the third — an address typed by hand is not the app's to replace.
addrFor: 'dx' | 'mgr' | 'user' | 'none';
fetching?: boolean;
}
@@ -58,10 +63,24 @@ function toSample(q: any): LabelSample {
};
}
// dedupeLines drops empties and any line already CONTAINED in an earlier one:
// the log often holds "20370 Casablanca Morocco" as the address AND "20370
// CASABLANCA" as the QTH AND "Morocco" as the country, and printing all three
// stacks the same city three times on the envelope.
function dedupeLines(lines: Array<any>): string[] {
const out: string[] = [];
for (const raw of lines) {
const l = String(raw ?? '').trim();
if (!l) continue;
const low = l.toLowerCase();
if (out.some((prev) => prev.toLowerCase().includes(low))) continue;
out.push(l);
}
return out;
}
function initialAddress(q: any): string {
const lines = [q.name, q.address, q.qth, q.country]
.map((x: any) => String(x ?? '').trim()).filter(Boolean);
return lines.join('\n');
return dedupeLines([q.name, q.address, q.qth, q.country]).join('\n');
}
export function LabelPrintModal({ open, onClose }: Props) {
@@ -82,10 +101,12 @@ export function LabelPrintModal({ open, onClose }: Props) {
const [myAddress, setMyAddress] = useState('');
const [myVars, setMyVars] = useState<Record<string, string>>({});
// Rasterised pages per kind, built when entering step 3.
const [pages, setPages] = useState<{ qso: string[]; addr: string[]; ret: string[] }>({ qso: [], addr: [], ret: [] });
// Rasterised pages per kind — kept separate for the previews, concatenated
// (with each page's own mm size) into the single PDF.
type Page = { png: string; w_mm: number; h_mm: number };
const [pages, setPages] = useState<{ qso: Page[]; addr: Page[]; ret: Page[] }>({ qso: [], addr: [], ret: [] });
const [building, setBuilding] = useState(false);
const [exported, setExported] = useState<Record<string, string>>({});
const [pdfPath, setPdfPath] = useState('');
const [markDate, setMarkDate] = useState(() => new Date().toISOString().slice(0, 10));
const [marked, setMarked] = useState(0);
@@ -102,11 +123,14 @@ export function LabelPrintModal({ open, onClose }: Props) {
}
setStations([...by.entries()].map(([call, qsos]) => {
const via = String(qsos[0]?.qsl_via ?? '').trim();
const address = initialAddress(qsos[0]);
// Via a manager, the DX's own address is exactly the wrong thing to
// print on the envelope — start empty and let the QRZ fetch fill in the
// MANAGER's.
const address = via ? '' : initialAddress(qsos[0]);
return {
call, qsos, checked: true,
routing: via ? 'via' as Routing : (address.split('\n').length >= 3 ? 'direct' as Routing : 'bureau' as Routing),
via, address,
via, address, addrFor: (via ? 'none' : 'dx') as Station['addrFor'],
};
}));
const st = (await LabelListStocks()) as any as LabelStock[];
@@ -133,7 +157,7 @@ export function LabelPrintModal({ open, onClose }: Props) {
useEffect(() => {
if (!open) return;
setStep('pick'); setPages({ qso: [], addr: [], ret: [] }); setExported({}); setMarked(0);
setStep('pick'); setPages({ qso: [], addr: [], ret: [] }); setPdfPath(''); setMarked(0);
void load();
}, [open, load]);
@@ -146,13 +170,26 @@ export function LabelPrintModal({ open, onClose }: Props) {
patchStation(i, { fetching: true });
try {
const r: any = await LookupCallsignFresh(target, '');
const lines = [
r?.name, r?.address,
// A postal address needs a STREET (or at least a name and a town). A
// lookup that fell back to cty.dat answers with a country alone —
// overwriting a reviewed address with a bare country is strictly worse
// than saying nothing was found, and losing the address is what was
// reported.
const street = String(r?.address ?? '').trim();
if (!street && !(String(r?.name ?? '').trim() && String(r?.qth ?? '').trim())) {
patchStation(i, { fetching: false });
setError(t('lpr.noAddress', { call: target }));
return;
}
const lines = dedupeLines([
r?.name, street,
[r?.zip, r?.qth].filter(Boolean).join(' '),
r?.country,
].map((x: any) => String(x ?? '').trim()).filter(Boolean);
if (lines.length) patchStation(i, { address: lines.join('\n'), fetching: false });
else { patchStation(i, { fetching: false }); setError(t('lpr.noAddress', { call: target })); }
]);
patchStation(i, {
address: lines.join('\n'), fetching: false,
addrFor: target === s.call ? 'dx' : 'mgr',
});
} catch (e: any) {
patchStation(i, { fetching: false });
setError(String(e?.message ?? e));
@@ -172,7 +209,8 @@ export function LabelPrintModal({ open, onClose }: Props) {
const stock = stocks.find((x) => x.id === tpl.stock_id) ?? stocks[0];
return stock ? { tpl, stock } : null;
};
const out = { qso: [] as string[], addr: [] as string[], ret: [] as string[] };
const out = { qso: [] as Page[], addr: [] as Page[], ret: [] as Page[] };
const page = (png: string, stock: LabelStock): Page => ({ png, w_mm: stock.w_mm, h_mm: stock.h_mm });
if (doQso) {
const got = await getTpl(qsoTplId);
if (!got) throw new Error(t('lpr.noQsoTpl'));
@@ -181,7 +219,7 @@ export function LabelPrintModal({ open, onClose }: Props) {
for (const s of picked) {
const vars = { CALL: s.call, NAME: s.qsos[0]?.name ?? '', QTH: s.qsos[0]?.qth ?? '', COUNTRY: s.qsos[0]?.country ?? '', VIA: s.via, ...myVars };
for (let i = 0; i < s.qsos.length; i += per) {
out.qso.push(rasterize(got.tpl, got.stock, { vars, qsos: s.qsos.slice(i, i + per).map(toSample) }));
out.qso.push(page(rasterize(got.tpl, got.stock, { vars, qsos: s.qsos.slice(i, i + per).map(toSample) }), got.stock));
}
}
}
@@ -197,7 +235,7 @@ export function LabelPrintModal({ open, onClose }: Props) {
e.type === 'addr_block' ? { ...e, lines: s.address.split('\n') } : e),
};
const vars = { CALL: s.call, VIA: s.via, ...myVars };
out.addr.push(rasterize(tpl, got.stock, { vars, qsos: [] }));
out.addr.push(page(rasterize(tpl, got.stock, { vars, qsos: [] }), got.stock));
}
}
if (doReturn) {
@@ -210,24 +248,20 @@ export function LabelPrintModal({ open, onClose }: Props) {
};
// One per envelope that needs a return slip — the direct/via ones.
const n = Math.max(1, needAddress.length);
const page = rasterize(tpl, got.stock, { vars: { ...myVars, CALL: myVars.MYCALL }, qsos: [] });
for (let i = 0; i < n; i++) out.ret.push(page);
const one = page(rasterize(tpl, got.stock, { vars: { ...myVars, CALL: myVars.MYCALL }, qsos: [] }), got.stock);
for (let i = 0; i < n; i++) out.ret.push(one);
}
setPages(out);
} catch (e: any) { setError(String(e?.message ?? e)); }
setBuilding(false);
}
async function exportKind(kind: 'qso' | 'addr' | 'ret') {
const tplId = kind === 'qso' ? qsoTplId : kind === 'addr' ? addrTplId : retTplId;
const info = tpls.find((x) => x.id === tplId);
const stock = stocks.find((x) => x.id === info?.stock_id) ?? stocks[0];
if (!stock) return;
const allPages = [...pages.qso, ...pages.addr, ...pages.ret];
async function openPdf() {
try {
const path = await LabelExportPDF(
kind === 'qso' ? 'qso-labels' : kind === 'addr' ? 'address-labels' : 'return-labels',
stock.w_mm, stock.h_mm, pages[kind]);
if (path) setExported((m) => ({ ...m, [kind]: path as string }));
const path = await LabelOpenPDF(allPages as any);
if (path) setPdfPath(path as string);
} catch (e: any) { setError(String(e?.message ?? e)); }
}
@@ -256,16 +290,10 @@ export function LabelPrintModal({ open, onClose }: Props) {
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">{title}</span>
<span className="text-xs text-muted-foreground">{t('lpr.pageCount', { n: pg.length })}</span>
<div className="flex-1" />
{exported[kind]
? <span className="text-xs text-success flex items-center gap-1"><Check className="size-3.5" />{exported[kind]}</span>
: <Button size="sm" className="h-7 text-xs" onClick={() => void exportKind(kind)}>
<Printer className="size-3.5" /> {t('lpr.savePdf')}
</Button>}
</div>
<div className="flex gap-2 overflow-x-auto pb-1">
{pg.slice(0, 8).map((p, i) => (
<img key={i} src={p} className="h-20 border border-border rounded-sm bg-white shrink-0" />
<img key={i} src={p.png} className="h-20 border border-border rounded-sm bg-white shrink-0" />
))}
{pg.length > 8 && <span className="text-xs text-muted-foreground self-center">+{pg.length - 8}</span>}
</div>
@@ -332,7 +360,18 @@ export function LabelPrintModal({ open, onClose }: Props) {
<div className="font-mono font-bold">{s.call}
<span className="ml-2 text-xs font-normal text-muted-foreground">{s.qsos.length} QSO{s.qsos.length > 1 ? 's' : ''}</span>
</div>
<Select value={s.routing} onValueChange={(v) => patchStation(i, { routing: v as Routing })}>
<Select value={s.routing} onValueChange={(v) => {
const routing = v as Routing;
const patch: Partial<Station> = { routing };
if (s.addrFor !== 'user') {
// The box follows the routing while the operator has
// not typed in it: via → empty (fetch the manager),
// direct → the DX's own prefill.
patch.address = routing === 'via' ? '' : initialAddress(s.qsos[0]);
patch.addrFor = routing === 'via' ? 'none' : 'dx';
}
patchStation(i, patch);
}}>
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="direct">{t('lpr.direct')}</SelectItem>
@@ -360,9 +399,9 @@ export function LabelPrintModal({ open, onClose }: Props) {
className={cn('flex-1 rounded-md border bg-background px-2 py-1 text-sm font-mono',
s.address.trim() ? 'border-input' : 'border-danger')}
rows={4}
placeholder={t('lpr.addressPh')}
placeholder={s.routing === 'via' ? t('lpr.mgrAddressPh') : t('lpr.addressPh')}
value={s.address}
onChange={(ev) => patchStation(i, { address: ev.target.value })} />
onChange={(ev) => patchStation(i, { address: ev.target.value, addrFor: 'user' })} />
)}
</div>
);
@@ -407,6 +446,14 @@ export function LabelPrintModal({ open, onClose }: Props) {
{kindBlock('qso', t('lpr.kQso'))}
{kindBlock('addr', t('lpr.kAddr'))}
{kindBlock('ret', t('lpr.kRet'))}
{allPages.length > 0 && (
<div className="flex items-center gap-3">
<Button size="sm" className="h-8" onClick={() => void openPdf()}>
<Printer className="size-3.5" /> {t('lpr.openPdf', { n: allPages.length })}
</Button>
{pdfPath && <span className="text-xs text-success flex items-center gap-1"><Check className="size-3.5" />{pdfPath}</span>}
</div>
)}
{/* mark as sent */}
{(pages.qso.length > 0 || pages.addr.length > 0) && (
<div className="rounded-lg border border-border p-3 space-y-2">
+2
View File
@@ -149,6 +149,7 @@ const en: Dict = {
'lpr.noAddrTpl': "No address label template — create one in the Label Designer first.",
'lpr.pageCount': "{n} label(s) — one PDF page each",
'lpr.savePdf': "Save PDF & open",
'lpr.openPdf': "Open the PDF ({n} labels)", 'lpr.mgrAddressPh': "Manager address — use Fetch (QRZ)",
'lpr.markTitle': "Record in the log", 'lpr.markBtn': "Mark {n} QSO(s) sent",
'lpr.markHint': "Sets QSL sent = Y with this date; via becomes B for bureau and D for direct or manager.",
'lpr.markDone': "{n} QSO(s) updated.",
@@ -693,6 +694,7 @@ const fr: Dict = {
'lpr.noAddrTpl': "Aucun modèle d'étiquette adresse — créez-en un dans le Créateur d'étiquettes.",
'lpr.pageCount': "{n} étiquette(s) — une page PDF chacune",
'lpr.savePdf': "Enregistrer le PDF et ouvrir",
'lpr.openPdf': "Ouvrir le PDF ({n} étiquettes)", 'lpr.mgrAddressPh': "Adresse du manager — utilisez Récupérer (QRZ)",
'lpr.markTitle': "Enregistrer dans le log", 'lpr.markBtn': "Marquer {n} QSO envoyés",
'lpr.markHint': "Passe QSL envoyée = Y à cette date ; le moyen devient B pour bureau et D pour direct ou manager.",
'lpr.markDone': "{n} QSO mis à jour.",
+2 -2
View File
@@ -730,14 +730,14 @@ export function LabelDeleteStock(arg1:number):Promise<void>;
export function LabelDeleteTemplate(arg1:number):Promise<void>;
export function LabelExportPDF(arg1:string,arg2:number,arg3:number,arg4:Array<string>):Promise<string>;
export function LabelGetTemplate(arg1:number):Promise<string>;
export function LabelListStocks():Promise<Array<labels.Stock>>;
export function LabelListTemplates():Promise<Array<main.LabelTemplateInfo>>;
export function LabelOpenPDF(arg1:Array<main.LabelPDFPage>):Promise<string>;
export function LabelPaperQueue():Promise<Array<qso.QSO>>;
export function LabelSampleQSOs(arg1:number):Promise<Array<main.LabelSampleQSO>>;
+4 -4
View File
@@ -1398,10 +1398,6 @@ export function LabelDeleteTemplate(arg1) {
return window['go']['main']['App']['LabelDeleteTemplate'](arg1);
}
export function LabelExportPDF(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['LabelExportPDF'](arg1, arg2, arg3, arg4);
}
export function LabelGetTemplate(arg1) {
return window['go']['main']['App']['LabelGetTemplate'](arg1);
}
@@ -1414,6 +1410,10 @@ export function LabelListTemplates() {
return window['go']['main']['App']['LabelListTemplates']();
}
export function LabelOpenPDF(arg1) {
return window['go']['main']['App']['LabelOpenPDF'](arg1);
}
export function LabelPaperQueue() {
return window['go']['main']['App']['LabelPaperQueue']();
}
+16
View File
@@ -2969,6 +2969,22 @@ export namespace main {
this.samples = source["samples"];
}
}
export class LabelPDFPage {
png: string;
w_mm: number;
h_mm: number;
static createFrom(source: any = {}) {
return new LabelPDFPage(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.png = source["png"];
this.w_mm = source["w_mm"];
this.h_mm = source["h_mm"];
}
}
export class LabelSampleQSO {
callsign: string;
qso_date: string;