Compare commits
No commits in common. "7d9f67a77a96c9c00443ad3b5b5b437cd9d4edfa" and "4787acd8eb819e4fdc82583d6949c1c16f99b6b0" have entirely different histories.
7d9f67a77a
...
4787acd8eb
22 changed files with 418 additions and 2467 deletions
|
|
@ -1,113 +0,0 @@
|
|||
# Groups MVP (Design in Progress)
|
||||
|
||||
Status: not implemented. [groups.md](groups.md) works out the underlying model (what a group is,
|
||||
how "acting as a group" is authenticated across federation). This document doesn't re-derive any
|
||||
of that; it takes that design as given and asks a narrower question: what is the smallest set of
|
||||
screens and actions that makes groups usable, reusing the UI patterns Toolshed already has for
|
||||
friends and inventory rather than inventing new ones.
|
||||
|
||||
## Goals
|
||||
|
||||
- Ship something a user can actually click through: create a group, add/remove members, and have
|
||||
the group own items, end to end.
|
||||
- Reuse existing screens and interaction patterns wherever the shape already matches, instead of
|
||||
designing new ones (see below).
|
||||
- Leave every deferred piece as an explicit non-goal rather than a silent gap, so it's clear what
|
||||
MVP does and doesn't cover.
|
||||
|
||||
## Non-goals (for now)
|
||||
|
||||
- **Group-to-group friending, and letting outside users friend a group.** groups.md's model
|
||||
supports both, but the only audience for a group's items in MVP is the group's own members;
|
||||
"private" already covers that (see "Availability policy for group items" below). Extending
|
||||
visibility to non-members via the group's own friends list is real, valuable, and deferred as a
|
||||
fast-follow, not designed here.
|
||||
- **Transferring an existing personally-owned item's ownership to a group.** MVP only supports
|
||||
creating a *new* item directly owned by a group. Moving an already-existing item across owners is
|
||||
a separate piece of work (ownership transfer isn't something Toolshed supports for user-to-user
|
||||
either today).
|
||||
- **Deleting a group.** MVP has no explicit "delete this group" action. A group's lifecycle is just
|
||||
"created, membership changes over time"; the orphaning guard (a group can't be left with zero
|
||||
members, see groups.md's Known Gaps) means a group that's no longer wanted just sits unused rather
|
||||
than needing a teardown flow.
|
||||
- **Any governance, roles, or per-item permissions.** Same non-goal as groups.md: every member has
|
||||
equal, full privileges over the group and everything it owns.
|
||||
|
||||
## User-facing features
|
||||
|
||||
### Creating a group
|
||||
|
||||
A new "Groups" section, entry point styled like Inventory's "Add" button. The form is just a name;
|
||||
the handle (`#name@yourdomain`) is derived from it the same way a username becomes part of a user's
|
||||
handle. The creator becomes the group's first member automatically — there's no empty-group state
|
||||
to design for.
|
||||
|
||||
### My Groups list
|
||||
|
||||
A "Groups" nav entry/page, modeled directly on `Friends.vue`: a table of groups the current user is
|
||||
a member of (name/handle, member count), each row linking into that group's detail page. No
|
||||
separate "discover groups you're not in" browsing for MVP — you land in a group by being added to
|
||||
it, the same way you become friends with someone by request/accept, not by browsing a directory of
|
||||
all users.
|
||||
|
||||
Known limitation: this list only ever queries the member's own home backend, so it only shows
|
||||
groups actually hosted there (groups you created, or joined on your own domain). Membership itself
|
||||
works regardless of which backend hosts the group — a remote member can still be invited, accept,
|
||||
and fully edit/delete the group's items (see "Owning items as a group" below) — but a group hosted
|
||||
on someone else's backend won't show up in your own "My Groups" list, because unlike friendship
|
||||
(which both sides record), group membership is only ever recorded on the group's own home backend,
|
||||
and there's no index anywhere of "which other backends has this identity been added to." Making a
|
||||
remote membership discoverable would need a small personal pointer index (written by the client at
|
||||
join time) plus a handle-based group lookup on the group's own backend; deferred as a fast-follow
|
||||
alongside group-friending.
|
||||
|
||||
### Group detail page
|
||||
|
||||
One page per group, with two sections, each reusing an existing pattern wholesale:
|
||||
|
||||
- **Members** — the add/remove-row pattern from `Friends.vue`'s friend list: an inline "add member"
|
||||
field (enter a handle, `user@domain`), and a remove (trash icon) action per row. Any member can
|
||||
add or remove any other member — flat privilege, no confirmation step beyond the orphaning guard
|
||||
(removing the group's last member is blocked, with an error explaining why, rather than silently
|
||||
emptying the group).
|
||||
- **Group inventory** — the exact table/grid pattern from `Inventory.vue` (Name, Availability
|
||||
Policy, Amount, Edit/Delete), scoped to items owned by this group, with the same "Add" button
|
||||
leading into the existing item-creation form (see below).
|
||||
|
||||
A "Leave group" action removes the current user from Members; if they're the last member, it's
|
||||
blocked by the same orphaning guard.
|
||||
|
||||
### Owning items as a group
|
||||
|
||||
The existing item create/edit form (`InventoryNew.vue` / `InventoryEdit.vue`) gets one new field:
|
||||
an "Owner" selector, defaulting to "Myself," with the groups you belong to as the other options.
|
||||
Every other field on that form (tags, properties, availability policy, storage location, quantity)
|
||||
is unchanged, and a group-owned item behaves exactly like a personally-owned one everywhere else in
|
||||
the app (edit, delete, detail view) — any member can edit or delete it, the same way the owner can
|
||||
today.
|
||||
|
||||
### Where group-owned items show up
|
||||
|
||||
The main "Inventory" page stays scoped to items you personally own, unchanged from today. A group's
|
||||
items are visible and managed in exactly one place: that group's detail page. This keeps "my
|
||||
inventory" meaning one thing (what I personally own) and avoids merging two different item lists
|
||||
with different edit semantics into one view for MVP.
|
||||
|
||||
### Availability policy for group items
|
||||
|
||||
The item form's existing Availability Policy field (private/share/lend/rent/sell) is unchanged and
|
||||
applies to group-owned items the same way it does today. Since group-friending is out of scope for
|
||||
MVP (see Non-goals), "share/lend/rent/sell" have no wider audience to expand to yet, only "private"
|
||||
is fully meaningful right now — a private group item is visible to and editable by every group
|
||||
member, which is already the core value the Problem statement in groups.md is after (a shared
|
||||
workshop's members all having a say over shared equipment). The field stays as-is rather than being
|
||||
trimmed down to just "private," so nothing needs to change on it once group-friending ships.
|
||||
|
||||
## Open scoping call
|
||||
|
||||
Deferring group-friending is the one judgment call in this document worth flagging explicitly:
|
||||
it means an MVP group can't yet share an item with anyone outside its own membership, which is a
|
||||
real limitation, not just a simplification. It was scoped out because it pulls in a second piece of
|
||||
UI (a group's own Friends tab, and "who can accept a friend request on the group's behalf") that
|
||||
isn't needed for the core "shared ownership among members" use case to work end to end. Worth
|
||||
confirming this is the right line before building against it.
|
||||
|
|
@ -1,233 +0,0 @@
|
|||
# Groups (Design in Progress)
|
||||
|
||||
Status: not implemented. This document collects the problem, goals, and open design questions for
|
||||
adding groups to Toolshed. Nothing here is settled; it's a starting point for discussion.
|
||||
|
||||
## What a group is
|
||||
|
||||
A group exists to model shared ownership of items, the way a club, workshop, or company owns
|
||||
equipment collectively rather than any one person owning it. A group has members, and all members
|
||||
have equal privileges to edit the items the group owns; there's no owner-vs-member distinction
|
||||
within a group, membership itself is the privilege.
|
||||
|
||||
This is a sharper (and more consequential) definition than "a label you can share things with":
|
||||
it means a group needs to be able to *own* things, not just receive shared access to them the way
|
||||
a friend does. That has implications worked out below.
|
||||
|
||||
## Problem
|
||||
|
||||
The only relationship Toolshed currently models between users is pairwise friendship (see
|
||||
[federation.md](../federation.md)), and every item has exactly one owning user. That's enough for
|
||||
"I trust this one specific person" and "I personally own this thing," but it breaks down for
|
||||
anything collectively owned:
|
||||
|
||||
- A shared workshop, tool library, or team can't own equipment as a unit. Today it has to belong
|
||||
to one specific person's account, which is a poor fit and doesn't reflect who actually has a say
|
||||
over it.
|
||||
- There's no way for several equally-privileged people to edit the same item; edit rights today
|
||||
are entirely tied to the single `owner` field.
|
||||
- Adding or removing a member of an informal group currently means renegotiating friendships and
|
||||
re-sharing individually; there's no shared object whose membership can just be edited once.
|
||||
|
||||
## Goals
|
||||
|
||||
- Let a set of users collectively own items, with every member holding equal edit rights over
|
||||
those items.
|
||||
- Let membership be managed in one place instead of via N pairwise arrangements.
|
||||
- A request from a client to any backend may not depend on any other backend being online at the
|
||||
same time — not to construct the request, and not to verify it. Concretely: the receiving server
|
||||
must be able to verify the request using only the request itself plus keys it has already cached
|
||||
from prior trust (friend-accept), with the group's own authoritative backend and the requesting
|
||||
member's home server both unneeded and unreachable-safe at verification time; and the client must
|
||||
be able to send the request using only what it already has cached, with the group's authoritative
|
||||
backend unneeded and unreachable-safe at send time too. This is the same property plain user
|
||||
requests already have (see federation.md's Cryptography section); group requests must not regress
|
||||
it on either side.
|
||||
- Fit into the existing handle system (see federation.md's "Unique Handles" section): a group
|
||||
should be nameable and referenceable using the same handle shape a user is.
|
||||
- Stay optional and additive. Pairwise friendship and single-user ownership should keep working
|
||||
exactly as they do now for people who never touch groups.
|
||||
|
||||
## Non-goals (for now)
|
||||
|
||||
- Group governance beyond flat, equal membership (voting, roles, hierarchies). Equal privileges
|
||||
for all members is the whole model for now; anything more layers on top later if needed.
|
||||
- Fine-grained per-item permissions within a group (e.g. "this member can edit but not delete").
|
||||
Membership is the only privilege level.
|
||||
|
||||
## Open design questions
|
||||
|
||||
### Does a group need its own keypair?
|
||||
|
||||
No. A group's day-to-day existence is a membership roster maintained by whichever backend is
|
||||
authoritative for the group's handle (the same "authoritative backend" idea a user's domain already
|
||||
implies, see federation.md's Servers subsection). A request made "as the group" is an ordinary
|
||||
request, signed with an actual member's own personal private key, together with a claim of which
|
||||
group it's acting on behalf of (`acting_as`), signed as part of the same payload as the rest of the
|
||||
request.
|
||||
|
||||
A receiving server must be able to authenticate such a request using only what arrives in the
|
||||
request plus keys it already holds; no other server, including the group's own authoritative
|
||||
backend, needs to be reachable at verification time. This is accomplished with **membership
|
||||
certificates**: on request, a group's authoritative backend issues a current member a small
|
||||
signed statement of the shape "handle X, public key P, is a member of group #G, valid from T1 until
|
||||
T2," signed with the group's own private key. Issuance is on-demand (the member asks, rather than
|
||||
the backend pushing renewals on a schedule), but it is its own separate action, decoupled from
|
||||
sending any particular group request: a member fetches and refreshes this certificate from the
|
||||
group's backend whenever they happen to be online, caches it locally, and later attaches whichever
|
||||
certificate they currently hold to a request they make "as the group." Sending that request never
|
||||
itself triggers a live fetch from the group's backend — if the cached certificate has expired and
|
||||
the group's backend isn't reachable right then, the request simply can't be sent as the group yet;
|
||||
the client doesn't fall back to contacting the group's backend synchronously to get one.
|
||||
|
||||
A receiving server checks two signatures, using keys it already has cached, with no outgoing call
|
||||
to anyone: the member's signature over the request, using the public key embedded in the
|
||||
certificate itself, and the certificate's own signature, using the group's public key, learned and
|
||||
cached exactly the way any friend's key is, at the point the group was friended. If both check out
|
||||
and the certificate hasn't expired, the request is authorized.
|
||||
|
||||
The certificate is what lets a receiving server trust a specific member's public key at all, for
|
||||
members it has never individually friended: that trust is vouched for by the group's already-cached
|
||||
key, rather than requiring a separate key-exchange with every member of every group a user happens
|
||||
to be friends with. Equal privileges for all members falls out of this directly, since any member's
|
||||
own key plus a valid certificate is sufficient proof.
|
||||
|
||||
Removing a member takes effect once their existing certificate expires, not the moment the
|
||||
backend's roster is edited; certificate lifetime is the parameter that governs how quickly a
|
||||
removal actually takes hold (see "How long should a membership certificate be valid for?" below,
|
||||
and Security below).
|
||||
|
||||
### Are group handles different from user handles, or is a group just a special kind of user?
|
||||
|
||||
The handle should look almost exactly like a user handle, just prefixed with `#`: `#groupname@domain`
|
||||
instead of `groupname@domain`. It's resolved the same way and referenceable in the same places (e.g.
|
||||
as an item's owner, or as a friend-list entry), so the reuse of the existing federation model is
|
||||
unaffected. The prefix exists only to keep group and user handles from occupying the same namespace on
|
||||
a domain: without it, "is `groupname@domain` a user or a group" would depend on which one happened to
|
||||
register the name first, and the two could never be told apart just by looking at the handle. With the
|
||||
prefix, a domain can have both a `climbing@domain` user and a `#climbing@domain` group with no
|
||||
collision and no ambiguity about which is which, and any code path that resolves a handle can dispatch
|
||||
on the actor kind (user vs. group) from the handle's own shape, before it even needs to ask a server.
|
||||
|
||||
But underneath, a group isn't really "a special kind of user," it's backed by a fundamentally
|
||||
different kind of identity: a membership list instead of a single keypair (see above). A user
|
||||
handle answers "is this request authentically from this one identity," a group handle answers "is
|
||||
this request from someone currently entitled to act for this collective identity." Those are
|
||||
different questions even though the answer to both ends up being "yes, forward the request." Groups
|
||||
and users are probably best thought of as two kinds of actor that share a handle format and most of
|
||||
the surrounding plumbing (ownership, friendship, availability policy), rather than one being a
|
||||
special case of the other.
|
||||
|
||||
### Should a group be able to grant read access to non-members ("group friends")?
|
||||
|
||||
Yes, this should reuse the same mechanism a user's own sharing already uses. Since a group is an
|
||||
actor with a handle, it can have its own friends the same way a user does, and a group's owned
|
||||
items can go through the same availability-policy check (private vs. visible to the group's
|
||||
friends) that an individual's items already do. Nothing new needs to be invented here, it's the
|
||||
existing friendship and availability-policy machinery applied to a second kind of actor.
|
||||
|
||||
### Should groups be able to befriend other groups?
|
||||
|
||||
Yes, for the same reason: if a group is an actor with a handle and a friends list, there's no
|
||||
reason the other side of that friendship has to be a user specifically. Two clubs befriending each
|
||||
other so each can see the other's shared equipment is the same mechanism as two users befriending
|
||||
each other, just with both sides being groups instead of one or zero.
|
||||
|
||||
### Should anyone be able to share directly with a specific group, instead of with "my friends" generally?
|
||||
|
||||
This is the one piece that isn't just reuse of what exists today. Right now, an item's
|
||||
availability policy is all-or-nothing across *all* of the owner's friends, there's no way to share
|
||||
with a subset of friends, individually or as a named group. Letting an item be shared with one
|
||||
specific group (a user's own item, shared with a club they belong to, say) instead of with every
|
||||
friend equally would be a genuine generalization of the current sharing model, not something that
|
||||
falls out of adding groups as an actor.
|
||||
|
||||
Worth noting: this capability would be just as useful for individual users wanting to share with a
|
||||
subset of their friends, without a group being involved at all. It might make more sense to design
|
||||
"share with a specific target (user, group, or named subset)" as its own piece of work, rather than
|
||||
building it as a groups-only feature.
|
||||
|
||||
### How long should a membership certificate be valid for?
|
||||
|
||||
This is the tuning knob the certificate design introduces, and it's a real trade-off rather than a
|
||||
detail to defer. A short validity window (say, hours) keeps the staleness window after a removal
|
||||
small, but means a member who's offline for longer than that can't act as the group at all until
|
||||
they reconnect and refresh. A long window (days or weeks) is more forgiving of intermittent
|
||||
connectivity but leaves a removed member's old certificate usable for longer. Whatever default is
|
||||
picked, a member should be able to fetch a fresh certificate well before the old one expires while
|
||||
still online, so the common case isn't "offline for exactly the wrong amount of time."
|
||||
|
||||
## Interaction with availability policy
|
||||
|
||||
Items currently have an availability policy (private / share / lend / rent / sell) that's a
|
||||
property of the item, not a list of who it applies to, "friends" is implicit and applies equally to
|
||||
all of them. A group-owned item works the same way, just with the group's own friends as the
|
||||
implicit audience instead of an individual's. Targeted sharing (the question above) would extend
|
||||
this, not replace it.
|
||||
|
||||
## Known gaps in the design
|
||||
|
||||
None of these block the design, but they're real gaps that need an explicit answer before
|
||||
implementation.
|
||||
|
||||
### Ambiguity
|
||||
|
||||
- **What exactly is signed.** The `acting_as` claim and the certificate's identifying fields must be
|
||||
signed as part of the same payload the member's key signs, not as free-standing, unsigned data
|
||||
alongside it. If they aren't inside the signed bytes, `acting_as` can be swapped after signing,
|
||||
turning a personal request into a group one or vice versa, or one group's request into another's.
|
||||
- **Who can change membership, and how.** Equal privileges to edit items doesn't by itself say
|
||||
whether that equality extends to *requesting or renewing certificates for others, or editing the
|
||||
authoritative backend's issuance list itself*. Flat and unilateral (any member can add or remove
|
||||
any member) is the simplest reading of "no owner-vs-member distinction," but it's a materially
|
||||
different trust model from "equal edit rights over items" and deserves its own explicit decision.
|
||||
|
||||
### Security
|
||||
|
||||
- **Confused deputy on `acting_as`.** Trust in an `acting_as` claim reduces to trust in the group's
|
||||
authoritative backend's issuance decisions: a malicious or compromised backend can sign a
|
||||
certificate for a handle that was never really a member, and every receiving server that trusts
|
||||
the group's key will accept it. The backend's issuance discipline, and the security of its own
|
||||
private key, is a single point of failure for the group as a whole.
|
||||
- **Membership staleness window.** There is a window after a member is removed during which their
|
||||
existing certificate keeps working: exactly the certificate's remaining validity period. This is a
|
||||
strictly worse revocation story than individual friendship, where trust is keyed to a public key
|
||||
learned once with no expiry, but the window is a bounded, chosen parameter (see "How long should a
|
||||
membership certificate be valid for?" above) rather than open-ended.
|
||||
- **Blast radius of a single compromised member key.** Because membership is flat and unilaterally
|
||||
editable by any member, a compromised personal key doesn't just expose that person's own items,
|
||||
as with an ordinary account compromise, it exposes edit rights over everything the group owns for
|
||||
as long as that member's certificate remains valid, and can be used to obtain a certificate for an
|
||||
attacker-controlled handle as a permanent member before anyone notices. This risk is inherent to
|
||||
"equal privileges, no roles" as a model, worth flagging even though richer governance is a
|
||||
non-goal for now.
|
||||
- **Self-lockout / orphaning.** Nothing in the design stops a group's last member from leaving (or
|
||||
removing everyone else) from the backend's issuance list, which would leave group-owned items with
|
||||
no one able to obtain a valid certificate for that owner at all once existing certificates expire.
|
||||
The backend should guard against removing the last member, but that guard doesn't address a
|
||||
member unilaterally removing every *other* member, which the flat model otherwise permits.
|
||||
- **Audit trail depends on discipline.** Since any member's signature plus a valid certificate
|
||||
satisfies authorization, "the group edited this item" is never sufficient for an audit trail; the
|
||||
actual signer's handle (from the certificate's embedded public key) must always be logged
|
||||
alongside the group claim, or member-level accountability is lost entirely.
|
||||
|
||||
### Maintainability
|
||||
|
||||
- **Two actor kinds sharing one code path.** `Group` should have the same shape as `User` for the
|
||||
things that matter (a `.friends` set, a `.handle`), so ownership/friending/availability-policy
|
||||
code can stay actor-agnostic. That reuse only holds if future code is disciplined about not
|
||||
special-casing `User` in ways that assume a single, non-expiring keypair (e.g. "cache the owner's
|
||||
public key forever, no expiry check needed") — a shortcut that would silently break the moment
|
||||
the owner turns out to be a group, where the *acting member's* key is only good until its
|
||||
certificate expires.
|
||||
- **Certificate issuance and refresh is a client responsibility.** A member's client needs to
|
||||
refresh its certificate before it expires, handle in-flight group actions failing closed if it
|
||||
didn't (the same as any expired-credential error), and surface refresh failures to the user
|
||||
rather than as a confusing rejected request.
|
||||
- **Expanded federation test surface.** Every existing federation test implicitly assumes the
|
||||
request's signer and its authorized actor are the same handle. `acting_as` plus an embedded
|
||||
membership certificate means the whole request-verification path needs testing for the
|
||||
signer-vs-actor split and the certificate's own signature and expiry checks, including
|
||||
cross-domain cases (group hosted on one domain, member's key registered on another, item owned by
|
||||
the group sitting on a third) and expiry-boundary cases (certificate expires mid-flight, is
|
||||
refreshed concurrently with a request, etc).
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
# Authenticated image caching (Design in Progress)
|
||||
|
||||
Status: not implemented. This document proposes a fix for a real performance gap: every
|
||||
authenticated image in the app is refetched, re-verified, and re-decoded from scratch on every page
|
||||
load, even though the backend already sends headers built for exactly the opposite.
|
||||
|
||||
## Problem
|
||||
|
||||
Images are served from `GET /media/<hash_path>` and `GET /media/<size>/<hash_path>/`
|
||||
(`backend/files/media_urls.py`), both gated behind `SignatureAuthentication`
|
||||
(`backend/authentication/signature_auth.py`): the client signs the full request URL with an Ed25519
|
||||
key and sends `Authorization: Signature <user>@<domain>:<sig>`. There's no cookie and no
|
||||
URL-embedded token — auth lives entirely in a request header that a browser has no way to attach to
|
||||
a plain `<img src="...">`. So `AuthenticatedImage.vue` does it by hand: `fetch()` with the header,
|
||||
`.blob()`, `URL.createObjectURL()`, assign that to `src` (`federation.js`'s `getRaw`,
|
||||
`fileCache.js`). `fileCache.js` is a module-level `Map` — it dedupes concurrent requests and holds
|
||||
decoded blobs for the life of the page, but it's memory-only. Reload the page (or just navigate
|
||||
between the SPA's route-based chunks in a way that re-mounts things) and it's gone; every image the
|
||||
user has already looked at gets fetched, signature-verified, and blob-decoded all over again.
|
||||
|
||||
Meanwhile the backend response already carries `ETag`, `Cache-Control: max-age=31536000, private,
|
||||
immutable`, and a 365-day `Expires` (`_cache_headers`, `media_urls.py`) — because `src` is a
|
||||
SHA-256 hash-addressed path, the same URL can only ever mean the same bytes, forever. Those headers
|
||||
are correct and unused: nothing durable in the client ever consults them. This design closes that
|
||||
gap using the browser's own Cache Storage API, without touching the backend.
|
||||
|
||||
## Goals
|
||||
|
||||
- Make a previously-viewed image load instantly on the next page load / browser restart, not just
|
||||
within the current tab's JS session.
|
||||
- Do it without weakening the authorization model: a signature is still required and verified
|
||||
server-side for the *first* fetch of a given file by a given identity. Caching must not let one
|
||||
identity's cached bytes leak to a different identity sharing the same browser.
|
||||
- Reuse the backend's existing headers rather than inventing a parallel freshness scheme — content
|
||||
is immutable, so a cache hit needs zero revalidation, ever.
|
||||
- No backend changes. This is purely a client-side storage question.
|
||||
|
||||
## Non-goals (for now)
|
||||
|
||||
- **Revoking already-cached bytes when access changes** (e.g. an unfriend). The backend's own
|
||||
1-year `Cache-Control` already accepts that risk today for anything an HTTP-compliant cache might
|
||||
hold; a persistent client cache extends the shelf life of that same accepted risk, it doesn't
|
||||
introduce a new one. Not solving revocation here.
|
||||
- **Prefetching / warming the cache ahead of navigation.** Real optimization, separate piece of
|
||||
work; this document is about not throwing away work already done.
|
||||
- **A Service Worker that reinstates plain `<img src>`.** Sketched below as a follow-up because it's
|
||||
the "real" fix for the root cause (no way to attach a header to an `<img>` request), but it's a
|
||||
bigger lift (SW lifecycle, an extra message-passing bridge for signing) than the storage win alone
|
||||
needs. Scoped out of this pass.
|
||||
|
||||
## Design: persist `fileCache` with the Cache Storage API
|
||||
|
||||
`window.caches` (the `CacheStorage` interface) is available to any page context, not just inside a
|
||||
Service Worker — `caches.open(name)` gives a store of real `Request`/`Response` pairs that survives
|
||||
reloads and browser restarts, backed by the browser's own disk quota. That's the missing tier;
|
||||
nothing else about `fileCache.js`'s existing shape needs to change.
|
||||
|
||||
**Two tiers, not one:**
|
||||
|
||||
- **L1 — in-memory `Map<key, objectURL>`** (what exists today). Kept as-is: within a single page
|
||||
session, components just want the already-created object URL back without re-touching storage at
|
||||
all. Same LRU/budget logic (`MAX_BYTES`), unchanged.
|
||||
- **L2 — `CacheStorage`**, consulted on an L1 miss, before falling back to the network. Holds raw
|
||||
`Response` objects (not blobs), keyed by the same request used for the authenticated fetch.
|
||||
|
||||
Revised `get(key, fetcher)` flow:
|
||||
|
||||
1. L1 hit → return the object URL, as today.
|
||||
2. L1 miss → check `cache.match(request)`. Hit → `.blob()` the cached response, create the object
|
||||
URL, populate L1, done. **No conditional GET, no revalidation** — the response is `immutable`,
|
||||
so if it's in the cache it's still correct by construction.
|
||||
3. L2 miss → run the existing authenticated `getRaw()` fetch. On success, `cache.put(request,
|
||||
response.clone())` before consuming the body, then proceed as today (`.blob()`, object URL,
|
||||
populate L1).
|
||||
|
||||
**Namespacing by identity, not one global cache.** `Cache-Control: private` on the response is the
|
||||
backend telling shared caches to stay out — correct, since access is per-requester
|
||||
(`_accessible_files`'s friends-or-self check). A single browser-wide `CacheStorage` bucket keyed
|
||||
only by URL would quietly turn into exactly the shared cache that header is warning off, *if* this
|
||||
browser ever holds more than one local identity (switching accounts, a shared machine). Concretely:
|
||||
open the cache as `images-${username}@${domain}` (derived from the active `state.keypair`, the same
|
||||
identity that produces the signature) rather than a single `"images"` name. Same-identity re-fetches
|
||||
get the full cache benefit; a different identity in the same browser starts with an empty bucket and
|
||||
goes through the normal authenticated-fetch-then-verify path, same as it does today. `invalidate()`
|
||||
and `clear()` already exist on `FileCache` but nothing calls them — wire `clear()` to also
|
||||
`caches.delete(currentNamespace)` and call it on logout/identity-switch, which is the natural,
|
||||
already-there hook for this.
|
||||
|
||||
**Storage budget.** L2 doesn't need its own hard byte cap the way L1 does — `CacheStorage` is
|
||||
subject to the browser's own storage-pressure eviction, which is the right backstop for "durable but
|
||||
not sacred" data like this. Optionally call `navigator.storage.persist()` once at startup to ask the
|
||||
browser to exempt the origin from casual eviction under pressure; harmless to skip if declined.
|
||||
|
||||
**Net effect:** a returning user's already-seen images (inventory thumbnails, profile pictures,
|
||||
friends' shared items) render from disk with zero network round-trips and zero re-verification,
|
||||
using exactly the durability guarantee (`immutable`, hash-addressed) the backend already asserts.
|
||||
First-time images are unaffected — same authenticated fetch as today, just now also written to L2 on
|
||||
the way through.
|
||||
|
||||
## Follow-up worth flagging: a Service Worker to restore plain `<img>`
|
||||
|
||||
The deeper cost isn't just the network round-trip — it's that every image, cached or not, is forced
|
||||
through manual `fetch → blob → createObjectURL`, so the browser's native image pipeline (off-main
|
||||
thread decode, `loading="lazy"`, `fetchpriority`, responsive `srcset`) is unavailable, and object
|
||||
URLs have to be manually revoked (`fileCache.js` already does this correctly, but every new call
|
||||
site is a chance to leak one). The reason the app can't use plain `<img src>` at all is that nothing
|
||||
can attach the `Authorization: Signature` header to a browser-initiated image request.
|
||||
|
||||
A Service Worker can, because its `fetch` handler intercepts requests — including image loads —
|
||||
before they leave the page, and can substitute its own request in place of the original:
|
||||
|
||||
- On a `fetch` event where `event.request.destination === 'image'` and the URL matches `/media/`,
|
||||
check the (identity-namespaced) `CacheStorage` first; hit → respond straight from cache, no
|
||||
network at all.
|
||||
- Miss → the SW doesn't have the signing key (it lives in page memory / `localStorage`, neither
|
||||
reachable from a SW), so it asks the one controlled client (`self.clients.get(event.clientId)` —
|
||||
the specific tab that issued the request, not "any open tab") for a signature over this exact URL
|
||||
via `postMessage`/`MessageChannel` — an in-process round trip, not a network call — attaches the
|
||||
returned header, performs the real fetch, stores the result in `CacheStorage`, and responds with
|
||||
it.
|
||||
- Once this exists, `AuthenticatedImage.vue` can go back to `<img :src="mediaUrl" loading="lazy"
|
||||
decoding="async">` directly; the SW is what makes that legal despite the custom auth scheme.
|
||||
|
||||
### Scoping the signing bridge: a compromised SW must not become a "sign anything" oracle
|
||||
|
||||
The message bridge above is the one new capability this design adds that doesn't exist today: a
|
||||
channel through which something can ask the page to sign a URL on its behalf. A Service Worker is a
|
||||
long-lived, network-interposing piece of code — exactly the kind of thing a supply-chain compromise
|
||||
or an XSS-planted `registration.update()` would target. If the page's message handler blindly signs
|
||||
whatever URL the request names, a compromised SW stops being "something that can read images this
|
||||
identity can already see" and becomes "something that can get a validly-signed request for *any*
|
||||
endpoint" — e.g. `POST /api/inventory/items/5/delete` or `POST /api/friends/accept` — and then just
|
||||
replay it directly against the real backend. That's a full account-takeover primitive smuggled in
|
||||
through what was supposed to be an image-caching optimization, and it's strictly worse than not
|
||||
having the bridge at all.
|
||||
|
||||
The fix has to live on the page side of the channel, since the SW is the presumed-compromised
|
||||
component in this threat model and can't be trusted to police itself. Treat the message handler as a
|
||||
dedicated, narrow function — not a thin wrapper around the app's general-purpose signer
|
||||
(`createSignAuth` in `federation.js`, which is used for arbitrary API calls elsewhere in the app) —
|
||||
that:
|
||||
|
||||
- **Ignores any method the request claims and always signs as `GET`.** The bridge never accepts a
|
||||
body/`data` field from the SW at all, which closes off the entire class of mutating requests
|
||||
(`POST`/`PUT`/`PATCH`) regardless of what path is named.
|
||||
- **Validates the path against a strict allowlist grammar before signing anything**, rather than a
|
||||
loose "starts with `/media/`" check. `src` values are hash-addressed —
|
||||
`/media/<hex>/<hex>/<64-hex-char-sha256>.<ext>` for originals, with an optional `/<32|64|256>/`
|
||||
size prefix for thumbnails. Because the variable part is constrained to `[0-9a-f]`, a regex over
|
||||
that exact shape is effectively a closed grammar: `.` and `/` (the characters path traversal or
|
||||
extra-segment tricks would need) simply aren't in the hex alphabet, so there's no meaningfully
|
||||
malformed input that still matches. Anything that doesn't match — a different endpoint, an
|
||||
encoded traversal attempt, an extra query string — is refused, silently or with a logged warning,
|
||||
never signed.
|
||||
- Optionally also checks the URL's host against the identity's home domain or its current friend
|
||||
servers (belt-and-suspenders — a signature is bound to the exact signed URL string, so it can't be
|
||||
replayed against a different host than the one named in it, but this catches a compromised SW
|
||||
fishing for signatures against a host that happens to also trust this key for unrelated reasons).
|
||||
|
||||
With this in place, the worst a fully compromised SW can do is obtain signed `GET`s for images the
|
||||
current identity is already authorized to fetch — the same blast radius as "can read the
|
||||
already-authorized image cache" — not an oracle for arbitrary authenticated mutation.
|
||||
|
||||
Deferred because it adds real surface area (SW registration/update lifecycle, this scoped
|
||||
message-passing bridge, first-load-before-SW-is-active edge cases) beyond what the storage change
|
||||
alone needs. Worth doing as a second pass once the simpler win above is in and paying off.
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
# Item Handles & Physical Labels (Design in Progress)
|
||||
|
||||
Status: not implemented. This document collects the problem, goals, and open design questions for
|
||||
giving inventory items stable identifiers and physical (scannable) labels. Nothing here is
|
||||
settled.
|
||||
|
||||
## Problem
|
||||
|
||||
As described in federation.md's "Unique Handles" section, an item today is identified only by a
|
||||
local id scoped to its owner, it isn't given an explicit, portable handle the way a tag, property,
|
||||
or category is. That's fine as long as the only thing ever addressing an item is the owning user's
|
||||
own signed API traffic. It stops being fine the moment something *outside* that loop needs to
|
||||
refer to the item:
|
||||
|
||||
- A friend who borrowed a physical tool has no way to look it up other than finding it in the
|
||||
owner's shared inventory list by eye.
|
||||
There's nothing you could put on a sticker.
|
||||
- If items ever need to be referenced from outside their owner's own requests (a group's shared
|
||||
view, a lending record, a printed label), there's currently no stable identifier to reference
|
||||
that's meaningful outside the owner's own account.
|
||||
- A local database id isn't something we'd want to expose or rely on externally: it's an
|
||||
implementation detail of one backend's storage, not a handle with the same guarantees
|
||||
(uniqueness, meaning, longevity) the rest of the federation model gives every other kind of
|
||||
entity.
|
||||
|
||||
Put simply: every other kind of thing in Toolshed (users, tags, properties, categories) has a
|
||||
handle that means something outside of one database. Items don't, and physical labeling is the
|
||||
clearest case where that gap actually matters.
|
||||
|
||||
## Goals
|
||||
|
||||
- Give an item a handle that's meaningful and resolvable outside its owner's own account, without
|
||||
requiring items to become shared/reusable entities the way tags are (an item is still owned by
|
||||
exactly one person; see the "Items" subsection of federation.md for why that keeps things
|
||||
simple).
|
||||
- Support a physical label (QR code, barcode, or similar) that can be printed and stuck on a real
|
||||
object, such that scanning it gets you to the right item on the right backend.
|
||||
- Make the label survive the normal life of a physical object: it gets lent out, comes back,
|
||||
maybe changes which storage location it lives in, all without needing a new label printed.
|
||||
|
||||
## Non-goals (for now)
|
||||
|
||||
- Turning items into shareable/reusable entities across owners (that's what tags/categories are
|
||||
for; see [tags.md](tags.md)). An item handle identifies *this specific person's specific thing*,
|
||||
not a class of thing.
|
||||
- Solving inventory tracking/auditing (check-in/check-out logs) as a whole system; that can build
|
||||
on top of a stable item handle once one exists, but isn't the same problem.
|
||||
|
||||
## Open design questions
|
||||
|
||||
**What does the handle look like?**
|
||||
The natural extension of the existing scheme is owner handle + local id, that's enough to be
|
||||
globally unique (no two users share a handle, and ids are already unique within one user's
|
||||
inventory) without inventing a new namespace. Worth deciding whether the id should be the existing
|
||||
internal database id (simple, but leaks a little implementation detail and a rough count of
|
||||
someone's inventory) or a separate opaque id generated for exactly this purpose (see the
|
||||
unguessability question below).
|
||||
|
||||
Two distinct formats are needed, because "an item handle" is used in two different situations:
|
||||
|
||||
- *A compact handle, for use where context already makes clear it's a Toolshed item.* Inside the
|
||||
app, in exports, in logs, anywhere the reader already knows they're looking at Toolshed data,
|
||||
the handle doesn't need to spell that out or be openable on its own. This can be as short as
|
||||
`user@domain.tld:id`, the item's owner handle with `:id` appended, mirroring how a tag/category
|
||||
handle already appends `:name` after its origin (see federation.md's Unique Handles section).
|
||||
No new delimiter concept, just the same pattern applied to items.
|
||||
|
||||
- *A self-contained URL, for use with no context at all.* A physical label, a link shared outside
|
||||
the app, has to work without the reader already knowing what it is or which server it belongs
|
||||
to, so it needs to open directly to the right frontend, resolve the right backend, and land on
|
||||
the right item. That means it has to encode the same information (owner handle + item id) as a
|
||||
full URL, e.g. `https://toolshed.webdomain.tld/i/alice@example.com/42`, note the owner's handle
|
||||
can be embedded in a path segment as-is (`@` doesn't need escaping in a URL path), which keeps it
|
||||
one segment shorter than splitting the handle back into `domain/user`, and means the handle is
|
||||
visible unmodified inside the link rather than reassembled from separate parts. If the owner is
|
||||
ever a group rather than a user (see groups.md), its handle carries a leading `#`, which does need
|
||||
the `+`-for-`#` substitution described in [handles-and-shortids.md](../handles-and-shortids.md)'s
|
||||
Handle syntax section before it can sit in a path segment, e.g.
|
||||
`https://toolshed.webdomain.tld/i/+climbing@example.com/42`. The frontend host
|
||||
in this URL (`toolshed.webdomain.tld`) doesn't have to be, and generally won't be, the backend
|
||||
authoritative for `example.com`, any frontend can resolve any handle (see federation.md's Servers
|
||||
subsection), so this is just whichever frontend happens to be handling the link, not part of the
|
||||
item's identity.
|
||||
|
||||
Both formats should stay as short as the encoded information allows, this matters most for the URL
|
||||
form, since it's the one that ends up in a QR code or printed label where physical size is a real
|
||||
constraint (see the labels goal above).
|
||||
|
||||
**How does this fit with the frontend's existing routes?**
|
||||
There's already a `/inventory/shared/:user/:id` route (`InventoryDetailForeign`), but today
|
||||
`:user` is just a bare username with no domain, i.e. it only works for a friend on the viewer's own
|
||||
domain, and the view itself doesn't yet do anything domain-aware with that param. A resolvable
|
||||
global handle needs the full `user@domain.tld` and a lookup step this route doesn't have yet. Two
|
||||
ways to reconcile that: extend the existing route to take a full handle in the `:user` segment
|
||||
(`/inventory/shared/alice@example.com/42`, no new route shape needed, just a richer meaning for the
|
||||
param it already has), or treat the short `/i/...` URL as a dedicated, minimal entry point whose
|
||||
only job is to resolve a handle and then hand off into whatever the richer in-app view ends up
|
||||
being. The two aren't mutually exclusive: the short form is what needs to be small enough to print,
|
||||
the in-app route doesn't have the same constraint and can stay more descriptive.
|
||||
|
||||
**What does scanning a label actually do?**
|
||||
Probably: the label encodes a URL or handle-like string; scanning it opens the frontend, which
|
||||
resolves the owner's domain the same way it resolves any other handle (see federation.md), and
|
||||
lands on that item. This reuses the discovery mechanism that already exists for logging in as a
|
||||
handle, rather than inventing a second one.
|
||||
|
||||
**Does resolving a label require authorization?**
|
||||
An item's availability policy already controls who can see it (owner-only if private, friends if
|
||||
shared, etc.). A label should presumably respect the same policy rather than being a backdoor that
|
||||
makes a private item visible to literally anyone who finds the physical object and scans its code.
|
||||
That means resolving a label isn't a free public lookup, it goes through the same friend/signature
|
||||
checks as everything else, which has UX implications (an anonymous finder of a lost tool can't
|
||||
necessarily see who it belongs to).
|
||||
|
||||
**Does the label need to be opaque/unguessable?**
|
||||
If item ids are small sequential integers, a label built from a guessable id lets anyone enumerate
|
||||
a user's items by scanning or guessing nearby numbers, even if each individual lookup is
|
||||
authorization-checked. Probably wants some amount of unguessability even before authorization is
|
||||
considered, as a defense-in-depth measure.
|
||||
|
||||
**What survives item changes?**
|
||||
Storage location, availability policy, name, and description can all change over the life of an
|
||||
object without it becoming a "different" item. The label should point at the handle, not at any of
|
||||
that mutable data, so none of those changes require a new label. The one thing that probably *does*
|
||||
need a decision is deletion: does a handle ever get reused, or is it retired for good once an item
|
||||
is deleted (retiring seems safer, avoids an old label resolving to an unrelated new item later)?
|
||||
|
||||
**Relationship to lending/borrowing.**
|
||||
A scannable label is the obvious hook for a future "mark as borrowed / returned" flow. Not solving
|
||||
that now, but the handle scheme chosen here should be able to carry that later without a redesign,
|
||||
i.e. it should be able to identify the item independent of who currently physically has it.
|
||||
|
|
@ -1,252 +0,0 @@
|
|||
# Tags, Properties & Categories (Design in Progress)
|
||||
|
||||
Status: partially implemented (the handle scheme and strict resolution described in federation.md
|
||||
exist today); this document is about the rough edges and open questions in that design, not a
|
||||
proposal to build it from scratch.
|
||||
|
||||
## Problem
|
||||
|
||||
Tags, properties, and categories are handles of the form `origin#type:name` (see federation.md's
|
||||
"Unique Handles" section), and a reference to a handle a server doesn't recognize is left
|
||||
unresolved rather than guessed at or merged. That strictness is deliberate and avoids silent data
|
||||
corruption, but it pushes a real cost onto users and creates some open problems:
|
||||
|
||||
- **Discoverability.** There's no way today to search or browse "what origins/taxonomies already
|
||||
exist" before creating a new tag. In practice this likely means people invent their own local
|
||||
tags for things a shared, canonical origin already covers, simply because they didn't know it
|
||||
existed.
|
||||
- **Fragmentation.** Because uniqueness is scoped per origin, nothing stops semantically identical
|
||||
concepts from existing under different names or different origins at once (`drill` vs.
|
||||
`power-drill`, one server's `origin:mytools` vs. another's `origin:community-tools`). Nothing
|
||||
reconciles these; they just coexist.
|
||||
- **No aliasing/synonyms.** If a user starts out with a local tag and later wants to adopt a shared
|
||||
origin's equivalent, there's no supported way to merge or alias the two; existing items keep
|
||||
pointing at the old handle.
|
||||
- **Trust and quality of shared origins.** An origin is "whatever the classification is considered
|
||||
to have come from," which could be an imported reference dataset. Nothing in the current design
|
||||
addresses who maintains such a dataset, how it's kept correct, or what happens when it changes
|
||||
(a category gets renamed or split upstream, e.g.).
|
||||
- **Localization.** A tag/category name is a single string. There's no notion of the same concept
|
||||
having a different display name per language while still resolving to the same handle.
|
||||
- **Property values aren't typed.** A property's value on an item is a plain string. A property
|
||||
definition can carry a unit (`unit_symbol`/`unit_name`), but nothing declares that its values are
|
||||
actually numbers meant to be read in that unit, versus, say, free text that happens to mention a
|
||||
unit. This is already a problem for one server in isolation (see the worked example below), and
|
||||
federation doesn't create it, it just means it now has to be solved consistently across origins
|
||||
instead of once.
|
||||
- **Handle collisions.** `origin` is a free-text string with nothing enforcing that it's actually
|
||||
unique to one definition. Fragmentation (above) is two different strings for the same thing;
|
||||
this is the more dangerous mirror image, the same handle string ending up attached to two
|
||||
different definitions, which is exactly the scenario the strict-resolution design was supposed
|
||||
to make impossible (see the second worked example below).
|
||||
|
||||
### Worked example: filtering by a numeric range across origins
|
||||
|
||||
Say two independently-run servers both end up with a property called "voltage," each under its own
|
||||
origin: `git:base#property:voltage` and `some-other-origin#property:voltage`. A user who's friends
|
||||
with people on both wants to filter their combined view of shared items by, say, `0 < voltage <
|
||||
4`. Two separate problems stack up:
|
||||
|
||||
1. **Are these the same property?** Per the fragmentation problem above, the two handles are, and
|
||||
stay, unrelated as far as the system is concerned, same name, different origin, no connection.
|
||||
A range filter built against one handle simply won't match items tagged with the other, even
|
||||
though a person looking at both would probably call them "the same thing."
|
||||
2. **Even if they were recognized as the same thing, are the values comparable?** A range filter
|
||||
needs actual numbers in a known unit. If one server's items store `"3.7"` and the other's store
|
||||
`"3700"` (volts vs. millivolts), a numeric comparison across the two silently produces nonsense
|
||||
unless the unit is known and converted. If either side stores the value as loosely-formatted
|
||||
text (`"3.7V"`, `"~3.7"`) rather than a bare number, it may not be reliably parseable as a
|
||||
number at all.
|
||||
|
||||
So a cross-origin range filter needs both an aliasing/equivalence answer (are `voltage` and
|
||||
`voltage` the same concept) and a units/typing answer (are their values actually numbers, in units
|
||||
that convert cleanly into each other), and the first being solved doesn't imply the second is.
|
||||
|
||||
### Worked example: the same handle meaning two different things
|
||||
|
||||
Say `git:ee2` names a specific reference dataset that started life in one shared git history, and
|
||||
two servers each imported it, at different times, from what has since become two diverging
|
||||
branches (or forks) of that history. Both servers now have a property whose handle is the exact
|
||||
same string, `git:ee2#property:charging_voltage`, but whose actual definition, say, unit, or
|
||||
dimensions has since diverged between the two branches. Neither server did anything wrong; each
|
||||
one faithfully imported "`git:ee2`" as it existed at the time.
|
||||
|
||||
This is a materially worse problem than the fragmentation/voltage example above. Fragmentation is
|
||||
a missed opportunity, two things that should be linked aren't, and the failure is visible (the
|
||||
filter just doesn't match as much as a person would expect). A handle collision is silent: nothing
|
||||
about the two servers exchanging data suggests anything is wrong, both sides say
|
||||
`git:ee2#property:charging_voltage`, so anything that trusts equal-handle-means-equal-definition
|
||||
(exactly what the strict-resolution design promises, and exactly what a filter, an alias, or a
|
||||
plain item-detail display would rely on) can silently combine or display incompatible values as if
|
||||
they were the same thing. This is the specific failure mode the whole handle design exists to
|
||||
prevent, so a scheme where it can still happen is a real gap, not just an inconvenience.
|
||||
|
||||
The root cause is that `origin` is a free-text label describing where something came from, not an
|
||||
identifier that's actually bound to a specific, fixed piece of content. A name like `git:ee2` reads
|
||||
as if it points to something immutable, but nothing about the origin field enforces that, `ee2`
|
||||
could easily be a branch or tag name rather than a specific commit, i.e. a pointer that can keep
|
||||
moving, and two importers pinned to it at different times without ever taking on a different
|
||||
handle to show for it.
|
||||
|
||||
This isn't hypothetical, it's exactly how the reference data included in this repository already
|
||||
works. The files in `backend/shared_data/` (`base.json`, `ee.json`, etc.) are origin datasets:
|
||||
`configure.py`'s import step sets `origin = "git:" + filename` for everything a file defines
|
||||
(`configure.py:119`), and a file can declare a fixed dependency on another one by name, e.g.
|
||||
`ee.json`'s `"depends": ["git:base"]`. The intent is clearly that these files are immutable once
|
||||
committed, and that a `depends` entry is a pin to a specific, settled parent, not a moving target,
|
||||
but nothing in the code enforces that today, it's a convention people are expected to follow.
|
||||
|
||||
There's already a piece of the machinery needed to enforce it, though: import already computes a
|
||||
sha256 of each file's raw content and stores it (`configure.py:125`, saved onto
|
||||
`ImportedIdentifierSets.hash`, which is `unique=True` alongside `name`, `hostadmin/models.py:15`).
|
||||
It's just not used for the thing it would be useful for, the "already imported, skipping" check
|
||||
(`configure.py:129-130`) matches on `name` alone, it never recomputes the hash of the file being
|
||||
imported and compares it against the hash already on record for that name. So even on a single
|
||||
server, an edited `ee.json` re-imported under its old filename wouldn't be noticed as a change,
|
||||
let alone flagged as a conflict, and the hash never leaves that server's own bookkeeping to be
|
||||
compared against what a friend server has on record for the same name.
|
||||
|
||||
## Goals
|
||||
|
||||
- Make it easy to find and reuse an existing origin/handle before inventing a new local one, to
|
||||
reduce fragmentation without weakening the strict-resolution guarantee that already exists.
|
||||
- Give users a path to move a locally-invented tag onto a shared origin later, without losing or
|
||||
having to manually re-tag their existing items.
|
||||
- Keep the core guarantee intact: a handle always means one specific, traceable thing, nothing
|
||||
should be implicitly merged or reinterpreted across origins.
|
||||
- Make that guarantee actually hold, not just assumed: two servers that both use a given handle
|
||||
should either really mean the same definition, or have some way to find out they don't, rather
|
||||
than the collision staying silent.
|
||||
|
||||
## Non-goals (for now)
|
||||
|
||||
- Building a moderation/governance system for shared origins. Worth thinking about, but a bigger
|
||||
problem than this document is trying to scope.
|
||||
- Free-text/fuzzy tag matching in search. Search UX can layer on top of resolved handles without
|
||||
changing what a handle means.
|
||||
|
||||
## Open design ideas
|
||||
|
||||
**A small set of well-known, shipped origins.**
|
||||
Toolshed could ship with one or a few canonical origins covering common tool/inventory categories
|
||||
out of the box, so that a fresh server already has a sensible baseline vocabulary to reuse instead
|
||||
of every server reinventing "drill," "screwdriver," "power tools," etc. independently. Doesn't
|
||||
solve fragmentation for everything, but raises the floor.
|
||||
|
||||
**Explicit aliasing rather than merging.**
|
||||
Rather than trying to detect and merge "equivalent" tags automatically (risky, exactly the kind of
|
||||
implicit behavior the strict-resolution design intentionally avoids), a tag could carry an explicit,
|
||||
user-initiated "supersedes"/"alias of" pointer to another handle. Items already tagged with the old
|
||||
handle could then be offered a one-time, explicit re-tag rather than a silent change of meaning.
|
||||
|
||||
For properties specifically, an alias needs to claim more than "these mean the same thing," it
|
||||
needs to claim the values are comparable, which means recording a unit conversion (possibly just
|
||||
"identical unit, factor 1") alongside the alias, not just a bare pointer. An alias with no stated
|
||||
conversion should probably be treated as "same concept, values not (yet) comparable," a range
|
||||
filter has no business guessing a conversion on its own.
|
||||
|
||||
**Filters default to per-handle, and only widen on an explicit alias.**
|
||||
Following directly from the strict-resolution philosophy in federation.md: a range filter should
|
||||
only ever combine two distinct property handles into one filterable facet because of an explicit
|
||||
alias (see above) that also states the values are comparable, never because their names or units
|
||||
happen to match. Absent that, two same-named properties from different origins should just show up
|
||||
as two separate filters, visibly distinct, rather than the UI silently guessing they're the same
|
||||
and producing a filter result that mixes incomparable values.
|
||||
|
||||
**Typed property values.**
|
||||
Giving a property definition a declared value type (number, text, boolean, ...) in addition to its
|
||||
existing unit metadata would let both the frontend and the alias/conversion mechanism above know
|
||||
whether "range filter" even applies to a given property, and would close the gap where a value
|
||||
happens to look numeric but isn't guaranteed to parse as one. This is useful even without
|
||||
federation in the picture, cross-origin comparison just makes the gap load-bearing instead of
|
||||
cosmetic.
|
||||
|
||||
**Origin metadata/versioning.**
|
||||
If an origin represents an imported dataset, giving it its own version or changelog would let a
|
||||
server know when the upstream taxonomy it imported has moved on, and decide explicitly whether to
|
||||
re-import, rather than silently drifting from what other servers using the "same" origin now have.
|
||||
This only helps if everyone's still on one shared timeline, though, it doesn't by itself address
|
||||
diverging forks/branches ending up with the same name (see below).
|
||||
|
||||
**Pin origins to immutable content, not movable names.**
|
||||
The `git:ee2` collision happens because the origin string names something mutable (a branch/tag)
|
||||
rather than something fixed. If an origin string were derived from the content itself, e.g. a hash
|
||||
of the definition, or a specific immutable commit rather than a branch, two independent imports
|
||||
could never end up with the same string unless the content was actually identical at that point,
|
||||
collisions would become structurally impossible rather than just unlikely. This is a bigger change
|
||||
than the versioning idea above: it's not tracking change over time, it's making the identifier
|
||||
itself incapable of silently referring to different things.
|
||||
|
||||
A concrete version of this: use the git blob hash of the file a property/tag/category was defined
|
||||
in as (part of) its origin. If the reference dataset already lives in a git repo, this is free,
|
||||
git's already computed it, and it's independently checkable, any server holding or able to fetch
|
||||
the same repo can recompute the hash from the content and confirm for itself, rather than trusting
|
||||
a label. This is a real improvement over free-text `origin` strings, but it isn't a free lunch:
|
||||
|
||||
- *Granularity.* A blob hash identifies a whole file, not a single property. If a file defines
|
||||
several properties together, editing any one of them changes every other property's "identity"
|
||||
in the same file too, even though nothing about them changed. Either definitions need to be
|
||||
one-per-file for the hash to mean what's intended, or the hash needs to cover just the relevant
|
||||
entry rather than the literal git blob.
|
||||
- *It converts every edit into a fork.* Since any change, including a typo fix, changes the hash,
|
||||
a routine upstream correction mechanically fragments what's still the same property into two
|
||||
handles. That's consistent with "never silently reinterpret a handle," but it means the aliasing
|
||||
mechanism above stops being a nice-to-have and becomes the primary upgrade path, every legitimate
|
||||
edit needs an explicit "supersedes" link, or old items are stranded on a stale, now-orphaned hash.
|
||||
- *A hash alone is an identity, not a location or a label.* It proves two things are the same (or
|
||||
aren't), but doesn't say where to fetch the content from if you don't already have it, and isn't
|
||||
human-readable. Pairing it with a location (which repo) and a mnemonic (which release/name it
|
||||
corresponds to) alongside the hash keeps the discoverability goal intact instead of trading it
|
||||
away for collision-proofing.
|
||||
|
||||
**Detect collisions on contact, as a backstop.**
|
||||
Even with better-behaved identifiers going forward, existing data and human-typed origin strings
|
||||
mean collisions can't be ruled out entirely. Whenever two servers interact over a handle they both
|
||||
claim to know (e.g. as part of resolving an alias, or federated search), comparing a fingerprint of
|
||||
the full definition, not just the handle string, would let a mismatch surface as an explicit
|
||||
conflict to resolve, rather than being silently trusted. This is the same instinct as the "explicit
|
||||
alias must state whether things are comparable" idea above, applied in the opposite direction, here
|
||||
the handles already match and the system needs to actively check whether that trust is warranted.
|
||||
|
||||
The needed ingredient already exists locally and just isn't being used this way: the sha256 hash
|
||||
already computed and stored per import (see above) is exactly the kind of definition fingerprint
|
||||
this needs. Two changes would make it actually do the job: first, comparing it on every import
|
||||
(including a "re-import" of a name already on record), not just recording it once, so a locally
|
||||
edited file gets caught before it's ever presented to anyone else, and second, exchanging it as
|
||||
part of whatever federated interaction references a shared-origin handle, so two servers can
|
||||
compare hashes for the same name and find out they've diverged instead of assuming they haven't.
|
||||
|
||||
**What this means for the handle actually on the wire.**
|
||||
Putting the above together, the everyday handle shouldn't change shape at all. It stays
|
||||
`origin#type:name`, e.g. `git:ee2#property:charging_voltage`, exactly as it is today. The reason is
|
||||
redundancy: this string is what appears on every single reference (every item's tag list, every
|
||||
property assignment), potentially many times per item across many items, while a hash only ever
|
||||
needs to be known once per origin. Carrying a full hash on every occurrence would repeat the same
|
||||
value over and over for no benefit beyond what knowing it once already provides.
|
||||
|
||||
Instead, the hash stays where it already lives, attached to the origin as a whole (extending
|
||||
`ImportedIdentifierSets`, see above), and gets exchanged at the points where two servers actually
|
||||
need to agree on one, e.g. the first time a friend's item references an origin a server doesn't
|
||||
already have a hash on record for. First contact just records it, same as resolving any unfamiliar
|
||||
handle today; a later mismatch against what's on record is the collision, and that's the point
|
||||
where it needs to become visible rather than silently trusted.
|
||||
|
||||
Only once a collision has actually been found does the wire format need to say more than
|
||||
`origin#type:name`, because at that point there genuinely are two different things sharing a name
|
||||
and something has to distinguish them for a person sorting it out. A short, abbreviated hash
|
||||
appended to the origin, the same idea git itself relies on for short commit hashes, keeps that
|
||||
escape hatch usable: `git:ee2~0f3a9c1e#property:charging_voltage` versus
|
||||
`git:ee2~7bc82a04#property:charging_voltage`. This longer form is exception-path plumbing for
|
||||
resolving an already-detected conflict, not something that changes the size or shape of handles in
|
||||
the common case.
|
||||
|
||||
**Search across known origins.**
|
||||
Before creating a new tag/category, a creation flow could search across origins the local server
|
||||
already knows about (its own, plus any it's imported) and surface likely existing matches. This is
|
||||
a UX/workflow fix rather than a change to the handle model itself, it doesn't need to touch
|
||||
resolution semantics at all.
|
||||
|
||||
**Display name vs. handle.**
|
||||
Separating "the name that appears in the handle" (stable, part of the identity) from "the label
|
||||
shown to a user" (translatable, cosmetic) would allow localization without affecting resolution or
|
||||
uniqueness, since resolution would stay keyed on the handle, not the display string.
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
# 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
|
||||
|
||||
- ~~`backend/authentication/models.py` (`class KnownIdentity`...), `signature_auth.py`
|
||||
(`author_identity`...), `frontend/src/identity.js` (`serializeIdentityRecord`...)~~ — no longer a
|
||||
finding: the glossary now has an explicit **Identity** entry (handle + keypair, held together as
|
||||
the unit a backend trusts), and this is exactly what these already name. No renaming needed here;
|
||||
if anything, these are the parts of the codebase the new Identity entry should point to as
|
||||
reference implementations.
|
||||
- `backend/toolshed/serializers.py:49-57` (`FriendSerializer`) — API field is literally named
|
||||
`"username"` but its value is a full handle (`username + '@' + domain`). Already
|
||||
self-acknowledged in a comment at `frontend/src/store.js:404`. **Highest-value single fix** —
|
||||
it's a live API contract, not just an internal name.
|
||||
- `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.
|
||||
|
||||
## Group / Group handle / Membership list
|
||||
|
||||
- `backend/backend/settings.py:36` — `django.contrib.auth` ships a built-in `Group` model, shown
|
||||
as "Groups" in the Django admin. Not unregistered anywhere. Will collide by name with the
|
||||
proposed actor-type Group once that's implemented — worth a decision now (unregister the
|
||||
built-in admin Group, or otherwise disambiguate) before the real feature lands.
|
||||
- `issues.md` (issue #3, "Group Concept", ~lines 62-156) — a standalone proposal that conflicts
|
||||
with the already-settled `docs/design-in-progress/groups.md` design on three points at once:
|
||||
- `Group.public_key`/`private_key` fields (contradicts "does a group need its own keypair? No").
|
||||
- `GroupMembership` backed by a signed `membership_certificate` rather than a plain membership
|
||||
list (contradicts the glossary's Membership list entry).
|
||||
- Bare `Group.handle` strings with no `#` prefix, e.g. `"makerspace-nord"`, and a
|
||||
`GroupInvitationIncoming.group_handle` field/API surface (`POST /api/groups/` etc., ~lines
|
||||
65-66, 117-121, 150-156) that never uses the `#groupname@domain` shape.
|
||||
This is a design-conflict issue, not a wording tweak — `issues.md` should be reconciled with (or
|
||||
explicitly marked superseded by) `groups.md` before anyone implements from it.
|
||||
|
||||
## 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:206-210` — Instance Admin TODO list: "identifier-sets" for **Origin** and bare
|
||||
"identifiers" for **Classification handle**, both alternate terms not matching glossary 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 Handle
|
||||
|
||||
- `frontend/src/views/Search.vue:30,52,110` — a field literally named `handle` is computed here
|
||||
(`e.owner==this.user ? e.id : "shared/"+e.owner+"/"+e.id`), but it's a router-path fragment, not
|
||||
an Item Handle: no domain-qualified `user@domain.tld:id` shape, and `e.owner` is a bare username.
|
||||
Whoever implements the real Item Handle later is likely to collide with this existing variable.
|
||||
|
||||
## 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").
|
||||
229
docs/glossary.md
229
docs/glossary.md
|
|
@ -1,229 +0,0 @@
|
|||
# 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), and [item handles](#item-handle) (proposed).
|
||||
*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** (Proposed)
|
||||
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).
|
||||
*See: [groups.md](design-in-progress/groups.md#what-a-group-is)*
|
||||
|
||||
**Group handle** (Proposed)
|
||||
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 item handles.
|
||||
*See: [federation.md](federation.md#cryptography)*
|
||||
|
||||
**Membership list** (Proposed)
|
||||
The record of which [user handles](#user-handle) currently belong to a [group](#group), maintained
|
||||
by whichever backend is authoritative for the group's handle. 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.
|
||||
*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 [item
|
||||
handle](#item-handle): 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, 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.
|
||||
*See: [federation.md](federation.md#tags-properties-and-categories), [tags.md](design-in-progress/tags.md)*
|
||||
|
||||
## Items & Physical Labels
|
||||
|
||||
**Item Handle** (Proposed)
|
||||
The compact identifier for a specific item that's meaningful outside its owner's own account:
|
||||
`user@domain.tld:id`, the owner's [user handle](#user-handle) plus a [local id](#local-id). Used
|
||||
where it's already clear from context that it's a Toolshed item, e.g. inside the app, in exports,
|
||||
in logs, so it doesn't need to spell that out or be openable on its own. Contrast with [Item
|
||||
URL](#item-url), the self-contained form for when no such context can be assumed.
|
||||
*See: [items-labels.md](design-in-progress/items-labels.md#open-design-questions)*
|
||||
|
||||
**Item Label** (Proposed)
|
||||
A physical, scannable encoding (QR code, barcode, or similar) of an item's [Item URL](#item-url),
|
||||
meant to be printed and stuck on the physical object it refers to.
|
||||
*See: [items-labels.md](design-in-progress/items-labels.md#goals)*
|
||||
|
||||
**Item URL** (Proposed)
|
||||
The self-contained URL form of an [Item Handle](#item-handle), for use with no context at all, e.g.
|
||||
an [Item Label](#item-label): `https://<any frontend>/i/user@domain.tld/id`. Has to open directly
|
||||
to the right frontend and land on the right item on its own, since the reader can't be assumed to
|
||||
already know what it is or which server it belongs to. Any frontend can serve this URL, the host
|
||||
named in it isn't part of the item's identity, only the handle in its path is.
|
||||
*See: [items-labels.md](design-in-progress/items-labels.md#open-design-questions)*
|
||||
|
||||
**Local id** (Implemented)
|
||||
An item's identifier as it exists today: unique only within its owner's own inventory, not
|
||||
meaningful outside that owner's account. The starting point both the [Item
|
||||
Handle](#item-handle) and [Item URL](#item-url) build on.
|
||||
*See: [federation.md](federation.md#items), [items-labels.md](design-in-progress/items-labels.md#problem)*
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
# 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, newer scheme for packing small integer
|
||||
id chains into a compact token, implemented in `frontend/src/short-id.js`.
|
||||
|
||||
## 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 id delimiter in a proposed Item Handle, the type/name delimiter in a
|
||||
classification handle, and the delimiter in a signed request's `Authorization` header), `+`
|
||||
(reserved as the URL-embedding escape for `#`, see below), `~` (the short-id token prefix, see
|
||||
Short IDs below, and a proposed collision-disambiguation suffix delimiter on a tag's origin), and
|
||||
`/` (the path-segment delimiter every handle and id ultimately sits next to once embedded in a
|
||||
URL). This has to be enforced by an explicit validator rather than left to Django's default
|
||||
`UnicodeUsernameValidator` (`^[\w.@+-]+\Z`), which currently permits both `@` and `+` (its own
|
||||
regex doesn't happen to allow `#`, `:`, or `/`, but that's incidental, not a designed restriction).
|
||||
|
||||
### 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.
|
||||
|
||||
Implemented in `frontend/src/handle-url.js` (`encodeHandleForUrl`/`decodeHandleFromUrl`).
|
||||
|
||||
## Short IDs
|
||||
|
||||
A general encoding for turning a small, fixed-shape list of integers into a compact, URL-safe
|
||||
token, with no server-side lookup table involved: the code *is* the data, nothing is stored
|
||||
server-side to make it resolvable. Originally proposed to answer items-labels.md's open "what does
|
||||
the handle/URL actually look like" question, but the encoding itself isn't item-specific; anything
|
||||
currently addressed by a short chain of small integers is a candidate. Implemented and tested in
|
||||
`frontend/src/short-id.js`; try it live at `/~<token>` (`frontend/src/views/ShortId.vue`), which
|
||||
decodes whatever token is in the URL and also lists worked examples for every registered kind.
|
||||
|
||||
### 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 | name | fields | notes |
|
||||
|---|---|---|---|
|
||||
| 0 | `item` | `owner_identity_id`, `item_local_id` | dominant case - the primary physical-label use case |
|
||||
| 1 | `storage_location` | `owner_identity_id`, `storage_location_id` | also label-printed |
|
||||
| 2 | `category` | `category_id` | label-adjacent (tagging); global, no owner |
|
||||
| 3 | `workflow` | `owner_identity_id`, `workflow_id` | shared in-app, not printed - first to pay the escape's cost |
|
||||
| 4 | `group` | `group_id` | shared even less often; global, no owner |
|
||||
| 5 | `file` | `file_id` | least often shared standalone; global, deduplicated by content hash |
|
||||
|
||||
`owner_identity_id` is `KnownIdentity.pk` (`backend/authentication/models.py`), not
|
||||
`ToolshedUser.pk`. Every local account already has exactly one stable `KnownIdentity` row
|
||||
(`ToolshedUser.public_identity`, created once at registration and never recreated), and every
|
||||
friend this backend knows about - local or remote - is represented by that same table, unique on
|
||||
`(username, domain)`. So one small integer already stands in for "this owner, as known by this
|
||||
backend" for both cases, with no separate local-vs-remote branching needed, and it's the same row
|
||||
federation.md's Cryptography section already treats as the trust anchor for a handle's public key.
|
||||
It appears on `item`, `storage_location`, and `workflow` because their backing models
|
||||
(`InventoryItem`, `StorageLocation`, `WorkflowInstance`) all FK `ToolshedUser` directly; `category`,
|
||||
`group`, and `file` skip it because their models are global/unscoped (`Group` has an unowned
|
||||
`members` M2M, `File` is deduplicated globally by content hash), so a bare row id is already
|
||||
everything needed to look them up.
|
||||
|
||||
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: the decoder
|
||||
always knows exactly how many integers a given kind calls for, and a chunk's continuation bit is
|
||||
`1 = more follows`, so a run of zero-padding at the very end can never be misread as "one more
|
||||
chunk" - it decodes as a terminated chunk, at which point every field the schema called for has
|
||||
already been produced and decoding simply stops. No `=` padding characters are needed either; those
|
||||
exist in classic base64 purely to communicate trailing-byte padding, and there is no byte layer
|
||||
here to need that.
|
||||
|
||||
### 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.
|
||||
|
||||
|
||||
### 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) the
|
||||
Examples table on `/~<token>` (`frontend/src/views/ShortId.vue`) shows for every registered kind:
|
||||
|
||||
| Kind | Fields | Serialized | Bits | Token |
|
||||
|---|---|---|---|---|
|
||||
| `item` | `owner_identity_id: 7`, `item_local_id: 42` | `[0, 7, 42]` | `00 00111 1001001010 0` | `~DyU` |
|
||||
| `storage_location` | `owner_identity_id: 3`, `storage_location_id: 1000` | `[1, 3, 1000]` | `01 00011 100111111001000 00` | `~Rz8g` |
|
||||
| `category` | `category_id: 5` | `[2, 5]` | `10 00101 00000` | `~ig` |
|
||||
| `workflow` | `owner_identity_id: 2`, `workflow_id: 9` | `[3, 2, 9]` | `11 00000 00010 01001 0` | `~wCS` |
|
||||
| `group` | `group_id: 11` | `[4, 11]` | `11 00001 01011` | `~wr` |
|
||||
| `file` | `file_id: 123` | `[5, 123]` | `11 00010 1011101011 0` | `~xXW` |
|
||||
|
||||
`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.
|
||||
|
|
@ -9,6 +9,5 @@ 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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
# Pixel fonts used for small label text (see ../../../scss/_pixel-fonts.scss)
|
||||
|
||||
- **Tom Thumb** (`TomThumb.ttf`) - by Brian Swetland, TTF conversion by gheja
|
||||
(https://github.com/gheja/tom-thumb-ttf). Licensed CC0 or CC-BY 3.0 (original:
|
||||
https://robey.lag.net/2010/01/23/tiny-monospace-font.html).
|
||||
- **PICO-8** (`PICO-8.ttf`) - reproduction by Jacob Pierce
|
||||
(https://github.com/jacobpierce/pico-8-font). MIT License, Copyright (c) 2016 Jacob Pierce.
|
||||
- **Silkscreen** (`Silkscreen-Regular.woff2`) - by Jason Kottke, served via Google Fonts
|
||||
(https://fonts.google.com/specimen/Silkscreen). SIL Open Font License 1.1.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -7,7 +7,7 @@
|
|||
<div class="template-grid d-flex flex-wrap align-items-start">
|
||||
<div v-for="t in labelTemplates" :key="t.id" class="template-option d-flex flex-column text-center"
|
||||
:class="{'template-option-disabled': !isAvailable(t)}"
|
||||
:title="isAvailable(t) ? '' : 'Not available - fill in the fields this layout needs above.'"
|
||||
:title="isAvailable(t) ? '' : 'Not available - open this page from an item to fill in the fields this layout needs.'"
|
||||
role="button" @click="isAvailable(t) && $emit('input', t.id)">
|
||||
<canvas :ref="el => setTemplateCanvasRef(t.id, el)"
|
||||
class="img-thumbnail template-thumb-canvas"
|
||||
|
|
@ -52,13 +52,12 @@
|
|||
</style>
|
||||
|
||||
<script>
|
||||
import {drawFallbackLabel} from "@/label.js";
|
||||
import {LABEL_TEMPLATES, templateIsAvailable, templateContent} from "@/label-layouts.js";
|
||||
import {LABEL_TEMPLATES, drawFallbackLabel, templateIsAvailable, templateContent} from "@/label-drawing.js";
|
||||
|
||||
export default {
|
||||
name: "LabelLayoutPreview",
|
||||
props: {
|
||||
// Named content fields the templates draw from (see label.js's buildLabelFields)
|
||||
// Named content fields the templates draw from (see label-content.js's buildLabelFields)
|
||||
// - kept in sync by the parent, not owned here. A field missing from this object (rather
|
||||
// than present-but-empty) means a template that needs it is unavailable right now.
|
||||
fields: {
|
||||
|
|
@ -110,7 +109,7 @@ export default {
|
|||
continue;
|
||||
}
|
||||
const content = templateContent(t, this.fields);
|
||||
if (!this.isAvailable(t) || !content) {
|
||||
if (!this.isAvailable(t) || (!content.qr && !content.text)) {
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
continue;
|
||||
|
|
|
|||
45
frontend/src/label-content.js
Normal file
45
frontend/src/label-content.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// Turns a {kind, components} prefill into the literal string a print label should show/encode.
|
||||
// Keeping this keyed by `kind` rather than having each caller build its own string means the
|
||||
// format for a given kind of label content only has to be gotten right in one place.
|
||||
export const LABEL_CONTENT_BUILDERS = {
|
||||
// The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a
|
||||
// printed label actually encodes, since scanning it has to resolve the right
|
||||
// frontend/backend/item with no other context, not just this browser's history.
|
||||
"item-url": ({user, id}) => `${window.location.origin}/i/${user}/${id}`,
|
||||
};
|
||||
|
||||
export function buildLabelContent(prefill) {
|
||||
if (!prefill) {
|
||||
return "";
|
||||
}
|
||||
const build = LABEL_CONTENT_BUILDERS[prefill.kind];
|
||||
return build ? build(prefill.components) : "";
|
||||
}
|
||||
|
||||
// Named fields the field-specific label templates (see label-drawing.js's LABEL_TEMPLATES) draw
|
||||
// from - keyed by `kind` for the same reason LABEL_CONTENT_BUILDERS is. A field missing from the
|
||||
// result (rather than present-but-empty) is what LabelLayoutPreview.vue treats as "not available",
|
||||
// so builders should only include a field once its inputs actually check out.
|
||||
const LABEL_FIELD_BUILDERS = {
|
||||
"item-url": ({user, id}) => {
|
||||
if (!user || !id) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
itemUrl: `${window.location.origin}/i/${user}/${id}`,
|
||||
// The compact "owner handle + id" form from docs/design-in-progress/items-labels.md -
|
||||
// meaningful only where context already makes clear it's a Toolshed item, unlike itemUrl.
|
||||
itemHandle: `${user}:${id}`,
|
||||
userHandle: user,
|
||||
itemId: String(id),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export function buildLabelFields(prefill) {
|
||||
if (!prefill) {
|
||||
return {};
|
||||
}
|
||||
const build = LABEL_FIELD_BUILDERS[prefill.kind];
|
||||
return build ? build(prefill.components) : {};
|
||||
}
|
||||
329
frontend/src/label-drawing.js
Normal file
329
frontend/src/label-drawing.js
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
import QRCode from "qrcode";
|
||||
|
||||
const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */
|
||||
/* A quiet zone narrower than the spec's usual 4 modules: the printer's own
|
||||
feed margin already keeps the code clear of the tape edge and the cut. */
|
||||
const QUIET_ZONE_MODULES = 2;
|
||||
|
||||
/* Sizing a label needs numbers only the driver can supply. */
|
||||
export function tapeFromStatus(status) {
|
||||
const printAreaPx = status?.tape?.printAreaPx;
|
||||
const dpi = status?.printer?.dpi;
|
||||
if (!(printAreaPx > 0) || !(dpi > 0)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
printAreaPx,
|
||||
dpi,
|
||||
mediaWidthMm: status.tape.mediaWidthMm,
|
||||
printLengthPx: status.tape.printLengthPx > 0 ? status.tape.printLengthPx : 0,
|
||||
/* Brother's documented margin for the mounted tape, in raster columns. */
|
||||
leadPx: status.tape.marginsMm
|
||||
? Math.round(status.tape.marginsMm * dpi / 25.4)
|
||||
: TRAILING_PADDING_PX,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
Draw the QR code as large as the tape allows, centered in a square, with an
|
||||
integer number of pixels per module so it stays crisp at printer
|
||||
resolution rather than blurring at a fractional scale.
|
||||
*/
|
||||
function drawQrLabel(canvas, qr, tape) {
|
||||
const modules = qr.modules.size + QUIET_ZONE_MODULES * 2;
|
||||
const maxLength = tape.printLengthPx
|
||||
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
|
||||
: Infinity;
|
||||
const scale = Math.floor(Math.min(tape.printAreaPx, maxLength) / modules);
|
||||
if (!(scale >= 1)) {
|
||||
throw new Error("This text needs a bigger QR code than the tape allows — "
|
||||
+ "try a shorter value or a wider tape.");
|
||||
}
|
||||
const square = modules * scale;
|
||||
|
||||
const width = tape.printLengthPx || (square + tape.leadPx + TRAILING_PADDING_PX);
|
||||
canvas.width = width;
|
||||
canvas.height = tape.printAreaPx;
|
||||
|
||||
const ctx = canvas.getContext("2d", {willReadFrequently: true});
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillRect(0, 0, width, canvas.height);
|
||||
|
||||
const left = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - square) / 2);
|
||||
const top = Math.floor((canvas.height - square) / 2);
|
||||
ctx.fillStyle = "#000";
|
||||
for (let row = 0; row < qr.modules.size; row++) {
|
||||
for (let col = 0; col < qr.modules.size; col++) {
|
||||
if (qr.modules.get(row, col)) {
|
||||
ctx.fillRect(
|
||||
left + (col + QUIET_ZONE_MODULES) * scale,
|
||||
top + (row + QUIET_ZONE_MODULES) * scale,
|
||||
scale, scale,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each template names which field (see label-content.js's buildLabelFields) feeds its QR code
|
||||
// and/or its printed text - `null` means that half of the layout is skipped. `text` can also be
|
||||
// an array of field names, one per printed line, for a stacked multi-line layout. A template is
|
||||
// only selectable once every field it names is actually available (see templateIsAvailable).
|
||||
export const LABEL_TEMPLATES = [
|
||||
{id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest.",
|
||||
qr: "value", text: null},
|
||||
{id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.",
|
||||
qr: "value", text: "value"},
|
||||
{id: "text", name: "Text only", description: "No code, just the text itself, as large as it fits.",
|
||||
qr: null, text: "value"},
|
||||
{id: "item-handle", name: "Item handle", qr: null, text: "itemHandle",
|
||||
description: "The compact owner@domain:id handle - meaningful in-app, not scannable on its own."},
|
||||
{id: "item-url", name: "Item URL", qr: null, text: "itemUrl",
|
||||
description: "The full item URL as text, with no code - for copying rather than scanning."},
|
||||
{id: "owner-handle", name: "Owner handle", qr: null, text: "userHandle",
|
||||
description: "Just the owning user's handle, as text only."},
|
||||
{id: "item-id", name: "Item ID", qr: null, text: "itemId",
|
||||
description: "Just the bare item id, as text only."},
|
||||
{id: "owner-id-text", name: "Owner + item ID", qr: null, text: ["userHandle", "itemId"],
|
||||
description: "The owner's handle and the item id, as two lines of text - no code."},
|
||||
{id: "item-url-qr-handle", name: "Item URL + handle", qr: "itemUrl", text: "itemHandle",
|
||||
description: "Scannable item URL, with the item's compact handle printed alongside."},
|
||||
{id: "item-url-qr-owner", name: "Item URL + owner", qr: "itemUrl", text: "userHandle",
|
||||
description: "Scannable item URL, with the owner's handle printed alongside."},
|
||||
{id: "item-url-qr-id", name: "Item URL + item ID", qr: "itemUrl", text: "itemId",
|
||||
description: "Scannable item URL, with the bare item id printed alongside."},
|
||||
{id: "item-url-qr-owner-id", name: "Item URL + owner + ID", qr: "itemUrl", text: ["userHandle", "itemId"],
|
||||
description: "Scannable item URL, with the owner's handle and the item id on two lines alongside."},
|
||||
];
|
||||
|
||||
// keysOf/templateIsAvailable/templateContent are the single place that understands the `qr`/
|
||||
// `text` field-name shape above (including `text` sometimes being an array) - both
|
||||
// LabelLayoutPreview.vue's thumbnail grid and Print.vue's big preview resolve a template through
|
||||
// these rather than each re-implementing the same lookup.
|
||||
function keysOf(spec) {
|
||||
if (!spec) {
|
||||
return [];
|
||||
}
|
||||
return Array.isArray(spec) ? spec : [spec];
|
||||
}
|
||||
|
||||
export function templateIsAvailable(t, fields) {
|
||||
return [...keysOf(t.qr), ...keysOf(t.text)].every(key => fields[key] !== undefined);
|
||||
}
|
||||
|
||||
/* Resolves a template's field names against actual field values. `text` comes back as an array
|
||||
whenever the template's `text` spec is an array (multi-line), or a plain string otherwise -
|
||||
drawLabel/drawFallbackLabel below accept either. */
|
||||
export function templateContent(t, fields) {
|
||||
const resolve = (spec) => {
|
||||
if (!spec) {
|
||||
return null;
|
||||
}
|
||||
return Array.isArray(spec) ? spec.map(key => fields[key]) : fields[spec];
|
||||
};
|
||||
return {qr: resolve(t.qr), text: resolve(t.text)};
|
||||
}
|
||||
|
||||
function measureAtHeight(ctx, text, px) {
|
||||
ctx.font = `${px}px sans-serif`;
|
||||
return ctx.measureText(text).width;
|
||||
}
|
||||
|
||||
/* Picks the largest integer font size (down to a floor) that fits every one of `lines` within
|
||||
maxWidth, stacked within maxHeight - this is a label, not a paragraph, so each line shrinks to
|
||||
fit rather than wrapping. */
|
||||
function fitTextSize(ctx, lines, maxWidth, maxHeight) {
|
||||
const minPx = 8;
|
||||
let px = Math.max(minPx, Math.floor(maxHeight / lines.length));
|
||||
while (px > minPx && lines.some(line => measureAtHeight(ctx, line, px) > maxWidth)) {
|
||||
px -= 1;
|
||||
}
|
||||
return px;
|
||||
}
|
||||
|
||||
/* The qr-text/text-only layouts, tape-fed. Kept separate from drawQrLabel above (rather than
|
||||
generalizing it) so the plain QR-only path - the common case - is untouched by this. */
|
||||
function drawLabelWithText(canvas, tape, qrContent, textContent) {
|
||||
const qr = qrContent ? QRCode.create(qrContent) : null;
|
||||
const lines = Array.isArray(textContent) ? textContent : [textContent];
|
||||
const availableHeight = tape.printAreaPx;
|
||||
const maxLength = tape.printLengthPx
|
||||
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
|
||||
: Infinity;
|
||||
|
||||
let scale = 0, qrSize = 0;
|
||||
if (qr) {
|
||||
const modules = qr.modules.size + QUIET_ZONE_MODULES * 2;
|
||||
// The QR only gets half the length budget on a fixed-length tape, so a long text value
|
||||
// can't starve it down to unreadable - the rest goes to the text next to it.
|
||||
const qrBudget = maxLength === Infinity ? Infinity : maxLength / 2;
|
||||
scale = Math.floor(Math.min(availableHeight, qrBudget) / modules);
|
||||
if (!(scale >= 1)) {
|
||||
throw new Error("This text needs a bigger QR code than the tape allows — "
|
||||
+ "try a shorter value, a wider tape, or the text-only layout.");
|
||||
}
|
||||
qrSize = modules * scale;
|
||||
}
|
||||
|
||||
const gap = qr ? Math.round(availableHeight * 0.15) : 0;
|
||||
const textBudget = maxLength === Infinity ? Infinity : maxLength - qrSize - gap;
|
||||
if (!(textBudget > 0)) {
|
||||
throw new Error("No room left for the text next to the QR code on this tape — "
|
||||
+ "try a wider tape or the QR-only layout.");
|
||||
}
|
||||
const measureCtx = canvas.getContext("2d");
|
||||
const textPx = fitTextSize(measureCtx, lines, textBudget, availableHeight);
|
||||
const textWidth = Math.max(...lines.map(line => measureAtHeight(measureCtx, line, textPx)));
|
||||
if (textBudget !== Infinity && textWidth > textBudget) {
|
||||
throw new Error("This text doesn't fit on this tape even at the smallest readable size — "
|
||||
+ "try a shorter value, a wider tape, or a bigger label.");
|
||||
}
|
||||
|
||||
const contentWidth = qrSize + gap + textWidth;
|
||||
const width = tape.printLengthPx || Math.ceil(contentWidth + tape.leadPx + TRAILING_PADDING_PX);
|
||||
canvas.width = width;
|
||||
canvas.height = availableHeight;
|
||||
|
||||
const ctx = canvas.getContext("2d", {willReadFrequently: true});
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillRect(0, 0, width, canvas.height);
|
||||
ctx.fillStyle = "#000";
|
||||
|
||||
let cursor = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - contentWidth) / 2);
|
||||
|
||||
if (qr) {
|
||||
const top = Math.floor((canvas.height - qrSize) / 2);
|
||||
for (let row = 0; row < qr.modules.size; row++) {
|
||||
for (let col = 0; col < qr.modules.size; col++) {
|
||||
if (qr.modules.get(row, col)) {
|
||||
ctx.fillRect(
|
||||
cursor + (col + QUIET_ZONE_MODULES) * scale,
|
||||
top + (row + QUIET_ZONE_MODULES) * scale,
|
||||
scale, scale,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor += qrSize + gap;
|
||||
}
|
||||
|
||||
ctx.font = `${textPx}px sans-serif`;
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.textAlign = "left";
|
||||
// Lines stack as a block vertically centered in the label, rather than each line centered on
|
||||
// its own - keeps a two-line block reading as one unit instead of drifting apart.
|
||||
const lineHeight = Math.ceil(textPx * 1.15);
|
||||
let y = (canvas.height - lineHeight * lines.length) / 2 + lineHeight / 2;
|
||||
for (const line of lines) {
|
||||
ctx.fillText(line, cursor, y);
|
||||
y += lineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dispatches to the right tape-fed layout - drawQrLabel is untouched so the plain QR-only
|
||||
layout keeps its exact original pixel output. `content` is {qr, text}, each either the string
|
||||
to encode/print or null/undefined to skip that half of the layout (see LABEL_TEMPLATES). */
|
||||
export function drawLabel(canvas, tape, content) {
|
||||
if (content.qr && !content.text) {
|
||||
drawQrLabel(canvas, QRCode.create(content.qr), tape);
|
||||
} else {
|
||||
drawLabelWithText(canvas, tape, content.qr, content.text);
|
||||
}
|
||||
}
|
||||
|
||||
const FALLBACK_SCALE_PX = 8; /* pixels per QR module in the no-webusb preview/PNG */
|
||||
const FALLBACK_QUIET_ZONE_MODULES = 4; /* the spec's usual quiet zone - there's no printer feed margin to lean on here */
|
||||
|
||||
/* Same idea as drawQrLabel, but without a real device to ask for tape dimensions: just a
|
||||
plain square QR code, sized for a PNG someone downloads and prints some other way. */
|
||||
function drawQrSquare(canvas, qr) {
|
||||
const modules = qr.modules.size + FALLBACK_QUIET_ZONE_MODULES * 2;
|
||||
const size = modules * FALLBACK_SCALE_PX;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
|
||||
const ctx = canvas.getContext("2d", {willReadFrequently: true});
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
ctx.fillStyle = "#000";
|
||||
for (let row = 0; row < qr.modules.size; row++) {
|
||||
for (let col = 0; col < qr.modules.size; col++) {
|
||||
if (qr.modules.get(row, col)) {
|
||||
ctx.fillRect(
|
||||
(col + FALLBACK_QUIET_ZONE_MODULES) * FALLBACK_SCALE_PX,
|
||||
(row + FALLBACK_QUIET_ZONE_MODULES) * FALLBACK_SCALE_PX,
|
||||
FALLBACK_SCALE_PX, FALLBACK_SCALE_PX,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const FALLBACK_LABEL_HEIGHT_PX = 200; /* target content height for the qr-text/text-only fallback layouts */
|
||||
const FALLBACK_TEXT_MARGIN_PX = 16; /* left/right margin around a text-only/qr-text fallback label */
|
||||
|
||||
/* The qr-text/text-only layouts for the no-webusb fallback preview/PNG. There's no real tape
|
||||
to fit into here, so - unlike drawLabelWithText - the canvas just grows to fit its content. */
|
||||
function drawFallbackLabelWithText(canvas, qrContent, textContent) {
|
||||
const qr = qrContent ? QRCode.create(qrContent) : null;
|
||||
const lines = Array.isArray(textContent) ? textContent : [textContent];
|
||||
|
||||
let scale = 0, qrSize = 0;
|
||||
if (qr) {
|
||||
const modules = qr.modules.size + FALLBACK_QUIET_ZONE_MODULES * 2;
|
||||
scale = Math.max(1, Math.floor(FALLBACK_LABEL_HEIGHT_PX / modules));
|
||||
qrSize = modules * scale;
|
||||
}
|
||||
|
||||
const gap = qr ? Math.round(FALLBACK_LABEL_HEIGHT_PX * 0.15) : 0;
|
||||
const measureCtx = canvas.getContext("2d");
|
||||
const textPx = fitTextSize(measureCtx, lines, Infinity, FALLBACK_LABEL_HEIGHT_PX);
|
||||
const textWidth = Math.max(...lines.map(line => measureAtHeight(measureCtx, line, textPx)));
|
||||
|
||||
const height = Math.max(qrSize, FALLBACK_LABEL_HEIGHT_PX);
|
||||
const width = qrSize + gap + textWidth + FALLBACK_TEXT_MARGIN_PX * 2;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext("2d", {willReadFrequently: true});
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
ctx.fillStyle = "#000";
|
||||
|
||||
let cursor = FALLBACK_TEXT_MARGIN_PX;
|
||||
if (qr) {
|
||||
const top = Math.floor((height - qrSize) / 2);
|
||||
for (let row = 0; row < qr.modules.size; row++) {
|
||||
for (let col = 0; col < qr.modules.size; col++) {
|
||||
if (qr.modules.get(row, col)) {
|
||||
ctx.fillRect(
|
||||
cursor + (col + FALLBACK_QUIET_ZONE_MODULES) * scale,
|
||||
top + (row + FALLBACK_QUIET_ZONE_MODULES) * scale,
|
||||
scale, scale,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor += qrSize + gap;
|
||||
}
|
||||
|
||||
ctx.font = `${textPx}px sans-serif`;
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.textAlign = "left";
|
||||
const lineHeight = Math.ceil(textPx * 1.15);
|
||||
let y = (height - lineHeight * lines.length) / 2 + lineHeight / 2;
|
||||
for (const line of lines) {
|
||||
ctx.fillText(line, cursor, y);
|
||||
y += lineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dispatches to the right fallback layout - drawQrSquare is untouched so the plain QR-only
|
||||
layout keeps its exact original pixel output. `content` is {qr, text}, see drawLabel above. */
|
||||
export function drawFallbackLabel(canvas, content) {
|
||||
if (content.qr && !content.text) {
|
||||
drawQrSquare(canvas, QRCode.create(content.qr));
|
||||
} else {
|
||||
drawFallbackLabelWithText(canvas, content.qr, content.text);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
// Each template's `layout` is a tree as described in label.js, with "qrcode"/"text" leaves'
|
||||
// `content` a function from the resolved field values (see label.js's buildLabelFields) to what
|
||||
// they render - `null`/`undefined` from that function means the field isn't available yet (see
|
||||
// templateIsAvailable below). A template is only selectable once every leaf's `content` resolves
|
||||
// to a value.
|
||||
const GAP = {type: "empty", "min-width": "1mm", "min-height": "1mm"};
|
||||
|
||||
export const LABEL_TEMPLATES = [
|
||||
{
|
||||
id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest.",
|
||||
required_vars: ["text"],
|
||||
layout: [{type: "qrcode", content: c => c.text}]
|
||||
},
|
||||
{
|
||||
id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.",
|
||||
required_vars: ["text"],
|
||||
layout: [{type: "qrcode", content: c => c.text}, GAP, {type: "text", content: c => c.text?.split("\n")}]
|
||||
},
|
||||
{
|
||||
id: "qr-text-below", name: "QR code + text below",
|
||||
description: "The code with the encoded text printed below it.",
|
||||
required_vars: ["text"],
|
||||
layout: [[{type: "qrcode", content: c => c.text}, GAP, {type: "text", content: c => c.text?.split("\n")}]]
|
||||
},
|
||||
{
|
||||
id: "id-qr-text-vertical", name: "ID + QR code + text below",
|
||||
description: "The code with the encoded text printed below it.",
|
||||
required_vars: ["itemId", "text", "userHandle"],
|
||||
layout: [[{type: "text", content: c => "Item: "+c.itemId}, GAP, {
|
||||
type: "qrcode",
|
||||
content: c => c.text
|
||||
}, GAP, {type: "text", content: c => c.userHandle}]]
|
||||
},
|
||||
{
|
||||
id: "text", name: "Text only", description: "No code, just the text itself, as large as it fits.",
|
||||
required_vars: ["text"],
|
||||
layout: [{type: "text", content: c => c.text?.split("\n")}]
|
||||
},
|
||||
{
|
||||
id: "item-handle", name: "Item handle",
|
||||
description: "The compact owner@domain:id handle - meaningful in-app, not scannable on its own.",
|
||||
required_vars: ["itemHandle"],
|
||||
layout: [{type: "text", content: c => c.itemHandle}]
|
||||
},
|
||||
{
|
||||
id: "item-url", name: "Item URL",
|
||||
description: "The full item URL as text, with no code - for copying rather than scanning.",
|
||||
required_vars: ["itemUrl"],
|
||||
layout: [{type: "text", content: c => c.itemUrl}]
|
||||
},
|
||||
{
|
||||
id: "owner-handle", name: "Owner handle", description: "Just the owning user's handle, as text only.",
|
||||
required_vars: ["userHandle"],
|
||||
layout: [{type: "text", content: c => c.userHandle}]
|
||||
},
|
||||
{
|
||||
id: "item-id", name: "Item ID", description: "Just the bare item id, as text only.",
|
||||
required_vars: ["itemId"],
|
||||
layout: [{type: "text", content: c => c.itemId}]
|
||||
},
|
||||
{
|
||||
id: "owner-id-text", name: "Owner + item ID",
|
||||
description: "The owner's handle and the item id, as two lines of text - no code.",
|
||||
required_vars: ["userHandle", "itemId"],
|
||||
layout: [{type: "text", content: c => [c.userHandle, c.itemId]}]
|
||||
},
|
||||
{
|
||||
id: "item-url-qr-handle", name: "Item URL + handle",
|
||||
description: "Scannable item URL, with the item's compact handle printed alongside.",
|
||||
required_vars: ["itemUrl", "itemHandle"],
|
||||
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.itemHandle}]
|
||||
},
|
||||
{
|
||||
id: "item-url-qr-owner", name: "Item URL + owner",
|
||||
description: "Scannable item URL, with the owner's handle printed alongside.",
|
||||
required_vars: ["itemUrl", "userHandle"],
|
||||
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.userHandle}]
|
||||
},
|
||||
{
|
||||
id: "item-url-qr-id", name: "Item URL + item ID",
|
||||
description: "Scannable item URL, with the bare item id printed alongside.",
|
||||
required_vars: ["itemUrl", "itemId"],
|
||||
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.itemId}]
|
||||
},
|
||||
{
|
||||
id: "item-url-qr-owner-id", name: "Item URL + owner + ID",
|
||||
description: "Scannable item URL, with the owner's handle and the item id on two lines alongside.",
|
||||
required_vars: ["itemUrl", "userHandle", "itemId"],
|
||||
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, {type: "text", content: c => [c.userHandle, c.itemId]}]
|
||||
},
|
||||
{
|
||||
id: "item-url-qr-owner-id2", name: "Item URL + owner + ID",
|
||||
description: "Scannable item URL, with the owner's handle and the item id on two lines alongside.",
|
||||
required_vars: ["itemUrl", "userHandle", "itemId"],
|
||||
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, [{
|
||||
type: "text",
|
||||
content: c => c.userHandle
|
||||
}, GAP, {type: "text", content: c => c.itemId}]]
|
||||
},
|
||||
];
|
||||
|
||||
// Every field name any template's required_vars names, in first-seen order.
|
||||
export const KNOWN_VARS = [...new Set(LABEL_TEMPLATES.flatMap(t => t.required_vars))];
|
||||
|
||||
// A derived var 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 below). `inputs` names every var (base or, in principle,
|
||||
// another derived one - see itemUrl/itemHandle below, which both read the derived userHandle)
|
||||
// `calc` reads - declared up front rather than inferred from calc's body so BASE_VARS below 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.
|
||||
export const DERIVED_VARS = {
|
||||
// The full owner handle (see federation.md's Unique Handles section / ToolshedUser's
|
||||
// separate username/domain columns) - kept as two base vars (user, domain) rather than one,
|
||||
// since that's how the account itself is actually shaped, with this just the display/URL form.
|
||||
userHandle: {
|
||||
inputs: ["user", "domain"],
|
||||
calc: (f) => `${f.user}@${f.domain}`,
|
||||
},
|
||||
// The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a printed
|
||||
// label actually encodes, since scanning it has to resolve the right frontend/backend/item
|
||||
// with no other context, not just this browser's history. `webdomain` defaults to this
|
||||
// browser's own origin (see Print.vue) but is editable, since any frontend can resolve any
|
||||
// handle - the label doesn't have to point back at whichever frontend happened to print it.
|
||||
itemUrl: {
|
||||
inputs: ["webdomain", "userHandle", "itemId"],
|
||||
calc: (f) => `${f.webdomain}/i/${f.userHandle}/${f.itemId}`,
|
||||
},
|
||||
// The compact "owner handle + id" form from docs/design-in-progress/items-labels.md -
|
||||
// meaningful only where context already makes clear it's a Toolshed item, unlike itemUrl.
|
||||
itemHandle: {
|
||||
inputs: ["userHandle", "itemId"],
|
||||
calc: (f) => `${f.userHandle}:${f.itemId}`,
|
||||
},
|
||||
};
|
||||
|
||||
// What Print.vue's content form offers a plain text input for: every KNOWN_VAR a template
|
||||
// references directly, minus the derived ones, plus every var a DERIVED_VARS calculation itself
|
||||
// needs (like "webdomain", which no template ever names). A template still lights up only once
|
||||
// every one of its own required_vars, base or derived, has a value (see templateIsAvailable
|
||||
// below).
|
||||
export const BASE_VARS = [...new Set([
|
||||
...KNOWN_VARS.filter(v => !(v in DERIVED_VARS)),
|
||||
...Object.values(DERIVED_VARS).flatMap(d => d.inputs).filter(v => !(v in DERIVED_VARS)),
|
||||
])];
|
||||
|
||||
// Runs every DERIVED_VARS calculation against `fields` (already holding the base vars - see
|
||||
// Print.vue), returning a copy with each one's result added wherever all of its own inputs are
|
||||
// present, so a caller never has to know DERIVED_VARS' internal {inputs, calc} shape.
|
||||
export function withDerivedVars(fields) {
|
||||
const result = {...fields};
|
||||
for (const [name, {inputs, calc}] of Object.entries(DERIVED_VARS)) {
|
||||
if (inputs.every(v => result[v])) {
|
||||
result[name] = calc(result);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function walkLeaves(node, fn) {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(child => walkLeaves(child, fn));
|
||||
} else {
|
||||
fn(node);
|
||||
}
|
||||
}
|
||||
|
||||
function mapTree(node, fn) {
|
||||
return Array.isArray(node) ? node.map(child => mapTree(child, fn)) : fn(node);
|
||||
}
|
||||
|
||||
// A content leaf's resolved value counts as present only if every part of it is - a single
|
||||
// string for "qrcode"/plain "text", every line for a multi-line "text" (see LABEL_TEMPLATES'
|
||||
// "owner-id-text" and "item-url-qr-owner-id").
|
||||
function isResolved(value) {
|
||||
return Array.isArray(value) ? value.every(isResolved) : value !== undefined && value !== null;
|
||||
}
|
||||
|
||||
// LabelLayoutPreview.vue's thumbnail grid and Print.vue's big preview both resolve a template
|
||||
// through these two functions rather than each re-implementing the leaf-walking/field-resolving
|
||||
// logic itself.
|
||||
export function templateIsAvailable(t, fields) {
|
||||
let available = true;
|
||||
walkLeaves(t.layout, leaf => {
|
||||
if (leaf.type !== "empty" && !isResolved(leaf.content(fields))) {
|
||||
available = false;
|
||||
}
|
||||
});
|
||||
return available;
|
||||
}
|
||||
|
||||
/* Resolves a template's `content` functions against actual field values, turning its layout
|
||||
tree into one ready for label.js's drawLabel/drawFallbackLabel - or null if there's nothing to
|
||||
render yet (every leaf's value is still empty, e.g. before the user has typed anything). */
|
||||
export function templateContent(t, fields) {
|
||||
const tree = mapTree(t.layout, leaf => leaf.type === "empty" ? leaf : {...leaf, value: leaf.content(fields)});
|
||||
let hasContent = false;
|
||||
walkLeaves(tree, leaf => {
|
||||
if (leaf.type !== "empty" && leaf.value && (!Array.isArray(leaf.value) || leaf.value.some(Boolean))) {
|
||||
hasContent = true;
|
||||
}
|
||||
});
|
||||
return hasContent ? tree : null;
|
||||
}
|
||||
|
|
@ -1,438 +0,0 @@
|
|||
import QRCode from "qrcode";
|
||||
import {encodeHandleForUrl} from "@/handle-url";
|
||||
|
||||
const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */
|
||||
|
||||
/* Sizing a label needs numbers only the driver can supply. */
|
||||
export function tapeFromStatus(status) {
|
||||
const printAreaPx = status?.tape?.printAreaPx;
|
||||
const dpi = status?.printer?.dpi;
|
||||
if (!(printAreaPx > 0) || !(dpi > 0)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
printAreaPx,
|
||||
dpi,
|
||||
mediaWidthMm: status.tape.mediaWidthMm,
|
||||
printLengthPx: status.tape.printLengthPx > 0 ? status.tape.printLengthPx : 0,
|
||||
/* Brother's documented margin for the mounted tape, in raster columns. */
|
||||
leadPx: status.tape.marginsMm
|
||||
? Math.round(status.tape.marginsMm * dpi / 25.4)
|
||||
: TRAILING_PADDING_PX,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
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: "qrcode", content} / {type: "text", content} draw a QR code
|
||||
or text block, where `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.
|
||||
*/
|
||||
const QR_ASPECT = 1; /* a QR code is always square */
|
||||
const TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural aspect ratio at */
|
||||
|
||||
// Below 10px, a general-purpose sans-serif gets blurry/illegible, so drawTextLeaf switches to
|
||||
// whichever of these bitmap-style fonts (see ../scss/_pixel-fonts.scss) is designed closest to -
|
||||
// and no smaller than - the box's actual effective size. Ordered smallest first; fontFamilyFor
|
||||
// below picks the first tier whose belowPx clears the requested size, sans-serif once none do.
|
||||
const PIXEL_FONT_TIERS = [
|
||||
{belowPx: 6, family: "Tom Thumb"}, /* designed for ~5px */
|
||||
{belowPx: 7, family: "PICO-8"}, /* designed for ~6px */
|
||||
{belowPx: 10, family: "Silkscreen"}, /* designed for ~7-8px */
|
||||
];
|
||||
|
||||
function fontFamilyFor(fontPx) {
|
||||
return PIXEL_FONT_TIERS.find(t => fontPx < t.belowPx)?.family ?? "sans-serif";
|
||||
}
|
||||
|
||||
// A pixel font (see PIXEL_FONT_TIERS above) is what makes a size down here still legible - a
|
||||
// plain sans-serif this small would fail the old MIN_READABLE_TEXT_PX=8 floor that predates them.
|
||||
// Below this, drawTextLeaf leaves that one field blank rather than drawing illegible ink - see
|
||||
// there for why that's a quieter failure than rejecting the whole label over it.
|
||||
const MIN_READABLE_TEXT_PX = 5;
|
||||
|
||||
function isSplit(node) {
|
||||
return Array.isArray(node);
|
||||
}
|
||||
|
||||
function parseMm(value, key) {
|
||||
const match = typeof value === "string" && /^([\d.]+)mm$/.exec(value);
|
||||
if (!match) {
|
||||
throw new Error(`An "empty" layout node needs a "${key}" like "2mm", got ${JSON.stringify(value)}.`);
|
||||
}
|
||||
return parseFloat(match[1]);
|
||||
}
|
||||
|
||||
/* 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 snapQrToCrispSize below: 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. */
|
||||
function relation(node, ownAxis, wantWidth, pxPerMm) {
|
||||
if (!isSplit(node)) {
|
||||
if (node.type === "qrcode" && node.crispSize !== undefined) {
|
||||
return {a: 0, b: node.crispSize};
|
||||
}
|
||||
if (node.type === "qrcode" || node.type === "text") {
|
||||
const aspect = node.aspect;
|
||||
return wantWidth ? {a: aspect, b: 0} : {a: 1 / aspect, b: 0};
|
||||
}
|
||||
const key = ownAxis ? "min-width" : "min-height";
|
||||
return {a: 0, b: parseMm(node[key], key) * pxPerMm};
|
||||
}
|
||||
|
||||
const axis = !ownAxis; // this split's own row(true)/column(false) axis
|
||||
const combinesAsWidth = axis; // a row sums widths for a shared height
|
||||
const parts = node.map(child => relation(child, axis, combinesAsWidth, pxPerMm));
|
||||
const a = parts.reduce((sum, p) => sum + p.a, 0);
|
||||
const b = parts.reduce((sum, p) => sum + p.b, 0);
|
||||
if (combinesAsWidth === wantWidth) {
|
||||
return {a, b};
|
||||
}
|
||||
return {a: 1 / a, b: -b / a}; // invert: solve the affine relation the other way
|
||||
}
|
||||
|
||||
/* Top-down: given the fixed (width, height) box `node` must exactly fill, assigns that box to
|
||||
it and, recursively, an appropriately-shaped box to every descendant. `ownAxis` carries the
|
||||
same meaning as in relation() above. */
|
||||
function layoutTree(node, ownAxis, width, height, pxPerMm) {
|
||||
node.box = {width, height};
|
||||
if (!isSplit(node)) {
|
||||
return;
|
||||
}
|
||||
const axis = !ownAxis;
|
||||
for (const child of node) {
|
||||
if (axis) {
|
||||
const {a, b} = relation(child, axis, true, pxPerMm);
|
||||
layoutTree(child, axis, a * height + b, height, pxPerMm);
|
||||
} else {
|
||||
const {a, b} = relation(child, axis, false, pxPerMm);
|
||||
layoutTree(child, axis, width, a * width + b, pxPerMm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Second top-down pass: turns each node's already-sized box into an absolute (x, y) position,
|
||||
placing a row's children left to right and a column's top to bottom. Kept separate from
|
||||
layoutTree since a node's size doesn't depend on its position, only on its box dimensions. */
|
||||
function positionTree(node, ownAxis, x, y) {
|
||||
node.box.x = x;
|
||||
node.box.y = y;
|
||||
if (!isSplit(node)) {
|
||||
return;
|
||||
}
|
||||
const axis = !ownAxis;
|
||||
let cursor = axis ? x : y;
|
||||
for (const child of node) {
|
||||
if (axis) {
|
||||
positionTree(child, axis, cursor, y);
|
||||
cursor += child.box.width;
|
||||
} else {
|
||||
positionTree(child, axis, x, cursor);
|
||||
cursor += child.box.height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 square a bare aspect ratio of 1 would suggest. Called once every qrcode leaf has a
|
||||
provisional (scale-free) box from a first layoutTree pass, this pins each one's real box.width
|
||||
(in row context) or box.height (in column context - whichever axis its box doesn't already
|
||||
share with its siblings) as `crispSize`, so relation() above 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 layoutContent) then resizes everything
|
||||
else around that real footprint, so nothing downstream reserves - and leaves unfilled - room
|
||||
for a squarer QR code than what actually gets drawn. */
|
||||
function snapQrToCrispSize(node) {
|
||||
if (isSplit(node)) {
|
||||
node.forEach(snapQrToCrispSize);
|
||||
return;
|
||||
}
|
||||
if (node.type === "qrcode") {
|
||||
const modules = node.qr.modules.size;
|
||||
const scale = Math.floor(Math.min(node.box.width, node.box.height) / modules);
|
||||
if (!(scale >= 1)) {
|
||||
throw new Error("This text needs a bigger QR code than the tape allows — "
|
||||
+ "try a shorter value or a wider tape.");
|
||||
}
|
||||
node.crispSize = modules * scale;
|
||||
}
|
||||
}
|
||||
|
||||
// Always measures in sans-serif at the fixed reference size, even though drawTextLeaf may end up
|
||||
// actually drawing in one of PIXEL_FONT_TIERS' fonts - the final effective size (and so which
|
||||
// font applies) isn't known until layoutTree has already sized the box this aspect ratio feeds
|
||||
// into. The pixel fonts are close enough in proportion for basic Latin/digits that the tiny
|
||||
// resulting mismatch is invisible in practice at these sizes, and the MIN_READABLE_TEXT_PX check
|
||||
// still catches anything that genuinely doesn't fit.
|
||||
function measureTextBlock(ctx, lines, referencePx) {
|
||||
ctx.font = `${referencePx}px sans-serif`;
|
||||
const width = Math.max(...lines.map(line => ctx.measureText(line).width));
|
||||
const height = referencePx * 1.15 * lines.length;
|
||||
return {width, height};
|
||||
}
|
||||
|
||||
/* Turns a resolved content tree (see templateContent below - leaf objects carry a `value`
|
||||
rather than a `content` function) into one ready for layout: a QR leaf gets its actual
|
||||
QRCode.create() object, 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. */
|
||||
function buildRenderTree(ctx, node, referencePx) {
|
||||
if (isSplit(node)) {
|
||||
return node.map(child => buildRenderTree(ctx, child, referencePx));
|
||||
}
|
||||
if (node.type === "qrcode") {
|
||||
return {type: "qrcode", aspect: QR_ASPECT, qr: QRCode.create(node.value)};
|
||||
}
|
||||
if (node.type === "text") {
|
||||
const lines = Array.isArray(node.value) ? node.value : [node.value];
|
||||
const {width, height} = measureTextBlock(ctx, lines, referencePx);
|
||||
return {type: "text", aspect: width / height, naturalHeight: height, lines};
|
||||
}
|
||||
return {type: "empty", "min-width": node["min-width"], "min-height": node["min-height"]};
|
||||
}
|
||||
|
||||
function drawQrLeaf(ctx, node) {
|
||||
const modules = node.qr.modules.size;
|
||||
const scale = Math.floor(Math.min(node.box.width, node.box.height) / modules);
|
||||
const size = modules * scale;
|
||||
const left = node.box.x + Math.floor((node.box.width - size) / 2);
|
||||
const top = node.box.y + Math.floor((node.box.height - size) / 2);
|
||||
for (let row = 0; row < modules; row++) {
|
||||
for (let col = 0; col < modules; col++) {
|
||||
if (node.qr.modules.get(row, col)) {
|
||||
ctx.fillRect(left + col * scale, top + row * scale, scale, scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the effective font size drawn at (or that would have been, if it's too small to draw -
|
||||
// see below) - drawTree collects these into drawLabel/drawFallbackLabel's textSizesPx.
|
||||
function drawTextLeaf(ctx, node, referencePx) {
|
||||
const fontPx = referencePx * (node.box.height / node.naturalHeight);
|
||||
// Even the smallest PIXEL_FONT_TIERS entry stops being legible below this - rather than
|
||||
// reject the whole label over one field that's too small (the old behavior), just leave this
|
||||
// leaf blank; its box was already accounted for, so nothing else in the layout shifts.
|
||||
if (fontPx < MIN_READABLE_TEXT_PX) {
|
||||
return fontPx;
|
||||
}
|
||||
const lineHeight = node.box.height / node.lines.length;
|
||||
const family = fontFamilyFor(fontPx);
|
||||
ctx.font = `${fontPx}px "${family}"`;
|
||||
// A @font-face family already in use elsewhere on the page loads in time for this, 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 the load
|
||||
// here 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.
|
||||
if (family !== "sans-serif") {
|
||||
document.fonts.load(ctx.font);
|
||||
}
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.textAlign = "center";
|
||||
const centerX = node.box.x + node.box.width / 2;
|
||||
// Lines stack as a block, each centered under the last - keeps a multi-line field reading as
|
||||
// one unit rather than drifting apart.
|
||||
let y = node.box.y + lineHeight / 2;
|
||||
for (const line of node.lines) {
|
||||
ctx.fillText(line, centerX, y);
|
||||
y += lineHeight;
|
||||
}
|
||||
return fontPx;
|
||||
}
|
||||
|
||||
// Flip this to true (in a debugger or a local edit) to outline every leaf's box - including
|
||||
// "empty" ones, normally invisible - in a color that can't be mistaken for real label ink. Handy
|
||||
// for checking a layout's actual padding/alignment; never wanted on a real printed label, so it's
|
||||
// a manual toggle rather than something wired up to any UI.
|
||||
let DEBUG_LEAF_BORDERS = false;
|
||||
|
||||
function drawDebugBorder(ctx, node) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "red";
|
||||
ctx.lineWidth = 1;
|
||||
// Inset by half a pixel so the 1px stroke lands crisply on-pixel instead of straddling the
|
||||
// box edge and rendering as a blurry 2px line.
|
||||
ctx.strokeRect(node.box.x + 0.5, node.box.y + 0.5, node.box.width - 1, node.box.height - 1);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// `textSizesPx` collects each "text" leaf's effective font size as drawTree walks the tree - see
|
||||
// drawLabel/drawFallbackLabel, which hand it back to the caller (Print.vue shows it alongside the
|
||||
// tape width) so a field rendering blank (see drawTextLeaf's MIN_READABLE_TEXT_PX check) shows up
|
||||
// as a suspiciously small size here rather than just silently not being there.
|
||||
function drawTree(ctx, node, referencePx, textSizesPx) {
|
||||
if (isSplit(node)) {
|
||||
node.forEach(child => drawTree(ctx, child, referencePx, textSizesPx));
|
||||
return;
|
||||
}
|
||||
if (node.type === "qrcode") {
|
||||
drawQrLeaf(ctx, node);
|
||||
} else if (node.type === "text") {
|
||||
textSizesPx.push(drawTextLeaf(ctx, node, referencePx));
|
||||
}
|
||||
// "empty" leaves carry no ink - their box just reserves the space.
|
||||
if (DEBUG_LEAF_BORDERS) {
|
||||
drawDebugBorder(ctx, node);
|
||||
}
|
||||
}
|
||||
|
||||
/* Builds, sizes and validates the tree for a fixed `height` (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. `height` and the tree's content
|
||||
fully determine its overall width; `maxLength`, when finite (a fixed-length/die-cut tape),
|
||||
rejects content that doesn't fit rather than shrinking it.
|
||||
|
||||
Sizing runs twice: a first pass treats every qrcode leaf as the scale-free square its aspect
|
||||
ratio of 1 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
|
||||
width reflect what's actually drawn rather than the idealized square no QR code ever quite
|
||||
fills. */
|
||||
function layoutContent(ctx, content, height, maxLength, referencePx, pxPerMm) {
|
||||
const tree = buildRenderTree(ctx, content, referencePx);
|
||||
|
||||
const measured = relation(tree, false, true, pxPerMm);
|
||||
layoutTree(tree, false, measured.a * height + measured.b, height, pxPerMm);
|
||||
snapQrToCrispSize(tree);
|
||||
|
||||
const {a, b} = relation(tree, false, true, pxPerMm);
|
||||
const width = a * height + b;
|
||||
if (maxLength !== Infinity && width > maxLength) {
|
||||
throw new Error("This doesn't fit on this tape — "
|
||||
+ "try a shorter value, a different layout, or a bigger label.");
|
||||
}
|
||||
layoutTree(tree, false, width, height, pxPerMm);
|
||||
return {tree, width};
|
||||
}
|
||||
|
||||
/* The tape-fed layout - draws a fully resolved content tree (see templateContent) at the tape's
|
||||
real pixel dimensions. See DEBUG_LEAF_BORDERS above to outline every leaf's box. Returns
|
||||
{textSizesPx}: each "text" leaf's effective font size, in the tree's own left-to-right,
|
||||
top-to-bottom order. */
|
||||
export function drawLabel(canvas, tape, content) {
|
||||
const maxLength = tape.printLengthPx
|
||||
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
|
||||
: Infinity;
|
||||
const measureCtx = canvas.getContext("2d");
|
||||
const pxPerMm = tape.dpi / 25.4;
|
||||
const {tree, width: contentWidth} = layoutContent(
|
||||
measureCtx, content, tape.printAreaPx, maxLength, TEXT_REFERENCE_PX, pxPerMm);
|
||||
|
||||
const width = tape.printLengthPx || Math.ceil(contentWidth + tape.leadPx + TRAILING_PADDING_PX);
|
||||
canvas.width = width;
|
||||
canvas.height = tape.printAreaPx;
|
||||
|
||||
const ctx = canvas.getContext("2d", {willReadFrequently: true});
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillRect(0, 0, width, canvas.height);
|
||||
ctx.fillStyle = "#000";
|
||||
|
||||
const originX = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - contentWidth) / 2);
|
||||
positionTree(tree, false, originX, 0);
|
||||
const textSizesPx = [];
|
||||
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
|
||||
return {textSizesPx};
|
||||
}
|
||||
|
||||
const FALLBACK_LABEL_HEIGHT_PX = 200; /* reference height the no-webusb preview/PNG scales from */
|
||||
const FALLBACK_DPI = 203; /* reference resolution for turning "empty" leaves' mm sizes into px */
|
||||
|
||||
/* 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. Returns {textSizesPx}, see drawLabel. */
|
||||
export function drawFallbackLabel(canvas, content) {
|
||||
const measureCtx = canvas.getContext("2d");
|
||||
const pxPerMm = FALLBACK_DPI / 25.4;
|
||||
const {tree, width: contentWidth} = layoutContent(
|
||||
measureCtx, content, FALLBACK_LABEL_HEIGHT_PX, Infinity, TEXT_REFERENCE_PX, pxPerMm);
|
||||
|
||||
canvas.width = Math.ceil(contentWidth);
|
||||
canvas.height = FALLBACK_LABEL_HEIGHT_PX;
|
||||
|
||||
const ctx = canvas.getContext("2d", {willReadFrequently: true});
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = "#000";
|
||||
|
||||
positionTree(tree, false, 0, 0);
|
||||
const textSizesPx = [];
|
||||
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
|
||||
return {textSizesPx};
|
||||
}
|
||||
|
||||
// Turns a {kind, components} prefill (see Print.vue's `prefill` prop) into the literal string a
|
||||
// print label should show/encode. Keeping this keyed by `kind` rather than having each caller
|
||||
// build its own string means the format for a given kind of label content only has to be gotten
|
||||
// right in one place.
|
||||
export const LABEL_CONTENT_BUILDERS = {
|
||||
// The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a
|
||||
// printed label actually encodes, since scanning it has to resolve the right
|
||||
// frontend/backend/item with no other context, not just this browser's history.
|
||||
"item-url": ({user, id}) => `${window.location.origin}/i/${encodeHandleForUrl(user)}/${id}`,
|
||||
};
|
||||
|
||||
export function buildLabelContent(prefill) {
|
||||
if (!prefill) {
|
||||
return "";
|
||||
}
|
||||
const build = LABEL_CONTENT_BUILDERS[prefill.kind];
|
||||
return build ? build(prefill.components) : "";
|
||||
}
|
||||
|
||||
// Seeds for the *base* label-layouts.js vars (see BASE_VARS there) - keyed by `kind` for the same
|
||||
// reason LABEL_CONTENT_BUILDERS is. Format-string vars derived from these (itemUrl, itemHandle)
|
||||
// aren't built here; they're calculated live from whatever the base vars currently are (see
|
||||
// label-layouts.js's DERIVED_VARS), prefill or hand-typed alike. A field missing from the result
|
||||
// (rather than present-but-empty) is what label-layouts.js's templateIsAvailable treats as "not
|
||||
// available", so builders should only include a field once its inputs actually check out.
|
||||
const LABEL_FIELD_BUILDERS = {
|
||||
// `user` here is already a full "user@domain" handle (that's the form login usernames take -
|
||||
// see Login.vue/store.js), so it's split into label-layouts.js's separate `user`/`domain`
|
||||
// base vars the same way store.js's own lookupServer does, rather than stuffing the whole
|
||||
// handle into one field the way userHandle (now derived from these two) used to be.
|
||||
"item-url": ({user, id}) => {
|
||||
if (!user || !id) {
|
||||
return {};
|
||||
}
|
||||
const at = user.indexOf("@");
|
||||
return {
|
||||
user: at === -1 ? user : user.slice(0, at),
|
||||
domain: at === -1 ? "" : user.slice(at + 1),
|
||||
itemId: String(id),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export function buildLabelFields(prefill) {
|
||||
if (!prefill) {
|
||||
return {};
|
||||
}
|
||||
const build = LABEL_FIELD_BUILDERS[prefill.kind];
|
||||
return build ? build(prefill.components) : {};
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
// Specialized bitmap-style fonts label.js's drawTextLeaf switches to below an effective text
|
||||
// size of 10px, where a general-purpose sans-serif gets blurry/illegible - each is designed for
|
||||
// (and named after) roughly the pixel size it's used at. See
|
||||
// ../assets/fonts/pixel/LICENSE.md for sources/licenses.
|
||||
@font-face {
|
||||
font-family: "Tom Thumb";
|
||||
src: url("../assets/fonts/pixel/TomThumb.ttf") format("truetype");
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "PICO-8";
|
||||
src: url("../assets/fonts/pixel/PICO-8.ttf") format("truetype");
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Silkscreen";
|
||||
src: url("../assets/fonts/pixel/Silkscreen-Regular.woff2") format("woff2");
|
||||
font-display: block;
|
||||
}
|
||||
|
|
@ -92,7 +92,6 @@ $body-color: $gray-700;
|
|||
@import "forms";
|
||||
@import "tags";
|
||||
@import "dropdown";
|
||||
@import "pixel-fonts";
|
||||
|
||||
#root, body, html {
|
||||
height: 100%;
|
||||
|
|
|
|||
|
|
@ -40,9 +40,6 @@
|
|||
<a :href="`/inventory/${item.id}/delete`" @click.prevent="deleteInventoryItem(item)">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
</a>
|
||||
<router-link :to="shortIdLink(item)">
|
||||
<b-icon-link></b-icon-link>
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
@ -68,14 +65,11 @@
|
|||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-danger btn-sm"
|
||||
@click="deleteInventoryItem(item)">Delete
|
||||
@click="deleteInventoryItem(item.id)">Delete
|
||||
</button>
|
||||
<router-link :to="`/inventory/${item.id}/edit`"
|
||||
class="btn btn-primary btn-sm">Edit
|
||||
</router-link>
|
||||
<router-link :to="shortIdLink(item)" class="btn btn-secondary btn-sm">
|
||||
<b-icon-link></b-icon-link>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -100,7 +94,6 @@ import {mapActions, mapGetters, mapMutations, mapState} from "vuex";
|
|||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import AuthenticatedImage from "../components/AuthenticatedImage.vue";
|
||||
import {encodeShortId, serializeShortId} from "@/short-id";
|
||||
|
||||
export default {
|
||||
name: "Inventory",
|
||||
|
|
@ -127,13 +120,6 @@ export default {
|
|||
const loc = this.storage_locations.find(loc => loc.id === item.storage_location)
|
||||
return loc ? loc.path : null
|
||||
},
|
||||
shortIdLink(item) {
|
||||
// owner_identity_id is a placeholder until the backend exposes a KnownIdentity pk
|
||||
// per item (see docs/handles-and-shortids.md) - it doesn't decode to a
|
||||
// meaningful owner yet.
|
||||
const ints = serializeShortId({kind: 'item', owner_identity_id: 0, item_local_id: item.id});
|
||||
return '/' + encodeShortId(ints);
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
await this.fetchInventoryItems()
|
||||
|
|
|
|||
|
|
@ -12,40 +12,6 @@
|
|||
alternatives below.
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-lg-8">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Label content</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div :class="v === 'text' ? 'col-12' : 'col-md-6'" v-for="v in baseVars" :key="v">
|
||||
<label class="form-label">{{ varLabel(v) }}</label>
|
||||
<textarea v-if="v === 'text'" class="form-control" rows="3"
|
||||
v-model="varValues[v]"
|
||||
placeholder="https://example.com/…"></textarea>
|
||||
<input v-else type="text" class="form-control" v-model="varValues[v]">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Calculated</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-for="v in derivedVars" :key="v" class="mb-2">
|
||||
<div class="small text-muted">{{ varLabel(v) }}</div>
|
||||
<div class="text-break">{{ fields[v] || "—" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!usbSupported" class="row">
|
||||
<div class="col-lg-7">
|
||||
<div class="card">
|
||||
|
|
@ -53,7 +19,13 @@
|
|||
<h5 class="card-title mb-0">Label preview</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="label-preview mb-3" v-show="selectedContent">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">QR code content</label>
|
||||
<input type="text" class="form-control" v-model="value"
|
||||
placeholder="https://example.com/…" autofocus>
|
||||
</div>
|
||||
|
||||
<div class="label-preview mb-3" v-show="selectedContent.qr || selectedContent.text">
|
||||
<canvas ref="fallbackCanvas"></canvas>
|
||||
</div>
|
||||
|
||||
|
|
@ -88,47 +60,21 @@
|
|||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0">Label</h5>
|
||||
<small v-if="tape" class="text-muted">{{ tape.mediaWidthMm }} mm tape{{ textSizesSummary }}</small>
|
||||
<small v-if="tape" class="text-muted">{{ tape.mediaWidthMm }} mm tape</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p v-if="!tape" class="text-muted">
|
||||
Connect a printer to preview and print a label.
|
||||
</p>
|
||||
<template v-else>
|
||||
<div class="preview-row mb-3" v-show="selectedContent">
|
||||
<div class="ruler-v">
|
||||
<div class="ruler-v-corner"></div>
|
||||
<div class="ruler ruler-v-ticks"
|
||||
:style="{height: (tape.mediaWidthMm * tapePxPerMm) + 'px'}">
|
||||
<span v-for="t in verticalRulerTicks" :key="'tick-' + t.mm"
|
||||
class="tick" :class="{'tick-major': t.major}"
|
||||
:style="{top: t.pos + 'px'}"></span>
|
||||
<span v-for="t in verticalRulerTicks.filter(t => t.major)"
|
||||
:key="'label-' + t.mm" class="tick-label"
|
||||
:style="{top: t.pos + 'px'}">{{ t.mm }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-track">
|
||||
<div class="ruler ruler-h"
|
||||
:style="{width: (printedWidthPx * zoom) + 'px'}">
|
||||
<span v-for="t in horizontalRulerTicks" :key="'tick-' + t.mm"
|
||||
class="tick" :class="{'tick-major': t.major}"
|
||||
:style="{left: t.pos + 'px'}"></span>
|
||||
<span v-for="t in horizontalRulerTicks.filter(t => t.major)"
|
||||
:key="'label-' + t.mm" class="tick-label"
|
||||
:style="{left: t.pos + 'px'}">{{ t.mm }}</span>
|
||||
</div>
|
||||
<!-- The tape's full physical width, printable area included - the
|
||||
print head can't mark all the way to the tape's outer edges, so
|
||||
the canvas (printAreaPx tall) is narrower than this and centered
|
||||
within it; the rest is real, if unprintable, tape margin. -->
|
||||
<div class="tape-full"
|
||||
:style="{height: (tape.mediaWidthMm * tapePxPerMm) + 'px'}">
|
||||
<div class="label-preview">
|
||||
<canvas ref="labelCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">QR code content</label>
|
||||
<input type="text" class="form-control" v-model="value"
|
||||
placeholder="https://example.com/…" autofocus>
|
||||
</div>
|
||||
|
||||
<div class="label-preview mb-3" v-show="selectedContent.qr || selectedContent.text">
|
||||
<canvas ref="labelCanvas"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 align-items-end">
|
||||
|
|
@ -203,8 +149,8 @@ import BaseLayout from "@/components/BaseLayout.vue";
|
|||
import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
|
||||
|
||||
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
|
||||
import {tapeFromStatus, drawLabel, drawFallbackLabel, buildLabelContent, buildLabelFields} from "@/label.js";
|
||||
import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, withDerivedVars, templateContent} from "@/label-layouts.js";
|
||||
import {buildLabelContent, buildLabelFields} from "@/label-content.js";
|
||||
import {LABEL_TEMPLATES, tapeFromStatus, drawLabel, drawFallbackLabel, templateContent} from "@/label-drawing.js";
|
||||
|
||||
// Served verbatim from public/vendor/ rather than bundled: libweblabel.js's
|
||||
// own emscripten glue resolves its .wasm sibling relative to *its own*
|
||||
|
|
@ -213,18 +159,6 @@ import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, withDerivedVars, templateConte
|
|||
const BLOB_URL = "/vendor/libweblabel.js";
|
||||
|
||||
const MAX_ZOOM = 4; /* never magnify the preview more than this */
|
||||
const MAX_PREVIEW_HEIGHT_PX = 300; /* never let the on-screen preview grow taller than this */
|
||||
// How far apart plain and labeled/major ticks sit, both coarser the longer the ruler itself runs
|
||||
// - tightly spaced ticks (and their labels) get too cramped to read/render once there are enough
|
||||
// of them. Ordered smallest threshold first; rulerTicks below uses the last entry whose `aboveMm`
|
||||
// the ruler's own length clears, so add a finer/coarser tier here 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.
|
||||
const RULER_TIERS = [
|
||||
{aboveMm: 0, tickMm: 1, majorEveryMm: 5},
|
||||
{aboveMm: 100, tickMm: 1, majorEveryMm: 10},
|
||||
{aboveMm: 500, tickMm: 5, majorEveryMm: 25},
|
||||
];
|
||||
|
||||
export default {
|
||||
name: "Print",
|
||||
|
|
@ -254,33 +188,8 @@ export default {
|
|||
connected: null,
|
||||
tape: null,
|
||||
labelBitmap: null,
|
||||
// The tape-fed preview's current on-screen scale and printed pixel width (see
|
||||
// fitZoom/redraw) - tracked reactively, rather than read straight off the canvas
|
||||
// element, purely so the mm ruler below can recompute its tick positions whenever
|
||||
// either one changes.
|
||||
zoom: 1,
|
||||
printedWidthPx: 0,
|
||||
// Each "text" leaf's effective font size in the current render (see label.js's
|
||||
// drawLabel) - shown alongside the tape width so a field rendering blank (too small
|
||||
// even for the smallest pixel font) shows up as a suspiciously tiny number here rather
|
||||
// than just silently not being there.
|
||||
textSizesPx: [],
|
||||
|
||||
// One input per *base* template variable (see label-layouts.js's BASE_VARS) - the
|
||||
// derived ones (userHandle, itemUrl, itemHandle) are format strings calculated from
|
||||
// these, not typed directly, so they're only ever shown (see the `fields` computed
|
||||
// below), never stored here. Prefilled from the ?kind=…&… query params where
|
||||
// buildLabelContent/buildLabelFields have a value for them, editable from there so a
|
||||
// template needing e.g. domain isn't stuck depending on a prefill that never arrives.
|
||||
varValues: {
|
||||
...Object.fromEntries(BASE_VARS.map(v => [v, ""])),
|
||||
text: buildLabelContent(this.prefill),
|
||||
// Defaults to wherever this page itself is being served from - editable since any
|
||||
// frontend can resolve any handle (see label-layouts.js's DERIVED_VARS.itemUrl),
|
||||
// so a label doesn't have to point back at this particular one.
|
||||
webdomain: window.location.origin,
|
||||
...buildLabelFields(this.prefill),
|
||||
},
|
||||
value: buildLabelContent(this.prefill),
|
||||
copies: 1,
|
||||
selectedTemplate: LABEL_TEMPLATES[0].id,
|
||||
|
||||
|
|
@ -291,30 +200,11 @@ export default {
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
// The base variables the "Label content" form renders an input for, and the derived ones
|
||||
// it instead calculates and lists read-only beside that form - plain passthroughs, but
|
||||
// keep the template from importing label-layouts.js just for these.
|
||||
baseVars() {
|
||||
return BASE_VARS;
|
||||
},
|
||||
derivedVars() {
|
||||
return Object.keys(DERIVED_VARS);
|
||||
},
|
||||
// Named content fields the templates draw from: the form's own base vars, plus every
|
||||
// DERIVED_VARS format string calculated live from those - so typing a userHandle and
|
||||
// itemId (whether by hand or via prefill) recalculates itemUrl/itemHandle the same way
|
||||
// either way. A blank/uncalculated value is dropped 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.
|
||||
// Named content fields the field-specific templates draw from, plus the free-text
|
||||
// `value` field the generic qr/qr-text/text templates use. A field this doesn't have
|
||||
// (rather than one that's merely empty) is what LabelLayoutPreview.vue greys out.
|
||||
fields() {
|
||||
const base = {};
|
||||
for (const v of BASE_VARS) {
|
||||
if (this.varValues[v]) {
|
||||
base[v] = this.varValues[v];
|
||||
}
|
||||
}
|
||||
return withDerivedVars(base);
|
||||
return {value: this.value, ...buildLabelFields(this.prefill)};
|
||||
},
|
||||
currentTemplate() {
|
||||
return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
|
||||
|
|
@ -340,65 +230,14 @@ export default {
|
|||
canPrint() {
|
||||
return Boolean(this.tape && this.labelBitmap && !this.busy);
|
||||
},
|
||||
// On-screen pixels per real millimeter of tape, at the preview's current zoom - what
|
||||
// turns a physical mm into a tick position the ruler can actually draw. Only meaningful
|
||||
// for the tape-fed preview (see redraw's printedWidthPx) - the no-webusb fallback preview
|
||||
// isn't fed from any particular real tape/dpi, so it gets no ruler (see the template).
|
||||
tapePxPerMm() {
|
||||
return this.tape ? (this.tape.dpi / 25.4) * this.zoom : 0;
|
||||
},
|
||||
// The physical length, in mm, each ruler axis actually needs to cover - see
|
||||
// horizontalRulerTicks/verticalRulerTicks below for what each one measures and why.
|
||||
horizontalTotalMm() {
|
||||
return (this.tape && this.printedWidthPx) ? this.printedWidthPx / (this.tape.dpi / 25.4) : 0;
|
||||
},
|
||||
verticalTotalMm() {
|
||||
return this.tape ? this.tape.mediaWidthMm : 0;
|
||||
},
|
||||
// The single RULER_TIERS entry both rulers draw from, keyed off whichever axis is
|
||||
// physically longer - so a long label's ruler doesn't end up coarser (or finer) than the
|
||||
// tape-width ruler right next to it just because the other axis happens to be shorter.
|
||||
rulerTier() {
|
||||
return RULER_TIERS.filter(t => Math.max(this.horizontalTotalMm, this.verticalTotalMm) >= t.aboveMm)
|
||||
.at(-1);
|
||||
},
|
||||
// Ticks along the tape's length (the printed bitmap's actual width, lead/trailing feed
|
||||
// margin included, since that's real physical tape too).
|
||||
horizontalRulerTicks() {
|
||||
if (!this.tape || !this.printedWidthPx) {
|
||||
return [];
|
||||
}
|
||||
return this.rulerTicks(this.horizontalTotalMm);
|
||||
},
|
||||
// Ticks across the tape's full physical width, mediaWidthMm - not printAreaPx/dpi: a
|
||||
// print head can't reach the tape's outer edges, so the printable area (see .tape-full in
|
||||
// the template) is genuinely narrower than the tape itself, by an amount that isn't a
|
||||
// fixed/predictable fraction of it. The ruler still has to show the *whole* tape - its
|
||||
// container is sized from mediaWidthMm too (see the template's inline height) precisely so
|
||||
// these ticks can't run past it, the way they did when both were sized from printAreaPx.
|
||||
verticalRulerTicks() {
|
||||
return this.tape ? this.rulerTicks(this.verticalTotalMm) : [];
|
||||
},
|
||||
// "(5px, 23px)" for the current render's text leaves (see data's textSizesPx), or "" once
|
||||
// there's nothing to show - appended straight onto the tape-width <small>, so the blank
|
||||
// string here just means that text is left with no trailing space.
|
||||
textSizesSummary() {
|
||||
if (!this.textSizesPx.length) {
|
||||
return "";
|
||||
}
|
||||
return ` (${this.textSizesPx.map(px => Math.round(px) + "px").join(", ")})`;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
varValues: {
|
||||
handler() {
|
||||
if (this.usbSupported) {
|
||||
this.redraw();
|
||||
} else {
|
||||
this.redrawFallback();
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
value() {
|
||||
if (this.usbSupported) {
|
||||
this.redraw();
|
||||
} else {
|
||||
this.redrawFallback();
|
||||
}
|
||||
},
|
||||
selectedTemplate() {
|
||||
if (this.usbSupported) {
|
||||
|
|
@ -419,26 +258,6 @@ export default {
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
// Turns a camelCase variable name (see label-layouts.js's KNOWN_VARS) into a form label,
|
||||
// e.g. "itemHandle" -> "Item Handle" - so adding a new template variable doesn't also
|
||||
// require hand-writing a label for it here.
|
||||
varLabel(v) {
|
||||
return v.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase());
|
||||
},
|
||||
|
||||
// Ticks from 0 up to totalMm, each positioned in on-screen pixels via tapePxPerMm - shared
|
||||
// by the horizontal/vertical ruler computeds above. Both the plain tick spacing and the
|
||||
// labeled/major one come from the shared rulerTier (see above), not from this totalMm, so
|
||||
// both rulers always coarsen together once *either* axis is long enough to need it.
|
||||
rulerTicks(totalMm) {
|
||||
const {tickMm, majorEveryMm} = this.rulerTier;
|
||||
const ticks = [];
|
||||
for (let mm = 0; mm <= totalMm; mm += tickMm) {
|
||||
ticks.push({mm, pos: mm * this.tapePxPerMm, major: mm % majorEveryMm === 0});
|
||||
}
|
||||
return ticks;
|
||||
},
|
||||
|
||||
async guard(fn) {
|
||||
this.error = null;
|
||||
this.busy = true;
|
||||
|
|
@ -507,7 +326,7 @@ export default {
|
|||
redraw() {
|
||||
this.labelBitmap = null;
|
||||
const content = this.selectedContent;
|
||||
if (!this.tape || !content) {
|
||||
if (!this.tape || (!content.qr && !content.text)) {
|
||||
return;
|
||||
}
|
||||
const canvas = this.$refs.labelCanvas;
|
||||
|
|
@ -515,26 +334,23 @@ export default {
|
|||
return;
|
||||
}
|
||||
this.resizeObserver.observe(canvas.parentElement);
|
||||
let textSizesPx;
|
||||
try {
|
||||
({textSizesPx} = drawLabel(canvas, this.tape, content));
|
||||
drawLabel(canvas, this.tape, content);
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
return;
|
||||
}
|
||||
this.error = null;
|
||||
this.textSizesPx = textSizesPx;
|
||||
const bitmap = canvasToBitmap(canvas);
|
||||
bitmapToCanvas(canvas, bitmap);
|
||||
this.labelBitmap = bitmap;
|
||||
this.printedWidthPx = canvas.width;
|
||||
this.zoom = this.fitZoom(canvas);
|
||||
this.fitZoom(canvas);
|
||||
},
|
||||
|
||||
redrawFallback() {
|
||||
this.fallbackReady = false;
|
||||
const content = this.selectedContent;
|
||||
if (!content) {
|
||||
if (!content.qr && !content.text) {
|
||||
return;
|
||||
}
|
||||
const canvas = this.$refs.fallbackCanvas;
|
||||
|
|
@ -566,20 +382,16 @@ export default {
|
|||
|
||||
/* Fit the preview to its card without ever needing a horizontal
|
||||
scrollbar for a label this small, magnifying short labels up to
|
||||
MAX_ZOOM rather than showing them at native (tiny) size - and never
|
||||
past MAX_PREVIEW_HEIGHT_PX tall, however long/wide the label itself
|
||||
runs. Returns the zoom actually used, so callers that care (see
|
||||
redraw's ruler bookkeeping) don't have to re-derive it. */
|
||||
MAX_ZOOM rather than showing them at native (tiny) size. */
|
||||
fitZoom(canvas) {
|
||||
const available = canvas.parentElement.clientWidth;
|
||||
if (!(available > 0)) {
|
||||
return 1;
|
||||
return;
|
||||
}
|
||||
const zoom = Math.min(MAX_ZOOM, available / canvas.width, MAX_PREVIEW_HEIGHT_PX / canvas.height);
|
||||
const zoom = Math.min(MAX_ZOOM, available / canvas.width);
|
||||
canvas.style.width = `${canvas.width * zoom}px`;
|
||||
canvas.style.height = `${canvas.height * zoom}px`;
|
||||
canvas.style.imageRendering = zoom >= 1 ? "pixelated" : "auto";
|
||||
return zoom;
|
||||
},
|
||||
|
||||
print() {
|
||||
|
|
@ -598,7 +410,7 @@ export default {
|
|||
for (const entry of entries) {
|
||||
const canvas = entry.target.querySelector("canvas");
|
||||
if (canvas) {
|
||||
this.zoom = this.fitZoom(canvas);
|
||||
this.fitZoom(canvas);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
|
@ -647,127 +459,15 @@ export default {
|
|||
.label-preview {
|
||||
overflow-x: auto;
|
||||
padding: .75rem;
|
||||
//background: rgba(127, 127, 127, .08);
|
||||
background: rgba(127, 127, 127, .08);
|
||||
border-radius: .35rem;
|
||||
text-align: center;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.label-preview canvas {
|
||||
display: inline-block;
|
||||
background: #fff;
|
||||
//box-shadow: 0 0 0 1px rgba(127, 127, 127, .5);
|
||||
}
|
||||
|
||||
/* The tape-fed preview's mm ruler (see Print.vue's template/script) - 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-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Holds the horizontal ruler and the canvas - 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. */
|
||||
.preview-track {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Overrides the standalone rule above: nested here, .label-preview 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 this card has. padding:0 so the canvas's own edges are exactly
|
||||
this box's edges too - the ruler's ticks (see .ruler-h/.ruler-v-ticks below) line up with those
|
||||
same edges, so any padding here would leave the ticks and the actual canvas misaligned. */
|
||||
.preview-track .label-preview {
|
||||
overflow-x: visible;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* The tape's full physical width (see the template) - a print head can't mark all the way to a
|
||||
tape's outer edges, so .label-preview/the canvas is narrower than this and centered within it
|
||||
(the print area sits centered on the tape, with equal margin on both sides); a faint tint
|
||||
distinguishes the margin as real (if blank, unprintable) tape rather than empty space. */
|
||||
.tape-full {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
background: rgba(127, 127, 127, .08);
|
||||
border-radius: .35rem;
|
||||
}
|
||||
|
||||
.ruler-v {
|
||||
flex: none;
|
||||
width: 2.2rem;
|
||||
color: rgba(127, 127, 127, .9);
|
||||
}
|
||||
|
||||
/* Matches .ruler-h's own height below - the vertical ruler's ticks start only after this, so tick
|
||||
0 lines up with the canvas's top edge rather than the horizontal ruler sitting above it. */
|
||||
.ruler-v-corner {
|
||||
height: 1.6rem;
|
||||
}
|
||||
|
||||
.ruler-v-ticks {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ruler-h {
|
||||
position: relative;
|
||||
height: 1.6rem;
|
||||
color: rgba(127, 127, 127, .9);
|
||||
}
|
||||
|
||||
.ruler .tick {
|
||||
position: absolute;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
/* Ticks anchor to the edge nearest the canvas (right for the vertical ruler, bottom for the
|
||||
horizontal one) and grow outward from it, so they read as pointing at the label; the mm labels
|
||||
sit on the opposite, outer edge, out of the ticks' way. */
|
||||
.ruler-v-ticks .tick {
|
||||
right: 0;
|
||||
width: .4rem;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.ruler-v-ticks .tick-major {
|
||||
width: .7rem;
|
||||
}
|
||||
|
||||
.ruler-h .tick {
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
height: .4rem;
|
||||
}
|
||||
|
||||
.ruler-h .tick-major {
|
||||
height: .7rem;
|
||||
}
|
||||
|
||||
.tick-label {
|
||||
position: absolute;
|
||||
font-size: .6rem;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* transform, not a fixed em nudge, so the label's actual center - not its edge - lands on the
|
||||
tick's mm position (t.pos, set inline), whatever the text's width/height happens to be. */
|
||||
.ruler-v-ticks .tick-label {
|
||||
left: 0;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.ruler-h .tick-label {
|
||||
top: 0;
|
||||
transform: translateX(-50%);
|
||||
box-shadow: 0 0 0 1px rgba(127, 127, 127, .5);
|
||||
}
|
||||
|
||||
.copies-input {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue