feat(labels): the Label Designer — QSO and address labels for paper QSL

The designer's data model lives in internal/labels: STOCKS are the physical
roll in the printer (geometry in mm, margins, dpi — seeded with the common
Brother DK sizes, the QL family being what prompted the feature), TEMPLATES are
one design each, of two kinds: the QSO label glued on the card, whose repeating
table carries several contacts of the same station, and the address label for
the envelope, whose lines collapse when a variable is empty.

Everything is measured in millimetres — labels are sold in mm, and an operator
lining a design up against a physical sticker thinks in mm; pixels exist only in
the renderer, at the stock's dpi. The canvas renderer is shared between the
editor's preview and the future print path, so there is no second
implementation for the preview to disagree with.

The preview is fed with the log's latest contacts rather than lorem ipsum: real
data shows a too-narrow column immediately.

Printing (PDF, one page per label at exact size) is the next module; nothing
here prints yet.
This commit is contained in:
2026-08-28 19:41:15 +02:00
parent 3dc31697cd
commit 1a169fdb4f
16 changed files with 1730 additions and 2 deletions
@@ -0,0 +1,30 @@
-- Label designer: printable labels for paper QSL work.
--
-- Two tables because the two things have different lifetimes. A STOCK is the
-- physical roll in the printer (width, height, margins) — one per label size,
-- shared by every design printed on it. A TEMPLATE is one design (what goes on
-- the label) and points at the stock it was drawn for. Deleting a design must
-- never take the roll definition of the other designs with it.
--
-- kind separates the two families the operator designs: 'qso' (the label glued
-- on the QSL card, with its repeating QSO table) and 'address' (destination or
-- return address). is_default is per (kind, profile scope) — printing wants
-- "the QSO label" and "the address label" without asking every time.
CREATE TABLE label_stocks (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE label_templates (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
kind TEXT NOT NULL,
profile_id INTEGER REFERENCES station_profiles(id) ON DELETE SET NULL,
stock_id INTEGER REFERENCES label_stocks(id) ON DELETE SET NULL,
json TEXT NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
+2
View File
@@ -51,6 +51,8 @@ var settingsTables = []string{
"operating_stations_new",
"award_references",
"qsl_templates",
"label_stocks",
"label_templates",
"cluster_servers",
"integrations_udp",
"callsign_cache",
+194
View File
@@ -0,0 +1,194 @@
// Package labels holds the label designer's data model: the printable labels
// an operator sticks on a QSL card (the QSO table) or an envelope (addresses).
//
// Everything is measured in MILLIMETRES. Label stock is sold in mm (a Brother
// DK-11201 is 29×90), printer margins are quoted in mm, and an operator lining
// a design up against a physical label thinks in mm — pixels only exist at
// render time, where the frontend rasterises at the stock's dpi. Storing mm
// keeps a template meaningful if it is ever printed at another resolution.
//
// The document is deliberately much simpler than the QSL card designer's: a
// label is monochrome text on a small sticker, so there are no photos, no
// effects, no presets — four element types and a geometry.
package labels
import (
"encoding/json"
"fmt"
"strings"
)
// Stock is one physical label size — the roll in the printer. Designs point at
// a stock rather than embedding the geometry so that changing "my printer's
// margins are actually 2 mm" fixes every design at once.
type Stock struct {
ID int64 `json:"id,omitempty"`
Name string `json:"name"`
WMm float64 `json:"w_mm"`
HMm float64 `json:"h_mm"`
// Margins are the unprintable border, in mm from each edge.
MarginTop float64 `json:"margin_top_mm"`
MarginRight float64 `json:"margin_right_mm"`
MarginBottom float64 `json:"margin_bottom_mm"`
MarginLeft float64 `json:"margin_left_mm"`
DPI int `json:"dpi"`
}
// BuiltinStocks are the label sizes seeded on first run — the common Brother DK
// rolls (the QL family is what prompted the feature) plus a 62 mm continuous
// strip. Ordinary rows once seeded: an operator with different margins edits
// them like any stock.
func BuiltinStocks() []Stock {
m := func(name string, w, h float64) Stock {
return Stock{Name: name, WMm: w, HMm: h,
MarginTop: 1.5, MarginRight: 3, MarginBottom: 1.5, MarginLeft: 3, DPI: 300}
}
return []Stock{
m("Brother DK-11201 · 29×90 mm (address)", 90, 29),
m("Brother DK-11202 · 62×100 mm (shipping)", 100, 62),
m("Brother DK-11208 · 38×90 mm (large address)", 90, 38),
m("Brother DK-22205 · 62 mm continuous (cut 100 mm)", 100, 62),
}
}
// Element is one thing drawn on the label. Type selects which fields matter:
//
// text X/Y/W, Text (with <VARIABLES>), Size, Bold, Align
// line X/Y/W, Thickness — a horizontal rule
// qso_table X/Y/W, Columns, RowsMax, RowH, Header — the repeating QSO block
// addr_block X/Y, Lines, Size, Bold, LineGap — address lines, blanks collapsed
//
// One struct with optional fields rather than a type per element: the document
// crosses the Wails boundary as JSON and the frontend edits it in place, and a
// closed union would buy safety here at the price of a parallel hierarchy on
// both sides of that boundary.
type Element struct {
Type string `json:"type"`
XMm float64 `json:"x_mm"`
YMm float64 `json:"y_mm"`
WMm float64 `json:"w_mm,omitempty"`
// text
Text string `json:"text,omitempty"`
SizePt float64 `json:"size_pt,omitempty"`
Bold bool `json:"bold,omitempty"`
Italic bool `json:"italic,omitempty"`
Align string `json:"align,omitempty"` // left | center | right
// line
ThicknessMm float64 `json:"thickness_mm,omitempty"`
// qso_table
Columns []Column `json:"columns,omitempty"`
RowsMax int `json:"rows_max,omitempty"`
RowHMm float64 `json:"row_h_mm,omitempty"`
Header bool `json:"header,omitempty"`
// addr_block
Lines []string `json:"lines,omitempty"`
LineGap float64 `json:"line_gap_mm,omitempty"`
}
// Column is one column of the QSO table. Field names the QSO field (the same
// lower-case keys the grids use: qso_date, time_on, band, freq, mode, rst_sent,
// rst_rcvd, …); Label is the printed header.
type Column struct {
Field string `json:"field"`
Label string `json:"label"`
WMm float64 `json:"w_mm"`
Align string `json:"align,omitempty"`
}
// Template is one label design.
type Template struct {
Version int `json:"version"`
Kind string `json:"kind"` // qso | address
Name string `json:"name,omitempty"`
StockID int64 `json:"stock_id"`
FontName string `json:"font,omitempty"` // one face for the whole label; "" = the renderer's default
Elements []Element `json:"elements"`
}
// Parse decodes a template document.
func Parse(doc []byte) (Template, error) {
var t Template
if err := json.Unmarshal(doc, &t); err != nil {
return t, fmt.Errorf("label template: %w", err)
}
return t, nil
}
// Encode is the inverse of Parse.
func Encode(t Template) ([]byte, error) { return json.MarshalIndent(t, "", " ") }
// Validate rejects a document that could not be rendered or printed sensibly.
// Geometry beyond the stock is NOT an error — the editor lets an element be
// dragged around freely and clips at render time — but nonsense that would make
// rendering undefined (unknown types, absurd sizes) is refused at save.
func Validate(t Template) error {
if t.Version != 1 {
return fmt.Errorf("unsupported label template version %d", t.Version)
}
if t.Kind != "qso" && t.Kind != "address" {
return fmt.Errorf("unknown label kind %q", t.Kind)
}
if len(t.Elements) == 0 {
return fmt.Errorf("the label has no elements")
}
if len(t.Elements) > 64 {
return fmt.Errorf("too many elements (%d)", len(t.Elements))
}
for i, e := range t.Elements {
switch e.Type {
case "text":
if strings.TrimSpace(e.Text) == "" {
return fmt.Errorf("element %d: empty text", i+1)
}
case "line":
if e.WMm <= 0 {
return fmt.Errorf("element %d: a line needs a width", i+1)
}
case "qso_table":
if len(e.Columns) == 0 {
return fmt.Errorf("element %d: the QSO table has no columns", i+1)
}
if e.RowsMax < 1 || e.RowsMax > 20 {
return fmt.Errorf("element %d: rows must be 1-20", i+1)
}
for _, c := range e.Columns {
if strings.TrimSpace(c.Field) == "" || c.WMm <= 0 {
return fmt.Errorf("element %d: every column needs a field and a width", i+1)
}
}
case "addr_block":
if len(e.Lines) == 0 {
return fmt.Errorf("element %d: the address block has no lines", i+1)
}
default:
return fmt.Errorf("element %d: unknown type %q", i+1, e.Type)
}
if e.SizePt < 0 || e.SizePt > 72 {
return fmt.Errorf("element %d: font size out of range", i+1)
}
}
return nil
}
// ValidStock rejects geometry no label printer produces.
func ValidStock(s Stock) error {
if strings.TrimSpace(s.Name) == "" {
return fmt.Errorf("the stock needs a name")
}
if s.WMm < 10 || s.WMm > 300 || s.HMm < 6 || s.HMm > 300 {
return fmt.Errorf("label size out of range (10-300 mm wide, 6-300 mm high)")
}
for _, m := range []float64{s.MarginTop, s.MarginRight, s.MarginBottom, s.MarginLeft} {
if m < 0 || m*2 >= s.HMm || m*2 >= s.WMm {
return fmt.Errorf("margins leave no printable area")
}
}
if s.DPI != 0 && (s.DPI < 72 || s.DPI > 1200) {
return fmt.Errorf("dpi out of range")
}
return nil
}
+252
View File
@@ -0,0 +1,252 @@
package labels
import (
"context"
"database/sql"
"fmt"
"time"
)
// Record is one stored template row; JSON holds the Template document.
type Record struct {
ID int64 `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
ProfileID *int64 `json:"profile_id,omitempty"`
StockID *int64 `json:"stock_id,omitempty"`
JSON string `json:"json"`
IsDefault bool `json:"is_default"`
UpdatedAt time.Time `json:"updated_at"`
}
// Repo accesses the label_stocks and label_templates tables. Same shape as the
// QSL template repo it is modelled on — the label designer is that feature's
// smaller sibling and the storage questions were settled there.
type Repo struct{ db *sql.DB }
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
// ── stocks ──────────────────────────────────────────────────────────────
// Stocks lists every stored label stock, oldest first (the seeded Brother rolls
// keep their familiar order at the top).
func (r *Repo) Stocks(ctx context.Context) ([]Stock, error) {
rows, err := r.db.QueryContext(ctx, `SELECT id, json FROM label_stocks ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Stock
for rows.Next() {
var id int64
var doc string
if err := rows.Scan(&id, &doc); err != nil {
return nil, err
}
var s Stock
if err := parseStock(doc, &s); err != nil {
continue // one corrupt row must not hide the rest
}
s.ID = id
out = append(out, s)
}
return out, rows.Err()
}
// SaveStock upserts one stock (ID 0 creates) and writes the id back.
func (r *Repo) SaveStock(ctx context.Context, s *Stock) error {
if err := ValidStock(*s); err != nil {
return err
}
doc, err := encodeStock(*s)
if err != nil {
return err
}
now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
if s.ID == 0 {
res, err := r.db.ExecContext(ctx,
`INSERT INTO label_stocks (name, json, created_at, updated_at) VALUES (?,?,?,?)`,
s.Name, doc, now, now)
if err != nil {
return fmt.Errorf("insert stock: %w", err)
}
s.ID, _ = res.LastInsertId()
return nil
}
_, err = r.db.ExecContext(ctx,
`UPDATE label_stocks SET name = ?, json = ?, updated_at = ? WHERE id = ?`,
s.Name, doc, now, s.ID)
return err
}
// DeleteStock removes a stock. Templates pointing at it keep their design and
// fall back to "pick a stock" in the editor (the FK nulls the reference).
func (r *Repo) DeleteStock(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM label_stocks WHERE id = ?`, id)
return err
}
// SeedStocks inserts the builtin rolls when the table is empty — first run, or
// an operator who deleted everything and wants the presets back gets them by
// emptying the table.
func (r *Repo) SeedStocks(ctx context.Context) error {
var n int
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM label_stocks`).Scan(&n); err != nil {
return err
}
if n > 0 {
return nil
}
for _, s := range BuiltinStocks() {
st := s
if err := r.SaveStock(ctx, &st); err != nil {
return err
}
}
return nil
}
// ── templates ───────────────────────────────────────────────────────────
const tplCols = `id, name, kind, profile_id, stock_id, json, is_default, updated_at`
// ListFor returns the templates visible to a profile (its own plus shared),
// defaults first.
func (r *Repo) ListFor(ctx context.Context, profileID int64) ([]Record, error) {
rows, err := r.db.QueryContext(ctx, `SELECT `+tplCols+` FROM label_templates
WHERE profile_id = ? OR profile_id IS NULL
ORDER BY is_default DESC, id DESC`, profileID)
if err != nil {
return nil, err
}
return scanRecords(rows)
}
// List returns every template (no active profile yet).
func (r *Repo) List(ctx context.Context) ([]Record, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT `+tplCols+` FROM label_templates ORDER BY is_default DESC, id DESC`)
if err != nil {
return nil, err
}
return scanRecords(rows)
}
// Get returns one template.
func (r *Repo) Get(ctx context.Context, id int64) (Record, error) {
row := r.db.QueryRowContext(ctx, `SELECT `+tplCols+` FROM label_templates WHERE id = ?`, id)
return scanRecord(row)
}
// Save upserts a template (ID 0 creates); the id is written back.
func (r *Repo) Save(ctx context.Context, rec *Record) error {
if rec.Name == "" {
return fmt.Errorf("template name required")
}
now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
if rec.ID == 0 {
res, err := r.db.ExecContext(ctx, `INSERT INTO label_templates
(name, kind, profile_id, stock_id, json, is_default, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?)`,
rec.Name, rec.Kind, nullID(rec.ProfileID), nullID(rec.StockID), rec.JSON,
boolInt(rec.IsDefault), now, now)
if err != nil {
return fmt.Errorf("insert label template: %w", err)
}
rec.ID, _ = res.LastInsertId()
return nil
}
_, err := r.db.ExecContext(ctx, `UPDATE label_templates
SET name = ?, kind = ?, profile_id = ?, stock_id = ?, json = ?, updated_at = ?
WHERE id = ?`,
rec.Name, rec.Kind, nullID(rec.ProfileID), nullID(rec.StockID), rec.JSON, now, rec.ID)
return err
}
// Delete removes a template.
func (r *Repo) Delete(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM label_templates WHERE id = ?`, id)
return err
}
// SetDefault marks one template as the default FOR ITS KIND within its profile
// scope: printing asks for "the QSO label" and "the address label" separately,
// so the two defaults must not compete.
func (r *Repo) SetDefault(ctx context.Context, id int64) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck
var kind string
var profileID sql.NullInt64
if err := tx.QueryRowContext(ctx,
`SELECT kind, profile_id FROM label_templates WHERE id = ?`, id).Scan(&kind, &profileID); err != nil {
return err
}
if profileID.Valid {
_, err = tx.ExecContext(ctx, `UPDATE label_templates SET is_default = 0
WHERE kind = ? AND (profile_id = ? OR profile_id IS NULL)`, kind, profileID.Int64)
} else {
_, err = tx.ExecContext(ctx, `UPDATE label_templates SET is_default = 0 WHERE kind = ?`, kind)
}
if err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `UPDATE label_templates SET is_default = 1 WHERE id = ?`, id); err != nil {
return err
}
return tx.Commit()
}
// ── scanning helpers ────────────────────────────────────────────────────
type rowScanner interface{ Scan(dest ...any) error }
func scanRecord(row rowScanner) (Record, error) {
var rec Record
var pid, sid sql.NullInt64
var def int
var updated string
if err := row.Scan(&rec.ID, &rec.Name, &rec.Kind, &pid, &sid, &rec.JSON, &def, &updated); err != nil {
return rec, err
}
if pid.Valid {
v := pid.Int64
rec.ProfileID = &v
}
if sid.Valid {
v := sid.Int64
rec.StockID = &v
}
rec.IsDefault = def != 0
rec.UpdatedAt, _ = time.Parse(time.RFC3339, updated)
return rec, nil
}
func scanRecords(rows *sql.Rows) ([]Record, error) {
defer rows.Close()
var out []Record
for rows.Next() {
rec, err := scanRecord(rows)
if err != nil {
return nil, err
}
out = append(out, rec)
}
return out, rows.Err()
}
func nullID(p *int64) any {
if p == nil || *p == 0 {
return nil
}
return *p
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
+13
View File
@@ -0,0 +1,13 @@
package labels
import "encoding/json"
// The stock row stores its geometry as JSON so adding a field never needs a
// migration; the name is duplicated into its own column for listing.
func parseStock(doc string, s *Stock) error { return json.Unmarshal([]byte(doc), s) }
func encodeStock(s Stock) (string, error) {
s.ID = 0 // the row id is authoritative; never persist a stale copy inside the blob
b, err := json.Marshal(s)
return string(b), err
}