This commit is contained in:
j3d1 2026-08-22 23:29:02 +02:00
parent 3299c97392
commit de7d9426be
10 changed files with 259 additions and 99 deletions

View file

@ -36,6 +36,7 @@ urlpatterns = [
path('admin/', include('hostadmin.api')), path('admin/', include('hostadmin.api')),
path('api/', include('toolshed.api.friend')), path('api/', include('toolshed.api.friend')),
path('api/', include('toolshed.api.group')), path('api/', include('toolshed.api.group')),
path('api/', include('toolshed.api.idmap')),
path('api/', include('toolshed.api.inventory')), path('api/', include('toolshed.api.inventory')),
path('api/', include('toolshed.api.info')), path('api/', include('toolshed.api.info')),
path('api/', include('toolshed.api.files')), path('api/', include('toolshed.api.files')),

View file

@ -0,0 +1,28 @@
from django.urls import path
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ViewSetMixin
from authentication.models import KnownIdentity
from authentication.signature_auth import SignatureAuthentication
from toolshed.serializers import FriendSerializer, GroupIdMapSerializer
class IdMap(APIView, ViewSetMixin):
authentication_classes = [SignatureAuthentication]
permission_classes = [IsAuthenticated]
def get(self, request, format=None): # /api/idmap/
identity = request.user
identities = identity.friends.all() | KnownIdentity.objects.filter(pk=identity.pk)
groups = identity.member_of_groups.all()
return Response({
'identities': FriendSerializer(identities, many=True).data,
'groups': GroupIdMapSerializer(groups, many=True).data,
})
urlpatterns = [
path('idmap/', IdMap.as_view(), name='idmap'),
]

View file

@ -92,6 +92,17 @@ class GroupSerializer(serializers.ModelSerializer):
return str(obj) return str(obj)
class GroupIdMapSerializer(serializers.ModelSerializer):
handle = serializers.SerializerMethodField()
class Meta:
model = Group
fields = ['id', 'name', 'domain', 'handle']
def get_handle(self, obj):
return str(obj)
class GroupInviteIncomingSerializer(serializers.ModelSerializer): class GroupInviteIncomingSerializer(serializers.ModelSerializer):
group = serializers.SerializerMethodField() group = serializers.SerializerMethodField()
inviter = serializers.SerializerMethodField() inviter = serializers.SerializerMethodField()

View file

@ -0,0 +1,48 @@
from django.test import Client
from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase
client = SignatureAuthClient()
class IdMapTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
def setUp(self):
super().setUp()
self.prepare_users()
def test_idmap_includes_self(self):
reply = client.get('/api/idmap/', self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
identities = reply.json()['identities']
self.assertIn(str(self.f['local_user1']), [i['username'] for i in identities])
def test_idmap_includes_friends(self):
self.f['local_user1'].friends.add(self.f['local_user2'].public_identity)
reply = client.get('/api/idmap/', self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
identities = reply.json()['identities']
self.assertIn(str(self.f['local_user2']), [i['username'] for i in identities])
def test_idmap_excludes_non_friends(self):
reply = client.get('/api/idmap/', self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
identities = reply.json()['identities']
self.assertNotIn(str(self.f['local_user2']), [i['username'] for i in identities])
def test_idmap_includes_member_groups(self):
self.prepare_groups()
reply = client.get('/api/idmap/', self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
groups = reply.json()['groups']
self.assertIn(str(self.f['group1']), [g['handle'] for g in groups])
def test_idmap_excludes_non_member_groups(self):
self.prepare_groups()
reply = client.get('/api/idmap/', self.f['local_user2'])
self.assertEqual(reply.status_code, 200)
groups = reply.json()['groups']
self.assertNotIn(str(self.f['group1']), [g['handle'] for g in groups])
def test_idmap_unauthenticated(self):
reply = Client().get('/api/idmap/')
self.assertEqual(reply.status_code, 403)

View file

@ -28,6 +28,7 @@ import Scan from "@/views/Scan.vue";
import Workflows from '@/views/Workflows.vue'; import Workflows from '@/views/Workflows.vue';
import WorkflowDetail from '@/views/WorkflowDetail.vue'; import WorkflowDetail from '@/views/WorkflowDetail.vue';
import ShortId from '@/views/ShortId.vue'; import ShortId from '@/views/ShortId.vue';
import {encodeShortId, serializeShortId,decodeShortId, deserializeShortId} from '@/short-id';
import Account from '@/views/settings/Account.vue'; import Account from '@/views/settings/Account.vue';
import Password from '@/views/settings/Password.vue'; import Password from '@/views/settings/Password.vue';
@ -46,6 +47,23 @@ export function decodeHandleFromUrl(segment) {
return segment.replace(/\+/g, "#"); return segment.replace(/\+/g, "#");
} }
const EXPANDED_ROUTE_BUILDERS = {
item: ({item_local_id}) => `/inventory/${item_local_id}`,
group_item: ({item_local_id}) => `/inventory/${item_local_id}`,
group: ({group_id}) => `/groups/${group_id}`,
storage_location: ({storage_location_id}) => `/storage-locations/${storage_location_id}`,
workflow: ({workflow_id}) => `/workflows/${workflow_id}`,
};
export function expandedRoute({kind, ...fields}) {
const buildRoute = EXPANDED_ROUTE_BUILDERS[kind];
return buildRoute ? buildRoute(fields) : null;
}
export function shortenedRoute(named) {
return '/' + encodeShortId(serializeShortId(named));
}
const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, { const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
path: '/inventory', path: '/inventory',
component: Inventory, component: Inventory,
@ -72,6 +90,16 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
const {id} = to.params const {id} = to.params
return handle === store.state.user ? '/inventory/' + id : '/inventory/shared/' + encodeHandleForUrl(handle) + '/' + id return handle === store.state.user ? '/inventory/' + id : '/inventory/shared/' + encodeHandleForUrl(handle) + '/' + id
} }
}, {
path: '/:short_id',
redirect: to => {
console.log(to)
const p = deserializeShortId(decodeShortId(to.params.short_id))
console.log(p)
const url = expandedRoute(p)
console.log(url)
return url;
}
}, {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, { }, {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, {
path: '/friends', path: '/friends',
component: Friends, component: Friends,
@ -161,9 +189,14 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
component: StorageLocationNew, component: StorageLocationNew,
meta: {requiresAuth: true} meta: {requiresAuth: true}
}, { }, {
path: '/:short_id', path: '/debug/:short_id',
component: ShortId, component: ShortId,
props: true props: true
}, {
path: '/:short_id',
redirect: to => {
}
}, {path: '/:pathMatch(.*)*', redirect: '/'}] }, {path: '/:pathMatch(.*)*', redirect: '/'}]
const router = createRouter({ const router = createRouter({

View file

@ -8,31 +8,37 @@ const SCHEMAS = {
fields: ['owner_identity_id', 'item_local_id'], fields: ['owner_identity_id', 'item_local_id'],
interpret: ([owner_identity_id, item_local_id]) => ({kind: 'item', owner_identity_id, item_local_id}) interpret: ([owner_identity_id, item_local_id]) => ({kind: 'item', owner_identity_id, item_local_id})
}, },
//0-1 left free for later use
'1-2': { '1-2': {
name: 'group_item',
fields: ['owner_group_id', 'item_local_id'],
interpret: ([owner_group_id, item_local_id]) => ({kind: 'group_item', owner_group_id, item_local_id})
},
'1-1': {
name: 'group',
fields: ['group_id'],
interpret: ([group_id]) => ({kind: 'group', group_id})
},
'2-2': {
name: 'storage_location', name: 'storage_location',
fields: ['owner_identity_id', 'storage_location_id'], fields: ['owner_identity_id', 'storage_location_id'],
interpret: ([owner_identity_id, storage_location_id]) => interpret: ([owner_identity_id, storage_location_id]) =>
({kind: 'storage_location', owner_identity_id, storage_location_id}) ({kind: 'storage_location', owner_identity_id, storage_location_id})
}, },
'2-1': { '2-1': {
name: 'category', name: 'file',
fields: ['category_id'], fields: ['file_id'],
interpret: ([category_id]) => ({kind: 'category', category_id}) interpret: ([file_id]) => ({kind: 'file', file_id})
}, },
'3-2': { '3-2': {
name: 'workflow', name: 'workflow',
fields: ['owner_identity_id', 'workflow_id'], fields: ['owner_identity_id', 'workflow_id'],
interpret: ([owner_identity_id, workflow_id]) => ({kind: 'workflow', owner_identity_id, workflow_id}) interpret: ([owner_identity_id, workflow_id]) => ({kind: 'workflow', owner_identity_id, workflow_id})
}, },
'4-1': { '3-1': {
name: 'group', name: 'category',
fields: ['group_id'], fields: ['category_id'],
interpret: ([group_id]) => ({kind: 'group', group_id}) interpret: ([category_id]) => ({kind: 'category', category_id})
},
'5-1': {
name: 'file',
fields: ['file_id'],
interpret: ([file_id]) => ({kind: 'file', file_id})
}, },
} }
@ -82,6 +88,10 @@ class BitReader {
} }
return value return value
} }
bitsLeft() {
return this.bits.length - this.pos
}
} }
function base64UrlToBits(text) { function base64UrlToBits(text) {
@ -128,25 +138,6 @@ function readChunkedInt(reader, chunkBits) {
} }
} }
function entriesForKindIndex(kindIndex) {
return Object.entries(SCHEMAS).filter(([key]) => key.startsWith(`${kindIndex}-`))
}
function schemaForKindIndex(kindIndex) {
const matches = entriesForKindIndex(kindIndex)
if (matches.length === 0) {
throw new Error(`unknown short id kind index: ${kindIndex}`)
}
if (matches.length > 1) {
throw new Error(
`ambiguous short id kind index ${kindIndex}: base-level decoding needs exactly one `
+ `field count per kind, found arities ${matches.map(([, s]) => s.fields.length).join(', ')} `
+ `- only deserializeShortId's (kind, fields.length) lookup can tell those apart`
)
}
return matches[0][1]
}
function schemaForKindAndArity(kindIndex, arity) { function schemaForKindAndArity(kindIndex, arity) {
const schema = SCHEMAS[`${kindIndex}-${arity}`] const schema = SCHEMAS[`${kindIndex}-${arity}`]
if (!schema) { if (!schema) {
@ -160,10 +151,12 @@ export function encodeShortId(ints) {
throw new Error('short id must be a non-empty list of [kindIndex, ...fieldValues]') throw new Error('short id must be a non-empty list of [kindIndex, ...fieldValues]')
} }
const [kindIndex, ...fieldValues] = ints const [kindIndex, ...fieldValues] = ints
const schema = schemaForKindIndex(kindIndex) const schema = schemaForKindAndArity(kindIndex, fieldValues.length)
if (fieldValues.length !== schema.fields.length) { if (fieldValues[fieldValues.length - 1] === 0) {
throw new Error( throw new Error(
`short id kind index ${kindIndex} ('${schema.name}') needs ${schema.fields.length} field value(s), got ${fieldValues.length}` `short id kind index ${kindIndex} ('${schema.name}'): the last field's value must not be 0 - `
+ `reserved so decodeShortId can tell a real trailing field apart from zero-padding without `
+ `knowing the kind's arity up front (see docs/handles-and-shortids.md)`
) )
} }
const writer = new BitWriter() const writer = new BitWriter()
@ -188,8 +181,14 @@ export function decodeShortId(token) {
if (kindIndex === DIRECT_KIND_COUNT) { if (kindIndex === DIRECT_KIND_COUNT) {
kindIndex = DIRECT_KIND_COUNT + Number(readChunkedInt(reader, CHUNK_BITS)) kindIndex = DIRECT_KIND_COUNT + Number(readChunkedInt(reader, CHUNK_BITS))
} }
const schema = schemaForKindIndex(kindIndex) const fieldValues = []
const fieldValues = schema.fields.map(() => Number(readChunkedInt(reader, CHUNK_BITS))) while (reader.bitsLeft() >= CHUNK_BITS + 1) {
const value = readChunkedInt(reader, CHUNK_BITS)
if (value === 0n && reader.bitsLeft() === 0) {
break // a trailing all-zero chunk with nothing after it is padding, not a real field
}
fieldValues.push(Number(value))
}
return [kindIndex, ...fieldValues] return [kindIndex, ...fieldValues]
} }

View file

@ -74,6 +74,7 @@ export default createStore({
friendProfiles: {}, friendProfiles: {},
groups: [], groups: [],
groupInvites: [], groupInvites: [],
idmap: {identities: [], groups: []},
item_map: {}, item_map: {},
home_servers: null, home_servers: null,
all_friends_servers: null, all_friends_servers: null,
@ -112,6 +113,9 @@ export default createStore({
setGroupInvites(state, invites) { setGroupInvites(state, invites) {
state.groupInvites = invites; state.groupInvites = invites;
}, },
setIdMap(state, idmap) {
state.idmap = idmap;
},
setHomeServers(state, home_servers) { setHomeServers(state, home_servers) {
state.home_servers = home_servers; state.home_servers = home_servers;
}, },
@ -498,6 +502,12 @@ export default createStore({
commit('setGroups', data) commit('setGroups', data)
return data return data
}, },
async fetchIdMap({commit, dispatch, getters}) {
const servers = await dispatch('getHomeServers')
const idmap = await servers.get(getters.signAuth, '/api/idmap/')
commit('setIdMap', idmap)
return idmap
},
async fetchGroup({dispatch, getters}, {id}) { async fetchGroup({dispatch, getters}, {id}) {
const servers = await dispatch('getHomeServers') const servers = await dispatch('getHomeServers')
return await servers.get(getters.signAuth, '/api/groups/' + id + '/') return await servers.get(getters.signAuth, '/api/groups/' + id + '/')
@ -786,6 +796,12 @@ export default createStore({
return state.item_map['/'] || [] return state.item_map['/'] || []
}, },
groupInventoryItems: (state) => (groupId) => state.item_map['/group/' + groupId] || [], groupInventoryItems: (state) => (groupId) => state.item_map['/group/' + groupId] || [],
identityIdByHandle(state) {
return Object.fromEntries(state.idmap.identities.map(i => [i.username, i.id]))
},
groupIdByHandle(state) {
return Object.fromEntries(state.idmap.groups.map(g => [g.handle, g.id]))
},
loaded_items(state) { loaded_items(state) {
return Object.entries(state.item_map).reduce((acc, [url, items]) => { return Object.entries(state.item_map).reduce((acc, [url, items]) => {
return acc.concat(items) return acc.concat(items)

View file

@ -5,23 +5,60 @@ test('encodes the worked example from docs/handles-and-shortids.md', () => {
expect(token).toBe('~DyU') expect(token).toBe('~DyU')
}) })
test('round-trips every kind as a flat [kindIndex, ...fieldValues] list', () => { test('kind ids are scoped per arity: id 0 means item (arity 2) or category (arity 1)', () => {
expect(deserializeShortId(decodeShortId(encodeShortId([0, 7, 42])))).toEqual({
kind: 'item', owner_identity_id: 7, item_local_id: 42
})
expect(deserializeShortId(decodeShortId(encodeShortId([0, 5])))).toEqual({
kind: 'category', category_id: 5
})
})
test('every kind round-trips through serialize/encode/decode/deserialize', () => {
const cases = [ const cases = [
[0, 7, 42], {kind: 'item', owner_identity_id: 7, item_local_id: 42},
[1, 3, 1000], {kind: 'category', category_id: 5},
[2, 0], {kind: 'group_item', owner_group_id: 5, item_local_id: 42},
{kind: 'group', group_id: 11},
{kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000},
{kind: 'file', file_id: 123},
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
] ]
for (const ints of cases) { for (const named of cases) {
const token = encodeShortId(ints) const token = encodeShortId(serializeShortId(named))
expect(decodeShortId(token)).toEqual(ints) expect(deserializeShortId(decodeShortId(token))).toEqual(named)
} }
}) })
test('round-trips small and large values, including zero', () => { test('a non-last field may be 0 - only the last field is restricted', () => {
const values = [0, 1, 7, 42, 100, 10000, 100000, 123456789] const named = {kind: 'item', owner_identity_id: 0, item_local_id: 42}
const token = encodeShortId(serializeShortId(named))
expect(deserializeShortId(decodeShortId(token))).toEqual(named)
})
test('rejects a 0 last-field value, for a single-field kind', () => {
expect(() => encodeShortId(serializeShortId({kind: 'category', category_id: 0}))).toThrow()
})
test('rejects a 0 last-field value, for a multi-field kind', () => {
expect(() => encodeShortId(serializeShortId({kind: 'item', owner_identity_id: 7, item_local_id: 0}))).toThrow()
})
test('decoding never over-reads: a single-chunk field that exactly exhausts padding is not ' +
'mistaken for a second field', () => {
// category_id: 5 encodes as kind-tag(2 bits) + one 5-bit chunk = 7 bits, padded with exactly
// 5 zero bits - the same 5 bits as a genuine one-chunk field of value 0. This is the concrete
// case the last-field-nonzero rule exists to disambiguate.
const token = encodeShortId([0, 5])
expect(token).toBe('~Cg')
expect(decodeShortId(token)).toEqual([0, 5])
})
test('round-trips small and large nonzero values, in both last and non-last field position', () => {
const values = [1, 7, 42, 100, 10000, 100000, 123456789]
for (const value of values) { for (const value of values) {
const token = encodeShortId([2, value]) expect(decodeShortId(encodeShortId([0, value]))).toEqual([0, value])
expect(decodeShortId(token)).toEqual([2, value]) expect(decodeShortId(encodeShortId([0, value, 1]))).toEqual([0, value, 1])
} }
}) })
@ -31,30 +68,11 @@ test('tokens are URL-safe: only unreserved characters, plus the leading ~', () =
expect(token.slice(1)).toMatch(/^[A-Za-z0-9\-_]+$/) expect(token.slice(1)).toMatch(/^[A-Za-z0-9\-_]+$/)
}) })
test('deserializeShortId names a decoded list into a flat shape, serializeShortId reverses it', () => { test('kinds at escaped ids (3+) round-trip through the escape tag, not just the direct 0-2 range', () => {
const cases = [ const token = encodeShortId(serializeShortId({kind: 'workflow', owner_identity_id: 2, workflow_id: 9}))
[[0, 7, 42], {kind: 'item', owner_identity_id: 7, item_local_id: 42}], expect(deserializeShortId(decodeShortId(token))).toEqual({
[[1, 3, 1000], {kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000}], kind: 'workflow', owner_identity_id: 2, workflow_id: 9
[[2, 0], {kind: 'category', category_id: 0}], })
[[3, 2, 9], {kind: 'workflow', owner_identity_id: 2, workflow_id: 9}],
[[4, 11], {kind: 'group', group_id: 11}],
[[5, 123], {kind: 'file', file_id: 123}],
]
for (const [ints, named] of cases) {
expect(deserializeShortId(ints)).toEqual(named)
expect(serializeShortId(named)).toEqual(ints)
}
})
test('kinds 3+ round-trip through the escape tag, not just the direct 0-2 range', () => {
for (const named of [
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
{kind: 'group', group_id: 11},
{kind: 'file', file_id: 123},
]) {
const token = encodeShortId(serializeShortId(named))
expect(deserializeShortId(decodeShortId(token))).toEqual(named)
}
}) })
test('serializeShortId then deserializeShortId round-trips through encode/decode', () => { test('serializeShortId then deserializeShortId round-trips through encode/decode', () => {
@ -75,30 +93,19 @@ test('rejects an unknown kind index', () => {
expect(() => encodeShortId([99, 1])).toThrow() expect(() => encodeShortId([99, 1])).toThrow()
}) })
test('deserializeShortId is keyed by (kind, fields.length), not kind alone', () => { test('rejects an arity with no registered schema at that kind index', () => {
// kind=1 with the arity storage_location actually has (2 fields) is fine... // kind 0 is registered for arity 2 (item) and arity 1 (category), but not arity 3.
expect(deserializeShortId([1, 3, 1000])).toEqual({ expect(() => encodeShortId([0, 7, 8, 9])).toThrow()
kind: 'storage_location',
owner_identity_id: 3,
storage_location_id: 1000
})
// ...but kind=1 with a different arity (1 field) isn't a registered schema at all, and must
// not be silently misread as a truncated storage_location.
expect(() => deserializeShortId([1, 359])).toThrow()
}) })
test('rejects an unknown kind name', () => { test('rejects an unknown kind name', () => {
expect(() => serializeShortId({kind: 'not_a_kind'})).toThrow() expect(() => serializeShortId({kind: 'not_a_kind'})).toThrow()
}) })
test('rejects a field count mismatch', () => {
expect(() => encodeShortId([0, 7])).toThrow()
})
test('rejects a missing field', () => { test('rejects a missing field', () => {
expect(() => serializeShortId({kind: 'item', owner_identity_id: 7})).toThrow() expect(() => serializeShortId({kind: 'item', owner_identity_id: 7})).toThrow()
}) })
test('rejects a negative field value', () => { test('rejects a negative field value', () => {
expect(() => encodeShortId([2, -1])).toThrow() expect(() => encodeShortId([0, -1, 42])).toThrow()
}) })

View file

@ -40,7 +40,7 @@
<a :href="`/inventory/${item.id}/delete`" @click.prevent="deleteInventoryItem(item)"> <a :href="`/inventory/${item.id}/delete`" @click.prevent="deleteInventoryItem(item)">
<b-icon-trash></b-icon-trash> <b-icon-trash></b-icon-trash>
</a> </a>
<router-link :to="shortIdLink(item)"> <router-link v-if="shortIdLink(item)" :to="shortIdLink(item)">
<b-icon-link></b-icon-link> <b-icon-link></b-icon-link>
</router-link> </router-link>
</td> </td>
@ -73,7 +73,8 @@
<router-link :to="`/inventory/${item.id}/edit`" <router-link :to="`/inventory/${item.id}/edit`"
class="btn btn-primary btn-sm">Edit class="btn btn-primary btn-sm">Edit
</router-link> </router-link>
<router-link :to="shortIdLink(item)" class="btn btn-secondary btn-sm"> <router-link v-if="shortIdLink(item)" :to="shortIdLink(item)"
class="btn btn-secondary btn-sm">
<b-icon-link></b-icon-link> <b-icon-link></b-icon-link>
</router-link> </router-link>
</div> </div>
@ -100,7 +101,7 @@ import {mapActions, mapGetters, mapMutations, mapState} from "vuex";
import * as BIcons from "bootstrap-icons-vue"; import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue"; import BaseLayout from "@/components/BaseLayout.vue";
import AuthenticatedImage from "../components/AuthenticatedImage.vue"; import AuthenticatedImage from "../components/AuthenticatedImage.vue";
import {encodeShortId, serializeShortId} from "@/short-id"; import {shortenedRoute} from "@/router";
export default { export default {
name: "Inventory", name: "Inventory",
@ -115,11 +116,11 @@ export default {
...BIcons ...BIcons
}, },
computed: { computed: {
...mapGetters(["inventory_items", "loaded_items"]), ...mapGetters(["inventory_items", "loaded_items", "identityIdByHandle", "groupIdByHandle"]),
...mapState(["user", "storage_locations"]), ...mapState(["user", "storage_locations"]),
}, },
methods: { methods: {
...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchStorageLocations"]), ...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchStorageLocations", "fetchIdMap"]),
only_images(files) { only_images(files) {
return files.filter(file => file.mime_type.startsWith("image/")); return files.filter(file => file.mime_type.startsWith("image/"));
}, },
@ -128,16 +129,20 @@ export default {
return loc ? loc.path : null return loc ? loc.path : null
}, },
shortIdLink(item) { shortIdLink(item) {
// owner_identity_id is a placeholder until the backend exposes a KnownIdentity pk if (item.owner_group) {
// per item (see docs/handles-and-shortids.md) - it doesn't decode to a const owner_group_id = this.groupIdByHandle[item.owner_group]
// meaningful owner yet. if (owner_group_id === undefined) return null
const ints = serializeShortId({kind: 'item', owner_identity_id: 0, item_local_id: item.id}); return shortenedRoute({kind: 'group_item', owner_group_id, item_local_id: item.id})
return '/' + encodeShortId(ints); }
const owner_identity_id = this.identityIdByHandle[item.owner]
if (owner_identity_id === undefined) return null
return shortenedRoute({kind: 'item', owner_identity_id, item_local_id: item.id})
}, },
}, },
async mounted() { async mounted() {
await this.fetchInventoryItems() await this.fetchInventoryItems()
await this.fetchStorageLocations() await this.fetchStorageLocations()
await this.fetchIdMap()
} }
} }
</script> </script>

View file

@ -19,6 +19,13 @@
</ul> </ul>
</div> </div>
</div> </div>
<div class="card">
<div class="card-header">Expanded</div>
<div class="card-body">
<router-link v-if="expandedRoute" :to="expandedRoute">{{ expandedRoute }}</router-link>
<span v-else>No page exists for kind '{{ deserialized.kind }}'</span>
</div>
</div>
<div class="card"> <div class="card">
<div class="card-header">Examples</div> <div class="card-header">Examples</div>
<table class="table table-striped mb-0"> <table class="table table-striped mb-0">
@ -56,14 +63,16 @@
<script> <script>
import BaseLayout from '@/components/BaseLayout.vue'; import BaseLayout from '@/components/BaseLayout.vue';
import {decodeShortId, deserializeShortId, encodeShortId, serializeShortId} from '@/short-id'; import {decodeShortId, deserializeShortId, encodeShortId, serializeShortId} from '@/short-id';
import {expandedRoute as buildExpandedRoute} from '@/router';
const EXAMPLES = [ const EXAMPLES = [
{kind: 'item', owner_identity_id: 7, item_local_id: 42}, {kind: 'item', owner_identity_id: 7, item_local_id: 42},
{kind: 'group', group_id: 11},
{kind: 'group_item', owner_group_id: 5, item_local_id: 42},
{kind: 'file', file_id: 123},
{kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000}, {kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000},
{kind: 'category', category_id: 5}, {kind: 'category', category_id: 5},
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9}, {kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
{kind: 'group', group_id: 11},
{kind: 'file', file_id: 123},
]; ];
// Debug-only display helper, kept out of short-id.js's production library: mirrors its bit-packing // Debug-only display helper, kept out of short-id.js's production library: mirrors its bit-packing
@ -130,6 +139,9 @@ export default {
const {kind, ...fields} = this.deserialized; const {kind, ...fields} = this.deserialized;
return fields; return fields;
}, },
expandedRoute() {
return buildExpandedRoute(this.deserialized);
},
examples() { examples() {
return EXAMPLES.map(named => { return EXAMPLES.map(named => {
const {kind, ...fields} = named; const {kind, ...fields} = named;