From 9b8b7e0ed8c4aa961b418ec1614f35719ec35936 Mon Sep 17 00:00:00 2001 From: crzykidd Date: Tue, 19 May 2026 20:24:48 -0700 Subject: [PATCH 01/84] Fix CI step gating and group Dependabot action updates --- .github/dependabot.yml | 4 +++ .github/workflows/ci.yml | 58 +++++++++++++++++++++++++++++++++------- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e71e765..5b31257 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,6 +13,10 @@ updates: labels: - dependencies - github-actions + groups: + github-actions-all: + patterns: + - "*" - package-ecosystem: pip directory: / diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e473676..5058db3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,15 +31,30 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + - name: Check for pyproject.toml + id: check + run: | + if [ -f pyproject.toml ]; then + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "exists=false" >> $GITHUB_OUTPUT + echo "::notice::pyproject.toml not present, skipping Python checks" + fi - uses: actions/setup-python@v6 + if: steps.check.outputs.exists == 'true' with: python-version: '3.12' cache: pip - - run: pip install -e .[dev] - - run: ruff check . - - run: ruff format --check . - - run: mypy backend - - run: pytest -q + - if: steps.check.outputs.exists == 'true' + run: pip install -e .[dev] + - if: steps.check.outputs.exists == 'true' + run: ruff check . + - if: steps.check.outputs.exists == 'true' + run: ruff format --check . + - if: steps.check.outputs.exists == 'true' + run: mypy backend + - if: steps.check.outputs.exists == 'true' + run: pytest -q frontend: needs: detect @@ -50,15 +65,29 @@ jobs: working-directory: frontend steps: - uses: actions/checkout@v6 + - name: Check for frontend/package.json + id: check + run: | + if [ -f package.json ]; then + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "exists=false" >> $GITHUB_OUTPUT + echo "::notice::frontend/package.json not present, skipping frontend checks" + fi - uses: actions/setup-node@v6 + if: steps.check.outputs.exists == 'true' with: node-version: lts/* cache: npm cache-dependency-path: frontend/package-lock.json - - run: npm ci - - run: npm run lint --if-present - - run: npx tsc --noEmit - - run: npm run build + - if: steps.check.outputs.exists == 'true' + run: npm ci + - if: steps.check.outputs.exists == 'true' + run: npm run lint --if-present + - if: steps.check.outputs.exists == 'true' + run: npx tsc --noEmit + - if: steps.check.outputs.exists == 'true' + run: npm run build docker: needs: detect @@ -66,8 +95,19 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + - name: Check for Dockerfile + id: check + run: | + if [ -f Dockerfile ]; then + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "exists=false" >> $GITHUB_OUTPUT + echo "::notice::Dockerfile not present, skipping Docker build" + fi - uses: docker/setup-buildx-action@v4 + if: steps.check.outputs.exists == 'true' - uses: docker/build-push-action@v6 + if: steps.check.outputs.exists == 'true' with: context: . push: false From b479d3ef9f0b6df0743e11bffc336533d6acddb1 Mon Sep 17 00:00:00 2001 From: crzykidd Date: Tue, 19 May 2026 22:17:40 -0700 Subject: [PATCH 02/84] Add slice 1 backend skeleton with quick-print path --- .env.example | 26 +++++ Dockerfile | 35 +++++++ backend/labelforge/__init__.py | 1 + backend/labelforge/catalog/__init__.py | 0 backend/labelforge/catalog/loader.py | 90 ++++++++++++++++++ backend/labelforge/config.py | 19 ++++ backend/labelforge/db.py | 34 +++++++ backend/labelforge/main.py | 61 ++++++++++++ backend/labelforge/models/__init__.py | 46 +++++++++ backend/labelforge/printer/__init__.py | 0 backend/labelforge/printer/client.py | 61 ++++++++++++ backend/labelforge/render/__init__.py | 0 backend/labelforge/render/fonts.py | 70 ++++++++++++++ backend/labelforge/render/text.py | 126 +++++++++++++++++++++++++ backend/labelforge/routes/__init__.py | 0 backend/labelforge/routes/auth.py | 15 +++ backend/labelforge/routes/fonts.py | 15 +++ backend/labelforge/routes/health.py | 8 ++ backend/labelforge/routes/labels.py | 20 ++++ backend/labelforge/routes/print.py | 63 +++++++++++++ compose.dev.yml | 29 ++++++ compose.yml | 31 ++++++ labels.yml | 125 ++++++++++++++++++++++++ 23 files changed, 875 insertions(+) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 backend/labelforge/__init__.py create mode 100644 backend/labelforge/catalog/__init__.py create mode 100644 backend/labelforge/catalog/loader.py create mode 100644 backend/labelforge/config.py create mode 100644 backend/labelforge/db.py create mode 100644 backend/labelforge/main.py create mode 100644 backend/labelforge/models/__init__.py create mode 100644 backend/labelforge/printer/__init__.py create mode 100644 backend/labelforge/printer/client.py create mode 100644 backend/labelforge/render/__init__.py create mode 100644 backend/labelforge/render/fonts.py create mode 100644 backend/labelforge/render/text.py create mode 100644 backend/labelforge/routes/__init__.py create mode 100644 backend/labelforge/routes/auth.py create mode 100644 backend/labelforge/routes/fonts.py create mode 100644 backend/labelforge/routes/health.py create mode 100644 backend/labelforge/routes/labels.py create mode 100644 backend/labelforge/routes/print.py create mode 100644 compose.dev.yml create mode 100644 compose.yml create mode 100644 labels.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f2f73f7 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# labelforge environment — copy to .env and fill in your values. +# All variables are read by pydantic-settings; names are case-insensitive. + +# REQUIRED: shared secret for the HTTP API. All /api/* routes (except /api/health) +# require: Authorization: Bearer +API_TOKEN=changeme + +# REQUIRED: IP or hostname of the Brother QL printer. +# For network backend this becomes tcp://:9100. +PRINTER_HOST=192.168.1.x + +# Brother QL model identifier, must match brother_ql's model list. +PRINTER_MODEL=QL-820NWB + +# Printer connection backend: network | linux_kernel | pyusb +PRINTER_BACKEND=network + +# Label media loaded in the printer by default (used as UI pre-selection). +DEFAULT_LABEL_MEDIA=62 + +# Host path where SQLite, labels.yml, fonts, and previews are stored. +# Must be bind-mounted into the container at the same path. +DATA_DIR=/var/docker/labelforge + +# Python log level: DEBUG | INFO | WARNING | ERROR +LOG_LEVEL=INFO diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3acec2c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# TODO: Slice N — add a multi-stage frontend build stage (node:lts-alpine, npm ci + npm run build) +# before this runtime stage, then COPY --from=frontend /app/frontend/dist /app/frontend/dist +# and mount it via FastAPI StaticFiles. + +FROM python:3.12-slim + +# System fonts required by render/text.py; Pillow wheels include libjpeg/zlib. +RUN apt-get update && apt-get install -y --no-install-recommends \ + fonts-dejavu-core \ + fonts-liberation2 \ + fonts-noto-core \ + && rm -rf /var/lib/apt/lists/* + +# Non-root runtime user (uid 1000). +RUN useradd -u 1000 -m labelforge + +WORKDIR /app + +# Install Python dependencies + the labelforge package (editable so the source +# at /app/backend/labelforge/ is authoritative — enables bind-mount hot-reload +# in compose.dev.yml without rebuilding the image). +COPY pyproject.toml README.md ./ +COPY backend/ backend/ +RUN pip install --no-cache-dir -e . + +# Default label catalog shipped in the image. At startup, if +# ${DATA_DIR}/labels.yml is absent, main.py copies this into the volume. +COPY labels.yml /app/labels.yml + +RUN chown -R labelforge:labelforge /app +USER labelforge + +EXPOSE 8000 + +CMD ["uvicorn", "labelforge.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/labelforge/__init__.py b/backend/labelforge/__init__.py new file mode 100644 index 0000000..f102a9c --- /dev/null +++ b/backend/labelforge/__init__.py @@ -0,0 +1 @@ +__version__ = "0.0.1" diff --git a/backend/labelforge/catalog/__init__.py b/backend/labelforge/catalog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/labelforge/catalog/loader.py b/backend/labelforge/catalog/loader.py new file mode 100644 index 0000000..d61facd --- /dev/null +++ b/backend/labelforge/catalog/loader.py @@ -0,0 +1,90 @@ +import logging +from pathlib import Path + +import yaml +from brother_ql.labels import ALL_LABELS, FormFactor + +from labelforge.models import LabelEntry + +logger = logging.getLogger(__name__) + +_catalog: dict[str, LabelEntry] = {} + +# Map FormFactor enum values to the integer stored in LabelEntry.form_factor. +# FormFactor.DIE_CUT=1, ENDLESS=2, ROUND_DIE_CUT=3, PTOUCH_ENDLESS=4 +_FORM_FACTOR_INT: dict[FormFactor, int] = {ff: ff.value for ff in FormFactor} + + +def load_catalog(yml_path: Path) -> None: + global _catalog + + lib_labels = {label.identifier: label for label in ALL_LABELS} + + yml_entries: dict[str, dict] = {} + if yml_path.exists(): + with yml_path.open() as fh: + data = yaml.safe_load(fh) or {} + for entry in data.get("labels", []): + try: + yml_entries[entry["id"]] = entry + except (KeyError, TypeError): + logger.warning("Skipping malformed catalog entry: %s", entry) + else: + logger.warning("labels.yml not found at %s — using library fallbacks only", yml_path) + + new_catalog: dict[str, LabelEntry] = {} + lib_only = 0 + + for lib_id, lib_label in lib_labels.items(): + form_factor_int = _FORM_FACTOR_INT.get(lib_label.form_factor, 0) + dots = (lib_label.dots_printable[0], lib_label.dots_printable[1]) + tape = (lib_label.tape_size[0], lib_label.tape_size[1]) + + if lib_id in yml_entries: + y = yml_entries[lib_id] + entry = LabelEntry( + id=lib_id, + display_name=y.get("display_name", lib_id), + brother_part=y.get("brother_part"), + description=y.get("description"), + category=y.get("category"), + color_capable=bool(y.get("color_capable", False)), + printer_requirements=y.get("printer_requirements") or [], + common_use=y.get("common_use") or [], + preview_image=y.get("preview_image"), + dots_printable=dots, + tape_size=tape, + form_factor=form_factor_int, + ) + else: + entry = LabelEntry( + id=lib_id, + display_name=lib_id, + dots_printable=dots, + tape_size=tape, + form_factor=form_factor_int, + ) + lib_only += 1 + + new_catalog[lib_id] = entry + + yml_only = sum(1 for k in yml_entries if k not in lib_labels) + for stale_id in yml_entries: + if stale_id not in lib_labels: + logger.warning("Catalog entry '%s' not in brother_ql library — hidden", stale_id) + + _catalog = new_catalog + logger.info( + "Catalog loaded: %d entries (%d library-only fallbacks, %d yml-only hidden)", + len(new_catalog), + lib_only, + yml_only, + ) + + +def get_catalog() -> dict[str, LabelEntry]: + return _catalog + + +def get_label(label_id: str) -> LabelEntry | None: + return _catalog.get(label_id) diff --git a/backend/labelforge/config.py b/backend/labelforge/config.py new file mode 100644 index 0000000..b31473f --- /dev/null +++ b/backend/labelforge/config.py @@ -0,0 +1,19 @@ +from pathlib import Path + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + api_token: str + printer_host: str + printer_model: str = "QL-820NWB" + # one of: network, linux_kernel, pyusb + printer_backend: str = "network" + data_dir: Path = Path("/var/docker/labelforge") + default_label_media: str = "62" + log_level: str = "INFO" + + model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} + + +settings = Settings() diff --git a/backend/labelforge/db.py b/backend/labelforge/db.py new file mode 100644 index 0000000..a8ef5a4 --- /dev/null +++ b/backend/labelforge/db.py @@ -0,0 +1,34 @@ +import sqlite3 +from pathlib import Path + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS print_jobs ( + id INTEGER PRIMARY KEY, + template_id TEXT NULL, + payload_json TEXT NOT NULL, + label_media TEXT NOT NULL, + preview_path TEXT NULL, + pinned INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +""" + + +def get_connection(db_path: Path) -> sqlite3.Connection: + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def init_db(db_path: Path) -> None: + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = get_connection(db_path) + conn.executescript(_SCHEMA) + conn.commit() + conn.close() diff --git a/backend/labelforge/main.py b/backend/labelforge/main.py new file mode 100644 index 0000000..d72ceca --- /dev/null +++ b/backend/labelforge/main.py @@ -0,0 +1,61 @@ +import logging +import shutil +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI + +from labelforge.catalog.loader import load_catalog +from labelforge.config import settings +from labelforge.db import init_db +from labelforge.render.fonts import load_fonts +from labelforge.routes import fonts, health, labels +from labelforge.routes import print as print_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): # noqa: ARG001 + logging.basicConfig( + level=settings.log_level.upper(), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + logger = logging.getLogger(__name__) + + data_dir: Path = settings.data_dir + (data_dir / "data").mkdir(parents=True, exist_ok=True) + (data_dir / "fonts").mkdir(parents=True, exist_ok=True) + logger.info("Data directory: %s", data_dir) + + yml_path = data_dir / "labels.yml" + if not yml_path.exists(): + default_yml = Path("/app/labels.yml") + if default_yml.exists(): + shutil.copy(default_yml, yml_path) + logger.info("Copied default labels.yml to %s", yml_path) + else: + logger.warning( + "labels.yml missing at %s and no default at /app/labels.yml", yml_path + ) + + db_path = data_dir / "data" / "app.db" + init_db(db_path) + logger.info("Database ready: %s", db_path) + + load_catalog(yml_path) + load_fonts(data_dir / "fonts") + + yield + # Nothing to clean up on shutdown. + + +app = FastAPI( + title="labelforge", + version="0.0.1", + description="Self-hosted Brother QL label printer API", + lifespan=lifespan, +) + +app.include_router(health.router, prefix="/api") +app.include_router(labels.router, prefix="/api") +app.include_router(fonts.router, prefix="/api") +app.include_router(print_router.router, prefix="/api") diff --git a/backend/labelforge/models/__init__.py b/backend/labelforge/models/__init__.py new file mode 100644 index 0000000..9373089 --- /dev/null +++ b/backend/labelforge/models/__init__.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + + +class LabelEntry(BaseModel): + id: str + display_name: str + brother_part: str | None = None + description: str | None = None + category: str | None = None + color_capable: bool = False + printer_requirements: list[str] = [] + common_use: list[str] = [] + preview_image: str | None = None + # library-derived fields + dots_printable: tuple[int, int] = (0, 0) + tape_size: tuple[int, int] = (0, 0) + # form_factor integer: 1=die-cut, 2=continuous, 3=round, 4=ptouch-continuous + form_factor: int = 0 + + +class FontInfo(BaseModel): + name: str + path: str + family: str + style: str + + +class QuickPrintRequest(BaseModel): + text: str = Field(..., min_length=1) + font: str + font_size: int = Field(48, ge=6, le=200) + alignment: Literal["left", "center", "right"] = "left" + orientation: Literal["standard", "rotated"] = "standard" + label_media: str + bold: bool = False + italic: bool = False + + +class PrintJobResponse(BaseModel): + job_id: int + status: str + preview_url: str | None = None diff --git a/backend/labelforge/printer/__init__.py b/backend/labelforge/printer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/labelforge/printer/client.py b/backend/labelforge/printer/client.py new file mode 100644 index 0000000..f3aa8b6 --- /dev/null +++ b/backend/labelforge/printer/client.py @@ -0,0 +1,61 @@ +# Import paths verified against brother-ql-inventree 1.3: +# brother_ql.raster.BrotherQLRaster — builds the instruction buffer +# brother_ql.conversion.convert — PIL Image → raster bytes (returns qlr.data) +# brother_ql.backends.helpers.send — transmits bytes to the printer +# +# Network backend expects printer_identifier in the form "tcp://host[:port]" +# (port defaults to 9100 when omitted). The send() helper also accepts +# backend_identifier values: "network", "linux_kernel", "pyusb". + +import logging + +from brother_ql.backends.helpers import send +from brother_ql.conversion import convert +from brother_ql.raster import BrotherQLRaster +from PIL import Image + +logger = logging.getLogger(__name__) + + +class PrintError(Exception): + pass + + +def print_image( + image: Image.Image, + label_media: str, + model: str, + backend: str, + host: str, +) -> str: + """Convert *image* to raster instructions and send to the printer. + + Returns the send outcome string. NOTE: the network backend cannot read + back from the printer, so it returns 'sent' (transmitted, result unknown) + rather than 'printed' even on success. Only USB backends can confirm an + actual print. Callers must not treat 'sent' as a guaranteed print. + + Raises PrintError on any failure so callers can surface a clean 500. + """ + try: + qlr = BrotherQLRaster(model) + # rotate=0: keep the rendered image's width (696px for 62mm) as the + # print-head width. rotate='auto' (the library default) can flip a + # wide continuous image into a geometry the printer reads as the wrong + # roll type. The renderer already produces the correct orientation. + instructions = convert(qlr, [image], label_media, cut=True, rotate="0") + identifier = f"tcp://{host}" if backend == "network" else host + result = send( + instructions=instructions, + printer_identifier=identifier, + backend_identifier=backend, + blocking=True, + ) + logger.debug("Print result: %s", result) + if result.get("outcome") == "error": + raise PrintError(f"Printer reported an error: {result}") + return result.get("outcome", "unknown") + except PrintError: + raise + except Exception as exc: + raise PrintError(f"Print failed: {exc}") from exc diff --git a/backend/labelforge/render/__init__.py b/backend/labelforge/render/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/labelforge/render/fonts.py b/backend/labelforge/render/fonts.py new file mode 100644 index 0000000..68dcc98 --- /dev/null +++ b/backend/labelforge/render/fonts.py @@ -0,0 +1,70 @@ +import logging +from dataclasses import dataclass +from pathlib import Path + +logger = logging.getLogger(__name__) + +_SYSTEM_FONT_DIR = Path("/usr/share/fonts/truetype") +_EXTENSIONS = {".ttf", ".otf"} +# Style keyword tokens used to split family from style in filename stems. +_STYLE_TOKENS = { + "Bold", "Italic", "Light", "Thin", "Medium", "Regular", + "Black", "Condensed", "Oblique", "SemiBold", "ExtraBold", +} + +_font_cache: list["FontInfo"] = [] + + +@dataclass +class FontInfo: + name: str # filename stem — used as font identifier in the API + path: str + family: str + style: str + + +def _parse_stem(stem: str) -> tuple[str, str]: + """Split 'DejaVuSans-Bold' into family='DejaVu Sans', style='Bold'.""" + tokens = stem.replace("-", " ").replace("_", " ").split() + style_parts = [t for t in tokens if t in _STYLE_TOKENS] + family_parts = [t for t in tokens if t not in _STYLE_TOKENS] + family = " ".join(family_parts) or stem + style = " ".join(style_parts) or "Regular" + return family, style + + +def _scan_dir(directory: Path, fonts: dict[str, FontInfo]) -> None: + if not directory.exists(): + logger.debug("Font directory not found, skipping: %s", directory) + return + for path in sorted(directory.rglob("*")): + if path.suffix.lower() in _EXTENSIONS and path.is_file(): + name = path.stem + family, style = _parse_stem(name) + fonts[name] = FontInfo(name=name, path=str(path), family=family, style=style) + + +def load_fonts(user_font_dir: Path) -> None: + global _font_cache + fonts: dict[str, FontInfo] = {} + _scan_dir(_SYSTEM_FONT_DIR, fonts) + # User fonts overlay system fonts on name collision. + _scan_dir(user_font_dir, fonts) + _font_cache = list(fonts.values()) + logger.info("Fonts loaded: %d fonts discovered", len(_font_cache)) + + +def get_fonts() -> list[FontInfo]: + return _font_cache + + +def get_font_path(name: str) -> str | None: + for f in _font_cache: + if f.name == name: + return f.path + return None + + +def reload_fonts(user_font_dir: Path) -> None: + """Re-scan font directories. Not yet wired to an endpoint.""" + load_fonts(user_font_dir) diff --git a/backend/labelforge/render/text.py b/backend/labelforge/render/text.py new file mode 100644 index 0000000..2e9d5cc --- /dev/null +++ b/backend/labelforge/render/text.py @@ -0,0 +1,126 @@ +import logging + +from PIL import Image, ImageDraw, ImageFont + +from labelforge.catalog.loader import get_label +from labelforge.render.fonts import get_font_path + +logger = logging.getLogger(__name__) + +# Horizontal and vertical padding in pixels applied inside the label bounds. +_PADDING = 20 + +# form_factor values that represent continuous (endless) media. +_CONTINUOUS_FORM_FACTORS = {2, 4} # ENDLESS=2, PTOUCH_ENDLESS=4 + + +class RenderError(Exception): + pass + + +def render_text( + text: str, + font_name: str, + font_size: int, + alignment: str, + orientation: str, + bold: bool, # noqa: ARG001 — reserved for future bold-variant font selection + italic: bool, # noqa: ARG001 — reserved for future italic-variant font selection + label_media: str, +) -> Image.Image: + """Render *text* onto a white PIL Image sized for *label_media*. + + For continuous media the height expands to fit the text. + For die-cut media the height is fixed; raises RenderError if text overflows. + """ + label = get_label(label_media) + if label is None: + raise RenderError(f"Unknown label media: {label_media}") + + font_path = get_font_path(font_name) + if font_path is None: + raise RenderError(f"Font not available: {font_name}") + + try: + pil_font = ImageFont.truetype(font_path, font_size) + except Exception as exc: + raise RenderError(f"Could not load font '{font_name}': {exc}") from exc + + width_px = label.dots_printable[0] + usable_width = width_px - 2 * _PADDING + + # Measure and wrap using a throw-away draw surface. + _probe = ImageDraw.Draw(Image.new("L", (1, 1))) + lines = _wrap_text(text, pil_font, usable_width, _probe) + line_height = _measure_line_height(pil_font, _probe) + total_text_height = len(lines) * line_height + total_height = total_text_height + 2 * _PADDING + + is_continuous = label.form_factor in _CONTINUOUS_FORM_FACTORS + + if is_continuous: + height_px = max(total_height, 1) + else: + height_px = label.dots_printable[1] + if total_height > height_px: + raise RenderError( + f"Text exceeds label dimensions at requested font size " + f"({total_height}px rendered, {height_px}px available). " + "Reduce font size or shorten the text." + ) + + img = Image.new("L", (width_px, height_px), 255) + draw = ImageDraw.Draw(img) + + y = _PADDING + for line in lines: + bbox = draw.textbbox((0, 0), line, font=pil_font) + text_width = bbox[2] - bbox[0] + + if alignment == "center": + x = (width_px - text_width) // 2 + elif alignment == "right": + x = width_px - text_width - _PADDING + else: + x = _PADDING + + draw.text((x, y), line, fill=0, font=pil_font) + y += line_height + + if orientation == "rotated": + img = img.rotate(90, expand=True) + + return img + + +def _measure_line_height(font: ImageFont.FreeTypeFont, draw: ImageDraw.ImageDraw) -> int: + bbox = draw.textbbox((0, 0), "Ag|", font=font) + return (bbox[3] - bbox[1]) + 4 # +4px inter-line gap + + +def _wrap_text( + text: str, + font: ImageFont.FreeTypeFont, + max_width: int, + draw: ImageDraw.ImageDraw, +) -> list[str]: + """Word-wrap *text* to fit within *max_width* pixels.""" + output: list[str] = [] + for paragraph in text.splitlines(): + if not paragraph.strip(): + output.append("") + continue + words = paragraph.split() + current = "" + for word in words: + candidate = f"{current} {word}".strip() if current else word + bbox = draw.textbbox((0, 0), candidate, font=font) + if bbox[2] - bbox[0] <= max_width: + current = candidate + else: + if current: + output.append(current) + current = word + if current: + output.append(current) + return output or [""] diff --git a/backend/labelforge/routes/__init__.py b/backend/labelforge/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/labelforge/routes/auth.py b/backend/labelforge/routes/auth.py new file mode 100644 index 0000000..72170f3 --- /dev/null +++ b/backend/labelforge/routes/auth.py @@ -0,0 +1,15 @@ + +from fastapi import Header, HTTPException + +from labelforge.config import settings + + +async def require_auth(authorization: str | None = Header(None)) -> None: + """FastAPI dependency: enforce Bearer token on all protected routes.""" + if authorization is None: + raise HTTPException(status_code=401, detail="Authorization header required") + if not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Authorization header must use Bearer scheme") + token = authorization[7:] + if token != settings.api_token: + raise HTTPException(status_code=403, detail="Invalid API token") diff --git a/backend/labelforge/routes/fonts.py b/backend/labelforge/routes/fonts.py new file mode 100644 index 0000000..7e2418b --- /dev/null +++ b/backend/labelforge/routes/fonts.py @@ -0,0 +1,15 @@ +from fastapi import APIRouter, Depends + +from labelforge.models import FontInfo +from labelforge.render.fonts import get_fonts +from labelforge.routes.auth import require_auth + +router = APIRouter(dependencies=[Depends(require_auth)]) + + +@router.get("/fonts", response_model=list[FontInfo]) +async def list_fonts() -> list[FontInfo]: + return [ + FontInfo(name=f.name, path=f.path, family=f.family, style=f.style) + for f in get_fonts() + ] diff --git a/backend/labelforge/routes/health.py b/backend/labelforge/routes/health.py new file mode 100644 index 0000000..5fcfcde --- /dev/null +++ b/backend/labelforge/routes/health.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/health") +async def health() -> dict: + return {"status": "ok"} diff --git a/backend/labelforge/routes/labels.py b/backend/labelforge/routes/labels.py new file mode 100644 index 0000000..fd7bcf8 --- /dev/null +++ b/backend/labelforge/routes/labels.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter, Depends, HTTPException + +from labelforge.catalog.loader import get_catalog, get_label +from labelforge.models import LabelEntry +from labelforge.routes.auth import require_auth + +router = APIRouter(dependencies=[Depends(require_auth)]) + + +@router.get("/labels", response_model=list[LabelEntry]) +async def list_labels() -> list[LabelEntry]: + return list(get_catalog().values()) + + +@router.get("/labels/{label_id}", response_model=LabelEntry) +async def get_label_by_id(label_id: str) -> LabelEntry: + entry = get_label(label_id) + if entry is None: + raise HTTPException(status_code=404, detail=f"Unknown label media: {label_id}") + return entry diff --git a/backend/labelforge/routes/print.py b/backend/labelforge/routes/print.py new file mode 100644 index 0000000..e8ca2e7 --- /dev/null +++ b/backend/labelforge/routes/print.py @@ -0,0 +1,63 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException + +from labelforge.catalog.loader import get_label +from labelforge.config import settings +from labelforge.db import get_connection +from labelforge.models import PrintJobResponse, QuickPrintRequest +from labelforge.printer.client import PrintError, print_image +from labelforge.render.text import RenderError, render_text +from labelforge.routes.auth import require_auth + +logger = logging.getLogger(__name__) + +router = APIRouter(dependencies=[Depends(require_auth)]) + + +@router.post("/print/quick", response_model=PrintJobResponse) +async def quick_print(request: QuickPrintRequest) -> PrintJobResponse: + if not request.text.strip(): + raise HTTPException(status_code=400, detail="Text is required") + + if get_label(request.label_media) is None: + raise HTTPException(status_code=400, detail=f"Unknown label media: {request.label_media}") + + try: + image = render_text( + text=request.text, + font_name=request.font, + font_size=request.font_size, + alignment=request.alignment, + orientation=request.orientation, + bold=request.bold, + italic=request.italic, + label_media=request.label_media, + ) + except RenderError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + try: + outcome = print_image( + image=image, + label_media=request.label_media, + model=settings.printer_model, + backend=settings.printer_backend, + host=settings.printer_host, + ) + except PrintError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + db_path = settings.data_dir / "data" / "app.db" + conn = get_connection(db_path) + try: + cursor = conn.execute( + "INSERT INTO print_jobs (payload_json, label_media) VALUES (?, ?)", + (request.model_dump_json(), request.label_media), + ) + conn.commit() + job_id = cursor.lastrowid + finally: + conn.close() + + return PrintJobResponse(job_id=job_id, status=outcome) diff --git a/compose.dev.yml b/compose.dev.yml new file mode 100644 index 0000000..508d22d --- /dev/null +++ b/compose.dev.yml @@ -0,0 +1,29 @@ +name: labelforge-dev + +services: + labelforge: + build: . + image: labelforge:dev + restart: "no" + env_file: .env + environment: + LOG_LEVEL: DEBUG + volumes: + # Bind-mount source for hot-reload. The editable pip install inside the + # image points to /app/backend/labelforge/, so this overlay takes effect + # immediately without rebuilding. + - ./backend:/app/backend + # Dev data directory — kept separate from any production volume. + - ./data:/data + ports: + - "8001:8000" + command: + - uvicorn + - labelforge.main:app + - --host + - "0.0.0.0" + - --port + - "8000" + - --reload + - --reload-dir + - /app/backend diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..640d808 --- /dev/null +++ b/compose.yml @@ -0,0 +1,31 @@ +name: labelforge + +services: + labelforge: + build: . + image: labelforge:latest + restart: unless-stopped + env_file: .env + volumes: + - /var/docker/labelforge:/var/docker/labelforge + networks: + - traefik + - dockflare + labels: + # ── Traefik (LAN — labels.home.arpa via internal Traefik instance) ──── + traefik.enable: "true" + traefik.http.routers.labelforge-lan.rule: "Host(`labels.home.arpa`)" + traefik.http.routers.labelforge-lan.entrypoints: "web" + traefik.http.services.labelforge.loadbalancer.server.port: "8000" + + # ── Dockflare (Cloudflare Tunnel — labels.crzynet.com) ─────────────── + # Adjust label keys to match your Dockflare version / configuration. + dockflare.enable: "true" + dockflare.hostname: "labels.crzynet.com" + dockflare.service: "http://labelforge:8000" + +networks: + traefik: + external: true + dockflare: + external: true diff --git a/labels.yml b/labels.yml new file mode 100644 index 0000000..6f8ae30 --- /dev/null +++ b/labels.yml @@ -0,0 +1,125 @@ +# labelforge default label catalog +# Maps brother_ql library identifiers to user-friendly metadata. +# Required fields: id, display_name +# All other fields are optional. Unknown fields are ignored. +# Reload: restart the app or POST /api/admin/reload-catalog (future slice). + +labels: + + # ── Continuous (mono) ───────────────────────────────────────────────────── + + - id: "12" + display_name: "12mm Continuous" + description: "Narrow continuous tape, black on white." + category: continuous + color_capable: false + + - id: "29" + display_name: "29mm Continuous" + description: "Medium-width continuous tape, black on white." + category: continuous + color_capable: false + + - id: "38" + display_name: "38mm Continuous" + description: "Wide continuous tape, black on white." + category: continuous + color_capable: false + + - id: "50" + display_name: "50mm Continuous" + description: "50mm continuous tape, black on white." + category: continuous + color_capable: false + + - id: "54" + display_name: "54mm Continuous" + description: "54mm continuous tape, black on white." + category: continuous + color_capable: false + + - id: "62" + display_name: "62mm Continuous (Black)" + brother_part: "DK-22205" + description: "General-purpose paper tape, black on white." + category: continuous + color_capable: false + common_use: + - "address labels" + - "file folders" + - "spool labels" + + # ── Continuous (two-color) ──────────────────────────────────────────────── + + - id: "62red" + display_name: "62mm Continuous (Black + Red)" + brother_part: "DK-22251" + description: "Two-color paper tape. Requires QL-800 series printer." + category: continuous + color_capable: true + printer_requirements: + - "QL-800" + - "QL-810W" + - "QL-820NWB" + + # ── Die-cut ─────────────────────────────────────────────────────────────── + + - id: "17x54" + display_name: "17mm × 54mm Die-cut" + description: "Small die-cut label." + category: die-cut + color_capable: false + + - id: "17x87" + display_name: "17mm × 87mm Die-cut" + description: "Small elongated die-cut label." + category: die-cut + color_capable: false + + - id: "29x90" + display_name: "29mm × 90mm Address Label" + brother_part: "DK-11201" + description: "Standard address label, die-cut." + category: die-cut + color_capable: false + common_use: + - "address" + - "file folder" + - "spool" + + - id: "38x90" + display_name: "38mm × 90mm Die-cut" + description: "Medium die-cut label." + category: die-cut + color_capable: false + + - id: "52x29" + display_name: "52mm × 29mm Die-cut" + description: "Wide short die-cut label." + category: die-cut + color_capable: false + + - id: "62x29" + display_name: "62mm × 29mm Die-cut" + description: "Wide short die-cut label." + category: die-cut + color_capable: false + + - id: "62x100" + display_name: "62mm × 100mm Shipping Label" + brother_part: "DK-11202" + description: "Larger die-cut, good for shipping or product labels." + category: die-cut + color_capable: false + common_use: + - "shipping" + - "product" + + # ── Round ───────────────────────────────────────────────────────────────── + + - id: "d24" + display_name: "24mm Round" + brother_part: "DK-11218" + description: "Round die-cut, 24mm diameter." + category: round + color_capable: false From 40272353fc89021d374e4debaba8f2ac1f1c342c Mon Sep 17 00:00:00 2001 From: crzykidd Date: Tue, 19 May 2026 22:17:50 -0700 Subject: [PATCH 03/84] Document printer setup and slice 1 decisions --- .gitignore | 2 ++ CHANGELOG.md | 25 +++++++++++++++++++++++-- README.md | 35 +++++++++++++++++++++++++++++++++++ docs/decisions.md | 24 ++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 8fbf412..1588431 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ Thumbs.db # Runtime data (should never be committed) data/ +data-dev/ *.db *.db-journal *.sqlite @@ -66,3 +67,4 @@ logs/ .coverage htmlcov/ coverage.xml +test-print.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 151c480..11dc144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,14 +9,35 @@ All notable changes to labelforge are recorded here. Format follows [Keep a Chan - PRD covering quick-print, templates, label catalog, history, HTTP API, printer status, and settings - Architecture doc locking stack: FastAPI + SQLite + brother-ql-inventree + Vite/TS + Fabric.js - Glossary defining vocabulary -- ADR log with 6 decisions recorded (library choice, license, name, storage, frontend, label catalog model, auth) +- ADR log (library choice, license, name, storage, frontend, label catalog model, auth, print-outcome reporting, convert rotation) - CLAUDE.md for AI session context - GPL-3.0 LICENSE - .gitattributes enforcing LF line endings - .gitignore for Python + Node + IDE artifacts +- **Slice 1 backend skeleton** — FastAPI app with lifespan startup (DB init, catalog load, font scan) +- `GET /api/health` — unauthenticated liveness probe +- `GET /api/labels`, `GET /api/labels/{id}` — merged label catalog (library truth + labels.yml metadata) +- `GET /api/fonts` — discovered font list (system fonts + user fonts at `${DATA_DIR}/fonts/`) +- `POST /api/print/quick` — render text with Pillow, print via brother-ql-inventree, log to history +- Bearer-token auth on all `/api/*` routes except `/api/health`; app refuses to start without `API_TOKEN` +- SQLite schema (`print_jobs`, `settings`) created on startup via raw sqlite3 +- Default `labels.yml` catalog (15 DK media entries: continuous, die-cut, round) +- Dockerfile (single-stage python:3.12-slim; bundled fonts: dejavu-core, liberation2, noto-core; multi-stage frontend build deferred) +- `compose.yml` (production: Traefik + Dockflare networks) and `compose.dev.yml` (hot-reload via bind-mount + uvicorn --reload, published on host port 8001) +- `.env.example` documenting all env vars +- README: required printer setup (Command Mode → Raster, Template Mode → Off, Unit → mm) and `wrong roll type` troubleshooting + +### Changed +- `convert()` called with explicit `rotate="0"` instead of the library default `auto` (see ADR 2026-05-20) + +### Fixed +- Dockerfile: copy `README.md` before `pip install -e .` (hatchling validates the readme path; build failed without it) +- `compose.dev.yml`: removed `DATA_DIR=/data/` override that did not match the bind-mount path, breaking startup catalog/DB load +- `.gitignore`: ignore `data-dev/` and the `test-print.json` scratch file +- Print API now reports the true send outcome (`sent` for the network backend) instead of always claiming `printed` (see ADR 2026-05-20) ### Status -- No code yet. Design phase complete. Next: backend skeleton + first end-to-end print path (slice 1). +- Slice 1 verified end-to-end: a real label printed on the QL-820NWB (DK-1209 die-cut, `62x29`). The render → convert → network-send path is confirmed working. Deferred to later slices: templates, canvas editor, history UI, printer-status query, settings UI, batch/increment, frontend. --- diff --git a/README.md b/README.md index 64affc5..fda8fbd 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,41 @@ Self-hosted web app for designing, saving, and printing labels to Brother QL ser - Print history with reprint and pinning - Freeform canvas editor (text, QR codes, barcodes, images, shapes) +## Printer setup (required) + +The app talks to the printer in **raster** mode over TCP. A factory or +previously-used Brother QL-820NWB often ships configured for standalone +template printing, which will reject raster jobs with a misleading +`wrong roll type` error. Set these on the printer's LCD before first use: + +- **Command Mode → Raster.** Menu → (Template/Command settings) → Command + Mode → Raster. If it is set to `P-touch Template` or `ESC/P`, raster jobs + fail. This is the single most common cause of prints not appearing. +- **Template Mode → Off.** Menu → Template Settings → Template Mode → Off. + A saved template size overrides DK roll auto-detection and forces a fixed + label size, causing `wrong roll type` on a non-matching roll. +- **Unit → mm.** Menu → Settings → Unit → mm. Cosmetic, but keeps the panel + readout consistent with the catalog. + +After changing Command Mode, reseat the DK roll (remove it, close the cover +empty so the printer reports no media, then reload) so media auto-detection +re-runs. + +### Troubleshooting `wrong roll type` + +If a job is rejected as `wrong roll type` even with the settings above: + +- **Worn or sample rolls.** Detection depends on the plastic tabs on the + roll's spool end-caps pressing micro-switches in the bay. Worn rolls (e.g. + the bundled SAMPLE roll) can fail to be sensed and get rejected. Test with + a standard DK roll that has intact end-caps. +- **Media mismatch.** The `label_media` in the request must match the roll + physically loaded. The printer rejects a job whose declared media does not + match what it senses. +- The network backend cannot read printer status back, so a failed print may + still return HTTP 200 with `status: "sent"` — `sent` means *transmitted*, + not *confirmed printed*. Watch the physical printer. + ## Design docs See [`docs/PRD.md`](docs/PRD.md) for scope, then [`docs/features/`](docs/features/) for per-feature designs. diff --git a/docs/decisions.md b/docs/decisions.md index d658afe..ab741b7 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -4,6 +4,30 @@ Architecture Decision Records, newest at the top. Each entry: what we decided, w --- +## 2026-05-20 — Print API reports `sent`, not `printed`, on the network backend + +**Decision**: `POST /api/print/*` returns the print outcome verbatim from the brother_ql backend. For the network (TCP) backend this is `sent`, meaning the raster was transmitted but the result is unconfirmed. Only backends that can read printer status back (USB) return `printed`. The API never claims `printed` for a network send. + +**Why**: The brother_ql network backend writes raster bytes and returns immediately — the QL-820NWB does not support status read-back over TCP, so the library cannot know whether a label actually printed. Reporting `printed` would be a lie that misleads API consumers (e.g. Home Assistant) into trusting a success that may not have happened. `sent` accurately means "transmitted, outcome unknown." + +**Considered**: +- Always report `printed` on a successful send (rejected — false positive; hides real failures like a rejected roll) +- Add a follow-up status query after sending (rejected for v1 — the network backend doesn't reliably answer status requests; revisit with printer-status feature) + +**Would revisit if**: the printer-status feature lands and we can poll for completion, or we add a USB backend path that confirms prints. + +--- + +## 2026-05-20 — brother_ql `convert()` called with explicit `rotate="0"` + +**Decision**: `printer/client.py` passes `rotate="0"` to `brother_ql.conversion.convert()` rather than relying on the library default of `rotate="auto"`. The renderer (`render/text.py`) produces images already in the correct orientation for the print head. + +**Why**: `auto` rotation can flip a wide continuous image into a geometry that misrepresents the label width. Keeping `rotate="0"` makes the rendered image's pixel width (e.g. 696px for 62mm) the print-head width directly, matching what the renderer intends. Verified that for the current render path both produce identical rasters, but explicit-zero removes ambiguity if the renderer's output dimensions change. + +**Would revisit if**: a future render path produces images in the feed-direction orientation, at which point rotation handling moves into the renderer or this flag changes accordingly. + +--- + ## 2026-05-19 — Use `brother-ql-inventree` as the printer library **Decision**: Take `brother-ql-inventree` (PyPI) as the printer protocol library. Pin as a normal dependency, do not fork. From 5da9aba466b307e87c254974d41aed73d28a8f14 Mon Sep 17 00:00:00 2001 From: crzykidd Date: Tue, 19 May 2026 22:20:50 -0700 Subject: [PATCH 04/84] updates --- .claude/CLAUDE.md | 60 ------------------------------------- .claude/hooks/vexp-guard.sh | 14 --------- .claude/settings.json | 16 ---------- .claude/settings.local.json | 7 ----- 4 files changed, 97 deletions(-) delete mode 100644 .claude/CLAUDE.md delete mode 100644 .claude/hooks/vexp-guard.sh delete mode 100644 .claude/settings.json delete mode 100644 .claude/settings.local.json diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md deleted file mode 100644 index 09b4011..0000000 --- a/.claude/CLAUDE.md +++ /dev/null @@ -1,60 +0,0 @@ -## vexp — Context-Aware AI Coding - -### MANDATORY: use vexp pipeline — do NOT grep or glob the codebase -For every task — bug fixes, features, refactors, debugging: -**call `run_pipeline` FIRST**. It executes context search + impact analysis + -memory recall in a single call, returning compressed results. - -Do NOT use grep, glob, Bash, or cat to search/explore the codebase. -vexp returns pre-indexed, graph-ranked context that is more relevant and -uses fewer tokens than manual searching. Prefer `get_skeleton` over Read to -inspect files (detail: minimal/standard/detailed, 70-90% token savings). -Only use Read when you need exact raw content to edit a specific line. - -### Primary Tool -- `run_pipeline` — **USE THIS FOR EVERYTHING**. Single call that runs - capsule + impact + memory server-side. Returns compressed results. - Auto-detects intent (debug/modify/refactor/explore) from your task. - Includes full file content for pivots. - Examples: - - `run_pipeline({ "task": "fix JWT validation bug" })` — auto-detect - - `run_pipeline({ "task": "refactor db layer", "preset": "refactor" })` — explicit - - `run_pipeline({ "task": "add auth", "observation": "using JWT" })` — save insight in same call - -### Other MCP tools (use only when run_pipeline is insufficient) -- `get_skeleton` — **preferred over Read** for inspecting files (minimal/standard/detailed detail levels, 70-90% token savings) -- `index_status` — indexing status and health check -- `expand_vexp_ref` — expand V-REF hash placeholders in v2 compact output - -### Workflow -1. `run_pipeline("your task")` — ALWAYS FIRST. Returns pivots + impact + memories in 1 call -2. Need more detail on a file? Use `get_skeleton({ files: [...], detail: "detailed" })` — avoid Read unless editing -3. Make targeted changes based on the context returned -4. `run_pipeline` again ONLY if you need more context during implementation -5. Do NOT chain multiple vexp calls — one `run_pipeline` replaces capsule + impact + memory + observation - -### Subagent / Explore / Plan mode -- Subagents CAN and MUST call `run_pipeline` — always include the task description -- The PreToolUse hook blocks Grep/Glob when vexp daemon is running -- Do NOT spawn Agent(Explore) to freely search — call `run_pipeline` first, - then pass the returned context into the agent prompt if needed -- Always: `run_pipeline` → get context → spawn agent with context - -### Smart Features (automatic — no action needed) -- **Intent Detection**: auto-detects from your task keywords. "fix bug" → Debug, "refactor" → blast-radius, "add" → Modify -- **Hybrid Search**: keyword + semantic + graph centrality ranking -- **Session Memory**: auto-captures observations; memories auto-surfaced in results -- **LSP Bridge**: VS Code captures type-resolved call edges -- **Change Coupling**: co-changed files included as related context - -### Advanced Parameters -- `preset: "debug"` — forces debug mode (capsule+tests+impact+memory) -- `preset: "refactor"` — deep impact analysis (depth 5) -- `max_tokens: 12000` — increase total budget for complex tasks -- `include_tests: true` — include test files in results -- `include_file_content: false` — omit full file content (lighter response) - -### Multi-Repo Workspaces -`run_pipeline` auto-queries all indexed repos. Use `repos: ["alias"]` to scope. -Use `index_status` to discover available repo aliases. - \ No newline at end of file diff --git a/.claude/hooks/vexp-guard.sh b/.claude/hooks/vexp-guard.sh deleted file mode 100644 index 9cefc1d..0000000 --- a/.claude/hooks/vexp-guard.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash -# vexp-guard: block Grep/Glob when vexp daemon is running AND index is healthy. -# Fast path: if socket file or healthy marker doesn't exist, allow immediately. -# PID check: verify daemon process is alive (handles stale files after kill -9). -VEXP_DIR="${CLAUDE_PROJECT_DIR:-.}/.vexp" -SOCK="$VEXP_DIR/daemon.sock" -HEALTHY="$VEXP_DIR/healthy" -PID_FILE="$VEXP_DIR/daemon.pid" -if [ -S "$SOCK" ] && [ -f "$HEALTHY" ] && [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE" 2>/dev/null)" 2>/dev/null; then - printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"vexp daemon is running. Use run_pipeline instead of Grep/Glob."}}' -else - printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"vexp index not ready, allowing direct search fallback."}}' -fi -exit 0 diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 3883ca6..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Grep|Glob|Regex", - "hooks": [ - { - "type": "command", - "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-guard.sh", - "timeout": 3000 - } - ] - } - ] - } -} diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 7cb5ff9..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(git push *)" - ] - } -} From 28a277c8d80ab7fb2405400932925b9eca4f6e23 Mon Sep 17 00:00:00 2001 From: crzykidd Date: Tue, 19 May 2026 22:20:59 -0700 Subject: [PATCH 05/84] update --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 1588431..ac33dab 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,9 @@ logs/ .vexp/daemon.pipe .vexp/vexp.log +# claude +.claude/ + # Test artifacts .coverage htmlcov/ From 81443d14a07a982c4b37f941b34fb26f93da8fd0 Mon Sep 17 00:00:00 2001 From: crzykidd Date: Tue, 19 May 2026 22:46:43 -0700 Subject: [PATCH 06/84] Add quick-print frontend scaffold (Vite + vanilla TS) --- CHANGELOG.md | 1 + frontend/index.html | 12 ++ frontend/package.json | 14 ++ frontend/src/api.ts | 42 +++++ frontend/src/main.ts | 4 + frontend/src/pages/quick-print.ts | 249 ++++++++++++++++++++++++++++++ frontend/src/style.css | 130 ++++++++++++++++ frontend/src/types.ts | 39 +++++ frontend/tsconfig.json | 13 ++ frontend/vite.config.ts | 9 ++ 10 files changed, 513 insertions(+) create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/pages/quick-print.ts create mode 100644 frontend/src/style.css create mode 100644 frontend/src/types.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 11dc144..885dbf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to labelforge are recorded here. Format follows [Keep a Chan - GPL-3.0 LICENSE - .gitattributes enforcing LF line endings - .gitignore for Python + Node + IDE artifacts +- **Slice 2 frontend skeleton** — Vite + TypeScript quick-print SPA: token gate, label/font selectors (grouped by form factor), font size, bold/italic, alignment, orientation, print via `POST /api/print/quick`, localStorage pref persistence; Preview button present but disabled pending backend endpoint - **Slice 1 backend skeleton** — FastAPI app with lifespan startup (DB init, catalog load, font scan) - `GET /api/health` — unauthenticated liveness probe - `GET /api/labels`, `GET /api/labels/{id}` — merged label catalog (library truth + labels.yml metadata) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..7b26961 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + LabelForge + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..ce02cf0 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,14 @@ +{ + "name": "labelforge-frontend", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "^5.7.2", + "vite": "^6.3.5" + } +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..a37a70d --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,42 @@ +import type { FontInfo, LabelEntry, PrintJobResponse, QuickPrintRequest } from './types' + +export const TOKEN_KEY = 'labelforge_token' + +function getToken(): string { + return localStorage.getItem(TOKEN_KEY) ?? '' +} + +async function apiFetch(path: string, options: RequestInit = {}): Promise { + const res = await fetch(path, { + ...options, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${getToken()}`, + ...(options.headers as Record), + }, + }) + if (!res.ok) { + let detail = `HTTP ${res.status}` + try { + const body = await res.json() as { detail?: unknown } + if (body.detail) detail = String(body.detail) + } catch { /* use status fallback */ } + throw new Error(detail) + } + return res.json() as Promise +} + +export function getLabels(): Promise { + return apiFetch('/api/labels') +} + +export function getFonts(): Promise { + return apiFetch('/api/fonts') +} + +export function quickPrint(req: QuickPrintRequest): Promise { + return apiFetch('/api/print/quick', { + method: 'POST', + body: JSON.stringify(req), + }) +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..b3b3711 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,4 @@ +import './style.css' +import { mountQuickPrint } from './pages/quick-print' + +mountQuickPrint(document.getElementById('app')!) diff --git a/frontend/src/pages/quick-print.ts b/frontend/src/pages/quick-print.ts new file mode 100644 index 0000000..0e27d81 --- /dev/null +++ b/frontend/src/pages/quick-print.ts @@ -0,0 +1,249 @@ +import { getFonts, getLabels, quickPrint, TOKEN_KEY } from '../api' +import type { LabelEntry, QuickPrintRequest } from '../types' + +// localStorage is the v1 stand-in for saved settings; replace when GET/PUT /api/settings exists +const PREF = { + font: 'labelforge_font', + font_size: 'labelforge_font_size', + label_media: 'labelforge_label_media', + alignment: 'labelforge_alignment', + orientation: 'labelforge_orientation', +} + +const FORM_FACTOR_LABEL: Record = { + 1: 'Die-cut', + 2: 'Continuous', + 3: 'Round', + 4: 'P-touch Continuous', +} + +// Render order for form-factor groups +const FF_ORDER = [2, 1, 3, 4] + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>') +} + +export function mountQuickPrint(root: HTMLElement): void { + if (!localStorage.getItem(TOKEN_KEY)) { + renderTokenGate(root) + } else { + renderForm(root) + } +} + +function renderTokenGate(root: HTMLElement): void { + root.innerHTML = ` +
+

LabelForge

+

Enter your API token to continue.

+ + +
+ ` + const input = root.querySelector('#token-input')! + const btn = root.querySelector('#token-save')! + + function save(): void { + const val = input.value.trim() + if (!val) return + localStorage.setItem(TOKEN_KEY, val) + renderForm(root) + } + + btn.addEventListener('click', save) + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') save() }) +} + +function renderForm(root: HTMLElement): void { + root.innerHTML = ` +
+

Quick Print

+ + +
+ ` + + const form = root.querySelector('#print-form')! + const textarea = root.querySelector('#text')! + const fontSelect = root.querySelector('#font')! + const fontSizeInput = root.querySelector('#font-size')! + const labelSelect = root.querySelector('#label-media')! + const boldCheck = root.querySelector('#bold')! + const italicCheck = root.querySelector('#italic')! + const btnPrint = root.querySelector('#btn-print')! + const statusMsg = root.querySelector('#status-msg')! + + function showStatus(msg: string, kind: 'success' | 'error'): void { + statusMsg.textContent = msg + statusMsg.className = `status-msg ${kind}` + statusMsg.hidden = false + } + + function hideStatus(): void { + statusMsg.hidden = true + } + + function updatePrintButton(): void { + btnPrint.disabled = textarea.value.trim() === '' + } + + textarea.addEventListener('input', updatePrintButton) + + Promise.all([getFonts(), getLabels()]).then(([fonts, labels]) => { + // Populate fonts + fontSelect.innerHTML = fonts + .map(f => ``) + .join('') + const savedFont = localStorage.getItem(PREF.font) + if (savedFont) fontSelect.value = savedFont + + // Group labels by form_factor, sort within groups by display_name + const groups = new Map() + for (const label of labels) { + const ff = label.form_factor + if (!groups.has(ff)) groups.set(ff, []) + groups.get(ff)!.push(label) + } + for (const entries of groups.values()) { + entries.sort((a, b) => a.display_name.localeCompare(b.display_name)) + } + + // Render known order first, then any remaining form_factor values + const allKeys = [...new Set([...FF_ORDER, ...groups.keys()])] + labelSelect.innerHTML = allKeys + .filter(k => groups.has(k)) + .map(k => { + const groupLabel = FORM_FACTOR_LABEL[k] ?? 'Other' + const opts = groups + .get(k)! + .map(l => ``) + .join('') + return `${opts}` + }) + .join('') + + const savedLabel = localStorage.getItem(PREF.label_media) + if (savedLabel) labelSelect.value = savedLabel + + // Restore remaining prefs + const savedSize = localStorage.getItem(PREF.font_size) + if (savedSize) fontSizeInput.value = savedSize + + const savedAlignment = localStorage.getItem(PREF.alignment) + if (savedAlignment) { + const radio = form.querySelector( + `input[name="alignment"][value="${savedAlignment}"]` + ) + if (radio) radio.checked = true + } + + const savedOrientation = localStorage.getItem(PREF.orientation) + if (savedOrientation) { + const radio = form.querySelector( + `input[name="orientation"][value="${savedOrientation}"]` + ) + if (radio) radio.checked = true + } + }).catch((err: Error) => { + showStatus(`Failed to load form data: ${err.message}`, 'error') + }) + + form.addEventListener('submit', async (e) => { + e.preventDefault() + btnPrint.disabled = true + hideStatus() + + const alignment = ( + form.querySelector('input[name="alignment"]:checked')?.value ?? 'left' + ) as QuickPrintRequest['alignment'] + + const orientation = ( + form.querySelector('input[name="orientation"]:checked')?.value ?? 'standard' + ) as QuickPrintRequest['orientation'] + + const req: QuickPrintRequest = { + text: textarea.value, + font: fontSelect.value, + font_size: parseInt(fontSizeInput.value, 10), + alignment, + orientation, + label_media: labelSelect.value, + bold: boldCheck.checked, + italic: italicCheck.checked, + } + + localStorage.setItem(PREF.font, req.font) + localStorage.setItem(PREF.font_size, String(req.font_size)) + localStorage.setItem(PREF.label_media, req.label_media) + localStorage.setItem(PREF.alignment, req.alignment) + localStorage.setItem(PREF.orientation, req.orientation) + + try { + const result = await quickPrint(req) + // "sent" = transmitted to printer network backend, not confirmed printed + showStatus( + `Sent — job #${result.job_id} (status: ${result.status}). "Sent" means the job was transmitted to the printer; delivery is not confirmed.`, + 'success' + ) + } catch (err) { + showStatus((err as Error).message, 'error') + } finally { + updatePrintButton() + } + }) +} diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..1668a0c --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,130 @@ +*, *::before, *::after { + box-sizing: border-box; +} + +body { + font-family: system-ui, sans-serif; + background: #f5f5f5; + margin: 0; + padding: 1rem; + color: #1a1a1a; +} + +#app { + max-width: 520px; + margin: 0 auto; +} + +h2 { + margin: 0 0 1.25rem; +} + +.token-gate, +.quick-print { + background: #fff; + border: 1px solid #ddd; + border-radius: 6px; + padding: 1.5rem; +} + +form { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +label { + display: block; + font-weight: 600; + font-size: 0.875rem; + margin-bottom: 0.25rem; +} + +textarea, +input[type="number"], +select { + width: 100%; + padding: 0.4rem 0.5rem; + border: 1px solid #ccc; + border-radius: 4px; + font: inherit; + font-size: 0.9rem; +} + +textarea { + resize: vertical; +} + +.checkboxes, +.radio-group { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.checkboxes label, +.radio-group label { + font-weight: normal; + display: flex; + align-items: center; + gap: 0.3rem; + cursor: pointer; +} + +.actions { + display: flex; + gap: 0.75rem; + margin-top: 0.5rem; +} + +button { + padding: 0.45rem 1.1rem; + border: 1px solid #999; + border-radius: 4px; + background: #fff; + font: inherit; + font-size: 0.9rem; + cursor: pointer; +} + +button[type="submit"] { + background: #1a6fdb; + color: #fff; + border-color: #1a6fdb; + font-weight: 600; +} + +button[type="submit"]:disabled, +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.status-msg { + padding: 0.6rem 0.8rem; + border-radius: 4px; + font-size: 0.875rem; + margin-bottom: 0.75rem; +} + +.status-msg.success { + background: #e6f4ea; + border: 1px solid #a8d5b5; + color: #1a5c2a; +} + +.status-msg.error { + background: #fdecea; + border: 1px solid #f5b8b4; + color: #7a1a14; +} + +/* token gate */ +.token-gate input[type="password"] { + width: 100%; + padding: 0.4rem 0.5rem; + border: 1px solid #ccc; + border-radius: 4px; + font: inherit; + margin-bottom: 0.5rem; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..3c3ebac --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,39 @@ +export interface LabelEntry { + id: string; + display_name: string; + brother_part: string | null; + description: string | null; + category: string | null; + color_capable: boolean; + printer_requirements: string[]; + common_use: string[]; + preview_image: string | null; + dots_printable: [number, number]; + tape_size: [number, number]; + // 1=die-cut, 2=continuous, 3=round, 4=ptouch-continuous + form_factor: number; +} + +export interface FontInfo { + name: string; + path: string; + family: string; + style: string; +} + +export interface QuickPrintRequest { + text: string; + font: string; + font_size: number; + alignment: 'left' | 'center' | 'right'; + orientation: 'standard' | 'rotated'; + label_media: string; + bold: boolean; + italic: boolean; +} + +export interface PrintJobResponse { + job_id: number; + status: string; + preview_url: string | null; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ca183c --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..30211ee --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + server: { + proxy: { + '/api': 'http://localhost:8001', + }, + }, +}) From 889c712d3ba6b38dd692ba14def0c4548e907b10 Mon Sep 17 00:00:00 2001 From: crzykidd Date: Tue, 19 May 2026 22:54:06 -0700 Subject: [PATCH 07/84] Add quick-print preview endpoint --- backend/labelforge/main.py | 2 ++ backend/labelforge/routes/preview.py | 41 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 backend/labelforge/routes/preview.py diff --git a/backend/labelforge/main.py b/backend/labelforge/main.py index d72ceca..bb377e7 100644 --- a/backend/labelforge/main.py +++ b/backend/labelforge/main.py @@ -11,6 +11,7 @@ from labelforge.render.fonts import load_fonts from labelforge.routes import fonts, health, labels from labelforge.routes import print as print_router +from labelforge.routes import preview as preview_router @asynccontextmanager @@ -59,3 +60,4 @@ async def lifespan(app: FastAPI): # noqa: ARG001 app.include_router(labels.router, prefix="/api") app.include_router(fonts.router, prefix="/api") app.include_router(print_router.router, prefix="/api") +app.include_router(preview_router.router, prefix="/api") diff --git a/backend/labelforge/routes/preview.py b/backend/labelforge/routes/preview.py new file mode 100644 index 0000000..7286ad5 --- /dev/null +++ b/backend/labelforge/routes/preview.py @@ -0,0 +1,41 @@ +import io +import logging + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import Response + +from labelforge.catalog.loader import get_label +from labelforge.models import QuickPrintRequest +from labelforge.render.text import RenderError, render_text +from labelforge.routes.auth import require_auth + +logger = logging.getLogger(__name__) + +router = APIRouter(dependencies=[Depends(require_auth)]) + + +@router.post("/preview/quick") +async def preview_quick(request: QuickPrintRequest) -> Response: + if not request.text.strip(): + raise HTTPException(status_code=400, detail="Text is required") + + if get_label(request.label_media) is None: + raise HTTPException(status_code=400, detail=f"Unknown label media: {request.label_media}") + + try: + image = render_text( + text=request.text, + font_name=request.font, + font_size=request.font_size, + alignment=request.alignment, + orientation=request.orientation, + bold=request.bold, + italic=request.italic, + label_media=request.label_media, + ) + except RenderError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + buf = io.BytesIO() + image.save(buf, format="PNG") + return Response(content=buf.getvalue(), media_type="image/png") From 12737ef7b393f254b828d167119dce69cacc7c2c Mon Sep 17 00:00:00 2001 From: crzykidd Date: Tue, 19 May 2026 22:55:18 -0700 Subject: [PATCH 08/84] Add settings store and GET/PUT settings endpoints --- backend/labelforge/main.py | 2 + backend/labelforge/routes/print.py | 6 ++ backend/labelforge/routes/settings.py | 27 ++++++++ backend/labelforge/settings_store.py | 97 +++++++++++++++++++++++++++ 4 files changed, 132 insertions(+) create mode 100644 backend/labelforge/routes/settings.py create mode 100644 backend/labelforge/settings_store.py diff --git a/backend/labelforge/main.py b/backend/labelforge/main.py index bb377e7..8701467 100644 --- a/backend/labelforge/main.py +++ b/backend/labelforge/main.py @@ -12,6 +12,7 @@ from labelforge.routes import fonts, health, labels from labelforge.routes import print as print_router from labelforge.routes import preview as preview_router +from labelforge.routes import settings as settings_router @asynccontextmanager @@ -61,3 +62,4 @@ async def lifespan(app: FastAPI): # noqa: ARG001 app.include_router(fonts.router, prefix="/api") app.include_router(print_router.router, prefix="/api") app.include_router(preview_router.router, prefix="/api") +app.include_router(settings_router.router, prefix="/api") diff --git a/backend/labelforge/routes/print.py b/backend/labelforge/routes/print.py index e8ca2e7..5430582 100644 --- a/backend/labelforge/routes/print.py +++ b/backend/labelforge/routes/print.py @@ -5,6 +5,7 @@ from labelforge.catalog.loader import get_label from labelforge.config import settings from labelforge.db import get_connection +from labelforge import settings_store from labelforge.models import PrintJobResponse, QuickPrintRequest from labelforge.printer.client import PrintError, print_image from labelforge.render.text import RenderError, render_text @@ -60,4 +61,9 @@ async def quick_print(request: QuickPrintRequest) -> PrintJobResponse: finally: conn.close() + try: + settings_store.set("last_quick_print", request.model_dump()) + except Exception: + logger.warning("Failed to record last_quick_print", exc_info=True) + return PrintJobResponse(job_id=job_id, status=outcome) diff --git a/backend/labelforge/routes/settings.py b/backend/labelforge/routes/settings.py new file mode 100644 index 0000000..d78f1e3 --- /dev/null +++ b/backend/labelforge/routes/settings.py @@ -0,0 +1,27 @@ +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException + +from labelforge import settings_store +from labelforge.routes.auth import require_auth + +router = APIRouter(dependencies=[Depends(require_auth)]) + + +@router.get("/settings") +async def get_settings() -> dict[str, Any]: + return settings_store.get_all() + + +@router.put("/settings") +async def update_settings(body: dict[str, Any]) -> dict[str, Any]: + # Validate all keys first (all-or-nothing) + for key, value in body.items(): + try: + settings_store.validate(key, value) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + # Write all + for key, value in body.items(): + settings_store.set(key, value) + return settings_store.get_all() diff --git a/backend/labelforge/settings_store.py b/backend/labelforge/settings_store.py new file mode 100644 index 0000000..0e82a6b --- /dev/null +++ b/backend/labelforge/settings_store.py @@ -0,0 +1,97 @@ +import json +import logging +from typing import Any + +from labelforge.config import settings as app_settings +from labelforge.db import get_connection + +logger = logging.getLogger(__name__) + +_REGISTRY: dict[str, dict] = { + "retention_mode": { + "default": "forever", + "vtype": str, + "enum": frozenset({"forever", "last_n", "last_days"}), + }, + "retention_count": {"default": 500, "vtype": int}, + "retention_days": {"default": 90, "vtype": int}, + # default falls back to config.settings.default_label_media, not a hardcoded literal + "default_label_media": {"default": None, "vtype": str, "nullable": True}, + "default_font": {"default": "DejaVuSans", "vtype": str}, + "default_font_size": {"default": 48, "vtype": int}, + "default_orientation": { + "default": "standard", + "vtype": str, + "enum": frozenset({"standard", "rotated"}), + }, + "printer_status_check": {"default": True, "vtype": bool}, + "printer_status_timeout_ms": {"default": 2000, "vtype": int}, + "last_quick_print": {"default": None, "vtype": dict, "nullable": True}, +} + + +def _db_path(): + return app_settings.data_dir / "data" / "app.db" + + +def _default(key: str) -> Any: + if key == "default_label_media": + return app_settings.default_label_media + return _REGISTRY[key]["default"] + + +def validate(key: str, value: Any) -> None: + """Raise ValueError if key is unknown or value fails type/enum check.""" + if key not in _REGISTRY: + raise ValueError(f"Unknown setting: {key}") + entry = _REGISTRY[key] + if value is None: + if not entry.get("nullable", False): + raise ValueError(f"Setting '{key}' cannot be null") + return + vtype = entry["vtype"] + if vtype is int: + # bool is a subclass of int — reject booleans for int fields + if not (isinstance(value, int) and not isinstance(value, bool)): + raise ValueError(f"Setting '{key}' expects int, got {type(value).__name__}") + elif not isinstance(value, vtype): + raise ValueError(f"Setting '{key}' expects {vtype.__name__}, got {type(value).__name__}") + if "enum" in entry and value not in entry["enum"]: + raise ValueError( + f"Setting '{key}' must be one of {sorted(entry['enum'])}, got {value!r}" + ) + + +def get_all() -> dict[str, Any]: + conn = get_connection(_db_path()) + try: + rows = conn.execute("SELECT key, value FROM settings").fetchall() + db_vals = {row["key"]: json.loads(row["value"]) for row in rows} + finally: + conn.close() + return {key: db_vals.get(key, _default(key)) for key in _REGISTRY} + + +def get(key: str) -> Any: + if key not in _REGISTRY: + raise ValueError(f"Unknown setting: {key}") + conn = get_connection(_db_path()) + try: + row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() + return json.loads(row["value"]) if row else _default(key) + finally: + conn.close() + + +def set(key: str, value: Any) -> None: # noqa: A001 + validate(key, value) + conn = get_connection(_db_path()) + try: + conn.execute( + "INSERT INTO settings (key, value) VALUES (?, ?)" + " ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (key, json.dumps(value)), + ) + conn.commit() + finally: + conn.close() From 51a6b08040a4bd2808b355f270af3378683e3bf8 Mon Sep 17 00:00:00 2001 From: crzykidd Date: Tue, 19 May 2026 22:56:49 -0700 Subject: [PATCH 09/84] Wire quick-print frontend to preview and settings endpoints --- frontend/src/api.ts | 31 ++++++ frontend/src/pages/quick-print.ts | 156 +++++++++++++++++------------- frontend/src/style.css | 11 +++ 3 files changed, 130 insertions(+), 68 deletions(-) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index a37a70d..e3da719 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -40,3 +40,34 @@ export function quickPrint(req: QuickPrintRequest): Promise { body: JSON.stringify(req), }) } + +export async function previewQuick(req: QuickPrintRequest): Promise { + const res = await fetch('/api/preview/quick', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${getToken()}`, + }, + body: JSON.stringify(req), + }) + if (!res.ok) { + let detail = `HTTP ${res.status}` + try { + const body = await res.json() as { detail?: unknown } + if (body.detail) detail = String(body.detail) + } catch { /* use status fallback */ } + throw new Error(detail) + } + return res.blob() +} + +export function getSettings(): Promise> { + return apiFetch>('/api/settings') +} + +export function putSettings(partial: Record): Promise> { + return apiFetch>('/api/settings', { + method: 'PUT', + body: JSON.stringify(partial), + }) +} diff --git a/frontend/src/pages/quick-print.ts b/frontend/src/pages/quick-print.ts index 0e27d81..836e6a0 100644 --- a/frontend/src/pages/quick-print.ts +++ b/frontend/src/pages/quick-print.ts @@ -1,15 +1,6 @@ -import { getFonts, getLabels, quickPrint, TOKEN_KEY } from '../api' +import { getFonts, getLabels, getSettings, previewQuick, quickPrint, TOKEN_KEY } from '../api' import type { LabelEntry, QuickPrintRequest } from '../types' -// localStorage is the v1 stand-in for saved settings; replace when GET/PUT /api/settings exists -const PREF = { - font: 'labelforge_font', - font_size: 'labelforge_font_size', - label_media: 'labelforge_label_media', - alignment: 'labelforge_alignment', - orientation: 'labelforge_orientation', -} - const FORM_FACTOR_LABEL: Record = { 1: 'Die-cut', 2: 'Continuous', @@ -17,7 +8,6 @@ const FORM_FACTOR_LABEL: Record = { 4: 'P-touch Continuous', } -// Render order for form-factor groups const FF_ORDER = [2, 1, 3, 4] function esc(s: string): string { @@ -111,10 +101,13 @@ function renderForm(root: HTMLElement): void {
- +
+ ` @@ -125,8 +118,12 @@ function renderForm(root: HTMLElement): void { const labelSelect = root.querySelector('#label-media')! const boldCheck = root.querySelector('#bold')! const italicCheck = root.querySelector('#italic')! + const btnPreview = root.querySelector('#btn-preview')! const btnPrint = root.querySelector('#btn-print')! const statusMsg = root.querySelector('#status-msg')! + const previewArea = root.querySelector('#preview-area')! + const previewImg = root.querySelector('#preview-img')! + let previewObjectUrl: string | null = null function showStatus(msg: string, kind: 'success' | 'error'): void { statusMsg.textContent = msg @@ -138,21 +135,64 @@ function renderForm(root: HTMLElement): void { statusMsg.hidden = true } - function updatePrintButton(): void { - btnPrint.disabled = textarea.value.trim() === '' + function updateButtons(): void { + const empty = textarea.value.trim() === '' + btnPrint.disabled = empty + btnPreview.disabled = empty + } + + function buildRequest(): QuickPrintRequest { + const alignment = ( + form.querySelector('input[name="alignment"]:checked')?.value ?? 'left' + ) as QuickPrintRequest['alignment'] + const orientation = ( + form.querySelector('input[name="orientation"]:checked')?.value ?? 'standard' + ) as QuickPrintRequest['orientation'] + return { + text: textarea.value, + font: fontSelect.value, + font_size: parseInt(fontSizeInput.value, 10), + alignment, + orientation, + label_media: labelSelect.value, + bold: boldCheck.checked, + italic: italicCheck.checked, + } } - textarea.addEventListener('input', updatePrintButton) + textarea.addEventListener('input', updateButtons) + + btnPreview.addEventListener('click', async () => { + btnPreview.disabled = true + hideStatus() + try { + const blob = await previewQuick(buildRequest()) + if (previewObjectUrl) URL.revokeObjectURL(previewObjectUrl) + previewObjectUrl = URL.createObjectURL(blob) + previewImg.src = previewObjectUrl + previewArea.hidden = false + } catch (err) { + showStatus((err as Error).message, 'error') + } finally { + updateButtons() + } + }) - Promise.all([getFonts(), getLabels()]).then(([fonts, labels]) => { - // Populate fonts + // Load fonts, labels, and settings in parallel; settings failure is non-fatal + Promise.all([ + getFonts(), + getLabels(), + getSettings().catch((err: Error) => { + console.warn('Failed to load settings:', err.message) + return null as Record | null + }), + ]).then(([fonts, labels, sett]) => { + // Populate fonts dropdown fontSelect.innerHTML = fonts .map(f => ``) .join('') - const savedFont = localStorage.getItem(PREF.font) - if (savedFont) fontSelect.value = savedFont - // Group labels by form_factor, sort within groups by display_name + // Group labels by form_factor const groups = new Map() for (const label of labels) { const ff = label.form_factor @@ -162,8 +202,6 @@ function renderForm(root: HTMLElement): void { for (const entries of groups.values()) { entries.sort((a, b) => a.display_name.localeCompare(b.display_name)) } - - // Render known order first, then any remaining form_factor values const allKeys = [...new Set([...FF_ORDER, ...groups.keys()])] labelSelect.innerHTML = allKeys .filter(k => groups.has(k)) @@ -177,27 +215,34 @@ function renderForm(root: HTMLElement): void { }) .join('') - const savedLabel = localStorage.getItem(PREF.label_media) - if (savedLabel) labelSelect.value = savedLabel - - // Restore remaining prefs - const savedSize = localStorage.getItem(PREF.font_size) - if (savedSize) fontSizeInput.value = savedSize - - const savedAlignment = localStorage.getItem(PREF.alignment) - if (savedAlignment) { - const radio = form.querySelector( - `input[name="alignment"][value="${savedAlignment}"]` + // Restore form from settings; last_quick_print takes precedence over per-key defaults + const lqp = (sett?.last_quick_print ?? null) as QuickPrintRequest | null + if (lqp) { + fontSelect.value = lqp.font ?? String(sett?.default_font ?? 'DejaVuSans') + fontSizeInput.value = String(lqp.font_size ?? sett?.default_font_size ?? 48) + labelSelect.value = String(lqp.label_media ?? sett?.default_label_media ?? '62') + boldCheck.checked = lqp.bold ?? false + italicCheck.checked = lqp.italic ?? false + const aRadio = form.querySelector( + `input[name="alignment"][value="${lqp.alignment ?? 'left'}"]` ) - if (radio) radio.checked = true - } - - const savedOrientation = localStorage.getItem(PREF.orientation) - if (savedOrientation) { - const radio = form.querySelector( - `input[name="orientation"][value="${savedOrientation}"]` + if (aRadio) aRadio.checked = true + const oRadio = form.querySelector( + `input[name="orientation"][value="${lqp.orientation ?? 'standard'}"]` + ) + if (oRadio) oRadio.checked = true + } else { + const defFont = String(sett?.default_font ?? 'DejaVuSans') + const defSize = String(sett?.default_font_size ?? 48) + const defMedia = String(sett?.default_label_media ?? '62') + const defOrientation = String(sett?.default_orientation ?? 'standard') + fontSelect.value = defFont + fontSizeInput.value = defSize + labelSelect.value = defMedia + const oRadio = form.querySelector( + `input[name="orientation"][value="${defOrientation}"]` ) - if (radio) radio.checked = true + if (oRadio) oRadio.checked = true } }).catch((err: Error) => { showStatus(`Failed to load form data: ${err.message}`, 'error') @@ -208,33 +253,8 @@ function renderForm(root: HTMLElement): void { btnPrint.disabled = true hideStatus() - const alignment = ( - form.querySelector('input[name="alignment"]:checked')?.value ?? 'left' - ) as QuickPrintRequest['alignment'] - - const orientation = ( - form.querySelector('input[name="orientation"]:checked')?.value ?? 'standard' - ) as QuickPrintRequest['orientation'] - - const req: QuickPrintRequest = { - text: textarea.value, - font: fontSelect.value, - font_size: parseInt(fontSizeInput.value, 10), - alignment, - orientation, - label_media: labelSelect.value, - bold: boldCheck.checked, - italic: italicCheck.checked, - } - - localStorage.setItem(PREF.font, req.font) - localStorage.setItem(PREF.font_size, String(req.font_size)) - localStorage.setItem(PREF.label_media, req.label_media) - localStorage.setItem(PREF.alignment, req.alignment) - localStorage.setItem(PREF.orientation, req.orientation) - try { - const result = await quickPrint(req) + const result = await quickPrint(buildRequest()) // "sent" = transmitted to printer network backend, not confirmed printed showStatus( `Sent — job #${result.job_id} (status: ${result.status}). "Sent" means the job was transmitted to the printer; delivery is not confirmed.`, @@ -243,7 +263,7 @@ function renderForm(root: HTMLElement): void { } catch (err) { showStatus((err as Error).message, 'error') } finally { - updatePrintButton() + updateButtons() } }) } diff --git a/frontend/src/style.css b/frontend/src/style.css index 1668a0c..9d8b79c 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -119,6 +119,17 @@ button:disabled { color: #7a1a14; } +.preview-area { + margin-top: 1rem; + text-align: center; +} + +.preview-area img { + max-width: 100%; + border: 1px solid #ddd; + border-radius: 4px; +} + /* token gate */ .token-gate input[type="password"] { width: 100%; From 57d936fad28a1d2906b4cc4ab6e39cdb460daef7 Mon Sep 17 00:00:00 2001 From: crzykidd Date: Tue, 19 May 2026 23:37:21 -0700 Subject: [PATCH 10/84] Add templates table, models, and store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deps: qrcode[pil]>=8.0 and python-barcode>=0.15 added to pyproject.toml — first new runtime deps since initial skeleton; required by the template renderer (Phase 3) for QR and barcode element generation. Schema: CREATE TABLE IF NOT EXISTS templates (id, name unique, display_name, label_media, canvas_json, field_schema, created_at, updated_at, deleted_at). Idempotent migration adds field_values TEXT and batch_id TEXT to print_jobs via PRAGMA table_info check + ALTER TABLE, since SQLite has no ADD COLUMN IF NOT EXISTS. Models: FieldSpec, Template, TemplateCreate, TemplateUpdate, PrintRequest, BatchPrintRequest, BatchJobResult, BatchPrintResponse added to labelforge.models. Store: labelforge.templates.store — list_templates, get_template, create_template, update_template, soft_delete, duplicate. Name validation enforces ^[a-z0-9][a-z0-9-]*$ slug; duplicate names raise ValueError (→ 409 in routes); missing templates return None (→ 404). --- backend/labelforge/db.py | 38 ++++- backend/labelforge/models/__init__.py | 58 +++++++ backend/labelforge/templates/__init__.py | 0 backend/labelforge/templates/store.py | 187 +++++++++++++++++++++++ pyproject.toml | 2 + 5 files changed, 277 insertions(+), 8 deletions(-) create mode 100644 backend/labelforge/templates/__init__.py create mode 100644 backend/labelforge/templates/store.py diff --git a/backend/labelforge/db.py b/backend/labelforge/db.py index a8ef5a4..d80b377 100644 --- a/backend/labelforge/db.py +++ b/backend/labelforge/db.py @@ -3,19 +3,31 @@ _SCHEMA = """ CREATE TABLE IF NOT EXISTS print_jobs ( - id INTEGER PRIMARY KEY, - template_id TEXT NULL, - payload_json TEXT NOT NULL, - label_media TEXT NOT NULL, - preview_path TEXT NULL, - pinned INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY, + template_id TEXT NULL, + payload_json TEXT NOT NULL, + label_media TEXT NOT NULL, + preview_path TEXT NULL, + pinned INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); + +CREATE TABLE IF NOT EXISTS templates ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + display_name TEXT NOT NULL, + label_media TEXT NOT NULL, + canvas_json TEXT NOT NULL, + field_schema TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT NULL +); """ @@ -26,9 +38,19 @@ def get_connection(db_path: Path) -> sqlite3.Connection: return conn +def _migrate_print_jobs(conn: sqlite3.Connection) -> None: + """Idempotently add columns to print_jobs that post-date the initial schema.""" + existing = {row["name"] for row in conn.execute("PRAGMA table_info(print_jobs)")} + if "field_values" not in existing: + conn.execute("ALTER TABLE print_jobs ADD COLUMN field_values TEXT NULL") + if "batch_id" not in existing: + conn.execute("ALTER TABLE print_jobs ADD COLUMN batch_id TEXT NULL") + conn.commit() + + def init_db(db_path: Path) -> None: db_path.parent.mkdir(parents=True, exist_ok=True) conn = get_connection(db_path) conn.executescript(_SCHEMA) - conn.commit() + _migrate_print_jobs(conn) conn.close() diff --git a/backend/labelforge/models/__init__.py b/backend/labelforge/models/__init__.py index 9373089..d197ce7 100644 --- a/backend/labelforge/models/__init__.py +++ b/backend/labelforge/models/__init__.py @@ -44,3 +44,61 @@ class PrintJobResponse(BaseModel): job_id: int status: str preview_url: str | None = None + + +# ── Templates ──────────────────────────────────────────────────────────────── + +class FieldSpec(BaseModel): + name: str + type: Literal["text", "number", "date", "enum"] = "text" + required: bool = True + default: str | None = None + increment: bool = False + enum_values: list[str] = [] + + +class Template(BaseModel): + name: str + display_name: str + label_media: str + canvas_json: dict + field_schema: list[FieldSpec] + created_at: str + updated_at: str + + +class TemplateCreate(BaseModel): + name: str + display_name: str | None = None + label_media: str + canvas_json: dict + field_schema: list[FieldSpec] = [] + + +class TemplateUpdate(BaseModel): + display_name: str | None = None + label_media: str | None = None + canvas_json: dict | None = None + field_schema: list[FieldSpec] | None = None + + +# ── Print / batch ───────────────────────────────────────────────────────────── + +class PrintRequest(BaseModel): + fields: dict[str, str] = {} + + +class BatchPrintRequest(BaseModel): + labels: list[dict[str, str]] + + +class BatchJobResult(BaseModel): + job_id: int + status: str + + +class BatchPrintResponse(BaseModel): + batch_id: str + jobs: list[BatchJobResult] + succeeded: int + failed: int diff --git a/backend/labelforge/templates/__init__.py b/backend/labelforge/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/labelforge/templates/store.py b/backend/labelforge/templates/store.py new file mode 100644 index 0000000..6d81b97 --- /dev/null +++ b/backend/labelforge/templates/store.py @@ -0,0 +1,187 @@ +import json +import re +import sqlite3 +from datetime import datetime, timezone + +from labelforge.config import settings +from labelforge.db import get_connection +from labelforge.models import FieldSpec, Template, TemplateCreate, TemplateUpdate + +_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") + + +def _db_path(): + return settings.data_dir / "data" / "app.db" + + +def _now_utc() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _row_to_template(row: sqlite3.Row) -> Template: + return Template( + name=row["name"], + display_name=row["display_name"], + label_media=row["label_media"], + canvas_json=json.loads(row["canvas_json"]), + field_schema=[FieldSpec(**f) for f in json.loads(row["field_schema"])], + created_at=row["created_at"], + updated_at=row["updated_at"], + ) + + +def _validate_name(name: str) -> None: + if not _NAME_RE.match(name): + raise ValueError( + f"Template name '{name}' is invalid — use lowercase letters, digits, " + "and hyphens only; must start with a letter or digit." + ) + + +def list_templates() -> list[Template]: + conn = get_connection(_db_path()) + try: + rows = conn.execute( + "SELECT * FROM templates WHERE deleted_at IS NULL ORDER BY name" + ).fetchall() + return [_row_to_template(r) for r in rows] + finally: + conn.close() + + +def get_template(name: str, include_deleted: bool = False) -> Template | None: + conn = get_connection(_db_path()) + try: + if include_deleted: + row = conn.execute( + "SELECT * FROM templates WHERE lower(name) = lower(?)", (name,) + ).fetchone() + else: + row = conn.execute( + "SELECT * FROM templates WHERE lower(name) = lower(?) AND deleted_at IS NULL", + (name,), + ).fetchone() + return _row_to_template(row) if row else None + finally: + conn.close() + + +def create_template(data: TemplateCreate) -> Template: + _validate_name(data.name) + conn = get_connection(_db_path()) + try: + if conn.execute( + "SELECT 1 FROM templates WHERE lower(name) = lower(?)", (data.name,) + ).fetchone(): + raise ValueError(f"Template name '{data.name}' already exists.") + + display_name = data.display_name or data.name + now = _now_utc() + conn.execute( + """INSERT INTO templates + (name, display_name, label_media, canvas_json, field_schema, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + ( + data.name, + display_name, + data.label_media, + json.dumps(data.canvas_json), + json.dumps([f.model_dump() for f in data.field_schema]), + now, + now, + ), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM templates WHERE name = ?", (data.name,) + ).fetchone() + return _row_to_template(row) + finally: + conn.close() + + +def update_template(name: str, data: TemplateUpdate) -> Template | None: + conn = get_connection(_db_path()) + try: + if not conn.execute( + "SELECT 1 FROM templates WHERE lower(name) = lower(?) AND deleted_at IS NULL", + (name,), + ).fetchone(): + return None + + updates: dict[str, object] = {"updated_at": _now_utc()} + if data.display_name is not None: + updates["display_name"] = data.display_name + if data.label_media is not None: + updates["label_media"] = data.label_media + if data.canvas_json is not None: + updates["canvas_json"] = json.dumps(data.canvas_json) + if data.field_schema is not None: + updates["field_schema"] = json.dumps([f.model_dump() for f in data.field_schema]) + + set_clause = ", ".join(f"{k} = ?" for k in updates) + conn.execute( + f"UPDATE templates SET {set_clause} WHERE lower(name) = lower(?)", # noqa: S608 + (*updates.values(), name), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM templates WHERE lower(name) = lower(?)", (name,) + ).fetchone() + return _row_to_template(row) if row else None + finally: + conn.close() + + +def soft_delete(name: str) -> bool: + conn = get_connection(_db_path()) + try: + cursor = conn.execute( + "UPDATE templates SET deleted_at = ? " + "WHERE lower(name) = lower(?) AND deleted_at IS NULL", + (_now_utc(), name), + ) + conn.commit() + return cursor.rowcount > 0 + finally: + conn.close() + + +def duplicate(name: str, new_name: str, new_label_media: str) -> Template: + _validate_name(new_name) + conn = get_connection(_db_path()) + try: + orig = conn.execute( + "SELECT * FROM templates WHERE lower(name) = lower(?) AND deleted_at IS NULL", + (name,), + ).fetchone() + if not orig: + raise ValueError(f"Template '{name}' not found.") + + if conn.execute( + "SELECT 1 FROM templates WHERE lower(name) = lower(?)", (new_name,) + ).fetchone(): + raise ValueError(f"Template name '{new_name}' already exists.") + + now = _now_utc() + conn.execute( + """INSERT INTO templates + (name, display_name, label_media, canvas_json, field_schema, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + ( + new_name, + new_name, + new_label_media, + orig["canvas_json"], + orig["field_schema"], + now, + now, + ), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM templates WHERE name = ?", (new_name,) + ).fetchone() + return _row_to_template(row) + finally: + conn.close() diff --git a/pyproject.toml b/pyproject.toml index 017f07b..5ca22c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,8 @@ dependencies = [ "pydantic-settings>=2.6", "pyyaml>=6.0", "pillow>=10.4", + "qrcode[pil]>=8.0", + "python-barcode>=0.15", "brother-ql-inventree>=1.3", ] From 9d3b2ceedecf5dea72ef585ae1c3fc1fdff60a3d Mon Sep 17 00:00:00 2001 From: crzykidd Date: Tue, 19 May 2026 23:58:28 -0700 Subject: [PATCH 11/84] Add template field detection and schema merge --- backend/labelforge/templates/fields.py | 73 ++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 backend/labelforge/templates/fields.py diff --git a/backend/labelforge/templates/fields.py b/backend/labelforge/templates/fields.py new file mode 100644 index 0000000..ec73593 --- /dev/null +++ b/backend/labelforge/templates/fields.py @@ -0,0 +1,73 @@ +import re + +from labelforge.models import FieldSpec + +_PLACEHOLDER_RE = re.compile(r"\{([a-zA-Z0-9_]+)\}") + +# Trailing-digit pattern for advance(): splits "spool-047" into ("spool-", "047") +_TRAILING_DIGITS_RE = re.compile(r"^(.*?)(\d+)$") + + +def detect_fields(canvas_json: dict) -> list[str]: + """Return ordered unique field names found in canvas element content.""" + seen: dict[str, None] = {} # ordered set via dict + for obj in canvas_json.get("objects", []): + raw: str | None = None + t = obj.get("type", "") + if t in ("i-text", "text", "textbox"): + raw = obj.get("labelforge_raw_content") or obj.get("text", "") + elif obj.get("labelforge_qr_payload") is not None: + raw = obj["labelforge_qr_payload"] + elif obj.get("labelforge_barcode_payload") is not None: + raw = obj["labelforge_barcode_payload"] + if raw: + for name in _PLACEHOLDER_RE.findall(raw): + seen[name] = None + return list(seen) + + +def merge_schema(detected: list[str], existing: list[FieldSpec]) -> list[FieldSpec]: + """Merge detected field names with the stored schema. + + - Keeps existing specs for names still detected (preserves user edits). + - Adds newly-detected names with defaults (type=text, required=True). + - Drops specs whose name is no longer detected. + """ + existing_by_name = {f.name: f for f in existing} + result: list[FieldSpec] = [] + for name in detected: + if name in existing_by_name: + result.append(existing_by_name[name]) + else: + result.append(FieldSpec(name=name)) + return result + + +def resolve_content(raw: str, values: dict[str, str]) -> str: + """Substitute {name} placeholders with values. Raises ValueError for missing keys.""" + + def replacer(m: re.Match) -> str: + name = m.group(1) + if name not in values: + raise ValueError(f"Missing required field: '{name}'") + return values[name] + + return _PLACEHOLDER_RE.sub(replacer, raw) + + +def advance(value: str) -> str: + """Increment the trailing numeric portion of *value* by 1. + + Pure number: "47" → "48" + Zero-padded: "047" → "048" (width preserved; grows on overflow: "099" → "100") + Suffix-numeric: "spool-047" → "spool-048" + Non-numeric: returned unchanged. + """ + m = _TRAILING_DIGITS_RE.match(value) + if not m: + return value + prefix, digits = m.group(1), m.group(2) + next_num = int(digits) + 1 + # Preserve zero-padding width; allow growth on overflow (e.g. 099 → 100) + next_str = str(next_num).zfill(len(digits)) if digits.startswith("0") else str(next_num) + return prefix + next_str From 14d2f1ff37b60203e82d5684c71384e8247eeabd Mon Sep 17 00:00:00 2001 From: crzykidd Date: Wed, 20 May 2026 17:31:38 -0700 Subject: [PATCH 12/84] Add server-side template renderer --- backend/labelforge/render/template.py | 232 ++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 backend/labelforge/render/template.py diff --git a/backend/labelforge/render/template.py b/backend/labelforge/render/template.py new file mode 100644 index 0000000..8965aa5 --- /dev/null +++ b/backend/labelforge/render/template.py @@ -0,0 +1,232 @@ +import io +import logging + +import barcode as _barcode_lib +import qrcode +from barcode.writer import ImageWriter +from PIL import Image, ImageDraw, ImageFont +from qrcode.constants import ERROR_CORRECT_H, ERROR_CORRECT_L, ERROR_CORRECT_M, ERROR_CORRECT_Q + +from labelforge.catalog.loader import get_label +from labelforge.models import Template +from labelforge.render.fonts import get_font_path +from labelforge.render.text import RenderError +from labelforge.templates.fields import resolve_content + +logger = logging.getLogger(__name__) + +_PADDING = 20 +_CONTINUOUS_FORM_FACTORS = {2, 4} + +_QR_CORRECTION = { + "L": ERROR_CORRECT_L, + "M": ERROR_CORRECT_M, + "Q": ERROR_CORRECT_Q, + "H": ERROR_CORRECT_H, +} + + +def _canvas_color_to_l(color: str | None) -> int | None: + """Map a CSS color string to mode-L pixel value; None means no fill.""" + if not color or color in ("transparent", "rgba(0,0,0,0)", "none"): + return None + lc = color.lower().strip() + if lc in ("#fff", "#ffffff", "white", "rgb(255,255,255)"): + return 255 + return 0 + + +def _resolve_font_path(family: str, weight: str | None, style: str | None) -> str | None: + """Return the best matching font file path, falling back to base family on miss.""" + bold = bool(weight and str(weight).lower() in ("bold", "700", "800", "900")) + italic = bool(style and str(style).lower() in ("italic", "oblique")) + + # Normalise family name into candidate stems (CSS name, no-space, hyphen-joined). + bases: list[str] = list( + dict.fromkeys([family, family.replace(" ", ""), family.replace(" ", "-")]) + ) + candidates: list[str] = [] + for base in bases: + if bold and italic: + candidates += [f"{base}-BoldItalic", f"{base}BoldItalic"] + if bold: + candidates += [f"{base}-Bold", f"{base}Bold"] + if italic: + candidates += [f"{base}-Italic", f"{base}Italic", f"{base}-Oblique"] + candidates.append(base) + + seen: set[str] = set() + for name in candidates: + if name in seen: + continue + seen.add(name) + path = get_font_path(name) + if path: + return path + return None + + +def _paste_onto( + canvas: Image.Image, sub: Image.Image, left: int, top: int, angle: float +) -> None: + """Paste sub-image onto canvas; invert-mask whites out, rotate around element centre.""" + if abs(angle) > 0.01: + # Preserve centre point across expand-rotation. + cx = left + sub.width // 2 + cy = top + sub.height // 2 + sub = sub.rotate(-angle, expand=True, resample=Image.BICUBIC, fillcolor=255) + left = cx - sub.width // 2 + top = cy - sub.height // 2 + # Dark pixels (value≈0) → mask 255 (opaque); white (255) → mask 0 (skip). + # Preserves antialiasing in intermediate greys. + mask = sub.point(lambda p: 255 - p) + canvas.paste(sub, (left, top), mask=mask) + + +def _render_text_element( + obj: dict, values: dict[str, str], box_w: int, box_h: int +) -> Image.Image: + raw = obj.get("labelforge_raw_content") or obj.get("text", "") + text = resolve_content(raw, values) + + family = obj.get("fontFamily", "") + font_path = _resolve_font_path(family, obj.get("fontWeight"), obj.get("fontStyle")) + if not font_path: + raise RenderError(f"Font not available: {family!r}") + + font_size = max(6, int(obj.get("fontSize", 20))) + try: + pil_font = ImageFont.truetype(font_path, font_size) + except Exception as exc: + raise RenderError(f"Could not load font '{family}': {exc}") from exc + + align = obj.get("textAlign", "left") + if align not in ("left", "center", "right"): + align = "left" + + sub = Image.new("L", (max(box_w, 1), max(box_h, 1)), 255) + draw = ImageDraw.Draw(sub) + draw.multiline_text((0, 0), text, font=pil_font, fill=0, align=align) + return sub + + +def _render_qr_element( + payload: str, correction: str, box_w: int, box_h: int +) -> Image.Image: + if not payload: + raise RenderError("QR payload is empty after field substitution") + ec = _QR_CORRECTION.get((correction or "M").upper(), ERROR_CORRECT_M) + qr = qrcode.QRCode(error_correction=ec, border=1) + qr.add_data(payload) + qr.make(fit=True) + buf = io.BytesIO() + qr.make_image(fill_color=0, back_color=255).save(buf, "PNG") + buf.seek(0) + img = Image.open(buf) + img.load() + return img.convert("L").resize((max(box_w, 1), max(box_h, 1)), Image.NEAREST) + + +def _render_barcode_element( + payload: str, symbology: str, box_w: int, box_h: int +) -> Image.Image: + if not payload: + raise RenderError("Barcode payload is empty after field substitution") + symb = (symbology or "code128").lower().replace("-", "").replace("_", "") + try: + bc_class = _barcode_lib.get_barcode_class(symb) + except Exception: + bc_class = _barcode_lib.get_barcode_class("code128") + try: + bc = bc_class(payload, writer=ImageWriter()) + except Exception as exc: + raise RenderError(f"Invalid barcode payload for {symbology!r}: {exc}") from exc + buf = io.BytesIO() + bc.write(buf, options={"write_text": False}) + buf.seek(0) + img = Image.open(buf) + img.load() + return img.convert("L").resize((max(box_w, 1), max(box_h, 1)), Image.LANCZOS) + + +def render_template(template: Template, values: dict[str, str]) -> Image.Image: + """Rasterize *template* with *values* substituted for placeholders. + + Returns a mode='L' PIL Image (0=black, 255=white) sized for the print head. + Two-color (62red) rendering is a later slice; always renders mono. + """ + label = get_label(template.label_media) + if label is None: + raise RenderError(f"Unknown label media: {template.label_media!r}") + + canvas_w = label.dots_printable[0] + objects = template.canvas_json.get("objects", []) + is_continuous = label.form_factor in _CONTINUOUS_FORM_FACTORS + + if is_continuous: + bottommost = 0 + for obj in objects: + t = int(obj.get("top", 0)) + h = int(obj.get("height", 0) * float(obj.get("scaleY", 1.0))) + bottommost = max(bottommost, t + h) + canvas_h = max(bottommost + _PADDING, 1) + else: + canvas_h = label.dots_printable[1] + + canvas = Image.new("L", (canvas_w, canvas_h), 255) + draw = ImageDraw.Draw(canvas) + + for obj in objects: + obj_type = obj.get("type", "") + left = int(obj.get("left", 0)) + top = int(obj.get("top", 0)) + angle = float(obj.get("angle", 0)) + box_w = max(1, int(obj.get("width", 10) * float(obj.get("scaleX", 1.0)))) + box_h = max(1, int(obj.get("height", 10) * float(obj.get("scaleY", 1.0)))) + + try: + if obj_type in ("i-text", "text", "textbox"): + sub = _render_text_element(obj, values, box_w, box_h) + _paste_onto(canvas, sub, left, top, angle) + + elif obj_type == "image": + if obj.get("labelforge_qr_payload") is not None: + payload = resolve_content(obj["labelforge_qr_payload"], values) + sub = _render_qr_element( + payload, obj.get("labelforge_qr_error_correction", "M"), box_w, box_h + ) + _paste_onto(canvas, sub, left, top, angle) + elif obj.get("labelforge_barcode_payload") is not None: + payload = resolve_content(obj["labelforge_barcode_payload"], values) + sub = _render_barcode_element( + payload, obj.get("labelforge_barcode_symbology", "code128"), box_w, box_h + ) + _paste_onto(canvas, sub, left, top, angle) + else: + raise RenderError("Image elements not yet supported") + + elif obj_type == "line": + # x1,y1,x2,y2 are offsets from the element's left,top origin. + x1 = left + int(obj.get("x1", 0)) + y1 = top + int(obj.get("y1", 0)) + x2 = left + int(obj.get("x2", box_w)) + y2 = top + int(obj.get("y2", box_h)) + draw.line([(x1, y1), (x2, y2)], fill=0, width=max(1, int(obj.get("strokeWidth", 1)))) + + elif obj_type in ("rect", "Rect"): + fill_v = _canvas_color_to_l(obj.get("fill")) + sw = max(1, int(obj.get("strokeWidth", 1))) + sub = Image.new("L", (box_w, box_h), 255) + sub_draw = ImageDraw.Draw(sub) + sub_draw.rectangle([0, 0, box_w - 1, box_h - 1], fill=fill_v, outline=0, width=sw) + _paste_onto(canvas, sub, left, top, angle) + + else: + logger.debug("Skipping unhandled element type %r", obj_type) + + except RenderError: + raise + except Exception as exc: + raise RenderError(f"Failed to render element '{obj_type}': {exc}") from exc + + return canvas From ea30f0bdd6ff1c89043e308d219cdd8302a840f2 Mon Sep 17 00:00:00 2001 From: crzykidd Date: Wed, 20 May 2026 17:44:50 -0700 Subject: [PATCH 13/84] Add template CRUD endpoints --- backend/labelforge/main.py | 2 + backend/labelforge/routes/templates.py | 94 ++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 backend/labelforge/routes/templates.py diff --git a/backend/labelforge/main.py b/backend/labelforge/main.py index 8701467..043cb85 100644 --- a/backend/labelforge/main.py +++ b/backend/labelforge/main.py @@ -13,6 +13,7 @@ from labelforge.routes import print as print_router from labelforge.routes import preview as preview_router from labelforge.routes import settings as settings_router +from labelforge.routes import templates as templates_router @asynccontextmanager @@ -63,3 +64,4 @@ async def lifespan(app: FastAPI): # noqa: ARG001 app.include_router(print_router.router, prefix="/api") app.include_router(preview_router.router, prefix="/api") app.include_router(settings_router.router, prefix="/api") +app.include_router(templates_router.router, prefix="/api") diff --git a/backend/labelforge/routes/templates.py b/backend/labelforge/routes/templates.py new file mode 100644 index 0000000..acffa32 --- /dev/null +++ b/backend/labelforge/routes/templates.py @@ -0,0 +1,94 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from labelforge.catalog.loader import get_label +from labelforge.models import Template, TemplateCreate, TemplateUpdate +from labelforge.routes.auth import require_auth +from labelforge.templates import store +from labelforge.templates.fields import detect_fields, merge_schema + +logger = logging.getLogger(__name__) + +router = APIRouter(dependencies=[Depends(require_auth)]) + + +def _load_or_404(name: str) -> Template: + tmpl = store.get_template(name) + if tmpl is None: + raise HTTPException(status_code=404, detail=f"Template '{name}' not found") + return tmpl + + +@router.get("/templates", response_model=list[Template]) +async def list_templates() -> list[Template]: + return store.list_templates() + + +@router.get("/templates/{name}", response_model=Template) +async def get_template(name: str) -> Template: + return _load_or_404(name) + + +@router.post("/templates", response_model=Template, status_code=201) +async def create_template(data: TemplateCreate) -> Template: + if get_label(data.label_media) is None: + raise HTTPException(status_code=400, detail=f"Unknown label media: {data.label_media!r}") + + objects = data.canvas_json.get("objects", []) + if not objects: + raise HTTPException(status_code=400, detail="Template has no elements") + + detected = detect_fields(data.canvas_json) + data.field_schema = merge_schema(detected, data.field_schema) + + try: + return store.create_template(data) + except ValueError as exc: + msg = str(exc) + status = 409 if "already exists" in msg else 400 + raise HTTPException(status_code=status, detail=msg) from exc + + +@router.put("/templates/{name}", response_model=Template) +async def update_template(name: str, data: TemplateUpdate) -> Template: + existing = _load_or_404(name) + + if data.label_media is not None and get_label(data.label_media) is None: + raise HTTPException(status_code=400, detail=f"Unknown label media: {data.label_media!r}") + + if data.canvas_json is not None: + detected = detect_fields(data.canvas_json) + data.field_schema = merge_schema(detected, existing.field_schema) + + result = store.update_template(name, data) + if result is None: + raise HTTPException(status_code=404, detail=f"Template '{name}' not found") + return result + + +@router.delete("/templates/{name}", status_code=204) +async def delete_template(name: str) -> None: + if not store.soft_delete(name): + raise HTTPException(status_code=404, detail=f"Template '{name}' not found") + + +class _DuplicateRequest(BaseModel): + name: str + label_media: str + + +@router.post("/templates/{name}/duplicate", response_model=Template, status_code=201) +async def duplicate_template(name: str, body: _DuplicateRequest) -> Template: + _load_or_404(name) # 404 if source doesn't exist / is deleted + + if get_label(body.label_media) is None: + raise HTTPException(status_code=400, detail=f"Unknown label media: {body.label_media!r}") + + try: + return store.duplicate(name, body.name, body.label_media) + except ValueError as exc: + msg = str(exc) + status = 409 if "already exists" in msg else 400 + raise HTTPException(status_code=status, detail=msg) from exc From 75e680681e264be1cfd038a6359769fba8ebf46a Mon Sep 17 00:00:00 2001 From: crzykidd Date: Wed, 20 May 2026 17:47:40 -0700 Subject: [PATCH 14/84] Add template print, preview, and batch endpoints --- backend/labelforge/main.py | 2 + backend/labelforge/routes/template_print.py | 183 ++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 backend/labelforge/routes/template_print.py diff --git a/backend/labelforge/main.py b/backend/labelforge/main.py index 043cb85..3db3042 100644 --- a/backend/labelforge/main.py +++ b/backend/labelforge/main.py @@ -13,6 +13,7 @@ from labelforge.routes import print as print_router from labelforge.routes import preview as preview_router from labelforge.routes import settings as settings_router +from labelforge.routes import template_print as template_print_router from labelforge.routes import templates as templates_router @@ -65,3 +66,4 @@ async def lifespan(app: FastAPI): # noqa: ARG001 app.include_router(preview_router.router, prefix="/api") app.include_router(settings_router.router, prefix="/api") app.include_router(templates_router.router, prefix="/api") +app.include_router(template_print_router.router, prefix="/api") diff --git a/backend/labelforge/routes/template_print.py b/backend/labelforge/routes/template_print.py new file mode 100644 index 0000000..1619e36 --- /dev/null +++ b/backend/labelforge/routes/template_print.py @@ -0,0 +1,183 @@ +import io +import json +import logging +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import Response + +from labelforge.config import settings +from labelforge.db import get_connection +from labelforge.models import ( + BatchJobResult, + BatchPrintRequest, + BatchPrintResponse, + PrintRequest, +) +from labelforge.printer.client import PrintError, print_image +from labelforge.render.template import render_template +from labelforge.render.text import RenderError +from labelforge.routes.auth import require_auth +from labelforge.templates import store + +logger = logging.getLogger(__name__) + +router = APIRouter(dependencies=[Depends(require_auth)]) + + +def _db_path(): + return settings.data_dir / "data" / "app.db" + + +def _apply_defaults(template_fields, values: dict[str, str]) -> dict[str, str]: + """Return values dict with defaults filled in; raise HTTPException for missing required.""" + result = dict(values) + for field in template_fields: + if field.name not in result: + if field.default is not None: + result[field.name] = field.default + elif field.required: + raise HTTPException( + status_code=400, + detail=f"Missing required field: '{field.name}'", + ) + return result + + +def _insert_job( + template_name: str, + label_media: str, + field_values: dict, + request_json: str, + batch_id: str | None = None, +) -> int: + conn = get_connection(_db_path()) + try: + cursor = conn.execute( + """INSERT INTO print_jobs + (template_id, payload_json, label_media, field_values, batch_id) + VALUES (?, ?, ?, ?, ?)""", + ( + template_name, + request_json, + label_media, + json.dumps(field_values), + batch_id, + ), + ) + conn.commit() + return cursor.lastrowid + finally: + conn.close() + + +@router.post("/print/{name}") +async def print_template(name: str, body: PrintRequest) -> dict: + tmpl = store.get_template(name) + if tmpl is None: + raise HTTPException(status_code=404, detail=f"Template '{name}' not found") + + values = _apply_defaults(tmpl.field_schema, body.fields) + + try: + image = render_template(tmpl, values) + except RenderError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + try: + outcome = print_image( + image=image, + label_media=tmpl.label_media, + model=settings.printer_model, + backend=settings.printer_backend, + host=settings.printer_host, + ) + except PrintError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + job_id = _insert_job(name, tmpl.label_media, values, body.model_dump_json()) + + # preview_url points at the history preview route, which lands in a later slice. + return { + "job_id": job_id, + "status": outcome, + "template": name, + "label_media": tmpl.label_media, + "preview_url": f"/api/history/{job_id}/preview.png", + } + + +@router.post("/preview/{name}") +async def preview_template(name: str, body: PrintRequest) -> Response: + tmpl = store.get_template(name) + if tmpl is None: + raise HTTPException(status_code=404, detail=f"Template '{name}' not found") + + values = _apply_defaults(tmpl.field_schema, body.fields) + + try: + image = render_template(tmpl, values) + except RenderError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + buf = io.BytesIO() + image.save(buf, format="PNG") + return Response(content=buf.getvalue(), media_type="image/png") + + +@router.post("/print/{name}/batch", response_model=BatchPrintResponse) +async def batch_print(name: str, body: BatchPrintRequest) -> BatchPrintResponse: + count = len(body.labels) + if count < 1: + raise HTTPException(status_code=400, detail="Batch count must be >= 1") + if count > 1000: + raise HTTPException(status_code=400, detail="Batch count exceeds maximum (1000)") + + tmpl = store.get_template(name) + if tmpl is None: + raise HTTPException(status_code=404, detail=f"Template '{name}' not found") + + batch_id = str(uuid.uuid4()) + jobs: list[BatchJobResult] = [] + succeeded = 0 + failed = 0 + + for label_values in body.labels: + try: + values = _apply_defaults(tmpl.field_schema, label_values) + image = render_template(tmpl, values) + outcome = print_image( + image=image, + label_media=tmpl.label_media, + model=settings.printer_model, + backend=settings.printer_backend, + host=settings.printer_host, + ) + job_id = _insert_job( + name, tmpl.label_media, values, + BatchPrintRequest(labels=[label_values]).model_dump_json(), + batch_id=batch_id, + ) + jobs.append(BatchJobResult(job_id=job_id, status=outcome)) + succeeded += 1 + except (HTTPException, RenderError, PrintError, ValueError) as exc: + msg = exc.detail if isinstance(exc, HTTPException) else str(exc) + jobs.append(BatchJobResult(job_id=-1, status=f"error: {msg}")) + failed += 1 + except Exception as exc: + jobs.append(BatchJobResult(job_id=-1, status=f"error: {exc}")) + failed += 1 + + # Return 200 if at least one succeeded; 500 if all failed. + # Mixed results return 200 — 207 deferred per api.md. + if failed == count: + raise HTTPException( + status_code=500, + detail=BatchPrintResponse( + batch_id=batch_id, jobs=jobs, succeeded=0, failed=failed + ).model_dump(), + ) + + return BatchPrintResponse( + batch_id=batch_id, jobs=jobs, succeeded=succeeded, failed=failed + ) From cd1f523d7b2f5407ad2ee6b059d168b9680630da Mon Sep 17 00:00:00 2001 From: crzykidd Date: Wed, 20 May 2026 22:22:12 -0700 Subject: [PATCH 15/84] Record printer-status and settings ADRs; update changelog status --- CHANGELOG.md | 8 +++++-- docs/decisions.md | 58 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 885dbf4..38910f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ All notable changes to labelforge are recorded here. Format follows [Keep a Chan - PRD covering quick-print, templates, label catalog, history, HTTP API, printer status, and settings - Architecture doc locking stack: FastAPI + SQLite + brother-ql-inventree + Vite/TS + Fabric.js - Glossary defining vocabulary -- ADR log (library choice, license, name, storage, frontend, label catalog model, auth, print-outcome reporting, convert rotation) +- ADR log (library choice, license, name, storage, frontend, label catalog model, auth, print-outcome reporting, convert rotation, server-side template rendering, settings source-of-truth, printer status via EWS) - CLAUDE.md for AI session context - GPL-3.0 LICENSE - .gitattributes enforcing LF line endings @@ -38,7 +38,11 @@ All notable changes to labelforge are recorded here. Format follows [Keep a Chan - Print API now reports the true send outcome (`sent` for the network backend) instead of always claiming `printed` (see ADR 2026-05-20) ### Status -- Slice 1 verified end-to-end: a real label printed on the QL-820NWB (DK-1209 die-cut, `62x29`). The render → convert → network-send path is confirmed working. Deferred to later slices: templates, canvas editor, history UI, printer-status query, settings UI, batch/increment, frontend. + +- Slice 1 verified end-to-end: a real label printed on the QL-820NWB (DK-1209 die-cut, `62x29`). The render → convert → network-send path is confirmed working. 62mm continuous print remains a media-coverage test pending a continuous roll (capability proven, that specific media not yet physically run). +- Templates engine (slice) built and committed: storage, CRUD, server-side renderer, print/preview/batch. Not yet smoke-tested against a created template end-to-end. +- Printer status: empirically confirmed the network print path (TCP 9100) does not answer status requests; the printer's EWS page (HTTP port 80) does report loaded media and is the chosen status source (opt-in). See ADR 2026-05-20 (c). +- Deferred to later slices: Fabric.js canvas editor, history UI + retention, printer-status feature (EWS scrape), settings UI, two-color (62red) rendering, image elements / image upload. --- diff --git a/docs/decisions.md b/docs/decisions.md index ab741b7..279a404 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -4,6 +4,64 @@ Architecture Decision Records, newest at the top. Each entry: what we decided, w --- +## 2026-05-20 (c) — Printer status comes from the EWS status page (opt-in), not the print path or vendor SDKs + +**Decision**: Live printer status (loaded media type, device-ready state) is read by fetching and parsing the printer's embedded web server (EWS) status page over HTTP — `http:///general/status.html` on the QL-820NWB — **as an opt-in feature, disabled by default**. The raster print path (TCP 9100) and the Brother b-PAC / Mobile SDKs are NOT used for status. + +**Why**: Three channels were evaluated against the locked stack (Python/FastAPI, Linux container, networked printer): + +- **TCP 9100 (raster/print path)** — send-only. A probe issuing the status-information request opcode (`ESC i S`) then reading returned empty against an idle, ready printer. No status here. (Confirmed empirically.) +- **Brother b-PAC SDK / Mobile SDK** — these do expose status (e.g. `getLabelInfoStatus` returning a label-ID enum), but b-PAC is a Windows COM component and the Mobile SDK is iOS/Android. Neither runs in a Linux container. Off-stack — rejected. (The enum reports the same sensed-media fact the EWS page already gives us, so nothing is lost.) +- **EWS over HTTP (port 80)** — the printer serves a status page reporting `Device Status` (e.g. READY), `Media Type` (e.g. "62mm x 29mm"), `Media Status`, and `Emulation`. The Status page is readable with an unauthenticated GET. Verified directly against the device. **Chosen.** + +**Scope of this decision**: read-only, unauthenticated status scrape, opt-in. + +- Default **off**. A setting (`printer_status_check`) enables it; when off, labelforge assumes nothing about loaded media and relies solely on the user-selected `label_media`. +- Status is **advisory, never a gate**. A status read never blocks or fails a print. If the fetch fails, times out, or the page can't be parsed, status is reported as "unknown" and printing proceeds normally. + +**Consequence — the page is firmware-controlled, so version-track the parser**: The status page is HTML emitted by printer firmware and can change shape across firmware versions. Therefore the parser targets a known page layout and records which layout/firmware it was written against (a parser-version constant); parsing must fail soft (unrecognized layout → status "unknown" + logged warning, never an exception reaching the print flow); treat the scrape as best-effort telemetry, not a contract. + +**Deferred open decision — authenticated EWS access (NOT decided here)**: Logging into the EWS with the admin password exposes firmware version and the ability to change raster/printer settings via authenticated POSTs (which carry a CSRF token). This is materially different from read-only status — it means storing the printer admin credential and performing writes against device config. That needs its own decision (security posture, where the password lives, whether write access is in scope for a single-user homelab tool). Flagged as a future fork; deliberately out of scope here. + +**Would revisit if**: the EWS page format proves too unstable across firmware to parse reliably, or a feature need pulls the authenticated-EWS decision onto the table. + +--- + +## 2026-05-20 (b) — Settings: DB rows are source of truth, env is bootstrap default + +**Decision**: User-adjustable preferences live in the SQLite `settings` table and are the source of truth at runtime. Code holds the default for each setting. Environment variables are NOT the runtime source for these preferences — with one bridge: the `default_label_media` setting falls back to the env value (`config.settings.default_label_media`) when no DB row exists. All other settings fall back to their code-defined defaults. + +**Why**: `config.py` already reads `default_label_media` from env, and `features/settings.md` lists the same key as a DB-backed setting — an overlap that needed resolving. The settings doc's model is "defaults in code, DB stores overrides," which fits a UI that lets the user change preferences at runtime (env changes require a container restart; DB changes don't). Making the DB authoritative means the Settings UI is the single place a preference is owned. The one env bridge (`default_label_media`) preserves the existing env-based bootstrap so a fresh install with no DB rows still honors a deployer's configured default. + +**Considered**: + +- **Env always wins** — rejected. A runtime Settings UI that can't actually change a setting without a container restart is a confusing UI; env is for deploy-time bootstrap, not live preferences. +- **Ignore env entirely, code defaults only** — rejected. Throws away the existing `default_label_media` env bootstrap that deployers may already rely on. +- **DB authoritative, env bridges `default_label_media` only** — chosen. DB owns runtime prefs; the existing env bootstrap is preserved for the one key that already had it. + +**Consequence**: The settings store reads DB-first, then default; for `default_label_media` the default is the env value rather than a hardcoded literal. `features/settings.md` should note this precedence so the env/DB relationship is documented where settings are specified. + +**Would revisit if**: more settings need a deploy-time env bootstrap (then generalize the bridge into a per-key "env default" mechanism rather than special-casing one key). + +--- + +## 2026-05-20 — Templates render server-side from element data, not from a browser-exported image + +**Decision**: A template stores its design as structured element data (the canvas scene plus per-element `labelforge_*` content with `{placeholders}`). At print/preview time the **server** resolves placeholder values into element content and rasterizes the scene to a Pillow bitmap. The rendered bitmap is the source of truth for both preview and print. The browser is never in the print path. + +**Why**: The API contract is "a client passes *values* for a named template and the server prints that template with those values" (`POST /api/print/{name}` with `{fields: {...}}`). The client sends values, not an image. Any client — a script, a webhook, a phone shortcut, a home-automation call, or the app's own UI — must get the same result with no browser involved. Therefore the server must hold the design and render it itself. This is also what `architecture.md` already assumes (Pillow is the rendering source of truth) and what `features/templates.md` implies (QR/barcode regenerated server-side from the resolved payload). + +**Considered**: +- **Browser exports a PNG, server prints that bitmap** — rejected. Breaks the core API contract: a headless client has no browser, so it could not render a template at all. Only the UI could ever print. This defeats the reason the API exists. +- **Headless browser on the server (Playwright/Puppeteer renders Fabric)** — rejected for v1. Faithful to the editor, but drags a full browser + Node runtime into the `python:3.12-slim` runtime image, inflating image size and ops weight against the single-small-container design. Disproportionate for a single-user homelab tool. +- **Server re-renders from element data with Pillow** — chosen. Browser-free, keeps the runtime image lean, and makes the API work for every client by construction. QR via `qrcode[pil]`, barcodes via `python-barcode`, text/line/rect/image via Pillow. + +**Consequence / known cost**: There are now two renderers of the same scene — the Fabric.js editor (authoring, in-browser) and the server-side Pillow renderer (preview + print). They must agree on geometry: coordinate origin, the 300dpi label scale, font metrics, and element transforms (`angle`, `scaleX`, `scaleY`). Divergence shows up as "preview/print doesn't match the editor." Mitigations: the editor operates in label-pixel coordinates at print DPI (per `features/templates.md`), and `POST /api/preview/{name}` returns the *server*-rendered bitmap so the user always previews the real output, not the editor's own canvas. The server renderer is the authority; the editor is an approximation of it. + +**Would revisit if**: editor/server geometry drift becomes a recurring source of bugs that coordinate-matching can't tame, at which point a headless-browser renderer (accepting the image-size cost) returns to the table. + +--- + ## 2026-05-20 — Print API reports `sent`, not `printed`, on the network backend **Decision**: `POST /api/print/*` returns the print outcome verbatim from the brother_ql backend. For the network (TCP) backend this is `sent`, meaning the raster was transmitted but the result is unconfirmed. Only backends that can read printer status back (USB) return `printed`. The API never claims `printed` for a network send. From 3ef28136dd2d61caad3943d0130c29f5b6d2765c Mon Sep 17 00:00:00 2001 From: crzykidd Date: Wed, 20 May 2026 22:38:51 -0700 Subject: [PATCH 16/84] Default DATA_DIR to /data and make compose deployment-generic --- .env.example | 7 ++++--- backend/labelforge/config.py | 2 +- compose.yml | 29 ++++++++--------------------- 3 files changed, 13 insertions(+), 25 deletions(-) diff --git a/.env.example b/.env.example index f2f73f7..d8a2af3 100644 --- a/.env.example +++ b/.env.example @@ -18,9 +18,10 @@ PRINTER_BACKEND=network # Label media loaded in the printer by default (used as UI pre-selection). DEFAULT_LABEL_MEDIA=62 -# Host path where SQLite, labels.yml, fonts, and previews are stored. -# Must be bind-mounted into the container at the same path. -DATA_DIR=/var/docker/labelforge +# Where SQLite, labels.yml, fonts, and previews are stored INSIDE the container. +# Default works with the named volume in compose.yml. Only change this if you +# also change the container-side mount path. +DATA_DIR=/data # Python log level: DEBUG | INFO | WARNING | ERROR LOG_LEVEL=INFO diff --git a/backend/labelforge/config.py b/backend/labelforge/config.py index b31473f..57cf0a7 100644 --- a/backend/labelforge/config.py +++ b/backend/labelforge/config.py @@ -9,7 +9,7 @@ class Settings(BaseSettings): printer_model: str = "QL-820NWB" # one of: network, linux_kernel, pyusb printer_backend: str = "network" - data_dir: Path = Path("/var/docker/labelforge") + data_dir: Path = Path("/data") default_label_media: str = "62" log_level: str = "INFO" diff --git a/compose.yml b/compose.yml index 640d808..1dea3b4 100644 --- a/compose.yml +++ b/compose.yml @@ -6,26 +6,13 @@ services: image: labelforge:latest restart: unless-stopped env_file: .env + ports: + - "8000:8000" + # Reverse proxy / tunnel wiring is deployment-specific. labelforge serves plain HTTP on port 8000; + # put it behind whatever proxy you use (Traefik, Caddy, nginx, Cloudflare Tunnel, etc.). + # To use a host directory instead of a named volume, replace with: - /your/host/path:/data volumes: - - /var/docker/labelforge:/var/docker/labelforge - networks: - - traefik - - dockflare - labels: - # ── Traefik (LAN — labels.home.arpa via internal Traefik instance) ──── - traefik.enable: "true" - traefik.http.routers.labelforge-lan.rule: "Host(`labels.home.arpa`)" - traefik.http.routers.labelforge-lan.entrypoints: "web" - traefik.http.services.labelforge.loadbalancer.server.port: "8000" + - labelforge-data:/data - # ── Dockflare (Cloudflare Tunnel — labels.crzynet.com) ─────────────── - # Adjust label keys to match your Dockflare version / configuration. - dockflare.enable: "true" - dockflare.hostname: "labels.crzynet.com" - dockflare.service: "http://labelforge:8000" - -networks: - traefik: - external: true - dockflare: - external: true +volumes: + labelforge-data: From 8e4501a32395d76aee7c8e219867c301bb072692 Mon Sep 17 00:00:00 2001 From: crzykidd Date: Wed, 20 May 2026 22:39:49 -0700 Subject: [PATCH 17/84] Remove homelab specifics from docs; correct branch model --- CLAUDE.md | 7 +++---- docs/architecture.md | 24 ++++++++++++------------ 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index adc1568..82350c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ When working on a task, load the relevant feature doc(s) plus `architecture.md` - **Config**: PyYAML for the label catalog - **Frontend**: Vite + TypeScript, vanilla (no React/Vue/Svelte), Fabric.js for the canvas - **Deployment**: single Docker container, multi-stage build (frontend → static assets → served by FastAPI) -- **Container image**: published to Gitea registry (`gitea.crzynet.com`) and Docker Hub +- **Container image**: built from the included `Dockerfile`; publish to whatever registry you use. ## Non-negotiables @@ -37,8 +37,7 @@ When working on a task, load the relevant feature doc(s) plus `architecture.md` - **No SaaS dependencies.** Self-hosted only. No cloud functions, no hosted databases, no third-party APIs that aren't user-controllable. - **No Next.js, no SSR frameworks.** Frontend is a static SPA served from the FastAPI container. - **No alternative printer libraries** without an ADR. We picked `brother-ql-inventree` after evaluation. -- **Container data path**: `/var/docker/labelforge/` on the host. SQLite at `data/app.db`, label catalog at `labels.yml`, fonts at `fonts/`, optional label preview images at `label-previews/`. -- **External hostname**: `labels.crzynet.com` (Cloudflare Tunnel via Dockflare). Internal: `labels.home.arpa` (Traefik on LAN). +- **Data path**: the app reads/writes everything under `$DATA_DIR` (default `/data` in the container): SQLite at `$DATA_DIR/data/app.db`, label catalog at `$DATA_DIR/labels.yml`, fonts at `$DATA_DIR/fonts/`, optional preview images at `$DATA_DIR/label-previews/`. How that path is backed (named volume, bind mount) is the operator's choice. ## Working style @@ -57,7 +56,7 @@ From the session prompt that owns this project: ## Repo conventions - Line endings: LF only. `.gitattributes` enforces this. If `git diff --stat` shows all files modified, run `git config core.autocrlf input && git checkout -- .` -- Branches: `main` is deployable. Feature work in `feature/` branches. +- Branches: `main` is protected — the ONLY way in is a pull request, gated by CodeQL and other checks; never push to `main` directly. `dev` is the working branch (solo work commits straight to `dev`). Use `feature/` branches when more than one person is working; merge those to `dev`, then PR `dev` → `main` for a release. - Commits: imperative present tense ("Add template recall endpoint" not "Added"). No conventional-commits prefixes. - Compose stack lives at the repo root as `compose.yml`. Dev compose at `compose.dev.yml`. diff --git a/docs/architecture.md b/docs/architecture.md index 3ca0d4c..e5fc46a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,10 +22,12 @@ ### Storage -- **SQLite** at `/var/docker/labelforge/data/app.db` — templates, history, settings, API tokens -- **`labels.yml`** at `/var/docker/labelforge/labels.yml` — user-editable label catalog metadata -- **Fonts** at `/var/docker/labelforge/fonts/` — `.ttf` / `.otf` files, drop-in -- **Label preview images** (optional) at `/var/docker/labelforge/label-previews/` — referenced from `labels.yml` +- **SQLite** at `$DATA_DIR/data/app.db` — templates, history, settings, API tokens +- **`labels.yml`** at `$DATA_DIR/labels.yml` — user-editable label catalog metadata +- **Fonts** at `$DATA_DIR/fonts/` — `.ttf` / `.otf` files, drop-in +- **Label preview images** (optional) at `$DATA_DIR/label-previews/` — referenced from `labels.yml` + +$DATA_DIR defaults to `/data` inside the container; back it with a named volume or bind mount as you prefer. ### Why these choices @@ -44,7 +46,7 @@ labelforge/ ├── CLAUDE.md ├── .gitignore ├── .gitattributes -├── compose.yml # production-shaped, used by Dockhand +├── compose.yml # single-service stack; bring your own proxy ├── compose.dev.yml # local dev: bind mounts, no Traefik labels ├── Dockerfile # multi-stage: frontend build → python runtime ├── pyproject.toml # backend deps + tool config @@ -123,16 +125,14 @@ merge: only identifiers in the intersection are user-facing. ## Deployment -- Single Compose stack deployed via Dockhand on `docker10` -- Networks: `traefik` (LAN routing) and `dockflare` (Cloudflare Tunnel to `labels.crzynet.com`) -- Data volume: `/var/docker/labelforge/` host path bind-mounted -- Image source: built locally on a CI host, pushed to Gitea registry, optionally mirrored to Docker Hub -- Env-driven config: printer host/port, API token, default label media, retention defaults -- No build step at deploy time — image is pre-built +- Single Docker image built from the included `Dockerfile` (multi-stage: frontend build → python runtime). No build step at deploy time once the image is built. +- Runs as one container serving plain HTTP on port `8000`. Put it behind whatever reverse proxy or tunnel you use; proxy wiring is deployment-specific and intentionally not baked into the app. +- Persistent data lives under `$DATA_DIR` (default `/data`); back it with a named volume or a host bind mount. See `compose.yml` for a standalone example and `compose.dev.yml` for local hot-reload dev. +- Env-driven config: printer host/port, API token, default label media, data dir, log level. See `.env.example`. ## Out of scope for v1 -- Reverse-proxy hardening beyond what Traefik defaults give +- Reverse-proxy hardening (proxy choice is left to the operator) - Database migrations beyond initial schema creation (manually managed for v1) - Health-check endpoint beyond what Traefik needs - Prometheus metrics endpoint (can add later if useful) From bac4ad7a5afdffa54153de9039ff167ac504b579 Mon Sep 17 00:00:00 2001 From: crzykidd Date: Wed, 20 May 2026 22:40:13 -0700 Subject: [PATCH 18/84] Record deployment-generic and branch-model ADR --- docs/decisions.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/decisions.md b/docs/decisions.md index 279a404..09ce095 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -4,6 +4,20 @@ Architecture Decision Records, newest at the top. Each entry: what we decided, w --- +## 2026-05-20 (d) — App stays deployment-generic; branch model is PR-gated main + dev working branch + +**Decision (deployment)**: labelforge's repo and docs describe the app generically — a single Docker container serving HTTP on 8000, with persistent data under `$DATA_DIR` (default `/data`). No specific host paths, hostnames, registries, orchestrators, or reverse-proxy wiring appear in the app or its public docs. `compose.yml` ships a standalone example using a named volume; operators substitute their own bind mount / proxy / tunnel. + +**Decision (branches)**: `main` is protected and reachable only via pull request, gated by CodeQL and other checks — never a direct push. `dev` is the working branch; solo work commits directly to `dev`. `feature/` branches are used when more than one person is involved, merged to `dev`, and `dev` is PR'd to `main` for a release. + +**Why**: The project is public open source. Baking the owner's homelab (host paths like `/var/docker/labelforge`, hostnames like `labels.crzynet.com`, Dockflare/Traefik labels, Gitea registry, the orchestrator) into the app's defaults and docs made it non-portable and misled readers — a stranger cloning the repo got the author's filesystem as a default and a deploy story they can't use. The deployment specifics are the operator's concern, not the app's. Separately, the documented branch model (`main` deployable, feature branches as default) did not match reality (PR-gated `main`, `dev` as the normal working branch), which repeatedly caused confusion; the docs now match the actual workflow. + +**Consequence**: `config.py` defaults `DATA_DIR=/data`; `compose.yml` uses a named volume and carries no proxy/network specifics; CLAUDE.md and architecture.md describe paths as `$DATA_DIR`-relative and deployment as bring-your-own-proxy. The owner's actual homelab deployment (named orchestrator, host paths, tunnel) lives outside this repo. Any future doc or default that reintroduces a specific host/hostname/registry/orchestrator into the app should be rejected and pointed at this ADR. + +**Would revisit if**: the project ships an official first-party deployment (e.g. a published image + opinionated compose) — at which point an *example* registry/image name may belong in docs, still framed as one option, not a baked-in default. + +--- + ## 2026-05-20 (c) — Printer status comes from the EWS status page (opt-in), not the print path or vendor SDKs **Decision**: Live printer status (loaded media type, device-ready state) is read by fetching and parsing the printer's embedded web server (EWS) status page over HTTP — `http:///general/status.html` on the QL-820NWB — **as an opt-in feature, disabled by default**. The raster print path (TCP 9100) and the Brother b-PAC / Mobile SDKs are NOT used for status. From 5d1ba221216f340581bfde900246d8aaf54a2b3e Mon Sep 17 00:00:00 2001 From: crzykidd Date: Wed, 20 May 2026 23:18:00 -0700 Subject: [PATCH 19/84] Render QR and barcode crisp for 1-bit print; make preview match print output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QR: use box_size=1 to generate the smallest natural image (1px/module), then scale by the largest integer multiple that fits the target box with NEAREST resampling. Every module is guaranteed N×N pure black/white pixels — no grey edges that the print threshold can crush. Barcode: binarize to pure 0/255 before scaling, then NEAREST instead of LANCZOS. LANCZOS was introducing anti-aliased grey at bar edges; those grey values survived the 1-bit threshold and merged adjacent bars. Preview: introduce PRINT_THRESHOLD (70, matching convert()'s default) and to_print_bitmap() in printer/client.py. Both preview endpoints now apply the same invert+threshold step as convert(), returning a pure B/W PNG that is the exact bitmap the printer rasterizes. print_image() passes the constant explicitly to convert() so the single definition governs both paths. --- backend/labelforge/printer/client.py | 20 +++++++++++++++- backend/labelforge/render/template.py | 26 +++++++++++++++++---- backend/labelforge/routes/preview.py | 3 ++- backend/labelforge/routes/template_print.py | 4 ++-- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/backend/labelforge/printer/client.py b/backend/labelforge/printer/client.py index f3aa8b6..b295030 100644 --- a/backend/labelforge/printer/client.py +++ b/backend/labelforge/printer/client.py @@ -16,6 +16,24 @@ logger = logging.getLogger(__name__) +# Centralized threshold — must match the value passed to convert() so that +# to_print_bitmap() and print_image() apply the exact same 1-bit decision. +PRINT_THRESHOLD = 70 # percent, same semantics as convert()'s threshold= kwarg +_THRESHOLD_PX: int = min(255, max(0, int((100.0 - PRINT_THRESHOLD) / 100.0 * 255))) +# Original L pixels ≤ this value print as black; above it → white (no ink). +_PRINT_CUTOFF: int = 255 - _THRESHOLD_PX + + +def to_print_bitmap(image: Image.Image) -> Image.Image: + """Reproduce convert()'s 1-bit threshold on *image*. + + Returns a mode-'L' image (0 = will print black, 255 = will not print) + that is the exact bitmap the printer rasterizes. Use this for preview + responses so preview == print for every element type. + """ + im = image.convert("L") + return im.point(lambda x: 0 if x <= _PRINT_CUTOFF else 255) + class PrintError(Exception): pass @@ -43,7 +61,7 @@ def print_image( # print-head width. rotate='auto' (the library default) can flip a # wide continuous image into a geometry the printer reads as the wrong # roll type. The renderer already produces the correct orientation. - instructions = convert(qlr, [image], label_media, cut=True, rotate="0") + instructions = convert(qlr, [image], label_media, cut=True, rotate="0", threshold=PRINT_THRESHOLD) identifier = f"tcp://{host}" if backend == "network" else host result = send( instructions=instructions, diff --git a/backend/labelforge/render/template.py b/backend/labelforge/render/template.py index 8965aa5..7ae9a44 100644 --- a/backend/labelforge/render/template.py +++ b/backend/labelforge/render/template.py @@ -116,15 +116,28 @@ def _render_qr_element( if not payload: raise RenderError("QR payload is empty after field substitution") ec = _QR_CORRECTION.get((correction or "M").upper(), ERROR_CORRECT_M) - qr = qrcode.QRCode(error_correction=ec, border=1) + # box_size=1 gives the smallest natural image (1px per module) so the + # integer scale factor below is maximised and module edges stay crisp. + qr = qrcode.QRCode(error_correction=ec, border=1, box_size=1) qr.add_data(payload) qr.make(fit=True) buf = io.BytesIO() qr.make_image(fill_color=0, back_color=255).save(buf, "PNG") buf.seek(0) - img = Image.open(buf) - img.load() - return img.convert("L").resize((max(box_w, 1), max(box_h, 1)), Image.NEAREST) + nat = Image.open(buf).convert("L") + nat.load() + nat_w, nat_h = nat.size # always square + scale = min(box_w // nat_w, box_h // nat_h) + if scale >= 1: + # Integer-multiple upscale: every module maps to exactly scale×scale pixels, + # pure black/white — no grey edges that the print threshold could crush. + scaled_w, scaled_h = nat_w * scale, nat_h * scale + scaled = nat.resize((scaled_w, scaled_h), Image.NEAREST) + result = Image.new("L", (box_w, box_h), 255) + result.paste(scaled, ((box_w - scaled_w) // 2, (box_h - scaled_h) // 2)) + return result + # Fallback: box smaller than natural QR; NEAREST keeps pixels pure B/W. + return nat.resize((max(box_w, 1), max(box_h, 1)), Image.NEAREST) def _render_barcode_element( @@ -146,7 +159,10 @@ def _render_barcode_element( buf.seek(0) img = Image.open(buf) img.load() - return img.convert("L").resize((max(box_w, 1), max(box_h, 1)), Image.LANCZOS) + # Force pure black/white before scaling; NEAREST keeps bars as whole-pixel + # columns with no anti-aliased grey that the print threshold could merge. + bw = img.convert("L").point(lambda x: 0 if x < 128 else 255) + return bw.resize((max(box_w, 1), max(box_h, 1)), Image.NEAREST) def render_template(template: Template, values: dict[str, str]) -> Image.Image: diff --git a/backend/labelforge/routes/preview.py b/backend/labelforge/routes/preview.py index 7286ad5..5c3602d 100644 --- a/backend/labelforge/routes/preview.py +++ b/backend/labelforge/routes/preview.py @@ -6,6 +6,7 @@ from labelforge.catalog.loader import get_label from labelforge.models import QuickPrintRequest +from labelforge.printer.client import to_print_bitmap from labelforge.render.text import RenderError, render_text from labelforge.routes.auth import require_auth @@ -37,5 +38,5 @@ async def preview_quick(request: QuickPrintRequest) -> Response: raise HTTPException(status_code=400, detail=str(exc)) from exc buf = io.BytesIO() - image.save(buf, format="PNG") + to_print_bitmap(image).save(buf, format="PNG") return Response(content=buf.getvalue(), media_type="image/png") diff --git a/backend/labelforge/routes/template_print.py b/backend/labelforge/routes/template_print.py index 1619e36..2d5f52a 100644 --- a/backend/labelforge/routes/template_print.py +++ b/backend/labelforge/routes/template_print.py @@ -14,7 +14,7 @@ BatchPrintResponse, PrintRequest, ) -from labelforge.printer.client import PrintError, print_image +from labelforge.printer.client import PrintError, print_image, to_print_bitmap from labelforge.render.template import render_template from labelforge.render.text import RenderError from labelforge.routes.auth import require_auth @@ -121,7 +121,7 @@ async def preview_template(name: str, body: PrintRequest) -> Response: raise HTTPException(status_code=400, detail=str(exc)) from exc buf = io.BytesIO() - image.save(buf, format="PNG") + to_print_bitmap(image).save(buf, format="PNG") return Response(content=buf.getvalue(), media_type="image/png") From 91e3aeb8b9b713f7a61c7a6feb3f1563b7c004b1 Mon Sep 17 00:00:00 2001 From: crzykidd Date: Wed, 20 May 2026 23:18:27 -0700 Subject: [PATCH 20/84] Record preview-matches-print threshold ADR --- docs/decisions.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/decisions.md b/docs/decisions.md index 09ce095..74f6f1b 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -4,6 +4,18 @@ Architecture Decision Records, newest at the top. Each entry: what we decided, w --- +## 2026-05-20 (e) — Preview must apply the same 1-bit threshold as print + +**Decision**: The preview endpoints return the exact 1-bit (black/white) image the printer will rasterize, produced by the same threshold/dither step as the print path — not the pre-threshold greyscale render. There is a single shared definition of "the bitmap that prints," used by both preview and print. + +**Why**: A QR element previewed as a crisp code but printed as a solid black block. Cause: the printer's `convert()` thresholds the greyscale render to 1-bit (~70% default, no dither), which crushed the QR's anti-aliased fine modules; preview returned the pre-threshold greyscale, so it looked fine while the print did not. The rendering ADR's promise that "preview is the exact bitmap that prints" only holds if preview applies the same final threshold. Fine detail (QR, barcode, thin lines, small text) is where preview-vs-print divergence shows up — and it showed up on a physical label, the most expensive place to find it. + +**Consequence**: QR/barcode elements are rendered as pure black/white scaled by integer factors so thresholding cannot crush them. Threshold/dither settings are explicit and centralized so preview and print provably agree. This refines (does not contradict) the server-side rendering ADR. + +**Would revisit if**: a future need for greyscale/dithered output (e.g. photo-ish images on a label) requires preview to represent dithering, at which point the shared step must reproduce the dither, not just the threshold. + +--- + ## 2026-05-20 (d) — App stays deployment-generic; branch model is PR-gated main + dev working branch **Decision (deployment)**: labelforge's repo and docs describe the app generically — a single Docker container serving HTTP on 8000, with persistent data under `$DATA_DIR` (default `/data`). No specific host paths, hostnames, registries, orchestrators, or reverse-proxy wiring appear in the app or its public docs. `compose.yml` ships a standalone example using a named volume; operators substitute their own bind mount / proxy / tunnel. From 60d20ecad5180b4a5fe2d5ece3ca748cb7f38711 Mon Sep 17 00:00:00 2001 From: crzykidd Date: Wed, 20 May 2026 23:30:21 -0700 Subject: [PATCH 21/84] Gate QR and barcode elements until print bug is fixed --- CHANGELOG.md | 4 ++++ backend/labelforge/render/template.py | 16 ++++++++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38910f9..18721db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to labelforge are recorded here. Format follows [Keep a Chan ## [Unreleased] +### Known Issues + +- QR and barcode template elements render in preview but print as a solid black block (1-bit threshold crushes fine detail). These elements are gated to raise a clear error until fixed. Text, lines, and rectangles print correctly. + ### Added - Project structure and design documentation - PRD covering quick-print, templates, label catalog, history, HTTP API, printer status, and settings diff --git a/backend/labelforge/render/template.py b/backend/labelforge/render/template.py index 7ae9a44..e53405c 100644 --- a/backend/labelforge/render/template.py +++ b/backend/labelforge/render/template.py @@ -110,6 +110,7 @@ def _render_text_element( return sub +# TODO: re-enable when QR/barcode 1-bit print bug is fixed def _render_qr_element( payload: str, correction: str, box_w: int, box_h: int ) -> Image.Image: @@ -140,6 +141,7 @@ def _render_qr_element( return nat.resize((max(box_w, 1), max(box_h, 1)), Image.NEAREST) +# TODO: re-enable when QR/barcode 1-bit print bug is fixed def _render_barcode_element( payload: str, symbology: str, box_w: int, box_h: int ) -> Image.Image: @@ -207,17 +209,15 @@ def render_template(template: Template, values: dict[str, str]) -> Image.Image: elif obj_type == "image": if obj.get("labelforge_qr_payload") is not None: - payload = resolve_content(obj["labelforge_qr_payload"], values) - sub = _render_qr_element( - payload, obj.get("labelforge_qr_error_correction", "M"), box_w, box_h + raise RenderError( + "QR elements are not yet supported for printing" + " (known bug: prints as a solid block)" ) - _paste_onto(canvas, sub, left, top, angle) elif obj.get("labelforge_barcode_payload") is not None: - payload = resolve_content(obj["labelforge_barcode_payload"], values) - sub = _render_barcode_element( - payload, obj.get("labelforge_barcode_symbology", "code128"), box_w, box_h + raise RenderError( + "Barcode elements are not yet supported for printing" + " (known bug: prints as a solid block)" ) - _paste_onto(canvas, sub, left, top, angle) else: raise RenderError("Image elements not yet supported") From 303bfdb82d63d94c2bb3fc4c0024fd58a6b69707 Mon Sep 17 00:00:00 2001 From: crzykidd Date: Wed, 20 May 2026 23:31:58 -0700 Subject: [PATCH 22/84] Add multi-stage frontend build to Dockerfile --- Dockerfile | 10 +- frontend/package-lock.json | 1156 ++++++++++++++++++++++++++++++++++++ 2 files changed, 1163 insertions(+), 3 deletions(-) create mode 100644 frontend/package-lock.json diff --git a/Dockerfile b/Dockerfile index 3acec2c..5c84282 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,9 @@ -# TODO: Slice N — add a multi-stage frontend build stage (node:lts-alpine, npm ci + npm run build) -# before this runtime stage, then COPY --from=frontend /app/frontend/dist /app/frontend/dist -# and mount it via FastAPI StaticFiles. +FROM node:lts-alpine AS frontend +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build FROM python:3.12-slim @@ -26,6 +29,7 @@ RUN pip install --no-cache-dir -e . # Default label catalog shipped in the image. At startup, if # ${DATA_DIR}/labels.yml is absent, main.py copies this into the volume. COPY labels.yml /app/labels.yml +COPY --from=frontend /app/frontend/dist /app/frontend/dist RUN chown -R labelforge:labelforge /app USER labelforge diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..83e96d3 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1156 @@ +{ + "name": "labelforge-frontend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "labelforge-frontend", + "devDependencies": { + "typescript": "^5.7.2", + "vite": "^6.3.5" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} From c3e6919555fc6758acc3802fc40507ffb8eab299 Mon Sep 17 00:00:00 2001 From: crzykidd Date: Wed, 20 May 2026 23:32:31 -0700 Subject: [PATCH 23/84] Serve built frontend SPA from FastAPI --- backend/labelforge/main.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/backend/labelforge/main.py b/backend/labelforge/main.py index 3db3042..afd9349 100644 --- a/backend/labelforge/main.py +++ b/backend/labelforge/main.py @@ -3,7 +3,9 @@ from contextlib import asynccontextmanager from pathlib import Path -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles from labelforge.catalog.loader import load_catalog from labelforge.config import settings @@ -67,3 +69,19 @@ async def lifespan(app: FastAPI): # noqa: ARG001 app.include_router(settings_router.router, prefix="/api") app.include_router(templates_router.router, prefix="/api") app.include_router(template_print_router.router, prefix="/api") + +_FRONTEND_DIST = Path("/app/frontend/dist") + +if _FRONTEND_DIST.exists(): + app.mount( + "/assets", + StaticFiles(directory=_FRONTEND_DIST / "assets"), + name="assets", + ) + + @app.get("/{full_path:path}", include_in_schema=False) + async def spa_fallback(request: Request, full_path: str) -> FileResponse: + if full_path.startswith("api/"): + from fastapi import HTTPException + raise HTTPException(status_code=404) + return FileResponse(_FRONTEND_DIST / "index.html") From 627a4fd78dd26769f72fea3a4da185b18a2892f6 Mon Sep 17 00:00:00 2001 From: crzykidd Date: Wed, 20 May 2026 23:33:35 -0700 Subject: [PATCH 24/84] Add client-side routing and nav to the SPA --- frontend/index.html | 4 ++++ frontend/src/main.ts | 6 ++++- frontend/src/pages/templates.ts | 8 +++++++ frontend/src/router.ts | 39 +++++++++++++++++++++++++++++++++ frontend/src/style.css | 22 +++++++++++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/templates.ts create mode 100644 frontend/src/router.ts diff --git a/frontend/index.html b/frontend/index.html index 7b26961..bd6a74f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,6 +6,10 @@ LabelForge +
diff --git a/frontend/src/main.ts b/frontend/src/main.ts index b3b3711..4fab5fd 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,4 +1,8 @@ import './style.css' import { mountQuickPrint } from './pages/quick-print' +import { mountTemplates } from './pages/templates' +import { initRouter, register } from './router' -mountQuickPrint(document.getElementById('app')!) +register('/', mountQuickPrint) +register('/templates', mountTemplates) +initRouter() diff --git a/frontend/src/pages/templates.ts b/frontend/src/pages/templates.ts new file mode 100644 index 0000000..b67b5d3 --- /dev/null +++ b/frontend/src/pages/templates.ts @@ -0,0 +1,8 @@ +export function mountTemplates(root: HTMLElement): void { + root.innerHTML = ` +
+

Templates

+

Coming soon.

+
+ ` +} diff --git a/frontend/src/router.ts b/frontend/src/router.ts new file mode 100644 index 0000000..3d200e4 --- /dev/null +++ b/frontend/src/router.ts @@ -0,0 +1,39 @@ +type MountFn = (root: HTMLElement) => void + +const routes: Record = {} + +export function register(path: string, mount: MountFn): void { + routes[path] = mount +} + +function setActiveNav(path: string): void { + document.querySelectorAll('[data-route]').forEach(a => { + a.classList.toggle('active', a.dataset.route === path) + }) +} + +export function navigate(path: string): void { + history.pushState(null, '', path) + render(path) +} + +function render(path: string): void { + const root = document.getElementById('app') + if (!root) return + const mount = routes[path] ?? routes['/'] + if (mount) { + setActiveNav(path) + mount(root) + } +} + +export function initRouter(): void { + document.addEventListener('click', e => { + const target = (e.target as HTMLElement).closest('[data-route]') + if (!target) return + e.preventDefault() + navigate(target.dataset.route!) + }) + window.addEventListener('popstate', () => render(window.location.pathname)) + render(window.location.pathname) +} diff --git a/frontend/src/style.css b/frontend/src/style.css index 9d8b79c..cf4329a 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -130,6 +130,28 @@ button:disabled { border-radius: 4px; } +/* nav */ +#nav { + max-width: 520px; + margin: 0 auto 1rem; + display: flex; + gap: 1.25rem; +} + +#nav a { + text-decoration: none; + color: #1a1a1a; + font-size: 0.875rem; + padding: 0.2rem 0; + border-bottom: 2px solid transparent; +} + +#nav a.active { + border-bottom-color: #1a6fdb; + color: #1a6fdb; + font-weight: 600; +} + /* token gate */ .token-gate input[type="password"] { width: 100%; From eb451f3c624d9035e1621fd4e46178df500f2f12 Mon Sep 17 00:00:00 2001 From: crzykidd Date: Wed, 20 May 2026 23:51:52 -0700 Subject: [PATCH 25/84] Add Fabric.js dependency and template API client --- frontend/package.json | 3 +++ frontend/src/api.ts | 48 ++++++++++++++++++++++++++++++++++++++++++- frontend/src/types.ts | 26 +++++++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/frontend/package.json b/frontend/package.json index ce02cf0..5d63f68 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,9 @@ "build": "tsc && vite build", "preview": "vite preview" }, + "dependencies": { + "fabric": "6.6.1" + }, "devDependencies": { "typescript": "^5.7.2", "vite": "^6.3.5" diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e3da719..854450f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { FontInfo, LabelEntry, PrintJobResponse, QuickPrintRequest } from './types' +import type { FontInfo, LabelEntry, PrintJobResponse, QuickPrintRequest, Template, TemplateCreate } from './types' export const TOKEN_KEY = 'labelforge_token' @@ -61,6 +61,52 @@ export async function previewQuick(req: QuickPrintRequest): Promise { return res.blob() } +export function listTemplates(): Promise { + return apiFetch('/api/templates') +} + +export function getTemplate(name: string): Promise