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..74c97a5
--- /dev/null
+++ b/frontend/src/components/LabelLayoutPreview.vue
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+
+
+ {{ t.name }}
+
+
{{ t.description }}
+
+
+
+
+
+
+
+
+
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..c514040
--- /dev/null
+++ b/frontend/src/label-drawing.js
@@ -0,0 +1,329 @@
+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. `text` can also be
+// an array of field names, one per printed line, for a stacked multi-line layout. A template is
+// only selectable once every field it names is actually available (see templateIsAvailable).
+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: "owner-id-text", name: "Owner + item ID", qr: null, text: ["userHandle", "itemId"],
+ description: "The owner's handle and the item id, as two lines of text - no code."},
+ {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."},
+ {id: "item-url-qr-owner-id", name: "Item URL + owner + ID", qr: "itemUrl", text: ["userHandle", "itemId"],
+ description: "Scannable item URL, with the owner's handle and the item id on two lines alongside."},
+];
+
+// keysOf/templateIsAvailable/templateContent are the single place that understands the `qr`/
+// `text` field-name shape above (including `text` sometimes being an array) - both
+// LabelLayoutPreview.vue's thumbnail grid and Print.vue's big preview resolve a template through
+// these rather than each re-implementing the same lookup.
+function keysOf(spec) {
+ if (!spec) {
+ return [];
+ }
+ return Array.isArray(spec) ? spec : [spec];
+}
+
+export function templateIsAvailable(t, fields) {
+ return [...keysOf(t.qr), ...keysOf(t.text)].every(key => fields[key] !== undefined);
+}
+
+/* Resolves a template's field names against actual field values. `text` comes back as an array
+ whenever the template's `text` spec is an array (multi-line), or a plain string otherwise -
+ drawLabel/drawFallbackLabel below accept either. */
+export function templateContent(t, fields) {
+ const resolve = (spec) => {
+ if (!spec) {
+ return null;
+ }
+ return Array.isArray(spec) ? spec.map(key => fields[key]) : fields[spec];
+ };
+ return {qr: resolve(t.qr), text: resolve(t.text)};
+}
+
+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 every one of `lines` within
+ maxWidth, stacked within maxHeight - this is a label, not a paragraph, so each line shrinks to
+ fit rather than wrapping. */
+function fitTextSize(ctx, lines, maxWidth, maxHeight) {
+ const minPx = 8;
+ let px = Math.max(minPx, Math.floor(maxHeight / lines.length));
+ while (px > minPx && lines.some(line => measureAtHeight(ctx, line, 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 lines = Array.isArray(textContent) ? textContent : [textContent];
+ 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, lines, textBudget, availableHeight);
+ const textWidth = Math.max(...lines.map(line => measureAtHeight(measureCtx, line, 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";
+ // Lines stack as a block vertically centered in the label, rather than each line centered on
+ // its own - keeps a two-line block reading as one unit instead of drifting apart.
+ const lineHeight = Math.ceil(textPx * 1.15);
+ let y = (canvas.height - lineHeight * lines.length) / 2 + lineHeight / 2;
+ for (const line of lines) {
+ ctx.fillText(line, cursor, y);
+ y += lineHeight;
+ }
+}
+
+/* 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;
+ const lines = Array.isArray(textContent) ? textContent : [textContent];
+
+ 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, lines, Infinity, FALLBACK_LABEL_HEIGHT_PX);
+ const textWidth = Math.max(...lines.map(line => measureAtHeight(measureCtx, line, 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";
+ const lineHeight = Math.ceil(textPx * 1.15);
+ let y = (height - lineHeight * lines.length) / 2 + lineHeight / 2;
+ for (const line of lines) {
+ ctx.fillText(line, cursor, y);
+ y += lineHeight;
+ }
+}
+
+/* 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/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/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/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..40f5b63 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>
-
+
+
@@ -138,10 +145,12 @@