This commit is contained in:
j3d1 2026-08-27 01:39:03 +02:00
parent d56784eb8d
commit 6ee5ae38b1
8 changed files with 246 additions and 153 deletions

View file

@ -1,9 +0,0 @@
# Pixel fonts used for small label text (see ../../../scss/_pixel-fonts.scss)
- **Tom Thumb** (`TomThumb.ttf`) - by Brian Swetland, TTF conversion by gheja
(https://github.com/gheja/tom-thumb-ttf). Licensed CC0 or CC-BY 3.0 (original:
https://robey.lag.net/2010/01/23/tiny-monospace-font.html).
- **PICO-8** (`PICO-8.ttf`) - reproduction by Jacob Pierce
(https://github.com/jacobpierce/pico-8-font). MIT License, Copyright (c) 2016 Jacob Pierce.
- **Silkscreen** (`Silkscreen-Regular.woff2`) - by Jason Kottke, served via Google Fonts
(https://fonts.google.com/specimen/Silkscreen). SIL Open Font License 1.1.

View file

@ -149,10 +149,16 @@ export const LABEL_TEMPLATES = [
},
{
id: "item-handle", name: "Item handle",
description: "The compact owner@domain:id handle - meaningful in-app, not scannable on its own.",
description: "The compact owner@domain:i<id> User-Qualified ID - meaningful in-app, not scannable on its own.",
required_vars: ["itemHandle"], tags: ["internal"],
layout: [{type: "text", content: c => c.itemHandle}]
},
{
id: "location-handle", name: "Storage location handle",
description: "The compact owner@domain:s<id> User-Qualified ID - meaningful in-app, not scannable on its own.",
required_vars: ["locationHandle"], tags: ["internal"],
layout: [{type: "text", content: c => c.locationHandle}]
},
{
id: "item-url", name: "Item URL",
description: "The full item URL as text, with no code - for copying rather than scanning.",
@ -252,11 +258,16 @@ export const DERIVED_VARS = {
inputs: ["webdomain", "userHandle", "itemId"],
calc: (f) => `${f.webdomain}/i/${encodeHandleForUrl(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.
// Compact User-Qualified ID (see docs/handles-and-shortids.md#user-qualified-id) - owner
// handle + a one-letter kind tag + local id, meaningful only where context already makes
// clear it's Toolshed data, unlike itemUrl. `i` = item, `s` = storage location.
itemHandle: {
inputs: ["userHandle", "itemId"],
calc: (f) => `${f.userHandle}:${f.itemId}`,
calc: (f) => `${f.userHandle}:i${f.itemId}`,
},
locationHandle: {
inputs: ["userHandle", "locationId"],
calc: (f) => `${f.userHandle}:s${f.locationId}`,
},
};

View file

@ -234,7 +234,7 @@
<script>
import * as BIcons from "bootstrap-icons-vue";
import {markRaw, nextTick} from "vue";
import {mapActions, mapGetters} from "vuex";
import {mapActions, mapGetters, mapState} from "vuex";
import BaseLayout from "@/components/BaseLayout.vue";
import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
import printerManager from "@/printerManager.js";
@ -337,7 +337,12 @@ export default {
};
},
computed: {
...mapState(["user"]),
...mapGetters(["identityIdByHandle", "groupIdByHandle"]),
homeDomain() {
const at = this.user ? this.user.indexOf("@") : -1;
return at === -1 ? null : this.user.slice(at + 1);
},
baseVars() {
return BASE_VARS.filter(v => v !== SHORT_URL_VAR && v !== SHORT_ID_VAR);
},
@ -357,7 +362,8 @@ export default {
if (shortId) {
derived[SHORT_ID_VAR] = shortId;
if (derived.webdomain) {
derived[SHORT_URL_VAR] = derived.webdomain + "/" + shortId;
derived[SHORT_URL_VAR] = derived.webdomain + "/"
+ (this.homeDomain ? this.homeDomain + ":" + shortId : shortId);
}
}
return derived;
@ -502,9 +508,6 @@ export default {
return ticks;
},
// Reads the recently-printed template ids back from localStorage; same try/catch shape as
// cameraManager.js's getRecentCameras since either a disabled/full localStorage shouldn't
// break printing.
loadRecentTemplateIds() {
try {
const saved = localStorage.getItem(RECENT_TEMPLATES_KEY);
@ -514,10 +517,7 @@ export default {
}
},
// Moves `id` to the front of the recent-templates list (deduping any earlier occurrence),
// capped to MAX_RECENT_TEMPLATES, so LabelLayoutPreview.vue's grid always bubbles the
// last four printed/downloaded layouts to the top.
rememberPrintedTemplate(id) {
rememberPrintedTemplate(id) {
const updated = [id, ...this.recentTemplateIds.filter(t => t !== id)].slice(0, MAX_RECENT_TEMPLATES);
this.recentTemplateIds = updated;
try {

View file

@ -70,17 +70,10 @@
import {mapActions} from "vuex";
import BaseLayout from "@/components/BaseLayout.vue";
import CameraScanner from "@/components/CameraScanner.vue";
import {encodeHandleForUrl, expandedRoute} from "@/router";
import {decodeShortId, deserializeShortId} from "@/short-id";
import {encodeHandleForUrl, expandedRoute, OWNED_KIND_FIELDS} from "@/router";
import {decodeShortId, deserializeShortId, isDomainQualifiedShortId, decodeDomainQualifiedShortId} from "@/short-id";
// A scanned label can encode any of these (see label-layouts.js's DERIVED_VARS): a full
// self-contained URL (itemUrl/shortUrl), a bare short-id.js token with no domain at all (the
// "mqr"/id-only layout), or the compact no-URL "<userHandle>:<itemId>" form (itemHandle). Detects
// which and returns a link target, or null if the text doesn't match any known format. `decoded`/
// `itemHandle` carry enough of the parsed token for describeLink (below) to also resolve a
// human-readable "what this points at" - not needed for the two URL cases, which link to
// somewhere already showing that.
const ITEM_HANDLE_RE = /^(#?[^\s@:/#+~]+@[^\s@:/#+~]+):(\d+)$/;
const OWNER_QUALIFIED_ID_RE = /^(#?[^\s@:/#+~]+@[^\s@:/#+~]+):([is])(\d+)$/;
function classifyScanText(text) {
if (!text) {
@ -88,32 +81,29 @@ function classifyScanText(text) {
}
try {
const url = new URL(text);
// Same-origin URLs (the common case - a label printed by this same app) get routed
// in-app instead of forcing a full page reload through an <a> tag. `immediate`: a URL
// needs no async lookup to confirm it's real (unlike the token/handle formats below), so
// it's already "resolved" the moment it's classified - see resolveDescription and
// maybeVisitFirstMatch.
return url.origin === window.location.origin
? {to: url.pathname + url.search + url.hash, immediate: true}
: {href: url.href};
} catch {
// Not an absolute URL - fall through to the other known formats below.
}
if (text.startsWith('~')) {
if (text.startsWith('~') || isDomainQualifiedShortId(text)) {
try {
const decoded = deserializeShortId(decodeShortId(text));
// expandedRoute needs idmap already loaded to resolve item/group_item (see
// router.js's NEEDS_IDMAP) - fall back to the token's own URL (ShortId.vue resolves
// it from there, same as a cold-opened short link) when it can't yet.
return {to: expandedRoute(decoded) || '/' + text, decoded};
const qualified = isDomainQualifiedShortId(text) ? decodeDomainQualifiedShortId(text) : null;
const decoded = deserializeShortId(qualified ? qualified.ints : decodeShortId(text));
return {to: (!qualified && expandedRoute(decoded)) || '/' + text, decoded, domain: qualified?.domain};
} catch {
return null; // starts with '~' but isn't a real short id - leave as plain text
return null; // looked like a short id but isn't a real one - leave as plain text
}
}
const handleMatch = text.match(ITEM_HANDLE_RE);
if (handleMatch) {
const [, handle, id] = handleMatch;
return {to: `/inventory/${encodeHandleForUrl(handle)}/${id}`, itemHandle: {handle, id}};
const ownerMatch = text.match(OWNER_QUALIFIED_ID_RE);
if (ownerMatch) {
const [, handle, kindLetter, id] = ownerMatch;
const kind = kindLetter === "s" ? "storage_location" : "item";
const to = kind === "storage_location"
? `/storage-locations/${encodeHandleForUrl(handle)}/${id}`
: `/inventory/${encodeHandleForUrl(handle)}/${id}`;
return {to, ownerQualifiedId: {handle, kind, id}};
}
return null;
}
@ -154,7 +144,7 @@ export default {
};
},
methods: {
...mapActions(["fetchItemByHandle", "fetchStorageLocationByHandle", "fetchIdMap"]),
...mapActions(["fetchItemByHandle", "fetchStorageLocationByHandle", "fetchIdMap", "resolveShortId"]),
resolveDescription(entry) {
const {link, text} = entry;
@ -190,13 +180,17 @@ export default {
},
async describeLink(link) {
if (link.itemHandle) {
return this.describeItem(link.itemHandle.handle, link.itemHandle.id);
if (link.ownerQualifiedId) {
const {handle, kind, id} = link.ownerQualifiedId;
return kind === "storage_location" ? this.describeLocation(handle, id) : this.describeItem(handle, id);
}
const decoded = link.decoded;
if (!decoded) {
return null;
}
if (link.domain) {
return this.describeForeignShortId(link.domain, decoded);
}
if (decoded.kind === "item" || decoded.kind === "group_item") {
const byId = decoded.kind === "item"
? this.$store.getters.identityHandleById
@ -236,6 +230,22 @@ export default {
return null; // workflow, category, file: no per-item title lookup wired up yet
},
async describeForeignShortId(domain, decoded) {
const owned = OWNED_KIND_FIELDS[decoded.kind];
if (!owned) {
return null; // no owner id to resolve a foreign backend against yet (group/category/file/workflow)
}
const resolved = await this.resolveShortId({
domain, kind: decoded.kind, ownerId: decoded[owned.ownerField], localId: decoded[owned.localField],
});
if (!resolved) {
return null;
}
return owned.isLocation
? this.describeLocation(resolved.handle, resolved.id)
: this.describeItem(resolved.handle, resolved.id);
},
async describeItem(handle, id) {
const item = await this.fetchItemByHandle({handle, id});
return item ? `[#${item.id}] ${item.name}` : null;
@ -263,20 +273,11 @@ export default {
error: null,
};
this.cameraLog.unshift(entry);
// Resolve against cameraLog[0], not the plain `entry` object above: Vue's reactivity
// tracks property sets through the reactive proxy unshift() just installed, and
// mutating the pre-insertion raw object later bypasses that proxy entirely, so the
// description would never appear to update (stuck on "resolving...") even once the
// lookup actually finished.
this.resolveDescription(this.cameraLog[0]);
// Caps the log (old entries trimmed) rather than growing forever, matching
// CameraScanner's own dedupe window that makes them stale for re-matching.
this.cameraLog.length = Math.min(this.cameraLog.length, 20);
},
},
created() {
// Not component data: resolveDescription's cached values are read back into each entry's
// own (reactive) `description` field, so the cache itself never needs to be reactive.
this.descriptionCache = new Map();
},
}

View file

@ -2,6 +2,10 @@
<BaseLayout hide-search>
<main class="content">
<div class="container-fluid p-0">
<div v-if="qualified" class="card">
<div class="card-header">Domain</div>
<div class="card-body">{{ qualified.domain }}</div>
</div>
<div class="card">
<div class="card-header">Decoded</div>
<div class="card-body">
@ -64,8 +68,11 @@
<script>
import {mapActions} from 'vuex';
import BaseLayout from '@/components/BaseLayout.vue';
import {decodeShortId, deserializeShortId, encodeShortId, serializeShortId} from '@/short-id';
import {expandedRoute as buildExpandedRoute, NEEDS_IDMAP} from '@/router';
import {
decodeShortId, deserializeShortId, encodeShortId, serializeShortId,
isDomainQualifiedShortId, decodeDomainQualifiedShortId
} from '@/short-id';
import {expandedRoute as buildExpandedRoute, NEEDS_IDMAP, domainQualifiedRoute} from '@/router';
const EXAMPLES = [
{kind: 'item', owner_identity_id: 7, item_local_id: 42},
@ -130,12 +137,17 @@ export default {
},
data() {
return {
error: null
error: null,
foreignRoute: undefined,
}
},
computed: {
// '<domain>:~<token>' vs a bare '~<token>' - see docs/handles-and-shortids.md#domain-qualified-short-id.
qualified() {
return isDomainQualifiedShortId(this.short_id) ? decodeDomainQualifiedShortId(this.short_id) : null;
},
decoded() {
return decodeShortId(this.short_id);
return this.qualified ? this.qualified.ints : decodeShortId(this.short_id);
},
deserialized() {
return deserializeShortId(this.decoded);
@ -145,7 +157,7 @@ export default {
return fields;
},
expandedRoute() {
return buildExpandedRoute(this.deserialized);
return this.qualified ? (this.foreignRoute || null) : buildExpandedRoute(this.deserialized);
},
examples() {
return EXAMPLES.map(named => {
@ -165,13 +177,21 @@ export default {
}
},
methods: {
...mapActions(['fetchIdMap'])
...mapActions(['fetchIdMap']),
async resolveIfQualified() {
if (this.qualified) {
this.foreignRoute = await domainQualifiedRoute(this.qualified.domain, this.decoded) || null;
}
}
},
mounted() {
this.resolveIfQualified();
if (NEEDS_IDMAP.has(this.deserialized.kind) && !this.$store.state.idmapLoaded) {
this.fetchIdMap().catch(e => {
this.error = e.message;
});
this.fetchIdMap()
.then(() => this.resolveIfQualified())
.catch(e => {
this.error = e.message;
});
}
}
}