toolshed/frontend/src/views/ShortId.vue
2026-09-04 15:07:40 +02:00

198 lines
7.4 KiB
Vue

<template>
<BaseLayout hide-search>
<main class="content">
<div class="container-fluid p-0">
<div v-if="qualified" class="card">
<div class="card-header">Domain</div>
<div class="card-body">{{ qualified.domain }}</div>
</div>
<div class="card">
<div class="card-header">Decoded</div>
<div class="card-body">
<ul>
<li v-for="(value, index) in decoded" :key="index">{{ value }}</li>
</ul>
</div>
</div>
<div class="card">
<div class="card-header">Deserialized</div>
<div class="card-body">
<p>kind: {{ deserialized.kind }}</p>
<ul>
<li v-for="(value, name) in fields" :key="name">{{ name }}: {{ value }}</li>
</ul>
</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-if="error" class="text-danger">{{ error }}</span>
<span v-else>No page exists for kind '{{ deserialized.kind }}'</span>
</div>
</div>
<div class="card">
<div class="card-header">Examples</div>
<table class="table table-striped mb-0">
<thead>
<tr>
<th>Kind</th>
<th>Fields</th>
<th>Serialized</th>
<th>Bits</th>
<th>Token</th>
</tr>
</thead>
<tbody>
<tr v-for="example in examples" :key="example.token">
<td>{{ example.kind }}</td>
<td>
<span v-for="(value, name) in example.fields" :key="name" class="me-2">
{{ name }}: {{ value }}
</span>
</td>
<td>[{{ example.ints.join(', ') }}]</td>
<td><code>{{ example.bits }}</code></td>
<td>
<router-link :to="'/' + example.token">{{ example.token }}</router-link>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
import {mapActions} from 'vuex';
import BaseLayout from '@/components/BaseLayout.vue';
import {
decodeShortId, deserializeShortId, encodeShortId, serializeShortId,
isDomainQualifiedShortId, decodeDomainQualifiedShortId
} from '@/lib/short-id';
import {expandedRoute as buildExpandedRoute, NEEDS_IDMAP, domainQualifiedRoute} from '@/router';
const EXAMPLES = [
{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: 'group_storage_location', owner_group_id: 5, storage_location_id: 1000},
{kind: 'category', category_id: 5},
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
];
// Debug-only mirror of short-id.js's bit-packing (2-bit kind tag + 4-bit chunks, see docs/handles-and-shortids.md), recomputed from `ints` for display only.
const KIND_TAG_BITS = 2;
const CHUNK_BITS = 4;
const DIRECT_KIND_COUNT = 2 ** KIND_TAG_BITS - 1;
function chunkedIntBits(value, chunkBits) {
value = BigInt(value);
const mask = (1n << BigInt(chunkBits)) - 1n;
const chunks = [];
let remaining = value;
do {
chunks.unshift(remaining & mask);
remaining >>= BigInt(chunkBits);
} while (remaining > 0n);
return chunks.map((chunk, i) => {
const continuation = i === chunks.length - 1 ? '0' : '1';
return continuation + chunk.toString(2).padStart(chunkBits, '0');
}).join('');
}
function shortIdBitSegments(ints) {
const [kindIndex, ...fieldValues] = ints;
const segments = [];
if (kindIndex < DIRECT_KIND_COUNT) {
segments.push(kindIndex.toString(2).padStart(KIND_TAG_BITS, '0'));
} else {
segments.push('1'.repeat(KIND_TAG_BITS));
segments.push(chunkedIntBits(kindIndex - DIRECT_KIND_COUNT, CHUNK_BITS));
}
fieldValues.forEach(value => segments.push(chunkedIntBits(value, CHUNK_BITS)));
const totalBits = segments.join('').length;
const paddingBits = (6 - (totalBits % 6)) % 6;
if (paddingBits > 0) {
segments.push('0'.repeat(paddingBits));
}
return segments.join(' ');
}
export default {
name: 'ShortId',
components: {
BaseLayout
},
props: {
short_id: {
type: String,
required: true
}
},
data() {
return {
error: null,
foreignRoute: undefined,
}
},
computed: {
// '<domain>:~<token>' vs a bare '~<token>' - see docs/handles-and-shortids.md#domain-qualified-short-id.
qualified() {
return isDomainQualifiedShortId(this.short_id) ? decodeDomainQualifiedShortId(this.short_id) : null;
},
decoded() {
return this.qualified ? this.qualified.ints : decodeShortId(this.short_id);
},
deserialized() {
return deserializeShortId(this.decoded);
},
fields() {
const {kind, ...fields} = this.deserialized;
return fields;
},
expandedRoute() {
return this.qualified ? (this.foreignRoute || null) : buildExpandedRoute(this.deserialized);
},
examples() {
return EXAMPLES.map(named => {
const {kind, ...fields} = named;
const ints = serializeShortId(named);
const token = encodeShortId(ints);
return {kind, fields, ints, token, bits: shortIdBitSegments(ints)};
});
}
},
watch: {
// expandedRoute re-evaluates once fetchIdMap resolves (see buildExpandedRoute in router.js); finishes the redirect the router's synchronous guard couldn't.
expandedRoute(url) {
if (url) {
this.$router.replace(url);
}
}
},
methods: {
...mapActions(['fetchIdMap']),
async resolveIfQualified() {
if (this.qualified) {
this.foreignRoute = await domainQualifiedRoute(this.qualified.domain, this.decoded) || null;
}
}
},
mounted() {
this.resolveIfQualified();
if (NEEDS_IDMAP.has(this.deserialized.kind) && !this.$store.state.idmapLoaded) {
this.fetchIdMap()
.then(() => this.resolveIfQualified())
.catch(e => {
this.error = e.message;
});
}
}
}
</script>