From 95ddb484eb47bb394c3df03c084e052995eeb036 Mon Sep 17 00:00:00 2001 From: jedi Date: Mon, 17 Aug 2026 19:30:48 +0200 Subject: [PATCH 1/3] stash --- frontend/src/router.js | 5 +- frontend/src/views/InventoryDetail.vue | 8 +- frontend/src/views/Print.vue | 279 ++++++++++++++++++++++++- 3 files changed, 275 insertions(+), 17 deletions(-) diff --git a/frontend/src/router.js b/frontend/src/router.js index 0cf663c..14ccfb5 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -78,7 +78,10 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, { path: '/print', component: Print, meta: {requiresAuth: true}, - props: route => ({prefill: route.query.text}) + props: route => { + const {kind, ...components} = route.query; + return {prefill: kind ? {kind, components} : null}; + } }, { path: '/search/:query', component: Search, diff --git a/frontend/src/views/InventoryDetail.vue b/frontend/src/views/InventoryDetail.vue index 780afdb..6de684b 100644 --- a/frontend/src/views/InventoryDetail.vue +++ b/frontend/src/views/InventoryDetail.vue @@ -47,7 +47,7 @@ Delete @@ -86,12 +86,6 @@ export default { }, location() { return this.storage_locations.find(loc => loc.id === this.item.storage_location) || null - }, - itemUrl() { - // The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what - // a printed label actually encodes, since scanning it has to resolve the right - // frontend/backend/item with no other context, not just this browser's history. - return `${window.location.origin}/i/${this.user}/${this.id}` } }, methods: { diff --git a/frontend/src/views/Print.vue b/frontend/src/views/Print.vue index b414b85..a72ad58 100644 --- a/frontend/src/views/Print.vue +++ b/frontend/src/views/Print.vue @@ -130,6 +130,31 @@ + +
+
+
+
+
Label layout
+
+
+
+
+ +
+ {{ t.name }} +
+
{{ t.description }}
+
+
+
+
+
+
@@ -142,6 +167,7 @@ import QRCode from "qrcode"; import BaseLayout from "@/components/BaseLayout.vue"; import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js"; +import {buildLabelContent} from "@/label-content.js"; // Served verbatim from public/vendor/ rather than bundled: libweblabel.js's // own emscripten glue resolves its .wasm sibling relative to *its own* @@ -215,6 +241,110 @@ function drawQrLabel(canvas, qr, tape) { } } +const LABEL_TEMPLATES = [ + {id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest."}, + {id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it."}, + {id: "text", name: "Text only", description: "No code, just the text itself, as large as it fits."}, +]; + +function measureAtHeight(ctx, text, px) { + ctx.font = `${px}px sans-serif`; + return ctx.measureText(text).width; +} + +/* Picks the largest integer font size (down to a floor) that fits `text` on one line within + maxWidth - this is a label, not a paragraph, so we shrink to fit rather than wrap. */ +function fitTextSize(ctx, text, maxWidth, maxHeight) { + const minPx = 8; + let px = Math.max(minPx, Math.floor(maxHeight)); + while (px > minPx && measureAtHeight(ctx, text, px) > maxWidth) { + px -= 1; + } + return px; +} + +/* The qr-text/text-only layouts, tape-fed. Kept separate from drawQrLabel above (rather than + generalizing it) so the plain QR-only path - the common case - is untouched by this. */ +function drawLabelWithText(canvas, value, tape, templateId) { + const showQr = templateId === "qr-text"; + const qr = showQr ? QRCode.create(value) : null; + const availableHeight = tape.printAreaPx; + const maxLength = tape.printLengthPx + ? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX + : Infinity; + + let scale = 0, qrSize = 0; + if (qr) { + const modules = qr.modules.size + QUIET_ZONE_MODULES * 2; + // The QR only gets half the length budget on a fixed-length tape, so a long text value + // can't starve it down to unreadable - the rest goes to the text next to it. + const qrBudget = maxLength === Infinity ? Infinity : maxLength / 2; + scale = Math.floor(Math.min(availableHeight, qrBudget) / modules); + if (!(scale >= 1)) { + throw new Error("This text needs a bigger QR code than the tape allows — " + + "try a shorter value, a wider tape, or the text-only layout."); + } + qrSize = modules * scale; + } + + const gap = qr ? Math.round(availableHeight * 0.15) : 0; + const textBudget = maxLength === Infinity ? Infinity : maxLength - qrSize - gap; + if (!(textBudget > 0)) { + throw new Error("No room left for the text next to the QR code on this tape — " + + "try a wider tape or the QR-only layout."); + } + const measureCtx = canvas.getContext("2d"); + const textPx = fitTextSize(measureCtx, value, textBudget, availableHeight); + const textWidth = measureAtHeight(measureCtx, value, textPx); + if (textBudget !== Infinity && textWidth > textBudget) { + throw new Error("This text doesn't fit on this tape even at the smallest readable size — " + + "try a shorter value, a wider tape, or a bigger label."); + } + + const contentWidth = qrSize + gap + textWidth; + const width = tape.printLengthPx || Math.ceil(contentWidth + tape.leadPx + TRAILING_PADDING_PX); + canvas.width = width; + canvas.height = availableHeight; + + const ctx = canvas.getContext("2d", {willReadFrequently: true}); + ctx.fillStyle = "#fff"; + ctx.fillRect(0, 0, width, canvas.height); + ctx.fillStyle = "#000"; + + let cursor = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - contentWidth) / 2); + + if (qr) { + const top = Math.floor((canvas.height - qrSize) / 2); + for (let row = 0; row < qr.modules.size; row++) { + for (let col = 0; col < qr.modules.size; col++) { + if (qr.modules.get(row, col)) { + ctx.fillRect( + cursor + (col + QUIET_ZONE_MODULES) * scale, + top + (row + QUIET_ZONE_MODULES) * scale, + scale, scale, + ); + } + } + } + cursor += qrSize + gap; + } + + ctx.font = `${textPx}px sans-serif`; + ctx.textBaseline = "middle"; + ctx.textAlign = "left"; + ctx.fillText(value, cursor, canvas.height / 2); +} + +/* Dispatches to the right tape-fed layout - drawQrLabel is untouched so the default QR-only + template keeps its exact original pixel output. */ +function drawLabel(canvas, value, tape, templateId) { + if (templateId === "qr") { + drawQrLabel(canvas, QRCode.create(value), tape); + } else { + drawLabelWithText(canvas, value, tape, templateId); + } +} + const FALLBACK_SCALE_PX = 8; /* pixels per QR module in the no-webusb preview/PNG */ const FALLBACK_QUIET_ZONE_MODULES = 4; /* the spec's usual quiet zone - there's no printer feed margin to lean on here */ @@ -243,6 +373,70 @@ function drawQrSquare(canvas, qr) { } } +const FALLBACK_LABEL_HEIGHT_PX = 200; /* target content height for the qr-text/text-only fallback layouts */ +const FALLBACK_TEXT_MARGIN_PX = 16; /* left/right margin around a text-only/qr-text fallback label */ + +/* The qr-text/text-only layouts for the no-webusb fallback preview/PNG. There's no real tape + to fit into here, so - unlike drawLabelWithText - the canvas just grows to fit its content. */ +function drawFallbackLabelWithText(canvas, value, templateId) { + const showQr = templateId === "qr-text"; + const qr = showQr ? QRCode.create(value) : null; + + let scale = 0, qrSize = 0; + if (qr) { + const modules = qr.modules.size + FALLBACK_QUIET_ZONE_MODULES * 2; + scale = Math.max(1, Math.floor(FALLBACK_LABEL_HEIGHT_PX / modules)); + qrSize = modules * scale; + } + + const gap = qr ? Math.round(FALLBACK_LABEL_HEIGHT_PX * 0.15) : 0; + const measureCtx = canvas.getContext("2d"); + const textPx = fitTextSize(measureCtx, value, Infinity, FALLBACK_LABEL_HEIGHT_PX); + const textWidth = measureAtHeight(measureCtx, value, textPx); + + const height = Math.max(qrSize, FALLBACK_LABEL_HEIGHT_PX); + const width = qrSize + gap + textWidth + FALLBACK_TEXT_MARGIN_PX * 2; + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext("2d", {willReadFrequently: true}); + ctx.fillStyle = "#fff"; + ctx.fillRect(0, 0, width, height); + ctx.fillStyle = "#000"; + + let cursor = FALLBACK_TEXT_MARGIN_PX; + if (qr) { + const top = Math.floor((height - qrSize) / 2); + for (let row = 0; row < qr.modules.size; row++) { + for (let col = 0; col < qr.modules.size; col++) { + if (qr.modules.get(row, col)) { + ctx.fillRect( + cursor + (col + FALLBACK_QUIET_ZONE_MODULES) * scale, + top + (row + FALLBACK_QUIET_ZONE_MODULES) * scale, + scale, scale, + ); + } + } + } + cursor += qrSize + gap; + } + + ctx.font = `${textPx}px sans-serif`; + ctx.textBaseline = "middle"; + ctx.textAlign = "left"; + ctx.fillText(value, cursor, height / 2); +} + +/* Dispatches to the right fallback layout - drawQrSquare is untouched so the default QR-only + template keeps its exact original pixel output. */ +function drawFallbackLabel(canvas, value, templateId) { + if (templateId === "qr") { + drawQrSquare(canvas, QRCode.create(value)); + } else { + drawFallbackLabelWithText(canvas, value, templateId); + } +} + export default { name: "Print", components: { @@ -250,11 +444,12 @@ export default { ...BIcons }, props: { - // Prefilled from ?text=… when arriving from e.g. an item's "Print label" button - // (see InventoryDetail.vue) - the router turns that query param into this prop - // (router.js's /print route), rather than the component reading $route directly. + // {kind, components} prefilled from the ?kind=…&… query params when arriving from e.g. + // an item's "Print label" button (see InventoryDetail.vue) - the router turns those + // query params into this prop (router.js's /print route), rather than the component + // reading $route directly. buildLabelContent turns it into the literal string below. prefill: { - type: String, + type: Object, default: null } }, @@ -270,8 +465,9 @@ export default { tape: null, labelBitmap: null, - value: this.prefill || "", + value: buildLabelContent(this.prefill), copies: 1, + selectedTemplate: LABEL_TEMPLATES[0].id, fallbackReady: false, // TODO: replace with the real commands for our printers. @@ -280,6 +476,9 @@ export default { }; }, computed: { + labelTemplates() { + return LABEL_TEMPLATES; + }, deviceRows() { if (!this.blob) { return []; @@ -301,6 +500,14 @@ export default { }, watch: { value() { + this.redrawTemplatePreviews(); + if (this.usbSupported) { + this.redraw(); + } else { + this.redrawFallback(); + } + }, + selectedTemplate() { if (this.usbSupported) { this.redraw(); } else { @@ -395,8 +602,7 @@ export default { } this.resizeObserver.observe(canvas.parentElement); try { - const qr = QRCode.create(this.value); - drawQrLabel(canvas, qr, this.tape); + drawLabel(canvas, this.value, this.tape, this.selectedTemplate); } catch (e) { this.error = e.message; return; @@ -419,8 +625,7 @@ export default { } this.resizeObserver.observe(canvas.parentElement); try { - const qr = QRCode.create(this.value); - drawQrSquare(canvas, qr); + drawFallbackLabel(canvas, this.value, this.selectedTemplate); } catch (e) { this.error = e.message; return; @@ -430,6 +635,38 @@ export default { this.fitZoom(canvas); }, + setTemplateCanvasRef(id, el) { + if (el) { + this.templateCanvases[id] = el; + } else { + delete this.templateCanvases[id]; + } + }, + + /* Live per-template thumbnails in the "Label layout" card. Always uses the + content-fit fallback renderer (rather than the tape-fed one), regardless of + whether a real printer is connected - these are illustrative previews sized by + CSS object-fit, not the accurate to-be-printed canvas the main preview is. */ + redrawTemplatePreviews() { + for (const t of LABEL_TEMPLATES) { + const canvas = this.templateCanvases[t.id]; + if (!canvas) { + continue; + } + if (!this.value) { + canvas.width = 1; + canvas.height = 1; + continue; + } + try { + drawFallbackLabel(canvas, this.value, t.id); + } catch (e) { + // A thumbnail that can't render at this content length just stays blank + // rather than surfacing an error for every keystroke. + } + } + }, + downloadPng() { const canvas = this.$refs.fallbackCanvas; if (!canvas) { @@ -485,9 +722,18 @@ export default { this.blob = null; this.resizeObserver = null; this.resizeTimer = null; + // Keyed by template id, populated by setTemplateCanvasRef() - a plain :ref="t.id" string + // inside v-for would still get Vue's refInFor array-collecting behavior even though + // each iteration uses a different name, turning this.$refs[t.id] into a one-element + // array rather than the canvas itself. A function ref sidesteps that entirely. + this.templateCanvases = {}; }, async mounted() { this.resizeObserver = new ResizeObserver(this.handleContainerResize); + // Unlike fallbackCanvas/labelCanvas, the thumbnail canvases in the "Label layout" card + // aren't behind a v-if on usbSupported, so their refs already exist here - no nextTick + // needed before this one. + this.redrawTemplatePreviews(); if (!("usb" in navigator)) { this.usbSupported = false; await nextTick(); @@ -543,4 +789,19 @@ export default { white-space: pre-wrap; word-break: break-all; } + +.template-option { + width: 20rem; +} + +.template-thumb-canvas { + display: block; + width: 100%; + height: 10rem; + /* The canvas itself is drawn at whatever size fits its content (see + redrawTemplatePreviews/drawFallbackLabel) - object-fit scales that down to the + thumbnail box the same way it would for an , no manual zoom math needed. */ + object-fit: contain; + background: #fff; +} From 8622e488a444d65a852d8e979b74140d925eec66 Mon Sep 17 00:00:00 2001 From: jedi Date: Mon, 17 Aug 2026 20:11:33 +0200 Subject: [PATCH 2/3] stash --- backend/backend/settings.py | 19 + backend/toolshed/api/info.py | 4 +- deploy/dev/Dockerfile.backend | 2 +- deploy/dev/Dockerfile.frontend | 1 + deploy/dev/docker-compose.yml | 3 + deploy/prod/Dockerfile.backend | 8 +- deploy/prod/Dockerfile.frontend | 8 + deploy/prod/playbook.yml | 4 + frontend/src/components/BaseLayout.vue | 11 + .../src/components/LabelLayoutPreview.vue | 138 +++++++ frontend/src/components/Sidebar.vue | 48 ++- frontend/src/label-content.js | 45 +++ frontend/src/label-drawing.js | 281 +++++++++++++ frontend/src/store.js | 4 + frontend/src/views/Print.vue | 378 ++---------------- frontend/vite.config.js | 22 + 16 files changed, 607 insertions(+), 369 deletions(-) create mode 100644 frontend/src/components/LabelLayoutPreview.vue create mode 100644 frontend/src/label-content.js create mode 100644 frontend/src/label-drawing.js diff --git a/backend/backend/settings.py b/backend/backend/settings.py index a1e0883..e56e4ab 100644 --- a/backend/backend/settings.py +++ b/backend/backend/settings.py @@ -10,12 +10,30 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/4.1/ref/settings/ """ import os +import subprocess import dotenv from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent + +def _git_commit(): + # deploy/dev/docker-compose.yml bind-mounts the repo's real .git dir at + # /git (separate from BASE_DIR, which only has the backend/ subtree) - + # point git at it explicitly there. Bare-metal dev has no such mount, but + # BASE_DIR sits inside the real checkout so plain rev-parse finds it by + # walking up. In prod, the build context is backend/ alone with no .git + # anywhere, so both fail and we fall back to the GIT_COMMIT build-arg/env + # var (see deploy/prod/Dockerfile.backend and playbook.yml). + cmd = ['git', '--git-dir=/git'] if os.path.isdir('/git') else ['git'] + try: + return subprocess.check_output( + [*cmd, 'rev-parse', '--short', 'HEAD'], cwd=BASE_DIR, stderr=subprocess.DEVNULL + ).decode().strip() + except (subprocess.CalledProcessError, FileNotFoundError, OSError): + return os.environ.get('GIT_COMMIT', 'unknown') + dotenv.load_dotenv(BASE_DIR / '.env') # Quick-start development settings - unsuitable for production @@ -30,6 +48,7 @@ DEBUG = os.environ.get('DEBUG', 'False').lower() == 'true' # Application definition TOOLSHED_VERSION = "0.0.0-dev.0" +GIT_COMMIT = _git_commit() INSTALLED_APPS = [ 'django.contrib.admin', diff --git a/backend/toolshed/api/info.py b/backend/toolshed/api/info.py index 7395c03..a99b398 100644 --- a/backend/toolshed/api/info.py +++ b/backend/toolshed/api/info.py @@ -7,14 +7,14 @@ from hostadmin.models import Domain from authentication.signature_auth import SignatureAuthentication from toolshed.models import Tag, Property, Category, InventoryItem from toolshed.serializers import CategorySerializer, PropertySerializer -from backend.settings import TOOLSHED_VERSION +from backend.settings import TOOLSHED_VERSION, GIT_COMMIT @api_view(['GET']) @permission_classes([]) @authentication_classes([]) def get_version(request, format=None): # /version/ - return Response({'version': TOOLSHED_VERSION}) + return Response({'version': TOOLSHED_VERSION, 'commit': GIT_COMMIT}) @api_view(['GET']) diff --git a/deploy/dev/Dockerfile.backend b/deploy/dev/Dockerfile.backend index 1134aba..d1f35f4 100644 --- a/deploy/dev/Dockerfile.backend +++ b/deploy/dev/Dockerfile.backend @@ -7,7 +7,7 @@ ENV PYTHONUNBUFFERED 1 # Set work directory WORKDIR /code - +RUN mkdir /git # Install dependencies COPY requirements.txt /code/ RUN pip install --no-cache-dir -r requirements.txt diff --git a/deploy/dev/Dockerfile.frontend b/deploy/dev/Dockerfile.frontend index 07f7c49..02b9e79 100644 --- a/deploy/dev/Dockerfile.frontend +++ b/deploy/dev/Dockerfile.frontend @@ -3,6 +3,7 @@ FROM node:14 # Set work directory WORKDIR /app +RUN mkdir /git # Install app dependencies # A wildcard is used to ensure both package.json AND package-lock.json are copied diff --git a/deploy/dev/docker-compose.yml b/deploy/dev/docker-compose.yml index abd20f2..30ed12a 100644 --- a/deploy/dev/docker-compose.yml +++ b/deploy/dev/docker-compose.yml @@ -12,6 +12,7 @@ services: TOOLSHED_SETUP_PATH: /mnt/testdata.py volumes: - ../../backend:/code + - ../../.git:/git:ro - ./instance_a/a.env:/code/.env - ./instance_a/testdata.py:/mnt/testdata.py - ./instance_a/a.sqlite3:/mnt/db.sqlite3 @@ -30,6 +31,7 @@ services: TOOLSHED_SETUP_PATH: /mnt/testdata.py volumes: - ../../backend:/code + - ../../.git:/git:ro - ./instance_b/b.env:/code/.env - ./instance_b/testdata.py:/mnt/testdata.py - ./instance_b/b.sqlite3:/mnt/db.sqlite3 @@ -44,6 +46,7 @@ services: dockerfile: ../deploy/dev/Dockerfile.frontend volumes: - ../../frontend:/app + - ../../.git:/git:ro - /app/node_modules expose: - 5173 diff --git a/deploy/prod/Dockerfile.backend b/deploy/prod/Dockerfile.backend index b6cf9af..d64a6fd 100644 --- a/deploy/prod/Dockerfile.backend +++ b/deploy/prod/Dockerfile.backend @@ -5,9 +5,15 @@ FROM python:3.11-slim +# The build context here is just backend/ (no .git), so settings.py's own +# `git rev-parse` fallback can't find a repo - the actual commit is passed +# in from the real checkout via this build-arg instead (see playbook.yml). +ARG GIT_COMMIT=unknown + ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ - DJANGO_SETTINGS_MODULE=backend.settings + DJANGO_SETTINGS_MODULE=backend.settings \ + GIT_COMMIT=$GIT_COMMIT WORKDIR /app diff --git a/deploy/prod/Dockerfile.frontend b/deploy/prod/Dockerfile.frontend index 12dec8d..1def462 100644 --- a/deploy/prod/Dockerfile.frontend +++ b/deploy/prod/Dockerfile.frontend @@ -6,6 +6,14 @@ FROM node:20-alpine AS build WORKDIR /app + +# The build context here is just frontend/ (no .git), so vite.config.js's +# own `git rev-parse` fallback can't find a repo - the actual commit is +# passed in from the real checkout via this build-arg instead (see +# playbook.yml). +ARG GIT_COMMIT=unknown +ENV GIT_COMMIT=$GIT_COMMIT + COPY package.json package-lock.json ./ COPY extras/ ./extras/ RUN npm ci diff --git a/deploy/prod/playbook.yml b/deploy/prod/playbook.yml index d46c406..aef70b6 100644 --- a/deploy/prod/playbook.yml +++ b/deploy/prod/playbook.yml @@ -294,6 +294,7 @@ # pinned commit isn't fetchable from upstream - don't let a broken # submodule block the checkout. recursive: false + register: toolshed_checkout - name: Create toolshed system user ansible.builtin.user: @@ -375,6 +376,7 @@ ansible.builtin.command: cmd: >- docker build -t {{ toolshed_backend_image }}:{{ toolshed_image_tag }} + --build-arg GIT_COMMIT={{ toolshed_checkout.after[:7] }} -f {{ toolshed_src_dir }}/deploy/prod/Dockerfile.backend {{ toolshed_src_dir }}/backend changed_when: true notify: restart backend @@ -462,6 +464,7 @@ version: "{{ toolshed_version | default('stable') }}" force: true recursive: false + register: toolshed_frontend_checkout delegate_to: localhost become: false @@ -469,6 +472,7 @@ ansible.builtin.command: cmd: >- docker build -t {{ toolshed_frontend_image }}:{{ toolshed_image_tag }} + --build-arg GIT_COMMIT={{ toolshed_frontend_checkout.after[:7] }} -f {{ toolshed_frontend_build_src_dir }}/deploy/prod/Dockerfile.frontend {{ toolshed_frontend_build_src_dir }}/frontend changed_when: true delegate_to: localhost diff --git a/frontend/src/components/BaseLayout.vue b/frontend/src/components/BaseLayout.vue index d9a1ac2..3a489d0 100644 --- a/frontend/src/components/BaseLayout.vue +++ b/frontend/src/components/BaseLayout.vue @@ -74,11 +74,22 @@ export default { width: 100%; min-width: 0; min-height: 100vh; + margin-left: 260px; transition: margin-left .35s ease-in-out, left .35s ease-in-out, margin-right .35s ease-in-out, right .35s ease-in-out; flex-direction: column; overflow: hidden; } +.main.expanded { + margin-left: 0; +} + +@media (min-width: 1px) and (max-width: 991.98px) { + .main, .main.expanded { + margin-left: 0; + } +} + .navbar-expand { flex-wrap: nowrap; justify-content: flex-start; diff --git a/frontend/src/components/LabelLayoutPreview.vue b/frontend/src/components/LabelLayoutPreview.vue new file mode 100644 index 0000000..4b7c481 --- /dev/null +++ b/frontend/src/components/LabelLayoutPreview.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/frontend/src/components/Sidebar.vue b/frontend/src/components/Sidebar.vue index de5fc9a..be71d2a 100644 --- a/frontend/src/components/Sidebar.vue +++ b/frontend/src/components/Sidebar.vue @@ -58,6 +58,7 @@ + @@ -70,31 +71,37 @@ export default { components: { ...BIcons }, + data() { + return { + frontendCommit: __GIT_COMMIT__, + backendCommit: null, + } + }, + computed: { + versionTitle() { + return `frontend build: ${this.frontendCommit}, backend: ${this.backendCommit || 'unknown'}` + } + }, + async mounted() { + try { + const {commit} = await this.$store.dispatch('fetchBackendVersion') + this.backendCommit = commit + } catch (e) { + console.error('could not fetch backend version', e) + } + }, } \ No newline at end of file diff --git a/frontend/src/label-content.js b/frontend/src/label-content.js new file mode 100644 index 0000000..1bb440a --- /dev/null +++ b/frontend/src/label-content.js @@ -0,0 +1,45 @@ +// Turns a {kind, components} prefill into the literal string a print label should show/encode. +// Keeping this keyed by `kind` rather than having each caller build its own string means the +// format for a given kind of label content only has to be gotten right in one place. +export const LABEL_CONTENT_BUILDERS = { + // The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a + // printed label actually encodes, since scanning it has to resolve the right + // frontend/backend/item with no other context, not just this browser's history. + "item-url": ({user, id}) => `${window.location.origin}/i/${user}/${id}`, +}; + +export function buildLabelContent(prefill) { + if (!prefill) { + return ""; + } + const build = LABEL_CONTENT_BUILDERS[prefill.kind]; + return build ? build(prefill.components) : ""; +} + +// Named fields the field-specific label templates (see label-drawing.js's LABEL_TEMPLATES) draw +// from - keyed by `kind` for the same reason LABEL_CONTENT_BUILDERS is. A field missing from the +// result (rather than present-but-empty) is what LabelLayoutPreview.vue treats as "not available", +// so builders should only include a field once its inputs actually check out. +const LABEL_FIELD_BUILDERS = { + "item-url": ({user, id}) => { + if (!user || !id) { + return {}; + } + return { + itemUrl: `${window.location.origin}/i/${user}/${id}`, + // The compact "owner handle + id" form from docs/design-in-progress/items-labels.md - + // meaningful only where context already makes clear it's a Toolshed item, unlike itemUrl. + itemHandle: `${user}:${id}`, + userHandle: user, + itemId: String(id), + }; + }, +}; + +export function buildLabelFields(prefill) { + if (!prefill) { + return {}; + } + const build = LABEL_FIELD_BUILDERS[prefill.kind]; + return build ? build(prefill.components) : {}; +} diff --git a/frontend/src/label-drawing.js b/frontend/src/label-drawing.js new file mode 100644 index 0000000..c78ae3e --- /dev/null +++ b/frontend/src/label-drawing.js @@ -0,0 +1,281 @@ +import QRCode from "qrcode"; + +const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */ +/* A quiet zone narrower than the spec's usual 4 modules: the printer's own + feed margin already keeps the code clear of the tape edge and the cut. */ +const QUIET_ZONE_MODULES = 2; + +/* Sizing a label needs numbers only the driver can supply. */ +export function tapeFromStatus(status) { + const printAreaPx = status?.tape?.printAreaPx; + const dpi = status?.printer?.dpi; + if (!(printAreaPx > 0) || !(dpi > 0)) { + return null; + } + return { + printAreaPx, + dpi, + mediaWidthMm: status.tape.mediaWidthMm, + printLengthPx: status.tape.printLengthPx > 0 ? status.tape.printLengthPx : 0, + /* Brother's documented margin for the mounted tape, in raster columns. */ + leadPx: status.tape.marginsMm + ? Math.round(status.tape.marginsMm * dpi / 25.4) + : TRAILING_PADDING_PX, + }; +} + +/* + Draw the QR code as large as the tape allows, centered in a square, with an + integer number of pixels per module so it stays crisp at printer + resolution rather than blurring at a fractional scale. +*/ +function drawQrLabel(canvas, qr, tape) { + const modules = qr.modules.size + QUIET_ZONE_MODULES * 2; + const maxLength = tape.printLengthPx + ? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX + : Infinity; + const scale = Math.floor(Math.min(tape.printAreaPx, maxLength) / modules); + if (!(scale >= 1)) { + throw new Error("This text needs a bigger QR code than the tape allows — " + + "try a shorter value or a wider tape."); + } + const square = modules * scale; + + const width = tape.printLengthPx || (square + tape.leadPx + TRAILING_PADDING_PX); + canvas.width = width; + canvas.height = tape.printAreaPx; + + const ctx = canvas.getContext("2d", {willReadFrequently: true}); + ctx.fillStyle = "#fff"; + ctx.fillRect(0, 0, width, canvas.height); + + const left = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - square) / 2); + const top = Math.floor((canvas.height - square) / 2); + ctx.fillStyle = "#000"; + for (let row = 0; row < qr.modules.size; row++) { + for (let col = 0; col < qr.modules.size; col++) { + if (qr.modules.get(row, col)) { + ctx.fillRect( + left + (col + QUIET_ZONE_MODULES) * scale, + top + (row + QUIET_ZONE_MODULES) * scale, + scale, scale, + ); + } + } + } +} + +// Each template names which field (see label-content.js's buildLabelFields) feeds its QR code +// and/or its printed text - `null` means that half of the layout is skipped. A template is only +// selectable once every field it names is actually available (see LabelLayoutPreview.vue). +export const LABEL_TEMPLATES = [ + {id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest.", + qr: "value", text: null}, + {id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.", + qr: "value", text: "value"}, + {id: "text", name: "Text only", description: "No code, just the text itself, as large as it fits.", + qr: null, text: "value"}, + {id: "item-handle", name: "Item handle", qr: null, text: "itemHandle", + description: "The compact owner@domain:id handle - meaningful in-app, not scannable on its own."}, + {id: "item-url", name: "Item URL", qr: null, text: "itemUrl", + description: "The full item URL as text, with no code - for copying rather than scanning."}, + {id: "owner-handle", name: "Owner handle", qr: null, text: "userHandle", + description: "Just the owning user's handle, as text only."}, + {id: "item-id", name: "Item ID", qr: null, text: "itemId", + description: "Just the bare item id, as text only."}, + {id: "item-url-qr-handle", name: "Item URL + handle", qr: "itemUrl", text: "itemHandle", + description: "Scannable item URL, with the item's compact handle printed alongside."}, + {id: "item-url-qr-owner", name: "Item URL + owner", qr: "itemUrl", text: "userHandle", + description: "Scannable item URL, with the owner's handle printed alongside."}, + {id: "item-url-qr-id", name: "Item URL + item ID", qr: "itemUrl", text: "itemId", + description: "Scannable item URL, with the bare item id printed alongside."}, +]; + +function measureAtHeight(ctx, text, px) { + ctx.font = `${px}px sans-serif`; + return ctx.measureText(text).width; +} + +/* Picks the largest integer font size (down to a floor) that fits `text` on one line within + maxWidth - this is a label, not a paragraph, so we shrink to fit rather than wrap. */ +function fitTextSize(ctx, text, maxWidth, maxHeight) { + const minPx = 8; + let px = Math.max(minPx, Math.floor(maxHeight)); + while (px > minPx && measureAtHeight(ctx, text, px) > maxWidth) { + px -= 1; + } + return px; +} + +/* The qr-text/text-only layouts, tape-fed. Kept separate from drawQrLabel above (rather than + generalizing it) so the plain QR-only path - the common case - is untouched by this. */ +function drawLabelWithText(canvas, tape, qrContent, textContent) { + const qr = qrContent ? QRCode.create(qrContent) : null; + const availableHeight = tape.printAreaPx; + const maxLength = tape.printLengthPx + ? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX + : Infinity; + + let scale = 0, qrSize = 0; + if (qr) { + const modules = qr.modules.size + QUIET_ZONE_MODULES * 2; + // The QR only gets half the length budget on a fixed-length tape, so a long text value + // can't starve it down to unreadable - the rest goes to the text next to it. + const qrBudget = maxLength === Infinity ? Infinity : maxLength / 2; + scale = Math.floor(Math.min(availableHeight, qrBudget) / modules); + if (!(scale >= 1)) { + throw new Error("This text needs a bigger QR code than the tape allows — " + + "try a shorter value, a wider tape, or the text-only layout."); + } + qrSize = modules * scale; + } + + const gap = qr ? Math.round(availableHeight * 0.15) : 0; + const textBudget = maxLength === Infinity ? Infinity : maxLength - qrSize - gap; + if (!(textBudget > 0)) { + throw new Error("No room left for the text next to the QR code on this tape — " + + "try a wider tape or the QR-only layout."); + } + const measureCtx = canvas.getContext("2d"); + const textPx = fitTextSize(measureCtx, textContent, textBudget, availableHeight); + const textWidth = measureAtHeight(measureCtx, textContent, textPx); + if (textBudget !== Infinity && textWidth > textBudget) { + throw new Error("This text doesn't fit on this tape even at the smallest readable size — " + + "try a shorter value, a wider tape, or a bigger label."); + } + + const contentWidth = qrSize + gap + textWidth; + const width = tape.printLengthPx || Math.ceil(contentWidth + tape.leadPx + TRAILING_PADDING_PX); + canvas.width = width; + canvas.height = availableHeight; + + const ctx = canvas.getContext("2d", {willReadFrequently: true}); + ctx.fillStyle = "#fff"; + ctx.fillRect(0, 0, width, canvas.height); + ctx.fillStyle = "#000"; + + let cursor = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - contentWidth) / 2); + + if (qr) { + const top = Math.floor((canvas.height - qrSize) / 2); + for (let row = 0; row < qr.modules.size; row++) { + for (let col = 0; col < qr.modules.size; col++) { + if (qr.modules.get(row, col)) { + ctx.fillRect( + cursor + (col + QUIET_ZONE_MODULES) * scale, + top + (row + QUIET_ZONE_MODULES) * scale, + scale, scale, + ); + } + } + } + cursor += qrSize + gap; + } + + ctx.font = `${textPx}px sans-serif`; + ctx.textBaseline = "middle"; + ctx.textAlign = "left"; + ctx.fillText(textContent, cursor, canvas.height / 2); +} + +/* Dispatches to the right tape-fed layout - drawQrLabel is untouched so the plain QR-only + layout keeps its exact original pixel output. `content` is {qr, text}, each either the string + to encode/print or null/undefined to skip that half of the layout (see LABEL_TEMPLATES). */ +export function drawLabel(canvas, tape, content) { + if (content.qr && !content.text) { + drawQrLabel(canvas, QRCode.create(content.qr), tape); + } else { + drawLabelWithText(canvas, tape, content.qr, content.text); + } +} + +const FALLBACK_SCALE_PX = 8; /* pixels per QR module in the no-webusb preview/PNG */ +const FALLBACK_QUIET_ZONE_MODULES = 4; /* the spec's usual quiet zone - there's no printer feed margin to lean on here */ + +/* Same idea as drawQrLabel, but without a real device to ask for tape dimensions: just a + plain square QR code, sized for a PNG someone downloads and prints some other way. */ +function drawQrSquare(canvas, qr) { + const modules = qr.modules.size + FALLBACK_QUIET_ZONE_MODULES * 2; + const size = modules * FALLBACK_SCALE_PX; + canvas.width = size; + canvas.height = size; + + const ctx = canvas.getContext("2d", {willReadFrequently: true}); + ctx.fillStyle = "#fff"; + ctx.fillRect(0, 0, size, size); + ctx.fillStyle = "#000"; + for (let row = 0; row < qr.modules.size; row++) { + for (let col = 0; col < qr.modules.size; col++) { + if (qr.modules.get(row, col)) { + ctx.fillRect( + (col + FALLBACK_QUIET_ZONE_MODULES) * FALLBACK_SCALE_PX, + (row + FALLBACK_QUIET_ZONE_MODULES) * FALLBACK_SCALE_PX, + FALLBACK_SCALE_PX, FALLBACK_SCALE_PX, + ); + } + } + } +} + +const FALLBACK_LABEL_HEIGHT_PX = 200; /* target content height for the qr-text/text-only fallback layouts */ +const FALLBACK_TEXT_MARGIN_PX = 16; /* left/right margin around a text-only/qr-text fallback label */ + +/* The qr-text/text-only layouts for the no-webusb fallback preview/PNG. There's no real tape + to fit into here, so - unlike drawLabelWithText - the canvas just grows to fit its content. */ +function drawFallbackLabelWithText(canvas, qrContent, textContent) { + const qr = qrContent ? QRCode.create(qrContent) : null; + + let scale = 0, qrSize = 0; + if (qr) { + const modules = qr.modules.size + FALLBACK_QUIET_ZONE_MODULES * 2; + scale = Math.max(1, Math.floor(FALLBACK_LABEL_HEIGHT_PX / modules)); + qrSize = modules * scale; + } + + const gap = qr ? Math.round(FALLBACK_LABEL_HEIGHT_PX * 0.15) : 0; + const measureCtx = canvas.getContext("2d"); + const textPx = fitTextSize(measureCtx, textContent, Infinity, FALLBACK_LABEL_HEIGHT_PX); + const textWidth = measureAtHeight(measureCtx, textContent, textPx); + + const height = Math.max(qrSize, FALLBACK_LABEL_HEIGHT_PX); + const width = qrSize + gap + textWidth + FALLBACK_TEXT_MARGIN_PX * 2; + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext("2d", {willReadFrequently: true}); + ctx.fillStyle = "#fff"; + ctx.fillRect(0, 0, width, height); + ctx.fillStyle = "#000"; + + let cursor = FALLBACK_TEXT_MARGIN_PX; + if (qr) { + const top = Math.floor((height - qrSize) / 2); + for (let row = 0; row < qr.modules.size; row++) { + for (let col = 0; col < qr.modules.size; col++) { + if (qr.modules.get(row, col)) { + ctx.fillRect( + cursor + (col + FALLBACK_QUIET_ZONE_MODULES) * scale, + top + (row + FALLBACK_QUIET_ZONE_MODULES) * scale, + scale, scale, + ); + } + } + } + cursor += qrSize + gap; + } + + ctx.font = `${textPx}px sans-serif`; + ctx.textBaseline = "middle"; + ctx.textAlign = "left"; + ctx.fillText(textContent, cursor, height / 2); +} + +/* Dispatches to the right fallback layout - drawQrSquare is untouched so the plain QR-only + layout keeps its exact original pixel output. `content` is {qr, text}, see drawLabel above. */ +export function drawFallbackLabel(canvas, content) { + if (content.qr && !content.text) { + drawQrSquare(canvas, QRCode.create(content.qr)); + } else { + drawFallbackLabelWithText(canvas, content.qr, content.text); + } +} diff --git a/frontend/src/store.js b/frontend/src/store.js index 560dee2..6befff7 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -359,6 +359,10 @@ export default createStore({ async getFriendServers({state, dispatch, commit}, {username}) { return dispatch('lookupServer', {username}).then(servers => new ServerSet(servers, state.unreachable_neighbors)) }, + async fetchBackendVersion({dispatch, getters}) { + const servers = await dispatch('getHomeServers') + return await servers.get(getters.nullAuth, '/api/version/') + }, async fetchInventoryItems({commit, dispatch, getters}) { const servers = await dispatch('getHomeServers') const items = await servers.get(getters.signAuth, '/api/inventory_items/') diff --git a/frontend/src/views/Print.vue b/frontend/src/views/Print.vue index a72ad58..8a209e4 100644 --- a/frontend/src/views/Print.vue +++ b/frontend/src/views/Print.vue @@ -25,7 +25,7 @@ placeholder="https://example.com/…" autofocus> -
+
@@ -73,7 +73,7 @@ placeholder="https://example.com/…" autofocus>
-
+
@@ -133,26 +133,8 @@
-
-
-
Label layout
-
-
-
-
- -
- {{ t.name }} -
-
{{ t.description }}
-
-
-
-
+
@@ -163,11 +145,12 @@