From 5ee0ade54b07e85f64bb558c7796d4f0057b437c Mon Sep 17 00:00:00 2001 From: rouggy Date: Fri, 28 Aug 2026 19:58:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(labels):=20print=20the=20labels=20?= =?UTF-8?q?=E2=80=94=20queue,=20address=20review,=20PDF,=20log=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The printing session in three steps. The worklist is the paper queue (ADIF qsl_sent R/Q), grouped by callsign since several QSOs of one station share a card. Each recipient is reviewed before anything prints: routing first — via manager when qsl_via says so, direct when an address is known, bureau otherwise — then the address itself, editable and fetchable from QRZ (the MANAGER's address when routing says via). What is printed is the reviewed text verbatim, not a re-resolution that could differ from what was checked. One PDF per label kind, never one file for all: a roll printer holds one stock at a time, and a file mixing 29 mm addresses with 62 mm QSO labels could not be printed at all. Pages are rasterised by the designer's own renderer at the stock's dpi and carried into the PDF untouched — internal/pdf is a hand-written image-page writer (DeviceGray + flate: monochrome, lossless, no cgo) because that is the entire need. Bureau stations get no address label (no envelope); return labels are printed one per envelope. Finishing offers the log update: QSL_SENT=Y, the chosen date, via B or D per routing — through the same BulkUpdateQSL the paper view uses. --- app_labels_print.go | 97 ++++ changelog.json | 6 +- frontend/src/App.tsx | 5 + .../src/components/labels/LabelPrintModal.tsx | 455 ++++++++++++++++++ frontend/src/components/labels/labelRender.ts | 15 + frontend/src/lib/i18n.tsx | 42 ++ frontend/wailsjs/go/main/App.d.ts | 4 + frontend/wailsjs/go/main/App.js | 8 + frontend/wailsjs/go/models.ts | 2 + internal/pdf/pdf.go | 131 +++++ internal/pdf/pdf_test.go | 54 +++ internal/qso/qso.go | 13 +- 12 files changed, 828 insertions(+), 4 deletions(-) create mode 100644 app_labels_print.go create mode 100644 frontend/src/components/labels/LabelPrintModal.tsx create mode 100644 internal/pdf/pdf.go create mode 100644 internal/pdf/pdf_test.go diff --git a/app_labels_print.go b/app_labels_print.go new file mode 100644 index 0000000..6c8ad83 --- /dev/null +++ b/app_labels_print.go @@ -0,0 +1,97 @@ +package main + +// The label PRINT path: pick the paper-QSL queue, review each address, choose +// the routing, and export PDFs whose pages are the exact label size — one PDF +// per label kind, because a roll printer holds one stock at a time and a file +// mixing 29 mm addresses with 62 mm QSO labels could not be printed at all. +// +// The pages arrive from the frontend already rasterised: the designer's canvas +// renderer draws them at the stock's dpi, so what was previewed is — pixel for +// pixel — what lands in the PDF. Go only carries them to disk. + +import ( + "encoding/base64" + "fmt" + "os" + "os/exec" + "strings" + + "hamlog/internal/applog" + "hamlog/internal/pdf" + "hamlog/internal/qso" + + wruntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// LabelPaperQueue returns the contacts whose paper QSL is REQUESTED or QUEUED +// (ADIF qsl_sent R/Q) — the natural worklist for a labelling session. The +// frontend groups them by callsign. +func (a *App) LabelPaperQueue() ([]qso.QSO, error) { + if a.qso == nil { + return nil, fmt.Errorf("db not initialized") + } + return a.qso.List(a.ctx, qso.ListFilter{ + QSLSentIn: []string{"R", "Q"}, + Limit: 10_000, + }) +} + +// LabelExportPDF writes one PDF of label pages and opens it in the system +// viewer, from which the operator prints. pages are base64 PNGs (data-URL +// prefix tolerated), all of the same wMm×hMm stock. +// +// Returns the chosen path ("" if the operator cancelled the dialog — not an +// error, they changed their mind). +func (a *App) LabelExportPDF(defaultName string, wMm, hMm float64, pages []string) (string, error) { + if len(pages) == 0 { + return "", fmt.Errorf("nothing to print") + } + if wMm < 5 || hMm < 5 || wMm > 400 || hMm > 400 { + return "", fmt.Errorf("label size out of range") + } + var doc pdf.Doc + for i, p := range pages { + if idx := strings.Index(p, ","); idx >= 0 && strings.Contains(p[:idx], "base64") { + p = p[idx+1:] + } + raw, err := base64.StdEncoding.DecodeString(p) + if err != nil { + return "", fmt.Errorf("page %d: %w", i+1, err) + } + if err := doc.AddImagePage(raw, wMm, hMm); err != nil { + return "", fmt.Errorf("page %d: %w", i+1, err) + } + } + out, err := doc.Bytes() + if err != nil { + return "", err + } + name := strings.TrimSpace(defaultName) + if name == "" { + name = "labels.pdf" + } + if !strings.HasSuffix(strings.ToLower(name), ".pdf") { + name += ".pdf" + } + path, err := wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{ + DefaultFilename: name, + Title: "Save label PDF", + Filters: []wruntime.FileFilter{{DisplayName: "PDF", Pattern: "*.pdf"}}, + }) + if err != nil { + return "", err + } + if path == "" { + return "", nil // cancelled + } + if err := os.WriteFile(path, out, 0o644); err != nil { + return "", err + } + applog.Printf("labels: wrote %d page(s) (%.0f×%.0f mm) to %s", len(pages), wMm, hMm, path) + // Opened in the default PDF viewer — printing happens there, by design: the + // operator asked for a file they can check and print with their own tool. + if err := exec.Command("rundll32", "url.dll,FileProtocolHandler", path).Start(); err != nil { + applog.Printf("labels: could not open the PDF viewer: %v", err) + } + return path, nil +} diff --git a/changelog.json b/changelog.json index 8ed094a..8495905 100644 --- a/changelog.json +++ b/changelog.json @@ -17,7 +17,8 @@ "TCI panorama spots: the colour was sent as a negative number and ExpertSDR dropped every spot in silence. It now goes out as the unsigned ARGB integer the protocol document uses, and the first few spots are written to the log verbatim.", "Cluster: “Group duplicates” was hiding the same station on OTHER bands and modes — a DXpedition spotted on five bands showed as one line and four slots disappeared. A duplicate is now what it should always have been: the same station on the same band and mode.", "LoTW: the downloaded confirmations list gains a Station column, so a list spanning several callsigns says which one each confirmation belongs to. Shown only when the report actually carries more than the one station.", - "Label Designer (Tools): design the labels for paper QSL work — a QSO label for the card (repeating QSO table, several contacts of the same station per label) and address labels for the envelope. Label sizes are profiles in millimetres with margins, seeded with the common Brother DK rolls; elements are dragged in place on a millimetre-true preview fed with your latest contacts. Printing to PDF comes next." + "Label Designer (Tools): design the labels for paper QSL work — a QSO label for the card (repeating QSO table, several contacts of the same station per label) and address labels for the envelope. Label sizes are profiles in millimetres with margins, seeded with the common Brother DK rolls; elements are dragged in place on a millimetre-true preview fed with your latest contacts. Printing to PDF comes next.", + "Label printing (Tools → Print QSL labels): a three-step session — pick from the paper-QSL queue (sent status R/Q), check each address with a routing choice (direct / bureau / via manager) and a QRZ fetch, then export one PDF per label kind (QSO / address / return) with pages at the exact label size, ready to print from any PDF viewer. Finishing marks the contacts sent with the date and the via." ], "fr": [ "Téléchargement LoTW : les détails QSL (date du QSL, locator, état, comté) deviennent optionnels et désactivés par défaut — LoTW met environ dix fois plus longtemps à construire ce rapport, vingt minutes contre deux sur le même compte, et marquer une confirmation n'en a pas besoin. Toujours demandés automatiquement quand on ajoute les QSO absents du log.", @@ -34,7 +35,8 @@ "Spots sur le panorama TCI : la couleur partait en nombre négatif et ExpertSDR écartait chaque spot en silence. Elle est désormais envoyée en entier ARGB non signé, comme dans la documentation du protocole, et les premiers spots sont écrits tels quels dans le journal.", "Cluster : « Grouper les doublons » masquait la même station sur les AUTRES bandes et modes — une expédition spottée sur cinq bandes n'affichait qu'une ligne et quatre créneaux disparaissaient. Un doublon est désormais ce qu'il aurait toujours dû être : la même station sur la même bande et le même mode.", "LoTW : la liste des confirmations téléchargées gagne une colonne Station, pour savoir à quel indicatif appartient chaque confirmation quand le téléchargement en couvre plusieurs. Affichée seulement si le rapport en contient effectivement.", - "Créateur d'étiquettes (Outils) : dessinez les étiquettes de vos QSL papier — étiquette QSO pour la carte (tableau de QSO répétable, plusieurs contacts de la même station par étiquette) et étiquettes adresse pour l'enveloppe. Les formats sont des profils en millimètres avec marges, préremplis avec les rouleaux Brother DK courants ; les éléments se placent à la souris sur un aperçu fidèle au millimètre nourri de vos derniers contacts. L'impression en PDF viendra ensuite." + "Créateur d'étiquettes (Outils) : dessinez les étiquettes de vos QSL papier — étiquette QSO pour la carte (tableau de QSO répétable, plusieurs contacts de la même station par étiquette) et étiquettes adresse pour l'enveloppe. Les formats sont des profils en millimètres avec marges, préremplis avec les rouleaux Brother DK courants ; les éléments se placent à la souris sur un aperçu fidèle au millimètre nourri de vos derniers contacts. L'impression en PDF viendra ensuite.", + "Impression des étiquettes (Outils → Imprimer les étiquettes QSL) : une session en trois étapes — choisir dans la file QSL papier (statut envoyé R/Q), vérifier chaque adresse avec le routage (direct / bureau / via manager) et une récupération QRZ, puis exporter un PDF par type d'étiquette (QSO / adresse / retour) aux pages à la taille exacte de l'étiquette, à imprimer depuis n'importe quel lecteur PDF. La fin de session marque les contacts envoyés avec la date et le moyen." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2e12943..09037bd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -71,6 +71,7 @@ import { APP_VERSION, APP_AUTHOR } from '@/version'; import { QSLManagerPanel } from '@/components/QSLManagerModal'; import { QslDesignerModal } from '@/components/qsl/QslDesignerModal'; import { LabelDesignerModal } from '@/components/labels/LabelDesignerModal'; +import { LabelPrintModal } from '@/components/labels/LabelPrintModal'; import { SendEQSLModal } from '@/components/qsl/SendEQSLModal'; import { AutoEQSL } from '@/components/qsl/AutoEQSL'; import { ConfirmDialog } from '@/components/ConfirmDialog'; @@ -1269,6 +1270,7 @@ export default function App() { } const [qslDesignerOpen, setQslDesignerOpen] = useState(false); const [labelDesignerOpen, setLabelDesignerOpen] = useState(false); + const [labelPrintOpen, setLabelPrintOpen] = useState(false); const [eqslQsoId, setEqslQsoId] = useState(null); // QSO being sent as eQSL function closeQslTab() { setQslTabOpen(false); @@ -5001,6 +5003,7 @@ export default function App() { { type: 'item', label: t('gsm.title'), action: 'tools.grids' }, { type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' }, { type: 'item', label: t('tools.labelDesigner'), action: 'tools.labeldesigner' }, + { type: 'item', label: t('tools.labelPrint'), action: 'tools.labelprint' }, { type: 'separator' }, { type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' }, { type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' }, @@ -5055,6 +5058,7 @@ export default function App() { case 'tools.grids': openGridsTab(); break; case 'tools.qsldesigner': setQslDesignerOpen(true); break; case 'tools.labeldesigner': setLabelDesignerOpen(true); break; + case 'tools.labelprint': setLabelPrintOpen(true); break; case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break; case 'tools.dvk': setDvkEnabled((v) => !v); break; case 'tools.cwdecoder': toggleCwDecoder(); break; @@ -8717,6 +8721,7 @@ export default function App() { /> setQslDesignerOpen(false)} /> setLabelDesignerOpen(false)} /> + setLabelPrintOpen(false)} /> void } + +type Routing = 'direct' | 'bureau' | 'via'; + +interface Station { + call: string; + qsos: any[]; // raw QSO rows, newest first + checked: boolean; + routing: Routing; + via: string; // manager callsign when routing = via + address: string; // multiline, the text that will be printed — verbatim + fetching?: boolean; +} + +interface TplInfo { id: number; name: string; kind: string; stock_id: number; is_default: boolean } + +// One label's worth of QSO rows, in the designer's sample shape. +function toSample(q: any): LabelSample { + const d = new Date(q.qso_date); + const pad = (n: number) => String(n).padStart(2, '0'); + return { + callsign: q.callsign ?? '', + qso_date: `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`, + time_on: `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`, + band: q.band ?? '', mode: q.mode ?? '', + freq: q.freq_hz ? (q.freq_hz / 1e6).toFixed(3) : '', + rst_sent: q.rst_sent ?? '', rst_rcvd: q.rst_rcvd ?? '', + name: q.name ?? '', qth: q.qth ?? '', country: q.country ?? '', + }; +} + +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'); +} + +export function LabelPrintModal({ open, onClose }: Props) { + const { t } = useI18n(); + const [step, setStep] = useState<'pick' | 'review' | 'print'>('pick'); + const [stations, setStations] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const [stocks, setStocks] = useState([]); + const [tpls, setTpls] = useState([]); + const [doQso, setDoQso] = useState(true); + const [doAddr, setDoAddr] = useState(true); + const [doReturn, setDoReturn] = useState(false); + const [qsoTplId, setQsoTplId] = useState(0); + const [addrTplId, setAddrTplId] = useState(0); + const [retTplId, setRetTplId] = useState(0); + const [myAddress, setMyAddress] = useState(''); + const [myVars, setMyVars] = useState>({}); + + // Rasterised pages per kind, built when entering step 3. + const [pages, setPages] = useState<{ qso: string[]; addr: string[]; ret: string[] }>({ qso: [], addr: [], ret: [] }); + const [building, setBuilding] = useState(false); + const [exported, setExported] = useState>({}); + const [markDate, setMarkDate] = useState(() => new Date().toISOString().slice(0, 10)); + const [marked, setMarked] = useState(0); + + const load = useCallback(async () => { + setLoading(true); setError(''); + try { + const rows: any[] = (await LabelPaperQueue()) ?? []; + // Group by callsign, newest first inside each group. + const by = new Map(); + for (const q of rows) { + const c = String(q.callsign ?? '').toUpperCase(); + if (!by.has(c)) by.set(c, []); + by.get(c)!.push(q); + } + setStations([...by.entries()].map(([call, qsos]) => { + const via = String(qsos[0]?.qsl_via ?? '').trim(); + const address = 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, + }; + })); + const st = (await LabelListStocks()) as any as LabelStock[]; + setStocks(st); + const tl = ((await LabelListTemplates()) ?? []) as any as TplInfo[]; + setTpls(tl); + const def = (kind: string) => tl.find((x) => x.kind === kind && x.is_default) ?? tl.find((x) => x.kind === kind); + setQsoTplId(def('qso')?.id ?? 0); + setAddrTplId(def('address')?.id ?? 0); + setRetTplId(def('address')?.id ?? 0); + const p: any = await GetActiveProfile().catch(() => null); + const mv = { + MYCALL: p?.callsign ?? '', MYNAME: p?.op_name ?? p?.operator ?? '', + MYSTREET: p?.my_street ?? '', MYZIP: p?.my_postal_code ?? '', + MYCITY: p?.my_city ?? '', MYCOUNTRY: p?.my_country ?? '', + }; + setMyVars(mv); + setMyAddress([mv.MYNAME && `${mv.MYNAME} · ${mv.MYCALL}` || mv.MYCALL, mv.MYSTREET, + [mv.MYZIP, mv.MYCITY].filter(Boolean).join(' '), mv.MYCOUNTRY] + .map((x) => String(x ?? '').trim()).filter(Boolean).join('\n')); + } catch (e: any) { setError(String(e?.message ?? e)); } + setLoading(false); + }, []); + + useEffect(() => { + if (!open) return; + setStep('pick'); setPages({ qso: [], addr: [], ret: [] }); setExported({}); setMarked(0); + void load(); + }, [open, load]); + + const patchStation = (i: number, p: Partial) => + setStations((l) => l.map((s, j) => (j === i ? { ...s, ...p } : s))); + + async function fetchAddress(i: number) { + const s = stations[i]; + const target = s.routing === 'via' && s.via.trim() ? s.via.trim().toUpperCase() : s.call; + patchStation(i, { fetching: true }); + try { + const r: any = await LookupCallsignFresh(target, ''); + const lines = [ + r?.name, r?.address, + [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 })); } + } catch (e: any) { + patchStation(i, { fetching: false }); + setError(String(e?.message ?? e)); + } + } + + const picked = stations.filter((s) => s.checked); + const needAddress = picked.filter((s) => s.routing !== 'bureau'); + + // ── step 3: build the pages ─────────────────────────────────────────── + async function buildPages() { + setBuilding(true); setError(''); + try { + const getTpl = async (id: number): Promise<{ tpl: LabelTemplate; stock: LabelStock } | null> => { + if (!id) return null; + const tpl = JSON.parse(await LabelGetTemplate(id)) as LabelTemplate; + 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[] }; + if (doQso) { + const got = await getTpl(qsoTplId); + if (!got) throw new Error(t('lpr.noQsoTpl')); + const table = got.tpl.elements.find((e) => e.type === 'qso_table'); + const per = Math.max(1, table?.rows_max ?? 4); + 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) })); + } + } + } + if (doAddr) { + const got = await getTpl(addrTplId); + if (!got) throw new Error(t('lpr.noAddrTpl')); + for (const s of needAddress) { + // The REVIEWED text wins: the template's address block prints these + // lines verbatim — that is what "check the address first" means. + const tpl: LabelTemplate = { + ...got.tpl, + elements: got.tpl.elements.map((e) => + 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: [] })); + } + } + if (doReturn) { + const got = await getTpl(retTplId); + if (!got) throw new Error(t('lpr.noAddrTpl')); + const tpl: LabelTemplate = { + ...got.tpl, + elements: got.tpl.elements.map((e) => + e.type === 'addr_block' ? { ...e, lines: myAddress.split('\n') } : e), + }; + // 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); + } + 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; + 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 })); + } catch (e: any) { setError(String(e?.message ?? e)); } + } + + async function markSent() { + try { + const date = markDate.replace(/-/g, ''); + let n = 0; + const groups: Array<[Station[], string]> = [ + [picked.filter((s) => s.routing === 'bureau'), 'B'], + [picked.filter((s) => s.routing !== 'bureau'), 'D'], + ]; + for (const [list, via] of groups) { + const ids = list.flatMap((s) => s.qsos.map((q) => q.id)); + if (ids.length === 0) continue; + n += (await BulkUpdateQSL(ids, { sent_status: 'Y', sent_date: date, via, rcvd_status: '', rcvd_date: '', rcvd_via: '', notes: '', comment: '' } as any)) as number; + } + setMarked(n); + } catch (e: any) { setError(String(e?.message ?? e)); } + } + + const kindBlock = (kind: 'qso' | 'addr' | 'ret', title: string) => { + const pg = pages[kind]; + if (pg.length === 0) return null; + return ( +
+
+ {title} + {t('lpr.pageCount', { n: pg.length })} +
+ {exported[kind] + ? {exported[kind]} + : } +
+
+ {pg.slice(0, 8).map((p, i) => ( + + ))} + {pg.length > 8 && +{pg.length - 8}} +
+
+ ); + }; + + if (!open) return null; + const sumQsos = picked.reduce((a, s) => a + s.qsos.length, 0); + + return ( +
+
+
+ + {t('lpr.title')} + + {step === 'pick' ? t('lpr.step1') : step === 'review' ? t('lpr.step2') : t('lpr.step3')} + +
+ +
+ {error &&
{error}
} + +
+ {loading ? ( +
+ … +
+ ) : step === 'pick' ? ( + stations.length === 0 ? ( +
+ {t('lpr.emptyQueue')} +
+ ) : ( +
+
+ + +
+ {t('lpr.pickSummary', { s: picked.length, q: sumQsos })} +
+ {stations.map((s, i) => ( + + ))} +
+ ) + ) : step === 'review' ? ( +
+ {picked.map((s) => { + const i = stations.indexOf(s); + return ( +
+
+
{s.call} + {s.qsos.length} QSO{s.qsos.length > 1 ? 's' : ''} +
+ + {s.routing === 'via' && ( + patchStation(i, { via: ev.target.value })} /> + )} + {s.routing !== 'bureau' && ( + + )} +
+ {s.routing === 'bureau' ? ( +
{t('lpr.bureauHint')}
+ ) : ( +