toolshed/frontend/src/views/Scan.vue
2026-08-26 20:40:23 +02:00

295 lines
12 KiB
Vue

<template>
<BaseLayout>
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Scan a code</h1>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch" id="visitFirstMatch"
v-model="visitFirstMatch">
<label class="form-check-label" for="visitFirstMatch">Visit first match</label>
</div>
<div v-if="error" class="alert alert-danger" role="alert">{{ error }}</div>
<div v-if="!insecureContext" class="alert alert-warning">
This page isn't in a secure context ({{ insecureOrigin }}), so the camera API is
unavailable. From another device, use https:// (or http://localhost) instead.
</div>
<div class="row">
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">Results</h5>
</div>
<div class="card-body">
<ul class="scan-results">
<li v-for="(c, i) in cameraLog" :key="i">
<span class="scan-result-type">{{ c.type }}</span>
<span v-if="c.metaText" class="text-muted"> {{ c.metaText }}</span>
<span class="text-muted"> @ {{ c.time }}</span>
<div class="text-break">
<a v-if="c.link?.href" :href="c.link.href" target="_blank"
rel="noopener noreferrer">{{ c.text }}</a>
<template v-else-if="c.link">
<router-link :to="c.link.to">{{ c.text }}
<span v-if="c.error" class="text-danger"> &rarr; {{
c.error
}}</span>
<span v-else-if="c.description"> &rarr; {{ c.description }}</span>
<span v-else-if="c.description === undefined" class="text-muted"> &rarr; resolving&hellip;</span>
</router-link>
</template>
<template v-else>{{ c.text }}</template>
</div>
</li>
</ul>
</div>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">Scan from camera</h5>
</div>
<div class="card-body">
<CameraScanner @scan="onCameraScan" @error="e => error = e"/>
</div>
</div>
</div>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
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";
// 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+)$/;
function classifyScanText(text) {
if (!text) {
return null;
}
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('~')) {
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};
} catch {
return null; // starts with '~' but isn't a real short id - 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}};
}
return null;
}
// Formats a decode result's metadata (see anyd-qr.js's SymbolMetadata) as a short string like
// "(v7, ec=M, mask=3)".
function metaSummary(metadata) {
const parts = [];
if (metadata.version != null) {
parts.push(`v${metadata.version}`);
}
if (metadata.size) {
parts.push(metadata.size);
}
if (metadata.ecc) {
parts.push(`ec=${metadata.ecc}`);
}
if (metadata.mask != null) {
parts.push(`mask=${metadata.mask}`);
}
return parts.length ? `(${parts.join(", ")})` : "";
}
export default {
name: "Scan",
components: {
BaseLayout,
CameraScanner,
},
data() {
return {
error: null,
insecureContext: window.isSecureContext,
insecureOrigin: `${window.location.protocol}//${window.location.hostname}`,
visitFirstMatch: this.$route.query.navigate === "immediate",
cameraLog: [],
};
},
methods: {
...mapActions(["fetchItemByHandle", "fetchStorageLocations", "fetchIdMap"]),
resolveDescription(entry) {
const {link, text} = entry;
if (!link || link.href) {
return; // full URLs already show where they go - see this feature's ask
}
if (link.immediate) {
this.maybeVisitFirstMatch(link);
return;
}
if (!this.descriptionCache.has(text)) {
this.descriptionCache.set(text, this.describeLink(link).catch(e => ({error: e.message ?? String(e)})));
}
this.descriptionCache.get(text).then(result => {
if (result && typeof result === "object") {
entry.description = null;
entry.error = result.error;
return;
}
entry.description = result;
if (result) {
this.maybeVisitFirstMatch(link);
}
});
},
maybeVisitFirstMatch(link) {
if (!this.visitFirstMatch || !link?.to) {
return;
}
this.visitFirstMatch = false;
this.$router.push(link.to);
},
async describeLink(link) {
if (link.itemHandle) {
return this.describeItem(link.itemHandle.handle, link.itemHandle.id);
}
const decoded = link.decoded;
if (!decoded) {
return null;
}
if (decoded.kind === "item" || decoded.kind === "group_item") {
const byId = decoded.kind === "item"
? this.$store.getters.identityHandleById
: this.$store.getters.groupHandleById;
const ownerId = decoded.kind === "item" ? decoded.owner_identity_id : decoded.owner_group_id;
let handle = byId[ownerId];
if (handle === undefined) {
await this.fetchIdMap();
handle = (decoded.kind === "item"
? this.$store.getters.identityHandleById
: this.$store.getters.groupHandleById)[ownerId];
}
return handle === undefined ? null : this.describeItem(handle, decoded.item_local_id);
}
if (decoded.kind === "group") {
let handle = this.$store.getters.groupHandleById[decoded.group_id];
if (handle === undefined) {
await this.fetchIdMap();
handle = this.$store.getters.groupHandleById[decoded.group_id];
}
return handle === undefined ? null : handle;
}
if (decoded.kind === "storage_location") {
if (!this.$store.state.storage_locations.length) {
await this.fetchStorageLocations();
}
const location = this.$store.state.storage_locations.find(l => l.id === decoded.storage_location_id);
return location ? `[#${location.id}] ${location.name}` : null;
}
return null; // workflow, category, file: no per-item title lookup wired up yet
},
async describeItem(handle, id) {
const item = await this.fetchItemByHandle({handle, id});
return item ? `[#${item.id}] ${item.name}` : null;
},
// Handler for CameraScanner's "scan" event - logs every code from the batch it decoded.
onCameraScan(codes) {
codes.forEach(this.logDecode);
},
logDecode(code) {
const text = code.text ?? "";
const entry = {
type: code.type,
text,
metaText: metaSummary(code.metadata),
time: new Date().toLocaleTimeString(),
link: classifyScanText(text),
description: undefined,
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();
},
}
</script>
<style scoped>
.scan-results {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: .4rem;
overflow-y: auto;
}
.scan-results li {
border: 1px solid var(--bs-gray-300);
border-radius: .35rem;
padding: .4rem .6rem;
font-size: .85rem;
}
.scan-result-type {
font-weight: 600;
color: var(--bs-success);
}
</style>