This commit is contained in:
j3d1 2026-08-27 01:39:03 +02:00
parent d56784eb8d
commit 6ee5ae38b1
8 changed files with 246 additions and 153 deletions

View file

@ -11,21 +11,17 @@ from toolshed.models import InventoryItem, WorkflowInstance
def _get_authorized_item(identity, item_id):
"""Look up an item by its owner-scoped id and confirm identity may act on it - either as its
personal owner (requires a local ToolshedUser account) or as a current member of its owning
group (works for a remote member too, since group membership is identity-level, see
docs/design-in-progress/groups-mvp.md). id is only unique within one owner/group's own items,
so the lookup itself must be scoped rather than a bare global get. Returns None if not found
or not authorized, the same shape InventoryItem.DoesNotExist handling around it already
expects."""
"""Owner-or-group-scoped item lookup; returns None if identity may not act on it."""
if identity.user.exists():
try:
return InventoryItem.objects.get(owner=identity.user.get(), id=item_id)
except InventoryItem.DoesNotExist:
pass
try:
return InventoryItem.objects.get(owner_group__in=identity.member_of_groups.all(), id=item_id)
except InventoryItem.DoesNotExist:
# Checked one group at a time, not owner_group__in=<all>, since id is only unique within one group's own items and a combined query could raise MultipleObjectsReturned on a collision.
for group in identity.member_of_groups.all():
item = InventoryItem.objects.filter(owner_group=group, id=item_id).first()
if item:
return item
return None
@ -33,7 +29,11 @@ def _get_authorized_item(identity, item_id):
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal])
def list_all_files(request, format=None): # /files/
files = File.objects.select_related().filter(connected_items__owner=request.user).distinct()
# request.user is a ToolshedUser here; reach group membership via public_identity.
files = File.objects.select_related().filter(
Q(connected_items__owner=request.user) |
Q(connected_items__owner_group__in=request.user.public_identity.member_of_groups.all())
).distinct()
return Response(FileSerializer(files, many=True).data)

View file

@ -47,7 +47,8 @@ and talks to it directly, regardless of where the frontend itself was loaded fro
A name that's unique within its own scope and carries, as part of itself, enough information to say
where it's authoritative, without needing a central registry to look it up. The general term
covering [user handles](#user-handle), [group handles](#group-handle) (proposed),
[classification handles](#classification-handle), and [item handles](#item-handle) (proposed).
[classification handles](#classification-handle), [User-Qualified IDs](#user-qualified-id), and
[Domain-Qualified Short IDs](#domain-qualified-short-id).
*See: [federation.md](federation.md#unique-handles)*
**Strict resolution** (Implemented)
@ -109,7 +110,8 @@ The asymmetric keypair backing exactly one [user handle](#user-handle); together
keypair backing it are what's called an [identity](#identity). The private key signs requests made
as that user and never leaves their control; the public key is handed out to establish trust in the
handle (at registration, or via a [friend](#friend-friendship) exchange) and is used to verify
signatures. Only user handles carry a keypair, not groups, classification handles, or item handles.
signatures. Only user handles carry a keypair, not groups, classification handles, or
User-Qualified IDs.
*See: [federation.md](federation.md#cryptography)*
**Membership list** (Proposed)
@ -157,8 +159,8 @@ a property alias specifically, also states a unit conversion (even if it's "iden
**Classification handle** (Implemented)
Umbrella term for a [tag](#tag-property-category), [property](#tag-property-category), or
[category](#tag-property-category) handle, of the form `origin#type:name`, e.g.
`git:base#property:length`. Distinct from a [user handle](#user-handle) or [item
handle](#item-handle): it names a reusable classification concept, not an actor or an owned thing.
`git:base#property:length`. Distinct from a [user handle](#user-handle) or [User-Qualified
ID](#user-qualified-id): it names a reusable classification concept, not an actor or an owned thing.
*See: [federation.md](federation.md#tags-properties-and-categories)*
**Definition fingerprint** (Partially implemented)
@ -201,29 +203,47 @@ strings today.
## Items & Physical Labels
**Item Handle** (Proposed)
The compact identifier for a specific item that's meaningful outside its owner's own account:
`user@domain.tld:id`, the owner's [user handle](#user-handle) plus a [local id](#local-id). Used
where it's already clear from context that it's a Toolshed item, e.g. inside the app, in exports,
in logs, so it doesn't need to spell that out or be openable on its own. Contrast with [Item
URL](#item-url), the self-contained form for when no such context can be assumed.
*See: [items-labels.md](design-in-progress/items-labels.md#open-design-questions)*
**Domain-Qualified Short ID** (Implemented)
A [domain](#domain) paired with a [short id](handles-and-shortids.md#short-ids) token, e.g.
`toolsheddomain.tld:~DyU`. A bare short id token is opaque and scoped to whichever backend minted
it, nothing in the token itself says which backend's id numbering to read it against, so it only
means something inside the app instance that's currently talking to that backend. Prefixing it with
its domain supplies the missing piece, the same way any other handle's domain half does, so the
pair keeps resolving correctly even after being copied out of the instance it was minted on. Unlike
a [User-Qualified ID](#user-qualified-id) or [Item URL](#item-url), it isn't limited to owned kinds
(any kind in the short id registry can be domain-qualified) and isn't meant to be openable with zero
context, no scheme, no path, opaque bit-packed payload, it belongs inside the app or between things
that already speak its short-id format, not on a physical label.
*See: [handles-and-shortids.md](handles-and-shortids.md#domain-qualified-short-id)*
**Item Label** (Proposed)
**Item Label** (Implemented)
A physical, scannable encoding (QR code, barcode, or similar) of an item's [Item URL](#item-url),
meant to be printed and stuck on the physical object it refers to.
*See: [items-labels.md](design-in-progress/items-labels.md#goals)*
**Item URL** (Proposed)
The self-contained URL form of an [Item Handle](#item-handle), for use with no context at all, e.g.
an [Item Label](#item-label): `https://<any frontend>/i/user@domain.tld/id`. Has to open directly
to the right frontend and land on the right item on its own, since the reader can't be assumed to
already know what it is or which server it belongs to. Any frontend can serve this URL, the host
named in it isn't part of the item's identity, only the handle in its path is.
**Item URL** (Implemented)
The self-contained URL form of an item's [User-Qualified ID](#user-qualified-id), for use with no
context at all, e.g. an [Item Label](#item-label): `https://<any frontend>/i/user@domain.tld/id`.
Has to open directly to the right frontend and land on the right item on its own, since the reader
can't be assumed to already know what it is or which server it belongs to. Any frontend can serve
this URL, the host named in it isn't part of the item's identity, only the handle in its path is.
*See: [items-labels.md](design-in-progress/items-labels.md#open-design-questions)*
**Local id** (Implemented)
An item's identifier as it exists today: unique only within its owner's own inventory, not
meaningful outside that owner's account. The starting point both the [Item
Handle](#item-handle) and [Item URL](#item-url) build on.
meaningful outside that owner's account. The starting point both a [User-Qualified
ID](#user-qualified-id) and [Item URL](#item-url) build on.
*See: [federation.md](federation.md#items), [items-labels.md](design-in-progress/items-labels.md#problem)*
**User-Qualified ID** (Implemented)
The compact identifier for a specific owned thing (an item, a storage location, ...) that's
meaningful outside its owner's own account: `<owner-handle>:<kind><local-id>`, e.g.
`alice@example.com:i42`, the owner's [user handle](#user-handle) (or [group
handle](#group-handle) for a group-owned thing) plus a one-letter kind tag (`i` for item, `s` for
storage location) and a [local id](#local-id). Used where it's already clear from context that it's
Toolshed data, e.g. inside the app, in exports, in logs, so it doesn't need to spell that out or be
openable on its own. Generalizes what used to be a bespoke, item-only "Item Handle" concept: the
kind letter is what an item-specific `user@domain.tld:id` shape was missing, nothing in that string
said it was specifically an item. Contrast with [Item URL](#item-url), the self-contained form for
when no such context can be assumed.
*See: [handles-and-shortids.md](handles-and-shortids.md#user-qualified-id)*

View file

@ -4,8 +4,8 @@ This is the syntax-level reference for two related but separate naming schemes.
"Unique Handles" section covers *why* Toolshed hands out handles at all and what each kind (user,
group, tag/property/category) means conceptually; this document covers the parsing rules those
handles have to follow once they're written down or embedded somewhere - legal characters and
escaping. It also covers short ids end to end: a separate, newer scheme for packing small integer
id chains into a compact token, implemented in `frontend/src/short-id.js`.
escaping. It also covers short ids end to end: a separate scheme for packing small integer id
chains into a compact token.
## Handle syntax
@ -15,14 +15,14 @@ A username ends up embedded, unescaped, in several composite formats beyond its
can't contain any character that already means something else in one of those: `@` (the
user/domain separator in a user handle), `#` (the group-handle prefix, and the origin/type
separator in a classification handle, see federation.md's Tags, Properties, and Categories
section), `:` (the id delimiter in a proposed Item Handle, the type/name delimiter in a
classification handle, and the delimiter in a signed request's `Authorization` header), `+`
(reserved as the URL-embedding escape for `#`, see below), `~` (the short-id token prefix, see
Short IDs below, and a proposed collision-disambiguation suffix delimiter on a tag's origin), and
`/` (the path-segment delimiter every handle and id ultimately sits next to once embedded in a
URL). This has to be enforced by an explicit validator rather than left to Django's default
`UnicodeUsernameValidator` (`^[\w.@+-]+\Z`), which currently permits both `@` and `+` (its own
regex doesn't happen to allow `#`, `:`, or `/`, but that's incidental, not a designed restriction).
section), `:` (the kind/id delimiter in a User-Qualified ID (see below), the type/name delimiter in
a classification handle, the domain/token delimiter in a Domain-Qualified Short ID (see below), and
the delimiter in a signed request's `Authorization` header), `+` (reserved as the URL-embedding
escape for `#`, see below), `~` (the short-id token prefix, see Short IDs below, and a
collision-disambiguation suffix delimiter on a tag's origin), and `/` (the path-segment delimiter
every handle and id ultimately sits next to once embedded in a URL). This has to be enforced by an
explicit validator rather than left to a framework default, which doesn't draw the line in the same
place.
### Embedding a `#`-bearing handle in a URL
@ -44,17 +44,45 @@ otherwise forbidden in every field a handle is built from (see Reserved characte
group name or tag name could itself contain a literal `+`, it would be indistinguishable from an
escaped `#` once decoded.
Implemented in `frontend/src/handle-url.js` (`encodeHandleForUrl`/`decodeHandleFromUrl`).
### User-Qualified ID
Owned things (an item, a storage location, ...) can be referred to outside their owner's own
account: anything whose id is only unique within one owner's own numbering needs an owner-qualified
form to resolve globally.
A **User-Qualified ID** is an owner handle (a user handle, or a group handle for a group-owned
thing) with a kind letter and a local id appended, separated by a single `:`:
`<owner-handle>:<kind><local-id>`, e.g. `alice@example.com:i42`. This is the same `origin#type:name`
pattern classification handles already use, built on `:` instead of `#` so it avoids the URL
fragment-escaping problem (see Embedding a `#`-bearing handle in a URL above) — a User-Qualified ID
isn't meant to sit directly in a URL path segment.
Its payload is a plain decimal local id with nothing self-describing baked in, unlike a Short ID's
bit-packed payload, so the owner is spelled out as a full handle rather than just a domain:
`example.com:i42` would be ambiguous between every user on that domain with local item id `42`;
`alice@example.com:i42` isn't. An owner handle already carries its domain, so a User-Qualified ID is
cross-domain-resolvable as written, with no separate domain-qualified wrapper needed.
The kind letter is a small, closed registry. A group handle already looks visibly different from a
user handle (`#` prefix), so unlike the short id kind registry below, this one doesn't need separate
letters for a user-owned vs. group-owned kind — the owner half already says which it is.
| letter | kind | notes |
|---|---|---|
| `i` | item | covers both a user-owned item and a group-owned item |
| `s` | storage location | covers both a user-owned and a group-owned storage location |
`workflow` has no letter assigned; the registry can grow without breaking anything already printed.
Kinds with no owner (`category`, `group`, `file`) don't fit this scheme: `category` and `group` each
already have their own dedicated handle form. `file` has neither an owner to qualify by nor a
dedicated handle of its own; a Domain-Qualified Short ID (below) is the fallback for it.
## Short IDs
A general encoding for turning a small, fixed-shape list of integers into a compact, URL-safe
token, with no server-side lookup table involved: the code *is* the data, nothing is stored
server-side to make it resolvable. Originally proposed to answer items-labels.md's open "what does
the handle/URL actually look like" question, but the encoding itself isn't item-specific; anything
currently addressed by a short chain of small integers is a candidate. Implemented and tested in
`frontend/src/short-id.js`; try it live at `/~<token>` (`frontend/src/views/ShortId.vue`), which
decodes whatever token is in the URL and also lists worked examples for every registered kind.
server-side to make it resolvable. Not item-specific; anything currently addressed by a short chain
of small integers is a candidate.
### Shape: a kind tag, then a fixed list of integers
@ -67,29 +95,21 @@ range" - so kind 3 is encoded as escape + chunked-int `0`, kind 4 as escape + `1
costs nothing for a kind that already fits in the direct range, and keeps the tag itself extensible
forever without ever having to widen it out from under codes that were already printed. A narrow
tag only pays off if kind usage is actually skewed the way id values are (a few kinds dominate),
which is why the registry below is ordered by expected frequency, cheapest (most-used) kind first:
which is why the registry below is ordered by expected frequency, cheapest (most-used) kind first.
Kind ids are scoped **per arity**, not globally: the same id can (and does) name a different kind
depending on how many fields follow it, since the arity is always known before the kind id needs
disambiguating:
| kind | name | fields | notes |
|---|---|---|---|
| 0 | `item` | `owner_identity_id`, `item_local_id` | dominant case - the primary physical-label use case |
| 1 | `storage_location` | `owner_identity_id`, `storage_location_id` | also label-printed |
| 2 | `category` | `category_id` | label-adjacent (tagging); global, no owner |
| 3 | `workflow` | `owner_identity_id`, `workflow_id` | shared in-app, not printed - first to pay the escape's cost |
| 4 | `group` | `group_id` | shared even less often; global, no owner |
| 5 | `file` | `file_id` | least often shared standalone; global, deduplicated by content hash |
`owner_identity_id` is `KnownIdentity.pk` (`backend/authentication/models.py`), not
`ToolshedUser.pk`. Every local account already has exactly one stable `KnownIdentity` row
(`ToolshedUser.public_identity`, created once at registration and never recreated), and every
friend this backend knows about - local or remote - is represented by that same table, unique on
`(username, domain)`. So one small integer already stands in for "this owner, as known by this
backend" for both cases, with no separate local-vs-remote branching needed, and it's the same row
federation.md's Cryptography section already treats as the trust anchor for a handle's public key.
It appears on `item`, `storage_location`, and `workflow` because their backing models
(`InventoryItem`, `StorageLocation`, `WorkflowInstance`) all FK `ToolshedUser` directly; `category`,
`group`, and `file` skip it because their models are global/unscoped (`Group` has an unowned
`members` M2M, `File` is deduplicated globally by content hash), so a bare row id is already
everything needed to look them up.
| kind id | arity | name | fields | notes |
|---|---|---|---|---|
| 0 | 2 | `item` | `owner_identity_id`, `item_local_id` | dominant case - a personally-owned item, the primary physical-label use case |
| 0 | 1 | `category` | `category_id` | label-adjacent (tagging); global, no owner - shares id 0 with `item` since the two never need the same arity |
| 1 | 2 | `group_item` | `owner_group_id`, `item_local_id` | same use case as `item`, but for a group-owned item |
| 1 | 1 | `group` | `group_id` | shared even less often; global, no owner |
| 2 | 2 | `storage_location` | `owner_identity_id`, `storage_location_id` | also label-printed |
| 2 | 1 | `file` | `file_id` | least often shared standalone; global, deduplicated by content hash |
| 3 | 2 | `workflow` | `owner_identity_id`, `workflow_id` | shared in-app, not printed - needs the escape range, since every other slot in the direct range (0-2) is already double-booked across the two arities in use |
| 4 | 2 | `group_storage_location` | `owner_group_id`, `storage_location_id` | same use case as `storage_location`, but for a group-owned location (see `group_item` above for the same owner/owner_group split); the direct range is fully double-booked, so this is the second kind that needs the escape range |
A short id is inherently scoped to the backend that minted it (an "owner" field is a row that only
exists in, and only means anything to, that one backend's database), not a portable replacement for
@ -119,13 +139,12 @@ Standard base64 assumes byte-aligned (8-bit) input, grouping 3 bytes into 4 outp
padding to a byte boundary before encoding. Since there's no byte layer here to begin with, the
bit-packed stream is instead packed directly into 6-bit groups and mapped straight onto the
URL-safe base64 alphabet (RFC 4648 §5: `-` and `_` in place of `+` and `/`), with the final
character's unused low bits padded with zeros. That padding is safe by construction: the decoder
always knows exactly how many integers a given kind calls for, and a chunk's continuation bit is
`1 = more follows`, so a run of zero-padding at the very end can never be misread as "one more
chunk" - it decodes as a terminated chunk, at which point every field the schema called for has
already been produced and decoding simply stops. No `=` padding characters are needed either; those
exist in classic base64 purely to communicate trailing-byte padding, and there is no byte layer
here to need that.
character's unused low bits padded with zeros. That padding is safe by construction: it's always
0-5 zero bits (just enough to reach the next multiple of 6), and the decoder stops reading fields
the moment a chunk decodes to 0 with nothing left after it - by the last-field-nonzero rule above,
that can only be the padding, never a genuine field, so it's discarded rather than counted. No `=`
padding characters are needed either; those exist in classic base64 purely to communicate
trailing-byte padding, and there is no byte layer here to need that.
### The leading `~`
@ -140,6 +159,35 @@ short id can be handed to any part of the stack without first checking which enc
there.
### Domain-Qualified Short ID
A short id token is deliberately opaque and scoped to whichever backend minted it: handed a bare
`~DyU`, nothing in the token itself says which backend's numbering it should be read against.
That's fine as long as the token stays inside a context that already knows the answer (the app the
user is currently looking at), but not once a token needs to travel outside that context, e.g.
pasted into a message to a friend on a different domain, or logged somewhere not tied to one
backend.
The fix is the same one every other cross-domain handle in this project already uses: pair the
opaque part with the domain that's authoritative for it. A **Domain-Qualified Short ID** is a short
id token prefixed with a domain and a literal `:`, e.g. `toolsheddomain.tld:~DyU`. The domain half
is exactly a handle's domain half (see federation.md's Unique Handles section): not a location,
just a statement of which backend to resolve the token against. Decoding still means handing the
`~token` half to that backend's decoder, same as ever; it's just no longer ambiguous which
backend's decoder to hand it to.
This is deliberately not the same thing as a User-Qualified ID (`owner-handle:i42`) or an Item URL.
It isn't a URL and isn't meant to be openable by something that doesn't already know what a
Toolshed short id is: there's no scheme, no path, and the payload after `:~` is bit-packed base64,
opaque to a human. It belongs to the same "context already makes clear it's Toolshed data" class as
a compact User-Qualified ID, meant for use inside the app (or between things that already speak the
short-id format), not for a physical label or a link shared outside it. What it adds over a bare
`~token` is that it no longer depends on "whichever backend I currently happen to be talking to" —
the domain travels with it, so it keeps resolving to the same entity once copied elsewhere. Unlike
a User-Qualified ID, it isn't limited to owned kinds: every kind in the short id registry can be
domain-qualified the same way, since the domain only ever names which backend's token namespace
applies, not which kind the token decodes to or whether that kind has an owner.
### Worked examples
Encoding `kind = item` (0), `owner_identity_id = 7`, `item_local_id = 42`:
@ -152,17 +200,19 @@ Total: 17 meaningful bits, padded to the next multiple of 6 (18) with one zero b
base64 characters: **`~DyU`**.
The same worked-out form for one example of every registered kind - `Bits` is the same
space-separated segmentation (kind tag, escape offset if present, each field, then padding) the
Examples table on `/~<token>` (`frontend/src/views/ShortId.vue`) shows for every registered kind:
space-separated segmentation: kind tag, escape offset if present, each field, then padding. Kinds
are grouped by shared id below to make the per-arity reuse visible:
| Kind | Fields | Serialized | Bits | Token |
|---|---|---|---|---|
| `item` | `owner_identity_id: 7`, `item_local_id: 42` | `[0, 7, 42]` | `00 00111 1001001010 0` | `~DyU` |
| `storage_location` | `owner_identity_id: 3`, `storage_location_id: 1000` | `[1, 3, 1000]` | `01 00011 100111111001000 00` | `~Rz8g` |
| `category` | `category_id: 5` | `[2, 5]` | `10 00101 00000` | `~ig` |
| `workflow` | `owner_identity_id: 2`, `workflow_id: 9` | `[3, 2, 9]` | `11 00000 00010 01001 0` | `~wCS` |
| `group` | `group_id: 11` | `[4, 11]` | `11 00001 01011` | `~wr` |
| `file` | `file_id: 123` | `[5, 123]` | `11 00010 1011101011 0` | `~xXW` |
| `category` | `category_id: 5` | `[0, 5]` | `00 00101 00000` | `~Cg` |
| `group_item` | `owner_group_id: 5`, `item_local_id: 42` | `[1, 5, 42]` | `01 00101 1001001010 0` | `~SyU` |
| `group` | `group_id: 11` | `[1, 11]` | `01 01011 00000` | `~Vg` |
| `storage_location` | `owner_identity_id: 3`, `storage_location_id: 1000` | `[2, 3, 1000]` | `10 00011 100111111001000 00` | `~hz8g` |
| `file` | `file_id: 123` | `[2, 123]` | `10 1011101011` | `~rr` |
| `workflow` | `owner_identity_id: 2`, `workflow_id: 9` | `[3, 2, 9]` | `11 00000 00010 01001 0` | `~wiS` |
| `group_storage_location` | `owner_group_id: 5`, `storage_location_id: 1000` | `[4, 5, 1000]` | `11 00001 00101 100111111001000 000` | `~wln5A` |
`workflow`, `group`, and `file` are kinds 3-5, so their `Bits` column shows the escape tag (`11`)
followed by its own offset segment payload fields.

View file

@ -1,9 +0,0 @@
# Pixel fonts used for small label text (see ../../../scss/_pixel-fonts.scss)
- **Tom Thumb** (`TomThumb.ttf`) - by Brian Swetland, TTF conversion by gheja
(https://github.com/gheja/tom-thumb-ttf). Licensed CC0 or CC-BY 3.0 (original:
https://robey.lag.net/2010/01/23/tiny-monospace-font.html).
- **PICO-8** (`PICO-8.ttf`) - reproduction by Jacob Pierce
(https://github.com/jacobpierce/pico-8-font). MIT License, Copyright (c) 2016 Jacob Pierce.
- **Silkscreen** (`Silkscreen-Regular.woff2`) - by Jason Kottke, served via Google Fonts
(https://fonts.google.com/specimen/Silkscreen). SIL Open Font License 1.1.

View file

@ -149,10 +149,16 @@ export const LABEL_TEMPLATES = [
},
{
id: "item-handle", name: "Item handle",
description: "The compact owner@domain:id handle - meaningful in-app, not scannable on its own.",
description: "The compact owner@domain:i<id> User-Qualified ID - meaningful in-app, not scannable on its own.",
required_vars: ["itemHandle"], tags: ["internal"],
layout: [{type: "text", content: c => c.itemHandle}]
},
{
id: "location-handle", name: "Storage location handle",
description: "The compact owner@domain:s<id> User-Qualified ID - meaningful in-app, not scannable on its own.",
required_vars: ["locationHandle"], tags: ["internal"],
layout: [{type: "text", content: c => c.locationHandle}]
},
{
id: "item-url", name: "Item URL",
description: "The full item URL as text, with no code - for copying rather than scanning.",
@ -252,11 +258,16 @@ export const DERIVED_VARS = {
inputs: ["webdomain", "userHandle", "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.
// Compact User-Qualified ID (see docs/handles-and-shortids.md#user-qualified-id) - owner
// handle + a one-letter kind tag + local id, meaningful only where context already makes
// clear it's Toolshed data, unlike itemUrl. `i` = item, `s` = storage location.
itemHandle: {
inputs: ["userHandle", "itemId"],
calc: (f) => `${f.userHandle}:${f.itemId}`,
calc: (f) => `${f.userHandle}:i${f.itemId}`,
},
locationHandle: {
inputs: ["userHandle", "locationId"],
calc: (f) => `${f.userHandle}:s${f.locationId}`,
},
};

View file

@ -234,7 +234,7 @@
<script>
import * as BIcons from "bootstrap-icons-vue";
import {markRaw, nextTick} from "vue";
import {mapActions, mapGetters} from "vuex";
import {mapActions, mapGetters, mapState} from "vuex";
import BaseLayout from "@/components/BaseLayout.vue";
import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
import printerManager from "@/printerManager.js";
@ -337,7 +337,12 @@ export default {
};
},
computed: {
...mapState(["user"]),
...mapGetters(["identityIdByHandle", "groupIdByHandle"]),
homeDomain() {
const at = this.user ? this.user.indexOf("@") : -1;
return at === -1 ? null : this.user.slice(at + 1);
},
baseVars() {
return BASE_VARS.filter(v => v !== SHORT_URL_VAR && v !== SHORT_ID_VAR);
},
@ -357,7 +362,8 @@ export default {
if (shortId) {
derived[SHORT_ID_VAR] = shortId;
if (derived.webdomain) {
derived[SHORT_URL_VAR] = derived.webdomain + "/" + shortId;
derived[SHORT_URL_VAR] = derived.webdomain + "/"
+ (this.homeDomain ? this.homeDomain + ":" + shortId : shortId);
}
}
return derived;
@ -502,9 +508,6 @@ export default {
return ticks;
},
// Reads the recently-printed template ids back from localStorage; same try/catch shape as
// cameraManager.js's getRecentCameras since either a disabled/full localStorage shouldn't
// break printing.
loadRecentTemplateIds() {
try {
const saved = localStorage.getItem(RECENT_TEMPLATES_KEY);
@ -514,9 +517,6 @@ export default {
}
},
// Moves `id` to the front of the recent-templates list (deduping any earlier occurrence),
// capped to MAX_RECENT_TEMPLATES, so LabelLayoutPreview.vue's grid always bubbles the
// last four printed/downloaded layouts to the top.
rememberPrintedTemplate(id) {
const updated = [id, ...this.recentTemplateIds.filter(t => t !== id)].slice(0, MAX_RECENT_TEMPLATES);
this.recentTemplateIds = updated;

View file

@ -70,17 +70,10 @@
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";
import {encodeHandleForUrl, expandedRoute, OWNED_KIND_FIELDS} from "@/router";
import {decodeShortId, deserializeShortId, isDomainQualifiedShortId, decodeDomainQualifiedShortId} 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+)$/;
const OWNER_QUALIFIED_ID_RE = /^(#?[^\s@:/#+~]+@[^\s@:/#+~]+):([is])(\d+)$/;
function classifyScanText(text) {
if (!text) {
@ -88,32 +81,29 @@ function classifyScanText(text) {
}
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('~')) {
if (text.startsWith('~') || isDomainQualifiedShortId(text)) {
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};
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; // starts with '~' but isn't a real short id - leave as plain text
return null; // looked like a short id but isn't a real one - 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}};
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"
? `/storage-locations/${encodeHandleForUrl(handle)}/${id}`
: `/inventory/${encodeHandleForUrl(handle)}/${id}`;
return {to, ownerQualifiedId: {handle, kind, id}};
}
return null;
}
@ -154,7 +144,7 @@ export default {
};
},
methods: {
...mapActions(["fetchItemByHandle", "fetchStorageLocationByHandle", "fetchIdMap"]),
...mapActions(["fetchItemByHandle", "fetchStorageLocationByHandle", "fetchIdMap", "resolveShortId"]),
resolveDescription(entry) {
const {link, text} = entry;
@ -190,13 +180,17 @@ export default {
},
async describeLink(link) {
if (link.itemHandle) {
return this.describeItem(link.itemHandle.handle, link.itemHandle.id);
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
@ -236,6 +230,22 @@ export default {
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;
@ -263,20 +273,11 @@ export default {
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();
},
}

View file

@ -2,6 +2,10 @@
<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">
@ -64,8 +68,11 @@
<script>
import {mapActions} from 'vuex';
import BaseLayout from '@/components/BaseLayout.vue';
import {decodeShortId, deserializeShortId, encodeShortId, serializeShortId} from '@/short-id';
import {expandedRoute as buildExpandedRoute, NEEDS_IDMAP} from '@/router';
import {
decodeShortId, deserializeShortId, encodeShortId, serializeShortId,
isDomainQualifiedShortId, decodeDomainQualifiedShortId
} from '@/short-id';
import {expandedRoute as buildExpandedRoute, NEEDS_IDMAP, domainQualifiedRoute} from '@/router';
const EXAMPLES = [
{kind: 'item', owner_identity_id: 7, item_local_id: 42},
@ -130,12 +137,17 @@ export default {
},
data() {
return {
error: null
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 decodeShortId(this.short_id);
return this.qualified ? this.qualified.ints : decodeShortId(this.short_id);
},
deserialized() {
return deserializeShortId(this.decoded);
@ -145,7 +157,7 @@ export default {
return fields;
},
expandedRoute() {
return buildExpandedRoute(this.deserialized);
return this.qualified ? (this.foreignRoute || null) : buildExpandedRoute(this.deserialized);
},
examples() {
return EXAMPLES.map(named => {
@ -165,11 +177,19 @@ export default {
}
},
methods: {
...mapActions(['fetchIdMap'])
...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().catch(e => {
this.fetchIdMap()
.then(() => this.resolveIfQualified())
.catch(e => {
this.error = e.message;
});
}