308 lines
12 KiB
Vue
308 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="custom-control custom-switch mb-3">
|
|
<input class="custom-control-input" type="checkbox" id="visitFirstMatch"
|
|
v-model="visitFirstMatch">
|
|
<label class="custom-control-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"> → {{
|
|
c.error
|
|
}}</span>
|
|
<span v-else-if="c.description"> → {{ c.description }}</span>
|
|
<span v-else-if="c.description === undefined" class="text-muted"> → resolving…</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, OWNED_KIND_FIELDS} from "@/router";
|
|
import {decodeShortId, deserializeShortId, isDomainQualifiedShortId, decodeDomainQualifiedShortId} from "@/short-id";
|
|
|
|
const OWNER_QUALIFIED_ID_RE = /^(#?[^\s@:/#+~]+@[^\s@:/#+~]+):([is])(\d+)$/;
|
|
|
|
function classifyScanText(text) {
|
|
if (!text) {
|
|
return null;
|
|
}
|
|
try {
|
|
const url = new URL(text);
|
|
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('~') || isDomainQualifiedShortId(text)) {
|
|
try {
|
|
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; // looked like a short id but isn't a real one - leave as plain text
|
|
}
|
|
}
|
|
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"
|
|
? {name: 'locations-detail', params: {handle: encodeHandleForUrl(handle), id}}
|
|
: {name: 'inventory-detail', params: {handle: encodeHandleForUrl(handle), id}};
|
|
return {to, ownerQualifiedId: {handle, kind, 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", "fetchStorageLocationByHandle", "fetchIdMap", "resolveShortId"]),
|
|
|
|
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.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
|
|
: 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" || decoded.kind === "group_storage_location") {
|
|
const byId = decoded.kind === "storage_location"
|
|
? this.$store.getters.identityHandleById
|
|
: this.$store.getters.groupHandleById;
|
|
const ownerId = decoded.kind === "storage_location" ? decoded.owner_identity_id : decoded.owner_group_id;
|
|
let handle = byId[ownerId];
|
|
if (handle === undefined) {
|
|
await this.fetchIdMap();
|
|
handle = (decoded.kind === "storage_location"
|
|
? this.$store.getters.identityHandleById
|
|
: this.$store.getters.groupHandleById)[ownerId];
|
|
}
|
|
return handle === undefined ? null : this.describeLocation(handle, decoded.storage_location_id);
|
|
}
|
|
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;
|
|
},
|
|
|
|
async describeLocation(handle, id) {
|
|
const location = await this.fetchStorageLocationByHandle({handle, id});
|
|
return location ? `[#${location.id}] ${location.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);
|
|
this.resolveDescription(this.cameraLog[0]);
|
|
this.cameraLog.length = Math.min(this.cameraLog.length, 20);
|
|
},
|
|
},
|
|
created() {
|
|
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>
|