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/commands/release-cut.md b/.claude/commands/release-cut.md new file mode 100644 index 0000000..fbd53c2 --- /dev/null +++ b/.claude/commands/release-cut.md @@ -0,0 +1,105 @@ +--- +description: Cut a GitHub release after the dev→main PR has merged and main CI is green +argument-hint: (e.g. 0.3.6 — must match what /release-prep prepared) +--- + + + +# Release Cut + +You are publishing the GitHub release for **v$ARGUMENTS**. Run this ONLY +after: + +- `/release-prep $ARGUMENTS` has merged into `main`, and +- the push-to-`main` CI + image-publish workflows are green and `:latest` + images are in the registry. + +Publishing the release triggers the `release: published` workflow, which +builds and pushes the production `:latest`, `:v$ARGUMENTS`, and `:v` +images. So this step is the point of no return for production images — +verify before tagging. + +## Execution rules + +- `$ARGUMENTS` SHOULD be bare semver (no `v` prefix). If a leading `v` was + typed (`v0.3.6`), strip it silently. After stripping, if the value does + not match `MAJOR.MINOR.PATCH` exactly, STOP and ask for a valid version. +- The bare value MUST equal the current version in `pyproject.toml` on + `main`. If it does not, STOP. +- The release tag is `v$ARGUMENTS` (with the `v` prefix — matches the + existing tag convention and the Docker `type=semver` extraction). Before + calling `gh`, assert the tag string matches `^v[0-9]+\.[0-9]+\.[0-9]+$` + exactly. If it does not, STOP — never create a malformed tag. +- Do NOT add `Co-authored-by` lines anywhere. +- If any verification step fails, STOP and report. Do not create the tag. + +## Step 1 — Verify we are releasing the right commit + +1. `git fetch origin` and check out `main`: `git checkout main && git pull`. +2. Confirm the version in `pyproject.toml` equals `$ARGUMENTS`. If not, the + prep PR is not merged (or the wrong version was passed) — STOP. +3. Confirm the working tree is clean. +4. Confirm `git log` shows the `chore(release): prepare v$ARGUMENTS` commit on + `main`. If absent, STOP — the PR has not been merged. + +## Step 2 — Verify CI is green on main + +Use `gh` to confirm the latest runs on `main` for this commit succeeded: + +1. `gh run list --branch main --limit 10` and confirm the most recent runs + for the release commit concluded `success` for BOTH `CI` + and `Build and Push`. +2. If a run is still in progress, tell the user to wait and STOP — do not tag + a commit whose images may not exist yet. +3. If a run failed, STOP and report which job failed. + +## Step 3 — Confirm the version tag does not already exist + +`git tag -l "v$ARGUMENTS"` and `gh release view v$ARGUMENTS` — if either +exists, STOP and report. Never overwrite an existing release/tag. + +## Step 4 — Assemble the release notes + +Extract the `## [$ARGUMENTS] — ` section from `CHANGELOG.md` (everything +from that header up to, but not including, the next `## [` header). This is +the release body — the changelog is the single source of truth, matching the +PR description `/release-prep` created. + +## Step 5 — Create the release + +Write the extracted section to a temp file and pass it via `--notes-file`. +Create an annotated tag on the current `main` HEAD and publish the release in +one step with `gh`: + +``` +gh release create v$ARGUMENTS \ + --target main \ + --title "v$ARGUMENTS" \ + --notes-file +``` + +Do not try to inline multi-line release notes. + +## Step 6 — Verify the production build fired + +1. `gh run list --workflow "Build and Push" --limit 3` and confirm a run + triggered by the `release` event for `v$ARGUMENTS` has started or + succeeded. +2. Report its status. + +## Step 7 — Report + +Print: + +- The release URL. +- The tag created (`v$ARGUMENTS`). +- The status of the production image build. +- A reminder of the expected image tags once the build finishes: `:latest`, + `:v$ARGUMENTS`, `:v.`, `:v` — the `v` prefix is added + by the `prefix=v` rule in `build-and-push.yml`'s `metadata-action`. + +Done — the release is live. diff --git a/.claude/commands/release-prep.md b/.claude/commands/release-prep.md new file mode 100644 index 0000000..1b3e95c --- /dev/null +++ b/.claude/commands/release-prep.md @@ -0,0 +1,243 @@ +--- +description: Prepare a release — bump version, roll changelog, sync docs, validate, commit, push to dev, open PR +argument-hint: (e.g. 0.3.6) +--- + + + +# Release Prep + +You are preparing release **v$ARGUMENTS**. This command does ONLY the prep + PR +steps. It does **not** merge and does **not** create the GitHub release — the +human merges, and `/release-cut` (run after `main` CI is green) creates the +release. + +## Execution rules + +- Work on the `dev` branch. Never push directly to `main`. +- Do NOT add `Co-authored-by` lines to the commit. +- Do NOT create the GitHub release or tag in this command. +- If any validation step fails, STOP and report — do not commit broken state. +- Make exactly ONE commit covering version + changelog + all doc updates. +- `$ARGUMENTS` is the target version. It SHOULD be bare semver, no `v` prefix + (e.g. `0.3.6`). If a leading `v` was typed (`v0.3.6`), strip it silently and + proceed with the bare number. After stripping, if the value is empty or does + not match `MAJOR.MINOR.PATCH` exactly (three integers, dot-separated, no + pre-release/build suffix), STOP and ask for a valid version. +- Reminder on the `v` convention: the version is stored and used BARE + everywhere (`pyproject.toml`, changelog header, README badge, in-code image + tags). The `v` prefix is added in exactly one place — the git tag / GitHub + release — and that happens in `/release-cut`, not here. + +## Step 0 — Preflight + +1. Confirm the current branch is `dev`. If not, STOP and report. +2. Confirm the working tree is clean (`git status --porcelain` empty). If + there are uncommitted changes, STOP and show them — the user must decide. +3. Read the current version from `pyproject.toml`. Parse both the current + version and `$ARGUMENTS` into `(MAJOR, MINOR, PATCH)` integer triples for + comparison. + +### 0a — Hard stops (never proceed past these) + +- **Not newer.** If `$ARGUMENTS` is not strictly greater than the current + version (compared as integer triples, not string compare), STOP and report. + This blocks re-running an already-shipped version, going backward, or a typo + that lands on an old number. Equal-to-current also stops. +- **Tag already exists.** Run `git fetch --tags` then check both + `git tag -l "v$ARGUMENTS"` and `gh release view "v$ARGUMENTS"`. If either + exists, STOP and report — the release already exists and must not be + clobbered. + +### 0b — Bump-tier classification (warn + confirm) + +Classify the jump from current → target. Only a clean single-patch bump +proceeds silently; everything else pauses for explicit confirmation. + +- **Patch bump** = MAJOR and MINOR unchanged, PATCH increased. + - If PATCH increased by exactly 1 (e.g. `0.3.3` → `0.3.4`): proceed, no + prompt. + - If PATCH skipped ahead (e.g. `0.3.3` → `0.3.7`): WARN that N patch + versions were skipped, show the expected next patch (current with + PATCH+1), and require explicit confirmation before proceeding. + +- **Minor bump** = MINOR increased (MAJOR unchanged), e.g. `0.3.3` → `0.4.0`. + ALWAYS warn and require confirmation, even for the clean `.0` case. Message: + this is a **new minor release**, which is infrequent — confirm it's + intended. Note that a new minor also fires the changelog archive trigger + (Step 3). If the target is a minor bump but PATCH is not `0` (e.g. + `0.3.3` → `0.4.2`), additionally flag that new minors normally start at + `.0`. + +- **Major bump** = MAJOR increased, e.g. `0.3.3` → `1.0.0`. ALWAYS warn with + strong language and require explicit confirmation: this is a **major + release**, the rarest and most consequential bump, and it produces a new + `:` image tag. If MINOR or PATCH is not `0` (e.g. `1.2.0`), + additionally flag that major releases normally start at `X.0.0`. + +When warning, always show the three "expected next" successors from the +current version so the user can see what they may have meant: +next patch (`MAJOR.MINOR.PATCH+1`), next minor (`MAJOR.MINOR+1.0`), +next major (`MAJOR+1.0.0`). + +Do not proceed on any warned tier without a clear affirmative ("yes", +"confirmed", etc.) in the chat. If the user declines, STOP. + +### 0c — Remaining setup + +4. Determine whether this is a **new minor** (MINOR differs from current) or + a **patch within the current minor**. This decides whether the archive + trigger fires (Step 3). (A major bump is also "new minor" for archive + purposes — the previous minor series gets archived regardless.) +5. Capture today's date as `YYYY-MM-DD` for the changelog header. + +## Step 1 — Bump the version + +Update `pyproject.toml` so the literal `version = ""` reflects +`$ARGUMENTS`. This is the single source of truth — CI and the in-app version +display both read from it. Do not touch helper functions or surrounding code. + +## Step 2 — Roll the changelog + +In `CHANGELOG.md`: + +1. Change the `## [Unreleased]` header to `## [$ARGUMENTS] — `. +2. Insert a fresh empty `## [Unreleased]` block (matching whatever HTML-comment + skeleton the file already uses) directly above the new version header. +3. Leave the rolled section's entries exactly as written by the dev work — do + not rewrite them, but DO sanity-check that every entry is user-facing prose + and sits under a correct category heading (Added / Changed / Fixed / + Security / Deprecated / Removed). Fix obvious miscategorisation only. +4. If the `[Unreleased]` section is empty (no entries to ship), STOP and + report — there is nothing to release. + +## Step 3 — Per-minor archive trigger (NEW MINOR ONLY) + +Only if Step 0 determined this is the **first release of a new minor** (e.g. +cutting `0.4.0` while the active file holds `0.3.x`): + +1. Move the entire previous minor series (all `0.3.x` blocks, in this example) + out of `CHANGELOG.md` into a new `docs/CHANGELOG-.x.md` (e.g. + `docs/CHANGELOG-0.3.x.md`), newest-first within that file, matching the + format of any existing archive file. +2. Prepend a link to the new archive in the "Archived releases" index at the + bottom of `CHANGELOG.md`. +3. Confirm the active `CHANGELOG.md` now holds only `[Unreleased]` plus the + new current minor series (just the `$ARGUMENTS` block at this point). + +For a **patch release** (e.g. `0.3.6`), do NOT archive anything — skip this +step entirely. + +## Step 4 — Sync the README + +In `README.md`: + +1. Update the version badge: in `**Version:** `, replace the current + version with `$ARGUMENTS` (e.g. `**Version:** 0.0.1` → `**Version:** $ARGUMENTS`). +2. Add a `### v$ARGUMENTS ()` entry at the top of the `## What's New` + section, summarising this release in user-facing language drawn from the + changelog entries you just rolled. Keep it consistent with the voice of the + existing entries. +3. Update any top-of-file new-in banner / one-line status blurb to reference + `$ARGUMENTS` if it currently names a specific version. + +## Step 5 — Sync long-form docs + +For each of these docs: + +- **`README.md`**: version line and `## What's New` entry — handled in Step 4. +- **`CLAUDE.md`**: update the **Build Status** block — set "Last shipped: + v$ARGUMENTS" and update "Target for next release" as appropriate. + +Do not invent new sections — only adjust version-referencing content that +already exists. + +## Step 6 — Validate locally BEFORE committing + +Run the same checks CI will run, so a red PR is caught now. Run each in order. +If ANY check fails, STOP, report exactly what failed, and do not commit. + +1. `ruff check .` +2. `ruff format --check .` +3. `mypy backend` +4. `pytest -q` +5. Config validation (`config` job from `ci.yml` — validate all tracked YAML/JSON/TOML): + ```bash + python - <<'PY' + import json, pathlib, subprocess, sys, tomllib + import yaml + files = subprocess.check_output( + ["git", "ls-files", "*.yml", "*.yaml", "*.json", "*.toml"], + text=True).split() + errors = [] + for f in files: + p, data = pathlib.Path(f), pathlib.Path(f).read_bytes() + try: + if p.suffix in (".yml", ".yaml"): + list(yaml.safe_load_all(data)) + elif p.suffix == ".json": + json.loads(data) + elif p.suffix == ".toml": + tomllib.loads(data.decode()) + except Exception as e: + errors.append(f"{f}: {e}") + for e in errors: + print(f"::error::{e}") + if errors: + sys.exit(1) + print(f"OK: {len(files)} config files parse cleanly") + PY + ``` +6. `[ -f .env ] || cp .env.example .env` +7. `docker compose -f docker-compose.yml config --quiet` +8. `docker compose -f docker-compose.dev.yml config --quiet` + +Also grep for version-string drift: confirm no stale `` +references remain in `README.md`, `pyproject.toml`, or `CLAUDE.md`. +Report any other occurrences you find rather than blindly editing. + +## Step 7 — Commit + +Stage everything and make ONE commit. Use a conventional-commit subject and a +body that lists what changed. Template: + +``` +chore(release): prepare v$ARGUMENTS + +- pyproject.toml bumped to $ARGUMENTS +- CHANGELOG: rolled [Unreleased] → [$ARGUMENTS] — +- README: version badge + What's New entry +- CLAUDE.md: Build Status block updated +<- archive line ONLY if a new-minor archive was performed> +``` + +No `Co-authored-by` lines. + +## Step 8 — Push and open the PR + +1. `git push origin dev`. +2. Open a PR `dev` → `main` with `gh pr create`: + - Title: `Release v$ARGUMENTS` + - Body: this release's CHANGELOG section (the `[$ARGUMENTS]` block you just + rolled), so the PR description is the release notes. This is the same + text `/release-cut` will use as the GitHub release body — single source + of truth. +3. Capture the PR URL. + +## Step 9 — Report and STOP + +Print a short summary: + +- The PR URL. +- Confirmation that local validation passed. +- The exact next steps for the human, verbatim: + 1. Review the PR on GitHub and wait for CI to go green. + 2. Merge the PR into `main`. + 3. Wait for the push-to-`main` build to publish `:latest` to the registry. + 4. Run `/release-cut $ARGUMENTS` to tag and publish the GitHub release. + +Do NOT proceed past this point. Do not merge. Do not tag. 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 index 3883ca6..887ffc4 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,16 +1,58 @@ { - "hooks": { - "PreToolUse": [ - { - "matcher": "Grep|Glob|Regex", - "hooks": [ - { - "type": "command", - "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-guard.sh", - "timeout": 3000 - } - ] - } + "permissions": { + "allow": [ + "Read(**)", + "Edit(**)", + "Write(**)", + "Bash(uname -a && hostname && cat /etc/os-release 2>/dev/null | head -5 && echo \"---\" && cat /proc/version 2>/dev/null)", + "Read(//usr/lib/**)", + "Read(//proc/**)", + "Bash(git rm *)", + "Bash(git add *)", + "Bash(git commit *)", + "Bash(git status *)", + "Bash(git log *)", + "Bash(git diff *)", + "Bash(git ls-files *)", + "Bash(git show *)", + "Bash(gh issue *)", + "Bash(gh repo *)", + "Bash(command -v tea)", + "Bash(tea --version)", + "Bash(tea issues *)", + "Bash(npm install *)", + "Bash(npm run *)", + "Bash(npx tsc *)", + "Bash(node_modules/.bin/tsc *)", + "Bash(find *)", + "Bash(grep *)", + "Bash(cat *)", + "Bash(head *)", + "Bash(ls *)", + "Bash(mkdir *)", + "Bash(docker compose *)", + "Bash(docker *)" ] + }, + "sandbox": { + "enabled": true, + "autoAllowBashIfSandboxed": true, + "network": { + "allowLocalBinding": true, + "allowedDomains": [ + "localhost", + "127.0.0.1", + "pypi.org", + "files.pythonhosted.org", + "registry.npmjs.org" + ] + }, + "filesystem": { + "allowWrite": [ + "/tmp/**", + "~/.cache/**", + "~/.npm/**" + ] + } } } 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 *)" - ] - } -} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e411aee --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# labelforge environment — copy to .env and fill in your values. +# All variables are read by pydantic-settings; names are case-insensitive. + +# Set DISABLE_AUTH=true to run with NO app-level auth — every /api/* route is +# open. Intended only for deployments fronted by a reverse proxy that enforces +# auth (e.g. Traefik forward-auth/basic-auth). Default false. +DISABLE_AUTH=false + +# REQUIRED (unless DISABLE_AUTH=true): shared secret for the HTTP API. All +# /api/* routes (except /api/health) require: Authorization: Bearer +# The app refuses to start if this is unset while auth is enabled. +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 + +# 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/.github/dependabot.yml b/.github/dependabot.yml index e71e765..0849edf 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,9 +2,9 @@ version: 2 updates: - package-ecosystem: github-actions directory: / + target-branch: dev schedule: - interval: weekly - day: monday + interval: monthly time: '06:00' timezone: Etc/UTC open-pull-requests-limit: 5 @@ -13,12 +13,16 @@ updates: labels: - dependencies - github-actions + groups: + github-actions-all: + patterns: + - "*" - package-ecosystem: pip directory: / + target-branch: dev schedule: - interval: weekly - day: monday + interval: monthly time: '06:00' timezone: Etc/UTC open-pull-requests-limit: 5 @@ -35,9 +39,9 @@ updates: - package-ecosystem: npm directory: /frontend + target-branch: dev schedule: - interval: weekly - day: monday + interval: monthly time: '06:00' timezone: Etc/UTC open-pull-requests-limit: 5 @@ -54,9 +58,9 @@ updates: - package-ecosystem: docker directory: / + target-branch: dev schedule: - interval: weekly - day: monday + interval: monthly time: '06:00' timezone: Etc/UTC open-pull-requests-limit: 5 diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index 3d7d499..58d403e 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -3,8 +3,8 @@ name: Build and Push on: push: branches: [main, dev] - tags: - - 'v*.*.*' + release: + types: [published] concurrency: group: build-${{ github.ref }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e473676..4b65246 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,11 +95,74 @@ 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 load: false cache-from: type=gha cache-to: type=gha,mode=max + + config: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - run: pip install pyyaml + - name: Validate tracked YAML / JSON / TOML config + run: | + python - <<'PY' + import json, pathlib, subprocess, sys, tomllib + import yaml + files = subprocess.check_output( + ["git", "ls-files", "*.yml", "*.yaml", "*.json", "*.toml"], + text=True).split() + errors = [] + for f in files: + p, data = pathlib.Path(f), pathlib.Path(f).read_bytes() + try: + if p.suffix in (".yml", ".yaml"): + list(yaml.safe_load_all(data)) + elif p.suffix == ".json": + json.loads(data) + elif p.suffix == ".toml": + tomllib.loads(data.decode()) + except Exception as e: + errors.append(f"{f}: {e}") + for e in errors: + print(f"::error::{e}") + if errors: + sys.exit(1) + print(f"OK: {len(files)} config files parse cleanly") + PY + + compose: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Validate compose files + run: | + # compose files reference `env_file: .env`, which is gitignored. + # Seed a throwaway .env from the tracked example so `config` can resolve it. + [ -f .env ] || cp .env.example .env + for f in docker-compose.yml docker-compose.dev.yml; do + if [ -f "$f" ]; then + docker compose -f "$f" config --quiet && echo "OK $f" + else + echo "skip $f (not present)" + fi + done diff --git a/.gitignore b/.gitignore index 8fbf412..77d115a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,20 @@ __pycache__/ .venv/ venv/ env/ +# uv is not this project's toolchain (Dockerfile uses pip + hatchling); lockfile is a local artifact +uv.lock + +# Sandbox-mounted host dotfiles (not project files) +.bash_profile +.bashrc +.gitconfig +.gitmodules +.idea +.mcp.json +.profile +.ripgreprc +.zprofile +.zshrc # Build artifacts build/ @@ -25,6 +39,8 @@ node_modules/ # Frontend build output (consumed by FastAPI in container build) frontend/dist/ +# Compiled TS output emitted next to source (tsc artifacts; not used by Vite) +frontend/src/**/*.js # IDE .vscode/ @@ -41,28 +57,30 @@ Thumbs.db # Runtime data (should never be committed) data/ +data-dev/ *.db *.db-journal *.sqlite *.sqlite3 -# vexp (VS Code extension daemon — all runtime state) +# Leftover vexp index dir — vexp is de-adopted (see standards.md), but its host +# daemon keeps regenerating .vexp/ until host teardown lands in the ansible repo. +# Ignored only to prevent accidentally committing regenerated index state. .vexp/ # Logs *.log logs/ -# vexp — local index only; manifest.json and .gitattributes are tracked -.vexp/index.db -.vexp/index.db-wal -.vexp/index.db-shm -.vexp/healthy -.vexp/daemon.pid -.vexp/daemon.pipe -.vexp/vexp.log +# claude — ignore the dir but track the shared settings.json and slash commands. +# Any per-host auto-generated .claude/CLAUDE.md and settings.local.json stay ignored. +.claude/* +!.claude/settings.json +!.claude/commands/ +!.claude/commands/*.md # Test artifacts .coverage htmlcov/ coverage.xml +test-print.json diff --git a/.vexp/.gitattributes b/.vexp/.gitattributes deleted file mode 100644 index 7ebdf05..0000000 --- a/.vexp/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -# Generated by vexp — index.db is gitignored, only manifest.json is tracked -manifest.json merge=union diff --git a/.vexp/manifest.json b/.vexp/manifest.json deleted file mode 100644 index d6f26fc..0000000 --- a/.vexp/manifest.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "file_hashes": { - "CHANGELOG.md": "d7bf580fb13c7b575f426b4fefc62a506c1cc615dc813a42f78f499ed9df0146", - "CLAUDE.md": "2837c4f80ba6e74a987c949c9bf1e4187c48754fdef7689769b90bda8b228624", - "README.md": "f37e9eeb4136973e6bdb5d56cef96148cc4b61eecc241080a9dcfff2505a080a", - "docs/PRD.md": "112695a62c4caca08095adf0fcba7cdec26f182d64c2f8d591fa45223a553a85", - "docs/architecture.md": "23c129232659739a7e59862b72b724d5dc97d4bad04a9fd3f9ffeefc9f7d6778", - "docs/decisions.md": "8ed2d9e4e57170c26395ffa857eb1b766052658e13f9c9f41dba3abb04a95948", - "docs/features/api.md": "a93de1dc6b551d270876d63eb3e5db7fddfcc42d14c80f579c8e492a0596c963", - "docs/features/history.md": "0a5c43c443c29169c6af8b9f57812eeb227f4c8c9ade775ef8eb9e351eaf903e", - "docs/features/label-catalog.md": "5aac08fa21ec923ff18462c59bc06e9ef70005bdcefde8f165409d3887f97ca4", - "docs/features/printer-status.md": "578c19ca5f251485acd2c2556bc6dd23c2213eb9a3491c017c1bc13780303e23", - "docs/features/quick-print.md": "0d9169bd53c23d661c4ecd8226f4a4392671dd3dddf278b0a64d2a5e5c77c933", - "docs/features/settings.md": "40c80d62f5f2d47b6941c7e867f5fb19871becf2e56c5e4924cbd2d5c371bf0b", - "docs/features/templates.md": "b4397a068d02f0bfc686a89357ddc6508b7890a20b458c41d2d8e4d61cdfad2e", - "docs/glossary.md": "997b9148fad45a6927be253ed0162b845bd34848dd683b052812c58661e798db" - }, - "indexed_at_commit": "24dea5380a133f2d48203c7531d19de61626111d", - "indexed_at_timestamp": "2026-05-20T02:38:25.457908200+00:00", - "schema_version": 3, - "stats": { - "total_edges": 163, - "total_files": 14, - "total_nodes": 177 - }, - "vexp_version": "2.0.17" -} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 151c480..d487f7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,29 +4,206 @@ All notable changes to labelforge are recorded here. Format follows [Keep a Chan ## [Unreleased] +## [0.1.0] — 2026-06-06 + +### Security + +- **Log-injection hardening (CWE-117)** — user-influenced values that reach a log line + (the history `job_id` path parameter and the requested label media on a media-mismatch + warning) are now passed through a `scrub()` helper that strips CR/LF before interpolation, + so a crafted value can't forge additional log entries. No behaviour change for legitimate + input. +- **Code-scanning cleanup** — documented three intentionally-empty exception handlers flagged + by CodeQL `py/empty-except` (shutdown-task cancellation, best-effort printer-socket close, + malformed stored-payload fallback); the socket-close handler now logs at debug instead of + silently swallowing. No behaviour change. +- **No exception detail in the printer-status error response (CWE-209)** — `GET /api/printer/status` + returned the raw exception text in its 503 body when status was unavailable, which CodeQL + flagged as information exposure. It now logs the exception server-side and returns a generic + "Printer status is currently unavailable." message. + +### Added + +- **Friendly template names** — when creating a template, type a human-readable name (e.g. + `Spool Label`); the URL slug (`spool-label`) is auto-derived and shown as a live read-only + hint. The friendly name is stored as `display_name`, shown in the template list and as the + editor title. Renaming `display_name` after creation is not yet available in the UI. Requires + a container image rebuild. + +- **DK part number in the template list** — the Media column now shows the Brother DK part + number with dimensions (e.g. `DK-1209 (62×29mm)`) instead of the raw media id. Two-color + media gets a `Red` suffix (e.g. `DK-2251 (62mm) Red`). If the media id is not in the catalog, + the raw id is shown as before. Requires a container image rebuild. + +- **Print a template on a different label media at recall time (one-off)** — the recall page + now shows a media selector instead of a read-only badge, defaulting to the template's own + media (e.g. a two-color template defaults to `62red`). Same-width media appear first + (most likely to fit the design without adjustment); a "Loaded in printer" toggle narrows + the list to the roll currently mounted. The stored template media is never mutated. The + chosen media is logged to history and reproduced faithfully on reprint. The Print button is + gated until a fresh preview has been taken after any media change. If the chosen media + doesn't match the roll actually loaded, the printer-status check still blocks with a 409, + but the recall page now offers a "print anyway" confirmation to override it. Requires a + container image rebuild. +- **Mono + red notice on recall** — when a template contains red elements and a mono + (single-color) media is selected, an inline notice explains that red will print in black. + The renderer already maps red → black automatically; no action is needed. +- **Overflow warning on recall** — when a die-cut media is chosen and the content extends + past its printable height, an inline warning appears near the preview. Printing still + proceeds — the user decides from the preview whether to adjust or proceed. + +### Changed + +- **Docs reconciled with shipped features** — the README's "What it does" was rewritten to + cover everything now implemented (two-color printing, printer-status/loaded-media detection, + the label catalog, settings/retention, print-time media override, batch printing, and + `DISABLE_AUTH`), and gained a "Running it" section with a configuration table. The PRD's + in-scope list now includes two shipped features it omitted (one-off media override at recall; + two-color red text in templates), and `architecture.md` was corrected to reference the real + compose filenames (`docker-compose.yml` / `docker-compose.dev.yml`). Docs-only. + +- **Template list actions are now compact icon buttons** — the per-row Print / Edit / Delete + buttons were full-size text buttons that, together with a verbose timestamp, overflowed the + card. They're now small icon buttons (with tooltips and accessible labels), the Updated + column shows a shorter date (no seconds) on a single line, and the table fits within the card + without widening the layout. Requires a container image rebuild. + +- **Adopted `release-prep-and-cut` standard (v1.0.0)** — `/release-prep` and `/release-cut` slash commands added to `.claude/commands/`; publish workflow (`build-and-push.yml`) now fires on `release: published` (tag-push trigger removed); `CLAUDE.md` and `standards.md` updated. Developer/process-facing only — no runtime change. + +### Fixed + +- **"Run cleanup now" works again** — the Settings → History & Retention "Run cleanup now" + button returned **Method Not Allowed**: the frontend posted to `/api/admin/prune-history`, + but that route was never implemented, so the request fell through to the SPA catch-all (a + GET) and 405'd. The endpoint now exists (auth-gated, like the other admin routes) and + `prune_history()` returns the number of jobs removed, so the button reports e.g. "Cleanup + done — 3 job(s) removed." Requires a container image rebuild. + +- **Upgrade now delivers new and corrected default catalog entries** (#16) — upgrading the + container image no longer leaves the operator's `labels.yml` stale. On startup, labelforge + performs a non-destructive 3-way merge: new entries from the bundled default are added, + corrected field values (e.g. `brother_part` SKU fixes) are applied to fields the operator + never customized, and any operator customizations or custom media entries are preserved and + never deleted. A backup is written to `$DATA_DIR/labels.yml.bak` before any change. Opt out + with `CATALOG_AUTO_MERGE=false`. Requires a container image rebuild. + +### Added + +- **`POST /api/admin/reload-catalog`** — re-runs catalog reconciliation and reloads the catalog + from disk without restarting the container. Returns a JSON summary of entries added/updated + and whether the operator file was rewritten. Requires API token. + +### Changed + +- **CI now type-checks the backend with mypy** — `mypy` (≥1.11) and `types-PyYAML` are added to the dev extra; a `[tool.mypy]` section in `pyproject.toml` enables the pydantic plugin and per-module stub overrides for unstubbed third-party libs (`brother_ql`, `qrcode`, `barcode`). Enabling type-checking surfaced a latent Pillow resampling deprecation (`Image.BICUBIC` / `Image.NEAREST` → `Image.Resampling.*`) and tightened several return types. Developer-facing only — no runtime behavior change. + +- **CI: compose validation now targets `docker-compose.yml` / `docker-compose.dev.yml`** — the compose job previously looked for `compose.yml` / `compose.dev.yml` (wrong filenames) and used a `bash -e` one-liner that treated a missing file as a failure. The loop is now hardened to skip absent files and only fail on a bad `docker compose config`. It also seeds a throwaway `.env` from the tracked `.env.example` first, since the compose files reference `env_file: .env` (which is gitignored) and `config` would otherwise fail to resolve it. The `CLAUDE.md` convention note is corrected to match the actual filenames. Backend linting (ruff) is also clean: import order fixed in `routes/print.py`, `datetime.UTC` modernisation in `templates/store.py`, and long-line wraps across several `backend/` files. + +- **Dependabot now targets `dev`, never `main`, and runs monthly** — all four ecosystems (github-actions, pip, npm, docker) in `.github/dependabot.yml` now set `target-branch: dev`, so dependency-bump PRs open against the working branch and only reach `main` through a managed release PR. Previously they defaulted to `main`, cluttering the release queue with PRs that could never be allowed to auto-merge. The version-update cadence is also relaxed from weekly to monthly to suit a low-maintenance released project (security updates, when enabled, are advisory-driven and unaffected by this schedule). Process-only — no runtime change. + +### Added + +- **Load previous values on template recall** — the recall form now has a **Load previous values** button (only for templates with variable fields). Clicking it fills the form with the field values from the last time this template was printed, so you can make quick adjustments without re-typing. The button is disabled when the template has no print history. The most recent print job for each template is now also protected from retention pruning, so these values survive cleanup. Requires a container image rebuild. + +- **Text-color control always visible in template editor** — the Black / Red color dropdown in the toolbar is now visible for all templates, not just two-color media. On mono media the Red option is present but disabled, with a tooltip explaining it requires a two-color label (e.g. 62red); on two-color media Red is selectable as before. Hovering over the Add Text button now also shows a tooltip noting `{fieldname}` placeholder syntax. Requires a container image rebuild. + +### Fixed + +- **Continuous templates now extend to fit large text** — previewing or printing a continuous-roll template (e.g. 62mm endless) where the last text element uses a large font no longer cuts off the bottom of that text. Previously the render trusted the editor's browser-measured font height, which is shorter than what Pillow actually draws at the same point size; the canvas was too short and the last line was clipped. The renderer now measures rasterized text height with Pillow before sizing the canvas. Die-cut template rendering is unchanged. + +- **Template preview no longer fails when the template has variable fields** — clicking Preview in the editor on a template containing `{fieldname}` placeholders previously returned "Missing required field" because the preview route used the same strict field-validation as the print route. The preview route now fills missing fields with their stored default (if any) or the field name itself as a sample value, so `{type}` renders as the literal text `type`. Passing real field values from the recall UI still works and takes precedence. Requires a container image rebuild. + +### Added + +- **Save As in template editor** — toolbar now has a **Save As** button that saves the current canvas first, then opens a modal for a new template slug and label media (pre-filled with the current media). Clones the template via `POST /api/templates/{name}/duplicate` and opens the editor on the copy. This is the documented way to re-use a design on different media; the existing template's media is never mutated. The current media is also shown as a read-only badge next to the template name. Requires a container image rebuild. +- **Full two-color (red) text in templates** — templates on two-color media (`62red` / DK-2251) can now use red text in addition to black. A **Black / Red** toggle appears in the editor toolbar only when the loaded label is two-color (hidden for mono media). Selecting an element and changing the toggle updates its Fabric `fill` to `#000000` or `#ff0000`; new text elements inherit the current selection. The server renderer now emits an RGB image for two-color media, compositing each text element's `fill` as the ink color (red → red plane, black → black plane); lines honor `stroke`, rects honor `fill` and `stroke`. The preview PNG for two-color templates now returns a color image rather than thresholded mono, so the preview reflects actual print output. Printing is unchanged (the print path already promoted L→RGB and passed `red=True` for two-color media). Requires a container image rebuild. + +### Fixed + +- **Template editor canvas aspect on continuous rolls** — opening the editor on a continuous roll (e.g. `62`, `62red`) showed a landscape canvas (696 × 400px for 62mm) because the initial working height was set to 400 dots (~34mm). This made the canvas visually wider than tall, which reads as wrong for a label roll. The default working height is now 1000 dots (~84mm), which gives a portrait display (418 × 600px scaled) for the 62mm roll. Print length is still content-driven server-side. Requires a container image rebuild. + +### 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, including in red on two-color media. + +### Changed + +- **De-adopted the `vexp-context-engine` standard** (now sunset at v3.0.0 — vexp retired homelab-wide). Removed all repo wiring: the `.claude/hooks/vexp-guard.sh` guard hook and `.vexpignore`, the `mcp__vexp__*` permission entries and `PreToolUse` hook in `.claude/settings.json`, the "Context search" operational-rules section in `CLAUDE.md`, and the vexp `.gitignore` block. Coding sessions use normal `grep`/`glob`/`Read` again. Developer/process-facing only — no runtime change. +- **Adopted four crzynet engineering standards**, pinned in a new root `standards.md`: `code-checkin-and-pr @ 1.1.0` (commit messages now use Conventional-Commits prefixes `feat:`/`fix:`/`chore:`/`docs:`; CI gained structured-config and `docker compose config` validation jobs), `handoff-prompt-workflow @ 1.5.0` (completed handoff prompts now archived under `prompts/done/`; `prompts/TEMPLATE.md` added), `repo-sandbox-permissions @ 1.0.0` (repo-wide sandbox in `.claude/settings.json` — auto-approves in-repo work, gates out-of-repo writes and network), and `vexp-context-engine @ 2.1.0` (guard hook now tracked, `.vexpignore` added). Developer/process-facing only — no runtime behavior change. + +### Added + +- **Optional app-level auth (`DISABLE_AUTH`)** — set `DISABLE_AUTH=true` to run with no Bearer-token auth, for deployments fronted by a reverse proxy (e.g. Traefik) that handles authentication. Default is unchanged and secure: auth on, and the app refuses to start without `API_TOKEN`. When disabled, every `/api/*` route is open, `GET /api/health` reports `auth_required: false`, and the web UI skips its token-entry gate. See ADR 2026-06-02. + +### Fixed + +- **Continuous-media templates are now editable** — opening the canvas editor on a continuous roll (e.g. `62`, `62red`) produced a zero-height canvas, because continuous media report a printable length of `0` (endless roll) and the editor used that directly. Elements added to it were invisible (and saved at `top=0`), which looked like "Add Text does nothing." Continuous templates now open at a default working length (~34mm); print length is still derived from content server-side, per the templates design doc. Requires a container image rebuild. +- **Template text now renders and prints** — the canvas editor uses Fabric.js v6, which serializes element types as PascalCase class names (`IText`, `Line`, `Image`); the server renderer and field detection still matched the Fabric v5 lowercase/hyphenated names (`i-text`), so every text element was silently skipped — previews and prints of any template came out blank and no variable fields were detected. Both now normalize the type (lowercase, strip hyphen) and handle v5 and v6 serializations. The editor's font family/size controls (which had the same mismatch) again update the selected text. The frontend fix requires a container image rebuild to take effect; the renderer/field-detection fix is backend-only. — printing to a two-color roll (DK-2251, 62mm black/red continuous, id `62red`) was rejected by the printer as "wrong roll: check the print data" because the job declared mono media. The print path now passes `red=True` and an RGB image to the rasterizer for two-color media, so the job declares the correct media type. Black-only text prints fine on these rolls (the red plane is left empty). +- **Pre-print media check no longer blocks same-size color variants** — the printer doesn't reliably report tape color, so the status read can't tell `62` from `62red` (both 62mm continuous) and was wrongly rejecting `62red` prints with a 409. The check now treats rolls of identical physical dimensions as compatible; mismatched sizes (e.g. 62 vs 29) still block. +- **API error messages no longer show `[object Object]`** — the frontend now surfaces the human-readable `message` from structured `409` errors (media mismatch / printer error) instead of stringifying the whole object. + +### 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 + +- **Color-limitation note on the label picker** — when "Loaded in printer" matches a roll that has both mono and two-color variants of the same size (e.g. `62` / `62red`), a note above the picker explains that the printer doesn't report tape color, so the right variant must be chosen manually. +- **Printer status controls in Settings** — the Settings → Printer section now has a "Status check enabled" toggle and a status-timeout (ms) field, wired to the `printer_status_check` / `printer_status_timeout_ms` settings. These existed only on the backend before, with no way to change them from the UI. +- **Loaded-media filter on label selectors** — the label-media dropdown on Quick Print and the New Template modal now includes a "Show all / Loaded in printer" toggle; switching to Loaded queries the printer once and narrows options to the roll actually mounted (matching mono and two-color variants by dimension). Falls back to the full list with an inline notice if the printer is unreachable, reports no media, or the loaded roll is not in the catalog. +- **Printer status check** — `GET /api/printer/status` returns loaded media, ready state, and errors; print endpoints (`POST /api/print/quick`, `/api/print/{name}`, `/api/print/{name}/batch`) block on media mismatch with a 409 (pass `?override=true` to proceed); Settings page "Test Printer" button shows live printer state. Status queried over raw TCP (ESC i S) with HTTP fallback to the printer's status page; degrades gracefully if unreachable. Controlled by `printer_status_check` and `printer_status_timeout_ms` settings. +- **Print history page** — browse, reprint, pin, and delete past prints at `/history`; paginated reverse-chronological list with authenticated thumbnail previews, template/quick-print labels, field-value display, and filters (by template name, pinned-only toggle). Reprint creates a new job and refreshes the list; a 409 (template or media gone) surfaces the server's error message. Delete requires confirmation. Pin toggle updates inline without a reload. Retention policy configurable in `/settings` (keep forever / last N / last N days); pinned prints are never pruned; "Run cleanup now" triggers an immediate prune. +- **Print history API** — every print is logged with a preview PNG; browse paginated history at `GET /api/history`, fetch full detail at `GET /api/history/{id}`, serve the preview image at `GET /api/history/{id}/preview.png`, reprint a past job at `POST /api/history/{id}/reprint` (creates a new row linked via `reprint_of`), pin/unpin at `POST /api/history/{id}/pin`, and delete at `DELETE /api/history/{id}`. Retention auto-pruning runs at startup and every 6 hours, honoring the `retention_mode` / `retention_count` / `retention_days` settings. Pinned rows are never pruned. +- **Printer-aware label catalog** — each label now carries a `supported` flag computed at catalog load against the configured printer (`PRINTER_MODEL`). Media the printer physically can't print — wide-format rolls on a non-QL-1xxx printer, two-color rolls on a mono printer — appear in the selectors greyed out, disabled, and marked `— unavailable` with a tooltip explaining why, instead of being silently offered. On the default QL-820NWB the six wide-format rolls (`102`, `103`, `104`, `102x51`, `102x152`, `103x164`) are now disabled; `62red` stays selectable. Compatibility is derived from the `brother_ql` library, not hand-maintained — the `printer_requirements` field in `labels.yml` is deprecated and ignored. +- **Brother DK part numbers in label selectors** — the label-media dropdown (quick-print and the new-template modal) now shows the part number with the name, e.g. `DK-2205: 62mm Continuous (Black)`; entries without a part number show the name alone. Backfilled `brother_part` for 9 more default catalog entries (14 of 15 now carry a part number; `52x29` has no consumer DK roll). Grouping/formatting moved into a shared `frontend/src/labels.ts` helper. +- **Template recall UI** — fill variable fields, preview, and print from saved templates at `/templates/{name}/print`; batch printing with auto-increment for numeric fields +- **Templates editor foundation (text-only slice)** — canvas editor at `/templates/{name}` built on Fabric.js 6.6.1: + - Templates list page at `/templates`: shows name, label media, last-updated; Edit and Delete (soft-delete) per row; empty state + - New-template modal: slug-validated name (`^[a-z0-9][a-z0-9-]*$`), label media grouped by form factor (same grouping as quick-print) + - Editor toolbar: Add Text, Delete selected, font family selector (populated from `/api/fonts`), font size input, Preview, Save, Back + - Canvas geometry: internal coordinates are label pixels at print DPI (300 dpi); canvas is displayed scale-to-fit the viewport while all saved coordinates remain in label pixels — the coordinate space the server renderer consumes + - `labelforge_raw_content` custom property: set on every text element at creation and kept in sync on every keystroke; registered via `FabricObject.customProperties` so it survives `canvas.toJSON()` / `loadFromJSON()` round-trips and is available to the server's `detect_fields` and `render_template` + - Save: calls `POST /api/templates` on first save of a new template, `PUT /api/templates/{name}` on subsequent saves; blocks if canvas is empty + - Load: `GET /api/templates/{name}` → `canvas.loadFromJSON()` with custom-prop re-attachment + - Preview button: auto-saves then calls `POST /api/preview/{name}`; shows the server-rendered PNG inline — this is the editor↔server geometry agreement check (text placed in the editor should appear at the same position in the preview PNG) + - Router extended with prefix-route support (`registerPrefix`) for parameterised paths like `/templates/:name` +- `fabric@6.6.1` added to frontend dependencies - Project structure and design documentation - 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, 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 - .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) +- `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 -### Status -- No code yet. Design phase complete. Next: backend skeleton + first end-to-end print path (slice 1). +### 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) -Format for future entries: +### Status -## [version] — YYYY-MM-DD +- 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. -### Added -### Changed -### Deprecated -### Removed -### Fixed -### Security \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index adc1568..14244b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,15 @@ Context for AI coding sessions in this repo. Read this before doing anything. Owner: crzykidd. Personal homelab project, public open source. Single-user app — no multi-user features. +## Build Status + +- **Last shipped:** v0.1.0 (first release) +- **Target for next release:** TBD + +## Standards + +This project implements shared engineering standards from the crzynet `homelab-configs` repo. **Read [`standards.md`](standards.md) at the repo root on session start** whenever the work could touch branching, commits, PRs, handoff prompts, or the sandbox — it pins which standards and versions this repo conforms to. The hard per-session operational rules from those standards are pasted verbatim at the end of this file (Code check-in; Release process). + ## Source of truth The design is in `docs/`. Do not invent features not described there. If a request expands scope, push back and ask whether the PRD should change first. @@ -28,17 +37,16 @@ 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 - **License is GPL-3.0.** Cannot be relaxed (the printer library is GPL-3.0). -- **No SSO** (Authentik, Authelia, etc.). Auth is a single shared secret from `.env`. Do not propose SSO under any circumstances. +- **No SSO** (Authentik, Authelia, etc.). Auth is a single shared secret from `.env`. Do not propose SSO under any circumstances. App-level auth can be disabled with `DISABLE_AUTH=true` (default-off, for deployments fronted by a reverse proxy that handles auth — see ADR 2026-06-02); that is *not* SSO and does not relax this rule. Still no multi-user / accounts. - **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 @@ -54,12 +62,40 @@ From the session prompt that owns this project: - Don't write a 50-line README before there's code - Don't propose features not in the PRD +## Session workflow + +Every task follows: **plan → decide → execute → document**. + +1. **Plan first.** Before writing code, outline what will change and why. For small fixes the plan can be verbal in-session. For larger features, produce a handoff prompt (see below). +2. **Decide: current session or handoff.** If the plan is scoped and the current session has context, do it here. If it's a large feature slice or a fresh context would be cleaner, write a handoff prompt for a new session. +3. **Handoff prompts live in `prompts/`** (checked into git), per the `handoff-prompt-workflow` standard pinned in [`standards.md`](standards.md). The top-level `prompts/` dir is the **live queue** — pending work only. Start new prompts from `prompts/TEMPLATE.md`. Frontmatter: + ```yaml + --- + name: YYYY-MM-DD-short-description + status: pending # pending | completed | failed + created: YYYY-MM-DD + model: # opus = research/planning, sonnet = coding + completed: # filled when done + result: # one-line summary of outcome + --- + ``` + Before making edits, run `git status --porcelain` and cross-reference the files the plan touches; if any overlap with uncommitted work, list them and ask before touching. The **last instruction** in every handoff prompt: update its own frontmatter (status / completed / result), then `git mv` it into `prompts/done/` (success) or `prompts/failed/` (failure) — created lazily on first use. Record non-obvious decisions (approach changed, alternative rejected, workaround needed) in `docs/decisions.md` as an ADR entry. +4. **To run a handoff prompt** — the moment you create one, hand the user this exact command (file-path form, never inlined `cat`): + ``` + claude --model "Read prompts/.md and execute it as your task." + ``` + `` matches the prompt's `model:` field (opus = research/planning, sonnet = coding; omit to use the default). Run from the repo root so the relative path resolves. +5. **Changelog entry required.** Every change — feature, fix, refactor — gets a short entry in `CHANGELOG.md` under `## [Unreleased]`. Write it for release notes (concise, user-facing language). +6. **All dev work on `dev`** unless explicitly told otherwise. +7. **Commit, don't push.** Sessions commit their work with a descriptive message. The owner pushes. +8. **Planning prompts for large features.** The owner will ask for a planning session prompt when scoping a new feature block. That prompt gets handed to a fresh session to execute. + ## 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. -- 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`. +- 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 with a Conventional-Commits prefix — `feat:` (user-facing feature), `fix:` (bug fix), `chore:` (config/tooling/deps), `docs:` (docs only). E.g. `feat: add template recall endpoint`. No co-author tags. See the **Code check-in (operational rules)** section below for the full rule set. +- Compose stack lives at the repo root as `docker-compose.yml`. Dev compose at `docker-compose.dev.yml`. ## Things to never do @@ -71,3 +107,74 @@ From the session prompt that owns this project: - Don't suggest hosted/SaaS replacements for any component - Don't write giant explainer comments in code — code should be readable; comments only for non-obvious *why* - Don't generate `package.json` / `pyproject.toml` / `Dockerfile` until the relevant slice has been scoped + + + +## Code check-in (operational rules) + +This project adopts the `code-checkin-and-pr` standard. The full why-and-how lives at +the source above; the rules below are the per-session do/don'ts a coding agent must +honor by default: + +- **Never push directly to `main`.** `main` is protected. All changes land via a pull + request from `dev` → `main`, and only when every required check is green. +- **Day-to-day work happens on `dev`** (or a short-lived branch off `dev`). Push to + `dev` freely. +- **Commit message prefixes are required** — Conventional-Commits style: + - `feat:` — new user-facing feature + - `fix:` — bug fix + - `chore:` — config, tooling, dependencies, maintenance + - `docs:` — documentation-only changes +- **Do not add `Co-authored-by:` trailers** unless the user explicitly asks. +- **Doc updates ship in the same commit as the code they describe** — never as a + follow-up commit. +- **Never bypass hooks** (no `--no-verify`, `--no-gpg-sign`, etc.) unless the user + explicitly asks. If a hook fails, fix the underlying issue. +- **Stable releases are tagged from `main` only.** Don't tag from `dev`. + +If you're unsure whether an action would violate one of the above, stop and ask before +acting. + + + +## Release process (operational rules) + +This project adopts the `release-prep-and-cut` standard. The full why-and-how +lives at the source above; the rules below are the per-session do/don'ts a +coding agent must honor by default: + +- **The version is stored BARE in the source-of-truth file** — no `v` prefix + anywhere in code. The `v` prefix is added in exactly one place: the git tag + and matching GitHub release name. Don't add it to README badges, CHANGELOG + headers, in-code image tags, or anywhere else. +- **`CHANGELOG.md` is the single source of truth for release notes.** The PR + description (set by `/release-prep`) and the GitHub release body (set by + `/release-cut`) reuse the **same section verbatim**. Never author release + notes twice. +- **One commit per release prep.** Version bump + changelog roll + every doc + sync ship in a single `chore(release): prepare v` commit. No + `Co-authored-by:` trailers. +- **Never re-tag.** If `v` already exists as a local tag, a remote + tag, or a GitHub release, STOP. Never delete-and-recreate; never `--force`. + Pick the next version instead. +- **`/release-cut` only after the PR has merged and CI is green.** The + publish-to-`main` workflow must have already pushed `:latest` images to the + registry before `/release-cut` runs. If you cannot confirm both — STOP and + tell the user to wait. +- **The release tag is the only thing the cut command writes to `main`.** Both + the prep commit and any follow-up docs commit land on `dev` and reach `main` + only via PR. Never push directly to `main` as part of a release. + +If you're unsure whether an action would violate one of the above, stop and +ask before acting. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5c84282 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +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 + +# 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 +COPY --from=frontend /app/frontend/dist /app/frontend/dist + +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/README.md b/README.md index 64affc5..1625a91 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,111 @@ Self-hosted web app for designing, saving, and printing labels to Brother QL series printers. -**Status**: Early development. Not yet usable. +**Status**: First release (v0.1.0) — all v1 features are working and the app is packaged as a single Docker image. + +**Version:** 0.1.0 + +## What's New + +### v0.1.0 (2026-06-06) + +First release. The full v1 feature set is here: quick-print, a Fabric.js canvas +template editor with variable `{placeholder}` fields, template recall with batch/increment +printing, two-color (black + red) printing on DK-2251, print history (reprint, pin, delete, +configurable retention), a hybrid `labels.yml` catalog with friendly names and Brother DK part +numbers, printer-status detection with a media-mismatch override, one-off media override at +recall, a settings UI, and a full Bearer-token HTTP API (every template callable for homelab +integrations). See [`CHANGELOG.md`](CHANGELOG.md) for the complete list. ## What it does -- Quick-print mode (text + font, like brother_ql_web) -- Save labels as named templates with variable fields -- Recall templates, fill variables, print -- Full HTTP API — every template is callable for homelab integrations -- Print history with reprint and pinning -- Freeform canvas editor (text, QR codes, barcodes, images, shapes) +- **Quick-print mode** — type text, pick a font/size/alignment, print (like brother_ql_web) +- **Named templates** — design a label on a freeform canvas (text, QR codes, barcodes, + images, lines, rectangles) and save it under a friendly name +- **Variable fields** — `{placeholder}` syntax in element text auto-generates a fill-in form + on recall; numeric fields support increment/batch printing +- **Two-color printing** — black + red on two-color media (DK-2251 / `62red`); red maps to + black automatically on mono media +- **Print history** — browse, reprint, pin, and delete past prints, with thumbnails and + configurable retention (keep forever / last N / last N days); pinned prints are never pruned +- **Label catalog** — `labels.yml` merges the printer library's truth with editable UX + metadata (friendly names, Brother DK part numbers); media the printer can't handle is + shown disabled +- **Printer status** — auto-detect the loaded roll and warn (or block) on a media mismatch, + with a print-anyway override +- **Print on different media at recall** — one-off print of a template on another label size + without mutating the saved template +- **Settings page** — retention policy, default media, and printer-status options, all editable + in the UI +- **Full HTTP API** — every template is callable via `POST /api/print/{name}` for homelab + integrations (Home Assistant, etc.); auth is a single Bearer token, or disable app-level + auth entirely behind a trusted reverse proxy + +## 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. + +## Running it + +The app ships as a single Docker image (multi-stage build: frontend → static assets served +by FastAPI). Copy `.env.example` to `.env`, fill in the values, and start the stack: + +``` +cp .env.example .env # set API_TOKEN and PRINTER_HOST at minimum +docker compose up -d # uses docker-compose.yml +``` + +For local development with hot-reload, use `docker-compose.dev.yml`. + +### Configuration + +All config is environment-driven (see `.env.example` for the full list). The essentials: + +| Variable | Default | Purpose | +| --- | --- | --- | +| `API_TOKEN` | _(required)_ | Bearer token for all `/api/*` routes. App refuses to start without it unless `DISABLE_AUTH=true`. | +| `PRINTER_HOST` | _(required)_ | IP/hostname of the Brother QL printer. | +| `PRINTER_MODEL` | `QL-820NWB` | Must match a `brother_ql` model id. Drives label-catalog compatibility. | +| `PRINTER_BACKEND` | `network` | `network` \| `linux_kernel` \| `pyusb`. | +| `DEFAULT_LABEL_MEDIA` | `62` | Pre-selected media in the UI. | +| `DATA_DIR` | `/data` | Where SQLite, `labels.yml`, fonts, and previews live in the container. | +| `DISABLE_AUTH` | `false` | Run with no app-level auth (for a reverse proxy that handles auth). Not SSO; still single-user. | +| `CATALOG_AUTO_MERGE` | `true` | On startup, non-destructively merge new/corrected default catalog entries into the operator's `labels.yml` (a `.bak` is written first). | +| `LOG_LEVEL` | `INFO` | `DEBUG` \| `INFO` \| `WARNING` \| `ERROR`. | + +Persistent data lives under `$DATA_DIR`; back it with a named volume or bind mount. The +interactive API docs are at `/docs`. ## Design docs 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..155b7f0 --- /dev/null +++ b/backend/labelforge/catalog/loader.py @@ -0,0 +1,137 @@ +import logging +from pathlib import Path + +import yaml +from brother_ql.labels import ALL_LABELS, FormFactor +from brother_ql.models import ALL_MODELS + +from labelforge.config import settings +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 _compute_compatibility( + lib_label, printer_model: str, two_color: bool | None +) -> tuple[bool, str | None]: + """Whether the configured printer can print this media, and why not. + + Library-derived (not from labels.yml). brother_ql's Label.works_with_model() + is unusable in the pinned fork: it raises NameError on any restricted label + and ignores two-color capability — see docs/decisions.md. Compute from the + primitive Label fields instead. + + `two_color is None` means the configured model wasn't found in the library; + treat all media as supported rather than wrongly disabling everything. + """ + if two_color is None: + return True, None + restricted = list(getattr(lib_label, "restricted_to_models", []) or []) + if restricted and printer_model not in restricted: + return False, "Requires a wide-format printer (QL-1100 series)" + if getattr(lib_label, "color", 0) and not two_color: + return False, "Requires a two-color printer (QL-800 series)" + return True, None + + +def load_catalog(yml_path: Path) -> None: + global _catalog + + lib_labels = {label.identifier: label for label in ALL_LABELS} + + # Compatibility is computed against the configured printer at load time + # (printer_model is fixed in config). POST /api/admin/reload-catalog calls + # this again to recompute. + printer_model = settings.printer_model + model = next((m for m in ALL_MODELS if m.identifier == printer_model), None) + if model is None: + logger.warning( + "Configured printer_model '%s' not in brother_ql — treating all media as supported", + printer_model, + ) + two_color = model.two_color if model is not None else None + + 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]) + restricted = list(getattr(lib_label, "restricted_to_models", []) or []) + color = int(getattr(lib_label, "color", 0) or 0) + supported, reason = _compute_compatibility(lib_label, printer_model, two_color) + + 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)), + common_use=y.get("common_use") or [], + preview_image=y.get("preview_image"), + dots_printable=dots, + tape_size=tape, + form_factor=form_factor_int, + restricted_to_models=restricted, + color=color, + supported=supported, + incompatible_reason=reason, + ) + else: + entry = LabelEntry( + id=lib_id, + display_name=lib_id, + dots_printable=dots, + tape_size=tape, + form_factor=form_factor_int, + restricted_to_models=restricted, + color=color, + supported=supported, + incompatible_reason=reason, + ) + 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/catalog/reconcile.py b/backend/labelforge/catalog/reconcile.py new file mode 100644 index 0000000..f23cc3d --- /dev/null +++ b/backend/labelforge/catalog/reconcile.py @@ -0,0 +1,189 @@ +import logging +import shutil +from pathlib import Path + +import yaml + +logger = logging.getLogger(__name__) + + +def merge_catalog(operator: dict, old_default: dict, new_default: dict) -> tuple[dict, list[str]]: + """3-way merge of labels.yml documents keyed by entry id. + + Returns (merged_document, change_log_lines). Operator always wins on conflict. + + Rules: + - New entry (id in new_default, not in operator): added verbatim. + - Existing entry: for each field in new_default, take the new value only when + the operator never customized it (op_val == old_val). Otherwise keep op_val. + - Operator-only entry: kept as-is regardless of what the default says. + """ + + def _entries(doc: dict) -> list[dict]: + return [e for e in (doc.get("labels") or []) if isinstance(e, dict) and "id" in e] + + op_by_id = {e["id"]: e for e in _entries(operator)} + old_by_id = {e["id"]: e for e in _entries(old_default)} + new_by_id = {e["id"]: e for e in _entries(new_default)} + + changes: list[str] = [] + merged_labels: list[dict] = [] + + # Pass 1: operator's entries in their original order + for op_entry in _entries(operator): + entry_id = op_entry["id"] + + if entry_id not in new_by_id: + # Operator-only or removed-from-default: keep as-is, never delete + merged_labels.append(dict(op_entry)) + continue + + merged = dict(op_entry) + old_entry = old_by_id.get(entry_id, {}) + new_entry = new_by_id[entry_id] + + for field, new_val in new_entry.items(): + if field == "id": + continue + old_val = old_entry.get(field) + op_val = op_entry.get(field) + if op_val == old_val and new_val != op_val: + merged[field] = new_val + changes.append(f"updated {entry_id}.{field}") + + merged_labels.append(merged) + + # Pass 2: brand-new entries from new_default not present in operator + op_ids = set(op_by_id) + for new_entry in _entries(new_default): + if new_entry["id"] not in op_ids: + merged_labels.append(dict(new_entry)) + changes.append(f"added {new_entry['id']}") + + return {"labels": merged_labels}, changes + + +def reconcile_catalog_files( + default_path: Path, + yml_path: Path, + baseline_path: Path, + auto_merge: bool = True, +) -> dict: + """Reconcile the bundled default catalog into the operator's copy. + + Called at startup and from the reload-catalog admin endpoint. + Returns a summary dict: {wrote, added, updated, backed_up, reason}. + Never raises — callers log and continue on failure. + """ + summary: dict = {"wrote": False, "added": 0, "updated": 0, "backed_up": False, "reason": ""} + + if not default_path.exists(): + summary["reason"] = f"no default at {default_path}" + return summary + + default_bytes = default_path.read_bytes() + + # Case 1: First run — no operator file yet + if not yml_path.exists(): + shutil.copy(default_path, yml_path) + baseline_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(default_path, baseline_path) + summary["reason"] = "first-run: copied default to operator file and baseline" + logger.info("First-run: copied %s → %s and %s", default_path, yml_path, baseline_path) + return summary + + # Case 2: Operator file exists but no baseline (upgrading to this feature for the first time) + if not baseline_path.exists(): + if auto_merge: + with yml_path.open() as fh: + op_doc = yaml.safe_load(fh) or {} + with default_path.open() as fh: + new_doc = yaml.safe_load(fh) or {} + + op_ids = { + e["id"] for e in (op_doc.get("labels") or []) if isinstance(e, dict) and "id" in e + } + new_entries = [ + e + for e in (new_doc.get("labels") or []) + if isinstance(e, dict) and "id" in e and e["id"] not in op_ids + ] + + if new_entries: + bak = yml_path.parent / (yml_path.name + ".bak") + shutil.copy(yml_path, bak) + summary["backed_up"] = True + op_doc.setdefault("labels", []).extend(new_entries) + yml_path.write_text(yaml.safe_dump(op_doc, sort_keys=False, allow_unicode=True)) + summary["added"] = len(new_entries) + summary["wrote"] = True + logger.info( + "No-baseline transition: added %d new catalog entries. " + "Field-level corrections apply on the next default change. Backup: %s", + len(new_entries), + bak, + ) + else: + logger.info( + "No-baseline transition: no new catalog entries to add. " + "Field-level corrections will apply on the next default change." + ) + else: + logger.info( + "CATALOG_AUTO_MERGE=false: no baseline present; " + "updated default is available but not applied" + ) + + baseline_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(default_path, baseline_path) + summary["reason"] = ( + f"no-baseline transition: {summary['added']} entries added, baseline written" + ) + return summary + + # Case 3: Baseline exists — check whether the default has changed + baseline_bytes = baseline_path.read_bytes() + if default_bytes == baseline_bytes: + summary["reason"] = "default unchanged, no-op" + return summary + + if not auto_merge: + logger.info("CATALOG_AUTO_MERGE=false: updated default catalog detected but not applied") + summary["reason"] = "auto_merge disabled, update detected but not applied" + return summary + + # Default changed — run full 3-way merge + with yml_path.open() as fh: + op_doc = yaml.safe_load(fh) or {} + with baseline_path.open() as fh: + old_doc = yaml.safe_load(fh) or {} + with default_path.open() as fh: + new_doc = yaml.safe_load(fh) or {} + + merged_doc, changes = merge_catalog(op_doc, old_doc, new_doc) + + added = sum(1 for c in changes if c.startswith("added ")) + updated_fields = sum(1 for c in changes if c.startswith("updated ")) + + bak = yml_path.parent / (yml_path.name + ".bak") + shutil.copy(yml_path, bak) + summary["backed_up"] = True + logger.info("Backed up operator labels.yml → %s", bak) + + yml_path.write_text(yaml.safe_dump(merged_doc, sort_keys=False, allow_unicode=True)) + shutil.copy(default_path, baseline_path) + + summary["wrote"] = True + summary["added"] = added + summary["updated"] = updated_fields + summary["reason"] = f"merged: {added} entries added, {updated_fields} fields updated" + + for line in changes: + logger.info("Catalog reconcile: %s", line) + logger.info( + "Catalog reconcile complete: %d entries added, %d fields updated", + added, + updated_fields, + ) + + return summary diff --git a/backend/labelforge/config.py b/backend/labelforge/config.py new file mode 100644 index 0000000..6034bd4 --- /dev/null +++ b/backend/labelforge/config.py @@ -0,0 +1,33 @@ +from pathlib import Path + +from pydantic import model_validator +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # When DISABLE_AUTH=true the app runs with no app-level auth (intended for + # deployments fronted by a reverse proxy that handles auth, e.g. Traefik). + # Default is secure: auth on, and the app refuses to start without a token. + disable_auth: bool = False + 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("/data") + default_label_media: str = "62" + log_level: str = "INFO" + catalog_auto_merge: bool = True + + model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} + + @model_validator(mode="after") + def _require_token_unless_disabled(self) -> "Settings": + if not self.disable_auth and not self.api_token: + raise ValueError( + "API_TOKEN is required (set DISABLE_AUTH=true to run without app-level auth)" + ) + return self + + +settings = Settings() diff --git a/backend/labelforge/db.py b/backend/labelforge/db.py new file mode 100644 index 0000000..aeea210 --- /dev/null +++ b/backend/labelforge/db.py @@ -0,0 +1,58 @@ +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 +); + +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 +); +""" + + +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 _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") + if "reprint_of" not in existing: + conn.execute("ALTER TABLE print_jobs ADD COLUMN reprint_of INTEGER 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) + _migrate_print_jobs(conn) + conn.close() diff --git a/backend/labelforge/history.py b/backend/labelforge/history.py new file mode 100644 index 0000000..57fda1e --- /dev/null +++ b/backend/labelforge/history.py @@ -0,0 +1,174 @@ +import io +import json +import logging + +from PIL import Image + +from labelforge import settings_store +from labelforge.config import settings +from labelforge.db import get_connection +from labelforge.printer.client import to_print_bitmap + +logger = logging.getLogger(__name__) + + +def insert_job_with_preview( + image: Image.Image, + payload_json: str, + label_media: str, + template_name: str | None = None, + field_values: dict | None = None, + batch_id: str | None = None, + reprint_of: int | None = None, +) -> int: + db_path = settings.data_dir / "data" / "app.db" + conn = get_connection(db_path) + try: + cursor = conn.execute( + """INSERT INTO print_jobs + (template_id, payload_json, label_media, field_values, batch_id, reprint_of) + VALUES (?, ?, ?, ?, ?, ?)""", + ( + template_name, + payload_json, + label_media, + json.dumps(field_values) if field_values is not None else None, + batch_id, + reprint_of, + ), + ) + conn.commit() + job_id = cursor.lastrowid + assert job_id is not None + finally: + conn.close() + + _save_preview(job_id, image) + return job_id + + +def _save_preview(job_id: int, image: Image.Image) -> None: + try: + previews_dir = settings.data_dir / "label-previews" + previews_dir.mkdir(parents=True, exist_ok=True) + buf = io.BytesIO() + to_print_bitmap(image).save(buf, format="PNG") + (previews_dir / f"{job_id}.png").write_bytes(buf.getvalue()) + + db_path = settings.data_dir / "data" / "app.db" + conn = get_connection(db_path) + try: + conn.execute( + "UPDATE print_jobs SET preview_path = ? WHERE id = ?", + (f"{job_id}.png", job_id), + ) + conn.commit() + finally: + conn.close() + except Exception: + logger.warning("Failed to save preview for job %d", job_id, exc_info=True) + + +def get_latest_field_values(template_name: str) -> tuple[dict | None, str | None]: + """Return (field_values, created_at) for the newest print of template_name, or (None, None).""" + db_path = settings.data_dir / "data" / "app.db" + conn = get_connection(db_path) + try: + row = conn.execute( + """SELECT field_values, created_at FROM print_jobs + WHERE template_id = ? AND field_values IS NOT NULL + ORDER BY created_at DESC, id DESC LIMIT 1""", + (template_name,), + ).fetchone() + finally: + conn.close() + if row is None: + return None, None + return json.loads(row["field_values"]), row["created_at"] + + +def prune_history() -> int: + """Prune history per the retention policy. Returns the number of jobs removed.""" + try: + mode = settings_store.get("retention_mode") + if mode == "forever": + return 0 + + db_path = settings.data_dir / "data" / "app.db" + conn = get_connection(db_path) + try: + total = conn.execute("SELECT COUNT(*) FROM print_jobs").fetchone()[0] + + # Newest job per template is always kept so recall pre-fill survives pruning. + _keep_latest = ( + "id NOT IN (SELECT MAX(id) FROM print_jobs" + " WHERE template_id IS NOT NULL GROUP BY template_id)" + ) + + if mode == "last_n": + count = settings_store.get("retention_count") + rows = conn.execute( + f"""SELECT id, preview_path FROM print_jobs + WHERE pinned = 0 + AND id NOT IN ( + SELECT id FROM print_jobs WHERE pinned = 0 + ORDER BY created_at DESC, id DESC + LIMIT ? + ) + AND {_keep_latest}""", # noqa: S608 + (count,), + ).fetchall() + elif mode == "last_days": + days = settings_store.get("retention_days") + rows = conn.execute( + f"""SELECT id, preview_path FROM print_jobs + WHERE pinned = 0 + AND created_at < datetime('now', ?) + AND {_keep_latest}""", # noqa: S608 + (f"-{days} days",), + ).fetchall() + else: + return 0 + + if not rows: + return 0 + + ids = [r["id"] for r in rows] + preview_paths = [r["preview_path"] for r in rows if r["preview_path"]] + pruned = len(ids) + db_size_before = db_path.stat().st_size if db_path.exists() else 0 + + conn.execute( + f"DELETE FROM print_jobs WHERE id IN ({','.join('?' * pruned)})", # noqa: S608 + ids, + ) + conn.commit() + + previews_dir = settings.data_dir / "label-previews" + for fname in preview_paths: + try: + (previews_dir / fname).unlink(missing_ok=True) + except Exception: + logger.warning("Could not delete preview file %s", fname, exc_info=True) + + if total > 0 and pruned / total > 0.1: + logger.info("Running VACUUM after pruning %.0f%% of rows", 100 * pruned / total) + try: + conn.execute("VACUUM") + except Exception: + logger.warning("VACUUM failed", exc_info=True) + + db_size_after = db_path.stat().st_size if db_path.exists() else 0 + logger.info( + "prune_history: pruned %d rows (mode=%s), DB %d → %d bytes", + pruned, + mode, + db_size_before, + db_size_after, + ) + return pruned + finally: + conn.close() + except Exception: + logger.error("prune_history failed", exc_info=True) + return 0 diff --git a/backend/labelforge/logutil.py b/backend/labelforge/logutil.py new file mode 100644 index 0000000..9ebe628 --- /dev/null +++ b/backend/labelforge/logutil.py @@ -0,0 +1,11 @@ +"""Helpers for safely logging user-influenced values.""" + + +def scrub(value: object) -> str: + """Strip CR/LF from a value before it is interpolated into a log line. + + A user-controlled value written verbatim into a log can forge new log + entries (CWE-117 / log injection) by smuggling newlines. Removing the + line-break characters neutralises that without otherwise altering the value. + """ + return str(value).replace("\r\n", "").replace("\r", "").replace("\n", "") diff --git a/backend/labelforge/main.py b/backend/labelforge/main.py new file mode 100644 index 0000000..2350ab5 --- /dev/null +++ b/backend/labelforge/main.py @@ -0,0 +1,120 @@ +import asyncio +import logging +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from labelforge import history as history_module +from labelforge.catalog.loader import load_catalog +from labelforge.catalog.reconcile import reconcile_catalog_files +from labelforge.config import settings +from labelforge.db import init_db +from labelforge.render.fonts import load_fonts +from labelforge.routes import admin as admin_router +from labelforge.routes import fonts, health, labels +from labelforge.routes import history as history_router +from labelforge.routes import preview as preview_router +from labelforge.routes import print as print_router +from labelforge.routes import printer as printer_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 + + +@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" + baseline_path = data_dir / "data" / "labels.default.yml" + default_yml = Path("/app/labels.yml") + try: + summary = reconcile_catalog_files( + default_yml, + yml_path, + baseline_path, + auto_merge=settings.catalog_auto_merge, + ) + logger.info("Catalog reconcile: %s", summary["reason"]) + except Exception: + logger.error("Catalog reconcile failed — loading existing file as-is", exc_info=True) + + 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") + + try: + history_module.prune_history() + except Exception: + logger.error("Startup retention pruning failed", exc_info=True) + + async def _retention_loop() -> None: + while True: + await asyncio.sleep(6 * 3600) + try: + history_module.prune_history() + except Exception: + logger.error("Scheduled retention pruning failed", exc_info=True) + + retention_task = asyncio.create_task(_retention_loop()) + + yield + + retention_task.cancel() + try: + await retention_task + except asyncio.CancelledError: + # Expected: the task we just cancelled re-raises CancelledError on await. + pass + + +app = FastAPI( + title="labelforge", + version="0.0.1", + description="Self-hosted Brother QL label printer API", + lifespan=lifespan, +) + +app.include_router(admin_router.router, prefix="/api") +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") +app.include_router(preview_router.router, prefix="/api") +app.include_router(printer_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") +app.include_router(history_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") diff --git a/backend/labelforge/models/__init__.py b/backend/labelforge/models/__init__.py new file mode 100644 index 0000000..2f4a604 --- /dev/null +++ b/backend/labelforge/models/__init__.py @@ -0,0 +1,139 @@ +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 + 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 + # models this media is restricted to ([] = works on all); from brother_ql + restricted_to_models: list[str] = [] + # 1 = two-color media (needs a two-color printer), 0 = mono + color: int = 0 + # whether the configured printer can print this media (computed at load) + supported: bool = True + # human-readable reason when supported is False + incompatible_reason: str | None = None + + +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 + + +# ── 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] = {} + label_media: str | None = None # None = use the template's stored media + + +class BatchPrintRequest(BaseModel): + labels: list[dict[str, str]] + label_media: str | None = None # None = use the template's stored media + + +class BatchJobResult(BaseModel): + job_id: int + status: str + + +class BatchPrintResponse(BaseModel): + batch_id: str + jobs: list[BatchJobResult] + succeeded: int + failed: int + + +# ── History ─────────────────────────────────────────────────────────────────── + + +class HistoryItem(BaseModel): + id: int + template_id: str | None + is_quick_print: bool + field_values: dict | None + label_media: str + pinned: bool + created_at: str + reprint_of: int | None + batch_id: str | None + preview_url: str + + +class HistoryDetail(HistoryItem): + payload_json: dict | None + + +class PinRequest(BaseModel): + pinned: bool 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..01abdca --- /dev/null +++ b/backend/labelforge/printer/client.py @@ -0,0 +1,296 @@ +# 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 +# brother_ql.backends.helpers.backend_factory — returns {"backend_class": ..., ...} +# brother_ql.reader.interpret_response — parses 32-byte status reply into dict +# brother_ql.labels.ALL_LABELS — list of Label objects; +# .tape_size, .identifier, .color +# +# 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". +# +# get_printer() raises NotImplementedError for backend="network" (library design). +# Bypass by instantiating BrotherQLBackendNetwork directly via backend_factory. + +import html.parser +import logging +import re +import urllib.request + +from brother_ql.backends.helpers import backend_factory, send +from brother_ql.conversion import convert +from brother_ql.labels import ALL_LABELS +from brother_ql.raster import BrotherQLRaster +from brother_ql.reader import interpret_response +from PIL import Image + +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 + + +class StatusUnavailable(Exception): + pass + + +def _media_id_from_dims(width_mm: int, length_mm: int, tape_color_raw: int | None) -> str | None: + matches = [lbl for lbl in ALL_LABELS if lbl.tape_size == (width_mm, length_mm)] + if not matches: + return None + if len(matches) == 1: + return matches[0].identifier + # Multiple labels share the same tape_size (e.g. "62" and "62red" are both (62, 0)). + # Tape color is NOT recoverable: Brother's status spec leaves bytes 24-31 reserved + # (00h) and exposes no color field, so tape_color_raw is meaningless here. We default + # to mono "62"; the user selects "62red" manually and media_compatible() treats + # same-dimension rolls as compatible. See docs/decisions.md (2026-05-31, two-color DK). + _ = tape_color_raw # always 00h per spec — kept for signature stability + return "62" + + +def _color_capable(media_id: str | None) -> bool: + if media_id is None: + return False + lbl = next((lbl for lbl in ALL_LABELS if lbl.identifier == media_id), None) + return bool(lbl and int(getattr(lbl, "color", 0) or 0)) + + +class _StatusPageParser(html.parser.HTMLParser): + """Extract dt/dd key-value pairs from the printer's status.html page.""" + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.pairs: dict[str, str] = {} + self._in_dt = False + self._in_dd = False + self._cur_dt: str | None = None + self._buf: str = "" + + def handle_starttag(self, tag: str, attrs: list) -> None: + if tag == "dt": + self._in_dt = True + self._in_dd = False + self._buf = "" + elif tag == "dd": + self._in_dd = True + self._in_dt = False + self._buf = "" + + def handle_endtag(self, tag: str) -> None: + if tag == "dt": + self._cur_dt = self._buf.strip() + self._in_dt = False + elif tag == "dd": + if self._cur_dt: + self.pairs[self._cur_dt] = self._buf.strip() + self._cur_dt = None + self._in_dd = False + + def handle_data(self, data: str) -> None: + if self._in_dt or self._in_dd: + self._buf += data + + +def media_compatible(loaded_id: str, expected_id: str) -> bool: + """Return True when the loaded media can print the expected layout. + + Color-variant rolls of identical physical size (e.g. "62" and "62red", both + 62mm continuous) are indistinguishable from the status read's dimensions + alone — the printer doesn't reliably report tape color over TCP/HTTP — so we + don't block on the color guess. The printer itself is the final authority on + a true media mismatch. Differing physical sizes (e.g. 62 vs 29) still block. + """ + if loaded_id == expected_id: + return True + loaded = next((lbl for lbl in ALL_LABELS if lbl.identifier == loaded_id), None) + expected = next((lbl for lbl in ALL_LABELS if lbl.identifier == expected_id), None) + if loaded is not None and expected is not None and loaded.tape_size == expected.tape_size: + return True + return False + + +def status_read(host: str, backend: str, timeout_ms: int = 2000) -> dict: + """Query printer status over TCP (primary) or HTTP (fallback). + + Returns: + { + "ready": bool, + "model": str | None, + "media_id": str | None, # e.g. "62", "62x29", "62red" + "width_mm": int | None, + "length_mm": int | None, # 0 for continuous + "color_capable": bool, + "errors": list[str], + "source": "tcp" | "http", + } + Raises StatusUnavailable if both paths fail or backend != "network". + """ + if backend != "network": + raise StatusUnavailable( + f"Printer status check only supported for network backend (got '{backend}')" + ) + + timeout_s = timeout_ms / 1000 + raw: bytes = b"" + width_mm: int | None = None + length_mm: int | None = None + + # Primary: raw TCP ESC i S (may return empty on some firmware — HTTP fallback handles it) + try: + be = backend_factory("network") + printer = None + try: + printer = be["backend_class"](f"tcp://{host}") + printer.read_timeout = timeout_s + printer.s.settimeout(timeout_s) + printer.write(b"\x1b\x69\x53") + raw = printer.read(32) + finally: + if printer is not None: + try: + printer.s.close() + except Exception as exc: + logger.debug("Failed to close printer socket cleanly: %s", exc) + except Exception as exc: + logger.debug("TCP status path failed: %s", exc) + + # Diagnostic: dumps the raw ESC i S status response as hex. Only emitted when + # LOG_LEVEL=DEBUG. Handy for inspecting undocumented bytes (e.g. probing + # whether a byte encodes tape color); see docs/features/printer-status.md. + logger.debug("TCP status raw (%d bytes): %s", len(raw), raw.hex()) + if len(raw) >= 32: + try: + parsed = interpret_response(raw) + width_mm = int(parsed.get("media_width", 0) or 0) + length_mm = int(parsed.get("media_length", 0) or 0) + tape_color_raw = raw[24] + media_id = _media_id_from_dims(width_mm, length_mm, tape_color_raw) + errors_list = [str(e) for e in (parsed.get("errors") or [])] + return { + "ready": not errors_list, + "model": str(parsed.get("model") or "") or None, + "media_id": media_id, + "width_mm": width_mm or None, + "length_mm": length_mm, + "color_capable": _color_capable(media_id), + "errors": errors_list, + "source": "tcp", + } + except Exception as exc: + logger.debug("TCP response parse failed: %s", exc) + + # Fallback: HTTP status page (unauthenticated, no readback limitation) + try: + url = f"http://{host}/general/status.html" + with urllib.request.urlopen(url, timeout=timeout_s) as resp: + html_content = resp.read().decode("utf-8", errors="replace") + + parser = _StatusPageParser() + parser.feed(html_content) + + device_status = parser.pairs.get("Device Status", "") + media_type_str = parser.pairs.get("Media Type", "") + ready = "READY" in device_status.upper() + + width_mm = None + length_mm = None + if media_type_str: + m = re.search(r"(\d+)mm\s*x\s*(\d+)mm", media_type_str, re.IGNORECASE) + if m: + width_mm = int(m.group(1)) + length_mm = int(m.group(2)) + else: + m2 = re.search(r"(\d+)mm", media_type_str, re.IGNORECASE) + if m2: + width_mm = int(m2.group(1)) + length_mm = 0 + + media_id = ( + _media_id_from_dims(width_mm, length_mm, None) + if width_mm is not None and length_mm is not None + else None + ) + return { + "ready": ready, + "model": None, + "media_id": media_id, + "width_mm": width_mm, + "length_mm": length_mm, + "color_capable": _color_capable(media_id), + "errors": [], + "source": "http", + } + except Exception as exc: + logger.debug("HTTP status path failed: %s", exc) + raise StatusUnavailable(f"Printer did not respond: {exc}") from exc + + +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) + # Two-color media (e.g. "62red" / DK-2251) must be printed with red=True + # even for black-only text: the job has to declare two-color media or the + # printer rejects it as "wrong roll: check the print data". convert() reads + # the red plane from an RGB image, so promote L→RGB; a black-on-white image + # simply leaves the red plane empty. + red = _color_capable(label_media) + img = image.convert("RGB") if red else image + # 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, [img], label_media, cut=True, rotate="0", threshold=PRINT_THRESHOLD, red=red + ) + 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..988cdbd --- /dev/null +++ b/backend/labelforge/render/fonts.py @@ -0,0 +1,79 @@ +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/template.py b/backend/labelforge/render/template.py new file mode 100644 index 0000000..0663133 --- /dev/null +++ b/backend/labelforge/render/template.py @@ -0,0 +1,372 @@ +import io +import logging +import math + +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 _canvas_color_to_rgb(color: str | None) -> tuple[int, int, int] | None: + """Map a CSS color to an RGB tuple for two-color rendering. + + Returns None for transparent/no-fill, (255,0,0) for red, (0,0,0) for black + (the only two ink colors on a two-color DK roll). White is treated as the + paper color (opaque white — use None for transparent backgrounds instead). + """ + if not color or color.lower().strip() 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, 255, 255) + if lc in ("#ff0000", "#f00", "red", "rgb(255,0,0)"): + return (255, 0, 0) + return (0, 0, 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, + rgb: tuple[int, int, int] | None = None, +) -> None: + """Paste sub-image (mode-L coverage mask) onto canvas. + + sub must be mode-L: 0 = ink (opaque), 255 = paper (transparent). + rgb: when set, composites a solid RGB patch through the coverage mask onto + an RGB canvas — used for coloured text/shapes on two-color media. When None, + pastes the grayscale sub directly (mono path). + """ + 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.Resampling.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) + if rgb is not None: + canvas.paste(Image.new("RGB", sub.size, rgb), (left, top), mask=mask) + else: + 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" + + # Measure actual PIL text extent — browser font metrics in Fabric differ from PIL's. + scratch = Image.new("L", (1, 1)) + bbox = ImageDraw.Draw(scratch).multiline_textbbox( + (0, 0), text, font=pil_font, align=align, spacing=4 + ) + real_w = max(box_w, math.ceil(bbox[2])) + real_h = math.ceil(bbox[3]) + 4 # +4px descender margin + + sub = Image.new("L", (max(real_w, 1), max(real_h, 1)), 255) + draw = ImageDraw.Draw(sub) + # Cancel any positive ascender gap so ink starts at y=0, not shifted down. + draw.multiline_text((0, -bbox[1]), text, font=pil_font, fill=0, align=align, spacing=4) + 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: + if not payload: + raise RenderError("QR payload is empty after field substitution") + ec = _QR_CORRECTION.get((correction or "M").upper(), ERROR_CORRECT_M) + # 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) + 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.Resampling.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.Resampling.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: + 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() + # 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.Resampling.NEAREST) + + +def detect_overflow(template: Template, media_id: str) -> bool: + """True when any element's bottom edge exceeds the printable height for a die-cut media. + + Continuous media never overflows (canvas length is content-driven). Returns False + for continuous media or when the media is unknown. + """ + label = get_label(media_id) + if label is None: + return False + if label.form_factor in _CONTINUOUS_FORM_FACTORS: + return False + max_h = label.dots_printable[1] + for obj in template.canvas_json.get("objects", []): + top = int(obj.get("top", 0)) + h = int(obj.get("height", 0) * float(obj.get("scaleY", 1.0))) + if top + h > max_h: + return True + return False + + +def render_template( + template: Template, + values: dict[str, str], + *, + media_override: str | None = None, +) -> Image.Image: + """Rasterize *template* with *values* substituted for placeholders. + + media_override: when set, render as if the template were on this media rather + than template.label_media. The stored template is never mutated. Used for + print-time one-off media selection (e.g. print a 62red design on 62x29). + + Returns a PIL Image sized for the print head. Mode is 'L' (0=black, 255=white) + for mono media. For two-color media (label.color == 1, e.g. 62red / DK-2251) + mode is 'RGB': black pixels are (0,0,0), red pixels are (255,0,0), paper is + (255,255,255). The print path promotes L→RGB and passes red=True for two-color + media; an RGB image here means red pixels land on the red print plane. + On mono media any red element is rendered as black (via _canvas_color_to_l/rgb). + """ + effective_media = media_override or template.label_media + label = get_label(effective_media) + if label is None: + raise RenderError(f"Unknown label media: {effective_media!r}") + + canvas_w = label.dots_printable[0] + objects = template.canvas_json.get("objects", []) + is_continuous = label.form_factor in _CONTINUOUS_FORM_FACTORS + two_color = label.color == 1 + + # Pre-render text elements once so PIL-measured extents inform continuous canvas height. + text_subs: dict[int, Image.Image] = {} + for i, obj in enumerate(objects): + norm_type = obj.get("type", "").lower().replace("-", "") + if norm_type in ("itext", "text", "textbox"): + 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: + text_subs[i] = _render_text_element(obj, values, box_w, box_h) + except RenderError: + raise + except Exception as exc: + raise RenderError(f"Failed to render element 'text': {exc}") from exc + + if is_continuous: + bottommost = 0 + for i, obj in enumerate(objects): + t = int(obj.get("top", 0)) + # Text: use PIL-measured height; other elements: Fabric height is reliable. + h = ( + text_subs[i].height + if i in text_subs + else 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] + + if two_color: + canvas: Image.Image = Image.new("RGB", (canvas_w, canvas_h), (255, 255, 255)) + else: + canvas = Image.new("L", (canvas_w, canvas_h), 255) + draw = ImageDraw.Draw(canvas) + + for i, obj in enumerate(objects): + obj_type = obj.get("type", "") + # Fabric v6 serializes `type` as the PascalCase class name (IText, Line, + # Rect, Image); v5 used lowercase/hyphenated (i-text). Normalize both. + norm_type = obj_type.lower().replace("-", "") + 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 norm_type in ("itext", "text", "textbox"): + sub = text_subs[i] # pre-rendered above + if two_color: + rgb = _canvas_color_to_rgb(obj.get("fill")) or (0, 0, 0) + _paste_onto(canvas, sub, left, top, angle, rgb=rgb) + else: + _paste_onto(canvas, sub, left, top, angle) + + elif norm_type == "image": + if obj.get("labelforge_qr_payload") is not None: + raise RenderError( + "QR elements are not yet supported for printing" + " (known bug: prints as a solid block)" + ) + elif obj.get("labelforge_barcode_payload") is not None: + raise RenderError( + "Barcode elements are not yet supported for printing" + " (known bug: prints as a solid block)" + ) + else: + raise RenderError("Image elements not yet supported") + + elif norm_type == "line": + 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)) + stroke_color: tuple[int, int, int] | int + if two_color: + stroke_color = _canvas_color_to_rgb(obj.get("stroke") or "#000000") or (0, 0, 0) + else: + stroke_color = 0 + draw.line( + [(x1, y1), (x2, y2)], + fill=stroke_color, + width=max(1, int(obj.get("strokeWidth", 1))), + ) + + elif norm_type == "rect": + sw = max(1, int(obj.get("strokeWidth", 1))) + if two_color: + fill_rgb = _canvas_color_to_rgb(obj.get("fill")) + outline_rgb = _canvas_color_to_rgb(obj.get("stroke") or "#000000") or (0, 0, 0) + # Draw fill and outline as separate L masks so each can carry its own + # color and rotation is handled by _paste_onto. + if fill_rgb is not None: + fill_sub = Image.new("L", (box_w, box_h), 255) + fill_sub_draw = ImageDraw.Draw(fill_sub) + fill_sub_draw.rectangle([sw, sw, box_w - 1 - sw, box_h - 1 - sw], fill=0) + _paste_onto(canvas, fill_sub, left, top, angle, rgb=fill_rgb) + outline_sub = Image.new("L", (box_w, box_h), 255) + outline_sub_draw = ImageDraw.Draw(outline_sub) + outline_sub_draw.rectangle([0, 0, box_w - 1, box_h - 1], outline=0, width=sw) + _paste_onto(canvas, outline_sub, left, top, angle, rgb=outline_rgb) + else: + fill_v = _canvas_color_to_l(obj.get("fill")) + 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 diff --git a/backend/labelforge/render/text.py b/backend/labelforge/render/text.py new file mode 100644 index 0000000..813dfd5 --- /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 int(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/admin.py b/backend/labelforge/routes/admin.py new file mode 100644 index 0000000..0298779 --- /dev/null +++ b/backend/labelforge/routes/admin.py @@ -0,0 +1,51 @@ +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, Depends + +from labelforge import history as history_module +from labelforge.catalog.loader import load_catalog +from labelforge.catalog.reconcile import reconcile_catalog_files +from labelforge.config import settings +from labelforge.routes.auth import require_auth + +router = APIRouter(dependencies=[Depends(require_auth)]) + +_DEFAULT_PATH = Path("/app/labels.yml") + + +@router.post("/admin/reload-catalog") +async def reload_catalog() -> dict[str, Any]: + """Re-run catalog reconciliation and reload from disk. + + Returns a summary of what changed (entries added/updated, whether the + operator file was rewritten) plus the resulting catalog size. + """ + yml_path = settings.data_dir / "labels.yml" + baseline_path = settings.data_dir / "data" / "labels.default.yml" + + summary = reconcile_catalog_files( + _DEFAULT_PATH, + yml_path, + baseline_path, + auto_merge=settings.catalog_auto_merge, + ) + load_catalog(yml_path) + + from labelforge.catalog.loader import get_catalog + + return { + "wrote": summary["wrote"], + "added": summary["added"], + "updated": summary["updated"], + "backed_up": summary["backed_up"], + "reason": summary["reason"], + "catalog_size": len(get_catalog()), + } + + +@router.post("/admin/prune-history") +async def prune_history_now() -> dict[str, int]: + """Run history retention pruning immediately. Returns the number of jobs removed.""" + pruned = history_module.prune_history() + return {"pruned": pruned} diff --git a/backend/labelforge/routes/auth.py b/backend/labelforge/routes/auth.py new file mode 100644 index 0000000..e2528cf --- /dev/null +++ b/backend/labelforge/routes/auth.py @@ -0,0 +1,20 @@ +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. + + No-op when DISABLE_AUTH=true (deployment is expected to be fronted by a + reverse proxy that handles authentication). + """ + if settings.disable_auth: + return + 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..1bc38f7 --- /dev/null +++ b/backend/labelforge/routes/fonts.py @@ -0,0 +1,12 @@ +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..11b4560 --- /dev/null +++ b/backend/labelforge/routes/health.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +from labelforge.config import settings + +router = APIRouter() + + +@router.get("/health") +async def health() -> dict: + # auth_required lets the SPA decide whether to show the token gate. + return {"status": "ok", "auth_required": not settings.disable_auth} diff --git a/backend/labelforge/routes/history.py b/backend/labelforge/routes/history.py new file mode 100644 index 0000000..e381c55 --- /dev/null +++ b/backend/labelforge/routes/history.py @@ -0,0 +1,250 @@ +import json +import logging + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import FileResponse, Response + +from labelforge.catalog.loader import get_label +from labelforge.config import settings +from labelforge.db import get_connection +from labelforge.history import insert_job_with_preview +from labelforge.logutil import scrub +from labelforge.models import HistoryDetail, HistoryItem, PinRequest, QuickPrintRequest +from labelforge.printer.client import PrintError, print_image +from labelforge.render.template import render_template +from labelforge.render.text import RenderError, render_text +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 _row_to_item(row) -> HistoryItem: + return HistoryItem( + id=row["id"], + template_id=row["template_id"], + is_quick_print=row["template_id"] is None, + field_values=json.loads(row["field_values"]) if row["field_values"] else None, + label_media=row["label_media"], + pinned=bool(row["pinned"]), + created_at=row["created_at"], + reprint_of=row["reprint_of"], + batch_id=row["batch_id"], + preview_url=f"/api/history/{row['id']}/preview.png", + ) + + +def _row_to_detail(row) -> HistoryDetail: + item = _row_to_item(row) + payload = None + if row["payload_json"]: + try: + payload = json.loads(row["payload_json"]) + except Exception: + # Malformed stored payload — return the row without the decoded payload. + pass + return HistoryDetail(**item.model_dump(), payload_json=payload) + + +@router.get("/history", response_model=list[HistoryItem]) +async def list_history( + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + template: str | None = None, + pinned: bool | None = None, + from_: str | None = Query(default=None, alias="from"), + to: str | None = None, +) -> list[HistoryItem]: + sql = "SELECT * FROM print_jobs WHERE 1=1" + params: list = [] + if template is not None: + sql += " AND template_id = ?" + params.append(template) + if pinned is not None: + sql += " AND pinned = ?" + params.append(1 if pinned else 0) + if from_ is not None: + sql += " AND created_at >= ?" + params.append(from_) + if to is not None: + sql += " AND created_at <= ?" + params.append(to) + sql += " ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + conn = get_connection(_db_path()) + try: + rows = conn.execute(sql, params).fetchall() # noqa: S608 + finally: + conn.close() + return [_row_to_item(r) for r in rows] + + +@router.get("/history/{job_id}", response_model=HistoryDetail) +async def get_history(job_id: int) -> HistoryDetail: + conn = get_connection(_db_path()) + try: + row = conn.execute("SELECT * FROM print_jobs WHERE id = ?", (job_id,)).fetchone() + finally: + conn.close() + if row is None: + raise HTTPException(status_code=404, detail=f"Print job {job_id} not found") + return _row_to_detail(row) + + +@router.get("/history/{job_id}/preview.png", response_class=FileResponse) +async def history_preview(job_id: int) -> Response: + conn = get_connection(_db_path()) + try: + row = conn.execute("SELECT preview_path FROM print_jobs WHERE id = ?", (job_id,)).fetchone() + finally: + conn.close() + if row is None: + raise HTTPException(status_code=404, detail="Print job not found") + if not row["preview_path"]: + raise HTTPException(status_code=404, detail="Preview not available") + preview_file = settings.data_dir / "label-previews" / row["preview_path"] + if not preview_file.exists(): + raise HTTPException(status_code=404, detail="Preview file not found") + return FileResponse(str(preview_file), media_type="image/png") + + +@router.post("/history/{job_id}/reprint") +async def reprint_history(job_id: int) -> dict: + conn = get_connection(_db_path()) + try: + row = conn.execute("SELECT * FROM print_jobs WHERE id = ?", (job_id,)).fetchone() + finally: + conn.close() + if row is None: + raise HTTPException(status_code=404, detail=f"Print job {job_id} not found") + + if row["template_id"] is not None: + return await _reprint_template(job_id, row) + return await _reprint_quick(job_id, row) + + +async def _reprint_template(job_id: int, row) -> dict: + tmpl = store.get_template(row["template_id"], include_deleted=True) + if tmpl is None: + raise HTTPException( + status_code=409, + detail=f"Template '{row['template_id']}' no longer exists and cannot be reprinted", + ) + # Reprint on the media from the original history row, not the template's stored media. + # This reproduces one-off media overrides from the recall page exactly. + row_media = row["label_media"] + if get_label(row_media) is None: + raise HTTPException( + status_code=409, + detail=f"Label media '{row_media}' is no longer in the catalog", + ) + field_values = json.loads(row["field_values"]) if row["field_values"] else {} + try: + image = render_template(tmpl, field_values, media_override=row_media) + except RenderError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + try: + outcome = print_image( + image=image, + label_media=row_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 + new_id = insert_job_with_preview( + image=image, + payload_json=row["payload_json"], + label_media=row_media, + template_name=row["template_id"], + field_values=field_values, + reprint_of=job_id, + ) + return {"job_id": new_id, "status": outcome, "reprint_of": job_id} + + +async def _reprint_quick(job_id: int, row) -> dict: + try: + request = QuickPrintRequest(**json.loads(row["payload_json"])) + except Exception as exc: + raise HTTPException( + status_code=409, detail=f"Cannot reconstruct quick-print request: {exc}" + ) from exc + if get_label(request.label_media) is None: + raise HTTPException( + status_code=409, + detail=f"Label media '{request.label_media}' is no longer in the catalog", + ) + 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 + new_id = insert_job_with_preview( + image=image, + payload_json=row["payload_json"], + label_media=request.label_media, + reprint_of=job_id, + ) + return {"job_id": new_id, "status": outcome, "reprint_of": job_id} + + +@router.post("/history/{job_id}/pin", response_model=HistoryItem) +async def pin_history(job_id: int, body: PinRequest) -> HistoryItem: + conn = get_connection(_db_path()) + try: + if not conn.execute("SELECT 1 FROM print_jobs WHERE id = ?", (job_id,)).fetchone(): + raise HTTPException(status_code=404, detail=f"Print job {job_id} not found") + conn.execute( + "UPDATE print_jobs SET pinned = ? WHERE id = ?", + (1 if body.pinned else 0, job_id), + ) + conn.commit() + row = conn.execute("SELECT * FROM print_jobs WHERE id = ?", (job_id,)).fetchone() + return _row_to_item(row) + finally: + conn.close() + + +@router.delete("/history/{job_id}", status_code=204) +async def delete_history(job_id: int) -> None: + conn = get_connection(_db_path()) + try: + row = conn.execute("SELECT preview_path FROM print_jobs WHERE id = ?", (job_id,)).fetchone() + if row is None: + raise HTTPException(status_code=404, detail=f"Print job {job_id} not found") + conn.execute("DELETE FROM print_jobs WHERE id = ?", (job_id,)) + conn.commit() + finally: + conn.close() + if row["preview_path"]: + try: + (settings.data_dir / "label-previews" / row["preview_path"]).unlink(missing_ok=True) + except Exception: + logger.warning("Could not delete preview file for job %s", scrub(job_id), exc_info=True) 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/preview.py b/backend/labelforge/routes/preview.py new file mode 100644 index 0000000..5c3602d --- /dev/null +++ b/backend/labelforge/routes/preview.py @@ -0,0 +1,42 @@ +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.printer.client import to_print_bitmap +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() + to_print_bitmap(image).save(buf, format="PNG") + return Response(content=buf.getvalue(), media_type="image/png") diff --git a/backend/labelforge/routes/print.py b/backend/labelforge/routes/print.py new file mode 100644 index 0000000..12e214e --- /dev/null +++ b/backend/labelforge/routes/print.py @@ -0,0 +1,118 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException + +from labelforge import settings_store +from labelforge.catalog.loader import get_label +from labelforge.config import settings +from labelforge.history import insert_job_with_preview +from labelforge.logutil import scrub +from labelforge.models import PrintJobResponse, QuickPrintRequest +from labelforge.printer.client import ( + PrintError, + StatusUnavailable, + media_compatible, + print_image, + status_read, +) +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, override: bool = False) -> 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 + + if settings_store.get("printer_status_check"): + timeout_ms = settings_store.get("printer_status_timeout_ms") + try: + status = status_read( + host=settings.printer_host, + backend=settings.printer_backend, + timeout_ms=timeout_ms, + ) + if status["errors"]: + code = status["errors"][0].lower().replace(" ", "_") + raise HTTPException( + status_code=409, + detail={ + "error": "printer_error", + "code": code, + "message": f"Printer error: {', '.join(status['errors'])}", + "raw": status, + }, + ) + media_id = status["media_id"] + if media_id is not None and not media_compatible(media_id, request.label_media): + if not override: + raise HTTPException( + status_code=409, + detail={ + "error": "media_mismatch", + "expected": request.label_media, + "loaded": media_id, + "override_allowed": True, + "message": ( + f"Printer has {media_id} loaded, " + f"template expects {request.label_media}. " + "Pass override=true to print anyway." + ), + }, + ) + logger.warning( + "Media mismatch (override): loaded=%s expected=%s", + scrub(media_id), + scrub(request.label_media), + ) + except StatusUnavailable: + logger.warning("Printer status unavailable; proceeding without check") + + 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 + + job_id = insert_job_with_preview( + image=image, + payload_json=request.model_dump_json(), + label_media=request.label_media, + ) + + 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, + preview_url=f"/api/history/{job_id}/preview.png", + ) diff --git a/backend/labelforge/routes/printer.py b/backend/labelforge/routes/printer.py new file mode 100644 index 0000000..d3df5cf --- /dev/null +++ b/backend/labelforge/routes/printer.py @@ -0,0 +1,61 @@ +import logging + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from labelforge import settings_store +from labelforge.catalog.loader import get_label +from labelforge.config import settings +from labelforge.printer.client import StatusUnavailable, status_read + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["printer"]) + + +@router.get("/printer/status", response_model=None) +async def get_printer_status() -> dict | JSONResponse: + timeout_ms = settings_store.get("printer_status_timeout_ms") + + try: + status = status_read( + host=settings.printer_host, + backend=settings.printer_backend, + timeout_ms=timeout_ms, + ) + except StatusUnavailable: + logger.exception("Printer status unavailable") + return JSONResponse( + status_code=503, + content={ + "error": "status_unavailable", + "message": "Printer status is currently unavailable.", + }, + ) + + media_id = status["media_id"] + loaded_media = None + if media_id is not None: + label = get_label(media_id) + if label: + display_name = label.display_name + elif status["length_mm"] == 0: + display_name = f"{status['width_mm']}mm Continuous" + else: + display_name = f"{status['width_mm']}mm × {status['length_mm']}mm" + + loaded_media = { + "id": media_id, + "display_name": display_name, + "width_mm": status["width_mm"], + "length_mm": status["length_mm"], + "color_capable": status["color_capable"], + } + + return { + "ready": status["ready"], + "model": status["model"], + "loaded_media": loaded_media, + "errors": status["errors"], + "source": status["source"], + } 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/routes/template_print.py b/backend/labelforge/routes/template_print.py new file mode 100644 index 0000000..9329b79 --- /dev/null +++ b/backend/labelforge/routes/template_print.py @@ -0,0 +1,304 @@ +import io +import logging +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import Response + +from labelforge import settings_store +from labelforge.catalog.loader import get_label +from labelforge.config import settings +from labelforge.history import insert_job_with_preview +from labelforge.logutil import scrub +from labelforge.models import ( + BatchJobResult, + BatchPrintRequest, + BatchPrintResponse, + PrintRequest, +) +from labelforge.printer.client import ( + PrintError, + StatusUnavailable, + media_compatible, + print_image, + status_read, + to_print_bitmap, +) +from labelforge.render.template import detect_overflow, 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 _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 _apply_sample_defaults(template_fields, values: dict[str, str]) -> dict[str, str]: + """Return values dict with caller-supplied values first, then defaults, + then field name as sample. + + Never raises — every field gets a value, so preview always renders. + """ + result = dict(values) + for field in template_fields: + if field.name not in result: + # Use stored default if set; otherwise show the field name itself so + # {type} renders as the literal text "type" — makes variables visible in preview. + result[field.name] = field.default if field.default is not None else field.name + return result + + +def _resolve_effective_media(body_label_media: str | None, tmpl_label_media: str) -> str: + """Validate and return the effective media id, raising HTTPException on invalid input.""" + if body_label_media is None: + return tmpl_label_media + label = get_label(body_label_media) + if label is None or not label.supported: + raise HTTPException( + status_code=400, + detail=f"Label media '{body_label_media}' is not a supported catalog entry", + ) + return body_label_media + + +@router.post("/print/{name}") +async def print_template(name: str, body: PrintRequest, override: bool = False) -> dict: + tmpl = store.get_template(name) + if tmpl is None: + raise HTTPException(status_code=404, detail=f"Template '{name}' not found") + + effective_media = _resolve_effective_media(body.label_media, tmpl.label_media) + values = _apply_defaults(tmpl.field_schema, body.fields) + + try: + image = render_template(tmpl, values, media_override=body.label_media) + except RenderError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + if settings_store.get("printer_status_check"): + timeout_ms = settings_store.get("printer_status_timeout_ms") + try: + status = status_read( + host=settings.printer_host, + backend=settings.printer_backend, + timeout_ms=timeout_ms, + ) + if status["errors"]: + code = status["errors"][0].lower().replace(" ", "_") + raise HTTPException( + status_code=409, + detail={ + "error": "printer_error", + "code": code, + "message": f"Printer error: {', '.join(status['errors'])}", + "raw": status, + }, + ) + media_id = status["media_id"] + if media_id is not None and not media_compatible(media_id, effective_media): + if not override: + raise HTTPException( + status_code=409, + detail={ + "error": "media_mismatch", + "expected": effective_media, + "loaded": media_id, + "override_allowed": True, + "message": ( + f"Printer has {media_id} loaded, " + f"template expects {effective_media}. " + "Pass override=true to print anyway." + ), + }, + ) + logger.warning( + "Media mismatch (override): loaded=%s expected=%s", + scrub(media_id), + scrub(effective_media), + ) + except StatusUnavailable: + logger.warning("Printer status unavailable; proceeding without check") + + try: + outcome = print_image( + image=image, + label_media=effective_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_with_preview( + image=image, + payload_json=body.model_dump_json(), + label_media=effective_media, + template_name=name, + field_values=values, + ) + + overflow = detect_overflow(tmpl, effective_media) + + return { + "job_id": job_id, + "status": outcome, + "template": name, + "label_media": effective_media, + "overflow": overflow, + "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") + + effective_media = _resolve_effective_media(body.label_media, tmpl.label_media) + + # Preview is a layout check — fill missing fields with samples rather than failing. + values = _apply_sample_defaults(tmpl.field_schema, body.fields) + + try: + image = render_template(tmpl, values, media_override=body.label_media) + except RenderError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + buf = io.BytesIO() + # Two-color templates render as RGB; preserve color in the preview PNG. + # Mono templates apply the print threshold so preview == print. + preview = image if image.mode == "RGB" else to_print_bitmap(image) + preview.save(buf, format="PNG") + + overflow = detect_overflow(tmpl, effective_media) + headers: dict[str, str] = {} + if overflow: + headers["X-Label-Overflow"] = "true" + + return Response(content=buf.getvalue(), media_type="image/png", headers=headers) + + +@router.post("/print/{name}/batch", response_model=BatchPrintResponse) +async def batch_print( + name: str, body: BatchPrintRequest, override: bool = False +) -> 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") + + effective_media = _resolve_effective_media(body.label_media, tmpl.label_media) + + # Status check once before the loop — avoids per-label overhead + if settings_store.get("printer_status_check"): + timeout_ms = settings_store.get("printer_status_timeout_ms") + try: + status = status_read( + host=settings.printer_host, + backend=settings.printer_backend, + timeout_ms=timeout_ms, + ) + if status["errors"]: + code = status["errors"][0].lower().replace(" ", "_") + raise HTTPException( + status_code=409, + detail={ + "error": "printer_error", + "code": code, + "message": f"Printer error: {', '.join(status['errors'])}", + "raw": status, + }, + ) + media_id = status["media_id"] + if media_id is not None and not media_compatible(media_id, effective_media): + if not override: + raise HTTPException( + status_code=409, + detail={ + "error": "media_mismatch", + "expected": effective_media, + "loaded": media_id, + "override_allowed": True, + "message": ( + f"Printer has {media_id} loaded, " + f"template expects {effective_media}. " + "Pass override=true to print anyway." + ), + }, + ) + logger.warning( + "Media mismatch (override): loaded=%s expected=%s", + scrub(media_id), + scrub(effective_media), + ) + except StatusUnavailable: + logger.warning("Printer status unavailable; proceeding without check") + + 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, media_override=body.label_media) + outcome = print_image( + image=image, + label_media=effective_media, + model=settings.printer_model, + backend=settings.printer_backend, + host=settings.printer_host, + ) + job_id = insert_job_with_preview( + image=image, + payload_json=BatchPrintRequest(labels=[label_values]).model_dump_json(), + label_media=effective_media, + template_name=name, + field_values=values, + 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) diff --git a/backend/labelforge/routes/templates.py b/backend/labelforge/routes/templates.py new file mode 100644 index 0000000..6c4722c --- /dev/null +++ b/backend/labelforge/routes/templates.py @@ -0,0 +1,107 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from labelforge.catalog.loader import get_label +from labelforge.history import get_latest_field_values +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 _LastValuesResponse(BaseModel): + values: dict | None = None + printed_at: str | None = None + + +@router.get("/templates/{name}/last-values", response_model=_LastValuesResponse) +async def get_template_last_values(name: str) -> _LastValuesResponse: + _load_or_404(name) + values, printed_at = get_latest_field_values(name) + return _LastValuesResponse(values=values, printed_at=printed_at) + + +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 diff --git a/backend/labelforge/settings_store.py b/backend/labelforge/settings_store.py new file mode 100644 index 0000000..04cd231 --- /dev/null +++ b/backend/labelforge/settings_store.py @@ -0,0 +1,95 @@ +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() 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/fields.py b/backend/labelforge/templates/fields.py new file mode 100644 index 0000000..4fe532a --- /dev/null +++ b/backend/labelforge/templates/fields.py @@ -0,0 +1,74 @@ +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 + # Fabric v6 emits PascalCase type names (IText); v5 used i-text. Normalize. + t = obj.get("type", "").lower().replace("-", "") + if t in ("itext", "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 diff --git a/backend/labelforge/templates/store.py b/backend/labelforge/templates/store.py new file mode 100644 index 0000000..4486119 --- /dev/null +++ b/backend/labelforge/templates/store.py @@ -0,0 +1,183 @@ +import json +import re +import sqlite3 +from datetime import UTC, datetime + +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(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/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..71e03c8 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,8 @@ +import os + +# config.py instantiates Settings() at import time, which requires PRINTER_HOST and +# either API_TOKEN or DISABLE_AUTH. CI has no .env file (it's gitignored), so set +# CI-safe defaults before any test module imports the app. setdefault means a real +# local .env / environment still wins. +os.environ.setdefault("PRINTER_HOST", "127.0.0.1") +os.environ.setdefault("DISABLE_AUTH", "true") diff --git a/backend/tests/test_app_smoke.py b/backend/tests/test_app_smoke.py new file mode 100644 index 0000000..d3cb4d6 --- /dev/null +++ b/backend/tests/test_app_smoke.py @@ -0,0 +1,24 @@ +"""Smoke test: the app must import and build its OpenAPI schema. + +Catches import-time / route-registration failures that per-module unit tests miss — +e.g. an invalid FastAPI `response_model` (a return annotation unioning a Response type) +raises when the route is registered, taking the whole app down at startup while every +isolated unit test stays green. CI had no such guard before this test. +""" + + +def test_app_imports_and_core_routes_registered(): + from labelforge.main import app + + paths = {route.path for route in app.routes} + assert "/api/health" in paths + assert "/api/printer/status" in paths + + +def test_openapi_schema_builds(): + # Generating the schema exercises response-model construction for every route; + # an invalid return annotation raises here rather than only at container startup. + from labelforge.main import app + + schema = app.openapi() + assert schema["info"]["title"] diff --git a/backend/tests/test_media_override.py b/backend/tests/test_media_override.py new file mode 100644 index 0000000..283ec0d --- /dev/null +++ b/backend/tests/test_media_override.py @@ -0,0 +1,217 @@ +"""Tests for print-time media override: render on a non-stored media, reprint +binds to the history row's media, and overflow detection on die-cut targets. +""" + +from __future__ import annotations + +import asyncio +import json +from unittest.mock import patch + +from labelforge.models import LabelEntry, Template +from PIL import Image + +# ── Fixtures ────────────────────────────────────────────────────────────────── + + +def _make_label( + id_: str, + *, + form_factor: int = 2, # 2 = continuous + dots_printable: tuple[int, int] = (696, 0), + tape_size: tuple[int, int] = (62, 0), + color: int = 0, + supported: bool = True, +) -> LabelEntry: + return LabelEntry( + id=id_, + display_name=id_, + dots_printable=dots_printable, + tape_size=tape_size, + form_factor=form_factor, + color=color, + supported=supported, + ) + + +def _make_template(label_media: str = "62red", canvas_json: dict | None = None) -> Template: + return Template( + name="test-tmpl", + display_name="Test Template", + label_media=label_media, + canvas_json=canvas_json or {"objects": []}, + field_schema=[], + created_at="2026-01-01T00:00:00", + updated_at="2026-01-01T00:00:00", + ) + + +# ── render_template with media_override ─────────────────────────────────────── + + +def test_render_template_override_uses_effective_media(): + """Rendering with media_override='62' (mono) should return an L-mode image.""" + label_62red = _make_label("62red", color=1, form_factor=2) + label_62 = _make_label("62", color=0, form_factor=2) + + def _get_label(id_: str) -> LabelEntry | None: + return {"62red": label_62red, "62": label_62}.get(id_) + + tmpl = _make_template(label_media="62red") + + with patch("labelforge.render.template.get_label", side_effect=_get_label): + from labelforge.render.template import render_template + + img = render_template(tmpl, {}, media_override="62") + + assert img.mode == "L", "Mono override should produce a grayscale image" + + +def test_render_template_no_override_is_unchanged(): + """No override should behave as before (two-color template → RGB image).""" + label_62red = _make_label("62red", color=1, form_factor=2) + + def _get_label(id_: str) -> LabelEntry | None: + return {"62red": label_62red}.get(id_) + + tmpl = _make_template(label_media="62red") + + with patch("labelforge.render.template.get_label", side_effect=_get_label): + from labelforge.render.template import render_template + + img = render_template(tmpl, {}) + + assert img.mode == "RGB", "Two-color template without override should produce RGB image" + + +def test_render_template_override_die_cut_sizes_to_label(): + """Rendering with a die-cut override sizes the canvas to the die-cut dimensions.""" + label_62red = _make_label("62red", color=1, form_factor=2, dots_printable=(696, 0)) + # 62x29 die-cut: 696 × 271 printable dots + label_62x29 = _make_label( + "62x29", color=0, form_factor=1, dots_printable=(696, 271), tape_size=(62, 29) + ) + + def _get_label(id_: str) -> LabelEntry | None: + return {"62red": label_62red, "62x29": label_62x29}.get(id_) + + tmpl = _make_template(label_media="62red") + + with patch("labelforge.render.template.get_label", side_effect=_get_label): + from labelforge.render.template import render_template + + img = render_template(tmpl, {}, media_override="62x29") + + assert img.size == (696, 271), f"Expected (696, 271), got {img.size}" + + +# ── detect_overflow ──────────────────────────────────────────────────────────── + + +def test_detect_overflow_continuous_never_overflows(): + """Continuous media should never be reported as overflowing.""" + label_62 = _make_label("62", form_factor=2, dots_printable=(696, 0)) + + def _get_label(id_: str) -> LabelEntry | None: + return {"62": label_62}.get(id_) + + # Template with an object that would overflow a die-cut + canvas = {"objects": [{"type": "Rect", "top": 0, "height": 5000, "scaleY": 1.0}]} + tmpl = _make_template(canvas_json=canvas) + + with patch("labelforge.render.template.get_label", side_effect=_get_label): + from labelforge.render.template import detect_overflow + + assert detect_overflow(tmpl, "62") is False + + +def test_detect_overflow_die_cut_within_bounds(): + """Content that fits within the die-cut should not be flagged.""" + label_62x29 = _make_label("62x29", form_factor=1, dots_printable=(696, 271), tape_size=(62, 29)) + + def _get_label(id_: str) -> LabelEntry | None: + return {"62x29": label_62x29}.get(id_) + + canvas = {"objects": [{"type": "Rect", "top": 10, "height": 100, "scaleY": 1.0}]} + tmpl = _make_template(canvas_json=canvas) + + with patch("labelforge.render.template.get_label", side_effect=_get_label): + from labelforge.render.template import detect_overflow + + assert detect_overflow(tmpl, "62x29") is False + + +def test_detect_overflow_die_cut_exceeds_bounds(): + """Content extending past the die-cut height should be flagged.""" + label_62x29 = _make_label("62x29", form_factor=1, dots_printable=(696, 271), tape_size=(62, 29)) + + def _get_label(id_: str) -> LabelEntry | None: + return {"62x29": label_62x29}.get(id_) + + # top=200 + height=200 = 400 > 271 printable dots + canvas = {"objects": [{"type": "Rect", "top": 200, "height": 200, "scaleY": 1.0}]} + tmpl = _make_template(canvas_json=canvas) + + with patch("labelforge.render.template.get_label", side_effect=_get_label): + from labelforge.render.template import detect_overflow + + assert detect_overflow(tmpl, "62x29") is True + + +def test_detect_overflow_unknown_media_returns_false(): + """Unknown media should not raise — return False.""" + with patch("labelforge.render.template.get_label", return_value=None): + from labelforge.render.template import detect_overflow + + tmpl = _make_template() + assert detect_overflow(tmpl, "nonexistent") is False + + +# ── reprint uses history row's media ────────────────────────────────────────── + + +def test_reprint_uses_row_media_not_template_media(): + """_reprint_template should render on the history row's label_media, not + the template's stored label_media. This ensures one-off overrides at recall + time are reproduced faithfully on reprint. + """ + label_62 = _make_label("62", color=0, form_factor=2) + label_62red = _make_label("62red", color=1, form_factor=2) + + def _get_label(id_: str) -> LabelEntry | None: + return {"62": label_62, "62red": label_62red}.get(id_) + + tmpl = _make_template(label_media="62red") # template stored on 62red + row = { + "template_id": "test-tmpl", + "label_media": "62", # was printed on mono 62 + "field_values": json.dumps({}), + "payload_json": json.dumps({}), + "reprint_of": None, + } + + calls: list[dict] = [] + + def _mock_render(template, values, *, media_override=None): + calls.append({"media_override": media_override}) + # Return a tiny mono image so downstream code doesn't crash. + return Image.new("L", (10, 10), 255) + + with ( + patch("labelforge.routes.history.store") as mock_store, + patch("labelforge.routes.history.get_label", side_effect=_get_label), + patch("labelforge.routes.history.render_template", side_effect=_mock_render), + patch("labelforge.routes.history.print_image", return_value="sent"), + patch("labelforge.routes.history.insert_job_with_preview", return_value=99), + ): + mock_store.get_template.return_value = tmpl + + from labelforge.routes.history import _reprint_template + + result = asyncio.run(_reprint_template(99, row)) + + assert len(calls) == 1, "render_template should have been called once" + assert calls[0]["media_override"] == "62", ( + "reprint should render on the history row's media ('62'), not the template's ('62red')" + ) + assert result["reprint_of"] == 99 diff --git a/backend/tests/test_reconcile.py b/backend/tests/test_reconcile.py new file mode 100644 index 0000000..ca95c8f --- /dev/null +++ b/backend/tests/test_reconcile.py @@ -0,0 +1,244 @@ +"""Tests for the catalog 3-way merge logic. + +merge_catalog is pure (no file IO), so these tests run without any filesystem setup. +reconcile_catalog_files tests use tmp_path. +""" + +import shutil + +import yaml +from labelforge.catalog.reconcile import merge_catalog, reconcile_catalog_files + + +def _doc(*entries): + return {"labels": list(entries)} + + +def _e(id, **kwargs): + return {"id": id, **kwargs} + + +# ── merge_catalog (pure function) ──────────────────────────────────────────── + + +def test_default_unchanged_no_op(): + entry = _e("62", display_name="62mm", brother_part="DK-2205") + doc = _doc(entry) + result, changes = merge_catalog(doc, doc, doc) + assert result["labels"] == [dict(entry)] + assert changes == [] + + +def test_new_default_entry_added(): + existing = _e("62", display_name="62mm") + new_entry = _e("29", display_name="29mm") + op = _doc(existing) + old_default = _doc(existing) + new_default = _doc(existing, new_entry) + + result, changes = merge_catalog(op, old_default, new_default) + ids = [e["id"] for e in result["labels"]] + assert "62" in ids + assert "29" in ids + assert "added 29" in changes + + +def test_operator_customized_field_preserved(): + """op != baseline → operator wins even when default changed it.""" + old = _e("62", brother_part="DK-OLD", display_name="62mm") + op_entry = _e("62", brother_part="DK-OP", display_name="62mm") # customized + new = _e("62", brother_part="DK-NEW", display_name="62mm") + + result, changes = merge_catalog(_doc(op_entry), _doc(old), _doc(new)) + found = next(e for e in result["labels"] if e["id"] == "62") + assert found["brother_part"] == "DK-OP" + assert not any("brother_part" in c for c in changes) + + +def test_uncustomized_field_updated_from_default(): + """SKU correction: op == baseline, so the corrected default value is taken.""" + old = _e("62x29", brother_part="DK-WRONG", display_name="62x29mm") + op_entry = _e("62x29", brother_part="DK-WRONG", display_name="62x29mm") # unchanged + new = _e("62x29", brother_part="DK-1209", display_name="62x29mm") + + result, changes = merge_catalog(_doc(op_entry), _doc(old), _doc(new)) + found = next(e for e in result["labels"] if e["id"] == "62x29") + assert found["brother_part"] == "DK-1209" + assert "updated 62x29.brother_part" in changes + + +def test_operator_only_custom_entry_preserved(): + default_entry = _e("62", display_name="62mm") + custom = _e("custom-roll", display_name="My Roll") + op = _doc(default_entry, custom) + default = _doc(default_entry) + + result, changes = merge_catalog(op, default, default) + ids = [e["id"] for e in result["labels"]] + assert "custom-roll" in ids + assert "62" in ids + + +def test_default_removed_entry_preserved(): + """An entry the default dropped must never be deleted from operator's file.""" + entry_62 = _e("62", display_name="62mm") + entry_29 = _e("29", display_name="29mm") + op = _doc(entry_62, entry_29) + old_default = _doc(entry_62, entry_29) + new_default = _doc(entry_62) # 29 dropped from default + + result, changes = merge_catalog(op, old_default, new_default) + ids = [e["id"] for e in result["labels"]] + assert "29" in ids + assert "62" in ids + + +def test_new_entries_appended_after_operator_entries(): + """Brand-new default entries appear after the operator's existing entries.""" + op_entry = _e("62", display_name="62mm") + new_entry = _e("29", display_name="29mm") + op = _doc(op_entry) + old_default = _doc(op_entry) + new_default = _doc(op_entry, new_entry) + + result, _ = merge_catalog(op, old_default, new_default) + ids = [e["id"] for e in result["labels"]] + assert ids.index("62") < ids.index("29") + + +def test_operator_extra_fields_kept(): + """Fields the operator added that aren't in the default are preserved.""" + old = _e("62", display_name="62mm") + op_entry = _e("62", display_name="62mm", custom_note="rack shelf 3") + new = _e("62", display_name="62mm") + + result, changes = merge_catalog(_doc(op_entry), _doc(old), _doc(new)) + found = next(e for e in result["labels"] if e["id"] == "62") + assert found.get("custom_note") == "rack shelf 3" + assert changes == [] + + +# ── reconcile_catalog_files (file IO) ──────────────────────────────────────── + + +def _write_yaml(path, data): + path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True)) + + +def test_no_baseline_transition(tmp_path): + """Upgrade from pre-reconcile: adds new entries only, existing untouched, baseline written.""" + default_path = tmp_path / "default.yml" + yml_path = tmp_path / "labels.yml" + baseline_path = tmp_path / "data" / "labels.default.yml" + + _write_yaml( + default_path, + _doc( + _e("62", display_name="62mm", brother_part="DK-2205"), + _e("29", display_name="29mm", brother_part="DK-2210"), + ), + ) + _write_yaml( + yml_path, + _doc( + _e("62", display_name="My Custom 62mm", brother_part="DK-2205"), + ), + ) + + summary = reconcile_catalog_files(default_path, yml_path, baseline_path) + + assert summary["added"] == 1 + assert summary["wrote"] is True + assert baseline_path.exists() + assert baseline_path.read_bytes() == default_path.read_bytes() + + result = yaml.safe_load(yml_path.read_text()) + entry_62 = next(e for e in result["labels"] if e["id"] == "62") + assert entry_62["display_name"] == "My Custom 62mm" # operator value preserved + ids = [e["id"] for e in result["labels"]] + assert "29" in ids + + +def test_no_baseline_no_new_entries(tmp_path): + """No-baseline transition with no new entries: no write, baseline still created.""" + default_path = tmp_path / "default.yml" + yml_path = tmp_path / "labels.yml" + baseline_path = tmp_path / "data" / "labels.default.yml" + + doc = _doc(_e("62", display_name="62mm")) + _write_yaml(default_path, doc) + _write_yaml(yml_path, doc) + + original_bytes = yml_path.read_bytes() + summary = reconcile_catalog_files(default_path, yml_path, baseline_path) + + assert summary["wrote"] is False + assert yml_path.read_bytes() == original_bytes + assert baseline_path.exists() + + +def test_first_run_copies_default(tmp_path): + """No operator file: default is copied to both yml_path and baseline_path.""" + default_path = tmp_path / "default.yml" + yml_path = tmp_path / "labels.yml" + baseline_path = tmp_path / "data" / "labels.default.yml" + + _write_yaml(default_path, _doc(_e("62", display_name="62mm"))) + reconcile_catalog_files(default_path, yml_path, baseline_path) + + assert yml_path.exists() + assert baseline_path.exists() + assert yml_path.read_bytes() == default_path.read_bytes() + assert baseline_path.read_bytes() == default_path.read_bytes() + + +def test_baseline_unchanged_noop(tmp_path): + """Default bytes == baseline bytes: no write.""" + default_path = tmp_path / "default.yml" + yml_path = tmp_path / "labels.yml" + baseline_path = tmp_path / "data" / "labels.default.yml" + baseline_path.parent.mkdir(parents=True) + + doc = _doc(_e("62", display_name="62mm")) + _write_yaml(default_path, doc) + _write_yaml(yml_path, doc) + shutil.copy(default_path, baseline_path) + + original_bytes = yml_path.read_bytes() + summary = reconcile_catalog_files(default_path, yml_path, baseline_path) + + assert summary["wrote"] is False + assert yml_path.read_bytes() == original_bytes + + +def test_full_merge_on_changed_default(tmp_path): + """Baseline differs from default: 3-way merge runs, backup written, baseline updated.""" + default_path = tmp_path / "default.yml" + yml_path = tmp_path / "labels.yml" + baseline_path = tmp_path / "data" / "labels.default.yml" + baseline_path.parent.mkdir(parents=True) + + old_doc = _doc(_e("62", brother_part="DK-OLD", display_name="62mm")) + op_doc = _doc(_e("62", brother_part="DK-OLD", display_name="62mm")) # not customized + new_doc = _doc( + _e("62", brother_part="DK-2205", display_name="62mm"), + _e("29", display_name="29mm"), + ) + + _write_yaml(yml_path, op_doc) + _write_yaml(baseline_path, old_doc) + _write_yaml(default_path, new_doc) + + summary = reconcile_catalog_files(default_path, yml_path, baseline_path) + + assert summary["wrote"] is True + assert summary["backed_up"] is True + assert summary["added"] == 1 + assert summary["updated"] >= 1 + + result = yaml.safe_load(yml_path.read_text()) + entry_62 = next(e for e in result["labels"] if e["id"] == "62") + assert entry_62["brother_part"] == "DK-2205" # corrected + assert any(e["id"] == "29" for e in result["labels"]) # new entry added + assert baseline_path.read_bytes() == default_path.read_bytes() + assert (yml_path.parent / (yml_path.name + ".bak")).exists() diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..508d22d --- /dev/null +++ b/docker-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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1dea3b4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +name: labelforge + +services: + labelforge: + build: . + 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: + - labelforge-data:/data + +volumes: + labelforge-data: diff --git a/docs/PRD.md b/docs/PRD.md index 5102122..54eaa07 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -28,8 +28,10 @@ Single Docker container, deployed on `docker10` via Dockhand. External access vi - Quick-print mode (text + font + size + label media — like brother_ql_web, kept) - Named templates with a freeform canvas layout (text, QR, barcode, image, line, rect) +- Two-color (black + red) text in templates on two-color media; red maps to black on mono media - Variable fields auto-detected from `{placeholder}` syntax in element content - Template recall: form auto-generated from field schema, fill, preview, print +- One-off print of a template on a different label media at recall time (stored media unchanged) - Increment / batch printing for numeric fields - Print history with reprint, pinning, and configurable retention - HTTP API: every template callable via `POST /api/print/{name}` with JSON field values diff --git a/docs/architecture.md b/docs/architecture.md index 3ca0d4c..78995e0 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 (auth is a single shared secret from the environment, not stored here) +- **`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,8 +46,8 @@ labelforge/ ├── CLAUDE.md ├── .gitignore ├── .gitattributes -├── compose.yml # production-shaped, used by Dockhand -├── compose.dev.yml # local dev: bind mounts, no Traefik labels +├── docker-compose.yml # single-service stack; bring your own proxy +├── docker-compose.dev.yml # local dev: bind mounts, no Traefik labels ├── Dockerfile # multi-stage: frontend build → python runtime ├── pyproject.toml # backend deps + tool config ├── docs/ @@ -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 `docker-compose.yml` for a standalone example and `docker-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) diff --git a/docs/decisions.md b/docs/decisions.md index d658afe..fc7dbd9 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -4,6 +4,472 @@ Architecture Decision Records, newest at the top. Each entry: what we decided, w --- +## 2026-06-06 — Print-time media override: one-off, warn-but-allow, red→black automatic, reprint binds to history + +**Decision**: A template can be printed on any supported media at recall time without mutating +the stored template. The behavior: + +- **One-off, non-persistent**: the media choice applies to the current print/preview only. + `template.label_media` is never modified. Save As remains the way to permanently retarget + a design. +- **Overflow on die-cut: warn but allow**: when the chosen media is a die-cut and content + extends beyond its printable height (`label.dots_printable[1]`), the preview response + returns `X-Label-Overflow: true` (header) and the print response includes `"overflow": true` + in the JSON body. The recall UI shows an inline warning. Printing is never blocked. +- **Red → black on mono: automatic, no toggle**: the renderer's existing `_canvas_color_to_l` + already maps any non-white color (including red) to 0 (black). Choosing a mono media for + a two-color template requires no additional renderer work — the preview will show the result. + An inline notice in the recall UI informs the user. +- **Reprint binds to the historical media**: `_reprint_template` now renders with + `media_override=row["label_media"]` (the row's actual print media) rather than + `tmpl.label_media`. This makes one-off overrides reproducible from history. + +**Supersedes**: the glossary rule "a template belongs to exactly one label media (a 62mm Spool +template cannot be printed on a 29×90 die-cut)" — see `docs/glossary.md`. The spirit of the +rule (Save As for persistent retargeting; editor media is immutable) is preserved. What changes +is the recall path: a one-off override is now supported. + +**Why**: The same physical design (e.g. a spool label) fits on rolls of the same width but +different geometries (continuous vs die-cut, different die-cut lengths). The user physically +swaps rolls and the pre-print media compatibility check already guards mismatches. Blocking +the print when geometry is close (same width, different length) is friction with no safety +benefit — the preview shows the result before ink touches paper. + +**Why same-width-first grouping**: When the list of all media is shown unsorted, the user must +scan to find the relevant alternatives. Same-width media share the design's coordinate system +width and are the most natural substitutes. Surfacing them first addresses the "overwhelming +list" concern without removing any option. + +**Considered**: +- Block cross-width prints entirely — rejected; a 62mm design on 62x29 is the exact motivating + case (user has a die-cut roll loaded, wants the label to auto-crop). The warn path is safer. +- A "save this media to the template" affordance — rejected; Save As already covers persistent + retargeting, and adding a second save path creates confusion about which one is canonical. +- Recolor toggle for red→black (explicit opt-in) — rejected; the preview already shows the + result, and an automatic mapping is simpler. A notice informs the user. +- Keep history logging the template's stored media — rejected; the historical row would then + be wrong (it logged a 62red print when a 62 roll was used), and reprint would reproduce + the wrong media. + +**Would revisit if**: a future override needs to be persistent without a full Save As (e.g. +"set this media as the new default for the template") — at that point a dedicated endpoint +or UI affordance would be appropriate. + +--- + +## 2026-06-05 — Release publish trigger: `release: published` replaces tag-push for production builds + +**Decision**: Removed the `tags: v*.*.*` trigger from `build-and-push.yml`. `release: published` is now the sole trigger for production image builds. The `push: branches: [main, dev]` trigger is retained for rolling dev/latest builds. + +**Why**: `gh release create v` (run by `/release-cut`) both creates the GitHub release AND pushes the git tag. With both `tags: v*.*.*` and `release: published` active, a single `/release-cut` invocation would fire the build workflow twice on the same commit — wasting CI minutes and risking a race between two concurrent builds pushing identical `:latest` image layers. Removing the tag-push trigger makes `release: published` the single, deterministic gate for release builds. This aligns with the `code-checkin-and-pr` standard's publishing matrix and is required by the `release-prep-and-cut` standard's `/release-cut` step 6 (verifies the build triggered by the `release` event specifically). + +**Image tags on release**: The `metadata-action`'s `type=semver,prefix=v` rules produce `:v`, `:v.`, `:v` (with a `v` prefix on image tags, matching the existing tag convention). `:latest` is also produced by `type=raw,value=latest,enable=startsWith(github.ref, 'refs/tags/v')`. This implements the `code-checkin-and-pr` matrix (`:latest`, `:`, `:`) with an added `v` prefix from the existing config. + +**Context**: Part of adopting `release-prep-and-cut @ 1.0.0`. + +**Considered**: +- Keep `tags: v*.*.*` only, remove `release: published` — rejected; the `release-prep-and-cut` standard requires `/release-cut` to verify a build triggered by the `release` event. +- Keep both triggers and deduplicate in the workflow — rejected; fragile and over-engineered for a solo project. + +**Would revisit if**: Docker image tag conventions change (e.g. dropping the `v` prefix from image tags); or if the release workflow needs pre-release staging triggered by draft releases. + +--- + +## 2026-06-05 — Catalog reconciliation: 3-way merge with baseline, operator-wins, never-delete + +**Decision**: On startup, labelforge performs a non-destructive 3-way merge of the bundled +`/app/labels.yml` into the operator's `$DATA_DIR/labels.yml`. A baseline copy +(`$DATA_DIR/data/labels.default.yml`) records the default as of the last sync. Merge logic: +if a field's operator value equals the baseline value (never customized), a changed default +value is applied; if the operator changed it, the operator wins. Entries the operator added or +that a later default removed are never deleted. A rolling backup (`$DATA_DIR/labels.yml.bak`) +is written before any write. The feature is opt-out via `CATALOG_AUTO_MERGE=false`. Closes #16. + +**Source of truth for reconciliation is the bundled image default, NOT the internet.** This is +intentionally distinct from "don't auto-update the catalog from the internet" (`CLAUDE.md`): +reconcile reads only the default shipped inside the image; no network access occurs. + +**PyYAML is used for the round-trip** (not `ruamel.yaml`). PyYAML drops YAML comments and +non-standard formatting from the operator file on write. Mitigation: the backup preserves the +original. This is an accepted trade-off; the operator file is user-editable but not expected to +carry extensive comments. + +**Why operator-wins + never-delete**: The operator may have customized `brother_part`, tweaked +`display_name`, or added custom media entries that have no default counterpart. Silently +overwriting these would break their setup. "Never delete" covers the case where an operator +has a physical roll for an entry a future default drops — their printer still works. + +**Why the baseline (3-source model, not 2)**: Without a baseline, distinguishing "operator +customized this field" from "operator left it at the old default value" is impossible when the +default changes. The baseline is the missing anchor that makes intent deterministic. + +**Considered**: +- `ruamel.yaml` for comment-preserving round-trips — deferred. `ruamel.yaml` is not in the + locked stack (PyYAML is); adding it would need its own ADR. The backup mitigates the loss. +- Simple overwrite on upgrade — rejected; clobbers operator customizations, the root cause of #16. +- Operator-file-wins on every field, no merge — rejected; defeats the purpose (SKU corrections + and new entries would never arrive). +- Delete entries removed from the default — rejected; custom media the operator added would + silently vanish. + +**Would revisit if**: comment-preserving round-trips become a strong operator ask (add +`ruamel.yaml` with an ADR); or the locked stack adds a structured YAML library with better +semantics. + +--- + +## 2026-06-04 — Template recall pre-fill uses print history; retention preserves latest job per template + +**Decision**: The "Load previous values" button on the recall form reads `field_values` from the newest `print_jobs` row for that template. No new table or column was needed — `field_values` was already stored at print time. `GET /api/templates/{name}/last-values` returns `{values, printed_at}`. + +Retention pruning (`prune_history`) now always exempts the highest `id` per `template_id` from deletion in both `last_n` and `last_days` modes (quick-print rows have `template_id = NULL` and are not protected). This is bounded by template count (single-user, small) and does not meaningfully undermine the configured N. + +**Why**: The data was already there; the feature is a query and a button. Protecting the latest job per template avoids a surprising edge case where `last_n = 1` + a burst of quick prints silently erases recall pre-fill. + +**Would revisit if**: A dedicated "last values" column is needed (e.g. to survive template deletion); at that point a migration to copy the latest values would be appropriate. + +--- + +## 2026-06-04 — Continuous label length measured from Pillow-rasterized text, not Fabric metrics + +**Decision**: For continuous-roll templates, `render_template` computes canvas height from PIL-measured text extents, not from Fabric's serialized `height`. Text elements are pre-rendered to PIL sub-images before the canvas is created; the sub-image's `.height` (measured with `multiline_textbbox`) drives `bottommost` for the continuous canvas sizing. Non-text elements (line, rect) still use `height * scaleY` from Fabric — those are reliable. + +`_render_text_element` also sizes its sub-image from the same PIL measurement (not `box_h` from Fabric), so text is never clipped inside its own element box regardless of media type. The draw origin is shifted by `-bbox[1]` to cancel any positive ascender gap, keeping the ink flush with the top of the sub-image without affecting the paste position (which is still taken from Fabric's `top`). + +**Why**: Fabric measures text with the browser's font engine; PIL measures with FreeType directly. The two disagree — PIL renders taller at the same `fontSize`, with the gap widening as font size grows. For continuous media, trusting Fabric's `height` produced a canvas too short to contain the last line. The root cause was the two-renderer divergence described in the 2026-05-20 server-side rendering ADR: "Divergence shows up as 'preview/print doesn't match the editor.'" This fix makes the server renderer authoritative for its own geometry. + +**Considered**: +- Correct only the canvas height, leave sub-image sized by Fabric `box_h` — rejected; text still clips inside its element box for die-cut media. +- Add a per-element measurement pass separate from the render pass — rejected; pre-rendering text elements once and reusing the sub-image is cleaner and avoids double font loads. + +**Would revisit if**: Fabric's font metrics are made to match PIL (e.g. by using the same font rendering engine server-side), at which point the browser measurement could be trusted for canvas sizing again. + +--- + +## 2026-06-03 — Two-color template rendering (supersedes "later slice" note) + +**Decision**: `render_template` now supports two-color (black + red) media. When `label.color == 1` (e.g. `62red` / DK-2251) the renderer returns a mode-`RGB` image instead of mode-`L`: black pixels are `(0,0,0)`, red pixels are `(255,0,0)`, paper is `(255,255,255)`. The print path in `printer/client.py` already promoted `L→RGB` and passed `red=True` for two-color media (2026-05-31 ADR) — it consumes the RGB image correctly without change. Text color comes from the Fabric element's `fill` property (`#000000` / `#ff0000`); lines use `stroke`; rects use `fill`/`stroke`. The template-preview PNG also returns RGB (color-accurate) instead of the threshold-crushed mono. + +The "Two-color (62red) rendering is a later slice; always renders mono" docstring note is superseded by this implementation. + +**Why**: Two-color DK rolls are a first-class media in the catalog and the most useful differentiated feature of those rolls. The print path infrastructure was already in place; only the renderer and frontend controls were missing. QR/barcode elements remain mono (raised as `RenderError`) — a separate fix is needed for those. + +**Considered**: +- Separate `render_template_color()` function — rejected; branching on `label.color` inside the existing function keeps the call-site unchanged. +- Always return RGB — rejected; doubles memory for mono jobs and changes the mono threshold behavior. + +**Would revisit if**: QR/barcode color rendering is implemented (extend the two-color path to those element types). + +--- + +## 2026-06-03 — Template media retargeting: Save As only; live media switch is not in scope + +**Decision**: A template is locked to its label media. The editor toolbar exposes a **Save As** button that clones the template to a new name and optionally a new media via `POST /api/templates/{name}/duplicate`. No dropdown or control that mutates the open template's `label_media` exists in the editor. The current media is shown as a read-only badge next to the template name. + +**Why**: Mutating a template's media in the editor would silently invalidate all saved element positions (they're in print-DPI pixel coordinates sized for the original media). Save As makes the user explicitly create a new template and adjust the layout, preventing silent corruption. This matches the design in `docs/features/templates.md` ("A template is locked to its label media"). + +**Considered**: +- A live media-switch dropdown in the editor that rescales element positions — rejected; position rescaling is lossy (different aspect ratios, different DPIs across form factors) and the complexity is not justified for a single-user homelab tool. +- Auto-redirect to a new template on media change (same as Save As, just triggered differently) — rejected; the explicit Save As click makes the copy intent clear and avoids accidental renames. + +**Would revisit if**: a "reflow to new media" feature is scoped in a PRD change with explicit rescaling rules. + +--- + +## 2026-06-02 — De-adopt vexp-context-engine (sunset) + +**Decision**: Removed the `vexp-context-engine` standard from this repo, following its v3.0.0 sunset (vexp retired homelab-wide — it didn't pay for its host-provisioning + guard-hook + per-session-rule tax). De-wired the repo per the v3.0.0 removal guide: deleted the guard hook + `.vexpignore`, the `mcp__vexp__*` allow entries and `PreToolUse` block in `.claude/settings.json`, the "Context search" section in `CLAUDE.md`, the vexp `.gitignore` block, and the on-disk `.vexp/` / auto-generated `.claude/CLAUDE.md`. `standards.md` row flipped to sunset. + +**Why**: The upstream standard is deprecated and instructs existing adopters to remove it. Coding agents return to normal `grep`/`glob`/`Read`. + +**Considered**: Keep vexp running locally despite the sunset (rejected — it's unmaintained, and the guard hook actively fights the agent's normal tools). + +**Would revisit if**: a maintained graph-RAG context engine is re-introduced homelab-wide. + +**Note**: Host-level teardown (uninstalling the vexp daemon/CLI on this WSL box) belongs to the `ansible` repo, not this app repo. + +--- + +## 2026-06-02 — App-level auth is optional (`DISABLE_AUTH`), default-on; proxy can own auth + +**Decision**: App-level Bearer auth becomes opt-out via a `DISABLE_AUTH` env flag. Default is unchanged and secure: auth on, and the app refuses to start without `API_TOKEN`. When `DISABLE_AUTH=true`, the `require_auth` dependency short-circuits (every `/api/*` route is open) and `GET /api/health` reports `auth_required: false` so the SPA skips its token gate. The intended deployment for the disabled mode is behind a reverse proxy (Traefik forward-auth / basic-auth) that authenticates instead. + +This amends **2026-05-19 — Auth** (the env token stays the default mechanism; it's now skippable, not removed). Multi-user accounts were considered and rejected again — single-user remains a hard non-goal. + +**Why**: Owner runs this behind Traefik and prefers to authenticate at the edge rather than maintain a second secret in the app. Per-app token auth is friction when the proxy already gates the route. + +**Considered**: +- User accounts / login (rejected — multi-user is a hard non-goal; `CLAUDE.md`). +- Rip auth out entirely (rejected — not reversible without a revert; an unconfigured instance would be silently open). The flag keeps default-secure behavior and is a one-line env change. +- Silently treat an empty `API_TOKEN` as "no auth" (rejected — too easy to ship an accidentally-open instance; disabling auth must be explicit). + +**Would revisit if**: we later want per-integration tokens (see 2026-05-19) or the proxy-auth assumption stops holding (e.g. exposing the app directly). + +--- + +## 2026-05-31 — Two-color DK rolls: print with `red=True`; tape color is not detectable from status + +**Decision**: +- `print_image()` passes `red=True` and an RGB image to `brother_ql.convert()` whenever the selected media is a two-color label (e.g. `62red` / DK-2251), even for black-only content. Without it the job declares mono media and the printer rejects it on the LCD as "Wrong roll: check the print data". +- The pre-print `media_compatible()` check treats rolls of identical physical dimensions (`tape_size`) as compatible rather than blocking on a guessed color. Differing sizes (e.g. 62 vs 29) still block. + +**Why**: The QL-800/810W/820NWB status protocol does **not** report tape/media color. Verified against Brother's official *Raster Command Reference QL-800/810W/820NWB*: the 32-byte `ESC i S` response carries media width (byte 10), media type = continuous/die-cut (byte 11), and media length (byte 17) only; bytes 12–14, 16, 23 and **24–31 are reserved/fixed `00h`**. The strings "tape color", "text color", "media color" do not appear in the document. The printer enforces the correct DK roll at print time by sensing the physical roll, but never surfaces color in status. So `62` and `62red` (both 62mm continuous, `tape_size (62, 0)`) are indistinguishable from a status read — the earlier plan to read `data[24]` for color (see the ESC i S ADR below) is not viable; that byte is always `00h`. Brother's own P-touch Editor doesn't auto-detect DK color either: the user manually picks the roll and a BK-RD vs Monochrome mode, and selecting Monochrome on a DK-2251 produces the same printer-side "wrong roll type" error ([Brother FAQ a_id/142492](https://help.brother-usa.com/app/answers/detail/a_id/142492/)). (Color *is* auto-detected on P-touch label makers, but only because TZe tape cassettes are physically keyed — DK rolls have no such keying.) + +**Considered**: (a) read `data[24]` for color — rejected, reserved `00h` per spec; (b) scrape the printer's `status.html` — rejected, it doesn't show color either; (c) key off the DK part number — we don't pass DK numbers to the library and can't read the loaded roll's part number. + +**Consequence**: The user picks `62` vs `62red` manually; the status panel labels a 62mm continuous roll generically. The "Loaded in printer" filter offers both same-size variants. A `red=True` job prints black-only fine (red plane left empty). + +**Would revisit if**: a future firmware/model exposes media color in the status response, or the library gains reliable DK color reading. + +--- + +## 2026-05-31 — Printer status over network: ESC i S unreliable; implement raw-TCP + HTTP fallback + +**Decision**: The Printer Status feature will use a two-path `status_read()` function in `printer/client.py`: + +1. **Primary — raw TCP ESC i S**: Directly instantiate `BrotherQLBackendNetwork(f"tcp://{host}")` (bypassing `get_printer()`, which raises `NotImplementedError` for the network backend), override `read_timeout` from the default 10 ms to the configured `printer_status_timeout_ms` value, then call `get_status()`. Parse the 32-byte response via `brother_ql.reader.interpret_response()`. For DK tape color detection, read `data[24]` directly (the library only parses `tape_color` for TZe-category tapes, not DK). + +2. **Fallback — HTTP scrape**: If the TCP path returns empty bytes, fetch `http://{host}/general/status.html` (unauthenticated) and parse `dt`/`dd` pairs for "Device Status" (→ ready bool) and "Media Type" (→ string like `"62mm x 29mm"` → regex-extract width/length → look up in `ALL_LABELS`). + +3. **Graceful degrade**: If both paths fail, raise `StatusUnavailable`; callers log and proceed (per `printer-status.md` print-path spec). + +Media ID mapping for both paths: search `brother_ql.labels.ALL_LABELS` where `lbl.tape_size == (width_mm, length_mm)`. For continuous 62 mm (length = 0), mono `"62"` and color `"62red"` share the same `tape_size` — disambiguate via `data[24]` on the TCP path; report `color_capable: false` (unknown) on the HTTP path. The exact DK-22251 tape-color byte value needs hardware verification when a two-color roll is loaded. + +**Why**: A spike against the QL-820NWB (2026-05-31) showed: +- `get_printer()` raises `NotImplementedError` for `backend="network"` (intentional library design; comment: "Not implemented due to lack of an available test device"). +- `send()` explicitly skips readback for the network backend: "The network backend doesn't support readback." +- The library's CLI `discover` command skips `get_status()` for the network backend. +- Live hardware test: TCP port 9100 connects, but ESC i S returns empty bytes regardless of timeout (10 ms, 500 ms, 2 s, 5 s all tested). Full raster init sequence (200 null bytes + ESC @ + ESC i a 01 + ESC i S) also returns empty. +- The HTTP interface at `/general/status.html` responded without authentication and returned "READY" + "62mm x 29mm". + +`get_status()` is technically callable via direct backend instantiation (the function sends ESC i S and reads; it does not filter on the backend type), so the TCP path is kept as primary in case it works on different firmware versions or printer states. The 10 ms default `read_timeout` is the likely cause of spurious failures if the printer ever does respond. + +**Considered**: +- HTTP-only: simpler, but abandons ESC i S even on printers/firmware where it works; provides no color-capability information. +- Pure TCP with no HTTP fallback: leaves us with 503 on every status call against the current hardware. +- Patching `get_printer()` via a fork: rejected — no fork policy without an ADR; bypass by direct instantiation is sufficient. + +**Would revisit if**: A firmware update makes ESC i S respond on the QL-820NWB (at which point the HTTP fallback can be dropped); or we test on a USB backend where `get_printer()` and `get_status()` work as intended. + +--- + +## 2026-05-31 — History UI: authed image loading via fetch+objectURL + +**Decision**: `/api/history/{id}/preview.png` requires a bearer token, so `` bare URLs 401. The history page fetches previews via `fetch()` with the `Authorization` header, creates an object URL via `URL.createObjectURL(blob)`, and sets that as `img.src`. Object URLs are revoked on page remount and on filter/pagination resets via a generation counter that skips stale async completions. + +**Why**: Matches the existing pattern used by `previewQuick` and `previewTemplate` in `api.ts`. Keeps the token out of query strings (which appear in server logs and browser history). + +**Considered**: Embedding tokens in query strings (`?token=...`) — rejected (leaks credential). Server-side session cookies — out of scope (auth is a single bearer token). + +**Revisit if**: The session adopts cookie-based auth, at which point `` works without a fetch wrapper. + +--- + +## 2026-05-31 — History UI: "Load more" pagination over prev/next + +**Decision**: The `/history` page uses a "Load more" button (appending to the list) rather than prev/next page navigation. + +**Why**: Simpler DOM management; no need to track current page number or re-render the full list on page change. Works well with the "most recent first" order where users typically care about the top of the list. + +**Revisit if**: The history list grows large enough that scrolling becomes painful, at which point a fixed-size window with prev/next would be preferable. + +--- + +## 2026-05-31 — Print history: preview stored as file on disk, not inline BLOB + +**Decision**: Preview images for print history are stored as PNG files under `${DATA_DIR}/label-previews/{job_id}.png`. The `print_jobs.preview_path` column stores the filename (e.g. `"42.png"`); the history preview route resolves the full path at request time. Previews are written after INSERT (job_id is needed for the filename), so rows exist briefly with `preview_path = NULL`. If preview write fails, the row is kept with `preview_path = NULL` and the preview route returns 404 for that job. + +**Why the schema already chose this**: The live `print_jobs` schema already had a `preview_path TEXT NULL` column — not a `preview_png BLOB` column. `history.md` specified a BLOB; the live schema diverged (likely because the data-path contract already reserves `label-previews/` and keeping SQLite small is a long-standing goal). Aligning the implementation with the live schema avoids a destructive migration. + +**Why files over BLOBs in general**: Keeps the SQLite file small at scale; HTTP serving (FileResponse) is simpler for binary content; preview files can be examined or deleted directly without touching the DB. For a homelab with retention pruning the footprint is bounded. + +**Consequence**: `docs/features/history.md` data model updated to reflect `preview_path` (file ref) rather than `preview_png` (BLOB). A missing or deleted preview file returns 404 from the preview route; the frontend should render a placeholder rather than erroring. + +**History frontend deferred**: The `/history` page and retention-settings UI are Slice B — not built in this slice. + +**Revisit if**: preview files become inconvenient to manage (backup, migration) compared to keeping everything in one SQLite file — at that point a BLOB column is a viable alternative. + +--- + +## 2026-05-31 — Adopt four homelab-configs standards; flip commits to Conventional-Commits prefixes + +**Decision**: Adopt `code-checkin-and-pr @ v1.1.0`, upgrade `handoff-prompt-workflow` to `v1.5.0`, adopt `repo-sandbox-permissions @ v1.0.0` (repo-wide), and formalize `vexp-context-engine @ v2.1.0`. All four are pinned in the new root `standards.md`. As part of `code-checkin-and-pr`, commit messages now **require** Conventional-Commits prefixes (`feat:` / `fix:` / `chore:` / `docs:`). + +**Why**: The standards' adoption was incomplete and undocumented — only `handoff-prompt-workflow @ 1.0.0` was in the registry, `standards.md` didn't exist, and vexp was wired with drift (guard hook untracked, stale custom snippet). `standards.md` + verbatim CLAUDE-snippets make conformance auditable in-repo. + +**The reversal**: `CLAUDE.md` previously said *"No conventional-commits prefixes."* This directly contradicted `code-checkin-and-pr`, which mandates them. We chose to flip the convention (adopt prefixes) rather than record a permanent deviation, so the standard is implemented cleanly. The "No co-author tags" rule is unchanged — it matches the standard. + +**Considered**: (a) Keep no-prefix commits and document a partial-adoption deviation in `standards.md` Notes — rejected; a deviation on the standard's most visible rule undercuts the point of adopting it. (b) Add an Alembic migration system to satisfy CI check #3 — rejected as out of scope; the app uses raw SQLite, so the migration check is marked **N/A** in `standards.md`. + +**Revisit if**: the project gains a migration system (wire CI check #3 then), or the GPU offload for vexp's local LLM is provisioned on this host (update the `vexp-context-engine` Notes row from CPU-only). + +## 2026-05-26 — Printer↔label compatibility is library-derived, computed at catalog load; `printer_requirements` deprecated + +**Decision**: A label's printability on the configured printer is computed at catalog load from primitive `brother_ql` fields, not declared in `labels.yml`. Each catalog entry gains `restricted_to_models` (`Label.restricted_to_models`), `color` (`Label.color`), a computed `supported: bool`, and `incompatible_reason: str | None`. The rule: + +```python +supported = (not label.restricted_to_models or printer_model in label.restricted_to_models) \ + and (label.color == 0 or model.two_color) +``` + +`model` is the entry in `brother_ql.models.ALL_MODELS` whose `identifier == PRINTER_MODEL`; `two_color` flags the QL-800 series. If the configured model isn't found, a warning is logged and all media are treated as supported. Selectors render unsupported media as disabled+greyed `