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
+28 -35
View File
@@ -14,13 +14,13 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
"time"
"hamlog/internal/applog" "hamlog/internal/applog"
"hamlog/internal/pdf" "hamlog/internal/pdf"
"hamlog/internal/qso" "hamlog/internal/qso"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
) )
// LabelPaperQueue returns the contacts whose paper QSL is REQUESTED or QUEUED // 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 // LabelPDFPage is one page of the session's output: a rasterised label and its
// viewer, from which the operator prints. pages are base64 PNGs (data-URL // physical size. Sizes vary WITHIN one document — the operator asked for a
// prefix tolerated), all of the same wMm×hMm stock. // 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.
// Returns the chosen path ("" if the operator cancelled the dialog — not an type LabelPDFPage struct {
// error, they changed their mind). PNG string `json:"png"` // base64, data-URL prefix tolerated
func (a *App) LabelExportPDF(defaultName string, wMm, hMm float64, pages []string) (string, error) { 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 { if len(pages) == 0 {
return "", fmt.Errorf("nothing to print") 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 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") { if idx := strings.Index(p, ","); idx >= 0 && strings.Contains(p[:idx], "base64") {
p = p[idx+1:] p = p[idx+1:]
} }
@@ -58,7 +67,7 @@ func (a *App) LabelExportPDF(defaultName string, wMm, hMm float64, pages []strin
if err != nil { if err != nil {
return "", fmt.Errorf("page %d: %w", i+1, err) 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) 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 { if err != nil {
return "", err return "", err
} }
name := strings.TrimSpace(defaultName) // A timestamped name in the temp dir: two sessions in one evening must not
if name == "" { // fight over the file, least of all while a viewer holds the first one open.
name = "labels.pdf" path := filepath.Join(os.TempDir(), fmt.Sprintf("opslog-labels-%s.pdf", time.Now().Format("20060102-150405")))
}
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 { if err := os.WriteFile(path, out, 0o644); err != nil {
return "", err return "", err
} }
applog.Printf("labels: wrote %d page(s) (%.0f×%.0f mm) to %s", len(pages), wMm, hMm, path) applog.Printf("labels: wrote %d page(s) to %s", len(pages), 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 { if err := exec.Command("rundll32", "url.dll,FileProtocolHandler", path).Start(); err != nil {
applog.Printf("labels: could not open the PDF viewer: %v", err) 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 return path, nil
} }
+2 -2
View File
@@ -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.", "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.", "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." "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 MANAGERs 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": [ "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.", "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.", "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.", "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." "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."
] ]
}, },
{ {
@@ -4,9 +4,10 @@
// callsign — several QSOs of the same station share one card. // callsign — several QSOs of the same station share one card.
// 2. REVIEW each recipient: routing (direct / bureau / via manager), the // 2. REVIEW each recipient: routing (direct / bureau / via manager), the
// address checked and edited by hand, a QRZ fetch to fill it. // 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 // 3. PRINT ONE PDF holding every label of the session (each page at its
// time, so QSO labels, addresses and return labels are separate // own physical size), opened straight in the system viewer — the
// files — then mark the contacts sent (date + via) in the log. // 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: // The pages are rasterised by the designer's own renderer at the stock's dpi:
// what the designer previewed is what the PDF carries. // what the designer previewed is what the PDF carries.
@@ -22,7 +23,7 @@ import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { import {
LabelPaperQueue, LabelListStocks, LabelListTemplates, LabelGetTemplate, LabelPaperQueue, LabelListStocks, LabelListTemplates, LabelGetTemplate,
LabelExportPDF, LookupCallsignFresh, BulkUpdateQSL, GetActiveProfile, LabelOpenPDF, LookupCallsignFresh, BulkUpdateQSL, GetActiveProfile,
} from '../../../wailsjs/go/main/App'; } from '../../../wailsjs/go/main/App';
import type { LabelSample, LabelStock, LabelTemplate } from './labelTypes'; import type { LabelSample, LabelStock, LabelTemplate } from './labelTypes';
import { rasterize } from './labelRender'; import { rasterize } from './labelRender';
@@ -38,6 +39,10 @@ interface Station {
routing: Routing; routing: Routing;
via: string; // manager callsign when routing = via via: string; // manager callsign when routing = via
address: string; // multiline, the text that will be printed — verbatim 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; 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 { function initialAddress(q: any): string {
const lines = [q.name, q.address, q.qth, q.country] return dedupeLines([q.name, q.address, q.qth, q.country]).join('\n');
.map((x: any) => String(x ?? '').trim()).filter(Boolean);
return lines.join('\n');
} }
export function LabelPrintModal({ open, onClose }: Props) { export function LabelPrintModal({ open, onClose }: Props) {
@@ -82,10 +101,12 @@ export function LabelPrintModal({ open, onClose }: Props) {
const [myAddress, setMyAddress] = useState(''); const [myAddress, setMyAddress] = useState('');
const [myVars, setMyVars] = useState<Record<string, string>>({}); const [myVars, setMyVars] = useState<Record<string, string>>({});
// Rasterised pages per kind, built when entering step 3. // Rasterised pages per kind — kept separate for the previews, concatenated
const [pages, setPages] = useState<{ qso: string[]; addr: string[]; ret: string[] }>({ qso: [], addr: [], ret: [] }); // (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 [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 [markDate, setMarkDate] = useState(() => new Date().toISOString().slice(0, 10));
const [marked, setMarked] = useState(0); const [marked, setMarked] = useState(0);
@@ -102,11 +123,14 @@ export function LabelPrintModal({ open, onClose }: Props) {
} }
setStations([...by.entries()].map(([call, qsos]) => { setStations([...by.entries()].map(([call, qsos]) => {
const via = String(qsos[0]?.qsl_via ?? '').trim(); 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 { return {
call, qsos, checked: true, call, qsos, checked: true,
routing: via ? 'via' as Routing : (address.split('\n').length >= 3 ? 'direct' as Routing : 'bureau' as Routing), 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[]; const st = (await LabelListStocks()) as any as LabelStock[];
@@ -133,7 +157,7 @@ export function LabelPrintModal({ open, onClose }: Props) {
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setStep('pick'); setPages({ qso: [], addr: [], ret: [] }); setExported({}); setMarked(0); setStep('pick'); setPages({ qso: [], addr: [], ret: [] }); setPdfPath(''); setMarked(0);
void load(); void load();
}, [open, load]); }, [open, load]);
@@ -146,13 +170,26 @@ export function LabelPrintModal({ open, onClose }: Props) {
patchStation(i, { fetching: true }); patchStation(i, { fetching: true });
try { try {
const r: any = await LookupCallsignFresh(target, ''); const r: any = await LookupCallsignFresh(target, '');
const lines = [ // A postal address needs a STREET (or at least a name and a town). A
r?.name, r?.address, // 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?.zip, r?.qth].filter(Boolean).join(' '),
r?.country, r?.country,
].map((x: any) => String(x ?? '').trim()).filter(Boolean); ]);
if (lines.length) patchStation(i, { address: lines.join('\n'), fetching: false }); patchStation(i, {
else { patchStation(i, { fetching: false }); setError(t('lpr.noAddress', { call: target })); } address: lines.join('\n'), fetching: false,
addrFor: target === s.call ? 'dx' : 'mgr',
});
} catch (e: any) { } catch (e: any) {
patchStation(i, { fetching: false }); patchStation(i, { fetching: false });
setError(String(e?.message ?? e)); 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]; const stock = stocks.find((x) => x.id === tpl.stock_id) ?? stocks[0];
return stock ? { tpl, stock } : null; 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) { if (doQso) {
const got = await getTpl(qsoTplId); const got = await getTpl(qsoTplId);
if (!got) throw new Error(t('lpr.noQsoTpl')); if (!got) throw new Error(t('lpr.noQsoTpl'));
@@ -181,7 +219,7 @@ export function LabelPrintModal({ open, onClose }: Props) {
for (const s of picked) { 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 }; 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) { 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), e.type === 'addr_block' ? { ...e, lines: s.address.split('\n') } : e),
}; };
const vars = { CALL: s.call, VIA: s.via, ...myVars }; 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) { if (doReturn) {
@@ -210,24 +248,20 @@ export function LabelPrintModal({ open, onClose }: Props) {
}; };
// One per envelope that needs a return slip — the direct/via ones. // One per envelope that needs a return slip — the direct/via ones.
const n = Math.max(1, needAddress.length); const n = Math.max(1, needAddress.length);
const page = rasterize(tpl, got.stock, { vars: { ...myVars, CALL: myVars.MYCALL }, qsos: [] }); 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(page); for (let i = 0; i < n; i++) out.ret.push(one);
} }
setPages(out); setPages(out);
} catch (e: any) { setError(String(e?.message ?? e)); } } catch (e: any) { setError(String(e?.message ?? e)); }
setBuilding(false); setBuilding(false);
} }
async function exportKind(kind: 'qso' | 'addr' | 'ret') { const allPages = [...pages.qso, ...pages.addr, ...pages.ret];
const tplId = kind === 'qso' ? qsoTplId : kind === 'addr' ? addrTplId : retTplId;
const info = tpls.find((x) => x.id === tplId); async function openPdf() {
const stock = stocks.find((x) => x.id === info?.stock_id) ?? stocks[0];
if (!stock) return;
try { try {
const path = await LabelExportPDF( const path = await LabelOpenPDF(allPages as any);
kind === 'qso' ? 'qso-labels' : kind === 'addr' ? 'address-labels' : 'return-labels', if (path) setPdfPath(path as string);
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)); } } 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"> <div className="flex items-center gap-2">
<span className="text-sm font-semibold">{title}</span> <span className="text-sm font-semibold">{title}</span>
<span className="text-xs text-muted-foreground">{t('lpr.pageCount', { n: pg.length })}</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>
<div className="flex gap-2 overflow-x-auto pb-1"> <div className="flex gap-2 overflow-x-auto pb-1">
{pg.slice(0, 8).map((p, i) => ( {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>} {pg.length > 8 && <span className="text-xs text-muted-foreground self-center">+{pg.length - 8}</span>}
</div> </div>
@@ -332,7 +360,18 @@ export function LabelPrintModal({ open, onClose }: Props) {
<div className="font-mono font-bold">{s.call} <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> <span className="ml-2 text-xs font-normal text-muted-foreground">{s.qsos.length} QSO{s.qsos.length > 1 ? 's' : ''}</span>
</div> </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> <SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="direct">{t('lpr.direct')}</SelectItem> <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', className={cn('flex-1 rounded-md border bg-background px-2 py-1 text-sm font-mono',
s.address.trim() ? 'border-input' : 'border-danger')} s.address.trim() ? 'border-input' : 'border-danger')}
rows={4} rows={4}
placeholder={t('lpr.addressPh')} placeholder={s.routing === 'via' ? t('lpr.mgrAddressPh') : t('lpr.addressPh')}
value={s.address} value={s.address}
onChange={(ev) => patchStation(i, { address: ev.target.value })} /> onChange={(ev) => patchStation(i, { address: ev.target.value, addrFor: 'user' })} />
)} )}
</div> </div>
); );
@@ -407,6 +446,14 @@ export function LabelPrintModal({ open, onClose }: Props) {
{kindBlock('qso', t('lpr.kQso'))} {kindBlock('qso', t('lpr.kQso'))}
{kindBlock('addr', t('lpr.kAddr'))} {kindBlock('addr', t('lpr.kAddr'))}
{kindBlock('ret', t('lpr.kRet'))} {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 */} {/* mark as sent */}
{(pages.qso.length > 0 || pages.addr.length > 0) && ( {(pages.qso.length > 0 || pages.addr.length > 0) && (
<div className="rounded-lg border border-border p-3 space-y-2"> <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.noAddrTpl': "No address label template — create one in the Label Designer first.",
'lpr.pageCount': "{n} label(s) — one PDF page each", 'lpr.pageCount': "{n} label(s) — one PDF page each",
'lpr.savePdf': "Save PDF & open", '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.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.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.", '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.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.pageCount': "{n} étiquette(s) — une page PDF chacune",
'lpr.savePdf': "Enregistrer le PDF et ouvrir", '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.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.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.", '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 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 LabelGetTemplate(arg1:number):Promise<string>;
export function LabelListStocks():Promise<Array<labels.Stock>>; export function LabelListStocks():Promise<Array<labels.Stock>>;
export function LabelListTemplates():Promise<Array<main.LabelTemplateInfo>>; 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 LabelPaperQueue():Promise<Array<qso.QSO>>;
export function LabelSampleQSOs(arg1:number):Promise<Array<main.LabelSampleQSO>>; 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); 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) { export function LabelGetTemplate(arg1) {
return window['go']['main']['App']['LabelGetTemplate'](arg1); return window['go']['main']['App']['LabelGetTemplate'](arg1);
} }
@@ -1414,6 +1410,10 @@ export function LabelListTemplates() {
return window['go']['main']['App']['LabelListTemplates'](); return window['go']['main']['App']['LabelListTemplates']();
} }
export function LabelOpenPDF(arg1) {
return window['go']['main']['App']['LabelOpenPDF'](arg1);
}
export function LabelPaperQueue() { export function LabelPaperQueue() {
return window['go']['main']['App']['LabelPaperQueue'](); return window['go']['main']['App']['LabelPaperQueue']();
} }
+16
View File
@@ -2969,6 +2969,22 @@ export namespace main {
this.samples = source["samples"]; 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 { export class LabelSampleQSO {
callsign: string; callsign: string;
qso_date: string; qso_date: string;