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

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