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.
253 lines
7.5 KiB
Go
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
|
|
}
|