// A template's layout tree, content resolution, and selectability contract. See // docs/implementation.md#template-layout-tree. const GAP = {type: "empty", "min-width": "1mm", "min-height": "1mm"}; // Generated matrix of "just the code" templates covering every QR_LEAF_TYPES combo. See // docs/implementation.md#generated-qr-only-template-matrix. const QR_ONLY_TEMPLATES = [ { id: "qr-l", name: "QR code only (low error correction)", description: "Just the code, at QR's lowest error-correction level - fits more data (or a " + "smaller code) for the same text, but less tolerant of damage.", }, {id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest."}, { id: "qr-q", name: "QR code only (quartile error correction)", description: "Just the code, at QR's second-highest (quartile) error-correction level - a " + "middle ground between code size and damage tolerance.", }, { id: "qr-h", name: "QR code only (high error correction)", description: "Just the code, at QR's highest error-correction level - still scans if scuffed " + "or partly obscured, at the cost of a bigger code for the same text.", }, { id: "mqr-d", name: "Micro QR code only (detection only)", description: "Just the code, in the more compact Micro QR format at its lowest, M1-only level - " + "the smallest QR-family code there is, but can only tell a scan is corrupted, not " + "recover from it.", }, { id: "mqr-l", name: "Micro QR code only (low error correction)", description: "Just the code, in the more compact Micro QR format at its low error-correction level.", }, { id: "mqr", name: "Micro QR code only", description: "Just the code, in the more compact Micro QR format - smallest label, prints fastest.", }, { id: "mqr-q", name: "Micro QR code only (quartile error correction)", description: "Just the code, in the more compact Micro QR format at its highest (quartile) " + "error-correction level.", }, { id: "rmqr", name: "rMQR code only", description: "Just the code, in the rectangular rMQR format, sized for the smallest total area " + "that fits the text - smallest label, prints fastest.", }, { id: "rmqr-min", name: "rMQR code only (shortest, widest)", description: "Just the code, in the rectangular rMQR format, preferring the flattest/widest " + "symbol that fits the text - shortest across the tape, longest along it.", }, { id: "rmqr-max", name: "rMQR code only (tallest, narrowest)", description: "Just the code, in the rectangular rMQR format, preferring the tallest/narrowest " + "symbol that fits the text - tallest across the tape, shortest along it.", }, { id: "rmqr-h", name: "rMQR code only (high error correction)", description: "Just the code, in the rectangular rMQR format at its high error-correction level, " + "sized for the smallest total area that fits the text - still scans if scuffed or partly " + "obscured, at the cost of a bigger code for the same text.", }, { id: "rmqr-h-min", name: "rMQR code only (high error correction, shortest/widest)", description: "Just the code, in the rectangular rMQR format at its high error-correction level, " + "preferring the flattest/widest symbol that fits the text.", }, { id: "rmqr-h-max", name: "rMQR code only (high error correction, tallest/narrowest)", description: "Just the code, in the rectangular rMQR format at its high error-correction level, " + "preferring the tallest/narrowest symbol that fits the text.", }, ].map(t => ({...t, required_vars: ["text"], layout: [{type: t.id, content: c => c.text}]})); export const LABEL_TEMPLATES = [ { id: "mqr-token", name: "MQR Token", description: "The code with the encoded text printed next to it.", required_vars: ["shortId"], tags: ["internal"], layout: [{type: "mqr", content: c => c.shortId}] }, { id: "qr-url", name: "MQR Token", description: "The code with the encoded text printed next to it.", required_vars: ["shortUrl"], tags: ["external"], layout: [{type: "qr-h", content: c => c.shortUrl}] }, { id: "rmqr-url", name: "rMQR Token", description: "The code with the encoded text printed next to it.", required_vars: ["shortUrl", "itemId"], tags: ["external"], layout: [{type: "rmqr", content: c => c.shortUrl}, GAP, { type: "text", content: c => c.itemId?.toString().padStart(4, "0") }] }, { id: "mqr-tokem-id", name: "rMQR Token", description: "The code with the encoded text printed next to it.", required_vars: ["shortId", "itemId"], tags: ["internal"], layout: [{type: "mqr", content: c => c.shortId}, GAP, { type: "text", content: c => c.itemId?.toString().padStart(4, "0") }] }, { id: "location-rmqr-url", name: "rMQR Token", description: "The code with the encoded text printed next to it.", required_vars: ["shortUrl", "locationId"], tags: ["external"], layout: [{type: "rmqr", content: c => c.shortUrl}, GAP, { type: "text", content: c => c.locationId?.toString().padStart(4, "0") }] }, { id: "location-mqr-tokem-id", name: "rMQR Token", description: "The code with the encoded text printed next to it.", required_vars: ["shortId", "locationId"], tags: ["internal"], layout: [{type: "mqr", content: c => c.shortId}, GAP, { type: "text", content: c => c.locationId?.toString().padStart(4, "0") }] }, ...QR_ONLY_TEMPLATES, { id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.", required_vars: ["text"], layout: [{type: "qr", content: c => c.text}, GAP, {type: "text", content: c => c.text?.split("\n")}] }, { id: "qr-text-below", name: "QR code + text below", description: "The code with the encoded text printed below it.", required_vars: ["text"], layout: [[{type: "qr", content: c => c.text}, GAP, {type: "text", content: c => c.text?.split("\n")}]] }, { id: "id-qr-text-vertical", name: "ID + QR code + text below", description: "The code with the encoded text printed below it.", required_vars: ["itemId", "text", "userHandle"], layout: [[{type: "text", content: c => "Item: " + c.itemId}, GAP, { type: "qr", content: c => c.text }, GAP, {type: "text", content: c => c.userHandle}]] }, { id: "text", name: "Text only", description: "No code, just the text itself, as large as it fits.", required_vars: ["text"], layout: [{type: "text", content: c => c.text?.split("\n")}] }, { id: "item-handle", name: "Item handle", description: "The compact owner@domain:id handle - meaningful in-app, not scannable on its own.", required_vars: ["itemHandle"], tags: ["internal"], layout: [{type: "text", content: c => c.itemHandle}] }, { id: "item-url", name: "Item URL", description: "The full item URL as text, with no code - for copying rather than scanning.", required_vars: ["itemUrl"], tags: ["external"], layout: [{type: "text", content: c => c.itemUrl}] }, { id: "owner-handle", name: "Owner handle", description: "Just the owning user's handle, as text only.", required_vars: ["userHandle"], tags: ["internal"], layout: [{type: "text", content: c => c.userHandle}] }, { id: "item-id", name: "Item ID", description: "Just the bare item id, as text only.", required_vars: ["itemId"], tags: ["internal"], layout: [{type: "text", content: c => c.itemId}] }, { id: "owner-id-text", name: "Owner + item ID", description: "The owner's handle and the item id, as two lines of text - no code.", required_vars: ["userHandle", "itemId"], tags: ["internal"], layout: [{type: "text", content: c => [c.userHandle, c.itemId]}] }, { id: "location-id", name: "Location ID", description: "Just the bare storage location id, as text only.", required_vars: ["locationId"], tags: ["internal"], layout: [{type: "text", content: c => c.locationId}] }, { id: "owner-id-text-location", name: "Owner + location ID", description: "The owner's handle and the location id, as two lines of text - no code.", required_vars: ["userHandle", "locationId"], tags: ["internal"], layout: [{type: "text", content: c => [c.userHandle, c.locationId]}] }, { id: "item-url-qr-handle", name: "Item URL + handle", description: "Scannable item URL, with the item's compact handle printed alongside.", required_vars: ["itemUrl", "itemHandle"], tags: ["external"], layout: [{type: "qr", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.itemHandle}] }, { id: "item-url-qr-owner", name: "Item URL + owner", description: "Scannable item URL, with the owner's handle printed alongside.", required_vars: ["itemUrl", "userHandle"], tags: ["external"], layout: [{type: "qr", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.userHandle}] }, { id: "item-url-qr-id", name: "Item URL + item ID", description: "Scannable item URL, with the bare item id printed alongside.", required_vars: ["itemUrl", "itemId"], tags: ["external"], layout: [{type: "qr", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.itemId}] }, { id: "item-url-qr-owner-id", name: "Item URL + owner + ID", description: "Scannable item URL, with the owner's handle and the item id on two lines alongside.", required_vars: ["itemUrl", "userHandle", "itemId"], tags: ["external"], layout: [{type: "qr", content: c => c.itemUrl}, GAP, {type: "text", content: c => [c.userHandle, c.itemId]}] }, { id: "short-url-qr", name: "Short link (QR code)", description: "Scannable short link for this item or storage location - more compact than " + "the full URL. Available for any element with a resolvable short link, not just items.", required_vars: ["shortUrl"], tags: ["external"], layout: [{type: "qr", content: c => c.shortUrl}] }, { id: "item-url-qr-owner-id2", name: "Item URL + owner + ID", description: "Scannable item URL, with the owner's handle and the item id on two lines alongside.", required_vars: ["itemUrl", "userHandle", "itemId"], tags: ["external"], layout: [{type: "qr", content: c => c.itemUrl}, GAP, [{ type: "text", content: c => c.userHandle }, GAP, {type: "text", content: c => c.itemId}]] }, ]; // Every distinct tag value used across LABEL_TEMPLATES' tags, in first-seen order - drives // LabelLayoutPreview.vue's filter buttons without hand-listing "internal"/"external" there. export const LABEL_TAGS = [...new Set(LABEL_TEMPLATES.flatMap(t => t.tags ?? []))]; // Every field name any template's required_vars names, in first-seen order. export const KNOWN_VARS = [...new Set(LABEL_TEMPLATES.flatMap(t => t.required_vars))]; // DERIVED_VARS shape and declaration-order invariant. See // docs/implementation.md#derived-vars-shape-and-ordering. export const DERIVED_VARS = { // Full user@domain handle, kept as separate user/domain base vars since that's how the // account is actually shaped (see federation.md's Unique Handles); this is just the // display/URL form. userHandle: { inputs: ["user", "domain"], calc: (f) => `${f.user}@${f.domain}`, }, // Self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a printed // label encodes, since scanning it must resolve everything with no other context. `webdomain` // defaults to this origin but is editable, since any frontend can resolve any handle. itemUrl: { inputs: ["webdomain", "userHandle", "itemId"], calc: (f) => `${f.webdomain}/i/${f.userHandle}/${f.itemId}`, }, // Compact "owner handle + id" form (see docs/design-in-progress/items-labels.md) - meaningful // only where context already makes clear it's a Toolshed item, unlike itemUrl. itemHandle: { inputs: ["userHandle", "itemId"], calc: (f) => `${f.userHandle}:${f.itemId}`, }, }; // Text-input vars = template KNOWN_VARS minus derived ones, plus any var a DERIVED_VARS calc // needs (e.g. "webdomain") even if no template names it directly. export const BASE_VARS = [...new Set([ ...KNOWN_VARS.filter(v => !(v in DERIVED_VARS)), ...Object.values(DERIVED_VARS).flatMap(d => d.inputs).filter(v => !(v in DERIVED_VARS)), ])]; // Runs each DERIVED_VARS calc against `fields`, adding the result wherever its inputs are // present, so callers never need to know DERIVED_VARS' {inputs, calc} shape. export function withDerivedVars(fields) { const result = {...fields}; for (const [name, {inputs, calc}] of Object.entries(DERIVED_VARS)) { if (inputs.every(v => result[v])) { result[name] = calc(result); } } return result; } function walkLeaves(node, fn) { if (Array.isArray(node)) { node.forEach(child => walkLeaves(child, fn)); } else { fn(node); } } function mapTree(node, fn) { return Array.isArray(node) ? node.map(child => mapTree(child, fn)) : fn(node); } // Resolved only if every part is - a single string for a QR-family/"text" leaf, every line for a // multi-line "text" (see "owner-id-text" and "item-url-qr-owner-id" above). function isResolved(value) { return Array.isArray(value) ? value.every(isResolved) : value !== undefined && value !== null; } // Shared by LabelLayoutPreview.vue's grid and Print.vue's preview, so neither reimplements // leaf-walking/field-resolving itself. export function templateIsAvailable(t, fields) { let available = true; walkLeaves(t.layout, leaf => { if (leaf.type !== "empty" && !isResolved(leaf.content(fields))) { available = false; } }); return available; } // Resolves t's content leaves against fields into a tree for label.js's // drawLabel/drawFallbackLabel. Throws the same {message, short} shape as label.js's own // capacity errors (see docs/implementation.md#missing-data-is-a-templatecontent-error) when a // leaf isn't resolved yet, rather than returning null, so a caller's single catch around // drawLabel/drawFallbackLabel handles both without a separate isAvailable pre-check. export function templateContent(t, fields) { if (!templateIsAvailable(t, fields)) { const err = new Error("This layout needs more fields filled in before it can be drawn."); err.short = "missing data"; throw err; } return mapTree(t.layout, leaf => leaf.type === "empty" ? leaf : {...leaf, value: leaf.content(fields)}); }