Files
OpsLog/internal/labels/repo.go
T
rouggy 1a169fdb4f 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.
2026-08-28 19:41:15 +02:00

253 lines
7.5 KiB
Go

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
}