diff --git a/app_labels_print.go b/app_labels_print.go index 6c8ad83..54895bb 100644 --- a/app_labels_print.go +++ b/app_labels_print.go @@ -14,13 +14,13 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strings" + "time" "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 @@ -36,21 +36,30 @@ func (a *App) LabelPaperQueue() ([]qso.QSO, error) { }) } -// 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) { +// LabelPDFPage is one page of the session's output: a rasterised label and its +// physical size. Sizes vary WITHIN one document — the operator asked for a +// single PDF holding QSO labels, addresses and return labels together, and PDF +// pages each carry their own MediaBox, so a 90×29 page can follow a 100×62 one. +type LabelPDFPage struct { + PNG string `json:"png"` // base64, data-URL prefix tolerated + WMm float64 `json:"w_mm"` + HMm float64 `json:"h_mm"` +} + +// LabelOpenPDF writes the session's labels to ONE temporary PDF and opens it in +// the system viewer, from which the operator prints. No save dialog by choice: +// the file is a print run, not a document to keep — anyone who wants to keep it +// saves from the viewer. +func (a *App) LabelOpenPDF(pages []LabelPDFPage) (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 { + for i, pg := range pages { + if pg.WMm < 5 || pg.HMm < 5 || pg.WMm > 400 || pg.HMm > 400 { + return "", fmt.Errorf("page %d: label size out of range", i+1) + } + p := pg.PNG if idx := strings.Index(p, ","); idx >= 0 && strings.Contains(p[:idx], "base64") { p = p[idx+1:] } @@ -58,7 +67,7 @@ func (a *App) LabelExportPDF(defaultName string, wMm, hMm float64, pages []strin if err != nil { return "", fmt.Errorf("page %d: %w", i+1, err) } - if err := doc.AddImagePage(raw, wMm, hMm); err != nil { + if err := doc.AddImagePage(raw, pg.WMm, pg.HMm); err != nil { return "", fmt.Errorf("page %d: %w", i+1, err) } } @@ -66,32 +75,16 @@ func (a *App) LabelExportPDF(defaultName string, wMm, hMm float64, pages []strin 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 - } + // A timestamped name in the temp dir: two sessions in one evening must not + // fight over the file, least of all while a viewer holds the first one open. + path := filepath.Join(os.TempDir(), fmt.Sprintf("opslog-labels-%s.pdf", time.Now().Format("20060102-150405"))) 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. + applog.Printf("labels: wrote %d page(s) to %s", len(pages), path) if err := exec.Command("rundll32", "url.dll,FileProtocolHandler", path).Start(); err != nil { applog.Printf("labels: could not open the PDF viewer: %v", err) + return "", fmt.Errorf("the PDF was written to %s but no viewer opened: %w", path, err) } return path, nil } diff --git a/changelog.json b/changelog.json index 8495905..1832fd5 100644 --- a/changelog.json +++ b/changelog.json @@ -18,7 +18,7 @@ "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 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." + "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 (the MANAGER’s address when routing says via), then ONE PDF holding every label at its exact size, opened straight in the viewer to print from. 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.", @@ -36,7 +36,7 @@ "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.", - "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." + "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 (l'adresse du MANAGER quand le routage le dit), puis UN PDF contenant toutes les étiquettes à leur taille exacte, ouvert directement dans le lecteur pour impression. La fin de session marque les contacts envoyés avec la date et le moyen." ] }, { diff --git a/frontend/src/components/labels/LabelPrintModal.tsx b/frontend/src/components/labels/LabelPrintModal.tsx index e627e6f..923d2ab 100644 --- a/frontend/src/components/labels/LabelPrintModal.tsx +++ b/frontend/src/components/labels/LabelPrintModal.tsx @@ -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): 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>({}); - // 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>({}); + 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) {
{title} {t('lpr.pageCount', { n: pg.length })} -
- {exported[kind] - ? {exported[kind]} - : }
{pg.slice(0, 8).map((p, i) => ( - + ))} {pg.length > 8 && +{pg.length - 8}}
@@ -332,7 +360,18 @@ export function LabelPrintModal({ open, onClose }: Props) {
{s.call} {s.qsos.length} QSO{s.qsos.length > 1 ? 's' : ''}
- { + const routing = v as Routing; + const patch: Partial = { 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); + }}> {t('lpr.direct')} @@ -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' })} /> )}
); @@ -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 && ( +
+ + {pdfPath && {pdfPath}} +
+ )} {/* mark as sent */} {(pages.qso.length > 0 || pages.addr.length > 0) && (
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 923740b..0043d76 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -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.", diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index b957cd6..977d611 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -730,14 +730,14 @@ export function LabelDeleteStock(arg1:number):Promise; export function LabelDeleteTemplate(arg1:number):Promise; -export function LabelExportPDF(arg1:string,arg2:number,arg3:number,arg4:Array):Promise; - export function LabelGetTemplate(arg1:number):Promise; export function LabelListStocks():Promise>; export function LabelListTemplates():Promise>; +export function LabelOpenPDF(arg1:Array):Promise; + export function LabelPaperQueue():Promise>; export function LabelSampleQSOs(arg1:number):Promise>; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index e60a95e..97768c4 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -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'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 213e0ff..7d1ef4b 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -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;