This commit is contained in:
j3d1 2026-08-17 20:11:33 +02:00
parent 95ddb484eb
commit 8622e488a4
16 changed files with 607 additions and 369 deletions

View file

@ -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',

View file

@ -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'])

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -0,0 +1,138 @@
<template>
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Label layout</h5>
</div>
<div class="card-body">
<div class="d-flex flex-wrap gap-3">
<div v-for="t in labelTemplates" :key="t.id" class="template-option text-center"
:class="{'template-option-disabled': !isAvailable(t)}"
:title="isAvailable(t) ? '' : 'Not available - open this page from an item to fill in the fields this layout needs.'"
role="button" @click="isAvailable(t) && $emit('input', t.id)">
<canvas :ref="el => setTemplateCanvasRef(t.id, el)"
class="img-thumbnail template-thumb-canvas"
:class="{'border-primary': value === t.id}"></canvas>
<div class="small mt-1"
:class="{'fw-bold text-primary': value === t.id}">
{{ t.name }}
</div>
<div class="small text-muted">{{ t.description }}</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.template-option {
width: 20rem;
}
.template-option-disabled {
opacity: .45;
cursor: not-allowed;
}
.template-thumb-canvas {
display: block;
width: 100%;
height: 10rem;
/* The canvas itself is drawn at whatever size fits its content (see redraw/drawFallbackLabel)
- object-fit scales that down to the thumbnail box the same way it would for an <img>,
no manual zoom math needed. */
object-fit: contain;
background: #fff;
}
</style>
<script>
import {LABEL_TEMPLATES, drawFallbackLabel} from "@/label-drawing.js";
export default {
name: "LabelLayoutPreview",
props: {
// Named content fields the templates draw from (see label-content.js's buildLabelFields)
// - kept in sync by the parent, not owned here. A field missing from this object (rather
// than present-but-empty) means a template that needs it is unavailable right now.
fields: {
type: Object,
required: true
},
// The selected template id.
value: {
type: String,
required: true
}
},
model: {
prop: "value",
event: "input"
},
emits: ["input"],
computed: {
labelTemplates() {
return LABEL_TEMPLATES;
}
},
watch: {
fields() {
this.redraw();
}
},
methods: {
setTemplateCanvasRef(id, el) {
if (el) {
this.templateCanvases[id] = el;
} else {
delete this.templateCanvases[id];
}
},
isAvailable(t) {
return [t.qr, t.text].filter(Boolean).every(key => this.fields[key] !== undefined);
},
contentFor(t) {
return {
qr: t.qr ? this.fields[t.qr] : null,
text: t.text ? this.fields[t.text] : null,
};
},
/* Live per-template thumbnails. 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. */
redraw() {
for (const t of LABEL_TEMPLATES) {
const canvas = this.templateCanvases[t.id];
if (!canvas) {
continue;
}
const content = this.contentFor(t);
if (!this.isAvailable(t) || (!content.qr && !content.text)) {
canvas.width = 1;
canvas.height = 1;
continue;
}
try {
drawFallbackLabel(canvas, content);
} catch (e) {
// A thumbnail that can't render at this content length just stays blank
// rather than surfacing an error for every keystroke.
}
}
}
},
created() {
// 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 = {};
},
mounted() {
this.redraw();
}
}
</script>

View file

@ -58,6 +58,7 @@
</router-link>
</li>
</ul>
<div class="sidebar-version" :title="versionTitle">{{ frontendCommit }}{{ backendCommit ? ' / ' + backendCommit : '' }}</div>
</div>
</nav>
</template>
@ -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)
}
},
}
</script>
<style scoped>
.sidebar {
position: fixed;
top: 0;
left: 0;
z-index: 1000;
min-width: 260px;
max-width: 260px;
direction: ltr;
}
.sidebar, .sidebar-content {
transition: margin-left .35s ease-in-out, left .35s ease-in-out, margin-right .35s ease-in-out, right .35s ease-in-out;
background: #222e3c;
}
.sidebar-content {
display: flex;
height: 100vh;
flex-direction: column;
}
.sidebar {
min-width: 260px;
max-width: 260px;
direction: ltr
}
@ -239,4 +246,11 @@ export default {
font-size: .75rem;
color: #ced4da
}
.sidebar-version {
padding: .5rem 1.5rem 1rem;
font-size: .7rem;
color: #ced4da4d;
text-align: center;
}
</style>

View file

@ -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) : {};
}

View file

@ -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);
}
}

View file

@ -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/')

View file

@ -25,7 +25,7 @@
placeholder="https://example.com/…" autofocus>
</div>
<div class="label-preview mb-3" v-show="value">
<div class="label-preview mb-3" v-show="selectedContent.qr || selectedContent.text">
<canvas ref="fallbackCanvas"></canvas>
</div>
@ -73,7 +73,7 @@
placeholder="https://example.com/…" autofocus>
</div>
<div class="label-preview mb-3" v-show="value">
<div class="label-preview mb-3" v-show="selectedContent.qr || selectedContent.text">
<canvas ref="labelCanvas"></canvas>
</div>
@ -133,26 +133,8 @@
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Label layout</h5>
</div>
<div class="card-body">
<div class="d-flex flex-wrap gap-3">
<div v-for="t in labelTemplates" :key="t.id" class="template-option text-center"
role="button" @click="selectedTemplate = t.id">
<canvas :ref="el => setTemplateCanvasRef(t.id, el)"
class="img-thumbnail template-thumb-canvas"
:class="{'border-primary': selectedTemplate === t.id}"></canvas>
<div class="small mt-1"
:class="{'fw-bold text-primary': selectedTemplate === t.id}">
{{ t.name }}
</div>
<div class="small text-muted">{{ t.description }}</div>
</div>
</div>
</div>
</div>
<label-layout-preview :fields="fields" :value="selectedTemplate"
@input="selectedTemplate = $event"></label-layout-preview>
</div>
</div>
</div>
@ -163,11 +145,12 @@
<script>
import * as BIcons from "bootstrap-icons-vue";
import {markRaw, nextTick} from "vue";
import QRCode from "qrcode";
import BaseLayout from "@/components/BaseLayout.vue";
import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
import {buildLabelContent} from "@/label-content.js";
import {buildLabelContent, buildLabelFields} from "@/label-content.js";
import {LABEL_TEMPLATES, tapeFromStatus, drawLabel, drawFallbackLabel} from "@/label-drawing.js";
// Served verbatim from public/vendor/ rather than bundled: libweblabel.js's
// own emscripten glue resolves its .wasm sibling relative to *its own*
@ -176,271 +159,12 @@ import {buildLabelContent} from "@/label-content.js";
const BLOB_URL = "/vendor/libweblabel.js";
const MAX_ZOOM = 4; /* never magnify the preview more than this */
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. */
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,
);
}
}
}
}
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 */
/* 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, 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: {
BaseLayout,
LabelLayoutPreview,
...BIcons
},
props: {
@ -476,8 +200,21 @@ export default {
};
},
computed: {
labelTemplates() {
return LABEL_TEMPLATES;
// Named content fields the field-specific templates draw from, plus the free-text
// `value` field the generic qr/qr-text/text templates use. A field this doesn't have
// (rather than one that's merely empty) is what LabelLayoutPreview.vue greys out.
fields() {
return {value: this.value, ...buildLabelFields(this.prefill)};
},
currentTemplate() {
return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
},
selectedContent() {
const t = this.currentTemplate;
return {
qr: t.qr ? this.fields[t.qr] : null,
text: t.text ? this.fields[t.text] : null,
};
},
deviceRows() {
if (!this.blob) {
@ -495,12 +232,11 @@ export default {
});
},
canPrint() {
return Boolean(this.value && this.tape && this.labelBitmap && !this.busy);
return Boolean(this.tape && this.labelBitmap && !this.busy);
},
},
watch: {
value() {
this.redrawTemplatePreviews();
if (this.usbSupported) {
this.redraw();
} else {
@ -593,7 +329,8 @@ export default {
redraw() {
this.labelBitmap = null;
if (!this.tape || !this.value) {
const content = this.selectedContent;
if (!this.tape || (!content.qr && !content.text)) {
return;
}
const canvas = this.$refs.labelCanvas;
@ -602,7 +339,7 @@ export default {
}
this.resizeObserver.observe(canvas.parentElement);
try {
drawLabel(canvas, this.value, this.tape, this.selectedTemplate);
drawLabel(canvas, this.tape, content);
} catch (e) {
this.error = e.message;
return;
@ -616,7 +353,8 @@ export default {
redrawFallback() {
this.fallbackReady = false;
if (!this.value) {
const content = this.selectedContent;
if (!content.qr && !content.text) {
return;
}
const canvas = this.$refs.fallbackCanvas;
@ -625,7 +363,7 @@ export default {
}
this.resizeObserver.observe(canvas.parentElement);
try {
drawFallbackLabel(canvas, this.value, this.selectedTemplate);
drawFallbackLabel(canvas, content);
} catch (e) {
this.error = e.message;
return;
@ -635,38 +373,6 @@ 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) {
@ -722,18 +428,9 @@ 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();
@ -789,19 +486,4 @@ 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 <img>, no manual zoom math needed. */
object-fit: contain;
background: #fff;
}
</style>

View file

@ -1,10 +1,32 @@
import {fileURLToPath, URL} from 'node:url'
import {execSync} from 'node:child_process'
import {existsSync} from 'node:fs'
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
function gitCommit() {
// deploy/dev/docker-compose.yml bind-mounts the repo's real .git dir at
// /git (separate from /app, which only has the frontend/ subtree) -
// point git at it explicitly there. Bare-metal dev has no such mount,
// but this file's cwd sits inside the real checkout so plain rev-parse
// finds it by walking up. In prod, the build context is frontend/ alone
// with no .git anywhere, so both fail and we fall back to the
// GIT_COMMIT build-arg/env var (see deploy/prod/Dockerfile.frontend and
// playbook.yml).
const gitDirFlag = existsSync('/git') ? '--git-dir=/git ' : ''
try {
return execSync(`git ${gitDirFlag}rev-parse --short HEAD`).toString().trim()
} catch {
return process.env.GIT_COMMIT || 'unknown'
}
}
export default defineConfig({
plugins: [vue()],
define: {
__GIT_COMMIT__: JSON.stringify(gitCommit())
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))