diff --git a/.gitignore b/.gitignore index b87b1c3..adbb97d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -svg_template/ \ No newline at end of file +data/ \ No newline at end of file diff --git a/cmd/generate_template.go b/cmd/generate_template.go index 13df8bb..9bbcb7f 100644 --- a/cmd/generate_template.go +++ b/cmd/generate_template.go @@ -3,8 +3,10 @@ package cmd import ( "fmt" "path/filepath" + "strings" "github.com/spf13/cobra" + "printer.backend/internal/model" "printer.backend/internal/svgtemplate" ) @@ -22,12 +24,27 @@ var generateTemplateCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { data := svgtemplate.Build(sizeFlag, bleedFlag, marginFlag, paddingFlag) - outPath := filepath.Join(svgtemplate.OutputDir, filepath.Base(outputFlag)) + base := filepath.Base(outputFlag) + outPath := filepath.Join(svgtemplate.OutputDir, base) if err := svgtemplate.WriteFile(outputFlag, data); err != nil { return fmt.Errorf("write svg: %w", err) } + spec := model.ItemSpec{ + SizeMM: sizeFlag, + BleedMM: bleedFlag, + MarginMM: marginFlag, + PaddingMM: paddingFlag, + } + name := strings.TrimSuffix(base, filepath.Ext(base)) + item, err := svgtemplate.WriteMeta(base, spec, name) + if err != nil { + return fmt.Errorf("write meta: %w", err) + } + + metaPath := filepath.Join(svgtemplate.OutputDir, model.MetaFilename(base)) cmd.Printf("Template erfolgreich generiert: %s\n", outPath) + cmd.Printf("Item-Metadaten gespeichert: %s (Item %s)\n", metaPath, item.ID) return nil }, } diff --git a/go.mod b/go.mod index e7c584d..3f6ac74 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.3 require github.com/spf13/cobra v1.10.2 require ( + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect ) diff --git a/go.sum b/go.sum index a6ee3e0..fd37884 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/internal/model/item.go b/internal/model/item.go new file mode 100644 index 0000000..3814698 --- /dev/null +++ b/internal/model/item.go @@ -0,0 +1,25 @@ +package model + +import "time" + +// ItemSpec holds mask parameters (from template CLI). +type ItemSpec struct { + SizeMM float64 `json:"size_mm"` + BleedMM float64 `json:"bleed_mm"` + MarginMM float64 `json:"margin_mm"` + PaddingMM float64 `json:"padding_mm"` +} + +// Item is a printable product type (SVG mask) placed on a plate. +type Item struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Spec ItemSpec `json:"spec"` + SVGTemplate string `json:"svg_template"` + CreatedAt time.Time `json:"created_at"` +} + +// MetaFilename returns the sidecar metadata path for an SVG basename. +func MetaFilename(svgBasename string) string { + return svgBasename + ".meta.json" +} diff --git a/internal/model/plate.go b/internal/model/plate.go new file mode 100644 index 0000000..c54eb42 --- /dev/null +++ b/internal/model/plate.go @@ -0,0 +1,26 @@ +package model + +import "time" + +// Plate describes a print bed with outer dimensions and margins on each side. +type Plate struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + WidthMM float64 `json:"width_mm"` + HeightMM float64 `json:"height_mm"` + MarginTop float64 `json:"margin_top_mm"` + MarginRight float64 `json:"margin_right_mm"` + MarginBottom float64 `json:"margin_bottom_mm"` + MarginLeft float64 `json:"margin_left_mm"` + CreatedAt time.Time `json:"created_at"` +} + +// PrintableWidth returns the usable width inside the margins. +func (p Plate) PrintableWidth() float64 { + return p.WidthMM - p.MarginLeft - p.MarginRight +} + +// PrintableHeight returns the usable height inside the margins. +func (p Plate) PrintableHeight() float64 { + return p.HeightMM - p.MarginTop - p.MarginBottom +} diff --git a/internal/paths/paths.go b/internal/paths/paths.go new file mode 100644 index 0000000..539988b --- /dev/null +++ b/internal/paths/paths.go @@ -0,0 +1,8 @@ +package paths + +// Runtime data directories (relative to process working directory). +const ( + DataDir = "data" + PlatesDir = "data/plates" + SVGTemplateDir = "data/svg_template" +) diff --git a/internal/server/admin/static/index.html b/internal/server/admin/static/index.html index 8651d43..20ce2b3 100644 --- a/internal/server/admin/static/index.html +++ b/internal/server/admin/static/index.html @@ -24,8 +24,8 @@ min-height: 100vh; line-height: 1.5; } - .layout { - max-width: 960px; + .page { + max-width: 1000px; margin: 0 auto; padding: 2rem 1.5rem; } @@ -47,6 +47,7 @@ border-radius: 10px; padding: 1.25rem; } + .card.wide { grid-column: 1 / -1; } .card h2 { font-size: 0.75rem; text-transform: uppercase; @@ -54,19 +55,6 @@ color: var(--muted); margin-bottom: 0.75rem; } - .status-dot { - display: inline-block; - width: 8px; - height: 8px; - border-radius: 50%; - margin-right: 0.5rem; - vertical-align: middle; - } - .status-dot.ok { background: var(--ok); } - .status-dot.err { background: var(--err); } - .status-dot.pending { background: var(--muted); animation: pulse 1.2s ease-in-out infinite; } - @keyframes pulse { 50% { opacity: 0.4; } } - .value { font-size: 1.1rem; font-weight: 500; } .muted { color: var(--muted); font-size: 0.85rem; margin-top: 0.5rem; } button { margin-top: 1rem; @@ -80,6 +68,15 @@ } button:hover { filter: brightness(1.1); } button:disabled { opacity: 0.5; cursor: not-allowed; } + button.danger { + background: transparent; + border: 1px solid var(--err); + color: var(--err); + margin-top: 0; + padding: 0.35rem 0.65rem; + font-size: 0.8rem; + } + button.danger:hover { background: rgba(239, 68, 68, 0.12); filter: none; } input { width: 100%; margin-top: 0.5rem; @@ -90,50 +87,216 @@ color: var(--text); font-size: 0.875rem; } - label { font-size: 0.8rem; color: var(--muted); } - pre { - margin-top: 0.75rem; - padding: 0.75rem; - background: var(--bg); - border-radius: 6px; - font-size: 0.75rem; - overflow-x: auto; - color: var(--muted); + label { font-size: 0.8rem; color: var(--muted); display: block; margin-top: 0.75rem; } + label:first-child { margin-top: 0; } + .form-grid { + display: grid; + gap: 0.5rem 1rem; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); } + table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; + margin-top: 0.75rem; + } + th, td { + text-align: left; + padding: 0.5rem 0.25rem; + border-bottom: 1px solid var(--border); + vertical-align: middle; + } + th { color: var(--muted); font-weight: 500; font-size: 0.75rem; } + .empty { color: var(--muted); font-size: 0.85rem; margin-top: 0.5rem; } + .msg-err { color: var(--err); font-size: 0.85rem; margin-top: 0.5rem; } + .msg-ok { color: var(--ok); font-size: 0.85rem; margin-top: 0.5rem; } + .item-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + margin-top: 1rem; + } + .item-card { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; + } + .item-preview { + background: #fff; + padding: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + min-height: 160px; + } + .item-preview img { + max-width: 100%; + max-height: 140px; + width: auto; + height: auto; + } + .item-body { + padding: 0.75rem; + flex: 1; + display: flex; + flex-direction: column; + gap: 0.35rem; + } + .item-body strong { font-size: 0.9rem; } + .item-body span { font-size: 0.8rem; color: var(--muted); } + .item-actions { + padding: 0 0.75rem 0.75rem; + } + .row-actions { display: flex; gap: 0.5rem; justify-content: flex-end; } -
+

Printer Backend

-

Admin-Dashboard

+

Platten und Items verwalten

-

API-Status

-

- - -

-

- +

API

+ + +

API erreichbar

+

API nicht erreichbar

-
-

API-Basis-URL

- - -

+      
+

Platte anlegen

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

+

+
-
-

System

-

-

Lokale Uhrzeit (Browser)

- +
+

Platten

+ + + + + + + + + + + + + + +
NameGrößeMarginsDruckfläche
+
+ +
+

Item anlegen

+

Erzeugt SVG-Maske in data/svg_template/.

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

+

+
+
+ +
+

Items

+ +
+ +
@@ -142,19 +305,42 @@ function dashboard() { return { apiBase: '', - apiLoading: false, apiOk: null, - apiResponse: '', - apiCheckedAt: '', - now: '', - get apiStatusClass() { - if (this.apiOk === null) return 'pending'; - return this.apiOk ? 'ok' : 'err'; + plates: [], + platesLoading: false, + plateSaving: false, + plateError: '', + plateSuccess: '', + plateForm: { + name: '', + width_mm: 300, + height_mm: 400, + margin_top_mm: 10, + margin_right_mm: 10, + margin_bottom_mm: 10, + margin_left_mm: 10, }, - get apiStatusLabel() { - if (this.apiOk === null) return 'Noch nicht geprüft'; - return this.apiOk ? 'API erreichbar' : 'API nicht erreichbar'; + + items: [], + itemsLoading: false, + itemSaving: false, + itemError: '', + itemSuccess: '', + itemForm: { + name: '', + size_mm: 80, + bleed_mm: 2, + margin_mm: 5, + padding_mm: 3, + }, + + apiUrl(path) { + return `${this.apiBase.replace(/\/$/, '')}${path}`; + }, + + itemSvgUrl(id) { + return `${this.apiUrl('/items/' + id + '/svg')}?t=${Date.now()}`; }, async init() { @@ -164,35 +350,159 @@ const cfg = await res.json(); this.apiBase = cfg.api_base || this.apiBase; } - } catch (_) { /* fallback below */ } - if (!this.apiBase) { - this.apiBase = 'http://127.0.0.1:8080'; - } - this.refreshClock(); - this.checkAPI(); - setInterval(() => this.refreshClock(), 1000); + } catch (_) {} + if (!this.apiBase) this.apiBase = 'http://127.0.0.1:8080'; + await this.onApiBaseChange(); }, - refreshClock() { - this.now = new Date().toLocaleString('de-DE'); + async onApiBaseChange() { + await this.checkAPI(); + if (this.apiOk) { + await Promise.all([this.loadPlates(), this.loadItems()]); + } }, async checkAPI() { - this.apiLoading = true; - this.apiResponse = ''; try { - const res = await fetch(`${this.apiBase.replace(/\/$/, '')}/health`); - const body = await res.json(); + const res = await fetch(this.apiUrl('/health')); this.apiOk = res.ok; - this.apiResponse = JSON.stringify(body, null, 2); - } catch (e) { + } catch { this.apiOk = false; - this.apiResponse = String(e.message || e); - } finally { - this.apiLoading = false; - this.apiCheckedAt = new Date().toLocaleTimeString('de-DE'); } }, + + async apiJSON(method, path, body) { + const opts = { method, headers: {} }; + if (body !== undefined) { + opts.headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify(body); + } + const res = await fetch(this.apiUrl(path), opts); + const text = await res.text(); + if (!res.ok) { + let msg = res.statusText; + try { + const err = JSON.parse(text); + if (err.error) msg = err.error; + } catch { + if (text) msg = text; + } + throw new Error(msg); + } + return text ? JSON.parse(text) : null; + }, + + async loadPlates() { + this.platesLoading = true; + try { + this.plates = await this.apiJSON('GET', '/plates') || []; + } catch (e) { + this.plateError = String(e.message || e); + } finally { + this.platesLoading = false; + } + }, + + async savePlate() { + this.plateSaving = true; + this.plateError = ''; + this.plateSuccess = ''; + try { + await this.apiJSON('POST', '/plates', { + name: this.plateForm.name, + width_mm: Number(this.plateForm.width_mm), + height_mm: Number(this.plateForm.height_mm), + margin_top_mm: Number(this.plateForm.margin_top_mm) || 0, + margin_right_mm: Number(this.plateForm.margin_right_mm) || 0, + margin_bottom_mm: Number(this.plateForm.margin_bottom_mm) || 0, + margin_left_mm: Number(this.plateForm.margin_left_mm) || 0, + }); + this.plateSuccess = 'Platte gespeichert.'; + await this.loadPlates(); + } catch (e) { + this.plateError = String(e.message || e); + } finally { + this.plateSaving = false; + } + }, + + async deletePlate(id) { + if (!confirm('Platte wirklich löschen?')) return; + this.plateError = ''; + try { + const res = await fetch(this.apiUrl('/plates/' + id), { method: 'DELETE' }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || res.statusText); + } + await this.loadPlates(); + } catch (e) { + this.plateError = String(e.message || e); + } + }, + + async loadItems() { + this.itemsLoading = true; + try { + this.items = await this.apiJSON('GET', '/items') || []; + } catch (e) { + this.itemError = String(e.message || e); + } finally { + this.itemsLoading = false; + } + }, + + async saveItem() { + this.itemSaving = true; + this.itemError = ''; + this.itemSuccess = ''; + try { + await this.apiJSON('POST', '/items', { + name: this.itemForm.name, + size_mm: Number(this.itemForm.size_mm), + bleed_mm: Number(this.itemForm.bleed_mm) || 0, + margin_mm: Number(this.itemForm.margin_mm) || 0, + padding_mm: Number(this.itemForm.padding_mm) || 0, + }); + this.itemSuccess = 'Item erzeugt.'; + this.itemForm.name = ''; + await this.loadItems(); + } catch (e) { + this.itemError = String(e.message || e); + } finally { + this.itemSaving = false; + } + }, + + async deleteItem(id) { + if (!confirm('Item inkl. SVG wirklich löschen?')) return; + this.itemError = ''; + try { + const res = await fetch(this.apiUrl('/items/' + id), { method: 'DELETE' }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || res.statusText); + } + await this.loadItems(); + } catch (e) { + this.itemError = String(e.message || e); + } + }, + + labelItem(it) { + return it.name || it.svg_template; + }, + fmtSize(w, h) { + return `${w} × ${h} mm`; + }, + fmtMargins(p) { + return `${p.margin_top_mm} / ${p.margin_right_mm} / ${p.margin_bottom_mm} / ${p.margin_left_mm}`; + }, + fmtPrintable(p) { + const w = p.width_mm - p.margin_left_mm - p.margin_right_mm; + const h = p.height_mm - p.margin_top_mm - p.margin_bottom_mm; + return `${w.toFixed(1)} × ${h.toFixed(1)} mm`; + }, }; } diff --git a/internal/server/api/api.go b/internal/server/api/api.go index 6b798ca..6854ec5 100644 --- a/internal/server/api/api.go +++ b/internal/server/api/api.go @@ -2,15 +2,44 @@ package api import ( "encoding/json" + "errors" "net/http" + "os" + "time" + + "printer.backend/internal/model" + "printer.backend/internal/store" ) // NewHandler returns the API HTTP handler (routes under /). func NewHandler() http.Handler { + plates := store.NewPlateStore("") + items := store.NewItemStore("") + mux := http.NewServeMux() mux.HandleFunc("GET /health", health) mux.HandleFunc("GET /", root) - return mux + mux.HandleFunc("GET /plates", listPlates(plates)) + mux.HandleFunc("POST /plates", savePlate(plates)) + mux.HandleFunc("DELETE /plates/{id}", deletePlate(plates)) + mux.HandleFunc("GET /items", listItems(items)) + mux.HandleFunc("POST /items", saveItem(items)) + mux.HandleFunc("DELETE /items/{id}", deleteItem(items)) + mux.HandleFunc("GET /items/{id}/svg", serveItemSVG(items)) + return withCORS(mux) +} + +func withCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) } func health(w http.ResponseWriter, _ *http.Request) { @@ -22,6 +51,163 @@ func root(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{ "service": "printer-backend-api", - "message": "API placeholder — endpoints folgen", }) } + +func listPlates(s *store.PlateStore) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + plates, err := s.List() + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if plates == nil { + plates = []model.Plate{} + } + writeJSON(w, http.StatusOK, plates) + } +} + +type savePlateRequest struct { + Name string `json:"name"` + WidthMM float64 `json:"width_mm"` + HeightMM float64 `json:"height_mm"` + MarginTop float64 `json:"margin_top_mm"` + MarginRight float64 `json:"margin_right_mm"` + MarginBottom float64 `json:"margin_bottom_mm"` + MarginLeft float64 `json:"margin_left_mm"` +} + +func savePlate(s *store.PlateStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req savePlateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if req.WidthMM <= 0 || req.HeightMM <= 0 { + writeError(w, http.StatusBadRequest, errors.New("width_mm and height_mm must be positive")) + return + } + + p := model.Plate{ + Name: req.Name, + WidthMM: req.WidthMM, + HeightMM: req.HeightMM, + MarginTop: req.MarginTop, + MarginRight: req.MarginRight, + MarginBottom: req.MarginBottom, + MarginLeft: req.MarginLeft, + CreatedAt: time.Now().UTC(), + } + saved, err := s.Save(p) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusCreated, saved) + } +} + +func deletePlate(s *store.PlateStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + writeError(w, http.StatusBadRequest, errors.New("id required")) + return + } + if err := s.Delete(id); err != nil { + writeError(w, http.StatusNotFound, err) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +func listItems(s *store.ItemStore) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + list, err := s.List() + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if list == nil { + list = []model.Item{} + } + writeJSON(w, http.StatusOK, list) + } +} + +type saveItemRequest struct { + Name string `json:"name"` + SizeMM float64 `json:"size_mm"` + BleedMM float64 `json:"bleed_mm"` + MarginMM float64 `json:"margin_mm"` + PaddingMM float64 `json:"padding_mm"` +} + +func saveItem(s *store.ItemStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req saveItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + spec := model.ItemSpec{ + SizeMM: req.SizeMM, + BleedMM: req.BleedMM, + MarginMM: req.MarginMM, + PaddingMM: req.PaddingMM, + } + saved, err := s.Create(req.Name, spec) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + writeJSON(w, http.StatusCreated, saved) + } +} + +func deleteItem(s *store.ItemStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + writeError(w, http.StatusBadRequest, errors.New("id required")) + return + } + if err := s.Delete(id); err != nil { + writeError(w, http.StatusNotFound, err) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +func serveItemSVG(s *store.ItemStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + path, err := s.SVGPath(id) + if err != nil { + writeError(w, http.StatusNotFound, err) + return + } + data, err := os.ReadFile(path) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + w.Header().Set("Content-Type", "image/svg+xml") + w.Header().Set("Cache-Control", "no-cache") + _, _ = w.Write(data) + } +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, code int, err error) { + writeJSON(w, code, map[string]string{"error": err.Error()}) +} diff --git a/internal/store/helpers.go b/internal/store/helpers.go new file mode 100644 index 0000000..c56afb0 --- /dev/null +++ b/internal/store/helpers.go @@ -0,0 +1,93 @@ +package store + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "time" +) + +func ensureDir(dir string) error { + return os.MkdirAll(dir, 0o755) +} + +func readJSON[T any](path string) (T, error) { + var v T + data, err := os.ReadFile(path) + if err != nil { + return v, err + } + if err := json.Unmarshal(data, &v); err != nil { + return v, err + } + return v, nil +} + +func writeJSON(path string, v any) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +func removeFile(path, notFoundMsg string) error { + if err := os.Remove(path); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("%s", notFoundMsg) + } + return err + } + return nil +} + +func listFromDir[T any]( + dir string, + match func(name string) bool, + readLabel string, + createdAt func(T) time.Time, +) ([]T, error) { + if err := ensureDir(dir); err != nil { + return nil, err + } + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("read %s: %w", readLabel, err) + } + + var out []T + for _, e := range entries { + if e.IsDir() || !match(e.Name()) { + continue + } + v, err := readJSON[T](filepath.Join(dir, e.Name())) + if err != nil { + return nil, err + } + out = append(out, v) + } + + sort.Slice(out, func(i, j int) bool { + return createdAt(out[i]).After(createdAt(out[j])) + }) + return out, nil +} + +func findByID[T any](list []T, id string, idOf func(T) string) (T, error) { + for _, v := range list { + if idOf(v) == id { + return v, nil + } + } + var zero T + return zero, fmt.Errorf("not found: %s", id) +} + +func stampNew(createdAt *time.Time) time.Time { + if createdAt.IsZero() { + return time.Now().UTC() + } + return *createdAt +} diff --git a/internal/store/item.go b/internal/store/item.go new file mode 100644 index 0000000..9ee6508 --- /dev/null +++ b/internal/store/item.go @@ -0,0 +1,108 @@ +package store + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" + "printer.backend/internal/model" + "printer.backend/internal/svgtemplate" +) + +// ItemStore manages items (SVG + metadata) in the template directory. +type ItemStore struct { + dir string +} + +// NewItemStore creates a store that scans templateDir for *.meta.json files. +func NewItemStore(templateDir string) *ItemStore { + if templateDir == "" { + templateDir = svgtemplate.OutputDir + } + return &ItemStore{dir: templateDir} +} + +// List returns all items sorted by creation time (newest first). +func (s *ItemStore) List() ([]model.Item, error) { + return listFromDir(s.dir, + func(name string) bool { return strings.HasSuffix(name, ".meta.json") }, + "template dir", + func(it model.Item) time.Time { return it.CreatedAt }, + ) +} + +// Get returns an item by ID. +func (s *ItemStore) Get(id string) (model.Item, error) { + list, err := s.List() + if err != nil { + return model.Item{}, err + } + item, err := findByID(list, id, func(it model.Item) string { return it.ID }) + if err != nil { + return model.Item{}, fmt.Errorf("item %w", err) + } + return item, nil +} + +// Create generates SVG + metadata for a new item. +func (s *ItemStore) Create(name string, spec model.ItemSpec) (model.Item, error) { + if spec.SizeMM <= 0 { + return model.Item{}, fmt.Errorf("size_mm must be positive") + } + + basename := svgBasename(name) + data := svgtemplate.Build(spec.SizeMM, spec.BleedMM, spec.MarginMM, spec.PaddingMM) + if err := svgtemplate.WriteFile(basename, data); err != nil { + return model.Item{}, fmt.Errorf("write svg: %w", err) + } + displayName := name + if displayName == "" { + displayName = strings.TrimSuffix(basename, filepath.Ext(basename)) + } + return svgtemplate.WriteMeta(basename, spec, displayName) +} + +// Delete removes an item's SVG and metadata by ID. +func (s *ItemStore) Delete(id string) error { + item, err := s.Get(id) + if err != nil { + return err + } + for _, path := range []string{ + filepath.Join(s.dir, item.SVGTemplate), + filepath.Join(s.dir, model.MetaFilename(item.SVGTemplate)), + } { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} + +// SVGPath returns the path to an item's SVG file. +func (s *ItemStore) SVGPath(id string) (string, error) { + item, err := s.Get(id) + if err != nil { + return "", err + } + path := filepath.Join(s.dir, item.SVGTemplate) + if _, err := os.Stat(path); err != nil { + return "", fmt.Errorf("svg file missing: %w", err) + } + return path, nil +} + +func svgBasename(name string) string { + base := strings.TrimSpace(name) + if base == "" { + return uuid.NewString() + ".svg" + } + base = strings.ReplaceAll(base, " ", "_") + if !strings.HasSuffix(strings.ToLower(base), ".svg") { + base += ".svg" + } + return filepath.Base(base) +} diff --git a/internal/store/plate.go b/internal/store/plate.go new file mode 100644 index 0000000..21e46d3 --- /dev/null +++ b/internal/store/plate.go @@ -0,0 +1,56 @@ +package store + +import ( + "fmt" + "path/filepath" + "time" + + "github.com/google/uuid" + "printer.backend/internal/model" + "printer.backend/internal/paths" +) + +// PlateStore persists plates as JSON files. +type PlateStore struct { + dir string +} + +// NewPlateStore creates a store under dir (default data/plates). +func NewPlateStore(dir string) *PlateStore { + if dir == "" { + dir = paths.PlatesDir + } + return &PlateStore{dir: dir} +} + +// List returns all plates sorted by creation time (newest first). +func (s *PlateStore) List() ([]model.Plate, error) { + return listFromDir(s.dir, + func(name string) bool { return filepath.Ext(name) == ".json" }, + "plates dir", + func(p model.Plate) time.Time { return p.CreatedAt }, + ) +} + +// Save writes a new plate and assigns an ID when empty. +func (s *PlateStore) Save(p model.Plate) (model.Plate, error) { + if err := ensureDir(s.dir); err != nil { + return model.Plate{}, err + } + if p.ID == "" { + p.ID = uuid.NewString() + } + p.CreatedAt = stampNew(&p.CreatedAt) + + path := filepath.Join(s.dir, p.ID+".json") + if err := writeJSON(path, p); err != nil { + return model.Plate{}, fmt.Errorf("write plate: %w", err) + } + return p, nil +} + +// Delete removes a plate by ID. +func (s *PlateStore) Delete(id string) error { + path := filepath.Join(s.dir, id+".json") + return removeFile(path, fmt.Sprintf("plate not found: %s", id)) +} diff --git a/internal/svgtemplate/meta.go b/internal/svgtemplate/meta.go new file mode 100644 index 0000000..65f5012 --- /dev/null +++ b/internal/svgtemplate/meta.go @@ -0,0 +1,38 @@ +package svgtemplate + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/google/uuid" + "printer.backend/internal/model" +) + +// WriteMeta writes item metadata next to the SVG in OutputDir. +func WriteMeta(svgBasename string, spec model.ItemSpec, name string) (model.Item, error) { + if err := os.MkdirAll(OutputDir, 0o755); err != nil { + return model.Item{}, err + } + + base := filepath.Base(filepath.Clean(svgBasename)) + item := model.Item{ + ID: uuid.NewString(), + Name: name, + Spec: spec, + SVGTemplate: base, + CreatedAt: time.Now().UTC(), + } + + metaPath := filepath.Join(OutputDir, model.MetaFilename(base)) + data, err := json.MarshalIndent(item, "", " ") + if err != nil { + return model.Item{}, err + } + if err := os.WriteFile(metaPath, data, 0o644); err != nil { + return model.Item{}, fmt.Errorf("write meta: %w", err) + } + return item, nil +} diff --git a/internal/svgtemplate/template.go b/internal/svgtemplate/template.go index b5d9ba2..aaa4724 100644 --- a/internal/svgtemplate/template.go +++ b/internal/svgtemplate/template.go @@ -5,6 +5,8 @@ import ( "os" "path/filepath" "text/template" + + "printer.backend/internal/paths" ) const svgTemplate = ` @@ -55,7 +57,7 @@ func Write(w io.Writer, data Data) error { } // OutputDir is the directory where generated SVG files are written. -const OutputDir = "svg_template" +const OutputDir = paths.SVGTemplateDir // WriteFile renders the SVG template into OutputDir. func WriteFile(path string, data Data) error {