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 }