diff --git a/frontend/src/label-layouts.js b/frontend/src/label-layouts.js
index 84dc502..7d8fed1 100644
--- a/frontend/src/label-layouts.js
+++ b/frontend/src/label-layouts.js
@@ -1,3 +1,5 @@
+import {encodeHandleForUrl} from "@/router";
+
// 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"};
@@ -248,7 +250,7 @@ export const DERIVED_VARS = {
// 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}`,
+ 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.
@@ -307,11 +309,6 @@ export function templateIsAvailable(t, fields) {
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.");
diff --git a/frontend/src/router.js b/frontend/src/router.js
index c0dc882..b37ece4 100644
--- a/frontend/src/router.js
+++ b/frontend/src/router.js
@@ -51,6 +51,10 @@ function itemDetailRoute(handle, item_local_id) {
return handle ? `/inventory/${encodeHandleForUrl(handle)}/${item_local_id}` : null;
}
+export function ownerOverviewRoute(item) {
+ return item.owner_group ? `/groups/${encodeHandleForUrl(item.owner_group)}` : '/inventory';
+}
+
const EXPANDED_ROUTE_BUILDERS = {
item: ({owner_identity_id, item_local_id}) =>
itemDetailRoute(store.getters.identityHandleById[owner_identity_id], item_local_id),
diff --git a/frontend/src/store.js b/frontend/src/store.js
index e9e725e..162b980 100644
--- a/frontend/src/store.js
+++ b/frontend/src/store.js
@@ -8,6 +8,11 @@ import {serializeWorkflowPayload, deserializeWorkflowPayload} from "@/workflows.
//import sharedStatePlugin from "@/../extras/shared-state-plugin";
//import persistentStatePlugin from "@/../extras/persistent-state-plugin";
+function splitGroupHandle(handle) {
+ const [name, domain] = handle.slice(1).split('@')
+ return {name, domain}
+}
+
const defaultPreferenceDefinitions = [
{
key: 'ui.compact_mode',
@@ -398,14 +403,22 @@ export default createStore({
state.last_load.files = 0
return reply
},
+ // A group-owned item lives on its owning group's own backend, not necessarily the
+ // caller's home one (see the group actions below) - item.owner_group ("#name@domain")
+ // already carries that domain, so update/delete resolve it the same way rather than
+ // needing the caller to track it separately.
async updateInventoryItem({state, dispatch, getters}, item) {
- const servers = await dispatch('getHomeServers')
+ const servers = item.owner_group
+ ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain})
+ : await dispatch('getHomeServers')
const data = {availability_policy: 'friends', ...item}
data.files = data.files.map(file => file.id)
return await servers.patch(getters.signAuth, '/api/inventory_items/' + item.id + '/', data)
},
async deleteInventoryItem({state, dispatch, getters}, item) {
- const servers = await dispatch('getHomeServers')
+ const servers = item.owner_group
+ ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain})
+ : await dispatch('getHomeServers')
const ret = await servers.delete(getters.signAuth, '/api/inventory_items/' + item.id + '/')
dispatch('fetchInventoryItems')
return ret
@@ -452,19 +465,21 @@ export default createStore({
return null;
}
},
- // Group handles have no owner-handle GET route yet, so they resolve differently than
- // personal handles here. See docs/implementation.md#fetch-item-by-handle-group-vs-personal-handles.
+ // A group handle resolves via fetchGroup (itself domain-routed, see below), so a group
+ // hosted elsewhere works exactly like one hosted on the caller's own backend - no idmap
+ // detour needed here. ServerSet.get() never checks response.ok, so a 404 (nonexistent or
+ // non-member group) surfaces as a JSON-parse rejection rather than resolving falsy - same
+ // try/catch shape as fetchForeignItem.
async fetchItemByHandle({dispatch, getters}, {handle, id}) {
if (handle.startsWith('#')) {
- // idmap isn't guaranteed loaded on a direct/refreshed visit here; fetchIdMap is
- // cheap and already called unconditionally by every other caller too.
- await dispatch('fetchIdMap')
- const groupId = getters.groupIdByHandle[handle]
- if (groupId === undefined) {
- return null
+ try {
+ const group = await dispatch('fetchGroup', {handle})
+ const items = await dispatch('fetchGroupInventoryItems', {groupId: group.id, domain: group.domain})
+ return items.find(item => item.id === parseInt(id)) || null
+ } catch (error) {
+ console.error(`Failed to fetch group item ${id} for ${handle}:`, error);
+ return null;
}
- const items = await dispatch('fetchGroupInventoryItems', groupId)
- return items.find(item => item.id === parseInt(id)) || null
}
return dispatch('fetchForeignItem', {owner: handle, id})
},
@@ -515,9 +530,14 @@ export default createStore({
const servers = await dispatch('getHomeServers')
return await servers.delete(getters.signAuth, '/api/friends/' + id + '/')
},
- // Groups are only ever hosted on the current user's own home backend for now (see
- // docs/design-in-progress/groups-mvp.md), so every group action below uses getHomeServers
- // rather than resolving a per-group domain.
+ // Listing/creating a group is inherently home-scoped - it's this backend's own view of
+ // "groups I host you in" - but every action below that acts on an *existing* group
+ // (found via its handle, e.g. from a GroupMembership pointer to a group hosted
+ // elsewhere - see GroupMembership/docs/design-in-progress/groups-mvp.md) resolves that
+ // group's own domain and talks to it directly, the same way requestFriend/
+ // fetchForeignItem resolve a friend's domain, rather than assuming home. A group hosted
+ // on the caller's own domain goes through exactly the same code path - there's nothing
+ // to special-case.
async fetchGroups({commit, dispatch, getters}) {
const servers = await dispatch('getHomeServers')
const data = await servers.get(getters.signAuth, '/api/groups/')
@@ -530,16 +550,18 @@ export default createStore({
commit('setIdMap', idmap)
return idmap
},
- async fetchGroup({dispatch, getters}, {id}) {
- const servers = await dispatch('getHomeServers')
- return await servers.get(getters.signAuth, '/api/groups/' + id + '/')
+ // handle is "#name@domain".
+ async fetchGroup({dispatch, getters}, {handle}) {
+ const {name, domain} = splitGroupHandle(handle)
+ const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
+ return await servers.get(getters.signAuth, `/api/groups/handle/${name}/${domain}/`)
},
async createGroup({dispatch, getters}, {name}) {
const servers = await dispatch('getHomeServers')
return await servers.post(getters.signAuth, '/api/groups/', {name})
},
- async removeGroupMember({dispatch, getters}, {groupId, identityId}) {
- const servers = await dispatch('getHomeServers')
+ async removeGroupMember({dispatch, getters}, {groupId, domain, identityId}) {
+ const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
return await servers.delete(getters.signAuth, '/api/groups/' + groupId + '/members/' + identityId + '/')
},
async fetchGroupInvites({commit, dispatch, getters}) {
@@ -554,11 +576,11 @@ export default createStore({
commit('setGroupMemberships', data)
return data
},
- async inviteToGroup({state, dispatch, getters}, {groupId, groupHandle, invitee}) {
- const home_servers = await dispatch('getHomeServers')
- const home_reply = await home_servers.post(
+ async inviteToGroup({state, dispatch, getters}, {groupId, domain, groupHandle, invitee}) {
+ const group_servers = await dispatch('getFriendServers', {username: 'x@' + domain})
+ const group_reply = await group_servers.post(
getters.signAuth, '/api/groups/' + groupId + '/invites/', {invitee})
- if (!home_reply.secret) {
+ if (!group_reply.secret) {
return false
}
const invitee_servers = await dispatch('getFriendServers', {username: invitee})
@@ -567,7 +589,7 @@ export default createStore({
inviter: state.user,
inviter_key: nacl.to_hex(state.keypair.signPk),
invitee,
- secret: home_reply.secret
+ secret: group_reply.secret
})
return true
},
@@ -588,10 +610,13 @@ export default createStore({
const servers = await dispatch('getHomeServers')
return await servers.delete(getters.signAuth, '/api/groupinvites/' + invite.id + '/')
},
- async fetchGroupInventoryItems({commit, dispatch, getters}, groupId) {
- const servers = await dispatch('getHomeServers')
+ async fetchGroupInventoryItems({commit, dispatch, getters}, {groupId, domain}) {
+ const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
const items = await servers.get(getters.signAuth, '/api/inventory_items/?group=' + groupId)
- commit('setInventoryItems', {url: '/group/' + groupId, items})
+ // Namespaced by domain, not just groupId: that pk is only unique within its own
+ // backend's database, so two different domains could otherwise collide on the same
+ // item_map key.
+ commit('setInventoryItems', {url: '/group/' + domain + '/' + groupId, items})
return items
},
async fetchFiles({state, commit, dispatch, getters}) {
@@ -823,7 +848,7 @@ export default createStore({
inventory_items(state) {
return state.item_map['/'] || []
},
- groupInventoryItems: (state) => (groupId) => state.item_map['/group/' + groupId] || [],
+ groupInventoryItems: (state) => (groupId, domain) => state.item_map['/group/' + domain + '/' + groupId] || [],
identityIdByHandle(state) {
return Object.fromEntries(state.idmap.identities.map(i => [i.username, i.id]))
},
diff --git a/frontend/src/views/GroupDetail.vue b/frontend/src/views/GroupDetail.vue
index 448f639..21eaaa9 100644
--- a/frontend/src/views/GroupDetail.vue
+++ b/frontend/src/views/GroupDetail.vue
@@ -86,6 +86,9 @@
+
+
+
@@ -111,6 +114,10 @@
Edit
+
+
+
@@ -120,7 +127,9 @@
Refresh
- Add
+ Add
+
@@ -159,42 +168,55 @@ export default {
},
computed: {
...mapState(['user']),
- id() {
- return this.groupIdByHandle[decodeHandleFromUrl(this.handle)]
+ decodedHandle() {
+ return decodeHandleFromUrl(this.handle)
},
items() {
- return this.groupInventoryItems(this.id)
+ return this.group ? this.groupInventoryItems(this.group.id, this.group.domain) : []
},
- ...mapGetters(['groupInventoryItems', 'groupIdByHandle'])
+ isOwnDomain() {
+ return this.group && this.group.domain === this.user.split('@')[1]
+ },
+ ...mapGetters(['groupInventoryItems'])
},
methods: {
...mapActions(['fetchGroup', 'removeGroupMember', 'inviteToGroup', 'fetchGroupInventoryItems',
- 'deleteInventoryItem', 'fetchIdMap']),
+ 'deleteInventoryItem']),
+ // Loads by handle regardless of which backend actually hosts the group - see
+ // store.js's fetchGroup.
refresh() {
- this.fetchGroup({id: this.id}).then((group) => {
+ return this.fetchGroup({handle: this.decodedHandle}).then((group) => {
this.group = group
})
},
fetchItems() {
- this.fetchGroupInventoryItems(this.id)
+ if (this.group) {
+ this.fetchGroupInventoryItems({groupId: this.group.id, domain: this.group.domain})
+ }
},
itemRoute(item) {
return `/inventory/${encodeHandleForUrl(this.group.handle)}/${item.id}`
},
+ printLinkFor(item) {
+ return {path: '/print', query: {kind: 'item', userHandle: this.group.handle, item: item.id}}
+ },
tryInvite() {
- this.inviteToGroup({groupId: this.id, groupHandle: this.group.handle, invitee: this.invitee})
- .then((ok) => {
- if (ok) {
- this.show_invite = false
- this.invitee = ""
- }
- }).catch(() => {
+ this.inviteToGroup({
+ groupId: this.group.id, domain: this.group.domain, groupHandle: this.group.handle,
+ invitee: this.invitee
+ }).then((ok) => {
+ if (ok) {
+ this.show_invite = false
+ this.invitee = ""
+ }
+ }).catch(() => {
})
},
tryRemoveMember(member) {
- this.removeGroupMember({groupId: this.id, identityId: member.id}).then(() => {
- this.refresh()
- }).catch(() => {
+ this.removeGroupMember({groupId: this.group.id, domain: this.group.domain, identityId: member.id})
+ .then(() => {
+ this.refresh()
+ }).catch(() => {
})
},
tryDeleteItem(item) {
@@ -204,15 +226,7 @@ export default {
}
},
mounted() {
- if (this.id === undefined) {
- this.fetchIdMap().then(() => {
- this.refresh()
- this.fetchItems()
- })
- } else {
- this.refresh()
- this.fetchItems()
- }
+ this.refresh().then(() => this.fetchItems())
}
}
diff --git a/frontend/src/views/Groups.vue b/frontend/src/views/Groups.vue
index f8fbc61..e2bd0d2 100644
--- a/frontend/src/views/Groups.vue
+++ b/frontend/src/views/Groups.vue
@@ -7,7 +7,7 @@
@@ -15,7 +15,7 @@
Name
Members
-
+
Refresh
@@ -27,37 +27,16 @@
-
+
- {{ group.handle }}
+
+ {{ group.handle }}
+
- {{ group.members.length }}
-
-
-
-
-
-
-
-
-
-
-
-
- Group
-
-
-
- Refresh
-
-
-
-
-
-
- {{ membership.handle }}
+
+ {{ group.hosted ? group.memberCount : '—' }}
@@ -121,15 +100,25 @@ export default {
},
computed: {
...mapState(['groups', 'groupInvites', 'groupMemberships']),
- foreignGroupMemberships() {
- const hostedHandles = new Set(this.groups.map(group => group.handle))
- return this.groupMemberships.filter(membership => !hostedHandles.has(membership.handle))
+ allGroups() {
+ const hosted = this.groups.map(group => ({
+ key: `hosted:${group.id}`, handle: group.handle, memberCount: group.members.length, hosted: true
+ }))
+ const hostedHandles = new Set(hosted.map(group => group.handle))
+ const foreign = this.groupMemberships
+ .filter(membership => !hostedHandles.has(membership.handle))
+ .map(membership => ({key: `foreign:${membership.id}`, handle: membership.handle, hosted: false}))
+ return [...hosted, ...foreign].sort((a, b) => a.handle.localeCompare(b.handle))
},
},
methods: {
encodeHandleForUrl,
...mapActions(['fetchGroups', 'fetchGroupInvites', 'fetchGroupMemberships', 'acceptGroupInvite',
'declineGroupInvite']),
+ refreshGroups() {
+ this.fetchGroups()
+ this.fetchGroupMemberships()
+ },
tryAcceptInvite(invite) {
this.acceptGroupInvite(invite).then(() => {
this.fetchGroupInvites()
diff --git a/frontend/src/views/Inventory.vue b/frontend/src/views/Inventory.vue
index 5adca48..15b8727 100644
--- a/frontend/src/views/Inventory.vue
+++ b/frontend/src/views/Inventory.vue
@@ -150,11 +150,10 @@ export default {
if (owner_identity_id === undefined) return null
return shortenedRoute({kind: 'item', owner_identity_id, item_local_id: item.id})
},
- // Routes to Print.vue with this item's raw identity rather than a pre-built link.
- // See docs/implementation.md#print-link-shape-for-personal-items.
printLinkFor(item) {
- if (!item.owner) return null
- return {path: '/print', query: {kind: 'item', userHandle: item.owner, item: item.id}}
+ const userHandle = item.owner || item.owner_group
+ if (!userHandle) return null
+ return {path: '/print', query: {kind: 'item', userHandle, item: item.id}}
},
},
async mounted() {
diff --git a/frontend/src/views/InventoryDetail.vue b/frontend/src/views/InventoryDetail.vue
index 0685771..3d9d847 100644
--- a/frontend/src/views/InventoryDetail.vue
+++ b/frontend/src/views/InventoryDetail.vue
@@ -46,7 +46,7 @@
Edit
$router.push('/inventory'))">
+ @click="deleteInventoryItem(item).then(() => $router.push(ownerOverviewRoute(item)))">
Delete
@@ -68,7 +68,7 @@ import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import {mapActions, mapGetters, mapState} from "vuex";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
-import {decodeHandleFromUrl} from "@/router";
+import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
export default {
name: "InventoryDetail",
@@ -94,23 +94,30 @@ export default {
},
computed: {
...mapGetters(["getNameFromHandle", "groupIdByHandle"]),
- ...mapState(["user"]),
+ ...mapState(["user", "groupMemberships"]),
decodedHandle() {
return decodeHandleFromUrl(this.handle)
},
+ // Am I a member of this group, hosted here (groupIdByHandle, from idmap) or elsewhere
+ // (groupMemberships - see GroupMembership/docs/design-in-progress/groups-mvp.md)? Either
+ // way the backend actually hosting the group is what authorizes the write - this is just
+ // enough of a client-side hint to show/hide the buttons for it.
+ isGroupMember() {
+ return this.decodedHandle in this.groupIdByHandle
+ || this.groupMemberships.some(m => m.handle === this.decodedHandle)
+ },
// Edit/Delete require actual authorization (own item or member group), not just view
// access - get_shared_item's friends_or_self() lets a friend view but never act.
canEdit() {
- return this.decodedHandle === this.user || this.decodedHandle in this.groupIdByHandle
+ return this.decodedHandle === this.user || this.isGroupMember
},
- // Printed labels only support a personal owner handle so far (see label.js's
- // splitUserHandle); group items get no print link until that's supported too.
canPrint() {
- return this.decodedHandle === this.user
+ return this.decodedHandle === this.user || this.isGroupMember
}
},
methods: {
- ...mapActions(["fetchItemByHandle", "deleteInventoryItem"]),
+ ownerOverviewRoute,
+ ...mapActions(["fetchItemByHandle", "deleteInventoryItem", "fetchGroupMemberships"]),
async loadItem() {
this.item = await this.fetchItemByHandle({handle: this.decodedHandle, id: this.id}) || {}
}
@@ -121,6 +128,9 @@ export default {
},
mounted() {
this.loadItem()
+ if (this.decodedHandle.startsWith('#')) {
+ this.fetchGroupMemberships()
+ }
}
}
diff --git a/frontend/src/views/InventoryEdit.vue b/frontend/src/views/InventoryEdit.vue
index da2d7d2..d539c84 100644
--- a/frontend/src/views/InventoryEdit.vue
+++ b/frontend/src/views/InventoryEdit.vue
@@ -57,7 +57,8 @@
Update
+ @click="updateInventoryItem(item).then(updated => $router.push(ownerOverviewRoute(updated)))">
+ Update
@@ -75,7 +76,7 @@ import BaseLayout from "@/components/BaseLayout.vue";
import TagField from "@/components/TagField.vue";
import PropertyField from "@/components/PropertyField.vue";
import CombinedFileField from "@/components/CombinedFileField.vue";
-import {decodeHandleFromUrl} from "@/router";
+import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
export default {
name: "InventoryEdit",
@@ -120,6 +121,7 @@ export default {
}
},
methods: {
+ ownerOverviewRoute,
...mapActions(["fetchItemByHandle", "updateInventoryItem", "fetchInfo", "fetchStorageLocations"]),
changeFiles(files) {
this.item.files = files
diff --git a/frontend/src/views/InventoryNew.vue b/frontend/src/views/InventoryNew.vue
index 0287d0e..3c1065e 100644
--- a/frontend/src/views/InventoryNew.vue
+++ b/frontend/src/views/InventoryNew.vue
@@ -66,7 +66,7 @@
$router.push('/inventory'))">Add
+ @click="createInventoryItem(item).then(created => $router.push(ownerOverviewRoute(created)))">Add
@@ -84,6 +84,7 @@ import BaseLayout from "@/components/BaseLayout.vue";
import TagField from "@/components/TagField.vue";
import PropertyField from "@/components/PropertyField.vue";
import CombinedFileField from "@/components/CombinedFileField.vue";
+import {ownerOverviewRoute} from "@/router";
export default {
name: "InventoryNew",
@@ -111,6 +112,7 @@ export default {
}
},
methods: {
+ ownerOverviewRoute,
...mapActions(['createInventoryItem', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups'])
},
computed: {
diff --git a/frontend/src/views/Print.vue b/frontend/src/views/Print.vue
index c4ad0d4..bca75a7 100644
--- a/frontend/src/views/Print.vue
+++ b/frontend/src/views/Print.vue
@@ -337,10 +337,7 @@ export default {
};
},
computed: {
- ...mapGetters(["identityIdByHandle"]),
- // Plain passthroughs of the form's base/derived vars, keeping the template from importing
- // label-layouts.js just for these. SHORT_URL_VAR/SHORT_ID_VAR exclusion: see
- // docs/implementation.md#calculated-short-link-fields.
+ ...mapGetters(["identityIdByHandle", "groupIdByHandle"]),
baseVars() {
return BASE_VARS.filter(v => v !== SHORT_URL_VAR && v !== SHORT_ID_VAR);
},
@@ -461,12 +458,17 @@ export default {
varLabel(v) {
return v.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase());
},
-
- // Builds the bare short-id.js token for the current fields. See docs/implementation.md#shortid-resolution.
shortId(f) {
if (!f.userHandle) {
return null;
}
+ if (f.userHandle.startsWith("#")) {
+ const owner_group_id = this.groupIdByHandle[f.userHandle];
+ if (owner_group_id === undefined || !f.itemId) {
+ return null;
+ }
+ return shortenedRoute({kind: "group_item", owner_group_id, item_local_id: f.itemId}).slice(1);
+ }
const owner_identity_id = this.identityIdByHandle[f.userHandle];
if (owner_identity_id === undefined) {
return null;
diff --git a/frontend/src/views/Scan.vue b/frontend/src/views/Scan.vue
index 51af3bd..918bf92 100644
--- a/frontend/src/views/Scan.vue
+++ b/frontend/src/views/Scan.vue
@@ -148,33 +148,14 @@ export default {
error: null,
insecureContext: window.isSecureContext,
insecureOrigin: `${window.location.protocol}//${window.location.hostname}`,
-
- // One-shot: cleared by maybeVisitFirstMatch as soon as it navigates, so it doesn't
- // keep firing router.push for every later scan/decode while left switched on.
- // Defaults on when the page itself was opened with ?navigate=immediate (e.g. a link
- // shared for a "scan and go" workflow), so the toggle doesn't need a manual flip first.
visitFirstMatch: this.$route.query.navigate === "immediate",
cameraLog: [],
};
},
methods: {
- ...mapActions(["fetchItemByHandle", "fetchGroup", "fetchStorageLocations", "fetchIdMap"]),
+ ...mapActions(["fetchItemByHandle", "fetchStorageLocations", "fetchIdMap"]),
- // Resolves entry.link into entry.description ("[#7] Cordless drill") for the non-URL
- // formats classifyScanText recognizes - mutates the already-rendered entry in place once
- // the lookup lands, rather than delaying the log/result list from showing the raw scanned
- // text and link immediately. Left as `undefined` (template shows "resolving...") while in
- // flight, and settles to a string or `null` (nothing else known to show, e.g. a workflow
- // short id, or the owner/item genuinely couldn't be resolved).
- //
- // descriptionCache (keyed by the raw scanned text) memoizes the outcome - a still-in-frame
- // code gets re-decoded and re-logged several times a second (see logDecode), and
- // re-scanning the same printed label later is common too, so without this every repeat
- // would re-fire the same fetchItemByHandle/fetchGroup/fetchStorageLocations/fetchIdMap
- // round trip. Caching the in-flight promise itself (not just its settled value) also
- // dedupes concurrent lookups for the same still-in-frame code, rather than firing one
- // request per decode.
resolveDescription(entry) {
const {link, text} = entry;
if (!link || link.href) {
@@ -185,10 +166,6 @@ export default {
return;
}
if (!this.descriptionCache.has(text)) {
- // Wrapped as {error} rather than swallowed to null: a lookup can fail for very
- // different reasons (not logged in, item not shared with this viewer, a genuine
- // network error) and collapsing them all to "no description" made every one of
- // them look identical to "nothing to show" - undiagnosable from the UI.
this.descriptionCache.set(text, this.describeLink(link).catch(e => ({error: e.message ?? String(e)})));
}
this.descriptionCache.get(text).then(result => {
@@ -198,19 +175,12 @@ export default {
return;
}
entry.description = result;
- // Only a truthy description confirms the target actually exists - a null/empty
- // one (unresolvable, or a kind with no title lookup wired up) shouldn't count as
- // a "match" to auto-visit.
if (result) {
this.maybeVisitFirstMatch(link);
}
});
},
- // Sends the viewer straight to the first scan this session that's confirmed to resolve
- // (immediately for a plain URL, or once resolveDescription confirms a real target for a
- // token/handle) while the "Visit first match" toggle is on. One-shot: switches the toggle
- // back off so it doesn't fire again for every later scan of the same or another code.
maybeVisitFirstMatch(link) {
if (!this.visitFirstMatch || !link?.to) {
return;
@@ -242,8 +212,12 @@ export default {
return handle === undefined ? null : this.describeItem(handle, decoded.item_local_id);
}
if (decoded.kind === "group") {
- const group = await this.fetchGroup({id: decoded.group_id});
- return group ? group.handle : null;
+ 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) {