feat(labels): print the labels — queue, address review, PDF, log update
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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
+4
-2
@@ -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."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<number | null>(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() {
|
||||
/>
|
||||
<QslDesignerModal open={qslDesignerOpen} onClose={() => setQslDesignerOpen(false)} />
|
||||
<LabelDesignerModal open={labelDesignerOpen} onClose={() => setLabelDesignerOpen(false)} />
|
||||
<LabelPrintModal open={labelPrintOpen} onClose={() => setLabelPrintOpen(false)} />
|
||||
<SendEQSLModal
|
||||
open={eqslQsoId !== null}
|
||||
qsoId={eqslQsoId}
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
// Label printing — the paper-QSL labelling session, in three steps:
|
||||
//
|
||||
// 1. PICK the contacts (preloaded with the R/Q paper queue), grouped by
|
||||
// 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.
|
||||
//
|
||||
// The pages are rasterised by the designer's own renderer at the stock's dpi:
|
||||
// what the designer previewed is what the PDF carries.
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { X, Printer, RefreshCw, ChevronRight, ChevronLeft, Check, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
LabelPaperQueue, LabelListStocks, LabelListTemplates, LabelGetTemplate,
|
||||
LabelExportPDF, LookupCallsignFresh, BulkUpdateQSL, GetActiveProfile,
|
||||
} from '../../../wailsjs/go/main/App';
|
||||
import type { LabelSample, LabelStock, LabelTemplate } from './labelTypes';
|
||||
import { rasterize } from './labelRender';
|
||||
|
||||
interface Props { open: boolean; onClose: () => 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<Station[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [stocks, setStocks] = useState<LabelStock[]>([]);
|
||||
const [tpls, setTpls] = useState<TplInfo[]>([]);
|
||||
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<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: [] });
|
||||
const [building, setBuilding] = useState(false);
|
||||
const [exported, setExported] = useState<Record<string, string>>({});
|
||||
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<string, any[]>();
|
||||
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<Station>) =>
|
||||
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 (
|
||||
<div className="rounded-lg border border-border p-3 space-y-2">
|
||||
<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" />
|
||||
))}
|
||||
{pg.length > 8 && <span className="text-xs text-muted-foreground self-center">+{pg.length - 8}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
const sumQsos = picked.reduce((a, s) => a + s.qsos.length, 0);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4">
|
||||
<div className="bg-card border border-border rounded-xl shadow-2xl w-full max-w-5xl h-[90vh] flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-border shrink-0">
|
||||
<Printer className="size-4 text-primary" />
|
||||
<span className="font-semibold text-sm">{t('lpr.title')}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{step === 'pick' ? t('lpr.step1') : step === 'review' ? t('lpr.step2') : t('lpr.step3')}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={onClose}><X className="size-4" /></Button>
|
||||
</div>
|
||||
{error && <div className="px-4 py-1.5 text-xs text-danger border-b border-border/60">{error}</div>}
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<Loader2 className="size-4 animate-spin" /> …
|
||||
</div>
|
||||
) : step === 'pick' ? (
|
||||
stations.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground text-center pt-16 max-w-md mx-auto leading-relaxed">
|
||||
{t('lpr.emptyQueue')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 pb-1 text-xs text-muted-foreground">
|
||||
<button type="button" className="underline underline-offset-2" onClick={() => setStations((l) => l.map((s) => ({ ...s, checked: true })))}>{t('lpr.all')}</button>
|
||||
<button type="button" className="underline underline-offset-2" onClick={() => setStations((l) => l.map((s) => ({ ...s, checked: false })))}>{t('lpr.none')}</button>
|
||||
<div className="flex-1" />
|
||||
<span>{t('lpr.pickSummary', { s: picked.length, q: sumQsos })}</span>
|
||||
</div>
|
||||
{stations.map((s, i) => (
|
||||
<label key={s.call} className="flex items-center gap-2.5 rounded-md border border-border/60 px-2.5 py-1.5 text-sm cursor-pointer hover:bg-muted/40">
|
||||
<Checkbox checked={s.checked} onCheckedChange={(c) => patchStation(i, { checked: !!c })} />
|
||||
<span className="font-mono font-bold w-28">{s.call}</span>
|
||||
<span className="text-xs text-muted-foreground w-16">{s.qsos.length} QSO{s.qsos.length > 1 ? 's' : ''}</span>
|
||||
<span className="text-xs text-muted-foreground flex-1 truncate">
|
||||
{s.qsos.map((q) => `${q.band ?? ''} ${q.mode ?? ''}`).slice(0, 5).join(' · ')}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-40">{s.qsos[0]?.country ?? ''}</span>
|
||||
{s.via && <span className="text-[10px] px-1.5 rounded bg-info-muted text-info-muted-foreground">via {s.via}</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : step === 'review' ? (
|
||||
<div className="space-y-2">
|
||||
{picked.map((s) => {
|
||||
const i = stations.indexOf(s);
|
||||
return (
|
||||
<div key={s.call} className="rounded-lg border border-border p-2.5 flex gap-3">
|
||||
<div className="w-64 shrink-0 space-y-1.5">
|
||||
<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 })}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="direct">{t('lpr.direct')}</SelectItem>
|
||||
<SelectItem value="bureau">{t('lpr.bureau')}</SelectItem>
|
||||
<SelectItem value="via">{t('lpr.viaMgr')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{s.routing === 'via' && (
|
||||
<Input className="h-7 text-xs font-mono uppercase" placeholder={t('lpr.mgrPh')}
|
||||
value={s.via} onChange={(ev) => patchStation(i, { via: ev.target.value })} />
|
||||
)}
|
||||
{s.routing !== 'bureau' && (
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs w-full"
|
||||
disabled={s.fetching}
|
||||
onClick={() => void fetchAddress(i)}>
|
||||
{s.fetching ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||
{t('lpr.fetchQrz')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{s.routing === 'bureau' ? (
|
||||
<div className="flex-1 text-xs text-muted-foreground self-center">{t('lpr.bureauHint')}</div>
|
||||
) : (
|
||||
<textarea
|
||||
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')}
|
||||
value={s.address}
|
||||
onChange={(ev) => patchStation(i, { address: ev.target.value })} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* what to print */}
|
||||
<div className="rounded-lg border border-border p-3 space-y-2">
|
||||
<div className="text-sm font-semibold">{t('lpr.whatToPrint')}</div>
|
||||
{([
|
||||
['qso', doQso, setDoQso, qsoTplId, setQsoTplId, 'qso', t('lpr.kQso')],
|
||||
['addr', doAddr, setDoAddr, addrTplId, setAddrTplId, 'address', t('lpr.kAddr')],
|
||||
['ret', doReturn, setDoReturn, retTplId, setRetTplId, 'address', t('lpr.kRet')],
|
||||
] as const).map(([key, on, setOn, tplId, setTplId, kind, label]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1.5 text-sm cursor-pointer w-64">
|
||||
<Checkbox checked={on} onCheckedChange={(c) => (setOn as any)(!!c)} /> {label}
|
||||
</label>
|
||||
<Select value={String(tplId || '')} onValueChange={(v) => (setTplId as any)(parseInt(v, 10))}>
|
||||
<SelectTrigger className="h-7 text-xs w-72"><SelectValue placeholder={t('lpr.pickTpl')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{tpls.filter((x) => x.kind === kind).map((x) => (
|
||||
<SelectItem key={x.id} value={String(x.id)}>{x.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
{doReturn && (
|
||||
<div className="flex items-start gap-2 pl-6">
|
||||
<span className="text-xs text-muted-foreground pt-1 w-24">{t('lpr.myAddress')}</span>
|
||||
<textarea className="flex-1 max-w-96 rounded-md border border-input bg-background px-2 py-1 text-xs font-mono" rows={4}
|
||||
value={myAddress} onChange={(ev) => setMyAddress(ev.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
<Button size="sm" className="h-8" onClick={() => void buildPages()} disabled={building || (!doQso && !doAddr && !doReturn)}>
|
||||
{building ? <Loader2 className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
|
||||
{t('lpr.build')}
|
||||
</Button>
|
||||
</div>
|
||||
{kindBlock('qso', t('lpr.kQso'))}
|
||||
{kindBlock('addr', t('lpr.kAddr'))}
|
||||
{kindBlock('ret', t('lpr.kRet'))}
|
||||
{/* mark as sent */}
|
||||
{(pages.qso.length > 0 || pages.addr.length > 0) && (
|
||||
<div className="rounded-lg border border-border p-3 space-y-2">
|
||||
<div className="text-sm font-semibold">{t('lpr.markTitle')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('lpr.markHint')}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="date" className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
value={markDate} onChange={(ev) => setMarkDate(ev.target.value)} />
|
||||
<Button size="sm" className="h-8" onClick={() => void markSent()} disabled={marked > 0}>
|
||||
<Check className="size-3.5" /> {t('lpr.markBtn', { n: sumQsos })}
|
||||
</Button>
|
||||
{marked > 0 && <span className="text-xs text-success">{t('lpr.markDone', { n: marked })}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* footer nav */}
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-t border-border shrink-0">
|
||||
{step !== 'pick' && (
|
||||
<Button variant="outline" size="sm" className="h-8"
|
||||
onClick={() => setStep(step === 'print' ? 'review' : 'pick')}>
|
||||
<ChevronLeft className="size-3.5" /> {t('lpr.back')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{step === 'pick' && (
|
||||
<Button size="sm" className="h-8" disabled={picked.length === 0} onClick={() => setStep('review')}>
|
||||
{t('lpr.next')} <ChevronRight className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{step === 'review' && (
|
||||
<Button size="sm" className="h-8"
|
||||
disabled={needAddress.some((s) => !s.address.trim())}
|
||||
title={needAddress.some((s) => !s.address.trim()) ? t('lpr.missingAddr') : undefined}
|
||||
onClick={() => setStep('print')}>
|
||||
{t('lpr.next')} <ChevronRight className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -173,3 +173,18 @@ function drawElement(
|
||||
}
|
||||
return { x: e.x_mm, y: e.y_mm, w: 10, h: 4 };
|
||||
}
|
||||
|
||||
// rasterize renders one label at the stock's dpi and returns a PNG data URL —
|
||||
// the pages handed to the PDF exporter. Same renderer as the preview, only the
|
||||
// scale changes, which is the whole guarantee of the feature.
|
||||
export function rasterize(
|
||||
t: LabelTemplate, stock: LabelStock, data: RenderData,
|
||||
): string {
|
||||
const scale = (stock.dpi || 300) / 25.4; // px per mm
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = Math.round(stock.w_mm * scale);
|
||||
cv.height = Math.round(stock.h_mm * scale);
|
||||
const ctx = cv.getContext('2d')!;
|
||||
render(ctx, t, stock, data, scale);
|
||||
return cv.toDataURL('image/png');
|
||||
}
|
||||
|
||||
@@ -132,6 +132,27 @@ const en: Dict = {
|
||||
'wk.tciHint': "Keying goes through the radio's own macro keyer, over the link already open — no WinKeyer and no second serial port. The radio can stop a message but cannot un-type one, so there is no type-ahead correction here.",
|
||||
'gen.miles': 'Distances in miles', 'gen.milesHint': '(instead of kilometres)',
|
||||
'tools.labelDesigner': "Label Designer…",
|
||||
'tools.labelPrint': "Print QSL labels…",
|
||||
'lpr.title': "Print QSL labels",
|
||||
'lpr.step1': "1 · pick the contacts", 'lpr.step2': "2 · check the addresses", 'lpr.step3': "3 · print and record",
|
||||
'lpr.emptyQueue': "No paper QSL is waiting. Mark contacts as Requested or Queued (QSL sent status R/Q) — in the QSL Manager's Paper QSL view or the QSO editor — and they will appear here.",
|
||||
'lpr.all': "all", 'lpr.none': "none", 'lpr.pickSummary': "{s} station(s) · {q} QSO(s)",
|
||||
'lpr.direct': "Direct", 'lpr.bureau': "Bureau", 'lpr.viaMgr': "Via manager", 'lpr.mgrPh': "Manager callsign",
|
||||
'lpr.fetchQrz': "Fetch address (QRZ)", 'lpr.noAddress': "No address found for {call}.",
|
||||
'lpr.bureauHint': "Bureau: no envelope, so no address label — the QSO label is enough.",
|
||||
'lpr.addressPh': "Name\nStreet\nZIP City\nCountry",
|
||||
'lpr.missingAddr': "A direct or via-manager station still has an empty address.",
|
||||
'lpr.whatToPrint': "Labels to print", 'lpr.pickTpl': "Pick a template…",
|
||||
'lpr.kQso': "QSO labels", 'lpr.kAddr': "Address labels", 'lpr.kRet': "My return-address labels",
|
||||
'lpr.myAddress': "My address", 'lpr.build': "Build the labels",
|
||||
'lpr.noQsoTpl': "No QSO 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.savePdf': "Save PDF & open",
|
||||
'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.",
|
||||
'lpr.back': "Back", 'lpr.next': "Next",
|
||||
'lbl.title': "Label Designer",
|
||||
'lbl.newQso': "QSO label", 'lbl.newAddr': "Address label",
|
||||
'lbl.newQsoName': "QSO label", 'lbl.newAddrName': "Address label",
|
||||
@@ -655,6 +676,27 @@ const fr: Dict = {
|
||||
'wk.tciHint': "La manipulation passe par le keyer à macros de la radio, sur la liaison déjà ouverte — ni WinKeyer ni second port série. La radio sait interrompre un message mais pas en effacer la fin, donc pas de correction en frappe anticipée ici.",
|
||||
'gen.miles': 'Distances en miles', 'gen.milesHint': '(au lieu des kilomètres)',
|
||||
'tools.labelDesigner': "Créateur d'étiquettes…",
|
||||
'tools.labelPrint': "Imprimer les étiquettes QSL…",
|
||||
'lpr.title': "Imprimer les étiquettes QSL",
|
||||
'lpr.step1': "1 · choisir les contacts", 'lpr.step2': "2 · vérifier les adresses", 'lpr.step3': "3 · imprimer et enregistrer",
|
||||
'lpr.emptyQueue': "Aucune QSL papier en attente. Marquez des contacts Demandée ou En file (statut QSL envoyée R/Q) — dans la vue QSL papier du gestionnaire ou l'éditeur de QSO — et ils apparaîtront ici.",
|
||||
'lpr.all': "tout", 'lpr.none': "aucun", 'lpr.pickSummary': "{s} station(s) · {q} QSO",
|
||||
'lpr.direct': "Direct", 'lpr.bureau': "Bureau", 'lpr.viaMgr': "Via manager", 'lpr.mgrPh': "Indicatif du manager",
|
||||
'lpr.fetchQrz': "Récupérer l'adresse (QRZ)", 'lpr.noAddress': "Aucune adresse trouvée pour {call}.",
|
||||
'lpr.bureauHint': "Bureau : pas d'enveloppe, donc pas d'étiquette adresse — l'étiquette QSO suffit.",
|
||||
'lpr.addressPh': "Nom\nRue\nCP Ville\nPays",
|
||||
'lpr.missingAddr': "Une station en direct ou via manager n'a pas encore d'adresse.",
|
||||
'lpr.whatToPrint': "Étiquettes à imprimer", 'lpr.pickTpl': "Choisir un modèle…",
|
||||
'lpr.kQso': "Étiquettes QSO", 'lpr.kAddr': "Étiquettes adresse", 'lpr.kRet': "Mes étiquettes adresse retour",
|
||||
'lpr.myAddress': "Mon adresse", 'lpr.build': "Composer les étiquettes",
|
||||
'lpr.noQsoTpl': "Aucun modèle d'étiquette QSO — 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.savePdf': "Enregistrer le PDF et ouvrir",
|
||||
'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.",
|
||||
'lpr.back': "Retour", 'lpr.next': "Suivant",
|
||||
'lbl.title': "Créateur d'étiquettes",
|
||||
'lbl.newQso': "Étiquette QSO", 'lbl.newAddr': "Étiquette adresse",
|
||||
'lbl.newQsoName': "Étiquette QSO", 'lbl.newAddrName': "Étiquette adresse",
|
||||
|
||||
Vendored
+4
@@ -730,12 +730,16 @@ 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 LabelPaperQueue():Promise<Array<qso.QSO>>;
|
||||
|
||||
export function LabelSampleQSOs(arg1:number):Promise<Array<main.LabelSampleQSO>>;
|
||||
|
||||
export function LabelSaveStock(arg1:labels.Stock):Promise<number>;
|
||||
|
||||
@@ -1398,6 +1398,10 @@ 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);
|
||||
}
|
||||
@@ -1410,6 +1414,10 @@ export function LabelListTemplates() {
|
||||
return window['go']['main']['App']['LabelListTemplates']();
|
||||
}
|
||||
|
||||
export function LabelPaperQueue() {
|
||||
return window['go']['main']['App']['LabelPaperQueue']();
|
||||
}
|
||||
|
||||
export function LabelSampleQSOs(arg1) {
|
||||
return window['go']['main']['App']['LabelSampleQSOs'](arg1);
|
||||
}
|
||||
|
||||
@@ -5227,6 +5227,7 @@ export namespace qso {
|
||||
band?: string;
|
||||
mode?: string;
|
||||
station_callsign?: string;
|
||||
qsl_sent_in?: string[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
|
||||
@@ -5240,6 +5241,7 @@ export namespace qso {
|
||||
this.band = source["band"];
|
||||
this.mode = source["mode"];
|
||||
this.station_callsign = source["station_callsign"];
|
||||
this.qsl_sent_in = source["qsl_sent_in"];
|
||||
this.limit = source["limit"];
|
||||
this.offset = source["offset"];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// Package pdf writes the one kind of PDF the label printer needs: a document
|
||||
// whose every page is a single full-bleed image at an exact physical size.
|
||||
//
|
||||
// Written by hand rather than through a library for two reasons. The build is
|
||||
// pure Go with no room for cgo, and the need is tiny: the pages arrive as
|
||||
// PNGs rasterised by the SAME canvas renderer the designer's preview uses, so
|
||||
// this file only has to carry pixels to paper without touching them. Fonts,
|
||||
// vectors, compression profiles — all already decided upstream.
|
||||
//
|
||||
// The images are stored as 8-bit DeviceGray with FlateDecode: labels are
|
||||
// monochrome, grey keeps antialiased text edges smooth on a 300 dpi thermal
|
||||
// head, and flate is lossless — JPEG artefacts around small print are exactly
|
||||
// what a QSL label cannot afford.
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"fmt"
|
||||
"image/png"
|
||||
)
|
||||
|
||||
const mmToPt = 72.0 / 25.4
|
||||
|
||||
// Doc accumulates pages; Bytes() renders the file.
|
||||
type Doc struct {
|
||||
pages []pageData
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
wPt, hPt float64
|
||||
imgW int
|
||||
imgH int
|
||||
gray []byte // zlib-compressed 8-bit samples
|
||||
}
|
||||
|
||||
// AddImagePage appends one page of wMm×hMm entirely covered by the PNG.
|
||||
// The PNG's aspect ratio is not checked against the page's: the caller
|
||||
// rasterised it AT this size, and a mismatch would be its bug to see.
|
||||
func (d *Doc) AddImagePage(pngBytes []byte, wMm, hMm float64) error {
|
||||
img, err := png.Decode(bytes.NewReader(pngBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("page image: %w", err)
|
||||
}
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if w <= 0 || h <= 0 {
|
||||
return fmt.Errorf("page image is empty")
|
||||
}
|
||||
// To 8-bit grey. Luminance weights, not an average: blue text on a designer
|
||||
// screen should darken the way a photocopier would darken it.
|
||||
gray := make([]byte, w*h)
|
||||
i := 0
|
||||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||
for x := b.Min.X; x < b.Max.X; x++ {
|
||||
r, g, bb, _ := img.At(x, y).RGBA()
|
||||
gray[i] = byte((299*r + 587*g + 114*bb) / 1000 >> 8)
|
||||
i++
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
zw := zlib.NewWriter(&buf)
|
||||
if _, err := zw.Write(gray); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
d.pages = append(d.pages, pageData{
|
||||
wPt: wMm * mmToPt, hPt: hMm * mmToPt,
|
||||
imgW: w, imgH: h, gray: buf.Bytes(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Bytes renders the whole document.
|
||||
func (d *Doc) Bytes() ([]byte, error) {
|
||||
if len(d.pages) == 0 {
|
||||
return nil, fmt.Errorf("no pages")
|
||||
}
|
||||
var out bytes.Buffer
|
||||
offsets := []int{0} // object 0 is the free-list head
|
||||
obj := func(body func()) int {
|
||||
offsets = append(offsets, out.Len())
|
||||
n := len(offsets) - 1
|
||||
fmt.Fprintf(&out, "%d 0 obj\n", n)
|
||||
body()
|
||||
out.WriteString("endobj\n")
|
||||
return n
|
||||
}
|
||||
|
||||
out.WriteString("%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
|
||||
|
||||
// Objects 1 (catalog) and 2 (pages) reference their children by number, so
|
||||
// the numbering is laid out first: 3 objects per page after the two roots.
|
||||
nPages := len(d.pages)
|
||||
pageObj := func(i int) int { return 3 + i*3 }
|
||||
|
||||
obj(func() { out.WriteString("<< /Type /Catalog /Pages 2 0 R >>\n") }) // 1
|
||||
obj(func() { // 2
|
||||
out.WriteString("<< /Type /Pages /Kids [")
|
||||
for i := 0; i < nPages; i++ {
|
||||
fmt.Fprintf(&out, "%d 0 R ", pageObj(i))
|
||||
}
|
||||
fmt.Fprintf(&out, "] /Count %d >>\n", nPages)
|
||||
})
|
||||
for i, p := range d.pages {
|
||||
content := fmt.Sprintf("q %.4f 0 0 %.4f 0 0 cm /Im0 Do Q", p.wPt, p.hPt)
|
||||
obj(func() { // page
|
||||
fmt.Fprintf(&out, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %.4f %.4f] /Contents %d 0 R /Resources << /XObject << /Im0 %d 0 R >> >> >>\n",
|
||||
p.wPt, p.hPt, pageObj(i)+1, pageObj(i)+2)
|
||||
})
|
||||
obj(func() { // contents
|
||||
fmt.Fprintf(&out, "<< /Length %d >>\nstream\n%s\nendstream\n", len(content), content)
|
||||
})
|
||||
obj(func() { // image
|
||||
fmt.Fprintf(&out, "<< /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /Length %d >>\nstream\n",
|
||||
p.imgW, p.imgH, len(p.gray))
|
||||
out.Write(p.gray)
|
||||
out.WriteString("\nendstream\n")
|
||||
})
|
||||
}
|
||||
|
||||
xref := out.Len()
|
||||
fmt.Fprintf(&out, "xref\n0 %d\n0000000000 65535 f \n", len(offsets))
|
||||
for _, off := range offsets[1:] {
|
||||
fmt.Fprintf(&out, "%010d 00000 n \n", off)
|
||||
}
|
||||
fmt.Fprintf(&out, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets), xref)
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testPNG(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for x := 0; x < w; x++ {
|
||||
img.Set(x, h/2, color.Black)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestDocShape(t *testing.T) {
|
||||
var d Doc
|
||||
// A 90×29 mm label at 300 dpi is 1063×343 px.
|
||||
if err := d.AddImagePage(testPNG(t, 1063, 343), 90, 29); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.AddImagePage(testPNG(t, 1063, 343), 90, 29); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := d.Bytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Not a PDF parser — the shape a viewer needs to find its way in.
|
||||
for _, want := range []string{"%PDF-1.4", "/Count 2", "/DeviceGray", "startxref", "%%EOF"} {
|
||||
if !bytes.Contains(b, []byte(want)) {
|
||||
t.Errorf("missing %q in output", want)
|
||||
}
|
||||
}
|
||||
// 90 mm = 255.118 pt — the page size a driver prints 1:1 on the roll.
|
||||
if !bytes.Contains(b, []byte("/MediaBox [0 0 255.1181 82.2047]")) {
|
||||
t.Errorf("media box is not the label size")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyDocRefused(t *testing.T) {
|
||||
var d Doc
|
||||
if _, err := d.Bytes(); err == nil {
|
||||
t.Fatal("an empty document should refuse to render")
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -232,8 +232,11 @@ type ListFilter struct {
|
||||
Band string `json:"band,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
StationCallsign string `json:"station_callsign,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
// QSLSentIn keeps only rows whose paper-QSL sent status is one of these
|
||||
// values — 'R' (requested) and 'Q' (queued) are the label printer's queue.
|
||||
QSLSentIn []string `json:"qsl_sent_in,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
}
|
||||
|
||||
// Repo accesses the qso table.
|
||||
@@ -1211,6 +1214,12 @@ func (r *Repo) List(ctx context.Context, f ListFilter) ([]QSO, error) {
|
||||
q += " AND station_callsign = ?"
|
||||
args = append(args, f.StationCallsign)
|
||||
}
|
||||
if len(f.QSLSentIn) > 0 {
|
||||
q += " AND qsl_sent IN (?" + strings.Repeat(",?", len(f.QSLSentIn)-1) + ")"
|
||||
for _, v := range f.QSLSentIn {
|
||||
args = append(args, v)
|
||||
}
|
||||
}
|
||||
q += " ORDER BY qso_date DESC, id DESC"
|
||||
if f.Limit <= 0 {
|
||||
f.Limit = 500
|
||||
|
||||
Reference in New Issue
Block a user