Snapshot: alpha-2026-9
This commit is contained in:
parent
9acf5a97e2
commit
d00b5c7961
241 changed files with 85546 additions and 2409 deletions
|
|
@ -97,9 +97,22 @@ Start the fullstack application:
|
|||
docker-compose -f deploy/docker-compose.override.yml up --build
|
||||
```
|
||||
|
||||
Run backend tests in Docker:
|
||||
|
||||
``` bash
|
||||
docker compose -f deploy/docker-compose.override.yml run --rm backend-a bash -lc "python configure.py && python manage.py test"
|
||||
```
|
||||
|
||||
This will start an instance of the frontend and wiki, a limited DoH (DNS over HTTPS) server and **two** instances of the backend.
|
||||
The two backend instances are set up to use the domains `a.localhost` and `b.localhost`, the local DoH
|
||||
server is used to direct the frontend to the correct backend instance.
|
||||
The frontend is configured to act as if it was served from the domain `a.localhost`.
|
||||
Access the frontend at `http://localhost:8080/`, backend at `http://localhost:8080/api/`, api docs
|
||||
at `http://localhost:8080/docs/` and the wiki at `http://localhost:8080/wiki/`.
|
||||
Access the frontend at `http://localhost:8080/`, backend at `http://localhost:8080/api/v1/`, api docs
|
||||
at `http://localhost:8080/docs/` and the wiki at `http://localhost:8080/wiki/`.
|
||||
|
||||
The dev proxies terminate TLS with `frontend/.local/localhost.crt`, signed by the dev-only CA at
|
||||
`frontend/.local/RootCA.crt` (regenerate both with `frontend/.local/make_ca.sh` and
|
||||
`make_localhost.sh` if they've expired). Import `RootCA.crt` into your OS/browser trust store once;
|
||||
without that, requests the frontend makes itself (federation calls, the DoH lookup) will fail with
|
||||
opaque network errors even though clicking through the browser's own cert warning for the page you
|
||||
navigated to by hand looked fine.
|
||||
|
|
@ -20,4 +20,97 @@ uses it to verify access to the friend's inventory. While accepting a friend req
|
|||
their own public key to the friend's server. This way both users can access each other's inventory.
|
||||
|
||||
The protocol is based on a simple HTTPS API exchanging JSON data that is signed with the user's private key. By default
|
||||
Toolshed servers provide a documentation of the API at [/docs/api](/docs/api).
|
||||
Toolshed servers provide a documentation of the API at [/docs/api](/docs/api).
|
||||
|
||||
## Unique Handles
|
||||
|
||||
Federation only works if every server can talk about the same thing without a central authority to ask. Toolshed's
|
||||
answer is that every kind of entity that needs to be referenced across servers gets a handle: a name that is unique
|
||||
within its own scope and that carries, as part of itself, enough information to say where it is authoritative. This
|
||||
keeps servers independent of each other while still letting them agree on what they're talking about.
|
||||
|
||||
### Users (and Groups)
|
||||
|
||||
A user's handle is their username paired with the domain their account belongs to, written the way an email address
|
||||
is, e.g. `user@toolsheddomain.tld`. Uniqueness is only required within a single domain, not across all of Toolshed,
|
||||
so two different domains can each have their own "alice" without conflict, the same way two different email
|
||||
providers can each have an "alice" mailbox. The domain half of the handle is what makes the name globally
|
||||
unambiguous, and it is also what tells any other backend where to look to find out who's currently authoritative for
|
||||
that identity, i.e. which backend holds the account and can vouch for its public key.
|
||||
|
||||
Groups aren't implemented yet, but they're intended to fit the same idea: a group would get its own handle on the
|
||||
domain of the server that hosts it, the same way a user does, so that group membership and group-owned data could be
|
||||
referenced by other servers without needing a separate mechanism. A group handle is written with a leading `#`, e.g.
|
||||
`#groupname@toolsheddomain.tld`, so that group and user handles occupy visibly distinct spaces on the same domain
|
||||
and a name can't be squatted as one to collide with the other. See [groups.md](design-in-progress/groups.md) for
|
||||
details.
|
||||
|
||||
### Servers
|
||||
|
||||
The domain half of a handle, e.g. `toolsheddomain.tld`, is an authority record, not a location. Owning a domain
|
||||
just means being able to say which backend is currently authoritative for handles under it; it says nothing about
|
||||
where that backend is hosted, who operates it, or how many other domains it might also be authoritative for. A
|
||||
single backend can just as easily host entities for one domain or for many unrelated ones at once, there's no
|
||||
assumption anywhere in the model that a domain and a backend are the same thing, or that the relationship is one to
|
||||
one.
|
||||
|
||||
The frontend application is a third, separate thing again. The app a user loads isn't necessarily served by, or
|
||||
even related to, the backend that ends up handling their requests: when given a handle, the frontend looks up which
|
||||
backend is currently authoritative for that handle's domain and talks to that backend directly from then on. So
|
||||
using the frontend at one domain to log into a backend authoritative for a completely different domain isn't a
|
||||
special case, it's the normal path, since "where the app was loaded from" and "which backend answers for a given
|
||||
handle" were never the same question to begin with. Servers, in the cryptographic sense described below, don't have
|
||||
an identity of their own beyond the handles they're currently authoritative for; a backend is, conceptually, just
|
||||
wherever a given domain's handles happen to resolve to right now.
|
||||
|
||||
### Tags, Properties, and Categories
|
||||
|
||||
Inventory items aren't just described in free text, they can be classified with tags, properties, and categories,
|
||||
and those get handles too, written as an origin followed by the kind and name, e.g. `origin#tag:drill` or
|
||||
`origin#category:power-tools`. This lets the same short name (e.g. a "drill" tag) exist independently under
|
||||
different origins without colliding, while a handle as a whole unambiguously says which taxonomy an entry belongs
|
||||
to.
|
||||
|
||||
An origin isn't necessarily a server; it's whatever the classification is considered to have come from, which could
|
||||
be a shared, canonical reference dataset that multiple servers import and reuse, just as easily as it could be a
|
||||
server's own locally-invented taxonomy. This lets independently-run servers converge on a shared vocabulary where it
|
||||
matters, without forcing every server to invent its own from scratch or requiring a central body to define one.
|
||||
|
||||
Handles are resolved strictly: a reference to an origin or entity a server doesn't know about is left unresolved
|
||||
rather than being guessed at or silently merged into something that looks similar. This mirrors the rest of
|
||||
Toolshed's federation philosophy, nothing is combined across servers implicitly; agreement always has to be
|
||||
traceable to an explicit, shared handle.
|
||||
|
||||
### Items
|
||||
|
||||
Inventory items don't get a handle of their own the way tags or categories do, because they don't need one: every
|
||||
item belongs to exactly one user, so a simple local identifier is already enough to tell two items apart within that
|
||||
user's inventory. Combined with the owner's user handle, that local identifier is automatically unique across all of
|
||||
Toolshed too, since no two users share a handle. Unlike a tag or category, an item isn't meant to be the same entity
|
||||
reused across servers, it describes something one specific person actually owns, so there's no shared-origin concept
|
||||
to design for here, ownership alone already provides the scope.
|
||||
|
||||
## Cryptography
|
||||
|
||||
Handles say who or what is being referred to; cryptography is what lets a server trust that the entity behind a
|
||||
handle really is who it claims to be, without needing to ask a central authority. Every user handle has exactly one
|
||||
asymmetric keypair backing it: a private key that never leaves the user's control, and a public key that gets handed
|
||||
out freely as part of establishing that handle elsewhere.
|
||||
|
||||
A server first learns a public key at the moment it has reason to trust it: for its own users, that's registration;
|
||||
for a friend's handle, that's the friend-request/accept exchange described above. From then on, a public key is
|
||||
permanently paired with the handle it arrived with, never with a server. This is why friending is really a
|
||||
key-exchange ceremony rather than just a social action, accepting a request is the moment a server starts trusting a
|
||||
new handle's signature.
|
||||
|
||||
Every request made on a user's behalf is signed with that user's private key, and whichever server receives it
|
||||
verifies the signature against the public key it holds for that handle. This is what makes it safe for a request to
|
||||
travel to a server that isn't the user's home server: the receiving server doesn't need to trust the network path or
|
||||
the sender, only the signature.
|
||||
|
||||
It's worth being explicit about what this layer of cryptography is for and what it isn't. Signing establishes
|
||||
authenticity and integrity, that a request genuinely came from the handle it claims to, unaltered, not
|
||||
confidentiality. The data itself isn't encrypted by the protocol; keeping it private in transit is what the
|
||||
underlying HTTPS layer is for. Only user handles carry a keypair; tags, categories, properties, and item handles are
|
||||
just names, their trustworthiness comes entirely from being reachable only through a signed request from the user
|
||||
handle that owns or created them, not from any cryptographic identity of their own.
|
||||
109
docs/glossary-todo.md
Normal file
109
docs/glossary-todo.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# Glossary terminology TODO
|
||||
|
||||
Working list from a repo-wide audit of where code/docs use a different word for a concept that
|
||||
[glossary.md](glossary.md) already gives a canonical name. Nothing here has been changed yet, this
|
||||
is a collection point before any renaming/edit work starts. Grouped by glossary term; only real
|
||||
inconsistencies are listed, not every correct usage that was checked and cleared.
|
||||
|
||||
## Backend
|
||||
|
||||
- `frontend/src/federation.js` — the whole module (`class ServerSet`, `add(server)`, every
|
||||
request method) talks about "server" throughout where the concept is a **Backend**.
|
||||
- `frontend/src/store.js` — `getHomeServers`, `getFriendServers`, `getAllKnownServers`,
|
||||
`setAllFriendsServers`/`all_friends_servers`, `home_servers`, `lookupServer` (~line 313, 325,
|
||||
332, 359, 76-77, 107-108, 334).
|
||||
- `frontend/src/views/Friends.vue:16,43` — user-visible table column labeled "Server".
|
||||
- `docs/design-in-progress/tags.md` (lines 10, 20, 45, 76-78, 117, 204, 228-229) — "server" used
|
||||
throughout for what the glossary calls Backend (same looseness federation.md already has, but
|
||||
worth normalizing here too since tags.md is in active editing).
|
||||
- `issues.md:58` — "federated home servers" / "user identity" conflation.
|
||||
- `deploy/dev/docker-compose.yml` (`instance_a`/`instance_b`) and `docs/development.md:106-108` —
|
||||
"instance"/"backend instance" as a near-synonym for Backend.
|
||||
- `cli-client/toolshed-client.py:11-70`, `README.md:92` — `--host`/`self.host` for "which backend
|
||||
to talk to".
|
||||
|
||||
## Discovery
|
||||
|
||||
- `frontend/src/store.js:313` — `lookupServer` action *is* the discovery operation, never named
|
||||
"discovery".
|
||||
- `frontend/src/store.js:347` — `could not resolve server for friend` — "resolve" used instead.
|
||||
- `docs/design-in-progress/items-labels.md:99`, `docs/development.md:108` — describe the
|
||||
discovery operation via "resolves"/"direct the frontend to the correct backend" without naming
|
||||
it (minor, but candidates for a one-word tightening).
|
||||
|
||||
## Handle / User handle
|
||||
|
||||
- `frontend/src/store.js` — several action params destructured as `{username}` that actually carry
|
||||
a full handle: `lookupServer` (313), `getFriendServers` (359), `fetchFriendProfile` (401-405),
|
||||
`login` (276-282).
|
||||
- `frontend/src/views/Login.vue` (lines 24, 27-28, 82-83, 102-105, 115-117) — form label/variable
|
||||
"Username" for a field that must be a full user handle (`user@domain`, per its own validation
|
||||
message at line 103).
|
||||
- `frontend/src/router.js:51` — route param `/inventory/shared/:user/:id` uses `:user` for what's
|
||||
meant to eventually be a full handle; contrast with the sibling route at line 61 which already
|
||||
correctly uses `:handle`. (Already called out by items-labels.md itself, so low-risk to leave
|
||||
as-is, but listed for completeness.)
|
||||
|
||||
## Availability policy, Friend/Friendship, Signature/Signing, Strict resolution, Actor, Targeted sharing
|
||||
|
||||
No real inconsistencies found — implemented code already uses the glossary's own terms
|
||||
consistently (`availability_policy` field name throughout backend+frontend; `friend`/`befriend`
|
||||
consistently; `Signature`/`sign`/`verify` consistently; `_HandleNotFound`/`_resolve_handle` in
|
||||
`backend/toolshed/offlinedata.py` implement strict resolution faithfully without needing to name
|
||||
it; Actor and Targeted sharing are unimplemented with no competing name anywhere).
|
||||
|
||||
- Checked and cleared, not a real conflict: `frontend/src/neigbors.js`'s `NeighborsCache`/
|
||||
"neighbor" vocabulary — refers to unreachable backend *domains* during discovery, not to
|
||||
friendship, despite reading like a synonym at a glance.
|
||||
|
||||
## Keypair / Private key / Public key
|
||||
|
||||
- Wire-format drift on the one field that actually crosses the network: `befriender_key` is used
|
||||
for a public key at `frontend/src/store.js:435,449` and `backend/toolshed/api/friend.py:107`,
|
||||
while the model field, serializer field, and UI all call the same value
|
||||
`befriender_public_key`/`public_key` (`backend/authentication/models.py:144`,
|
||||
`backend/toolshed/serializers.py:65`, `backend/toolshed/api/friend.py:118`,
|
||||
`frontend/src/views/Friends.vue:81`).
|
||||
- `cli-client/toolshed-client.py` (`--key`, `TOOLSHED_KEY`, `self.signing_key`, ~lines 12, 52, 57)
|
||||
and `README.md:92` — never say "private key," just "key"/"Toolshed key", even though it's
|
||||
specifically the private half.
|
||||
|
||||
## Origin
|
||||
|
||||
- `backend/configure.py:130` ("Identifier set {} already imported, skipping") and the model
|
||||
`ImportedIdentifierSets` (`backend/hostadmin/models.py:13-19`) — call an imported origin dataset
|
||||
an "identifier set".
|
||||
- `issues.md:200-202` — Instance Admin TODO list: "identifier-sets" for **Origin** (matches
|
||||
`ImportedIdentifierSets`/`configure.py`: one identifier set per `shared_data/*.json` file, named
|
||||
`git:<file>`, i.e. exactly an Origin) and bare "identifiers" for **Classifier** (not Classification
|
||||
handle — a classification handle is the `origin#type:name` pointer, not the entity it names).
|
||||
|
||||
## Alias
|
||||
|
||||
- `backend/shared_data/ee_packages.json:40,64` — two tags already carry an `"alias"` field in real
|
||||
data (e.g. `SOT54` → `alias: "TO-92"`), but shaped as a bare name string, not an
|
||||
`origin#type:name` handle pointer, and with no unit-conversion concept. It's silently dropped on
|
||||
import today (`Tag`/`TagSerializer` have no `alias` field). Not a different-word issue, but the
|
||||
design doc (which calls Alias "Proposed") doesn't acknowledge this pre-existing, inert
|
||||
precedent — worth reconciling either the data or the doc.
|
||||
|
||||
## Tag / Property / Category
|
||||
|
||||
- `frontend/src/components/workflow/workflows/BulkItemImportWorkflow.vue:495` — CSV
|
||||
column-auto-mapping heuristic treats `"type"` as a synonym for Category:
|
||||
`lowerColumn.includes('category') || lowerColumn.includes('type')`.
|
||||
|
||||
## Item Label
|
||||
|
||||
- `frontend/src/components/workflow/workflows/FotoFirstBulkImportWorkflow.vue:567-572,822` and
|
||||
`FotoFirstBulkImportWorkflow2.vue:593-598,847` — checkbox "Generate QR codes for items" /
|
||||
`importOptions.generate_qr_codes` names exactly the Item Label concept but never uses that term
|
||||
(and the option is currently unwired — declared and defaulted `true` but never read elsewhere).
|
||||
|
||||
## Item URL / Local id / Domain / Frontend / Definition fingerprint / Fragmentation / Handle collision / Classification handle
|
||||
|
||||
No real inconsistencies found — each already uses consistent, glossary-matching vocabulary
|
||||
(`id`/`item_id` for Local id; `origin` kept cleanly separate from `domain` everywhere it's used;
|
||||
`get_handle()` consistently for Classification handle; no competing names found anywhere for
|
||||
Fragmentation, Handle collision, or Definition fingerprint, which also doesn't collide with the
|
||||
unrelated `File.hash` content-hash field despite both being called "hash").
|
||||
258
docs/glossary.md
Normal file
258
docs/glossary.md
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
# Glossary
|
||||
|
||||
This page collects one unambiguous name for each concept discussed in [federation.md](federation.md)
|
||||
and the [design-in-progress](design-in-progress/) documents, so later writing can refer to them
|
||||
consistently instead of reinventing or subtly renaming them. Entries are grouped by topic, and
|
||||
alphabetical within each group. Each one notes whether the concept exists in Toolshed today or is
|
||||
still a design proposal, and links back to where it's discussed in full.
|
||||
|
||||
Two terms are easy to conflate and worth telling apart up front: a **domain** is who a handle
|
||||
belongs to; an **origin** is where a classification entry came from. They look similar in prose but
|
||||
are unrelated concepts, see their entries below.
|
||||
|
||||
---
|
||||
|
||||
## Federation Basics
|
||||
|
||||
**Backend** (Implemented)
|
||||
The server software that stores an [actor](#actor)'s data and is currently authoritative for a
|
||||
[domain](#domain). Deliberately decoupled from both the domain (a backend isn't tied to one domain,
|
||||
and can be authoritative for many at once) and the [frontend](#frontend) (the app a user loads isn't
|
||||
necessarily served by the backend that ends up handling their requests). Prefer "backend" over the
|
||||
looser word "server" when precision matters, "server" is used informally in places to mean either
|
||||
backend or domain interchangeably.
|
||||
*See: [federation.md](federation.md#servers)*
|
||||
|
||||
**Discovery** (Implemented)
|
||||
The lookup a [frontend](#frontend) performs to find which [backend](#backend) is currently
|
||||
authoritative for a [domain](#domain), given a [handle](#handle). Operational/DNS-level detail
|
||||
about how this lookup works belongs in the deployment docs, not here, this glossary only fixes the
|
||||
name for the concept.
|
||||
*See: [federation.md](federation.md#servers)*
|
||||
|
||||
**Domain** (Implemented)
|
||||
The half of a [handle](#handle) after the `@`, e.g. `toolsheddomain.tld`. An authority record, not
|
||||
a location or a piece of software: it says which [backend](#backend) currently vouches for handles
|
||||
under it, nothing more. Not the same thing as an [origin](#origin), see the note at the top of this
|
||||
page.
|
||||
*See: [federation.md](federation.md#servers)*
|
||||
|
||||
**Frontend** (Implemented)
|
||||
The client application a user interacts with. Independent of any one [domain](#domain) or
|
||||
[backend](#backend): given a handle, it performs [discovery](#discovery) to find the right backend
|
||||
and talks to it directly, regardless of where the frontend itself was loaded from.
|
||||
*See: [federation.md](federation.md#servers)*
|
||||
|
||||
**Handle** (Implemented)
|
||||
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), [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)
|
||||
The rule that a reference to a [handle](#handle) a backend doesn't recognize is left unresolved
|
||||
rather than guessed at or silently merged into something that looks similar. The foundational
|
||||
guarantee the rest of the handle system, and most of the open problems in
|
||||
[tags.md](design-in-progress/tags.md), are trying to preserve or actually make hold.
|
||||
*See: [federation.md](federation.md#tags-properties-and-categories)*
|
||||
|
||||
## Actors, Identity & Sharing
|
||||
|
||||
**Actor** (Proposed)
|
||||
Umbrella term for anything that can hold a handle, have friends, and own items. Today this only
|
||||
means [user](#user). [Groups](#group) are a proposed second kind of actor, so that "friendship,"
|
||||
"ownership," and "handle" all mean the same thing regardless of which kind of actor is involved.
|
||||
*See: [groups.md](design-in-progress/groups.md#are-group-handles-different-from-user-handles-or-is-a-group-just-a-special-kind-of-user)*
|
||||
|
||||
**Availability policy** (Implemented)
|
||||
A setting on an item (`private` / `share` / `lend` / `rent` / `sell`) controlling who besides the
|
||||
owner can see it. Today the audience for any non-`private` policy is implicitly "all of the owner's
|
||||
[friends](#friend-friendship)," equally, there's no way to name a narrower audience. See [targeted
|
||||
sharing](#targeted-sharing) for the proposed alternative.
|
||||
*See: [groups.md](design-in-progress/groups.md#interaction-with-availability-policy)*
|
||||
|
||||
**Friend / Friendship** (Implemented for users; proposed for groups)
|
||||
A mutual, explicitly-established trust relationship between two [actors](#actor). Established by a
|
||||
friend-request/accept exchange, which is also the point a [public key](#keypair-private-key-public-key)
|
||||
is first trusted for that handle. Currently only exists between users; groups having friends, and
|
||||
groups befriending groups, are proposed extensions of the same mechanism, not a new one.
|
||||
*See: [federation.md](federation.md#cryptography), [groups.md](design-in-progress/groups.md#should-a-group-be-able-to-grant-read-access-to-non-members-group-friends)*
|
||||
|
||||
**Group** (Implemented)
|
||||
A second kind of [actor](#actor), modeling collective ownership (a club, workshop, or company)
|
||||
rather than any one person owning something. All members hold equal edit rights over what the group
|
||||
owns; membership itself is the privilege, there's no separate owner/member distinction within a
|
||||
group. Backed by a [membership list](#membership-list) rather than a [keypair](#keypair-private-key-public-key),
|
||||
and identified by a [group handle](#group-handle). Groups can own items and storage locations today
|
||||
(`owner_group` on both); a group having its own friends, or befriending another group, is not
|
||||
implemented, so a group is not yet a full [actor](#actor) in every sense of that entry.
|
||||
*See: [groups.md](design-in-progress/groups.md#what-a-group-is)*
|
||||
|
||||
**Group handle** (Implemented)
|
||||
A [group](#group)'s handle: a name and [domain](#domain) written like a [user handle](#user-handle)
|
||||
but prefixed with `#`, e.g. `#groupname@toolsheddomain.tld`. The prefix keeps groups and users in
|
||||
disjoint namespaces on the same domain (no squatting collision between a user and a group wanting
|
||||
the same name) and lets an actor's kind be read directly off its handle, without a lookup.
|
||||
*See: [federation.md](federation.md#users-and-groups), [groups.md](design-in-progress/groups.md#are-group-handles-different-from-user-handles-or-is-a-group-just-a-special-kind-of-user)*
|
||||
|
||||
**Identity** (Implemented)
|
||||
A [user handle](#user-handle) paired with the [keypair](#keypair-private-key-public-key) that backs
|
||||
it, held together as the one unit a [backend](#backend) actually trusts: not just a name, and not
|
||||
just key material, but both at once. This is what gets established at registration for one's own
|
||||
handle, and what gets recorded on [friend](#friend-friendship)-accept for someone else's handle.
|
||||
Only [users](#user) have an identity in this sense, since a [group](#group) is deliberately backed
|
||||
by a [membership list](#membership-list) instead of a keypair, there's no key half for a group
|
||||
handle to pair with.
|
||||
*See: [federation.md](federation.md#cryptography)*
|
||||
|
||||
**Keypair / Private key / Public key** (Implemented, users only)
|
||||
The asymmetric keypair backing exactly one [user handle](#user-handle); together, a handle and the
|
||||
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
|
||||
User-Qualified IDs.
|
||||
*See: [federation.md](federation.md#cryptography)*
|
||||
|
||||
**Membership list** (Implemented)
|
||||
The authoritative record of which [user handles](#user-handle) currently belong to a
|
||||
[group](#group), maintained by whichever backend is authoritative for the group's handle
|
||||
(`Group.members` in `authentication/models.py`). What backs a group's identity in place of a
|
||||
keypair: a request "as the group" is a normal signed request from a current member, plus a check
|
||||
against this list, not a request signed by some shared group key. Distinct from
|
||||
`authentication.models.GroupMembership`, a second, member-side record kept on a *member's own* home
|
||||
backend mirroring the bare fact of belonging (analogous to how a friendship is independently
|
||||
recorded on both sides) — that pointer is not itself authoritative, it exists so a member's own
|
||||
backend can list groups it believes the member belongs to without querying every other backend.
|
||||
*See: [groups.md](design-in-progress/groups.md#does-a-group-need-its-own-keypair)*
|
||||
|
||||
**Signature / Signing** (Implemented)
|
||||
The act of authenticating a request as genuinely coming from a specific [user
|
||||
handle](#user-handle), unaltered, using that handle's private key. Establishes authenticity and
|
||||
integrity only, not confidentiality (that's HTTPS's job) and not, today, protection against replay.
|
||||
*See: [federation.md](federation.md#cryptography)*
|
||||
|
||||
**Targeted sharing** (Proposed)
|
||||
Sharing an item with one specific [actor](#actor) (a particular friend, or a particular group)
|
||||
instead of the current all-or-nothing [availability policy](#availability-policy) audience of every
|
||||
friend equally. Flagged as a generalization useful beyond groups specifically, not a groups-only
|
||||
feature.
|
||||
*See: [groups.md](design-in-progress/groups.md#should-anyone-be-able-to-share-directly-with-a-specific-group-instead-of-with-my-friends-generally)*
|
||||
|
||||
**User** (Implemented)
|
||||
The original, and currently only implemented, kind of [actor](#actor): backed by exactly one
|
||||
[keypair](#keypair-private-key-public-key) and identified by a [user handle](#user-handle).
|
||||
*See: [federation.md](federation.md#users-and-groups)*
|
||||
|
||||
**User handle** (Implemented)
|
||||
A user's username paired with its [domain](#domain), written like an email address, e.g.
|
||||
`user@toolsheddomain.tld`. Unique only within its domain, not across all of Toolshed. Contrast with
|
||||
a [group handle](#group-handle), which is the same shape but prefixed with `#`. Paired with its
|
||||
[keypair](#keypair-private-key-public-key), the two together are called an [identity](#identity).
|
||||
*See: [federation.md](federation.md#users-and-groups)*
|
||||
|
||||
## Classification: Tags, Properties & Categories
|
||||
|
||||
**Alias** (Proposed)
|
||||
An explicit, one-directional "supersedes"/"alias of" pointer from one [classification
|
||||
handle](#classification-handle) to another, asserting they mean the same thing. Never inferred
|
||||
automatically, always a deliberate act, in keeping with [strict resolution](#strict-resolution). For
|
||||
a property alias specifically, also states a unit conversion (even if it's "identical unit, factor
|
||||
1"); without one, the values behind the two handles aren't assumed to be comparable.
|
||||
*See: [tags.md](design-in-progress/tags.md#open-design-ideas)*
|
||||
|
||||
**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 [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)
|
||||
A hash of a classification entry's full definition, used to tell whether two [actors](#actor)
|
||||
that both use the same handle actually mean the same thing. A sha256 of each `shared_data/*.json`
|
||||
file is already computed and stored per import (`ImportedIdentifierSets.hash`), but it's only ever
|
||||
recorded, not compared, so it doesn't yet catch a [handle collision](#handle-collision) in
|
||||
practice.
|
||||
*See: [tags.md](design-in-progress/tags.md#open-design-ideas)*
|
||||
|
||||
**Fragmentation** (Known problem)
|
||||
Two different [classification handles](#classification-handle) that mean, or were intended to
|
||||
mean, the same real-world concept (different origins, or a locally-invented tag versus a shared
|
||||
one). The opposite failure from a [handle collision](#handle-collision): visible and merely
|
||||
wasteful, rather than silent and dangerous.
|
||||
*See: [tags.md](design-in-progress/tags.md#problem)*
|
||||
|
||||
**Handle collision** (Known problem)
|
||||
Two [actors](#actor) independently ending up with the exact same [classification
|
||||
handle](#classification-handle) string backing two different definitions, e.g. two servers that
|
||||
each imported `git:ee2` from what has since become diverging branches. The dangerous mirror image
|
||||
of [fragmentation](#fragmentation): silent, because nothing about the interaction signals that
|
||||
anything's wrong, both sides just say the same string.
|
||||
*See: [tags.md](design-in-progress/tags.md#worked-example-the-same-handle-meaning-two-different-things)*
|
||||
|
||||
**Origin** (Implemented)
|
||||
The first component of a [classification handle](#classification-handle), naming where that tag,
|
||||
property, or category came from, e.g. `git:base` in `git:base#property:length`. Not necessarily a
|
||||
server or a domain, it can equally be a shared reference dataset (like the files in
|
||||
`backend/shared_data/`) or a server's own locally-invented taxonomy. Not the same thing as a
|
||||
[domain](#domain), see the note at the top of this page.
|
||||
*See: [federation.md](federation.md#tags-properties-and-categories)*
|
||||
|
||||
**Tag / Property / Category** (Implemented)
|
||||
The three kinds of classification entity an item can reference, collectively called a
|
||||
**classifier**, each identified by a [classification handle](#classification-handle). A property
|
||||
additionally carries unit metadata (`unit_symbol`/`unit_name`), though property *values* on an item
|
||||
are plain, undeclared-type strings today. A single [origin](#origin) (e.g. one
|
||||
`backend/shared_data/*.json` file) typically defines many classifiers at once.
|
||||
*See: [federation.md](federation.md#tags-properties-and-categories), [tags.md](design-in-progress/tags.md)*
|
||||
|
||||
## Items & Physical Labels
|
||||
|
||||
**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 - including a physical label meant for the app's own scanner,
|
||||
not a link or code meant to be opened by something with no idea what a Toolshed short id is.
|
||||
*See: [handles-and-shortids.md](handles-and-shortids.md#domain-qualified-short-id)*
|
||||
|
||||
**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** (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 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)*
|
||||
219
docs/handles-and-shortids.md
Normal file
219
docs/handles-and-shortids.md
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
# Handles and Short IDs
|
||||
|
||||
This is the syntax-level reference for two related but separate naming schemes. [federation.md](federation.md)'s
|
||||
"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 scheme for packing small integer id
|
||||
chains into a compact token.
|
||||
|
||||
## Handle syntax
|
||||
|
||||
### Reserved characters
|
||||
|
||||
A username ends up embedded, unescaped, in several composite formats beyond its own handle, so it
|
||||
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 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
|
||||
|
||||
A literal `#` can't appear unescaped in a URL path segment: per RFC 3986, `#` starts the URI's
|
||||
fragment component, so any URL-parsing client (a browser, a QR scanner, a link preview) treats
|
||||
everything from the first unescaped `#` onward as a fragment and never sends it to the server at
|
||||
all, before a request is even made, not merely a server-side quirk to work around. The usual fix is
|
||||
to percent-encode it (`%23`), but that's exactly the encode/decode step a self-contained item URL
|
||||
(a physical label, a link shared outside the app) is designed to avoid for anything that sits
|
||||
directly in a path segment (`@` needs no such treatment). Instead, whenever a handle containing a
|
||||
`#` (a group handle, or a tag/property/category
|
||||
handle) has to appear as a raw URL path segment, substitute `+` for `#` in that rendering only:
|
||||
`#groupname@domain` becomes `+groupname@domain` in a URL, and `origin#type:name` becomes
|
||||
`origin+type:name`. This is a URL-embedding convention, not a second handle format: the canonical
|
||||
handle, the one used in the API, in signed requests, in the database, and everywhere else a handle
|
||||
is written or displayed, is unchanged and is still written `#groupname@domain`. Reversing the
|
||||
substitution when parsing a path segment back into a handle is unambiguous only because `+` is
|
||||
otherwise forbidden in every field a handle is built from (see Reserved characters above); if a
|
||||
group name or tag name could itself contain a literal `+`, it would be indistinguishable from an
|
||||
escaped `#` once decoded.
|
||||
|
||||
### 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. 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
|
||||
|
||||
Every short id starts with a small, fixed-width **kind** tag saying which schema the rest of the
|
||||
bits should be read against, followed by exactly the integer fields that kind's schema calls for,
|
||||
in a fixed order. `kind` is a small, closed, slow-growing set, so it doesn't need to be
|
||||
self-delimiting the way the integer fields do: 2 bits directly name kinds 0-2, and the all-ones
|
||||
value (3) is an escape meaning "the real kind follows as the next field, offset by this direct
|
||||
range" - so kind 3 is encoded as escape + chunked-int `0`, kind 4 as escape + `1`, and so on. This
|
||||
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.
|
||||
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 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
|
||||
a `user@domain.tld` handle, which stays the form to use anywhere cross-domain resolution actually
|
||||
matters. Resolving a short id still goes through the same friend/signature checks as everything
|
||||
else, unchanged; nothing about how the code looks grants any authority of its own (see Guessability
|
||||
below).
|
||||
|
||||
### Packing one integer: dynamic bit depth
|
||||
|
||||
Each integer field is made self-delimiting with **continuation chunking** (UTF-8/LEB128-style):
|
||||
split the value into fixed-size chunks (4 data bits each, most-significant chunk first), each
|
||||
preceded by one continuation bit meaning "another chunk follows" (`1`) or "this is the last chunk"
|
||||
(`0`). A value like `42` (`0b101010`) needs two 4-bit chunks, costing 10 bits total (2 × (1
|
||||
continuation + 4 data)); `7` fits in one chunk, costing 5 bits. This was chosen over an
|
||||
Elias-gamma-style unary/delimiter scheme (encode the value's bit-length in unary, then that many
|
||||
literal bits): unary is cheaper for single-digit values but its prefix grows every time the value's
|
||||
bit-length grows, so it never wins once ids pass single digits, which is the common case here (auto
|
||||
increment database ids realistically sitting in the tens through low-hundred-thousands over an
|
||||
installation's life). A 4-bit chunk width is a reasonable fixed default across that whole range;
|
||||
per-field tuning was checked against both a uniform and a skewed (geometric) distribution and never
|
||||
won by more than a fraction of a character, not enough to justify a tuning knob.
|
||||
|
||||
### From bits to text: base64 without the byte layover
|
||||
|
||||
Standard base64 assumes byte-aligned (8-bit) input, grouping 3 bytes into 4 output characters and
|
||||
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: 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 `~`
|
||||
|
||||
Every token is prefixed with a literal `~`, so a short id in a URL looks like `~DyU`. Its only job
|
||||
is to mark "everything after me decodes as one of these": URL-safe base64 never produces a `~`
|
||||
itself, so the prefix can never be confused with the payload, and none of Toolshed's other
|
||||
path-segment formats (bare usernames, `user@domain` handles, slugs, plain numeric ids) start with
|
||||
`~` either. `~` is one of RFC 3986's `unreserved` characters (§2.3, the same class as letters,
|
||||
digits, `-`, `.`, and `_`), a stronger guarantee than merely being legal in a path segment: it's
|
||||
never a target for percent-encoding and never carries special meaning in any URI component, so a
|
||||
short id can be handed to any part of the stack without first checking which encoding rules apply
|
||||
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, including a physical label meant for the app's own scanner) - not a link or code
|
||||
meant to be opened by something with no idea what a Toolshed short id is. 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`:
|
||||
|
||||
- `kind`: 2 fixed bits → `00`
|
||||
- `owner_identity_id = 7`: fits in one 4-bit chunk → 5 bits (`00111`)
|
||||
- `item_local_id = 42`: needs two 4-bit chunks → 10 bits (`1001001010`)
|
||||
|
||||
Total: 17 meaningful bits, padded to the next multiple of 6 (18) with one zero bit, yielding 3
|
||||
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. 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` |
|
||||
| `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.
|
||||
276
docs/implementation.md
Normal file
276
docs/implementation.md
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
# Implementation Notes
|
||||
|
||||
Design rationale and non-obvious invariants that used to live as long inline comments, moved here so the code stays skimmable while the reasoning stays discoverable. Code comments reference these sections by anchor.
|
||||
|
||||
## Content-Addressed Files & Caching
|
||||
|
||||
### FileCache Design Rationale
|
||||
`fileCache` (`frontend/src/fileCache.js`) is a shared, session-lifetime cache for the bytes behind `AuthenticatedImage`'s `src` (a `/media/...` hash-addressed path; see `backend/files/media_urls.py`). Content there is immutable and hash-addressed (SHA-256), so a cache hit never needs revalidation — the same `src` string can only ever resolve to the same bytes, for any owner or requester.
|
||||
|
||||
It is deliberately NOT Vuex state: it holds `Blob`/object-URL data that nothing needs reactive access to (components only bind the resulting object-URL string, which they hold in their own local state), so a plain module-level `Map` avoids Vue's reactivity overhead entirely and sidesteps proxying `Blob` instances for no benefit.
|
||||
|
||||
### SHA-256 File Hashing Must Match The Backend
|
||||
Before uploading, the frontend computes a SHA-256 hash of a file's raw bytes via the Web Crypto API (`crypto.subtle.digest('SHA-256', ...)`). This hash must match the one the backend computes over the same bytes (`backend/files/models.py`, `hashlib.sha256`), so the hash computed client-side can later be used to identify the same `File` row server-side without a mismatch. This same computation appears in `CameraFileSource.vue`, `DragDropFileSource.vue`, `FsFileSource.vue`, and `WebcamFileSource.vue`.
|
||||
|
||||
### Stale Orphan Cleanup At The Canonical Hash Path
|
||||
`FileManager.create()` (`backend/files/models.py`) derives each file's upload path entirely from its content hash (`hash_upload`), and `hash` is unique in the database. That means if no `File` row currently owns a given hash, anything already sitting at that hash's computed storage path is necessarily a stale orphan — for example, left behind by a bug in cleanup code that deleted a `File` row without removing its stored bytes, or by a crashed upload.
|
||||
|
||||
Before saving a new `File` row, `create()` proactively deletes anything already at the expected path rather than letting Django's storage layer invent an alternate filename to avoid the apparent "collision". A suffixed name would silently break every part of the app that derives a file's URL purely from its hash (media serving, thumbnail generation, item/avatar attachment), and no future caller could ever discover the file again. `backend/files/tests.py`'s `test_file_upload_reclaims_stale_orphan_at_canonical_path` is a regression test for this exact scenario.
|
||||
|
||||
### RGBA Flattening Avoids Revealing Black Under Transparent Pixels
|
||||
When generating thumbnails (`backend/files/media_urls.py`, `thumbnail_urls`), the source image is first converted to RGBA and composited onto an opaque white background before being flattened to RGB and saved as JPEG. This matters because some source modes (grayscale+alpha, palette-with-transparency, RGBA) store meaningless color or luminance data underneath fully transparent pixels, which is often zeroed out (i.e. black). Converting straight to RGB without compositing reveals that zeroed data as solid black instead of showing nothing, producing a thumbnail that looks broken even though the visible, opaque content is fine.
|
||||
|
||||
`backend/files/tests.py`'s `test_thumbnail_flattens_transparency_instead_of_going_black` is a regression test built around an `'LA'` (grayscale + alpha) source image whose transparent half has zeroed-out luminance underneath, reproducing exactly this failure mode.
|
||||
|
||||
## Label Rendering Engine
|
||||
|
||||
### QR Encoder Loading
|
||||
`anyd-qr.js`'s own `loadAnyDCode()` memoizes the wasm instantiation itself, so calling it more than once (each of `Print.vue` and `LabelLayoutPreview.vue` does, on mount) is free — `label.js`'s module-level `anyd` variable just mirrors its resolved value so `buildRenderTree` can use it synchronously. Until it resolves, a QR-family leaf (any `QR_LEAF_TYPES` entry) throws (see `encodeQr`) the same way an oversized value already does — callers already have to handle `layoutContent` throwing, so this reuses that path rather than adding a second failure mode.
|
||||
|
||||
### QR Leaf Type Mapping
|
||||
`QR_LEAF_TYPES` maps each of `label-layouts.js`'s `LABEL_TEMPLATES` leaf types that draw a code to the `anyd-qr.js` symbology/error-correction level (and, for rMQR, size strategy) it renders as (see anyd's `EncodeOptions` — `ecc`/`size` — and its per-symbology `EcLevel` enums, `wasm.rs`'s `qr_ec`/`micro_ec`/`rmqr_ec`/`rmqr_size`). Which combination a given label uses is baked into its layout tree (see `label-layouts.js`'s `"qr"`-prefixed templates), rather than a single choice applied to every code leaf alike, so there's no longer a global selector for any of them (see `Print.vue`).
|
||||
|
||||
A plain symbology id (no suffix) always means anyd's own defaults — ecc `"M"`, rMQR size `"balanced"` — every other value gets a `"-<name>"` suffix naming it:
|
||||
- **ecc**: the same letter anyd itself uses (`qr_ec`/`micro_ec`'s L/M/Q/H), except micro-qr's `"Detection"` (`MicroEcLevel::Detection`, an M1-only error-*detection*-but-not-correction mode with no plain single-letter grade of its own). Coverage isn't uniform across symbologies (see `qr_ec`/`micro_ec`/`rmqr_ec`) — full QR takes all four grades, Micro QR swaps `"L"` (QR's actual lowest) for `"Detection"` (lower still, but M1-only) and has no `"H"` at all, and rMQR only ever supports `"M"` or `"H"`.
|
||||
- **size** (rMQR only, see `rmqr_size`/`SizeStrategy`): `"min"`/`"max"` prefer the shortest (flattest, widest) or tallest (narrowest) symbol that fits the text, over the default `"balanced"` (smallest total module area) — which shape to prefer depends on which of the tape's two axes (across vs. along the feed) is more constrained.
|
||||
|
||||
rMQR's matrix isn't square (see `encodeQr`'s width/height), unlike qr/micro-qr, which always are.
|
||||
|
||||
### QR Module Matrix Shim
|
||||
`encodeQr` wraps anyd's row-major `Uint8Array` matrix in a BitMatrix-alike shim (`{width, height, get(row, col)}`), matching the shape `drawQrLeaf`/`snapQrToCrispSize` expect — they predate this and were written against the old `"qrcode"` package's own `modules.size`/`get()` shape. anyd's matrix already excludes the quiet zone from width/height (see its `ModuleMatrix` type), same as the old library's BitMatrix. width/height are kept separate rather than a single `size` (the old library's own shape, always square) since rMQR symbols are rectangular.
|
||||
|
||||
### Layout Tree Structure
|
||||
A layout is a tree built from two shapes, alternating orientation by nesting depth:
|
||||
|
||||
- An array is a "split" node: its children sit side by side (a *row*) at even depth (the root, depth 0, is always a row), or stacked (a *column*) at odd depth. To turn a row into a column, wrap it in an extra one-element array — that array is one depth deeper, so its lone child (the original row) is now read at odd depth.
|
||||
- An object is a leaf: `{type, content}` where `type` is one of `QR_LEAF_TYPES`' keys draws a QR/Micro QR/rMQR code at that id's symbology/error-correction level (see QR Leaf Type Mapping above), `{type: "text", content}` draws a text block — either way `content` is a function from the resolved field values to the string (or, for `"text"`, an array of strings — one per line) to render. `{type: "empty", "min-width": "2mm"}` / `{type: "empty", "min-height": "2mm"}` is a spacer with no ink of its own — the *only* way padding/gaps enter a layout, since nothing here draws a border, margin or gap on its own. An "empty" leaf's dimension always names the axis its enclosing split flows along: `"min-width"` inside a row, `"min-height"` inside a column.
|
||||
|
||||
See `label-layouts.js`'s `LABEL_TEMPLATES` for concrete trees.
|
||||
|
||||
### Pixel Font Selection
|
||||
Below 33px, a general-purpose sans-serif gets blurry/illegible, so `drawTextLeaf` switches to one of `fontTierFor`'s bitmap-style fonts instead (see `../assets/fonts/pixel/LICENSE.md`) — Silkscreen/Chava/Pixelon/Terminus across increasingly larger size bands, snapping to whichever fixed/hinted size each reads best at rather than whatever `fontPx` literally asks for. Unlike the top (Inter) tier, these are rendered through `pixel-font.js`'s `drawPixelText` rather than canvas `fillText` — see Freetype Production Rendering below for why. At 33px and up, `fontTierFor` names Inter explicitly (see `../scss/_label-fonts.scss`) rather than falling back to the CSS generic `sans-serif` keyword — that keyword resolves to a different real font per browser/OS, which would make the same label print differently depending on where it was rendered from.
|
||||
|
||||
Every tier was chosen only after rendering through `prototypes/freetype-wasm/ladder.html` — FreeType's own monochrome rasterizer at real canvas pixel sizes — and inspecting the actual pixels; checking that `fillText` was merely *called* doesn't confirm anything legible got drawn, and neither does a DPI-adjusted size that was never the number actually handed to the rasterizer. Two earlier candidates were tried and dropped: Tom Thumb's declared ascent/descent (0 / ~fontPx, backwards from a normal font) turned out not to be a centering quirk — its actual visible ink was only ~1/3.2 of its own nominal font-size — and PICO-8 rendered cleanly but has no lowercase glyphs at all (silently draws lowercase input as uppercase), ruling it out for real label content (item handles, URLs) that isn't reliably all-caps; see `frontend/src/assets/fonts/pixel/LICENSE.md`. Chava carries the same uppercase-only limitation but was kept anyway, restricted to a narrow size band, since PICO-8's dealbreaker didn't get applied consistently — see that same LICENSE.md.
|
||||
|
||||
Each tier's `size` (the actual px handed to the rasterizer) replaces the old Tom Thumb-only `scale` correction, generalized: `fontPx` (the layout-derived logical size, compared against each branch's threshold) still drives box centering/stacking regardless of what `size` a tier resolves to. Those thresholds and `MIN_TEXT_PX` are both compared against that same logical, unscaled `fontPx`, deliberately not adjusted for the tape's dpi: a browser's font rasterizer only ever sees a raw pixel count, with no notion of "physical size" at all, so that's what determines whether a glyph's fine detail survives — confirmed by real-Chromium testing, where a raw 4.35px render was a solid blob regardless of what a dpi-scaled version of that number would have implied.
|
||||
|
||||
A tier's optional `tracking` retunes glyph spacing (`drawPixelText` adds it to every glyph's advance) without ever touching the rendered glyph size.
|
||||
|
||||
### Freetype Production Rendering
|
||||
Canvas 2D's `fillText` is always anti-aliased — there is no monochrome/hinted-bitmap text mode in the API, however carefully a glyph's size/position are snapped to the pixel grid — and `weblabel.js`'s `bitmap.js` hard-thresholds the whole canvas at 50% luminance before it ever reaches a printer (a pin either fires or it doesn't). Anti-aliased glyph edges surviving into that threshold read as stray or missing pixels, not a clean bitmap-font shape. `pixel-font.js`'s `drawPixelText` sidesteps this for every `fontTierFor` tier below Inter by rendering through the same FreeType-wasm rasterizer `ladder.html` uses to *pick* those fonts in the first place (`FT_LOAD_TARGET_MONO`, true 1-bit output) and blitting the result at integer pixel offsets — `measureLine`'s pass computes a line's real ink extent (advance width, max ascent/descent actually reached by *these* glyphs) the same way `drawTextLeaf`'s old `ctx.measureText(...).actualBoundingBox*` did, so centering math is unchanged; only where the pixels themselves come from is different. Inter (the one non-`pixel` tier) still uses `fillText` — anti-aliasing is expected/fine at that size, the same reasoning `weblabel.js`'s own text renderer relies on ("what stops small text turning to mush is not the threshold but the size").
|
||||
|
||||
`preloadPixelFontRenderer()` (re-exported from `label.js`, defined in `pixel-font.js`) loads `freetype.wasm` and fetches every bitmap tier's font bytes up front, the same pattern as `preloadQrEncoder`/`anyd` (see QR Encoder Loading) — `drawTree`/`drawTextLeaf` stay fully synchronous, so both `Print.vue` and `LabelLayoutPreview.vue` await it (alongside `preloadQrEncoder`) before their first `redraw()`, and `drawPixelText` throws the same "still loading" shape of error `encodeQr` does if a draw is ever attempted before it resolves. The wasm module holds exactly one font face loaded at a time (`ft_load_font` replaces whatever was there); `ensureFace` skips the reload when consecutive leaves already share a family.
|
||||
|
||||
### Affine Width Height Relations
|
||||
Every node's width/height relate to each other affinely — `width = A*height + B` for a node read in row context, `height = A*width + B` in column context — because a leaf is either scale-free (a text block, whose aspect ratio holds at any size: `A = aspect` or `1/aspect`, `B = 0`) or a fixed physical size (an "empty" spacer, or a QR code once its crisp pixel size is known — see Crisp QR Sizing: `A = 0`, `B` = the size in px). Splits combine their children's relations by addition (a row's total width is the sum of each child's width for the shared height, and symmetrically for a column), which stays affine, so the same two numbers describe a whole subtree no matter how deeply it nests.
|
||||
|
||||
`ownAxis` is true if the split directly containing `node` is a row, false if a column — for a leaf, that's what "empty" measures itself against; for a split, its own axis (and thus how it combines its children) is always the opposite, per the alternating-depth rule. `wantWidth` is true to ask for `{A, B}` such that `width = A*height + B`, false for `height = A*width + B`; requesting the direction a split doesn't naturally combine in just inverts its own relation.
|
||||
|
||||
### Fixed Size Relation Edge Case
|
||||
In `relation()`, when every child is a fixed size (`a === 0`) in the combining direction — e.g. a row that's just one crisp QR leaf, with no scale-free (text) sibling to invert against — inverting "width = b" for an `a` of 0 would divide by zero: a constant width genuinely doesn't determine a height, since nothing here actually scales with it. Instead, ask each child directly for its own size in the wanted direction (every one of them must be similarly fixed, since only a fixed leaf ever contributes `a === 0`), and take the largest — the shared dimension has to fit whichever child needs the most room, with any child that ends up with room to spare centered within it (see `drawQrLeaf`).
|
||||
|
||||
### Crisp Qr Sizing
|
||||
A QR code needs an integer number of pixels per module to render crisply rather than blurring at a fractional scale, so its true size is whatever that rounds down to — almost never the scale-free box its aspect ratio alone would suggest. `snapQrToCrispSize`, called once every QR-family leaf has a provisional (scale-free) box from a first `layoutTree` pass, pins each one's real `box.width`/`box.height` as `crispWidth`/`crispHeight`, so `relation()` starts treating it as a fixed size, the same as an "empty" leaf, instead of one that scales with whatever height/width it's offered. A second `relation()`/`layoutTree()` pass (see Label Content Layout) then resizes everything else around that real footprint, so nothing downstream reserves — and leaves unfilled — room for a squarer/differently-shaped code than what actually gets drawn. Kept as two independent dimensions rather than one `crispSize` (as when every code here was a square QR) since an rMQR symbol isn't square — see `encodeQr`.
|
||||
|
||||
### Render Tree Construction
|
||||
`buildRenderTree` turns a resolved content tree (see `templateContent` — leaf objects carry a `value` rather than a `content` function) into one ready for layout: a QR-family leaf gets its actual encoded modules (see `encodeQr`, keyed off the leaf's own type via `QR_LEAF_TYPES`) and an aspect ratio taken from their real width/height — 1 (square) for qr/micro-qr, but not for rmqr, whose symbols are rectangular — a text leaf gets its measured natural aspect ratio, and an "empty" leaf passes through untouched. Multi-line text (`value` is an array) measures as one leaf, not one per line — splitting it into a column of independently-sized leaves would let each line grow to its own full width, ending up at a different font size than its neighbors, which is legible but not what "one text field" should look like.
|
||||
|
||||
### Canvas Font Loading
|
||||
Applies only to Inter, the one `fontTierFor` tier still drawn with `fillText` (see Freetype Production Rendering above — every other tier goes through `pixel-font.js` instead, which fetches its own font bytes directly and has no `@font-face`/`document.fonts` involvement at all). A `@font-face` family already in use elsewhere on the page loads in time for `drawTextLeaf`'s use, but canvas text silently falls back to the next font in the stack (there isn't one here, so the browser default) if drawn before its first-ever load finishes — unlike DOM text, a canvas `fillText` never waits or repaints on its own once the real font arrives. Kicking off `document.fonts.load()` there means only that very first draw at a given size risks the fallback; every redraw after it (`Print.vue`'s live preview redraws on every keystroke) picks up the real font.
|
||||
|
||||
### Label Content Layout
|
||||
`layoutContent` builds, sizes and validates the tree for a fixed `fixedSize` (the tape's cross-web `printAreaPx`, or the fallback preview's reference height) — the one dimension every layout scales from, plus `pxPerMm` to turn "empty" leaves' physical sizes into pixels. `fixedSize` and the tree's content fully determine its overall size along the other, growing axis (the one that runs along the tape as it feeds); `maxLength`, when finite (a fixed-length/die-cut tape), rejects content that doesn't fit rather than shrinking it.
|
||||
|
||||
`orientation` picks which axis `fixedSize` binds to: `"along"` (the default) fixes the tree's height — the tape's cross-web width — and grows its width along the feed direction, same as a plain read top-to-bottom design. `"across"` fixes the tree's width instead and grows its height, so the design is built turned 90deg from how it'd read `"along"` — `drawLabel`/`drawFallbackLabel` are what actually rotate the drawing back into the physical raster's fixed orientation; nothing here needs to know about that rotation, since `relation()`/`layoutTree()` already solve the tree in either direction symmetrically.
|
||||
|
||||
Sizing runs twice: a first pass treats every QR-family leaf as the scale-free box its real width/height ratio suggests, purely to find out how much room each one would actually be offered; from that, `snapQrToCrispSize` pins each one's real (smaller, crisp-pixel) size. The second pass then resolves the whole tree again with that real size fixed in, so every sibling and the overall size reflect what's actually drawn rather than the idealized box no code ever quite fills.
|
||||
|
||||
### Tape Fed Label Drawing
|
||||
`drawLabel` is the tape-fed layout — it draws a fully resolved content tree (see `templateContent`) at the tape's real pixel dimensions. `orientation` is `"along"` (the default) to lay the design out reading along the tape's feed direction, or `"across"` to turn it 90deg so it reads across the tape instead — either way the physical raster this returns is still exactly `printedLength x tape.printAreaPx` (that's fixed by the tape/print head, not a choice this makes); `"across"` just draws the (now width-fixed, see Label Content Layout) tree through a rotated canvas transform so it lands correctly in that same raster, rather than transposing every box the tree itself computed. See `DEBUG_LEAF_BORDERS` to outline every leaf's box.
|
||||
|
||||
### Across Orientation Rotation
|
||||
When `drawLabel` draws with `orientation === "across"`, the tree was solved width-fixed (see Label Content Layout) — its width already exactly fills `tape.printAreaPx`, so only its (growing) height needs the same along-the-feed centering `origin` got earlier; `translate`+`rotate` then carries that tree-local `(x, y)` box straight into the physical (`printedLength x printAreaPx`) raster, a quarter turn at a time.
|
||||
|
||||
### Fallback Label Preview
|
||||
`drawFallbackLabel` is the no-webusb preview/PNG — same layout tree and renderer as `drawLabel`, just scaled from a fixed reference height instead of a real tape's, and with no `maxLength` (there's no physical tape to run out of, so the canvas just grows to fit) and no printer feed margin, since there's no real print head here to keep clear of.
|
||||
|
||||
## Label Templates, Preview & Pixel Fonts
|
||||
|
||||
### Template Layout Tree
|
||||
A template's `layout` is a tree as described in label.js, with leaves whose `type` is one of label.js's `QR_LEAF_TYPES` keys or `"text"`, and whose `content` is a function from the resolved field values (see label.js's `buildLabelFields`) to what they render. `null`/`undefined` returned from that function means the field isn't available yet (see `templateIsAvailable`). A template is only selectable once every leaf's `content` resolves to a value.
|
||||
|
||||
### Generated QR-Only Template Matrix
|
||||
`QR_ONLY_TEMPLATES` is the full "just the code" matrix: every `{symbology, error-correction level, [rMQR] size strategy}` combination label.js's `QR_LEAF_TYPES` supports, one template each. A plain `id` (no suffix) is always anyd's own defaults: ecc `"M"` and, for rMQR, size `"balanced"`. Coverage isn't uniform (see `QR_LEAF_TYPES`): full QR gets all four ecc grades L/M/Q/H; Micro QR swaps `"L"` (QR's actual lowest) for the even-lower, M1-only, detection-only `"Detection"`, and has no `"H"` at all; rMQR only ever supports ecc `"M"` or `"H"`, each crossed with all three size strategies (balanced/min/max). The matrix is generated (rather than hand-writing every near-duplicate entry) so that a symbology/level/size combination it's missing is one new row here, not a new block to keep in sync with its neighbors. `id` doubles as the layout's leaf `type`, since that's exactly what `QR_LEAF_TYPES` is keyed by.
|
||||
|
||||
### Content Kinds Registry
|
||||
`CONTENT_KINDS`/`CONTENT_KINDS_BY_ID` (`label-layouts.js`) is the single registry for everything that varies per content kind (currently `item`/`storage-location`): the handles-and-shortids.md kind letter (`typedPrefix`), the short-id.js schema name to use for an individually- vs. group-owned thing of that kind (`shortIdKind`/`groupShortIdKind`), the field name short-id.js's `serializeShortId` expects for its local id (`localIdField`), and a builder for the kind's long-form URL (`buildUrl`, `undefined` for a future kind added with no such route - see the `url` `DERIVED_VARS` entry). Every place that used to special-case "item vs. storage location" - `label.js`'s prefill builders, `label-layouts.js`'s `DERIVED_VARS`, Print.vue's `shortId()` - reads this one table instead, keyed by the Content card's `kind` field (a `<select>`, see Print.vue) rather than inferring which kind is meant from which id-shaped field happens to be populated.
|
||||
|
||||
### Derived Vars Shape And Ordering
|
||||
A derived var (`DERIVED_VARS`) is a format string calculated from other vars rather than typed directly - it doesn't get its own input, just a read-only, live-recalculated display next to the ones that do (see Print.vue and `withDerivedVars`). Its `inputs` name every var (base or, in principle, another derived one - e.g. `qualifiedHandle`/`url`, which both read the derived `userHandle`, and `qualifiedHandle` also reads the derived `typedPrefix`) that `calc` reads. `inputs` is declared up front rather than inferred from `calc`'s body so `BASE_VARS` can include a var like `"webdomain"` that only feeds a calculation and that no template ever references directly. Declaration order matters here: `withDerivedVars` runs these in a single pass, so a derived var must be declared after every other derived var it depends on - `typedPrefix` before `qualifiedHandle`, in particular.
|
||||
|
||||
### Kind And Id Known Vars
|
||||
`BASE_VARS` is computed purely from `KNOWN_VARS` (every template's `required_vars`, see `templateIsAvailable`) plus whatever `DERIVED_VARS` reads - there's no separate registry of "fields a prefill might supply". `kind` and `id` reach `BASE_VARS` (and so the Content card's form, and `fields()`) the same way any other var does: named directly in several templates' `required_vars`, and `kind` also as an input of the derived `typedPrefix`. A content var meant to be prefillable/typeable needs that same anchor - at least one template naming it in `required_vars`, or some `DERIVED_VARS` entry reading it - or `fields()` (which only copies `BASE_VARS` keys out of `varValues`) silently drops it.
|
||||
|
||||
## Print View
|
||||
|
||||
### Tape-Full Print Margin
|
||||
The tape-fed preview draws two representations of the tape: `.tape-full` (in the template, wrapping `.label-preview`/the canvas) spans the tape's entire physical width (`tape.mediaWidthMm`), while the canvas inside it is only `printAreaPx` wide. A label printer's print head can't mark all the way to a tape's outer edges, so the true printable area is narrower than the tape itself, by an amount that isn't a fixed or predictable fraction of the tape width. `.tape-full` centers the canvas within itself (equal margin on both sides) and applies a faint background tint to that margin, so it reads as real, if unprintable, tape rather than empty page space. Because of this gap, the vertical ruler (`verticalRulerTicks`) is deliberately built from `tape.mediaWidthMm`, not from `printAreaPx`/`dpi` — ticking off only the printable width would run past the edge of the visible tape. The ruler's container is sized from `mediaWidthMm` too (see the template's inline `height`), so ticks can never run past what's actually drawn.
|
||||
|
||||
### Calculated Short Link Fields
|
||||
`SHORT_ID_VAR` ("shortId"), `DOMAIN_SHORT_ID_VAR` ("domainShortId") and `SHORT_URL_VAR` ("shortUrl") are Print.vue-local additions on top of label-layouts.js's own `DERIVED_VARS`. Resolving any of them needs the current `identityIdByHandle` map (populated by store.js's `fetchIdMap`) to turn a handle into the numeric `owner_identity_id` that short-id.js encodes — that dependency on Vuex state means none can be a pure fields-to-value calculation like the rest of `DERIVED_VARS`, so all three are computed here instead (see the `shortId` method and the `fields` computed). They're excluded from `baseVars`, unlike every other entry `KNOWN_VARS` normally picks up from a template's `required_vars`, because none is ever typed directly into the form - `SHORT_URL_VAR`/`DOMAIN_SHORT_ID_VAR` are filtered out again in the `baseVars`/`derivedVars` computed even though some template's `required_vars` puts them in `BASE_VARS`, since neither is actually a label-layouts.js `DERIVED_VARS` entry.
|
||||
|
||||
These target three scopes, by how much context a reader needs before the value is useful at all: `SHORT_ID_VAR` is the bare short-id.js token (e.g. "~AbCd12") with no domain or leading "/" - **Home-Instance** scope, only meaningful to a reader already logged in as the identity it was minted for (printed by "internal"-tagged templates); `DOMAIN_SHORT_ID_VAR` prepends the printing user's own home domain (`homeDomain + ":" + shortId`, e.g. "a.example.com:~AbCd12") with still no URL wrapper - **Any-Instance** scope, resolvable by any Toolshed frontend's own scanner/resolver via a cross-domain lookup (short-id.js's `isDomainQualifiedShortId`/`decodeDomainQualifiedShortId`, see Domain-Qualified Short ID Resolution below) regardless of who's logged in there; `SHORT_URL_VAR` wraps that same bare `shortId` in `webdomain` as a clickable URL. `webdomain` only picks which frontend loads - it says nothing about which backend the data lives on, since that's decided entirely by whoever ends up logged in once it does - so `SHORT_URL_VAR` shares `SHORT_ID_VAR`'s Home-Instance trust requirement (same identity, just a tap instead of typed/scanned into an already-open session), not a step up to a universally-openable link. The actual zero-context scope is served elsewhere, by the long-form Item/Location URL (`url`, see Content Kinds Registry above), which resolves by handle rather than by the opener's own idmap.
|
||||
|
||||
### Libweblabel Served Unbundled
|
||||
`libweblabel.js` is served verbatim from `public/vendor/` rather than bundled by Vite. Its own emscripten glue resolves its `.wasm` sibling relative to *its own* `import.meta.url` at runtime, so both files need to keep sitting together, unhashed, at a stable URL rather than a Vite-fingerprinted asset path.
|
||||
|
||||
### Concurrent Wasm Loading
|
||||
`preloadQrEncoder()` and `preloadPixelFontRenderer()` (see Freetype Production Rendering) are both kicked off in `mounted()` without an immediate `await`, so they load concurrently with each other and with `MultiPrinterBlob.load()` instead of serializing three independent wasm fetches. Every `redraw()`/`redrawFallback()` call still waits on both (via `fontsReady`, itself `Promise.all([qrReady, preloadPixelFontRenderer()])`) before drawing, since a qr/mqr/rmqr leaf throws (see label.js's `encodeQr`) until the encoder has finished loading, and a bitmap-tier text leaf throws the same way (see `drawPixelText`) until the font renderer has.
|
||||
|
||||
### Ruler Tier Selection
|
||||
`RULER_TIERS` controls how far apart plain ticks and labeled/major ticks sit, both getting coarser the longer the ruler runs — tightly spaced ticks (and their labels) get too cramped to read or render once there are enough of them. The list is ordered smallest threshold first; `rulerTier` picks the last entry whose `aboveMm` the ruler's own length clears, so a finer or coarser tier is added there rather than growing a pile of separate constants. Every tier's `majorEveryMm` is a multiple of its own `tickMm`, so major ticks always land on a tick that's actually drawn. `rulerTier` itself is a single shared value keyed off whichever axis (`horizontalTotalMm`/`verticalTotalMm`) is physically longer, so a long label's ruler never ends up coarser or finer than the tape-width ruler right next to it just because the other axis happens to be shorter — both rulers coarsen together once *either* axis needs it.
|
||||
|
||||
### Fields Computed: Dropping Blank Values
|
||||
The `fields` computed builds the named content fields templates draw from: the form's own base vars, plus every `DERIVED_VARS` format string calculated live from those, so typing a `userHandle`/`kind`/`id` (by hand or via prefill) recalculates `url`/`qualifiedHandle` the same way either way. A blank or uncalculated value is dropped entirely rather than passed through as an empty string, so it reads as *absent* to `templateIsAvailable`/`templateContent` the same way a prefill that never supplied it would — that's what LabelLayoutPreview.vue greys a template's thumbnail out on.
|
||||
|
||||
### ShortId Resolution
|
||||
The `shortId` method builds the same shortened link Inventory.vue's and StorageLocation.vue's own `shortIdLink` build for one of their rows, but returns the bare token (see short-id.js's `encodeShortId`) rather than a router target or full URL — `shortenedRoute`'s leading "/" is stripped since this is plain display/label content, not something this view itself navigates to. Which short-id.js kind applies depends directly on the Content card's `kind` field (via `CONTENT_KINDS_BY_ID`, see Content Kinds Registry above), not on which id-shaped field happens to be populated — so hand-typing a `userHandle`+`kind`+`id` with no prefill at all resolves exactly the same way a prefilled print link does. It falls back to no value, the same as an unresolved `DERIVED_VARS` entry, until `identityIdByHandle` has loaded (see `mounted`'s `fetchIdMap`) or if the handle isn't in it.
|
||||
|
||||
`DOMAIN_SHORT_ID_VAR` reattaches the `homeDomain` computed specifically — the printing user's own home domain (`state.user`'s domain half), not `derived.domain` (the printed thing's own owner handle domain, which for a group-owned print is the group's domain, not necessarily the printer's). This matters because `owner_identity_id`/`owner_group_id` are resolved via `identityIdByHandle`/`groupIdByHandle`, both sourced from `state.idmap`, which always loads from the printing user's own home server (`getHomeServers`) regardless of whose item is being printed — so the domain a Domain-Qualified Short ID needs to carry is always the printer's home domain, the one whose KnownIdentity/Group numbering the encoded ints are actually scoped to (see docs/handles-and-shortids.md#domain-qualified-short-id). Getting this wrong (e.g. using `derived.domain` for a group print) would silently mint a token that resolves to the wrong backend, or to nothing, once scanned by someone else.
|
||||
|
||||
### Domain-Qualified Short ID Resolution
|
||||
A Domain-Qualified Short ID (`<domain>:~<token>`) resolving to something other than "the app I'm currently in" needs a real network round-trip, since the token's ints are only meaningful to whichever backend minted them (see docs/handles-and-shortids.md#domain-qualified-short-id). `router.js`'s `domainQualifiedRoute(domain, ints)` is the single chokepoint every entry surface (the `/:short_id` route's `beforeEnter` guard, `ShortId.vue`'s `resolveIfQualified`, `Scan.vue`'s `describeForeignShortId`) funnels through: if `domain` matches the caller's own home domain (`store.state.user`'s domain, read via `isLoggedIn` first to force its lazy hydration — see `getHomeServers`'s own comment on the same ordering issue) it resolves locally via the existing idmap-based `expandedRoute`, exactly like a bare token; otherwise it dispatches `resolveShortId` (`store.js`), which SRV-discovers `domain`'s backend (`getFriendServers`) and calls its new `GET /api/v1/resolve_short_id/<kind>/<owner_id>/<local_id>/` (`backend/toolshed/api/inventory.py`). That endpoint answers "what does this owner/local id pair mean" against its own KnownIdentity/Group numbering, applying the exact same friend-or-self/membership and `availability_policy` checks `InventoryItemViewSet.get_queryset()` already does (see Owner-Handle Scoped Routes below) — it only differs in being keyed by numeric owner id instead of a handle string, since the caller doesn't have a handle yet, that's exactly what it returns (`{handle, id}`). Only the four owned short-id kinds (`item`/`group_item`/`storage_location`/`group_storage_location`) are wired up; `group`/`category`/`file`/`workflow` return 400 and the frontend falls through to `ShortId.vue`'s debug view.
|
||||
|
||||
### Fit-Zoom Preview Scaling
|
||||
`fitZoom` fits the preview to its card without ever needing a horizontal scrollbar for a label this small: it magnifies short labels up to `MAX_ZOOM` rather than showing them at native (tiny) size, and never lets the preview grow past `MAX_PREVIEW_HEIGHT_PX` tall however long or wide the label itself runs. It returns the zoom actually used, so callers that care (`redraw`'s ruler bookkeeping) don't have to re-derive it. When magnifying, the zoom is rounded *down* to a whole number rather than rounded or ceiled: `image-rendering: pixelated` only looks crisp when every source pixel maps to the same number of screen pixels, and at a fractional zoom (the common case, since the raw zoom is just whatever ratio the tape/card happen to produce) some source pixels get rounded up to one extra screen pixel and others don't, unevenly warping fine, already-pixel-perfect detail like a crisp QR module or a tiny bitmap font glyph. Flooring keeps the same "never bigger than available space" guarantee the raw zoom already had. Shrinking (zoom < 1) has no equivalent whole-factor snap to make, since downsampling always blends source pixels, so it's left as-is.
|
||||
|
||||
### Mm Ruler Layout
|
||||
The tape-fed preview's mm ruler is a horizontal track above the canvas and a vertical one to its left, both ticked in real physical millimeters rather than preview pixels, since what they're measuring is the actual label.
|
||||
|
||||
### Preview Track No-Scroll Invariant
|
||||
`.preview-track` holds the horizontal ruler and the canvas, and is deliberately never scrollable (no `overflow-x: auto`): `fitZoom`'s zoom always satisfies `canvas.width * zoom <= available`, so the canvas can never actually be wider than this has room for, and a scrollbar here would let the ruler and canvas drift apart (or just look broken) for no reason. `min-width: 0` only lets this flex item shrink to the card's real available width — it doesn't enable scrolling.
|
||||
|
||||
### Label-Preview Override In Preview Track
|
||||
`.preview-track .label-preview` overrides the standalone `.label-preview` rule: nested here, it must neither scroll nor center its canvas. `overflow-x: visible` (never `auto`) rules out a second, inner scrollbar, and `text-align: left` keeps the canvas flush with the ruler's zero tick instead of drifting to the middle of whatever spare width the card has. `padding: 0` makes the canvas's own edges exactly this box's edges too, so the ruler's ticks line up with those same edges — any padding here would leave the ticks and the actual canvas misaligned.
|
||||
|
||||
## Routing, Handles & Short IDs
|
||||
|
||||
### Escaping Hash in Handles for URL Path Segments
|
||||
`router.js`'s `encodeHandleForUrl`/`decodeHandleFromUrl` embed/extract a handle in a URL path segment. `#` starts a URI's fragment component, so a group handle (`#name@domain`) or classification handle (`origin#type:name`) can't appear unescaped in a path segment. `+` stands in for `#` there instead of the usual `%23` - safe to reverse unambiguously because every field a handle is built from is already required to exclude `+` (see docs/federation.md's "Reserved characters" and its "Embedding a `#`-bearing handle in a URL" section). The canonical handle itself never changes; this only affects how one gets embedded in, or read back out of, a URL path segment.
|
||||
|
||||
### Owner-Filtered Overview Routes Use Path Segments, Not Query Strings
|
||||
`/inventory/:owner?` and `/storage-location/:owner?` (`Inventory.vue`/`StorageLocation.vue`'s `owner` prop) filter the overview to one owner - the caller, a group, or a friend - using the same handle shape/escaping as `/groups/:handle` (see Escaping Hash in Handles above). `/inventory/new/:group?` and `/storage-locations/new/:group?` (`InventoryNew.vue`/`StorageLocationNew.vue`'s `group` prop) prefill the owner picker the same way when arriving from a filtered overview. All four are optional trailing path params (`:name?`), the same style as `/workflows/:id/:phase?` - deliberately not a query string: vue-router's query parser treats a bare `+` as an encoded space (the x-www-form-urlencoded convention), so the `#`->`+` swap that works fine in a path segment would silently corrupt a group handle passed as `?owner=...` instead.
|
||||
|
||||
These coexist safely with their more specific neighbors (`/inventory/new/:group?` next to `/inventory/:owner?` and `/inventory/:handle/:id`; `/storage-location/:owner?` next to the plural `/storage-locations/...` routes) because vue-router's matcher always ranks a route's more literal/static segments above a dynamic one at the same depth, regardless of declaration order - e.g. `/inventory/new` resolves to the static `new` segment, never `:owner` on `/inventory/:owner?`.
|
||||
|
||||
`ownerOverviewRoute`/`ownerLocationOverviewRoute` (`router.js`) only ever pick between the caller and their own group, never a friend, since a friend's items/locations are read-only (see Perform Update Rejects Non-Owned Items Explicitly below).
|
||||
|
||||
### Item Short-Link Redirect Route
|
||||
`/i/:handle/:id` and `/s/:handle/:id` are the self-contained label/short-link entry points for an item and a storage location respectively (see `label.js`'s `buildLabelContent`/`CONTENT_KINDS_BY_ID`, whose `buildUrl` for each `CONTENT_KINDS` entry builds exactly this shape, and docs/design-in-progress/items-labels.md). `:handle` is already URL-escaped the same way `/inventory/:handle/:id`/`/storage-locations/:handle/:id` expect it, so each is just a shorter alias for its own detail route, with no owner-is-the-viewer special case: `InventoryItemViewSet.get_queryset()`/`StorageLocationViewSet.get_queryset()` (see Owner-Handle Scoped Routes below) already treat "it's the viewer's own item/location" as one case of "the viewer may see this owner's item/location," not a separate path.
|
||||
|
||||
### `beforeEnter` Guard vs `redirect` for `/:short_id`
|
||||
This route uses a `beforeEnter` guard, not `redirect`: `redirect` is called synchronously and its return value is used as-is (never awaited), and it also *must* resolve to a valid location on every match (an unresolvable one throws, see vue-router's `handleRedirectRecord`) - it can't itself wait on `fetchIdMap` (see `NEEDS_IDMAP`) for the `item`/`group_item` kinds whose owner handle isn't resolvable from the token alone. A guard can return `null`/undefined to mean "proceed to the component instead," which is exactly what's needed here: when `expandedRoute` can't resolve yet (or ever - an unrecognized kind), stay on this same URL and mount `ShortId.vue` in place, which has full component-lifecycle async support and takes it from there - fetch idmap, retry, redirect once resolved, or keep showing the decode view. Vue Router guards support returning a Promise, not just a value, which is what makes this the right chokepoint for a Domain-Qualified Short ID too (see Domain-Qualified Short ID Resolution above): the guard is `async`, and for a foreign domain it `await`s `domainQualifiedRoute` (a real network round-trip) before deciding whether to redirect or fall through to `ShortId.vue`, the exact same "resolve or fall through" shape as the synchronous idmap case.
|
||||
|
||||
## Federation, Identity, Friends & Groups
|
||||
|
||||
### Group Invite And Accept Self-Certifying Verification
|
||||
`verify_incoming_group_invite(request, raw_request_body, handle_field, key_field)` is a self-certifying verifier for the two legs of the group invite/accept dance that land on a backend which doesn't have the caller cached as a `KnownIdentity` yet (see `docs/design-in-progress/groups-mvp.md`): the inviter delivering an invite to the invitee's own backend (`handle_field='inviter'`, `key_field='inviter_key'`), and the invitee accepting on the group's home backend (`handle_field='invitee'`, `key_field='invitee_key'`). It mirrors `verify_incoming_friend_request` exactly, just with configurable field names so the same logic serves both legs.
|
||||
|
||||
### Group Membership Is Recorded On Both Sides, Like Friendship
|
||||
Accepting a group invite used to only ever write membership on the group's own home backend (`Group.members`, via `acceptGroupInvite`); the invitee's own home backend just deleted its now-obsolete `GroupInviteIncoming` and forgot the invite ever happened. That meant a member's own backend had no way to answer "which groups is this identity in" for a group hosted elsewhere. `recordGroupMembership` (`POST /api/v1/groupinvites/<pk>/accept/`, local-auth) closes that gap: it's the second leg of the accept dance, called by the client right after the group-home-backend accept succeeds, and it converts the `GroupInviteIncoming` into a durable `GroupMembership` pointer (just `user`, `group_name`, `group_domain` — no roster, since this backend isn't authoritative for the group) instead of discarding it. `GET /api/v1/groupmemberships/` (also local-auth) lists a user's own pointers; `Groups.vue`'s "Other groups you're a member of" section is this list, filtered against the locally-hosted `groups` to avoid double-listing. This is deliberately the same shape as the friend-accept flow's second POST (see below): each side's own home backend independently records the relationship in its own local table, so neither side depends on the other's backend being reachable later to know it happened.
|
||||
|
||||
### Owner-Handle Scoped Routes
|
||||
`InventoryItemViewSet`/`StorageLocationViewSet` (`toolshed/api/inventory.py`) address every route — list/create/retrieve/update/destroy — by an owner handle URL parameter, resolved by `resolve_owner_handle(handle)` into `(owner_user, owner_group)`. There's deliberately no bare, handle-less route: `id` is only unique within its own owner/owner_group scope, so a route spanning more than one such scope could match two different objects sharing a local id, raising `MultipleObjectsReturned`.
|
||||
|
||||
Detail routes (retrieve/update/destroy) still address the object by its owner-scoped `id`, not the internal row id. The router names the URL capture group `pk`, so `lookup_url_kwarg` stays `'pk'` — only `lookup_field` changes, to `'id'` — this is not a mismatch to "fix"; renaming `lookup_url_kwarg` to match would break the URL conf, which still names the capture group `pk`.
|
||||
|
||||
### Perform Update Rejects Non-Owned Items Explicitly
|
||||
Since `get_queryset()` (see Owner-Handle Scoped Routes above) can now return a friend's items/locations (read-only) on the same route, an object found there isn't necessarily writable. `perform_update` on both `InventoryItemViewSet` and `StorageLocationViewSet` checks `_is_authorized` and raises `PermissionDenied` explicitly, rather than silently no-op, which returning without saving would otherwise do.
|
||||
|
||||
### Friend Request and Accept Protocol Flow
|
||||
The friend request/accept flow spans two backends (A hosts `x@A`, B hosts `y@B`):
|
||||
1. `x@A` sends a friend request to `y@B`:
|
||||
1. `x@A`'s client POSTs to `A/api/v1/friendrequests/` with `{from: x@A, to: y@B}`.
|
||||
2. A's backend creates a `FriendRequestOutgoing`, containing `x@A`'s identity and `y@B`'s name.
|
||||
3. `x@A`'s client POSTs to `B/api/v1/friendrequests/` with `{from: x@A, to: y@B, public_key: x@A's public key}`.
|
||||
4. B's backend creates a `FriendRequestIncoming`, containing `y@B`'s and `x@A`'s identities.
|
||||
2. `y@B` accepts the friend request:
|
||||
1. `y@B`'s client POSTs to `A/api/v1/friendrequests/` with `{from: x@A, to: y@B, public_key: y@B's public key}`.
|
||||
2. A's backend matches the data to the `FriendRequestOutgoing` object, deletes both, and creates a `Friend` object containing `x@A`'s and `y@B`'s identities.
|
||||
3. `y@B`'s client POSTs to `B/api/v1/friends/` containing the id of the `FriendRequestIncoming` object.
|
||||
4. B's backend creates its own `Friend` object, using the identities from the `FriendRequestIncoming` object.
|
||||
|
||||
### Fetch Item By Handle: Group vs Personal Handles
|
||||
`fetchItemByHandle` in `store.js` branches on whether the handle is a group handle (leading `#`) or a personal/friend handle. A personal/friend handle goes through `fetchForeignItem`, which GETs the item directly by owner handle (see Owner-Handle Scoped Routes above). A group handle has no equivalent single-item GET route, so it resolves via `fetchGroup` (domain-routed by the handle, see `GroupDetail` in `toolshed/api/group.py`, addressed by handle directly) followed by the already-correct, already-authenticated group listing (`fetchGroupInventoryItems`), picking the matching item out of that list — which only ever contains this one group's own items, so an id collision with anything else can't happen.
|
||||
|
||||
## Workflows Architecture
|
||||
|
||||
### Workflow Catalog
|
||||
`frontend/src/workflows.js` is the single source of truth for every workflow type known to the frontend: what it's called, what category/description/icons it has, how many steps it has and what they're called, what its initial payload looks like, and which Vue component renders it.
|
||||
|
||||
Each workflow has a fully co-located component + metadata as a static `meta` option on the component (`Component.meta`, right next to `name`/`props`/etc.) in `@/components/workflow/workflows/*.vue` - `workflows.js` simply imports those components and reads `.meta` off of them to build the catalog.
|
||||
|
||||
This replaces a previous design of a parallel `BaseWorkflow` class hierarchy (metadata) plus a separate per-step `ComponentRegistry.js` (components) - both concerns now live in one flat array with each component responsible for its own metadata and UI implementation.
|
||||
|
||||
### Workflow Payload Is An Opaque String
|
||||
The backend stores `WorkflowInstance.payload` as an opaque string - it never parses or understands it as JSON. The frontend is fully responsible for serializing it before sending and deserializing it after receiving.
|
||||
|
||||
### Workflow `meta` Co-location
|
||||
Workflow metadata (title, category, description, icons, step definitions, initial payload) is co-located with its implementation as a static `meta` property on the workflow's Vue component, so there is a single source of truth per workflow type. It's consumed by `@/workflows.js` (via `Component.meta`) to assemble the catalog used by the `Workflows` and `WorkflowDetail` views.
|
||||
|
||||
### Bulk Item Import Workflow
|
||||
`BulkItemImportWorkflow.vue` implements every step of the `import-items` workflow in a single component. Steps 1 (File Upload) and 6 (Import Items) have fully custom UI; the remaining steps (2-5, 7) currently fall back to a generic "in progress" placeholder driven by this workflow's own step metadata, and can be fleshed out later without touching any other file.
|
||||
|
||||
### Staged Photos Are The Durable State
|
||||
In `FotoFirstBulkImportWorkflow.vue`, `photos`' durable state is the `WorkflowInstance.staged_files` relation itself, kept in sync directly by `stageFile()`/`unstageFile()` rather than by writing to `payload`. On load, `photos` is seeded from the `workflowInstance` prop, not from `payload`. Entries restored this way have no local bytes yet (this session never uploaded them), so `dataUrl` stays null and the gallery falls back to fetching a thumbnail by hash via `AuthenticatedImage`. Symmetrically, when a photo is newly staged, it's persisted server-side right away (keyed to the workflow instance) so it survives a reload or a switch to another device - nothing about photos needs to go into `payload` too.
|
||||
|
||||
### Thumbnail Lookup By Hash
|
||||
`thumbnailPathForHash()` in `FotoFirstBulkImportWorkflow.vue` builds a derived storage path mirroring `hash_upload()` (`files/models.py`), matching how `FileSerializer.name` already builds file URLs elsewhere in the app (e.g. `AuthenticatedImage`'s `src` for item files). `files/media_urls.py`'s `thumbnail_urls` generates and disk-caches a resized JPEG at that path on first request, since a gallery card only needs a small image, not the full-size original.
|
||||
|
||||
Once a photo finishes uploading, the gallery switches it to the server-fetched thumbnail by re-looking it up by hash rather than mutating the closed-over `photo` object from the upload closure - that reference predates the `this.photos.push()` call, so it's the raw object, not the reactive proxy Vue tracks, and writing to it wouldn't trigger a re-render.
|
||||
|
||||
### Bulk Label Print Workflow
|
||||
`BulkLabelPrintWorkflow.vue` implements the `bulk-label-print` workflow: step 1 collects a kind/owner/id range plus a label layout (reusing `LabelLayoutPreview.vue` for the template grid, previewed against the range's first id); step 2 connects a printer (or falls back to PNG downloads, same device-chip/PNG-Export pattern as `Print.vue`) and, on start, sequentially renders and prints/downloads one label per id in the range via `label.js`'s `drawLabel`, logging each id's outcome. `MAX_RANGE_SIZE` caps a single run so a mistyped range can't queue an unbounded number of labels.
|
||||
|
||||
### Foto First Bulk Import Workflow
|
||||
`FotoFirstBulkImportWorkflow2.vue` implements every step of the `foto-first-bulk-import` workflow (photo capture, image processing, item detail entry, import completion) in a single component. Keeping the whole workflow in one file avoids splitting closely related state (photos, processed images, completed items) across many small step components and their prop/emit boundaries.
|
||||
|
||||
### Staged Files Are Identified By Hash Alone
|
||||
A staged file's SHA-256 hash is enough to identify it, since the client computes content hashes the same way the backend does and can fetch bytes from a hash-derived storage path. This underpins several places in the workflow file-staging API:
|
||||
|
||||
- `WorkflowInstanceSerializer.staged_files` (`backend/toolshed/serializers.py`) exposes only hashes, not a fuller `FileSerializer` representation like `InventoryItemSerializer.files` uses - for a file staged by *this* session there's nothing more to say, and for one staged elsewhere (another device/tab), the hash is what lets this session recognize and fetch it.
|
||||
- `get_staged_files()` (`backend/toolshed/api/files.py`) returns the same bare hash list, useful mainly for discovering what another session/device already staged on a workflow.
|
||||
- `post_item_file()`'s `file_hash` branch (`backend/toolshed/api/files.py`) lets a caller attach a file already staged on one of their own workflows by content hash, instead of re-uploading bytes already stored server-side. Workflows are always personally owned, so this only applies to a caller with a local account.
|
||||
|
||||
## Offline Data Export & Import
|
||||
|
||||
### File Naming Convention In Exports
|
||||
Files attached to a user's inventory items are yielded by `inventory_files()` as `(arcname, data)` pairs, deduplicated by content hash and placed under a `files/` subfolder in the export zip, e.g. `files/<hash><ext>`. The extension is guessed from each `File`'s `mime_type` so attachments and images remain viewable once extracted, rather than sitting as extension-less blobs. This same `files/<hash><ext>` convention is used for the profile picture and is what `available_files` (passed into `import_profile()` and `import_inventory()`) is keyed by.
|
||||
|
||||
### Profile Import Semantics
|
||||
`import_profile()` is a fault-tolerant importer for `profile.json`. Only `first_name`, `last_name`, `email`, and `profile_picture` are applied to the account; `profile_picture` is only set when its value is a `files/...` path present in `available_files` (see File Naming Convention above). `username` and `domain` are ignored even if present in the payload, since they identify the account itself and cannot be changed by an import.
|
||||
|
||||
### Account & Data Deletion
|
||||
`delete_user_data()` permanently deletes everything that the data export covers, while keeping the account itself intact: inventory items (hard delete, bypassing soft-delete), storage locations, account preferences, the friends relation on the user's public identity, the profile picture, and any `File` blobs that become orphaned as a result. A `File` is only deleted by `_delete_orphaned_files()` once nothing else references it - no `InventoryItem`, no `ToolshedUser` (as a profile picture), and no `WorkflowInstance` (as a staged file) - since files are deduplicated by content hash and may be shared with other items or users. The `ToolshedUser` account (and its underlying `KnownIdentity`) is deliberately *not* deleted by `delete_user_data()`; see `delete_account()` in `toolshed/api/offlinedata.py` for that.
|
||||
|
||||
`delete_user_account()` builds on this: it calls `delete_user_data()` first and then deletes the `ToolshedUser` row itself, closing the local account. The `KnownIdentity` is still kept so that remote friends/history relating to this identity remain intact for other users - only the local account is closed. Both functions return a summary dict describing what was removed; `delete_user_account()`'s summary additionally sets `account: True`.
|
||||
|
||||
### Location Import Ordering And Savepoints
|
||||
`import_locations()` fault-tolerantly imports `locations.csv` into `StorageLocation` rows. Rows are read by header label; a row missing the required `name` column is skipped, and optional columns (`description`, `category`) are simply omitted if absent. Rows are processed in path-depth order (shallowest first) so that a child location's parent has already been created by the time it's needed. Each row runs inside its own transaction savepoint (`transaction.atomic()`), so a DB-level failure on one row (e.g. a constraint violation) can't poison the surrounding transaction and silently break every subsequent row - it only skips that one row.
|
||||
|
||||
### Handle Resolution Semantics
|
||||
A fully qualified handle (e.g. `git:base#tag:drill`) identifies one *specific* entity from one *specific* origin. `_resolve_handle()` therefore only ever resolves such a handle to an *existing* model instance and never creates one: silently creating a new local entity named after the raw handle string would be incorrect, since that's not the entity the handle actually points to. It raises `_HandleNotFound` (defined for exactly this purpose) when the handle's entity type doesn't match the expected one, or when no such object exists locally - which lets the caller (`import_inventory()`) skip just that row and report a helpful error instead of guessing.
|
||||
|
||||
### Properties CSV Encoding
|
||||
An inventory item's properties are encoded into the `properties` CSV cell as a comma-separated `handle=value` list by `_encode_properties_cell()`, and decoded back into `(Property, value)` tuples by `_parse_properties_cell()`. Because both the list separator (`,`) and the key/value separator (`=`) could otherwise appear inside a value, `_quote_value_if_needed()` wraps any value containing a comma or a quote character in CSV-style double quotes (doubling embedded quotes), and `_split_quoted_comma_list()` is the matching reader-side routine: it splits on commas while honouring quoted substrings so a quoted value's own commas aren't mistaken for separators, unescapes doubled quotes (`""`) back to a single literal quote, and strips only the single space that follows each `", "` separator (as written by the encoder) rather than doing a blanket `.strip()` - so genuine leading/trailing whitespace inside a quoted value survives the round trip.
|
||||
|
||||
### Inventory Import Semantics
|
||||
`import_inventory()` fault-tolerantly imports `inventory.csv` into `InventoryItem` rows owned by the target user. The `files` column's paths are looked up in `available_files` (see File Naming Convention above); a reference to a file that wasn't successfully extracted from the zip is simply ignored rather than failing the row. If a row references a fully qualified tag/property/category handle that doesn't resolve locally (see Handle Resolution Semantics above), the whole item is skipped - rather than creating a bogus local entity - and a message is appended to the returned `errors` list. As with `import_locations()`, each row runs inside its own transaction savepoint so one row's DB-level failure can't break the rest of the import.
|
||||
|
||||
## Camera Scanning & Inventory UI
|
||||
|
||||
### Video Stream Attach And Resize Sync
|
||||
`setupVideoStream` in `Scan.vue` attaches a `MediaStream` to the `<video>` element and is shared by `startCamera` and the camera-switch/reconnect paths, so the `CameraScanner` (which just keeps reading frames off the same `<video>` element) never needs to be recreated when the camera changes. Right after `video.play()`, `video.videoWidth`/`videoHeight` are not populated yet - the browser only reports them once the video's own `resize` (and, on first load, `loadedmetadata`) event fires, confirming the intrinsic dimensions actually took effect. `onVideoResize` listens for both events and is the one place that resizes the overlay `<canvas>` to match the video's current rendered size, replacing whatever stale size it had from before a switch to a camera with a different native resolution/aspect ratio.
|
||||
|
||||
### Print Link Shape
|
||||
`printLinkFor` in `Inventory.vue` and `StorageLocation.vue` (and the print buttons in `InventoryDetail.vue`/`StorageLocationDetail.vue`) all route to `Print.vue` with the same `{kind, userHandle, id}` query shape - the thing's raw identity - rather than any pre-built link. `kind` is one of `CONTENT_KINDS`' ids (`"item"`/`"storage-location"`, see Content Kinds Registry above); `label.js`'s `buildLabelFields` reads it generically since every kind's prefill now shares this one shape. That lets the print page derive every representation it needs (qualified handle, owner handle, URL, short link, …) itself, instead of being tied to whichever one the calling button happened to construct. Group-owned items have no individual owner handle - `short-id.js`'s `group_item` kind resolves them via `owner_group` instead (see `shortIdLink`) - so there's no `{userHandle, id}` to build yet; `Inventory.vue`'s `printLinkFor` returns `null` for them until group print support exists. Storage locations are always individually owned (see `StorageLocationViewSet.get_queryset`), so `StorageLocation.vue`'s version never has that fallback to make.
|
||||
|
|
@ -9,5 +9,7 @@ This is the documentation for the Toolshed project. It is a work in progress.
|
|||
- [Deploying Toolshed](deployment.md)
|
||||
- [Development Setup](development.md)
|
||||
- [About Federation](federation.md)
|
||||
- [Handles and Short IDs](handles-and-shortids.md)
|
||||
- [Implementation Notes](implementation.md)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue