Compare commits

..

No commits in common. "0c025db7998bf0ccec1b4ad2ccc6f2454a3b5c7b" and "8d96bc97c434bbea55ffc040087c033171ce7f0a" have entirely different histories.

64 changed files with 1887 additions and 2276 deletions

View file

@ -18,8 +18,9 @@ from hostadmin.models import Domain
router = routers.SimpleRouter()
# Schema for account-level preferences a client may store server-side (see AccountPreference);
# device-level preferences stay in the browser's local storage instead.
# Schema for the account-level preferences a client may store on the server (see
# AccountPreference). Device-level preferences are never sent here - they stay in the
# browser's local storage since they describe the device, not the account.
PREFERENCE_DEFINITIONS = [
{
'key': 'ui.compact_mode',
@ -101,8 +102,9 @@ class UserViewSet(viewsets.ModelViewSet):
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal])
def getUserInfo(request):
"""Get or update the authenticated local user's own account info; only the account owner may
call this on their own home server (see getUserProfile for viewing a friend's public profile)."""
"""Get or update the authenticated local user's own account info. Only usable by the
account owner on their own home server - see getUserProfile for viewing another (friend)
user's public profile."""
user = request.user
if request.method == 'PATCH':
old_file = user.profile_picture
@ -145,9 +147,9 @@ def getUserInfo(request):
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
def getUserProfile(request, handle):
"""Get another local user's public profile by handle, e.g. so a friend can look up an avatar;
caller must be a friend of that user (or the user itself, signing with their own known
identity rather than local credentials)."""
"""Get another local user's public profile by handle (username@domain), e.g. so a friend
can look up someone's avatar. The caller must be a friend of that user (or the user
itself, signing with their own known identity rather than their local credentials)."""
try:
username, domain = split_userhandle_or_throw(handle)
except ValueError:
@ -211,9 +213,11 @@ def preference_definitions(request):
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal])
def account_preferences(request):
"""Get or bulk-upsert the authenticated user's account-level preferences: GET returns the
current preferences as {key: value}; PUT sets/overwrites one or more, leaving unspecified
keys untouched."""
"""Get or bulk-upsert the authenticated user's account-level preferences.
GET returns the current preferences as a {key: value} dict. PUT accepts a {key: value}
dict of one or more preferences to set/overwrite; unspecified keys are left untouched.
"""
if request.method == 'PUT':
if not isinstance(request.data, dict):
return Response({'detail': 'Expected an object of key/value pairs.'}, status=400)

View file

@ -114,8 +114,11 @@ class ToolshedUser(AbstractUser):
class AccountPreference(models.Model):
"""A single account-level (server-synced, cross-device) preference as a key/value pair;
device-level preferences are intentionally *not* stored here."""
"""A single account-level (server-synced, cross-device) user preference, stored as a key/value pair.
Device-level preferences are intentionally *not* stored here - they stay in the browser's
local storage since they describe the device, not the account.
"""
user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='preferences')
key = models.CharField(max_length=255)
value = models.JSONField()
@ -162,9 +165,9 @@ class Group(models.Model):
class GroupInvite(models.Model):
"""A pending invite tracked on the group's own home backend, checked when the invitee's accept
request arrives (mirror: GroupInviteIncoming on the invitee's backend; see
docs/design-in-progress/groups-mvp.md)."""
"""A pending invite tracked on the group's own home backend, checked when the invitee's
accept request arrives (see GroupInviteIncoming for the mirror record on the invitee's own
backend, and docs/design-in-progress/groups-mvp.md for the full invite/accept dance)."""
secret = models.CharField(max_length=255)
group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name='invites')
invitee_username = models.CharField(max_length=255)

View file

@ -81,8 +81,12 @@ def verify_incoming_friend_request(request, raw_request_body):
def verify_incoming_group_invite(request, raw_request_body, handle_field, key_field):
"""Self-certifying verifier for the group invite/accept dance. See
docs/implementation.md#group-invite-and-accept-self-certifying-verification."""
"""Self-certifying verifier for the two legs of the group invite/accept dance that land on a
backend which doesn't have the caller cached as a KnownIdentity yet (see
docs/design-in-progress/groups-mvp.md): the inviter delivering an invite to the invitee's own
backend (handle_field='inviter', key_field='inviter_key'), and the invitee accepting on the
group's home backend (handle_field='invitee', key_field='invitee_key'). Mirrors
verify_incoming_friend_request exactly, just with configurable field names."""
try:
username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body)
except ValueError:
@ -139,8 +143,10 @@ def authenticate_request_against_local_users(request, raw_request_body):
class SignatureAuthentication(authentication.BaseAuthentication):
def authenticate(self, request):
identity = authenticate_request_against_known_identities(request, request.body.decode('utf-8'))
# Bare None (not a (None, None) tuple) tells DRF to try the next authenticator, instead
# of treating the request as authenticated with an empty user.
# Returning a bare None (rather than a (None, None) tuple) tells DRF this
# authenticator doesn't apply, so it moves on to the next authenticator in the
# authentication_classes list instead of treating the request as authenticated
# with an empty user.
if identity is None:
return None
return identity, None

View file

@ -1,3 +1,12 @@
"""
ASGI config for backend project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application

View file

@ -1,3 +1,14 @@
"""
Django settings for backend project.
Generated by 'django-admin startproject' using Django 4.2.2.
For more information on this file, see
https://docs.djangoproject.com/en/4.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.1/ref/settings/
"""
import os
import subprocess
import dotenv
@ -8,9 +19,13 @@ BASE_DIR = Path(__file__).resolve().parent.parent
def _git_commit():
# Docker dev bind-mounts the real .git dir at /git (see docker-compose.yml); bare-metal dev
# finds it by walking up from BASE_DIR instead. Prod has no .git at all, so both fail and we
# fall back to the GIT_COMMIT build-arg/env var (see Dockerfile.backend/playbook.yml).
# deploy/dev/docker-compose.yml bind-mounts the repo's real .git dir at
# /git (separate from BASE_DIR, which only has the backend/ subtree) -
# point git at it explicitly there. Bare-metal dev has no such mount, but
# BASE_DIR sits inside the real checkout so plain rev-parse finds it by
# walking up. In prod, the build context is backend/ alone with no .git
# anywhere, so both fail and we fall back to the GIT_COMMIT build-arg/env
# var (see deploy/prod/Dockerfile.backend and playbook.yml).
cmd = ['git', '--git-dir=/git'] if os.path.isdir('/git') else ['git']
try:
return subprocess.check_output(
@ -21,6 +36,9 @@ def _git_commit():
dotenv.load_dotenv(BASE_DIR / '.env')
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/
SECRET_KEY = os.environ.get('SECRET_KEY', None)
if SECRET_KEY is None:
raise Exception('environment variable SECRET_KEY not set. try running `configure.py` or setting it manually')
@ -109,6 +127,9 @@ TEMPLATES = [
WSGI_APPLICATION = 'backend.wsgi.application'
# Database
# https://docs.djangoproject.com/en/4.1/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
@ -118,6 +139,9 @@ DATABASES = {
AUTH_USER_MODEL = 'authentication.ToolshedUser'
# Password validation
# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
@ -133,6 +157,9 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
# Internationalization
# https://docs.djangoproject.com/en/4.1/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
@ -141,18 +168,26 @@ USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.1/howto/static-files/
STATIC_ROOT = 'staticfiles'
STATIC_URL = '/static/'
MEDIA_ROOT = os.environ.get('TOOLSHED_USERFILES_PATH', 'userfiles')
MEDIA_URL = '/media/'
# Pinned explicitly (rather than left to the backend process's ambient umask) so group-read
# is guaranteed for nginx/www-data regardless of how the container is started - see
# In prod, nginx (running as www-data) reads these files directly - see
# SERVE_X_ACCEL_REDIRECT and playbook.yml's `location /redirect_media/`.
# Pinned explicitly rather than left to the backend process's ambient umask,
# so group-read (www-data is added to the backend's system group) is
# guaranteed regardless of how the container is started.
FILE_UPLOAD_PERMISSIONS = 0o640
FILE_UPLOAD_DIRECTORY_PERMISSIONS = 0o750
# Default primary key field type
# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
STORAGES = {

View file

@ -1,3 +1,18 @@
"""backend URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/4.1/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
from drf_yasg import openapi

View file

@ -1,3 +1,12 @@
"""
WSGI config for backend project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application

View file

@ -32,8 +32,11 @@ def yesno(prompt, default=False):
def configure():
# Keys this function may generate/update; tracked so an unwritable .env (e.g. a prod
# container configured via --env-file) can still print them for the operator to apply manually.
# Keys this function may generate/update, tracked so that if .env turns
# out to be unwritable (e.g. a prod container running as an unprivileged
# user, whose real config comes from --env-file instead) we can still
# print the resulting configuration for the operator to apply manually,
# rather than silently discarding it or crashing.
tracked_keys = ['SECRET_KEY', 'ALLOWED_HOSTS']
unwritable = False

View file

@ -24,8 +24,9 @@ THUMBNAIL_SIZES = (32, 64, 256)
def _accessible_files(request):
# Shared by media_urls and thumbnail_urls: a file is visible if the requester is
# friends-or-self with whatever references it (item, profile picture), or it's their own staged photo.
# Shared by media_urls and thumbnail_urls so both endpoints always agree on who can see
# what - a file is visible if the requester is friends-or-self with whatever currently
# references it (an inventory item, a profile picture) or it's their own staged photo.
return File.objects.filter(
Q(connected_items__owner__in=request.user.friends_or_self()) |
Q(profile_picture_users__in=request.user.friends_or_self()) |
@ -34,7 +35,8 @@ def _accessible_files(request):
def _cache_headers(etag):
# Content is hash-addressed and can never change under a given URL, so it's cacheable forever.
# Content is addressed by its own hash and can never change under a given URL, so caches
# (and the conditional-GET checks in both views below) can treat it as immutable forever.
return {
'ETag': etag,
'Cache-Control': 'max-age=31536000, private, immutable',
@ -47,16 +49,23 @@ def _cache_headers(etag):
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
def media_urls(request, hash_path):
# CORS is added automatically by middleware, except via X-Accel-Redirect, where nginx's
# /redirect_media/ block must set it instead.
# Note: CORS headers are NOT set here - django-cors-headers (CorsMiddleware,
# configured in settings.py) adds them to every Django response automatically, so
# setting them manually on these responses would just be redundant. The one exception
# is the SERVE_X_ACCEL_REDIRECT path: nginx replaces this response entirely when it
# follows the X-Accel-Redirect and serves the file itself, so the CORS header for that
# case has to be configured in nginx's `location /redirect_media/` block instead.
#
# Looked up by the derived storage path, not the raw hash, to match FileSerializer.name
# (used for AuthenticatedImage's `src`) and the existing test suite (MediaUrlTestCase).
# Looked up by the derived storage path (not by raw hash) because FileSerializer.name
# (files/serializers.py) - used everywhere a file URL is handed to the frontend, e.g.
# AuthenticatedImage's `src` - already returns this path via Django's FileField.url, and
# the existing test suite (files/tests.py MediaUrlTestCase) exercises it this way too.
try:
file = _accessible_files(request).get(file=hash_path)
# Must run before this check, else a bare hash + If-None-Match would let anyone probe
# file existence for files they can't see.
# The access-control lookup above must happen before this check - otherwise a bare
# hash + If-None-Match would let anyone probe "does a file with this hash exist" for
# files they can't actually see.
if request.META.get('HTTP_IF_NONE_MATCH') == file.hash:
return HttpResponse(status=status.HTTP_304_NOT_MODIFIED)
@ -70,7 +79,9 @@ def media_urls(request, hash_path):
**cache_headers,
})
else:
# Reads via FieldFile.open() (not file.file.path) since tests swap in an in-memory storage backend.
# Read via the FieldFile itself (works against whatever storage backend is
# actually configured) rather than assuming file.file.path is a real filesystem
# path - the test suite swaps in an in-memory backend where that isn't true.
with file.file.open('rb') as fh:
content = fh.read()
return HttpResponse(status=status.HTTP_200_OK,
@ -83,7 +94,9 @@ def media_urls(request, hash_path):
def _thumbnail_rel_path(file_hash, size):
# Mirrors hash_upload()'s sharding under thumbnails/<size>/, reachable via the same nginx alias as originals.
# Mirrors files/models.py's hash_upload() sharding, under its own `thumbnails/<size>/`
# subtree - reachable through the same nginx `/redirect_media/` alias as originals when
# served from real disk, no separate nginx location needed.
return os.path.join('thumbnails', str(size), file_hash[:2], file_hash[2:4], file_hash[4:6],
file_hash[6:] + '.jpg')
@ -103,15 +116,22 @@ def thumbnail_urls(request, size, hash_path):
if request.META.get('HTTP_IF_NONE_MATCH') == etag:
return HttpResponse(status=status.HTTP_304_NOT_MODIFIED)
# Read/write via default_storage, not a hand-rolled path, to work with both real-disk
# and in-memory test storage.
# Read/write through the default storage backend, same as File.file itself, rather
# than a hand-rolled filesystem path - correct regardless of storage backend (real
# disk in production, in-memory under the test runner) and keeps the cache in the
# same place originals live.
rel_path = _thumbnail_rel_path(file.hash, size)
if not default_storage.exists(rel_path):
# Always re-encoded as JPEG regardless of original format - simpler than preserving transparency at this scale.
# Thumbnails are always re-encoded as JPEG regardless of the original format -
# smaller and simpler than preserving e.g. PNG transparency at this scale.
with file.file.open('rb') as fh:
image = Image.open(fh)
image.thumbnail((size, size))
# Flatten through RGBA before dropping to RGB. See docs/implementation.md#rgba-flattening-avoids-revealing-black-under-transparent-pixels.
# Flatten through RGBA before dropping to RGB - some modes (grayscale+alpha,
# palette-with-transparency, RGBA) store meaningless color/luminance data under
# fully transparent pixels (often zeroed out, i.e. black). Converting straight
# to RGB reveals that instead of "nothing there"; compositing onto an opaque
# background first shows what the image is actually supposed to look like.
rgba = image.convert('RGBA')
flattened = Image.new('RGB', rgba.size, (255, 255, 255))
flattened.paste(rgba, mask=rgba.getchannel('A'))

View file

@ -43,7 +43,15 @@ class FileManager(models.Manager):
else:
raise ValueError('data must be a base64 encoded string or file and hash must be provided')
if not self.filter(hash=kwargs['hash']).exists():
# Clears a stale orphan already at this hash's canonical path before saving. See docs/implementation.md#stale-orphan-cleanup-at-the-canonical-hash-path.
# The upload path is derived entirely from the hash (hash_upload, above), and hash
# is DB-unique - so if no File row owns this hash yet, anything already sitting at
# its computed path is necessarily a stale orphan (e.g. left behind by a bug in
# cleanup code that deleted a File row without removing its stored bytes, or a
# crashed upload). Clear it before saving instead of letting Django's storage layer
# invent an alternate filename to avoid the "collision" - a suffixed name would
# silently break every part of the app that derives this file's URL purely from its
# hash (media serving, thumbnail generation, item/avatar attachment), and no future
# caller could ever discover it again.
expected_path = hash_upload(SimpleNamespace(hash=kwargs['hash']), '')
if default_storage.exists(expected_path):
default_storage.delete(expected_path)

View file

@ -113,7 +113,12 @@ class FilesTestCase(FilesTestMixin, ToolshedTestCase):
self.assertEqual(countdir(DefaultStorage(), ''), 3)
def test_file_upload_reclaims_stale_orphan_at_canonical_path(self):
# Regression test for a stale orphan at the canonical hash path. See docs/implementation.md#stale-orphan-cleanup-at-the-canonical-hash-path.
# Reproduces a real incident: a File row gets deleted without its underlying stored
# bytes being removed (e.g. a bug in some cleanup call site), leaving an orphan sitting
# at the exact path hash_upload() would compute for that content. A later upload of the
# same content must land back on that canonical path - not get silently suffixed by
# Django's default collision-avoidance, which would make it unreachable to every part
# of the app that derives a file's URL purely from its hash.
expected_path = f"{self.f['hash4'][:2]}/{self.f['hash4'][2:4]}/{self.f['hash4'][4:6]}/{self.f['hash4'][6:]}"
default_storage.save(expected_path, ContentFile(self.f['test_content4']))
self.assertTrue(default_storage.exists(expected_path))
@ -226,8 +231,11 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To
self.prepare_properties()
self.prepare_inventory()
# Each test uses a distinct seeded image (own hash/cache path) since InMemoryStorage
# isn't reset between test methods, so a shared image risks one test's cached thumbnail leaking into another's assertions.
# Each test method gets its own distinct image content (and therefore its own content
# hash / thumbnail cache path) - InMemoryStorage isn't reset between test methods within
# a run, so sharing one fixed image across methods risks one test's cached (or, in
# test_thumbnail_served_from_cache_on_second_request's case, deliberately corrupted)
# thumbnail leaking into another test's assertions.
seed = zlib.crc32(self._testMethodName.encode()) % 256
buffer = io.BytesIO()
Image.new('RGB', (800, 600), (seed, 255 - seed, 128)).save(buffer, 'PNG')
@ -246,7 +254,8 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To
return os.path.join('thumbnails', str(size), h[:2], h[2:4], h[4:6], h[6:] + '.jpg')
def test_thumbnail_sizes_available(self):
# Fixed size allow-list this suite exercises - update both if media_urls.py's THUMBNAIL_SIZES changes.
# Documents the fixed size allow-list this test suite exercises against - update both
# if files/media_urls.py's THUMBNAIL_SIZES ever changes.
self.assertEqual(THUMBNAIL_SIZES, (32, 64, 256))
@override_settings(SERVE_X_ACCEL_REDIRECT=False)
@ -265,7 +274,10 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To
@override_settings(SERVE_X_ACCEL_REDIRECT=False)
def test_thumbnail_flattens_transparency_instead_of_going_black(self):
# Regression test for an 'LA' source with zeroed transparent-region luminance. See docs/implementation.md#rgba-flattening-avoids-revealing-black-under-transparent-pixels.
# Reproduces a real incident: an 'LA' (grayscale + alpha) source whose fully-transparent
# region has zeroed-out luminance underneath, as many image tools produce. Converting
# straight to RGB (dropping alpha without compositing) reveals that zeroed data - the
# whole thumbnail comes out solid black even though the visible (opaque) content isn't.
half_transparent = Image.new('LA', (200, 200))
pixels = half_transparent.load()
for x in range(200):
@ -298,7 +310,8 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To
with default_storage.open(rel_path, 'rb') as f:
cached_bytes = f.read()
# Overwrites the cache with a marker so a correct implementation must serve it back, not regenerate.
# Overwrite the cached file with a marker so a correct implementation must serve this
# exact content back rather than regenerating it from the original.
default_storage.delete(rel_path)
default_storage.save(rel_path, ContentFile(cached_bytes + b'MARKER'))
@ -320,7 +333,8 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To
self.assertEqual(reply.status_code, 403)
def test_thumbnail_not_friend(self):
# local_user1/local_user2 are friends here (see prepare_inventory), so the denied case needs a stranger instead.
# local_user1/local_user2 are friends in these fixtures (see prepare_inventory) - the
# denied case needs a stranger to that friendship instead.
reply = client.get(self._thumb_url(64), self.f['ext_user1'])
self.assertEqual(reply.status_code, 404)
self.assertFalse(default_storage.exists(self._thumb_rel_path(64)))

View file

@ -50,7 +50,10 @@ def post_item_file(request, item_id):
if item is None:
return Response(status=status.HTTP_404_NOT_FOUND)
if 'file_hash' in request.data:
# Attaches an already-staged file by hash instead of re-uploading it. See docs/implementation.md#staged-files-are-identified-by-hash-alone.
# Attach a file the caller already staged on one of their own workflows, identified
# by its content hash (which the client already computed before ever uploading it),
# instead of re-uploading bytes that are already stored server-side. Workflows are
# always personally owned, so this only applies to a caller with a local account.
if not request.user.user.exists():
return Response(status=status.HTTP_404_NOT_FOUND)
try:
@ -71,7 +74,10 @@ def post_item_file(request, item_id):
def get_staged_files(request, workflow_id):
try:
workflow = WorkflowInstance.objects.get(id=workflow_id, owner=request.user)
# Hash alone is enough to discover what another session/device already staged. See docs/implementation.md#staged-files-are-identified-by-hash-alone.
# Hash alone identifies a staged file (client and server hash content the same way, and
# bytes are fetchable from a hash-derived storage path) - useful mainly for discovering
# what another session/device already staged on this workflow, unlike the fuller
# FileSerializer representation item_files uses.
return Response(list(workflow.staged_files.values_list('hash', flat=True)))
except WorkflowInstance.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)

View file

@ -35,22 +35,26 @@ class InventoryItemViewSet(viewsets.ModelViewSet):
serializer_class = InventoryItemSerializer
authentication_classes = [SignatureAuthentication]
permission_classes = [IsAuthenticated]
# Detail routes address an item by its owner-scoped id, not the internal row id. See
# docs/implementation.md#inventory-detail-routes-use-owner-scoped-ids.
# Detail routes address an item by its owner-scoped id, not the internal row id - the
# router still names the URL capture group 'pk', so keep that as lookup_url_kwarg and just
# change which model field it's matched against. get_queryset() below is always already
# scoped to the requester's own items/groups, so this can't cross into another owner's ids.
lookup_field = 'id'
lookup_url_kwarg = 'pk'
def get_queryset(self):
# A pure group-member KnownIdentity may have no local ToolshedUser account; only
# personal items require .user.exists(). See
# docs/implementation.md#group-member-identities-without-local-accounts.
# A KnownIdentity acting purely as a group member (e.g. a remote member on a group
# hosted on this backend) never has a local ToolshedUser account here - group-owned
# items must stay reachable for such an identity, only personal ("owner=...") items
# require .user.exists().
if type(self.request.user) != KnownIdentity:
return InventoryItem.objects.none()
identity = self.request.user
group_items = InventoryItem.objects.filter(owner_group__in=identity.member_of_groups.all())
if self.action != 'list':
# retrieve/update/destroy: any item the caller may act on, own or group. See
# docs/implementation.md#inventory-queryset-scope-by-action.
# retrieve/update/destroy: anything the caller may act on - their own items, or any
# group they're currently a member of. The narrower per-group listing below is only
# for the list action, so the main Inventory page stays scoped to personal items.
if identity.user.exists():
return InventoryItem.objects.filter(owner=identity.user.get()) | group_items
return group_items
@ -123,8 +127,13 @@ def search_inventory_items(request):
@authentication_classes([SignatureAuthentication])
@permission_classes([IsAuthenticated])
def get_shared_item(request, handle, id):
"""Fetch a single item by its owner's handle and local id, for /i/<handle>/<id> or
/inventory/shared/<handle>/<id>. See docs/implementation.md#get-shared-item-looks-up-by-owner."""
"""Fetch a single item by its owner's handle (username@domain) and local id, e.g. for the
/i/<handle>/<id> item URL (see docs/design-in-progress/items-labels.md) or the
/inventory/shared/<handle>/<id> in-app view. Unlike InventoryItemViewSet, which only ever
returns the requester's own items, this looks the item up by owner instead of by requester,
so it's the only endpoint that can serve a friend's item - subject to the same
friends-or-self and availability_policy checks getUserProfile/_accessible_files already
use elsewhere."""
try:
username, domain = split_userhandle_or_throw(handle)
except ValueError:
@ -149,10 +158,6 @@ class StorageLocationViewSet(viewsets.ModelViewSet):
serializer_class = StorageLocationSerializer
authentication_classes = [SignatureAuthentication]
permission_classes = [IsAuthenticated]
# Detail routes address a location by its owner-scoped id, not the internal row id. See
# docs/implementation.md#inventory-detail-routes-use-owner-scoped-ids.
lookup_field = 'id'
lookup_url_kwarg = 'pk'
def get_queryset(self):
if type(self.request.user) == KnownIdentity and self.request.user.user.exists():

View file

@ -83,9 +83,9 @@ class Tag(models.Model):
class OwnerItemSequence(models.Model):
"""Tracks the last InventoryItem id handed out per owner/owner_group scope for sequential,
gapless allocation (see InventoryItem.create_for_owner); exactly one of owner/owner_group is
set, mirroring InventoryItem's own split."""
"""Tracks the last InventoryItem id handed out to a given owner or owner_group, so ids can
be allocated sequentially and without gaps within that scope (see InventoryItem.create_for_owner).
Exactly one of owner/owner_group is set, mirroring InventoryItem's own owner/owner_group split."""
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, null=True, blank=True, related_name='+')
owner_group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True, blank=True, related_name='+')
last_id = models.PositiveIntegerField(default=0)
@ -117,8 +117,9 @@ class InventoryItem(SoftDeleteModel):
)
internal_id = models.AutoField(primary_key=True)
# Externally visible id, sequential/gapless within owner/owner_group's own items (see
# OwnerItemSequence), never internal_id; always allocate via create_for_owner, not .objects.create().
# The externally visible identifier: sequential and gapless within owner/owner_group's own
# items (see OwnerItemSequence), never the internal_id above. Always allocate through
# create_for_owner rather than InventoryItem.objects.create() directly.
id = models.PositiveIntegerField(editable=False)
published = models.BooleanField(default=False)
name = models.CharField(max_length=255, null=True, blank=True)
@ -151,8 +152,8 @@ class InventoryItem(SoftDeleteModel):
@classmethod
def create_for_owner(cls, *, owner=None, owner_group=None, **kwargs):
"""The only supported way to create an InventoryItem: atomically allocates the next id
for this owner/owner_group scope."""
"""The only supported way to create an InventoryItem: allocates the next id for this
owner/owner_group scope and creates the item with it, atomically."""
with transaction.atomic():
next_id = OwnerItemSequence.allocate(owner=owner, owner_group=owner_group)
return cls.objects.create(owner=owner, owner_group=owner_group, id=next_id, **kwargs)
@ -169,27 +170,7 @@ class ItemTag(models.Model):
inventory_item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE)
class OwnerStorageLocationSequence(models.Model):
"""Tracks the last StorageLocation id handed out per owner for sequential, gapless allocation
(see StorageLocation.create_for_owner)."""
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='+', unique=True)
last_id = models.PositiveIntegerField(default=0)
@classmethod
def allocate(cls, *, owner):
with transaction.atomic():
seq, _ = cls.objects.select_for_update().get_or_create(owner=owner)
seq.last_id += 1
seq.save(update_fields=['last_id'])
return seq.last_id
class StorageLocation(models.Model):
internal_id = models.AutoField(primary_key=True)
# Externally visible id, sequential/gapless within the owner's own locations (see
# OwnerStorageLocationSequence), never internal_id; always allocate via create_for_owner, not
# .objects.create().
id = models.PositiveIntegerField(editable=False)
name = models.CharField(max_length=255)
description = models.TextField(null=True, blank=True)
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True,
@ -197,23 +178,10 @@ class StorageLocation(models.Model):
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='storage_locations')
class Meta:
constraints = [
models.UniqueConstraint(fields=['owner', 'id'], name='storagelocation_unique_owner_scoped_id'),
]
def __str__(self):
parent = str(self.parent) + "/" if self.parent else ""
return parent + self.name
@classmethod
def create_for_owner(cls, *, owner, **kwargs):
"""The only supported way to create a StorageLocation: atomically allocates the next id
for this owner's scope."""
with transaction.atomic():
next_id = OwnerStorageLocationSequence.allocate(owner=owner)
return cls.objects.create(owner=owner, id=next_id, **kwargs)
class WorkflowInstance(models.Model):
slug = models.CharField(max_length=255)

View file

@ -1,4 +1,9 @@
"""Data helpers for building/importing a user's offline export; deal with toolshed models and CSV row shapes only, and know nothing about the zip container format or request auth - see toolshed/api/offlinedata.py for that."""
"""Data helpers for building/importing a user's offline export.
These functions deal with the toolshed models and CSV row shapes only - they know nothing
about the zip container format or how the request is authenticated. See toolshed/api/offlinedata.py
for the zip-building/parsing and the API views themselves.
"""
def inventory_rows(user):
@ -71,7 +76,11 @@ def location_rows(user):
def inventory_files(user):
"""Yields (arcname, data) for each unique file attached to the user's inventory items, deduplicated by hash. See docs/implementation.md#file-naming-convention-in-exports for the naming scheme."""
"""Generator that yields (arcname, data) for each unique file attached to the user's inventory items.
Files are deduplicated by hash and placed under a 'files/' subfolder, keeping their original
extension (guessed from mime_type) so attachments and images remain viewable once extracted.
"""
import mimetypes
from toolshed.models import InventoryItem
@ -116,7 +125,11 @@ def profile_data(user):
def profile_picture_files(user):
"""Generator that yields (arcname, data) for the user's profile picture, if one is set. Kept separate from `inventory_files()` so the picture is included even for users with no inventory items or whose picture isn't attached to any item."""
"""Generator that yields (arcname, data) for the user's profile picture, if one is set.
Kept separate from `inventory_files()` so the profile picture is included in the export
even for users with no inventory items or whose picture isn't attached to any item.
"""
import mimetypes
if not user.profile_picture:
@ -135,12 +148,21 @@ def profile_picture_files(user):
def settings_data(user):
"""Return the given user's account-level preferences as a {key: value} dict for settings.json. Only account preferences are exported; device-level preferences stay in the browser's local storage since they describe the device, not the account."""
"""Return the given user's account-level preferences as a {key: value} dict for settings.json.
Only account preferences (AccountPreference) are exported; device-level preferences stay
in the browser's local storage since they describe the device, not the account.
"""
return {pref.key: pref.value for pref in user.preferences.all()}
def import_profile(user, data, available_files):
"""Fault-tolerant import of profile.json, updating the user's editable profile fields. See docs/implementation.md#profile-import-semantics for which fields are applied and why."""
"""Fault-tolerant import of profile.json, updating the user's editable profile fields.
Only 'first_name', 'last_name', 'email' and 'profile_picture' (a 'files/...' path present in
`available_files`) are applied; 'username' and 'domain' are ignored since they identify the
account itself and can't be changed by an import.
"""
import json
try:
@ -167,7 +189,11 @@ def import_profile(user, data, available_files):
def import_settings(user, data):
"""Fault-tolerant import of settings.json; each top-level key/value pair is upserted as an AccountPreference, and unreadable data or a non-object payload is skipped rather than aborting the whole import."""
"""Fault-tolerant import of settings.json into the user's account-level preferences.
Each top-level key/value pair is upserted as an AccountPreference; unreadable data or a
non-object payload is skipped rather than aborting the whole import.
"""
import json
from authentication.models import AccountPreference
@ -191,7 +217,18 @@ def import_settings(user, data):
def delete_user_data(user):
"""Permanently delete everything that `user_data()` exports, keeping the account itself intact. Returns a summary dict describing what was removed. See docs/implementation.md#account-data-deletion for exactly what's removed and why the account itself survives."""
"""Permanently delete everything that `user_data()` exports, keeping the account itself intact.
This removes the user's inventory items (hard delete, bypassing soft-delete), storage
locations, account preferences, the friends relation on their public identity, the profile
picture, and any File blobs (profile picture / inventory attachments) that would otherwise be
orphaned - i.e. not referenced by any other inventory item or user. Files still referenced
elsewhere (they're deduplicated by content hash) are left untouched. The ToolshedUser account
(and its underlying KnownIdentity) is *not* deleted - this only wipes the account's data, it
doesn't close the account. See `delete_account()` in toolshed/api/offlinedata.py for that.
Returns a summary dict describing what was removed.
"""
from django.db import transaction
from toolshed.models import InventoryItem, StorageLocation
@ -225,7 +262,15 @@ def delete_user_data(user):
def delete_user_account(user):
"""Permanently delete the local user's account, after wiping all of its data via `delete_user_data()`. Returns a summary dict with `account` set to True. See docs/implementation.md#account-data-deletion for why the underlying KnownIdentity is kept."""
"""Permanently delete the local user's account, after wiping all of its data.
This first calls `delete_user_data()` to remove everything covered by the data export
(inventory, locations, settings, friends relation, profile picture and orphaned files),
then deletes the ToolshedUser row itself. The underlying KnownIdentity is kept so remote
friends/history relating to this identity remain intact - only the local account is closed.
Returns a summary dict describing what was removed, with `account` set to True.
"""
from django.db import transaction
with transaction.atomic():
@ -237,7 +282,11 @@ def delete_user_account(user):
def _delete_orphaned_files(file_ids):
"""Delete File rows (and their underlying blobs) in `file_ids` that are no longer referenced. Returns the number of files deleted. See docs/implementation.md#account-data-deletion for the orphan definition."""
"""Delete File rows (and their underlying blobs) in `file_ids` that are no longer referenced.
A File is considered orphaned once no InventoryItem, no ToolshedUser (profile picture), and no
WorkflowInstance (staged file) references it anymore. Returns the number of files deleted.
"""
from files.models import File
deleted = 0
@ -311,7 +360,15 @@ def get_or_create_category(path):
def import_locations(user, data):
"""Fault-tolerant import of locations.csv into StorageLocations owned by `user`. See docs/implementation.md#location-import-ordering-and-savepoints for row ordering and error-isolation rules."""
"""Fault-tolerant import of locations.csv into StorageLocations owned by `user`.
Rows are read by header label. Rows missing the required 'name' column are skipped.
Optional columns ('description', 'category') are simply omitted if absent. Locations are
processed in path-depth order so a child's parent already exists by the time it's needed.
Each row runs in its own transaction savepoint so a DB-level failure on one row (e.g. a
constraint violation) can't poison the surrounding transaction and silently break every
subsequent row.
"""
from django.db import transaction
from toolshed.models import StorageLocation
@ -339,17 +396,13 @@ def import_locations(user, data):
if category_path:
category = get_or_create_category(category_path)
defaults = {
'description': row.get('description', '') or '',
'category': category,
}
try:
location = StorageLocation.objects.get(owner=user, name=name, parent=parent)
for field, value in defaults.items():
setattr(location, field, value)
location.save(update_fields=list(defaults.keys()))
except StorageLocation.DoesNotExist:
location = StorageLocation.create_for_owner(owner=user, name=name, parent=parent, **defaults)
location, _ = StorageLocation.objects.update_or_create(
owner=user, name=name, parent=parent,
defaults={
'description': row.get('description', '') or '',
'category': category,
},
)
resolved_by_path[path] = location
imported += 1
except Exception as error:
@ -391,11 +444,22 @@ def import_friends(user, data):
class _HandleNotFound(Exception):
"""Raised when a fully qualified handle (e.g. 'git:base#tag:drill') can't be resolved, aborting the row rather than creating a new entity. See docs/implementation.md#handle-resolution-semantics."""
"""Raised when a fully qualified handle (e.g. 'git:base#tag:drill') can't be resolved.
This intentionally aborts the whole row (rather than falling back to creating a new
entity), since a handle references a *specific* entity from a *specific* origin - silently
creating a new local entity named after the raw handle string would be incorrect.
"""
def _resolve_handle(value, entity_type, model):
"""Resolve a fully qualified handle (e.g. 'git:base#tag:drill') to an *existing* model instance; never creates one. See docs/implementation.md#handle-resolution-semantics for the rationale."""
"""Resolve a fully qualified handle (e.g. 'git:base#tag:drill') to an *existing* model instance.
Never creates anything: a handle references a specific entity from a specific origin, so
silently creating a new local entity named after the raw handle string would be incorrect.
Raises `_HandleNotFound` if the handle's entity type doesn't match or no such object exists,
so the caller can skip the row and report a helpful error instead.
"""
origin, rest = value.split('#', 1)
if ':' in rest:
found_type, name = rest.split(':', 1)
@ -411,14 +475,20 @@ def _resolve_handle(value, entity_type, model):
def _quote_value_if_needed(value):
"""Wrap `value` in double quotes (CSV-style, doubling embedded quotes) if it contains a comma or quote character. See docs/implementation.md#properties-csv-encoding."""
"""Wrap `value` in double quotes (CSV-style, doubling any embedded quotes) if it contains a
comma or a quote character, so it survives sitting inside a comma-separated "handle=value"
list unambiguously.
"""
if any(ch in value for ch in ',"'):
return '"' + value.replace('"', '""') + '"'
return value
def _encode_properties_cell(item_properties):
"""Encode an item's properties as a comma-separated "handle=value" list for the 'properties' CSV cell. See docs/implementation.md#properties-csv-encoding and `_parse_properties_cell()` for the reader side."""
"""Encode an item's properties as a comma-separated "handle=value" list for the 'properties'
CSV cell, quoting a value (CSV-style) when it contains a comma or a quote character so it
still round-trips correctly. See `_parse_properties_cell()` for the reader side.
"""
entries = [
f"{ip.property.get_handle()}={_quote_value_if_needed(ip.value or '')}"
for ip in item_properties
@ -427,7 +497,15 @@ def _encode_properties_cell(item_properties):
def _split_quoted_comma_list(raw_value):
"""Split a comma-separated list into entries, honouring double-quoted substrings (CSV-style) so a quoted value's own commas aren't mistaken for separators. See docs/implementation.md#properties-csv-encoding for the quoting/whitespace rules this implements."""
"""Split a comma-separated list into entries, honouring double-quoted substrings (CSV-style,
wherever they appear in an entry) so a quoted value's own commas aren't mistaken for
separators. Doubled quotes ("") inside a quoted substring are unescaped to a single literal
quote, and the surrounding quotes themselves are stripped from the result.
Only the single space after each ", " separator (as written by `_encode_properties_cell()`)
is dropped - unlike a blanket `.strip()`, this preserves any leading/trailing whitespace that
is genuinely part of a (quoted) value.
"""
entries = []
current = []
in_quotes = False
@ -459,7 +537,12 @@ def _split_quoted_comma_list(raw_value):
def _parse_properties_cell(raw_value, resolve_property):
"""Parse the 'properties' CSV cell into a list of (Property, value) tuples. See docs/implementation.md#properties-csv-encoding for how the cell is encoded by `_encode_properties_cell()`."""
"""Parse the 'properties' CSV cell into a list of (Property, value) tuples.
The cell is a comma-separated list of "handle=value" entries; a value containing a comma or
a quote character is double-quoted (CSV-style) by `_encode_properties_cell()` so it survives
the round trip intact.
"""
raw_value = (raw_value or '').strip()
if not raw_value:
return []
@ -478,7 +561,17 @@ def _parse_properties_cell(raw_value, resolve_property):
def import_inventory(user, data, available_files):
"""Fault-tolerant import of inventory.csv into InventoryItems owned by `user`. See docs/implementation.md#inventory-import-semantics for file/handle resolution and error-isolation rules."""
"""Fault-tolerant import of inventory.csv into InventoryItems owned by `user`.
`available_files` maps the 'files' column's paths to File instances successfully extracted
from the zip; references to missing files are ignored rather than failing the row.
If a row references a fully qualified tag/property/category handle that doesn't exist
locally, the whole item is skipped (rather than creating a bogus local entity named after
the raw handle) and a helpful message is added to the returned `errors` list. Each row runs
in its own transaction savepoint so a DB-level failure on one row can't poison the
surrounding transaction and silently break every subsequent row.
"""
from django.db import transaction
from toolshed.models import Category, InventoryItem, ItemProperty, StorageLocation, Tag, Property

View file

@ -1,4 +1,3 @@
from django.core.exceptions import ObjectDoesNotExist
from rest_framework import serializers
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming
from authentication.serializers import OwnerSerializer, GroupOwnerSerializer
@ -151,50 +150,15 @@ class CategorySerializer(serializers.ModelSerializer):
return resolve_category_handle(data.split("/")[-1])
class OwnerScopedPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
"""Resolves/represents by the owner-scoped `id` rather than the model's internal pk, scoped to
the requesting user - StorageLocation.parent points at another StorageLocation, whose publicly
visible identity is now the owner-scoped id (see StorageLocation.create_for_owner), not
internal_id."""
def use_pk_only_optimization(self):
# False: to_representation needs the owner-scoped `id`, not just the internal pk that the
# PKOnlyObject optimization would otherwise limit us to.
return False
def get_queryset(self):
queryset = super().get_queryset()
request = self.context.get('request')
if request is not None and type(request.user) == KnownIdentity and request.user.user.exists():
return queryset.filter(owner=request.user.user.get())
return queryset.none()
def to_internal_value(self, data):
queryset = self.get_queryset()
try:
if isinstance(data, bool):
raise TypeError
return queryset.get(id=data)
except ObjectDoesNotExist:
self.fail('does_not_exist', pk_value=data)
except (TypeError, ValueError):
self.fail('incorrect_type', data_type=type(data).__name__)
def to_representation(self, value):
return value.id
class StorageLocationSerializer(serializers.ModelSerializer):
owner = OwnerSerializer(read_only=True)
category = serializers.CharField(required=False, allow_null=True, allow_blank=True)
parent = OwnerScopedPrimaryKeyRelatedField(queryset=StorageLocation.objects.all(), required=False,
allow_null=True)
path = serializers.SerializerMethodField()
class Meta:
model = StorageLocation
fields = ['id', 'name', 'description', 'path', 'category', 'owner', 'parent']
read_only_fields = ['id', 'path']
read_only_fields = ['path']
@staticmethod
def get_path(obj):
@ -202,9 +166,6 @@ class StorageLocationSerializer(serializers.ModelSerializer):
return StorageLocationSerializer.get_path(obj.parent) + "/" + obj.name
return obj.name
def create(self, validated_data):
return StorageLocation.create_for_owner(**validated_data)
class ItemPropertySerializer(serializers.ModelSerializer):
property = PropertySerializer(read_only=True)
@ -234,8 +195,6 @@ class InventoryItemSerializer(serializers.ModelSerializer):
properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set')
category = CategorySerializer(required=False, allow_null=True)
files = FileSerializer(many=True, read_only=True)
storage_location = OwnerScopedPrimaryKeyRelatedField(queryset=StorageLocation.objects.all(), required=False,
allow_null=True)
class Meta:
model = InventoryItem
@ -296,7 +255,11 @@ class InventoryItemSerializer(serializers.ModelSerializer):
class WorkflowInstanceSerializer(serializers.ModelSerializer):
owner = serializers.StringRelatedField(read_only=True)
# Only the hash is needed to identify a staged file, unlike InventoryItemSerializer.files. See docs/implementation.md#staged-files-are-identified-by-hash-alone.
# Hash is enough to identify a staged file (the client computes the same SHA-256 the backend
# does, and can fetch bytes from a hash-derived storage path) - for anything staged by *this*
# session there's nothing more to say, and for a file staged elsewhere (another device/tab),
# hash is what lets this session recognize and fetch it. Unlike InventoryItemSerializer.files,
# no fuller FileSerializer representation is needed here.
staged_files = serializers.SerializerMethodField()
class Meta:

View file

@ -49,12 +49,12 @@ class InventoryTestMixin(CategoryTestMixin, TagTestMixin, PropertyTestMixin):
class LocationTestMixin:
def prepare_locations(self):
self.f['loc1'] = StorageLocation.create_for_owner(name='loc1', owner=self.f['local_user1'])
self.f['loc2'] = StorageLocation.create_for_owner(name='loc2', owner=self.f['local_user1'],
category=self.f['cat1'])
self.f['loc3'] = StorageLocation.create_for_owner(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1'])
self.f['loc4'] = StorageLocation.create_for_owner(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'],
category=self.f['cat1'])
self.f['loc1'] = StorageLocation.objects.create(name='loc1', owner=self.f['local_user1'])
self.f['loc2'] = StorageLocation.objects.create(name='loc2', owner=self.f['local_user1'],
category=self.f['cat1'])
self.f['loc3'] = StorageLocation.objects.create(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1'])
self.f['loc4'] = StorageLocation.objects.create(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'],
category=self.f['cat1'])
class WorkflowTestMixin:

View file

@ -85,8 +85,20 @@ class FriendApiTestCase(UserTestMixin, ToolshedTestCase):
self.assertEqual(self.f['local_user1'].friends.count(), 1)
# Friend request/accept protocol walkthrough. See
# docs/implementation.md#friend-request-and-accept-protocol-flow.
# what ~should~ happen:
# 1. user x@A sends a friend request to user y@B
# 1.1. x@A's client sends a POST request to A/api/friendrequests/ with body {from: x@A, to: y@B}
# 1.2. A's backend creates a FriendRequestOutgoing object, containing x@A's identity and y@B's name
# 1.3. x@A's client sends a POST request to B/api/friendrequests/ with body
# {from: x@A, to: y@B, public_key: x@A's public key}
# 1.4. B's backend creates a FriendRequestIncoming object, containing y@B's and x@A's identities
# 2. user y@B accepts the friend request
# 2.1. y@B's client sends a POST request to A/api/friendsrequests/ with body
# {from: x@A, to: y@B, public_key: y@B's public key}
# 2.2. A's backend matches the data to the FriendRequestOutgoing object, deletes both and creates a Friend object,
# containing x@A's and y@B's identities
# 2.3. y@B's client sends a POST request to B/api/friends/ containing the id of the FriendRequestIncoming object
# 2.4. B's backend creates a Friend object, using the identities from the FriendRequestIncoming object
class FriendRequestListTestCase(UserTestMixin, ToolshedTestCase):

View file

@ -388,8 +388,9 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
self.assertEqual(InventoryItem.objects.filter(id=item_id).count(), 0)
def test_remote_member_without_local_account_can_edit(self):
# A remote member (KnownIdentity, no ToolshedUser row) must still act on group-owned
# items - not unauthorized just because .user.exists() is False.
# A remote member (no ToolshedUser row at all on this backend, only a KnownIdentity -
# see docs/design-in-progress/groups-mvp.md) must still be able to act on group-owned
# items here; it must not be treated as unauthorized just because .user.exists() is False.
self.f['group1'].members.add(self.f['ext_user1'].public_identity)
item_id = self.create_group_item().json()['id']
reply = client.get('/api/inventory_items/{}/'.format(item_id), self.f['ext_user1'])
@ -425,8 +426,8 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
self.assertEqual(len(reply.json()), 0)
def test_create_group_owned_item_with_full_fields(self):
# Parity with InventoryApiTestCase.test_post_new_item: tags/properties/category attach
# to a group-owned item the same way as a personal one.
# Parity with InventoryApiTestCase.test_post_new_item - tags/properties/category must
# attach to a group-owned item exactly the same way they do for a personal one.
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
'availability_policy': 'rent',
'category': 'cat2',
@ -449,7 +450,8 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
self.assertEqual([p.value for p in item.itemproperty_set.all()], ['value1', 'value2'])
def test_create_group_owned_item_empty_fails(self):
# Parity with InventoryApiTestCase.test_post_new_item_empty: clean()'s name-or-files validation still applies.
# Parity with InventoryApiTestCase.test_post_new_item_empty - clean()'s name-or-files
# validation must still apply to group-owned items.
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
'availability_policy': 'private', 'owned_quantity': 1, 'owner_group': self.f['group1'].id,
})
@ -469,8 +471,9 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
self.assertEqual(len(reply.json()), 0)
def test_put_group_item(self):
# Parity with InventoryApiTestCase.test_put_item, but as a PUT by a different member than
# the creator, to exercise the _is_authorized branch in perform_update for PUT too.
# Parity with InventoryApiTestCase.test_put_item - full replace, by a different member
# than the one who created it, exercising the group_items_id -> _is_authorized branch
# in perform_update for a PUT (not just PATCH).
item_id = self.create_group_item().json()['id']
reply = client.put('/api/inventory_items/{}/'.format(item_id), self.f['local_user2'], {
'availability_policy': 'sell',
@ -536,8 +539,9 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
self.assertEqual([f for f in item.files.all()], [self.f['test_file3']])
def test_group_items_excluded_from_search(self):
# Group-owned items are reachable only via the group's own detail page for MVP (see
# docs/design-in-progress/groups-mvp.md) - search must not surface them either.
# Group-owned items are only ever reachable via the group's own detail page for MVP
# (see docs/design-in-progress/groups-mvp.md) - search must not surface them, same as
# the main Inventory list already doesn't.
self.create_group_item(name='searchable-drill')
InventoryItem.create_for_owner(owner=self.f['local_user1'], owned_quantity=1, name='searchable-personal')
reply = client.get('/api/search/?query=searchable', self.f['local_user1'])
@ -547,8 +551,8 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
class InventoryItemIdAllocationTestCase(UserTestMixin, ToolshedTestCase):
"""InventoryItem.id is sequential, gapless, and never reused within each owner/owner_group's
own items, allocated independently per scope (see OwnerItemSequence)."""
"""InventoryItem.id is sequential and gapless within each owner/owner_group's own items,
never reused, and allocated independently per scope - see OwnerItemSequence."""
def setUp(self):
super().setUp()

View file

@ -123,7 +123,10 @@ class DeleteAccountTestCase(_DeleteTestDataMixin, ToolshedTestCase):
class ImportInventoryPropertiesTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, PropertyTestMixin,
ToolshedTestCase):
"""Properties must round-trip through export/import even when their value contains a comma or '=' sign, which a naive "handle=value, handle2=value2" encoding would otherwise misinterpret as a separator."""
"""Properties must round-trip through export/import even when their value contains a
comma or an '=' sign - characters that a naive "handle=value, handle2=value2" encoding of
the 'properties' CSV cell would misinterpret as a field/entry separator.
"""
def setUp(self):
super().setUp()
@ -219,7 +222,10 @@ class ImportInventoryPropertiesTestCase(UserTestMixin, CategoryTestMixin, TagTes
class ExportImportApiRoundTripTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, PropertyTestMixin,
ToolshedTestCase):
"""End-to-end coverage of the /api/export/ + /api/import/ endpoints (as actually used by clients), rather than calling the internal helper functions directly - this is what a real export/import round trip between two accounts looks like."""
"""End-to-end coverage of the /api/export/ + /api/import/ endpoints (as actually used by
clients), rather than calling the internal helper functions directly - this is what a real
export/import round trip between two accounts looks like.
"""
def setUp(self):
super().setUp()

View file

@ -1 +1,14 @@
FROM nginx:bookworm
# snakeoil for localhost
RUN apt-get update && \
apt-get install -y openssl && \
openssl genrsa -des3 -passout pass:x -out server.pass.key 2048 && \
openssl rsa -passin pass:x -in server.pass.key -out server.key && \
rm server.pass.key && \
openssl req -new -key server.key -out server.csr \
-subj "/C=US/ST=Denial/L=Springfield/O=Dis/CN=localhost" && \
openssl x509 -req -days 365 -in server.csr -signkey server.key -out server.crt &&\
mv server.crt /etc/nginx/nginx.crt && \
mv server.key /etc/nginx/nginx.key \

View file

@ -72,14 +72,6 @@ services:
- ./instance_a/dns.json:/var/www/dns.json:ro
- ./instance_a/domains.json:/var/www/domains.json:ro
- ./instance_a/userfiles:/var/www/userfiles:ro
# A stable, CA-signed cert (see frontend/.local/make_localhost.sh) covering localhost plus
# every loopback IP a dev proxy binds to below, instead of Dockerfile.proxy generating a
# fresh throwaway self-signed one on every image build - that regenerated cert invalidated
# any trust exception you'd added in your browser on the previous build. Trust
# frontend/.local/RootCA.crt once (see docs/development.md) and it keeps working across
# rebuilds.
- ../../frontend/.local/localhost.crt:/etc/nginx/nginx.crt:ro
- ../../frontend/.local/localhost.key:/etc/nginx/nginx.key:ro
ports:
- "127.0.0.1:8080:8080"
- "127.0.0.3:5353:5353"
@ -91,8 +83,6 @@ services:
volumes:
- ./instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro
- ./instance_b/userfiles:/var/www/userfiles:ro
- ../../frontend/.local/localhost.crt:/etc/nginx/nginx.crt:ro
- ../../frontend/.local/localhost.key:/etc/nginx/nginx.key:ro
ports:
- "127.0.0.2:8080:8080"

View file

@ -48,27 +48,17 @@ toolshed:
```
- `toolshed_domain` — the **web domain**: the nginx `server_name`, Django
`ALLOWED_HOSTS`, and the hostname(s) you'll point a TLS cert at — e.g.
`toolshed.webdomain.tld`. Required, no default. May be a single domain (as
above) or a list, e.g. to also answer on a `www.` alias:
```yaml
toolshed_domain:
- toolshed.webdomain.tld
- www.toolshed.webdomain.tld
```
The Let's Encrypt certificate covers all of them, named on disk after
whichever one is listed first. This is not necessarily the same as the
**handle domain** your users log in with (the part after `@` in
`user@yourtoolshed.tld`) — see [DNS](#3-dns) for how those two relate.
`ALLOWED_HOSTS`, and the hostname you'll point a TLS cert at — e.g.
`toolshed.webdomain.tld`. Required, no default. This is not necessarily the
same as the **handle domain** your users log in with (the part after `@`
in `user@yourtoolshed.tld`) — see [DNS](#3-dns) for how those two relate.
- `toolshed_handle_domain` — the **handle domain**, only needed when it's
different from `toolshed_domain`. Omit it when the two are the same (it
then defaults to `toolshed_domain`). Like `toolshed_domain`, it may be a
single domain or a list, e.g. if this deployment accepts registrations for
more than one handle domain. It doesn't affect nginx/Django at all (they
only ever accept `toolshed_domain` as the `Host` header) — it's used
then defaults to `toolshed_domain`). It doesn't affect nginx/Django at all
(they only ever accept `toolshed_domain` as the `Host` header) — it's used
solely to populate the `/local/domains` registration fixture (see
`toolshed_register_domains` in `playbook.yml`); publishing the SRV record for
each handle domain is a separate, manual DNS step either way.
`toolshed_register_domains` in `playbook.yml`); publishing the SRV record is a
separate, manual DNS step either way.
- `toolshed_repo_url` — the git remote the playbook checks out and builds
from. Required, no default.
- `toolshed_version` — the branch, tag or commit to check out and build.

View file

@ -10,21 +10,10 @@ toolshed:
ansible_user: deploy
# toolshed_domain is the "web domain" - see the README's DNS section
# for how this relates to the separate "handle domain" your users
# log in with (user@yourtoolshed.tld). May be a single domain (as
# here) or a list, e.g. to also answer on a "www." alias:
# toolshed_domain:
# - toolshed.webdomain.tld
# - www.toolshed.webdomain.tld
# The Let's Encrypt certificate is requested for all of them, named
# after whichever one is listed first.
# log in with (user@yourtoolshed.tld).
toolshed_domain: toolshed.webdomain.tld
# Optional - only needed if the handle domain differs from the web
# domain above. Omit it entirely when they're the same. Like
# toolshed_domain, this may be a single domain or a list, e.g. if this
# deployment accepts registrations for more than one handle domain:
# toolshed_handle_domain:
# - yourtoolshed.tld
# - alt.yourtoolshed.tld
# domain above. Omit it entirely when they're the same.
toolshed_handle_domain: yourtoolshed.tld
toolshed_repo_url: git@example.com:your-org/toolshed.git
# Optional - branch, tag or commit to deploy. Defaults to "stable".

View file

@ -15,9 +15,8 @@
# at a time, never the whole "toolshed" group in one run):
# ansible-playbook -i inventory.yml playbook.yml --limit my-server
#
# toolshed_repo_url, toolshed_domain, toolshed_handle_domain (optional,
# either may be a single domain or a list of domains), toolshed_version
# (optional, defaults to "stable"), behind_tls_proxy and
# toolshed_repo_url, toolshed_domain, toolshed_handle_domain (optional),
# toolshed_version (optional, defaults to "stable"), behind_tls_proxy and
# toolshed_letsencrypt_email (required unless behind_tls_proxy is true) are
# per-deployment and must be set as host_vars in inventory.yml (copy
# inventory.example.yml) rather than here or via -e, so that each host in
@ -54,17 +53,11 @@
# proxying to gunicorn for every asset request.
toolshed_static_dir: /var/www/toolshed-static
# Domain(s) this server accepts registrations for (the "handle domain" -
# see the README's DNS section). toolshed_handle_domain may be a single
# domain or a list; when unset it falls back to toolshed_domain (whole
# list, if that's a list too). Served as a static /local/domains fixture
# that the frontend's registration/pairing forms fetch to populate their
# domain dropdown (frontend/src/views/Register.vue, Pairing.vue) -
# without it that dropdown is just empty.
toolshed_handle_domain_or_default: "{{ toolshed_handle_domain | default(toolshed_domain) }}"
toolshed_register_domains: >-
{{ ([toolshed_handle_domain_or_default]
if toolshed_handle_domain_or_default is string
else toolshed_handle_domain_or_default) | unique }}
# see the README's DNS section). Served as a static /local/domains
# fixture that the frontend's registration/pairing forms fetch to
# populate their domain dropdown (frontend/src/views/Register.vue,
# Pairing.vue) - without it that dropdown is just empty.
toolshed_register_domains: "{{ [toolshed_handle_domain | default(toolshed_domain)] | unique }}"
# DoH resolvers the frontend falls back to for SRV lookups when it has
# no cached preference yet, served as a static /local/dns fixture. These
# match the frontend's own hardcoded fallback (frontend/src/dns.js), so
@ -97,18 +90,8 @@
# and doesn't necessarily have an A record pointing at this host at all
# (see the README's DNS section), so it can't reliably serve an HTTP-01
# challenge or ever show up as this nginx's Host header.
#
# toolshed_domain may be a single domain or a list (e.g. a bare domain
# plus a "www." alias). certbot names the Let's Encrypt certificate's
# live/ directory after whichever domain is passed first via -d, so
# toolshed_hostnames[0] (below) is used wherever the playbook needs to
# reference that directory by name.
toolshed_domain_checked: >-
{{ toolshed_domain | mandatory('toolshed_domain must be set as a host_var for ' ~ inventory_hostname) }}
toolshed_hostnames: >-
{{ ([toolshed_domain_checked]
if toolshed_domain_checked is string
else toolshed_domain_checked) | unique }}
{{ [toolshed_domain | mandatory('toolshed_domain must be set as a host_var for ' ~ inventory_hostname)] }}
# Generated once per host on the controller and reused on every
# subsequent run against that host, keyed by inventory_hostname so
# separate deployments never end up sharing a Django SECRET_KEY.
@ -260,8 +243,8 @@
listen [::]:443 ssl;
server_name {{ toolshed_hostnames | join(' ') }};
ssl_certificate /etc/letsencrypt/live/{{ toolshed_hostnames[0] }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ toolshed_hostnames[0] }}/privkey.pem;
ssl_certificate /etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ toolshed_domain }}/privkey.pem;
client_max_body_size 128M;
root {{ toolshed_dist_dir }};
@ -347,13 +330,19 @@
# until reloaded.
notify: reload nginx
# Owned by ansible_user rather than www-data up front: the frontend dist
# sync below pushes files over a plain rsync-over-ssh connection as
# ansible_user (synchronize shells out to the local rsync binary, which
# opens its own ssh session - it doesn't go through Ansible's become),
# so that account needs write access here first. "Fix ownership of
# exported frontend build" resets this to www-data (via become) right
# after the sync completes.
- name: Create frontend static output directory
ansible.builtin.file:
path: "{{ toolshed_dist_dir }}"
state: directory
owner: "www-data"
group: "www-data"
mode: "0750"
owner: "{{ ansible_user }}"
mode: "0755"
- name: Create backend static output directory
ansible.builtin.file:
@ -361,7 +350,7 @@
state: directory
owner: www-data
group: www-data
mode: "0750"
mode: "0755"
- name: Write backend environment file
ansible.builtin.copy:
@ -581,7 +570,7 @@
- name: Check for an existing Let's Encrypt certificate
ansible.builtin.stat:
path: "/etc/letsencrypt/live/{{ toolshed_hostnames[0] }}/fullchain.pem"
path: "/etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem"
register: toolshed_cert
when: not (behind_tls_proxy | default(false) | bool)
@ -644,7 +633,7 @@
- name: Re-check the certificate now that certbot has run
ansible.builtin.stat:
path: "/etc/letsencrypt/live/{{ toolshed_hostnames[0] }}/fullchain.pem"
path: "/etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem"
register: toolshed_cert
when: not (behind_tls_proxy | default(false) | bool)

View file

@ -10,6 +10,5 @@ This is the documentation for the Toolshed project. It is a work in progress.
- [Development Setup](development.md)
- [About Federation](federation.md)
- [Handles and Short IDs](handles-and-shortids.md)
- [Implementation Notes](implementation.md)

View file

@ -5,30 +5,31 @@
"requires": true,
"dependencies": {
"@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="
"version": "7.24.8",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz",
"integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ=="
},
"@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz",
"integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w=="
},
"@babel/parser": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
"version": "7.25.6",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz",
"integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==",
"requires": {
"@babel/types": "^7.29.8"
"@babel/types": "^7.25.6"
}
},
"@babel/types": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"version": "7.25.6",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz",
"integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==",
"requires": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
"@babel/helper-string-parser": "^7.24.8",
"@babel/helper-validator-identifier": "^7.24.7",
"to-fast-properties": "^2.0.0"
}
},
"@esbuild/android-arm": {
@ -200,9 +201,9 @@
}
},
"@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="
},
"@leichtgewicht/base64-codec": {
"version": "1.0.0",
@ -249,30 +250,33 @@
"optional": true
},
"@tootallnate/once": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz",
"integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
"integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==",
"dev": true
},
"@types/chai": {
"version": "4.3.20",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz",
"integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==",
"version": "4.3.19",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.19.tgz",
"integrity": "sha512-2hHHvQBVE2FiSK4eN0Br6snX9MtolHaTo/batnLjlGRhoQzlCL61iVpxoqO7SfFyOw+P/pwv+0zNHzKoGWz9Cw==",
"dev": true
},
"@types/chai-subset": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.6.tgz",
"integrity": "sha512-m8lERkkQj+uek18hXOZuec3W/fCRTrU4hrnXjH3qhHy96ytuPaPiWGgu7sJb7tZxZonO75vYAjCvpe/e4VUwRw==",
"dev": true
},
"@types/node": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.5.tgz",
"integrity": "sha512-c2mPnw+xHtXDoHmdtcCXGwyLMiauiAyxWMzhGpqHC4nqI/Y5G2XhTampslK2rb59kpcuHon03UH8W6iYUzw88A==",
"dev": true,
"requires": {
"undici-types": "~8.3.0"
"@types/chai": "*"
}
},
"@types/node": {
"version": "22.5.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz",
"integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==",
"dev": true,
"requires": {
"undici-types": "~6.19.2"
}
},
"@vitejs/plugin-vue": {
@ -451,15 +455,15 @@
"dev": true
},
"acorn": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"version": "8.12.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
"integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
"dev": true
},
"acorn-walk": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
"dev": true,
"requires": {
"acorn": "^8.11.0"
@ -475,15 +479,15 @@
}
},
"ansi-regex": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
"integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
"dev": true
},
"ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
"dev": true
},
"anymatch": {
@ -537,9 +541,9 @@
"integrity": "sha512-Xba1GTDYon8KYSDTKiiAtiyfk4clhdKQYvCQPMkE58+F5loVwEmh0Wi+ECCfowNc9SGwpoSLpSkvg7rhgZBttw=="
},
"brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0"
@ -565,16 +569,6 @@
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true
},
"call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"requires": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
}
},
"chai": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
@ -662,9 +656,9 @@
}
},
"confbox": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.7.tgz",
"integrity": "sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==",
"dev": true
},
"config-chain": {
@ -678,9 +672,9 @@
}
},
"cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"dev": true,
"requires": {
"path-key": "^3.1.0",
@ -698,9 +692,9 @@
}
},
"csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"data-urls": {
"version": "4.0.0",
@ -723,18 +717,18 @@
}
},
"debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dev": true,
"requires": {
"ms": "^2.1.3"
}
},
"decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
"integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==",
"dev": true
},
"deep-eql": {
@ -773,17 +767,6 @@
"webidl-conversions": "^7.0.0"
}
},
"dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"requires": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
}
},
"eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@ -791,14 +774,14 @@
"dev": true
},
"editorconfig": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz",
"integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==",
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
"integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
"dev": true,
"requires": {
"@one-ini/wasm": "0.1.1",
"commander": "^10.0.0",
"minimatch": "^9.0.1",
"minimatch": "9.0.1",
"semver": "^7.5.3"
}
},
@ -813,39 +796,6 @@
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="
},
"es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true
},
"es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true
},
"es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"dev": true,
"requires": {
"es-errors": "^1.3.0"
}
},
"es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"requires": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
}
},
"esbuild": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
@ -906,26 +856,24 @@
}
},
"foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
"integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==",
"dev": true,
"requires": {
"cross-spawn": "^7.0.6",
"cross-spawn": "^7.0.0",
"signal-exit": "^4.0.1"
}
},
"form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dev": true,
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
"mime-types": "^2.1.12"
}
},
"fsevents": {
@ -935,50 +883,16 @@
"dev": true,
"optional": true
},
"function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true
},
"get-func-name": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
"integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
"dev": true
},
"get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"requires": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
}
},
"get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"requires": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
}
},
"glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"dev": true,
"requires": {
"foreground-child": "^3.1.0",
@ -987,6 +901,17 @@
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"dependencies": {
"minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"requires": {
"brace-expansion": "^2.0.1"
}
}
}
},
"glob-parent": {
@ -998,36 +923,6 @@
"is-glob": "^4.0.1"
}
},
"gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true
},
"has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true
},
"has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"requires": {
"has-symbols": "^1.0.3"
}
},
"hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dev": true,
"requires": {
"function-bind": "^1.1.2"
}
},
"html-encoding-sniffer": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
@ -1068,9 +963,9 @@
}
},
"immutable": {
"version": "4.3.9",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz",
"integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==",
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz",
"integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==",
"dev": true
},
"ini": {
@ -1143,22 +1038,22 @@
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg=="
},
"js-beautify": {
"version": "1.15.4",
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz",
"integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==",
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.1.tgz",
"integrity": "sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==",
"dev": true,
"requires": {
"config-chain": "^1.1.13",
"editorconfig": "^1.0.4",
"glob": "^10.4.2",
"glob": "^10.3.3",
"js-cookie": "^3.0.5",
"nopt": "^7.2.1"
"nopt": "^7.2.0"
}
},
"js-cookie": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz",
"integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==",
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"dev": true
},
"js-nacl": {
@ -1210,9 +1105,9 @@
"dev": true
},
"lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
},
"loupe": {
@ -1238,12 +1133,6 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true
},
"md5-hex": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/md5-hex/-/md5-hex-3.0.1.tgz",
@ -1269,38 +1158,30 @@
}
},
"minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz",
"integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
"dev": true,
"requires": {
"brace-expansion": "^2.0.2"
"brace-expansion": "^2.0.1"
}
},
"minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true
},
"mlly": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
"integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.1.tgz",
"integrity": "sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==",
"dev": true,
"requires": {
"acorn": "^8.16.0",
"pathe": "^2.0.3",
"pkg-types": "^1.3.1",
"ufo": "^1.6.3"
},
"dependencies": {
"pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true
}
"acorn": "^8.11.3",
"pathe": "^1.1.2",
"pkg-types": "^1.1.1",
"ufo": "^1.5.3"
}
},
"moment": {
@ -1315,9 +1196,9 @@
"dev": true
},
"nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g=="
},
"nopt": {
"version": "7.2.1",
@ -1335,9 +1216,9 @@
"dev": true
},
"nwsapi": {
"version": "2.2.24",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
"integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
"version": "2.2.12",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.12.tgz",
"integrity": "sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w==",
"dev": true
},
"p-limit": {
@ -1350,26 +1231,18 @@
}
},
"package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz",
"integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==",
"dev": true
},
"parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
"integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==",
"dev": true,
"requires": {
"entities": "^6.0.0"
},
"dependencies": {
"entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true
}
"entities": "^4.4.0"
}
},
"path-key": {
@ -1406,28 +1279,20 @@
"integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw=="
},
"picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true
},
"pkg-types": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.2.0.tgz",
"integrity": "sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==",
"dev": true,
"requires": {
"confbox": "^0.1.8",
"mlly": "^1.7.4",
"pathe": "^2.0.1"
},
"dependencies": {
"pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true
}
"confbox": "^0.1.7",
"mlly": "^1.7.1",
"pathe": "^1.1.2"
}
},
"popper.js": {
@ -1477,13 +1342,10 @@
"dev": true
},
"psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"dev": true,
"requires": {
"punycode": "^2.3.1"
}
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
"integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==",
"dev": true
},
"punycode": {
"version": "2.3.1",
@ -1519,9 +1381,9 @@
"dev": true
},
"rollup": {
"version": "3.30.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz",
"integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==",
"version": "3.29.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz",
"integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==",
"dev": true,
"requires": {
"fsevents": "~2.3.2"
@ -1560,9 +1422,9 @@
}
},
"semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true
},
"shebang-command": {
@ -1604,9 +1466,9 @@
"dev": true
},
"std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz",
"integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==",
"dev": true
},
"string-width": {
@ -1655,12 +1517,12 @@
}
},
"strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
"dev": true,
"requires": {
"ansi-regex": "^6.2.2"
"ansi-regex": "^6.0.1"
}
},
"strip-ansi-cjs": {
@ -1719,6 +1581,11 @@
"integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==",
"dev": true
},
"to-fast-properties": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
"integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog=="
},
"to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -1756,15 +1623,15 @@
"dev": true
},
"ufo": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz",
"integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==",
"dev": true
},
"undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"dev": true
},
"universalify": {
@ -1875,9 +1742,9 @@
}
},
"vue-component-type-helpers": {
"version": "2.2.12",
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz",
"integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==",
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.1.6.tgz",
"integrity": "sha512-ng11B8B/ZADUMMOsRbqv0arc442q7lifSubD0v8oDXIFoMg/mXwAPUunrroIDkY+mcD0dHKccdaznSVp8EoX3w==",
"dev": true
},
"vue-multiselect": {
@ -2032,9 +1899,9 @@
}
},
"ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"dev": true
},
"xml-name-validator": {
@ -2050,9 +1917,9 @@
"dev": true
},
"yocto-queue": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
"integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz",
"integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==",
"dev": true
}
}

View file

@ -1,5 +1,7 @@
// Shared camera stream manager for webcam access (WebcamFileSource, Scan) - centralizes device
// enumeration, preferred-camera memory, stream reuse, and disconnect/reconnect handling.
// Shared camera stream manager used by any component that needs webcam access
// (WebcamFileSource, Scan). Centralizing this means every consumer gets device
// enumeration, preferred-camera memory, stream reuse and disconnect/reconnect
// handling for free instead of re-implementing it per component.
class CameraManager {
constructor() {
this.availableCameras = [];

View file

@ -73,8 +73,9 @@ export default {
return;
}
try {
// Cached by src alone: content is hash-addressed and immutable (see fileCache.js),
// so every instance showing the same file shares one fetch and blob.
// Cached by src alone (content is hash-addressed and immutable - see
// fileCache.js) so every AuthenticatedImage instance showing the same
// file, across the whole app, shares one fetch and one decoded blob.
const url = await fileCache.get(this.src, async () => {
this.servers = await this.getFriendServers({username: this.owner});
const response = await this.servers.getRaw(this.signAuth, this.src);

View file

@ -9,9 +9,6 @@
<SearchBox/>
<div class="navbar-collapse collapse">
<ul class="navbar-nav navbar-align">
<router-link to="/scan?navigate=immediate" class="nav-icon">
<b-icon-qr-code-scan class="bi-valign-middle"></b-icon-qr-code-scan>
</router-link>
<Notifications :notifications="notifications"/>
<Messages :messages="messages"/>
<UserDropdown/>

View file

@ -1,618 +0,0 @@
<template>
<div class="camera-scanner">
<p v-if="!cameraSupported" class="text-muted">
This browser has no camera API available.
</p>
<template v-else>
<p v-if="cameraRes" class="text-muted small">{{ cameraRes }}</p>
<div class="video-wrap">
<video ref="video" autoplay muted playsinline></video>
<canvas ref="overlay"></canvas>
</div>
<div class="row g-2 align-items-end mb-3">
<div class="col-auto flex-grow-1">
<select class="form-control" v-model="selectedCameraId">
<option value="">Auto (prefer recent, then rear camera)</option>
<option v-for="(c, i) in cameras" :key="c.deviceId"
:value="c.deviceId">
{{ cleanCameraLabel(c.label) || ('Camera ' + (i + 1)) }}
</option>
</select>
</div>
<div v-if="cameraRunning" class="col-auto">
<button class="btn btn-outline-secondary" @click="stopCamera">
<b-icon-stop-fill class="me-1"></b-icon-stop-fill>
Stop
</button>
</div>
</div>
</template>
</div>
</template>
<script>
import * as BIcons from "bootstrap-icons-vue";
import {loadAnyDCode, toGrayFrame} from "../../vendor/anyd-qr.js";
// Same file as above, but as a plain URL (Vite's `?url` suffix skips bundling it into this
// module) - the decode worker built in createDecodeWorker() below needs a fetchable/importable
// URL for its own, independent `import()`, since a worker has no access to this module's own
// import bindings.
import anydUrl from "../../vendor/anyd-qr.js?url";
import cameraManager from "@/cameraManager.js";
// How long a detected code's overlay box stays drawn before fading, so it doesn't linger once
// the code has moved out of frame.
const OVERLAY_CLEAR_MS = 2000;
// Capture-loop tuning, matching prototypes/anydcode-wasm's advanced camera demo's own
// defaults/findings (see its parameter-sweep-findings.md) but at a rate suited to a
// battery-conscious inventory-scanning UI rather than that demo's max-responsiveness dial.
const CAPTURE_FPS = 4;
const CAPTURE_DOWNSCALE = 2;
// Milliseconds to suppress re-emitting a symbology+text pair already reported - independent of
// (and shorter than) COOLDOWN_MS below, since it throttles noisy duplicate "scan" events even
// on ticks where a candidate did get decoded (e.g. because the worker was otherwise idle).
const EMIT_DEDUPE_MS = 800;
// Location-based cooldown: skip re-decoding a candidate whose box overlaps a recent successful
// decode while the worker has other work pending, so a code sitting still in frame doesn't keep
// re-paying decode2d's cost every tick. Ported the IoU-overlap idea (not the code) from
// prototypes/anydcode-wasm/packages/qr-wasm/advanced_demo/pipeline.js's createCooldownTracker.
const COOLDOWN_MS = 1500;
const COOLDOWN_IOU_THRESHOLD = 0.3;
// A crop bigger than this (px, longer side) is almost always oversampled for its module count,
// so shrink it before decode2d - cheaper, and no less reliable for a close-up code - capped at
// MAX_DECODE_DOWNSCALE so a genuinely complex/high-version code that happens to fill the frame
// isn't pushed below a safe pixels-per-module floor. Values as validated by the advanced demo's
// parameter sweep (see its parameter-sweep-findings.md).
const DECODE_TARGET_CROP_SIZE = 400;
const MAX_DECODE_DOWNSCALE = 3;
// A queued decode job older than this is dropped unsent rather than dispatched - by the time the
// (single) worker would reach it, the frame it came from is stale enough that decoding it is
// wasted work. MAX_DECODE_QUEUE bounds how many candidates can be waiting at once; there's only
// one worker, so this is a queue depth, not a pool size.
const MAX_QUEUE_AGE_MS = 1000;
const MAX_DECODE_QUEUE = 4;
function boundingBox(corners) {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const [x, y] of corners) {
if (x < x0) x0 = x;
if (y < y0) y0 = y;
if (x > x1) x1 = x;
if (y > y1) y1 = y;
}
return {x0, y0, x1, y1};
}
// Intersection-over-union of two `{x0,y0,x1,y1}` boxes, 0 (no overlap) to 1 (identical).
function iou(a, b) {
const x0 = Math.max(a.x0, b.x0);
const y0 = Math.max(a.y0, b.y0);
const x1 = Math.min(a.x1, b.x1);
const y1 = Math.min(a.y1, b.y1);
const interArea = Math.max(0, x1 - x0) * Math.max(0, y1 - y0);
if (interArea === 0) return 0;
const areaA = (a.x1 - a.x0) * (a.y1 - a.y0);
const areaB = (b.x1 - b.x0) * (b.y1 - b.y0);
return interArea / (areaA + areaB - interArea);
}
// Crop a sub-frame out of `luma` (row-major, `width`x`height`), expanding by `pad` pixels and
// clamping to the source bounds, so a tight `locate()` box still leaves the decoder its quiet
// zone.
function cropGray(width, height, luma, box, pad) {
const x0 = Math.max(0, Math.floor(box.x0 - pad));
const y0 = Math.max(0, Math.floor(box.y0 - pad));
const x1 = Math.min(width, Math.ceil(box.x1 + pad));
const y1 = Math.min(height, Math.ceil(box.y1 + pad));
const w = Math.max(1, x1 - x0);
const h = Math.max(1, y1 - y0);
const out = new Uint8Array(w * h);
for (let row = 0; row < h; row++) {
const srcStart = (y0 + row) * width + x0;
out.set(luma.subarray(srcStart, srcStart + w), row * w);
}
return {width: w, height: h, luma: out, offsetX: x0, offsetY: y0};
}
// Pick how much to shrink this candidate's crop before decode2d(), from its own detected size -
// `1` (no change) for the common small/typical case; only a candidate detected as unusually
// large gets downscaled, and only by as much as its size actually calls for.
function chooseDecodeDownscale(box, targetSize, maxDownscale) {
const size = Math.max(box.x1 - box.x0, box.y1 - box.y0);
return Math.min(maxDownscale, Math.max(1, Math.floor(size / targetSize)));
}
// Crop `box` (expanded by `pad`, clamped to the frame) for decoding, downscaling by
// `decodeDownscale` in the same step when that's `> 1`. At `1` this is exactly `cropGray`; a
// real downscale instead re-samples from `sourceCanvas` (the RGBA source) so the browser's own
// image scaling does the antialiased averaging a manual subsample of an already-grayscale array
// would not.
function cropForDecode(width, height, luma, box, pad, decodeDownscale, sourceCanvas, scratchCanvas, scratchCtx) {
if (decodeDownscale === 1) {
return {...cropGray(width, height, luma, box, pad), decodeDownscale};
}
const x0 = Math.max(0, Math.floor(box.x0 - pad));
const y0 = Math.max(0, Math.floor(box.y0 - pad));
const x1 = Math.min(width, Math.ceil(box.x1 + pad));
const y1 = Math.min(height, Math.ceil(box.y1 + pad));
const srcW = Math.max(1, x1 - x0);
const srcH = Math.max(1, y1 - y0);
const dstW = Math.max(1, Math.round(srcW / decodeDownscale));
const dstH = Math.max(1, Math.round(srcH / decodeDownscale));
if (scratchCanvas.width !== dstW || scratchCanvas.height !== dstH) {
scratchCanvas.width = dstW;
scratchCanvas.height = dstH;
}
scratchCtx.drawImage(sourceCanvas, x0, y0, srcW, srcH, 0, 0, dstW, dstH);
const cropLuma = toGrayFrame(scratchCtx.getImageData(0, 0, dstW, dstH)).luma;
return {width: dstW, height: dstH, luma: cropLuma, offsetX: x0, offsetY: y0, decodeDownscale};
}
// Builds the off-main-thread decode2d()/decode1d() half of the pipeline: a single Worker (its
// source assembled from a template string, not a separate file, to keep this pipeline contained
// in this component) that dynamically imports the same anyd-qr.js bundle already used on the
// main thread for locate(), so a slow decode never blocks capture, detection, or this
// component's own overlay/UI rendering. It gets its own wasm instance - a worker's wasm memory
// isn't shared with the main thread - via that import's own `loadAnyDCode()` cache, exactly like
// the main thread's `anydPromise` below.
function createDecodeWorker() {
const absoluteAnydUrl = new URL(anydUrl, window.location.href).href;
const source = `
import { loadAnyDCode } from ${JSON.stringify(absoluteAnydUrl)};
const anydPromise = loadAnyDCode();
self.onmessage = async (event) => {
const {jobId, width, height, luma} = event.data;
try {
const anyd = await anydPromise;
const codes = anyd.decodeImage({width, height, luma: new Uint8Array(luma)});
self.postMessage({type: "result", jobId, codes});
} catch (err) {
self.postMessage({type: "error", jobId, message: err?.message ?? String(err)});
}
};
`;
const blobUrl = URL.createObjectURL(new Blob([source], {type: "application/javascript"}));
const worker = new Worker(blobUrl, {type: "module"});
URL.revokeObjectURL(blobUrl);
return worker;
}
export default {
name: "CameraScanner",
components: {
...BIcons
},
props: {
// Whether to open the camera as soon as it's available (still gated on browser support
// and a secure context - the camera API is unavailable otherwise). false lets a caller
// that wants to gate opening the camera behind its own condition (e.g. a "scan" button)
// skip the auto-open.
autostart: {
type: Boolean,
default: true,
},
},
emits: [
// Fired once per onDecode batch with anyd-qr.js's raw decoded codes (type/text/metadata/box).
"scan",
// Fired with a message string when a camera error occurs, or null once it clears (e.g. on
// reconnect) - lets the caller show/clear it alongside errors from its own other sources.
"error",
],
data() {
return {
cameraSupported: Boolean(navigator.mediaDevices?.getUserMedia),
cameras: [],
localSelectedCameraId: "",
cameraRunning: false,
cameraRes: "",
};
},
computed: {
// Switches camera immediately on selection change (no explicit "apply" step), matching
// prototypes/camera-inputs/InputPhoto.vue's selectedCameraId.
selectedCameraId: {
get() {
return this.localSelectedCameraId;
},
set(value) {
this.localSelectedCameraId = value;
if (this.cameraRunning) {
this.switchCamera(value || null);
}
},
},
},
methods: {
// Device labels only populate once permission is granted, so this is called again right
// after getUserMedia resolves in startCamera, not just once up front.
async populateCameraList() {
if (!navigator.mediaDevices?.enumerateDevices) {
return;
}
await cameraManager.enumerateDevices();
this.cameras = cameraManager.getAvailableCameras();
},
cleanCameraLabel(label) {
if (!label) return "";
const parts = label.split(":").map((p) => p.trim());
if (parts.length === 2 && parts[0] === parts[1]) {
return parts[0];
}
return label;
},
syncOverlaySize() {
const video = this.$refs.video;
const overlay = this.$refs.overlay;
if (!video || !overlay) {
return;
}
overlay.width = video.clientWidth;
overlay.height = video.clientHeight;
},
// Draws each detected code's box (video's native pixel space, scaled to the overlay's
// displayed size) and fades it out after OVERLAY_CLEAR_MS.
drawOverlay(codes) {
const overlay = this.$refs.overlay;
const video = this.$refs.video;
if (!overlay || !video || !video.videoWidth) {
return;
}
const ctx = overlay.getContext("2d");
ctx.clearRect(0, 0, overlay.width, overlay.height);
const scaleX = overlay.width / video.videoWidth;
const scaleY = overlay.height / video.videoHeight;
ctx.lineWidth = 3;
ctx.strokeStyle = "#3ce77c";
ctx.fillStyle = "#3ce77c";
ctx.font = "14px sans-serif";
for (const code of codes) {
if (!code.box) {
continue;
}
const {x0, y0, x1, y1} = code.box;
ctx.strokeRect(x0 * scaleX, y0 * scaleY, (x1 - x0) * scaleX, (y1 - y0) * scaleY);
ctx.fillText(`${code.type}: ${code.text ?? ""}`, x0 * scaleX, Math.max(14, y0 * scaleY - 4));
}
clearTimeout(this.clearOverlayTimer);
this.clearOverlayTimer = setTimeout(
() => ctx.clearRect(0, 0, overlay.width, overlay.height), OVERLAY_CLEAR_MS);
},
// Attaches `stream` to the video element so the scanner never needs recreating. See
// docs/implementation.md#video-stream-attach-and-resize-sync.
setupVideoStream(stream) {
const video = this.$refs.video;
if (!video) return;
video.srcObject = stream;
video.play();
},
// Resizes the overlay to match the video's new size once its intrinsic dimensions change.
// See docs/implementation.md#video-stream-attach-and-resize-sync.
onVideoResize() {
const video = this.$refs.video;
if (!video || !video.videoWidth) {
return;
}
this.syncOverlaySize();
this.cameraRes = `Native resolution: ${video.videoWidth} x ${video.videoHeight}`;
},
async startCamera() {
this.$emit("error", null);
try {
const stream = await cameraManager.openStream(this.localSelectedCameraId || null);
this.setupVideoStream(stream);
await this.populateCameraList();
this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";
const anyd = await this.anydPromise;
this.detectWasm = anyd.raw;
this.seen = new Map();
this.cooldownEntries = [];
clearInterval(this.captureTimer);
this.captureTimer = setInterval(() => this.captureTick(), 1000 / CAPTURE_FPS);
this.cameraRunning = true;
} catch (e) {
this.$emit("error", `Camera error: ${e.message ?? e}`);
}
},
// The tick driving the whole pipeline: grab+downscale the current video frame, run
// `locate()` right here on the main thread (cheap enough not to need a worker of its
// own), then hand each candidate crop off to the decode worker rather than decoding it
// inline - see createDecodeWorker()'s doc comment for why.
captureTick() {
const video = this.$refs.video;
if (!video || !video.videoWidth || !video.videoHeight || !this.detectWasm) {
return;
}
const width = Math.max(1, Math.round(video.videoWidth / CAPTURE_DOWNSCALE));
const height = Math.max(1, Math.round(video.videoHeight / CAPTURE_DOWNSCALE));
if (this.captureCanvas.width !== width || this.captureCanvas.height !== height) {
this.captureCanvas.width = width;
this.captureCanvas.height = height;
}
this.captureCtx.drawImage(video, 0, 0, width, height);
const frame = toGrayFrame(this.captureCtx.getImageData(0, 0, width, height));
let candidates;
try {
candidates = this.detectWasm.locate(width, height, frame.luma);
} catch (err) {
this.$emit("error", `Camera error: ${err.message ?? err}`);
return;
}
if (!Array.isArray(candidates)) {
return;
}
const now = performance.now();
for (const candidate of candidates) {
if (!candidate?.corners) continue;
const box = boundingBox(candidate.corners);
const nativeBox = {
x0: box.x0 * CAPTURE_DOWNSCALE,
y0: box.y0 * CAPTURE_DOWNSCALE,
x1: box.x1 * CAPTURE_DOWNSCALE,
y1: box.y1 * CAPTURE_DOWNSCALE,
};
// Something decoded near here recently, and the worker has other work pending -
// skip re-decoding this candidate rather than spend it re-reading a code that's
// (almost certainly) still just sitting there. If the worker/queue is otherwise
// idle, decode anyway: there's no contention to save, and it keeps this
// location's cooldown entry from going stale between real hits.
if ((this.decodeWorkerBusy || this.decodeQueue.length > 0) && this.findCooldownNear(nativeBox, now)) {
continue;
}
const decodeDownscale = chooseDecodeDownscale(box, DECODE_TARGET_CROP_SIZE, MAX_DECODE_DOWNSCALE);
const crop = cropForDecode(
width, height, frame.luma, box, 4, decodeDownscale,
this.captureCanvas, this.decodeScratchCanvas, this.decodeScratchCtx);
this.enqueueDecodeJob(crop, decodeDownscale, now);
}
},
enqueueDecodeJob(crop, decodeDownscale, now) {
this.pruneStaleJobs(now);
if (this.decodeQueue.length >= MAX_DECODE_QUEUE) {
return;
}
this.decodeQueue.push({
jobId: ++this.jobCounter,
generation: this.captureGeneration,
enqueuedAt: now,
payload: {jobId: this.jobCounter, width: crop.width, height: crop.height, luma: crop.luma.buffer},
transfer: [crop.luma.buffer],
offsetX: crop.offsetX,
offsetY: crop.offsetY,
downscale: CAPTURE_DOWNSCALE,
decodeDownscale,
});
this.dispatchNextJob();
},
// FIFO order means the front of the queue is always the oldest, so the first
// non-expired entry means nothing behind it can be expired either - one pass from the
// front is sufficient.
pruneStaleJobs(now) {
while (this.decodeQueue.length > 0 && now - this.decodeQueue[0].enqueuedAt > MAX_QUEUE_AGE_MS) {
this.decodeQueue.shift();
}
},
dispatchNextJob() {
if (this.decodeWorkerBusy) {
return;
}
this.pruneStaleJobs(performance.now());
const job = this.decodeQueue.shift();
if (!job) {
return;
}
this.decodeWorkerBusy = true;
this.pendingJob = job;
this.decodeWorker.postMessage(job.payload, job.transfer);
},
// The worker only ever saw this one candidate's crop, so any `box` it reports is
// relative to that crop's own pixels - coarser than the capture frame's if this
// candidate got its own decode-time downscale. Scale back up by that factor to land in
// the crop's own space, then apply its offset, then the capture downscale in effect
// when this job was dispatched, to land on native video pixels.
handleWorkerMessage(event) {
const msg = event.data;
const job = this.pendingJob;
this.decodeWorkerBusy = false;
this.pendingJob = null;
if (msg.type === "error") {
console.error("[camera scan]", msg.message);
} else if (job && job.jobId === msg.jobId && job.generation === this.captureGeneration) {
const now = performance.now();
const codes = (msg.codes ?? []).map((code) => ({
...code,
box: code.box && {
x0: (code.box.x0 * job.decodeDownscale + job.offsetX) * job.downscale,
y0: (code.box.y0 * job.decodeDownscale + job.offsetY) * job.downscale,
x1: (code.box.x1 * job.decodeDownscale + job.offsetX) * job.downscale,
y1: (code.box.y1 * job.decodeDownscale + job.offsetY) * job.downscale,
},
}));
for (const code of codes) {
if (code.box) this.rememberCooldown(code.box, now);
}
const dedupeNow = Date.now();
const fresh = codes.filter((code) => {
const key = `${code.type}|${code.text ?? ""}`;
const lastSeen = this.seen.get(key);
if (lastSeen !== undefined && dedupeNow - lastSeen < EMIT_DEDUPE_MS) {
return false;
}
this.seen.set(key, dedupeNow);
return true;
});
if (fresh.length > 0) {
this.$emit("scan", fresh);
this.drawOverlay(fresh);
}
}
this.dispatchNextJob();
},
rememberCooldown(box, now) {
const existing = this.cooldownEntries.find((e) => iou(e.box, box) >= COOLDOWN_IOU_THRESHOLD);
if (existing) {
existing.box = box;
existing.decodedAt = now;
} else {
this.cooldownEntries.push({box, decodedAt: now});
}
// Prune well past the cooldown window so a long session doesn't accumulate forever.
this.cooldownEntries = this.cooldownEntries.filter((e) => now - e.decodedAt < COOLDOWN_MS * 4);
},
findCooldownNear(box, now) {
return this.cooldownEntries.some(
(e) => now - e.decodedAt < COOLDOWN_MS && iou(e.box, box) >= COOLDOWN_IOU_THRESHOLD);
},
async switchCamera(cameraId) {
if (!this.cameraRunning) return;
try {
const stream = await cameraManager.switchCamera(cameraId);
if (stream) this.setupVideoStream(stream);
} catch (e) {
this.$emit("error", `Camera error: ${e.message ?? e}`);
}
},
stopCamera() {
clearInterval(this.captureTimer);
this.captureTimer = null;
this.decodeQueue = [];
// Bumped so a decode job dispatched before this stop - still in flight in the
// worker - is recognized as stale and discarded once its result arrives (see
// handleWorkerMessage), rather than applied against whatever's running by then.
// decodeWorkerBusy/pendingJob are deliberately left alone: they resolve themselves
// when that in-flight result lands.
this.captureGeneration++;
cameraManager.closeStream();
if (this.$refs.video) {
this.$refs.video.srcObject = null;
}
this.cameraRunning = false;
const overlay = this.$refs.overlay;
if (overlay) {
overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
}
},
// cameraManager already tries a fallback device and only fires this event once none
// remain, so the running scan session has to stop here.
handleCameraDisconnected(event) {
if (!this.cameraRunning) return;
this.$emit("error", `Camera error: ${event.detail?.error || "Camera disconnected"}`);
this.stopCamera();
},
handleCameraReconnected() {
if (!this.cameraRunning) return;
const stream = cameraManager.getActiveStream();
if (!stream) return;
this.$emit("error", null);
this.setupVideoStream(stream);
this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";
},
},
created() {
// Kept as a promise (not awaited) so this and Scan.vue's own file-decode path share one
// in-flight load; loadAnyDCode also memoizes, so later calls elsewhere in the app are
// free too.
this.anydPromise = loadAnyDCode();
this.detectWasm = null;
this.clearOverlayTimer = null;
this.captureTimer = null;
this.captureGeneration = 0;
this.seen = new Map();
this.cooldownEntries = [];
// decode2d()/decode1d() run in this worker, off the main thread, so a slow decode never
// stalls locate()/capture or this component's own rendering - see createDecodeWorker().
// Long-lived for the component's lifetime (not recreated per start/stop) so switching
// cameras or restarting the scan doesn't pay wasm-instantiation cost again.
this.decodeWorker = createDecodeWorker();
this.decodeWorker.onmessage = (event) => this.handleWorkerMessage(event);
this.decodeWorkerBusy = false;
this.pendingJob = null;
this.jobCounter = 0;
this.decodeQueue = [];
this.captureCanvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(1, 1) : document.createElement("canvas");
this.captureCtx = this.captureCanvas.getContext("2d");
this.decodeScratchCanvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(1, 1) : document.createElement("canvas");
this.decodeScratchCtx = this.decodeScratchCanvas.getContext("2d");
},
mounted() {
window.addEventListener("resize", this.syncOverlaySize);
window.addEventListener("camera-disconnected", this.handleCameraDisconnected);
window.addEventListener("camera-reconnected", this.handleCameraReconnected);
navigator.mediaDevices?.addEventListener("devicechange", this.populateCameraList);
this.$refs.video?.addEventListener("loadedmetadata", this.onVideoResize);
this.$refs.video?.addEventListener("resize", this.onVideoResize);
if (this.cameraSupported && window.isSecureContext && this.autostart) {
this.startCamera();
} else {
this.populateCameraList();
}
},
beforeUnmount() {
this.stopCamera();
window.removeEventListener("resize", this.syncOverlaySize);
window.removeEventListener("camera-disconnected", this.handleCameraDisconnected);
window.removeEventListener("camera-reconnected", this.handleCameraReconnected);
navigator.mediaDevices?.removeEventListener("devicechange", this.populateCameraList);
this.$refs.video?.removeEventListener("loadedmetadata", this.onVideoResize);
this.$refs.video?.removeEventListener("resize", this.onVideoResize);
clearTimeout(this.clearOverlayTimer);
this.decodeWorker.terminate();
},
}
</script>
<style scoped>
.video-wrap {
position: relative;
margin-bottom: .75rem;
background: #000;
border-radius: .35rem;
overflow: hidden;
}
.video-wrap video {
display: block;
width: 100%;
height: auto;
}
.video-wrap canvas {
position: absolute;
inset: 0;
pointer-events: none;
}
</style>

View file

@ -4,51 +4,19 @@
<h5 class="card-title mb-0">Label layout</h5>
</div>
<div class="card-body">
<div class="mb-3 btn-group" role="group">
<input type="radio" class="btn-check" id="layout-filter-all"
autocomplete="off" value="all" v-model="activeTag">
<label class="btn btn-outline-secondary" for="layout-filter-all">All</label>
<template v-for="tag in labelTags" :key="tag">
<input type="radio" class="btn-check" :id="'layout-filter-' + tag"
autocomplete="off" :value="tag" v-model="activeTag">
<label class="btn btn-outline-secondary" :for="'layout-filter-' + tag">
{{ tagLabel(tag) }}
</label>
</template>
</div>
<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': !isSelectable(t)}"
:title="unavailableReason(t)"
role="button" @click="isSelectable(t) && $emit('input', t.id)">
<div class="template-thumb-wrap">
<canvas :ref="el => setTemplateCanvasRef(t.id, el)"
class="img-thumbnail template-thumb-canvas"
:class="{'border-primary': value === t.id}"></canvas>
<span v-if="t.id in failed" class="template-thumb-warning" :title="failed[t.id].message">
{{ failed[t.id].short }}
</span>
<span v-else-if="t.id in warned" class="template-thumb-warning template-thumb-warning-soft"
:title="warned[t.id].map(w => w.message).join(' ')">
{{ warned[t.id].map(w => w.short).join(", ") }}
</span>
</div>
:class="{'template-option-disabled': !isAvailable(t)}"
:title="isAvailable(t) ? '' : 'Not available - fill in the fields this layout needs above.'"
role="button" @click="isAvailable(t) && $emit('input', t.id)">
<canvas :ref="el => setTemplateCanvasRef(t.id, el)"
class="img-thumbnail template-thumb-canvas"
:class="{'border-primary': value === t.id}"></canvas>
<div class="small"
:class="{'fw-bold text-primary': value === t.id}">
{{ t.name }}
<span v-for="tag in t.tags" :key="tag" class="badge bg-secondary">{{ tagLabel(tag) }}</span>
</div>
<div class="small text-muted">{{ t.description }}</div>
<!-- Debugging aid: every QR-family/text leaf's raw size numbers, shown regardless
of whether either tripped a warning above - see label.js's qrInfo/textInfo. -->
<div v-if="t.id in qrInfo || t.id in textInfo" class="small text-secondary">
<div v-for="(info, i) in qrInfo[t.id]" :key="'qr' + i">
mm-per-mod: {{ info.moduleMm.toFixed(2) }}, px-per-mod: {{ info.scale }}
</div>
<div v-for="(info, i) in textInfo[t.id]" :key="'text' + i">
text-mm: {{ info.fontMm.toFixed(2) }}, text-px: {{ info.fontPx.toFixed(1) }}
</div>
</div>
</div>
</div>
</div>
@ -56,7 +24,8 @@
</template>
<style scoped>
/* This build pins Bootstrap 4 (no gap-* utilities, those are 5.1+) - hence plain CSS gap here. */
/* This build pins Bootstrap 4 (no gap-* utilities, those are Bootstrap 5.1+), so the spacing
here is plain CSS gap rather than a Bootstrap gap-N class. */
.template-grid {
gap: 1rem;
}
@ -70,70 +39,28 @@
cursor: not-allowed;
}
.template-option .badge {
font-size: .65rem;
vertical-align: middle;
margin-left: .35rem;
}
.template-thumb-wrap {
position: relative;
}
.template-thumb-canvas {
display: block;
width: 100%;
height: 10rem;
/* Canvas draws at whatever size fits its content (see redraw/drawFallbackLabel); object-fit
scales it into the thumbnail box like it would an <img>, no manual zoom math needed. */
/* The canvas itself is drawn at whatever size fits its content (see redraw/drawFallbackLabel)
- object-fit scales that down to the thumbnail box the same way it would for an <img>,
no manual zoom math needed. */
object-fit: contain;
background: #fff;
}
/* Corner badge flagging *why* a layout failed to render - a capacity error (label.js's "bigger
code than the tape allows"/"doesn't fit on this tape") or label-layouts.js's templateContent
throwing "missing data" because a field it needs isn't filled in yet (see redraw()'s catch).
Shows the short label directly (e.g. "too big", "missing data") rather than just an icon, so the
reason reads at a glance; the full sentence is still the title tooltip. Layered on top of the
greyed-out/unselectable state isSelectable/unavailableReason already apply for the same case. */
.template-thumb-warning {
position: absolute;
top: .35rem;
right: .35rem;
max-width: calc(100% - .7rem);
padding: .15rem .45rem;
border-radius: 1rem;
background: rgba(220, 53, 69, .9);
color: #fff;
font-size: .7rem;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
box-shadow: 0 0 0 1px rgba(0, 0, 0, .15);
cursor: help;
}
/* Soft variant for a template that rendered fine but tripped one of label.js's scan-reliability
thresholds (MIN_RECOMMENDED_QR_PX_PER_MODULE/MIN_RECOMMENDED_QR_MODULE_MM) - still selectable,
just flagged, so amber rather than the hard-failure badge's red. */
.template-thumb-warning-soft {
background: rgba(255, 193, 7, .9);
color: #000;
}
</style>
<script>
import {drawLabel, drawFallbackLabel, preloadQrEncoder} from "@/label.js";
import {LABEL_TEMPLATES, LABEL_TAGS, templateIsAvailable, templateContent} from "@/label-layouts.js";
import {drawFallbackLabel, preloadQrEncoder} from "@/label.js";
import {LABEL_TEMPLATES, templateIsAvailable, templateContent} from "@/label-layouts.js";
export default {
name: "LabelLayoutPreview",
props: {
// Named content fields templates draw from (see label.js's buildLabelFields), kept in
// sync by the parent. A field's absence (not just empty) means a template needing it is
// unavailable.
// Named content fields the templates draw from (see label.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: {
type: Object,
required: true
@ -143,20 +70,12 @@ export default {
type: String,
required: true
},
// Ids of the most-recently printed/downloaded templates, most recent first (see
// Print.vue's rememberPrintedTemplate); bubbled to the front of the grid below.
recentTemplateIds: {
type: Array,
default: () => []
},
// The connected printer's tape/resolution (see label.js's tapeFromStatus), or null when
// no printer is connected. When present, redraw() renders every thumbnail with drawLabel
// at this tape's real dpi/printArea/length instead of drawFallbackLabel's fixed reference
// - so the grid's px/mm-per-module numbers and any length-limit failures reflect what
// would actually come out of the connected printer.
tape: {
type: Object,
default: null
// Print.vue's global qr/micro-qr/rmqr choice (see label.js's QR_CODE_TYPES) - passed
// straight through to drawFallbackLabel so these thumbnails match whatever symbology the
// main preview is actually using.
codeType: {
type: String,
default: "qr"
}
},
model: {
@ -164,69 +83,20 @@ export default {
event: "input"
},
emits: ["input"],
data() {
return {
// Keyed by template id, holding the {short, message} error from its most recent
// redraw() failure (e.g. text too long, or too many QR modules for the thumbnail's
// fixed reference size - see label.js's snapQrToCrispSize/encodeQr). Absent, not just
// falsy, for a template that last drew fine, so `t.id in failed` matches "has one".
failed: {},
// Keyed by template id, holding the array of {short, message} scan-reliability
// warnings (see label.js's drawFallbackLabel) from its most recent successful
// redraw() - present only when that render tripped
// MIN_RECOMMENDED_QR_PX_PER_MODULE/MIN_RECOMMENDED_QR_MODULE_MM.
warned: {},
// Keyed by template id, holding every QR-family leaf's raw {scale, moduleMm} from its
// most recent successful redraw(), regardless of whether it warned - a debugging aid
// shown unconditionally below each thumbnail.
qrInfo: {},
// Same idea as qrInfo, but every text leaf's raw {fontPx, fontMm} (see label.js's
// checkTextSizes).
textInfo: {},
// Which LABEL_TAGS entry (or "all") the grid below is narrowed to.
activeTag: "all",
};
},
computed: {
labelTags() {
return LABEL_TAGS;
},
// LABEL_TEMPLATES with recentTemplateIds' entries pulled to the front (most recent
// first, everything else following in its original order), narrowed to activeTag, then
// with anything currently in `failed` (not printable - see redraw()'s catch) stably
// sorted to the back regardless of recency/tag order, so real errors don't crowd out
// layouts that actually work.
labelTemplates() {
const recent = this.recentTemplateIds
.map(id => LABEL_TEMPLATES.find(t => t.id === id))
.filter(Boolean);
const recentIds = new Set(recent.map(t => t.id));
const ordered = [...recent, ...LABEL_TEMPLATES.filter(t => !recentIds.has(t.id))];
const filtered = this.activeTag === "all" ? ordered : ordered.filter(t => t.tags?.includes(this.activeTag));
return [...filtered].sort((a, b) => (a.id in this.failed ? 1 : 0) - (b.id in this.failed ? 1 : 0));
return LABEL_TEMPLATES;
}
},
watch: {
fields() {
this.redraw();
},
// Switching filters mounts fresh canvas elements for thumbnails hidden until now (see
// labelTemplates); nextTick waits for those refs to land before drawing into them.
activeTag() {
this.$nextTick(() => this.redraw());
},
// Printer connect/disconnect/switch - every thumbnail's canvas already exists regardless
// of tape (unlike Print.vue's own tape-fed preview), so no flush:'post' is needed here.
tape() {
codeType() {
this.redraw();
}
},
methods: {
// "internal" -> "Internal", for both the filter buttons and each thumbnail's badge.
tagLabel(tag) {
return tag.charAt(0).toUpperCase() + tag.slice(1);
},
setTemplateCanvasRef(id, el) {
if (el) {
this.templateCanvases[id] = el;
@ -239,88 +109,41 @@ export default {
return templateIsAvailable(t, this.fields);
},
// Greyed out (and unclickable) either because a needed field isn't filled in yet
// (isAvailable) or its content failed to render (see redraw()'s catch) - either way
// there's nothing a click could select that would actually show anything.
isSelectable(t) {
return this.isAvailable(t) && !(t.id in this.failed);
},
// The disabled thumbnail's tooltip - empty once it's selectable again.
unavailableReason(t) {
if (!this.isAvailable(t)) {
return "Not available - fill in the fields this layout needs above.";
}
return this.failed[t.id]?.message ?? "";
},
// .template-thumb-canvas's object-fit:contain scales the just-drawn bitmap into its fixed
// CSS box (getBoundingClientRect, unlike canvas.width/height, reflects that box - object-fit
// doesn't change it); when that's a magnification, switch to nearest-neighbor so the
// print-accurate pixel edges stay crisp instead of blurring, same call Print.vue's own
// fitZoom makes for its explicitly-sized preview.
applyImageRendering(canvas) {
const {width: boxWidth, height: boxHeight} = canvas.getBoundingClientRect();
const scale = Math.min(boxWidth / canvas.width, boxHeight / canvas.height);
canvas.style.imageRendering = scale >= 1 ? "pixelated" : "auto";
},
// Live per-template thumbnails. Renders each with drawLabel at the connected printer's
// real tape/resolution when one's connected (see the `tape` prop), so the numbers/errors
// shown match what would actually print; falls back to drawFallbackLabel's fixed
// reference size otherwise. Either way, still illustrative previews via CSS object-fit,
// not the to-be-printed-accurate canvas Print.vue's own main preview is.
/* Live per-template thumbnails. Always uses the content-fit fallback renderer (rather
than the tape-fed one), regardless of whether a real printer is connected - these are
illustrative previews sized by CSS object-fit, not the accurate to-be-printed canvas
the main preview is. */
redraw() {
for (const t of LABEL_TEMPLATES) {
const canvas = this.templateCanvases[t.id];
if (!canvas) {
continue;
}
try {
const content = templateContent(t, this.fields);
const {warnings, qrInfo, textInfo} = this.tape
? drawLabel(canvas, this.tape, content, "along")
: drawFallbackLabel(canvas, content, "along");
this.applyImageRendering(canvas);
delete this.failed[t.id];
if (warnings.length) {
this.warned[t.id] = warnings;
} else {
delete this.warned[t.id];
}
if (qrInfo.length) {
this.qrInfo[t.id] = qrInfo;
} else {
delete this.qrInfo[t.id];
}
if (textInfo.length) {
this.textInfo[t.id] = textInfo;
} else {
delete this.textInfo[t.id];
}
} catch (e) {
// Grey the thumbnail out (see isSelectable/unavailableReason) rather than
// leaving it blank - selecting a template that can't render here would only
// hand Print.vue's own redraw the exact same failure.
const content = templateContent(t, this.fields);
if (!this.isAvailable(t) || !content) {
canvas.width = 1;
canvas.height = 1;
this.failed[t.id] = {short: e.short ?? "error", message: e.message};
delete this.warned[t.id];
delete this.qrInfo[t.id];
delete this.textInfo[t.id];
continue;
}
try {
drawFallbackLabel(canvas, content, "along", this.codeType);
} catch (e) {
// A thumbnail that can't render at this content length just stays blank
// rather than surfacing an error for every keystroke.
}
}
}
},
created() {
// Keyed by template id, populated by setTemplateCanvasRef() - a plain :ref="t.id" string
// in v-for still gets Vue's refInFor array-collecting behavior despite distinct names per
// iteration, turning this.$refs[t.id] into a one-element array; a function ref avoids that.
// inside v-for would still get Vue's refInFor array-collecting behavior even though
// each iteration uses a different name, turning this.$refs[t.id] into a one-element
// array rather than the canvas itself. A function ref sidesteps that entirely.
this.templateCanvases = {};
},
async mounted() {
// See Print.vue's mounted() - every thumbnail here has a qr/mqr/rmqr leaf, so nothing's
// worth drawing before the wasm resource resolves.
// See Print.vue's mounted() - every thumbnail here has a "qrcode" leaf, so there's
// nothing worth drawing before this resolves.
await preloadQrEncoder();
this.redraw();
}

View file

@ -38,7 +38,9 @@ export default {
return;
}
const data = new Uint8Array(buffer);
// SHA-256 must match the backend's content hash for file identification. See docs/implementation.md#sha-256-file-hashing-must-match-the-backend.
// SHA-256 via Web Crypto - must match the backend's own content hash
// (files/models.py, hashlib.sha256) so a hash computed here can later be
// used to identify the same File row server-side without a mismatch.
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hash = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, "0")).join("");

View file

@ -61,7 +61,9 @@ export default {
return;
}
const data = new Uint8Array(buffer);
// SHA-256 must match the backend's content hash for file identification. See docs/implementation.md#sha-256-file-hashing-must-match-the-backend.
// SHA-256 via Web Crypto - must match the backend's own content hash
// (files/models.py, hashlib.sha256) so a hash computed here can later
// be used to identify the same File row server-side without a mismatch.
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hash = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, "0")).join("");

View file

@ -41,7 +41,9 @@ export default {
return;
}
const data = new Uint8Array(buffer);
// SHA-256 must match the backend's content hash for file identification. See docs/implementation.md#sha-256-file-hashing-must-match-the-backend.
// SHA-256 via Web Crypto - must match the backend's own content hash
// (files/models.py, hashlib.sha256) so a hash computed here can later be
// used to identify the same File row server-side without a mismatch.
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hash = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, "0")).join("");

View file

@ -206,7 +206,9 @@ export default {
const mimeType = this.dataImage.split(';')[0].split(':')[1];
const data = this.dataImage.split(',')[1];
const raw_data = atob(data);
// SHA-256 must match the backend's content hash for file identification. See docs/implementation.md#sha-256-file-hashing-must-match-the-backend.
// SHA-256 via Web Crypto - must match the backend's own content hash (files/models.py,
// hashlib.sha256) so a hash computed here can later be used to identify the same File
// row server-side without a mismatch.
const bytes = Uint8Array.from(raw_data, c => c.charCodeAt(0));
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes);
const hash = Array.from(new Uint8Array(hashBuffer))

View file

@ -263,10 +263,21 @@
import * as BIcons from "bootstrap-icons-vue";
import { mapActions } from 'vuex';
// Implements every step of 'import-items'; steps 2-5,7 use a generic placeholder. See docs/implementation.md#bulk-item-import-workflow.
/**
* Bulk Item Import Workflow
*
* This single component implements every step of the 'import-items' workflow.
* Steps 1 (File Upload) and 6 (Import Items) have fully custom UI; the
* remaining steps (2-5, 7) currently fall back to a generic "in progress"
* placeholder driven by this workflow's own step metadata, but can be
* fleshed out here later without touching any other file.
*/
export default {
name: 'BulkItemImportWorkflow',
// Co-located workflow metadata, single source of truth per workflow type. See docs/implementation.md#workflow-meta-co-location.
// Metadata describing this workflow, co-located with its implementation
// so there is a single source of truth per workflow type. Consumed by
// `@/workflows.js` (via `Component.meta`) to assemble the catalog used
// by the Workflows and WorkflowDetail views.
meta: {
slug: 'import-items',
title: 'Bulk Item Import',
@ -415,12 +426,15 @@ export default {
},
async analyzeFile(file) {
// Parsing happens entirely client-side; the backend only ever sees the resulting `items` list.
// Parsing happens entirely client-side. The backend never sees the
// raw file - only the structured `items` list produced here, sent
// later as generic payload/request body.
if (file.name.endsWith('.csv')) {
const text = await file.text();
this.parsedRows = this.parseCsv(text);
} else {
// Mock implementation; a real Excel parser would use a library like SheetJS.
// For Excel files, you'd use a library like SheetJS to parse
// client-side. This is a mock implementation.
this.detectedColumns = ['Name', 'Category', 'Quantity', 'Unit', 'Description'];
this.parsedRows = [
{ Name: 'Sample Item 1', Category: 'Tools', Quantity: '1', Unit: 'piece', Description: 'Sample description' },
@ -458,7 +472,9 @@ export default {
});
},
// Converts parsed rows + column mapping into item dicts for the "Import Items" step.
// Convert the fully parsed rows + column mapping into the generic item
// dicts consumed by the "Import Items" step, which creates each one via
// the standard generic InventoryItem create endpoint.
buildItemsFromMapping() {
return this.parsedRows.map(row => ({
name: this.columnMapping.name ? row[this.columnMapping.name] : '',
@ -579,7 +595,10 @@ export default {
this.importError = null;
const created_item_ids = [];
const errors = [];
// Each row is created via the same generic POST /api/inventory_items/ endpoint any client would use.
// Items were fully parsed client-side (File Upload step). The backend
// never sees the source file or any bulk-import-specific endpoint -
// each row is created through the same generic
// POST /api/inventory_items/ endpoint any other client would use.
for (const [index, row] of this.items.entries()) {
if (!row.name) {
errors.push(`Row ${index + 1}: missing required "name" field, skipped.`);

View file

@ -890,7 +890,11 @@ export default {
methods: {
...mapActions(['stageFile', 'unstageFile']),
loadFromPayload() {
// `photos` is seeded from workflowInstance.staged_files, not payload. See docs/implementation.md#staged-photos-are-the-durable-state.
// `photos`' durable state is the WorkflowInstance.staged_files relation itself (kept
// in sync directly by stageFile()/unstageFile(), not by writing to payload) - so it's
// seeded from the prop, not from payload. Entries restored this way have no local
// bytes yet (this session never uploaded them), so `dataUrl` stays null - the gallery
// falls back to fetching a thumbnail by hash via AuthenticatedImage (see below).
this.photos = (this.workflowInstance.staged_files || []).map(hash => ({
hash,
name: null,
@ -915,7 +919,11 @@ export default {
// --- Step 1: Photo capture ---
thumbnailPathForHash(hash, size = 256) {
// Derived storage path for a disk-cached resized thumbnail. See docs/implementation.md#thumbnail-lookup-by-hash.
// files/media_urls.py's thumbnail_urls generates (and disk-caches) a resized JPEG
// on first request - a gallery card only needs a small image, not the full-size
// original. Looked up by the derived storage path, mirroring hash_upload()
// (files/models.py) - matches how FileSerializer.name already builds file URLs
// elsewhere in the app (e.g. AuthenticatedImage's `src` for item files).
return `/media/${size}/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash.slice(4, 6)}/${hash.slice(6)}/`;
},
@ -935,14 +943,21 @@ export default {
}));
this.photos.push(...staged);
// Persists each photo server-side immediately so it survives a reload/device switch. See docs/implementation.md#staged-photos-are-the-durable-state.
// Persist each photo server-side right away, keyed to this workflow instance, so it
// survives a reload or a switch to another device. WorkflowInstance.staged_files is
// the durable record of this - nothing about photos needs to go into payload too.
await Promise.all(staged.map(async ({hash, data, mime_type}) => {
try {
await this.stageFile({
lifetime_id: this.workflowInstance.id,
file: {data, mime_type}
});
// Re-lookup by hash rather than mutate the closed-over `photo`, which predates this.photos.push() and isn't Vue's reactive proxy. See docs/implementation.md#thumbnail-lookup-by-hash.
// Once persisted, the gallery can show the server-fetched thumbnail instead
// of the local dataUrl (kept around for step 2's client-side processing).
// Re-lookup by hash rather than mutating the closed-over `photo` object -
// that reference predates this.photos.push() above, so it's the raw object,
// not the reactive proxy Vue tracks; writing to it wouldn't trigger a
// re-render.
const photo = this.photos.find(p => p.hash === hash);
if (photo) photo.uploaded = true;
} catch (error) {
@ -1239,7 +1254,8 @@ export default {
object-fit: cover;
}
/* Stacks the dataUrl preview and server-fetched image during their crossfade so neither disappears before the other lays out. */
/* Stacks the local dataUrl preview and the server-fetched AuthenticatedImage on top of each
other during their crossfade, instead of one disappearing before the other lays out. */
.photo-thumb-wrap {
position: relative;
height: 150px;

View file

@ -756,10 +756,21 @@
<script>
import * as BIcons from "bootstrap-icons-vue";
// Implements every step of 'foto-first-bulk-import' in one file. See docs/implementation.md#foto-first-bulk-import-workflow.
/**
* Foto First Bulk Import Workflow
*
* This single component implements every step of the 'foto-first-bulk-import'
* workflow (photo capture, image processing, item detail entry and import
* completion). Keeping the whole workflow in one file avoids splitting
* closely related state (photos, processed images, completed items) across
* many small step components and their prop/emit boundaries.
*/
export default {
name: 'FotoFirstBulkImportWorkflow',
// Co-located workflow metadata, single source of truth per workflow type. See docs/implementation.md#workflow-meta-co-location.
// Metadata describing this workflow, co-located with its implementation
// so there is a single source of truth per workflow type. Consumed by
// `@/workflows.js` (via `Component.meta`) to assemble the catalog used
// by the Workflows and WorkflowDetail views.
meta: {
slug: 'foto-first-bulk-import',
title: 'Foto First Bulk Import',

View file

@ -282,9 +282,11 @@ class ServerSet {
function ServerSetUnion(serverSets) {
return new Proxy(serverSets, {
get: function (target, prop, receiver) {
// Must precede the generic forwarding check below: ServerSet.prototype also has its
// own add(server), so funcs.includes('add') would otherwise always be true and this
// union-specific add would never run.
// Note: 'add' must be checked before the generic funcs-forwarding branch below,
// because ServerSet.prototype also defines its own `add(server)` method (for
// adding a raw server address string to a single ServerSet). Without this check
// first, `funcs.includes('add')` would always be true and the union-specific
// "add a ServerSet to this union" logic below would never be reached.
if (prop === 'add') {
return function (serverset) {
if (!serverset || !(serverset instanceof ServerSet)) {

View file

@ -1,4 +1,12 @@
// Session cache for hash-addressed image bytes, deliberately not Vuex. See docs/implementation.md#filecache-design-rationale.
// Shared, session-lifetime cache for the bytes behind AuthenticatedImage's `src` (a
// `/media/...` hash-addressed path - see backend/files/media_urls.py). Content there is
// immutable and hash-addressed (SHA-256), so a cache hit never needs revalidation: the
// same `src` string can only ever resolve to the same bytes, for any owner/requester.
//
// Deliberately NOT Vuex state - this holds Blob/object-URL data that nothing needs
// reactive access to (components only bind the resulting object-URL string, which they
// hold in their own local state), so a plain module-level Map avoids Vue's reactivity
// overhead entirely and sidesteps proxying Blob instances for no benefit.
const MAX_BYTES = 150 * 1024 * 1024; // budget for decoded image bytes before evicting LRU entries
@ -10,7 +18,8 @@ class FileCache {
}
_touch(key) {
// Delete+re-insert moves this entry to the MRU end of the Map's iteration order.
// Delete+re-insert moves this entry to the "most recently used" end of the
// Map's iteration order, without needing a separate linked list.
const entry = this._entries.get(key);
this._entries.delete(key);
this._entries.set(key, entry);
@ -31,7 +40,8 @@ class FileCache {
}
}
// fetcher: () => Promise<Blob>, called at most once per key even under concurrent callers.
// fetcher: () => Promise<Blob>. Called at most once per key even if many components
// ask for the same key while the first request is still in flight.
async get(key, fetcher) {
if (this._entries.has(key)) {
this._touch(key);

View file

@ -1,3 +1,9 @@
// Escapes `#` as `+` for use in a URL path segment. See docs/implementation.md#escaping-hash-in-handles-for-url-path-segments.
// Embeds/extracts a handle in a URL path segment. See docs/federation.md's "Embedding a
// `#`-bearing handle in a URL": `#` starts a URI's fragment component, so a group handle
// (`#name@domain`) or classification handle (`origin#type:name`) can't appear unescaped in a
// path segment. `+` stands in for `#` there instead of the usual %23 - safe to reverse
// unambiguously because every field a handle is built from is already required to exclude `+`
// (see federation.md's Reserved characters). The canonical handle itself never changes; this only
// affects how one gets embedded in, or read back out of, a URL path segment.

View file

@ -1,9 +1,20 @@
// A template's layout tree, content resolution, and selectability contract. See
// docs/implementation.md#template-layout-tree.
// Each template's `layout` is a tree as described in label.js, with leaves whose `type` is one of
// label.js's QR_LEAF_TYPES keys or "text", and whose `content` is a function from the resolved
// field values (see label.js's buildLabelFields) to what they render - `null`/`undefined` 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"};
// Generated matrix of "just the code" templates covering every QR_LEAF_TYPES combo. See
// docs/implementation.md#generated-qr-only-template-matrix.
// The full "just the code" matrix: every {symbology, error-correction level, [rMQR] size
// strategy} label.js's QR_LEAF_TYPES supports, one template each - a plain `id` (no suffix) is
// always anyd's own defaults, ecc "M" and (rMQR only) size "balanced". Coverage isn't uniform
// (see QR_LEAF_TYPES): full QR gets all four ecc grades L/M/Q/H; Micro QR swaps "L" (QR's actual
// lowest) for the even-lower, M1-only, detection-only "Detection" and has no "H" at all; rMQR
// only ever supports ecc "M" or "H", each of those crossed with all three size strategies
// (balanced/min/max). Generated (rather than hand-writing every near-duplicate entry) so a
// symbology/level/size this matrix is missing is one new row here, not a new block to keep in
// sync with its neighbors. `id` doubles as the layout's leaf `type`, since that's exactly what
// QR_LEAF_TYPES is keyed by.
const QR_ONLY_TEMPLATES = [
{
id: "qr-l", name: "QR code only (low error correction)",
@ -76,49 +87,14 @@ const QR_ONLY_TEMPLATES = [
export const LABEL_TEMPLATES = [
{
id: "mqr-token", name: "MQR Token", description: "The code with the encoded text printed next to it.",
required_vars: ["shortId"], tags: ["internal"],
required_vars: ["shortId"],
layout: [{type: "mqr", content: c => c.shortId}]
},
{
id: "qr-url", name: "MQR Token", description: "The code with the encoded text printed next to it.",
required_vars: ["shortUrl"], tags: ["external"],
required_vars: ["shortUrl"],
layout: [{type: "qr-h", content: c => c.shortUrl}]
},
{
id: "rmqr-url", name: "rMQR Token", description: "The code with the encoded text printed next to it.",
required_vars: ["shortUrl", "itemId"], tags: ["external"],
layout: [{type: "rmqr", content: c => c.shortUrl}, GAP, {
type: "text",
content: c => c.itemId?.toString().padStart(4, "0")
}]
},
{
id: "mqr-tokem-id", name: "rMQR Token", description: "The code with the encoded text printed next to it.",
required_vars: ["shortId", "itemId"], tags: ["internal"],
layout: [{type: "mqr", content: c => c.shortId}, GAP, {
type: "text",
content: c => c.itemId?.toString().padStart(4, "0")
}]
},
{
id: "location-rmqr-url", name: "rMQR Token", description: "The code with the encoded text printed next to it.",
required_vars: ["shortUrl", "locationId"], tags: ["external"],
layout: [{type: "rmqr", content: c => c.shortUrl}, GAP, {
type: "text",
content: c => c.locationId?.toString().padStart(4, "0")
}]
},
{
id: "location-mqr-tokem-id",
name: "rMQR Token",
description: "The code with the encoded text printed next to it.",
required_vars: ["shortId", "locationId"],
tags: ["internal"],
layout: [{type: "mqr", content: c => c.shortId}, GAP, {
type: "text",
content: c => c.locationId?.toString().padStart(4, "0")
}]
}, ...QR_ONLY_TEMPLATES,
},...QR_ONLY_TEMPLATES,
{
id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.",
@ -135,7 +111,7 @@ export const LABEL_TEMPLATES = [
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, {
layout: [[{type: "text", content: c => "Item: "+c.itemId}, GAP, {
type: "qr",
content: c => c.text
}, GAP, {type: "text", content: c => c.userHandle}]]
@ -148,77 +124,66 @@ export const LABEL_TEMPLATES = [
{
id: "item-handle", name: "Item handle",
description: "The compact owner@domain:id handle - meaningful in-app, not scannable on its own.",
required_vars: ["itemHandle"], tags: ["internal"],
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"], tags: ["external"],
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"], tags: ["internal"],
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"], tags: ["internal"],
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"], tags: ["internal"],
required_vars: ["userHandle", "itemId"],
layout: [{type: "text", content: c => [c.userHandle, c.itemId]}]
},
{
id: "location-id", name: "Location ID", description: "Just the bare storage location id, as text only.",
required_vars: ["locationId"], tags: ["internal"],
layout: [{type: "text", content: c => c.locationId}]
},
{
id: "owner-id-text-location", name: "Owner + location ID",
description: "The owner's handle and the location id, as two lines of text - no code.",
required_vars: ["userHandle", "locationId"], tags: ["internal"],
layout: [{type: "text", content: c => [c.userHandle, c.locationId]}]
},
{
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"], tags: ["external"],
required_vars: ["itemUrl", "itemHandle"],
layout: [{type: "qr", 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"], tags: ["external"],
required_vars: ["itemUrl", "userHandle"],
layout: [{type: "qr", 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"], tags: ["external"],
required_vars: ["itemUrl", "itemId"],
layout: [{type: "qr", 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"], tags: ["external"],
required_vars: ["itemUrl", "userHandle", "itemId"],
layout: [{type: "qr", content: c => c.itemUrl}, GAP, {type: "text", content: c => [c.userHandle, c.itemId]}]
},
{
id: "short-url-qr", name: "Short link (QR code)",
description: "Scannable short link for this item or storage location - more compact than "
+ "the full URL. Available for any element with a resolvable short link, not just items.",
required_vars: ["shortUrl"], tags: ["external"],
required_vars: ["shortUrl"],
layout: [{type: "qr", content: c => c.shortUrl}]
},
{
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"], tags: ["external"],
required_vars: ["itemUrl", "userHandle", "itemId"],
layout: [{type: "qr", content: c => c.itemUrl}, GAP, [{
type: "text",
content: c => c.userHandle
@ -226,47 +191,55 @@ export const LABEL_TEMPLATES = [
},
];
// Every distinct tag value used across LABEL_TEMPLATES' tags, in first-seen order - drives
// LabelLayoutPreview.vue's filter buttons without hand-listing "internal"/"external" there.
export const LABEL_TAGS = [...new Set(LABEL_TEMPLATES.flatMap(t => t.tags ?? []))];
// 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))];
// DERIVED_VARS shape and declaration-order invariant. See
// docs/implementation.md#derived-vars-shape-and-ordering.
// 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 = {
// Full user@domain handle, kept as separate user/domain base vars since that's how the
// account is actually shaped (see federation.md's Unique Handles); this is just the
// display/URL form.
// 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}`,
},
// Self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a printed
// label encodes, since scanning it must resolve everything with no other context. `webdomain`
// defaults to this origin but is editable, since any frontend can resolve any handle.
// 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}`,
},
// Compact "owner handle + id" form (see docs/design-in-progress/items-labels.md) - meaningful
// only where context already makes clear it's a Toolshed item, unlike itemUrl.
// 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}`,
},
};
// Text-input vars = template KNOWN_VARS minus derived ones, plus any var a DERIVED_VARS calc
// needs (e.g. "webdomain") even if no template names it directly.
// 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 each DERIVED_VARS calc against `fields`, adding the result wherever its inputs are
// present, so callers never need to know DERIVED_VARS' {inputs, calc} shape.
// 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)) {
@ -289,14 +262,16 @@ function mapTree(node, fn) {
return Array.isArray(node) ? node.map(child => mapTree(child, fn)) : fn(node);
}
// Resolved only if every part is - a single string for a QR-family/"text" leaf, every line for a
// multi-line "text" (see "owner-id-text" and "item-url-qr-owner-id" above).
// A content leaf's resolved value counts as present only if every part of it is - a single
// string for a QR-family leaf (any label.js QR_LEAF_TYPES entry) or 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;
}
// Shared by LabelLayoutPreview.vue's grid and Print.vue's preview, so neither reimplements
// leaf-walking/field-resolving itself.
// 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 => {
@ -307,16 +282,16 @@ export function templateIsAvailable(t, fields) {
return available;
}
// Resolves t's content leaves against fields into a tree for label.js's
// drawLabel/drawFallbackLabel. Throws the same {message, short} shape as label.js's own
// capacity errors (see docs/implementation.md#missing-data-is-a-templatecontent-error) when a
// leaf isn't resolved yet, rather than returning null, so a caller's single catch around
// drawLabel/drawFallbackLabel handles both without a separate isAvailable pre-check.
/* 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) {
if (!templateIsAvailable(t, fields)) {
const err = new Error("This layout needs more fields filled in before it can be drawn.");
err.short = "missing data";
throw err;
}
return mapTree(t.layout, leaf => leaf.type === "empty" ? leaf : {...leaf, value: leaf.content(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;
}

View file

@ -1,16 +1,38 @@
import {loadAnyDCode} from "../vendor/anyd-qr.js";
import {encodeHandleForUrl} from "@/router"
// Mirrors loadAnyDCode()'s memoized wasm instance for synchronous use in buildRenderTree. See docs/implementation.md#qr-encoder-loading.
// anyd-qr.js's own loadAnyDCode() memoizes the wasm instantiation itself, so calling it more
// than once (each of Print.vue and LabelLayoutPreview.vue does, on mount) is free - `anyd` just
// mirrors its resolved value so buildRenderTree below can use it synchronously. Until it
// resolves, a QR-family leaf (any QR_LEAF_TYPES entry) throws (see encodeQr) the same way an
// oversized value already does - callers already have to handle layoutContent throwing, so this
// reuses that path rather than adding a second failure mode.
let anyd = null;
export function preloadQrEncoder() {
return loadAnyDCode().then(instance => { anyd = instance; });
}
// Maps label-layouts.js leaf types to anyd-qr.js symbology/ecc/size options, via a naming
// convention (plain id = anyd defaults; "-<name>" suffix names an ecc letter or rMQR size
// strategy). See docs/implementation.md#qr-leaf-type-mapping.
// Maps each of label-layouts.js's LABEL_TEMPLATES leaf types that draw a code to the anyd-qr.js
// symbology/error-correction level (and, for rMQR, size strategy) it renders as (see anyd's
// EncodeOptions - `ecc`/`size` - and its per-symbology EcLevel enums, `wasm.rs`'s
// qr_ec/micro_ec/rmqr_ec/rmqr_size) - which combination a given label uses is baked into its
// layout tree (see label-layouts.js's "qr"-prefixed templates), rather than a single choice
// applied to every code leaf alike, so there's no longer a global selector for any of them (see
// Print.vue). A plain symbology id (no suffix) always means anyd's own defaults - ecc "M", rMQR
// size "balanced" - every other value gets a "-<name>" suffix naming it:
// - ecc: the same letter anyd itself uses (qr_ec/micro_ec's L/M/Q/H), except micro-qr's
// "Detection" (`MicroEcLevel::Detection`, an M1-only error-*detection*-but-not-correction mode
// with no plain single-letter grade of its own). Coverage isn't uniform across symbologies
// (see qr_ec/micro_ec/rmqr_ec) - full QR takes all four grades, Micro QR swaps "L" (QR's
// actual lowest) for "Detection" (lower still, but M1-only) and has no "H" at all, and rMQR
// only ever supports "M" or "H".
// - size (rMQR only, see rmqr_size/SizeStrategy): "min"/"max" prefer the shortest (flattest,
// widest) or tallest (narrowest) symbol that fits the text, over the default "balanced"
// (smallest total module area) - which shape to prefer depends on which of the tape's two
// axes (across vs. along the feed) is more constrained.
// rMQR's matrix isn't square (see encodeQr's width/height below), unlike qr/micro-qr, which
// always are.
const QR_LEAF_TYPES = {
"qr-l": {codeType: "qr", ecc: "L"},
qr: {codeType: "qr", ecc: "M"},
@ -34,24 +56,16 @@ function isQrLeaf(node) {
function encodeQr(text, codeType, options) {
if (!anyd) {
const err = new Error("The QR encoder is still loading — try again in a moment.");
err.short = "loading…";
throw err;
}
// BitMatrix-alike shim over anyd's row-major matrix, matching the old "qrcode" package's
// modules.size/get() shape that drawQrLeaf/snapQrToCrispSize expect. See
// docs/implementation.md#qr-module-matrix-shim.
try {
const {width, height, modules} = anyd.encode(codeType, new TextEncoder().encode(text), options).matrix;
return {width, height, get: (row, col) => modules[row * width + col] !== 0};
} catch (e) {
// anyd's own errors (e.g. "capacity exceeded: …") have no `short` of their own - every
// failure here comes down to the content not fitting the chosen QR variant's capacity.
if (e && typeof e === "object" && !("short" in e)) {
e.short = "too long";
}
throw e;
throw new Error("The QR encoder is still loading — try again in a moment.");
}
// BitMatrix-alike view over anyd's row-major Uint8Array, matching the shape drawQrLeaf/
// snapQrToCrispSize below expect (they predate this and were written against the "qrcode"
// package's own modules.size/get()). anyd's matrix already excludes the quiet zone from
// width/height (see its ModuleMatrix type), same as the old library's BitMatrix. width/height
// are kept separate rather than a single `size` (the old library's own shape, always square)
// since rMQR symbols are rectangular.
const {width, height, modules} = anyd.encode(codeType, new TextEncoder().encode(text), options).matrix;
return {width, height, get: (row, col) => modules[row * width + col] !== 0};
}
const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */
@ -75,46 +89,66 @@ export function tapeFromStatus(status) {
};
}
// A layout tree alternates row/column split nodes by nesting depth, with QR/text/empty leaves.
// See docs/implementation.md#layout-tree-structure.
/*
A layout is a tree built from two shapes, alternating orientation by nesting depth:
- An array is a "split" node: its children sit side by side (a *row*) at even depth
(the root, depth 0, is always a row), or stacked (a *column*) at odd depth. To turn a
row into a column, wrap it in an extra one-element array - that array is one depth
deeper, so its lone child (the original row) is now read at odd depth.
- An object is a leaf: {type, content} where `type` is one of QR_LEAF_TYPES' keys draws a
QR/Micro QR/rMQR code at that id's symbology/error-correction level (see QR_LEAF_TYPES
above), {type: "text", content} draws a text block - either way `content` is a function
from the resolved field values to the string (or, for "text", an array of strings - one
per line) to render. {type: "empty",
"min-width": "2mm"} / {type: "empty", "min-height": "2mm"} is a spacer with no ink of
its own - the *only* way padding/gaps enter a layout, since nothing here draws a
border, margin or gap on its own. An "empty" leaf's dimension always names the axis its
enclosing split flows along: "min-width" inside a row, "min-height" inside a column.
See label-layouts.js's LABEL_TEMPLATES for concrete trees.
*/
const TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural aspect ratio at */
// Below 10px, a general-purpose sans-serif gets illegible, so drawTextLeaf switches to a bitmap
// font (Tom Thumb/Silkscreen) instead; see the empirical rationale (font choice, `scale`, and why
// sizes aren't dpi-adjusted) at docs/implementation.md#pixel-font-selection. The 10px+ tier names
// Inter explicitly (see ../scss/_label-fonts.scss) rather than falling back to the CSS generic
// "sans-serif" keyword, which resolves to a different real font per browser/OS and would make the
// same label print differently depending on where it was rendered from.
const FONT_TIERS = [
{belowPx: 8, family: "Tom Thumb", scale: 3.2, pixel: true},
{belowPx: 10, family: "Silkscreen", pixel: true},
{belowPx: Infinity, family: "Inter"},
// Below 10px, a general-purpose sans-serif gets blurry/illegible, so drawTextLeaf switches to one
// of these bitmap-style fonts instead (see ../scss/_pixel-fonts.scss) - Tom Thumb for the smallest
// sizes, Silkscreen once there's enough room for its more conventional letterforms.
//
// Both were chosen only after rendering single letters in a real browser *at raw canvas pixel
// sizes* and inspecting the actual pixels - checking that fillText was merely *called* doesn't
// confirm anything legible got drawn, and neither does a DPI-adjusted size that was never the
// number actually handed to ctx.font. Silkscreen confirmed clean at 8px+. A third candidate,
// PICO-8, also rendered cleanly across the whole range, but has no lowercase glyphs at all - it
// silently draws lowercase input as uppercase - which rules it out for real label content (item
// handles, URLs) that isn't reliably all-caps. Tom Thumb's declared ascent/descent (0 / ~fontPx,
// backwards from a normal font) turned out not to be a centering quirk: its actual visible ink is
// only ~1/3.2 of its own nominal font-size (confirmed both by measuring actualBoundingBox at
// several sizes and by a live-browser check - "16px" reads as roughly 5px of real glyph height),
// hence the `scale` below - whatever logical size is requested, the font is actually drawn that
// many times larger so its real ink comes out at the intended size. Silkscreen's declared size
// already matches its ink, so it has no `scale` (equivalent to 1).
//
// belowPx and MIN_READABLE_TEXT_PX below are both compared against the *logical* (unscaled)
// fontPx, deliberately not adjusted for the tape's dpi: a browser's font rasterizer only ever
// sees a raw pixel count, with no notion of "physical size" at all, so that's what determines
// whether a glyph's fine detail survives - confirmed by the same real-Chromium testing, where a
// raw 4.35px render was a solid blob regardless of what a dpi-scaled version of that number would
// have implied.
const PIXEL_FONT_TIERS = [
{belowPx: 8, family: "Tom Thumb", scale: 3.2},
{belowPx: 10, family: "Silkscreen"},
];
function fontFamilyFor(fontPx) {
return FONT_TIERS.find(t => fontPx < t.belowPx);
return PIXEL_FONT_TIERS.find(t => fontPx < t.belowPx) ?? {family: "sans-serif"};
}
// Below this font size (px) or physical height (mm) - Tom Thumb's real-Chromium-tested legibility
// floor - text is a hard failure, the same two-metric shape as MIN_RECOMMENDED_QR_PX_PER_MODULE/
// MIN_RECOMMENDED_QR_MODULE_MM below (see checkTextSizes). No longer just silently blanked.
const MIN_TEXT_PX = 5;
const MIN_TEXT_MM = 0.5;
// Below this, still legible but a soft warning - flagged rather than blocking, same idea as
// MIN_RECOMMENDED_QR_PX_PER_MODULE/MIN_RECOMMENDED_QR_MODULE_MM.
const MIN_RECOMMENDED_TEXT_PX = 8;
const MIN_RECOMMENDED_TEXT_MM = 1;
// Below this many pixels per module, a QR-family code still technically fits (see
// snapQrToCrispSize's scale >= 1 hard requirement) but risks blurring together on a real thermal
// printer's dot pitch - a soft warning rather than the outright rejection scale < 1 gets.
const MIN_RECOMMENDED_QR_PX_PER_MODULE = 3;
// Below this per-module physical size (in mm), a QR-family code is a rule-of-thumb risk for a
// phone camera to resolve at normal scanning distance even when crisply printed at full
// resolution - also a soft warning, independent of the pixels-per-module check above (a printer
// can hit that check's px/module floor at any dpi, but only a high enough dpi keeps modules this
// physically small still legible).
const MIN_RECOMMENDED_QR_MODULE_MM = 0.5;
// Tom Thumb (see PIXEL_FONT_TIERS above) held up down to 5px in the same real-Chromium pixel-level
// verification - a plain sans-serif this small would fail the old MIN_READABLE_TEXT_PX=8 floor
// that predates it. 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);
@ -128,9 +162,20 @@ function parseMm(value, key) {
return parseFloat(match[1]);
}
// Every node's width/height relate affinely (width = A*height + B, or symmetrically); `ownAxis`
// and `wantWidth` pick which direction and against which split axis. See
// docs/implementation.md#affine-width-height-relations.
/* Every node's width/height relate to each other affinely - width = A*height + B for a node
read in row context, height = A*width + B in column context - because a leaf is either
scale-free (a text block, whose aspect ratio holds at any size: A = aspect or 1/aspect,
B = 0) or a fixed physical size (an "empty" spacer, or a QR code once its crisp pixel size is
known - see 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 (isQrLeaf(node) && node.crispWidth !== undefined) {
@ -153,16 +198,23 @@ function relation(node, ownAxis, wantWidth, pxPerMm) {
return {a, b};
}
if (a === 0) {
// Every child is fixed-size (a === 0) in the combining direction, so inverting would
// divide by zero. See docs/implementation.md#fixed-size-relation-edge-case.
// Every child is a fixed size (a === 0) in the combining direction - e.g. a row that's
// just one crisp QR leaf, with no scale-free (text) sibling to invert against. Inverting
// "width = b" for an a of 0 would divide by zero: a constant width genuinely doesn't
// determine a height, since nothing here actually scales with it. Ask each child directly
// for its own size in the wanted direction instead (every one of them must be similarly
// fixed, since only a fixed leaf ever contributes a === 0), and take the largest - the
// shared dimension has to fit whichever child needs the most room, with any child that
// ends up with room to spare centered within it (see drawQrLeaf).
const otherParts = node.map(child => relation(child, axis, wantWidth, pxPerMm));
return {a: 0, b: Math.max(...otherParts.map(p => p.b))};
}
return {a: 1 / a, b: -b / a}; // invert: solve the affine relation the other way
}
// Top-down: assigns the fixed (width, height) box `node` must exactly fill, recursively, to
// every descendant; `ownAxis` carries the same meaning as in relation() above.
/* 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)) {
@ -180,8 +232,9 @@ function layoutTree(node, ownAxis, width, height, pxPerMm) {
}
}
// Second top-down pass: turns each already-sized box into an absolute (x, y) position; kept
// separate from layoutTree since a node's size doesn't depend on its position.
/* 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;
@ -201,113 +254,56 @@ function positionTree(node, ownAxis, x, y) {
}
}
// Pins each QR-family leaf's real crisp-pixel box.width/box.height so relation() above starts
// treating it as fixed-size. Appends a {short, message} entry to `warnings` for each of
// MIN_RECOMMENDED_QR_PX_PER_MODULE/MIN_RECOMMENDED_QR_MODULE_MM the leaf falls short of (still
// printable, just flagged as a scan-reliability risk), and unconditionally appends its raw
// {scale, moduleMm} to `qrInfo` - callers needing to show those numbers regardless of whether
// they tripped a threshold (see LabelLayoutPreview.vue's debug line) shouldn't have to re-derive
// them. See docs/implementation.md#crisp-qr-sizing.
function snapQrToCrispSize(node, pxPerMm, warnings, qrInfo) {
/* A QR code needs an integer number of pixels per module to render crisply rather than blurring
at a fractional scale, so its true size is whatever that rounds down to - almost never the
scale-free box its aspect ratio alone would suggest. Called once every QR-family leaf has a
provisional (scale-free) box from a first layoutTree pass, this pins each one's real
box.width/box.height as `crispWidth`/`crispHeight`, 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/differently-shaped code than what actually gets drawn. Kept as two independent
dimensions rather than one `crispSize` (as when every code here was a square QR) since an rMQR
symbol isn't square - see encodeQr. */
function snapQrToCrispSize(node) {
if (isSplit(node)) {
node.forEach(child => snapQrToCrispSize(child, pxPerMm, warnings, qrInfo));
node.forEach(snapQrToCrispSize);
return;
}
if (isQrLeaf(node)) {
const {width: modulesW, height: modulesH} = node.qr;
const scale = Math.floor(Math.min(node.box.width / modulesW, node.box.height / modulesH));
if (!(scale >= 1)) {
const err = new Error("This text needs a bigger code than the tape allows — "
throw new Error("This text needs a bigger code than the tape allows — "
+ "try a shorter value or a wider tape.");
err.short = "too big";
throw err;
}
node.crispWidth = modulesW * scale;
node.crispHeight = modulesH * scale;
const moduleMm = scale / pxPerMm;
qrInfo.push({scale, moduleMm});
if (scale < MIN_RECOMMENDED_QR_PX_PER_MODULE) {
warnings.push({
short: `${scale}px/mod`,
message: `This code's modules are only ${scale}px wide - they may blur together `
+ "when printed; consider a bigger label or shorter content.",
});
}
if (moduleMm < MIN_RECOMMENDED_QR_MODULE_MM) {
warnings.push({
short: `${moduleMm.toFixed(2)}mm/mod`,
message: `This code's modules are only ${moduleMm.toFixed(2)}mm across - it may `
+ "be too small to scan reliably; consider a bigger label or shorter content.",
});
}
}
}
// The rendered font size, floored to a whole pixel - same reasoning as snapQrToCrispSize's
// integer module scale (rounding up could overflow the box a fractional size would have fit
// exactly). checkTextSizes and drawTextLeaf both call this rather than each computing their own
// raw fraction, so what gets measured/threshold-checked is exactly what gets drawn.
function effectiveFontPx(node, referencePx) {
return Math.floor(referencePx * (node.box.height / node.naturalHeight));
}
// Same shape as snapQrToCrispSize but for "text" leaves: throws below MIN_TEXT_PX/MIN_TEXT_MM
// (too small to read at all), appends a {short, message} warning to `warnings` for each of
// MIN_RECOMMENDED_TEXT_PX/MIN_RECOMMENDED_TEXT_MM it falls short of, and unconditionally appends
// its raw {fontPx, fontMm} to `textInfo`. Must run after the tree's *final* layoutTree pass (unlike
// snapQrToCrispSize, which runs before solve() re-resolves the tree) - a text leaf's box.height
// isn't stable until then, since (unlike a QR leaf's crispWidth/crispHeight) it doesn't feed back
// into that re-resolve.
function checkTextSizes(node, referencePx, pxPerMm, warnings, textInfo) {
if (isSplit(node)) {
node.forEach(child => checkTextSizes(child, referencePx, pxPerMm, warnings, textInfo));
return;
}
if (node.type !== "text") {
return;
}
const fontPx = effectiveFontPx(node, referencePx);
const fontMm = fontPx / pxPerMm;
if (fontPx < MIN_TEXT_PX || fontMm < MIN_TEXT_MM) {
const err = new Error(`This text only fits at ${fontPx}px `
+ `(${fontMm.toFixed(2)}mm) - too small to read; try a shorter value, a different `
+ "layout, or a bigger label.");
err.short = "too small";
throw err;
}
textInfo.push({fontPx, fontMm});
if (fontPx < MIN_RECOMMENDED_TEXT_PX) {
warnings.push({
short: `${fontPx}px text`,
message: `This text renders at only ${fontPx}px - it may be hard to read; `
+ "consider a bigger label or shorter content.",
});
}
if (fontMm < MIN_RECOMMENDED_TEXT_MM) {
warnings.push({
short: `${fontMm.toFixed(2)}mm text`,
message: `This text renders at only ${fontMm.toFixed(2)}mm tall - it may be hard to `
+ "read; consider a bigger label or shorter content.",
});
}
}
// Always measures in Inter at the reference size, since the eventual font (see FONT_TIERS) isn't
// known until layoutTree sizes the box this aspect ratio feeds into; the resulting mismatch (when
// a pixel font tier ends up chosen instead) is invisible in practice, and checkTextSizes still
// catches real failures.
// 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 "Inter"`;
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};
}
// Converts a resolved content tree (see templateContent) into one ready for layout. See
// docs/implementation.md#render-tree-construction.
/* 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-family leaf gets its
actual encoded modules (see encodeQr, keyed off the leaf's own type via QR_LEAF_TYPES) and an
aspect ratio taken from their real width/height - 1 (square) for qr/micro-qr, but not for rmqr,
whose symbols are rectangular - a text leaf gets its measured natural aspect ratio, and an
"empty" leaf passes through untouched. Multi-line text (`value` is an array) measures as one
leaf, not one per line - splitting it into a column of independently-sized leaves would let
each line grow to its own full width, ending up at a different font size than its neighbors,
which is legible but not what "one text field" should look like. */
function buildRenderTree(ctx, node, referencePx) {
if (isSplit(node)) {
return node.map(child => buildRenderTree(ctx, child, referencePx));
@ -341,34 +337,54 @@ function drawQrLeaf(ctx, node) {
}
}
// Returns the effective (un-normalized) font size drawn at; drawTree collects these into
// drawLabel/drawFallbackLabel's textSizesPx. Always draws - checkTextSizes (run earlier, on the
// same tree, before any of this) already rejected anything below MIN_TEXT_PX/MIN_TEXT_MM, so
// there's no "too small to draw" case left to special-case here.
// Returns the effective (raw, un-normalized) 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 = effectiveFontPx(node, referencePx);
const {family, scale = 1, pixel: isPixelFont = false} = fontFamilyFor(fontPx);
// Position (not size - fontPx is already a whole pixel) still needs snapping for pixel fonts
// to stay grid-aligned, since node.box.x/y are ordinary (fractional) layout math; Inter is
// left exact since anti-aliasing handles fractional positions fine.
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 {family, scale = 1} = fontFamilyFor(fontPx);
const isPixelFont = family !== "sans-serif";
// A pixel font's glyphs are meant to land exactly on the pixel grid - node.box.x/y are
// ordinary layout math (sums/quotients of affine-solved sizes) and essentially never land on
// a whole pixel, so drawing at their exact fractional size/position would misalign a pixel
// font's 1px-wide strokes the same as it would any other font. Snapping size and position to
// the nearest whole pixel fixes that; sans-serif is left at its exact fractional fit, since
// ordinary anti-aliased text is expected to (and looks fine) regardless of position.
const snap = isPixelFont ? Math.round : (v) => v;
// `scale` (Tom Thumb only, see FONT_TIERS above) corrects the size handed to ctx.font for its
// real ink; fontPx itself stays the logical size used for centering/stacking math.
ctx.font = `${fontPx * scale}px "${family}"`;
// Canvas text silently falls back if drawn before a not-yet-loaded font resolves, unlike DOM
// text. See docs/implementation.md#canvas-font-loading. Every tier now names a real,
// self-hosted webfont (see FONT_TIERS above), so this always applies, not just to pixel fonts.
document.fonts.load(ctx.font);
const drawFontPx = snap(fontPx);
// `scale` (Tom Thumb only, see PIXEL_FONT_TIERS above) corrects for a font whose declared
// size doesn't match its real visible ink - the size actually handed to ctx.font, not
// drawFontPx itself, which stays the logical size everything else here (box centering, line
// stacking) is measured against.
ctx.font = `${drawFontPx * scale}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 (isPixelFont) {
document.fonts.load(ctx.font);
}
ctx.textAlign = "center";
const centerX = snap(node.box.x + node.box.width / 2);
const lineHeight = node.box.height / node.lines.length;
// Lines stack as a block, each centered under the last, so a multi-line field reads as one unit.
// Lines stack as a block, each centered under the last - keeps a multi-line field reading as
// one unit rather than drifting apart.
let sliceTop = node.box.y;
for (const line of node.lines) {
if (isPixelFont) {
// textBaseline:"middle" centers on declared ascent/descent, which is backwards for
// Tom Thumb; centering on actualBoundingBox{Ascent,Descent} instead measures this
// string's real rendered ink and stays correct regardless.
// textBaseline:"middle" centers on the font's *declared* ascent/descent (its line
// height) - Tom Thumb's are backwards (see PIXEL_FONT_TIERS above) and would center on
// nonsense. Centering on actualBoundingBox{Ascent,Descent} instead - this specific
// string's real rendered ink (its character height) - costs nothing and stays correct
// regardless of whether a pixel font's declared metrics can be trusted.
ctx.textBaseline = "alphabetic";
const {actualBoundingBoxAscent: up, actualBoundingBoxDescent: down} = ctx.measureText(line);
ctx.fillText(line, centerX, snap(sliceTop + (lineHeight + up - down) / 2));
@ -381,22 +397,26 @@ function drawTextLeaf(ctx, node, referencePx) {
return fontPx;
}
// Manual debug toggle (flip in a debugger) to outline every leaf's box, including
// normally-invisible "empty" ones, in a color that can't be mistaken for real label ink; never
// wired up to any UI.
// 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 edge.
// 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();
}
// Collects each text leaf's effective font size so callers (Print.vue) can spot a suspiciously
// small field (checkTextSizes' warnings/textInfo cover the same numbers in more structured form).
// `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));
@ -413,9 +433,27 @@ function drawTree(ctx, node, referencePx, textSizesPx) {
}
}
// Builds, sizes and validates the tree for a fixed dimension plus pxPerMm; runs sizing twice so
// QR-family leaves' real crisp size is known before the tree is finally resolved, then checks
// every text leaf's final font size. See docs/implementation.md#label-content-layout.
/* Builds, sizes and validates the tree for a fixed `fixedSize` (the tape's cross-web printAreaPx,
or the fallback preview's reference height) - the one dimension every layout scales from, plus
`pxPerMm` to turn "empty" leaves' physical sizes into pixels. `fixedSize` and the tree's content
fully determine its overall size along the other, growing axis (the one that runs along the
tape as it feeds); `maxLength`, when finite (a fixed-length/die-cut tape), rejects content that
doesn't fit rather than shrinking it.
`orientation` picks which axis `fixedSize` binds to: "along" (the default) fixes the tree's
height - the tape's cross-web width - and grows its width along the feed direction, same as a
plain read top-to-bottom design. "across" fixes the tree's width instead and grows its height,
so the design is built turned 90deg from how it'd read "along" - drawLabel/drawFallbackLabel
are what actually rotate the drawing back into the physical raster's fixed orientation; nothing
here needs to know about that rotation, since relation()/layoutTree() below already solve the
tree in either direction symmetrically.
Sizing runs twice: a first pass treats every QR-family leaf as the scale-free box its real
width/height ratio suggests, purely to find out how much room each one would actually be
offered; from that, snapQrToCrispSize pins each one's real (smaller, crisp-pixel) size. The
second pass then resolves the whole tree again with that real size fixed in, so every sibling
and the overall size reflect what's actually drawn rather than the idealized box no code ever
quite fills. */
function layoutContent(ctx, content, fixedSize, maxLength, referencePx, pxPerMm, orientation) {
const tree = buildRenderTree(ctx, content, referencePx);
const alongTape = orientation !== "across";
@ -429,38 +467,35 @@ function layoutContent(ctx, content, fixedSize, maxLength, referencePx, pxPerMm,
let {width, height} = solve();
layoutTree(tree, false, width, height, pxPerMm);
const warnings = [];
const qrInfo = [];
snapQrToCrispSize(tree, pxPerMm, warnings, qrInfo);
snapQrToCrispSize(tree);
({width, height} = solve());
const length = alongTape ? width : height;
if (maxLength !== Infinity && length > maxLength) {
const err = new Error("This doesn't fit on this tape — "
throw new Error("This doesn't fit on this tape — "
+ "try a shorter value, a different layout, or a bigger label.");
err.short = "too big";
throw err;
}
layoutTree(tree, false, width, height, pxPerMm);
const textInfo = [];
checkTextSizes(tree, referencePx, pxPerMm, warnings, textInfo);
return {tree, length, warnings, qrInfo, textInfo};
return {tree, length};
}
// The tape-fed layout: draws a fully resolved content tree (see templateContent) at the tape's
// real pixel dimensions. See docs/implementation.md#tape-fed-label-drawing. Returns
// {textSizesPx}: each "text" leaf's effective font size, in the tree's own left-to-right,
// top-to-bottom order; {warnings}: {short, message} scan-reliability entries from
// snapQrToCrispSize/checkTextSizes, if any; {qrInfo}: each QR-family leaf's raw
// {scale, moduleMm}; {textInfo}: each text leaf's raw {fontPx, fontMm} - both regardless of
// whether they tripped a warning.
/* The tape-fed layout - draws a fully resolved content tree (see templateContent) at the tape's
real pixel dimensions. `orientation` is "along" (the default) to lay the design out reading
along the tape's feed direction, or "across" to turn it 90deg so it reads across the tape
instead - either way the physical raster this returns is still exactly
printedLength x tape.printAreaPx (that's fixed by the tape/print head, not a choice this
makes); "across" just draws the (now width-fixed, see layoutContent) tree through a rotated
canvas transform so it lands correctly in that same raster, rather than transposing every box
the tree itself computed. See DEBUG_LEAF_BORDERS 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, orientation = "along") {
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, length: contentLength, warnings, qrInfo, textInfo} = layoutContent(
const {tree, length: contentLength} = layoutContent(
measureCtx, content, tape.printAreaPx, maxLength, TEXT_REFERENCE_PX, pxPerMm, orientation);
const printedLength = tape.printLengthPx || Math.ceil(contentLength + tape.leadPx + TRAILING_PADDING_PX);
@ -476,8 +511,10 @@ export function drawLabel(canvas, tape, content, orientation = "along") {
+ Math.floor((printedLength - tape.leadPx - TRAILING_PADDING_PX - contentLength) / 2);
const textSizesPx = [];
if (orientation === "across") {
// Rotates/translates the width-fixed tree into the physical raster a quarter turn at a
// time. See docs/implementation.md#across-orientation-rotation.
// The tree was solved width-fixed (see layoutContent) - its width already exactly fills
// tape.printAreaPx, so only its (growing) height needs the same along-the-feed centering
// originX got above; translate+rotate then carries that tree-local (x, y) box straight
// into the physical (printedLength x printAreaPx) raster, a quarter turn at a time.
positionTree(tree, false, 0, origin);
ctx.save();
ctx.translate(0, tape.printAreaPx);
@ -488,19 +525,21 @@ export function drawLabel(canvas, tape, content, orientation = "along") {
positionTree(tree, false, origin, 0);
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
}
return {textSizesPx, warnings, qrInfo, textInfo};
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/renderer as drawLabel, scaled from a fixed
// reference height instead. See docs/implementation.md#fallback-label-preview. `orientation` and
// the {textSizesPx, warnings, qrInfo, textInfo} return, see drawLabel.
/* 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. `orientation`, see drawLabel. Returns {textSizesPx},
see drawLabel. */
export function drawFallbackLabel(canvas, content, orientation = "along") {
const measureCtx = canvas.getContext("2d");
const pxPerMm = FALLBACK_DPI / 25.4;
const {tree, length: contentLength, warnings, qrInfo, textInfo} = layoutContent(
const {tree, length: contentLength} = layoutContent(
measureCtx, content, FALLBACK_LABEL_HEIGHT_PX, Infinity, TEXT_REFERENCE_PX, pxPerMm, orientation);
canvas.width = Math.ceil(contentLength);
@ -523,18 +562,25 @@ export function drawFallbackLabel(canvas, content, orientation = "along") {
positionTree(tree, false, 0, 0);
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
}
return {textSizesPx, warnings, qrInfo, textInfo};
return {textSizesPx};
}
// Turns a {kind, components} prefill (see Print.vue's `prefill` prop) into the literal string a
// print label should show/encode; keyed by `kind` so each kind's format is defined in one place.
// 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), built from just
// the prefill's {userHandle, item}; the short link (Print.vue's `shortUrl`) needs an async store
// lookup, so it stays a separate field/template rather than being baked in here.
"item": ({userHandle, item}) => `${window.location.origin}/i/${encodeHandleForUrl(userHandle)}/${item}`,
// Storage locations have no long-form URL route yet (see router.js), so `text` starts blank;
// the short link and any future location template still work via the base vars below.
// 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. Nothing here
// needs anything beyond the prefill's own {userHandle, id} - the short link (see Print.vue's
// `shortUrl` computed) needs a store lookup no synchronous builder can do, so it's never baked
// into `text` this way; it's just another field/template a user can pick once the page is up.
"item": ({userHandle, id}) => `${window.location.origin}/i/${encodeHandleForUrl(userHandle)}/${id}`,
// Storage locations have no long-form URL route of their own (see router.js - only items get
// an /i/:handle/:id) - so there's nothing to bake synchronously here. Its base vars (below)
// still populate normally, so the short link (Print.vue's `shortUrl`) and any future
// location template are still available; `text` just starts blank until one is picked.
};
export function buildLabelContent(prefill) {
@ -545,9 +591,10 @@ export function buildLabelContent(prefill) {
return build ? build(prefill.components) : "";
}
// Splits a prefill's {userHandle, item/location} into label-layouts.js's user/domain base vars (the same way
// store.js's lookupServer does), tagging on whichever id field the resource's templates key
// required_vars by.
// A prefill's {userHandle, id} is the same raw identity for either resource kind below - this
// just splits the handle into label-layouts.js's separate `user`/`domain` base vars the same way
// store.js's own lookupServer does, and tags on whichever id field the resource's own templates
// key their required_vars by.
function splitUserHandle(userHandle) {
if (!userHandle) {
return null;
@ -559,23 +606,27 @@ function splitUserHandle(userHandle) {
};
}
// Seeds label-layouts.js's BASE_VARS, keyed by `kind`; derived vars (userHandle, itemUrl, …) are
// computed live elsewhere (see DERIVED_VARS, Print.vue's `shortUrl`), and omitting a field
// (rather than leaving it present-but-empty) signals "not available" to templateIsAvailable.
// 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 (userHandle, itemUrl,
// itemHandle, …) aren't built here; they're calculated live from whatever the base vars currently
// are (see label-layouts.js's DERIVED_VARS and Print.vue's `shortUrl`), 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 = {
"item": ({userHandle, item}) => {
"item": ({userHandle, id}) => {
const split = splitUserHandle(userHandle);
if (!split || !item) {
if (!split || !id) {
return {};
}
return {...split, itemId: String(item)};
return {...split, itemId: String(id)};
},
"storage-location": ({userHandle, location}) => {
"storage-location": ({userHandle, id}) => {
const split = splitUserHandle(userHandle);
if (!split || !location) {
if (!split || !id) {
return {};
}
return {...split, locationId: String(location)};
return {...split, locationId: String(id)};
},
};

View file

@ -1,66 +0,0 @@
// Shared printer device manager for WebUSB label printers (Print.vue) - centralizes device
// enumeration and preferred-printer memory, mirroring cameraManager.js's approach for webcams.
class PrinterManager {
constructor() {
this.availableDevices = [];
}
// A USB device has no stable "deviceId" like MediaDeviceInfo - vendorId+productId+serialNumber
// is the closest stable identity across plug/unplug and page reloads (empty serial number still
// uniquely identifies the common single-printer-of-that-model setup).
printerId(device) {
return `${device.vendorId}:${device.productId}:${device.serialNumber || ''}`;
}
async enumerateDevices() {
try {
this.availableDevices = await navigator.usb.getDevices();
return this.availableDevices;
} catch (err) {
console.error('Error enumerating printers:', err);
return [];
}
}
getAvailableDevices() {
return this.availableDevices;
}
getRecentPrinters() {
try {
const saved = localStorage.getItem('recentPrinterIds');
return saved ? JSON.parse(saved) : [];
} catch (err) {
console.error('Error loading recent printers:', err);
return [];
}
}
savePreferredPrinter(device) {
const printerId = this.printerId(device);
const recentPrinters = this.getRecentPrinters();
const updated = [printerId, ...recentPrinters.filter((id) => id !== printerId)];
try {
localStorage.setItem('recentPrinterIds', JSON.stringify(updated));
} catch (err) {
console.error('Error saving recent printers:', err);
}
}
// Most-recently-used device that's among `devices` (already-paired printers currently
// visible), for auto-connecting on page load / when a printer is plugged back in - same
// "recent list, first still-present match wins" logic as cameraManager's loadPreferredCamera.
findPreferredDevice(devices) {
const recentPrinters = this.getRecentPrinters();
for (const printerId of recentPrinters) {
const device = devices.find((d) => this.printerId(d) === printerId);
if (device) {
return device;
}
}
return null;
}
}
export default new PrinterManager();

View file

@ -46,7 +46,9 @@ export function decodeHandleFromUrl(segment) {
return segment.replace(/\+/g, "#");
}
// item/group_item share this /inventory/:handle/:id shape; only owner-handle resolution (identity vs. group, see store.js) differs.
// Both item-ish kinds land on the same /inventory/:handle/:id shape - only how they resolve
// their owner's handle differs (a personal owner vs. a group, see identityHandleById/
// groupHandleById in store.js), so that resolution is the only part that stays separate.
function itemDetailRoute(handle, item_local_id) {
return handle ? `/inventory/${encodeHandleForUrl(handle)}/${item_local_id}` : null;
}
@ -61,7 +63,10 @@ const EXPANDED_ROUTE_BUILDERS = {
workflow: ({workflow_id}) => `/workflows/${workflow_id}`,
};
// Kinds whose route needs identityHandleById/groupHandleById (from state.idmap); ShortId.vue only waits on an idmap fetch for these.
// Only these two builders read identityHandleById/groupHandleById (derived from state.idmap) -
// ShortId.vue checks this to decide whether a cold-open fetch of idmap is worth waiting on before
// giving up, so a storage_location/group/workflow/file short id never waits on an unrelated
// network call.
export const NEEDS_IDMAP = new Set(['item', 'group_item']);
export function expandedRoute({kind, ...fields}) {
@ -88,11 +93,24 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
meta: {requiresAuth: true},
props: true
}, {
// Label/short-link entry point; alias of /inventory/:handle/:id. See docs/implementation.md#item-short-link-redirect-route.
// The self-contained label/short-link entry point (see label.js's LABEL_CONTENT_BUILDERS
// and docs/design-in-progress/items-labels.md) - :handle is already URL-escaped the same
// way /inventory/:handle/:id expects it, so this is just a shorter alias for that route,
// with no owner-is-the-viewer special case: get_shared_item (and friends_or_self()) already
// treat "it's the viewer's own item" as one case of "the viewer may see this owner's item",
// not a separate path.
path: '/i/:handle/:id',
redirect: to => `/inventory/${to.params.handle}/${to.params.id}`
}, {
// beforeEnter, not redirect: falls through to ShortId.vue when the route can't resolve synchronously. See docs/implementation.md#beforeenter-guard-vs-redirect-for-short_id.
// A beforeEnter guard, not `redirect`: `redirect` is called synchronously and its return value
// is used as-is (never awaited), and it also *must* resolve to a valid location on every match
// (an unresolvable one throws, see vue-router's handleRedirectRecord) - it can't itself wait on
// fetchIdMap (see NEEDS_IDMAP) for the item/group_item kinds whose owner handle isn't
// resolvable from the token alone. A guard can return `null`/undefined to mean "proceed to the
// component instead", which is exactly what's needed here: when expandedRoute can't resolve yet
// (or ever - an unrecognized kind), stay on this same URL and mount ShortId.vue in place, which
// has full component-lifecycle async support and takes it from there - fetch idmap, retry,
// redirect once resolved, or keep showing the decode view.
path: '/:short_id',
component: ShortId,
props: true,
@ -139,9 +157,7 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
meta: {requiresAuth: true},
props: route => {
const {kind, ...components} = route.query;
// queryFields lets any base var (e.g. ?text=...) be set directly, without going through
// a `kind` builder - see Print.vue's varValues.
return {prefill: kind ? {kind, components} : null, queryFields: components};
return {prefill: kind ? {kind, components} : null};
}
}, {
path: '/scan',

View file

@ -1,12 +1,19 @@
// Bitmap-style fonts label.js's drawTextLeaf switches to below 10px, where sans-serif gets
// illegible (see ../assets/fonts/pixel/LICENSE.md for sources); label.js's PIXEL_FONT_TIERS uses
// only these two of three candidates - the third, PICO-8, has no lowercase glyphs.
// 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");

View file

@ -93,8 +93,6 @@ $body-color: $gray-700;
@import "tags";
@import "dropdown";
@import "pixel-fonts";
@import "pixel-fonts-candidates";
@import "label-fonts";
#root, body, html {
height: 100%;

View file

@ -329,17 +329,22 @@ export default createStore({
const request = '_toolshed-server._tcp.' + domain + '.'
return await state.resolver.query(request, 'SRV').then(
(result) => result.map(
// Must match the browser's real Host header (federation.js signs/fetches
// "https://"+server+target); browser omits :443 for default HTTPS, so
// keeping it here would break signature checks on the receiving end.
// Must match what the browser actually puts in the Host header for
// the request this gets used to build (federation.js always signs
// and fetches "https://" + server + target) - it omits a :443 for
// the default HTTPS port, so keeping it here would make every
// signature check on the receiving end fail against the real request.
(answer) => answer.port === 443 ? answer.target : answer.target + ':' + answer.port))
},
async getHomeServers({state, dispatch, commit, getters}) {
if (state.home_servers)
return state.home_servers
// Reading isLoggedIn first forces its lazy hydration of state.user from localStorage,
// needed here since routes without requiresAuth (e.g. short-id redirect) skip that
// check; fail clearly if still not logged in rather than crashing on username.split.
// isLoggedIn (store.js's getters) is what lazily hydrates state.user/token/keypair
// from localStorage on first read - a route with no requiresAuth meta (e.g. the
// short-id redirect) never triggers that beforeEach check, so state.user can still be
// null here even for an actually-logged-in visitor. Reading the getter first forces
// that hydration; if it's still false afterwards, the visitor really isn't logged in,
// so fail with a clear error instead of lookupServer crashing on username.split(...).
if (!getters.isLoggedIn) {
throw new Error('Not logged in')
}
@ -360,7 +365,8 @@ export default createStore({
const s = await dispatch('lookupServer', {username: friend.username})
servers.add(new ServerSet(s, state.unreachable_neighbors))
} catch (e) {
// Skip an unresolvable/unreachable friend rather than aborting the whole lookup.
// Don't let a single unresolvable/unreachable friend abort the whole
// search/federation lookup - just skip them and continue.
console.error('could not resolve server for friend', friend.username, e)
}
}
@ -436,8 +442,9 @@ export default createStore({
async fetchForeignItem({dispatch, getters}, {owner, id}) {
try {
const servers = await dispatch('getFriendServers', {username: owner});
// owner is a full handle (username@domain); this endpoint looks the item up by
// owner, not requester (see toolshed/api/inventory.py get_shared_item).
// owner here is a full handle (username@domain) - see the /api/inventory_items/<handle>/<id>/
// endpoint (toolshed/api/inventory.py get_shared_item), which looks the item up by owner rather
// than by requester, unlike the plain /api/inventory_items/ list/detail endpoints.
const item = await servers.get(getters.signAuth, '/api/inventory_items/' + owner + '/' + id + '/');
if (item && item.files) {
item.files.forEach(file => file.owner = item.owner)
@ -448,12 +455,21 @@ export default createStore({
return null;
}
},
// Group handles have no owner-handle GET route yet, so they resolve differently than
// personal handles here. See docs/implementation.md#fetch-item-by-handle-group-vs-personal-handles.
// A group handle (leading '#') has no working owner-handle GET route yet
// (get_shared_item, which fetchForeignItem calls, only resolves a personal
// ToolshedUser handle) - resolve it instead via the already-correct, already-
// authenticated group listing (?group=<id>, see fetchGroupInventoryItems) and pick the
// matching item out of that, which only ever contains this one group's own items, so an
// id collision with anything else can't happen. A personal/friend handle still goes
// through fetchForeignItem as before.
async fetchItemByHandle({dispatch, getters}, {handle, id}) {
if (handle.startsWith('#')) {
// idmap isn't guaranteed loaded on a direct/refreshed visit here; fetchIdMap is
// cheap and already called unconditionally by every other caller too.
// groupIdByHandle is derived from state.idmap (see store.js's getters), which
// nothing guarantees is loaded yet at this point - unlike Inventory.vue/
// StorageLocation.vue/Print.vue, a direct or refreshed visit to an item's own
// detail/edit page never fetched it. Loading it here, every time, is simplest;
// fetchIdMap is cheap and already called unconditionally (no cache check) by
// every other caller too.
await dispatch('fetchIdMap')
const groupId = getters.groupIdByHandle[handle]
if (groupId === undefined) {
@ -512,8 +528,10 @@ export default createStore({
return await servers.delete(getters.signAuth, '/api/friends/' + id + '/')
},
// Groups are only ever hosted on the current user's own home backend for now (see
// docs/design-in-progress/groups-mvp.md), so every group action below uses getHomeServers
// rather than resolving a per-group domain.
// docs/design-in-progress/groups-mvp.md) - a remote member's edit/delete rights on a
// group-owned item work regardless, but "My Groups" has no way to discover a group hosted
// elsewhere, so every group action below talks to getHomeServers rather than resolving a
// per-group domain.
async fetchGroups({commit, dispatch, getters}) {
const servers = await dispatch('getHomeServers')
const data = await servers.get(getters.signAuth, '/api/groups/')
@ -751,8 +769,8 @@ export default createStore({
},
async createWorkflow({state, commit, dispatch, getters}, workflowData) {
const servers = await dispatch('getHomeServers')
// The backend stores payload as an opaque string; the frontend (de)serializes it.
// See docs/implementation.md#workflow-payload-is-an-opaque-string.
// The backend stores `payload` as an opaque string - the frontend is
// responsible for serializing/deserializing the JSON itself.
const data = await servers.post(getters.signAuth, '/api/workflows/', serializeWorkflowPayload(workflowData))
state.last_load.active_workflows = 0 // Invalidate cache
return deserializeWorkflowPayload(data)
@ -820,8 +838,9 @@ export default createStore({
groupIdByHandle(state) {
return Object.fromEntries(state.idmap.groups.map(g => [g.handle, g.id]))
},
// Reverse of the two getters above: turns a short-id's raw owner_identity_id/owner_group_id
// back into a handle (see router.js EXPANDED_ROUTE_BUILDERS), no backend lookup needed.
// Reverse of the two getters above - turns a short-id's raw owner_identity_id/
// owner_group_id back into a handle (see router.js's EXPANDED_ROUTE_BUILDERS), without
// a separate backend lookup since the idmap already has both directions of this data.
identityHandleById(state) {
return Object.fromEntries(state.idmap.identities.map(i => [i.id, i.username]))
},
@ -882,7 +901,11 @@ export default createStore({
}
return fallbackDefault
},
/** Extracts the name from a handle like "git:tools#tag:drill"; returns non-handles unchanged. */
/**
* Extracts the human-readable name from a fully qualified handle.
* Handles look like "git:tools#tag:drill" or "git:base#property:length".
* If the given value does not look like a handle, it is returned unchanged.
*/
getNameFromHandle: () => (handle) => {
if (typeof handle !== 'string') {
return handle;

View file

@ -46,7 +46,9 @@ test('rejects a 0 last-field value, for a multi-field kind', () => {
test('decoding never over-reads: a single-chunk field that exactly exhausts padding is not ' +
'mistaken for a second field', () => {
// category_id 5 -> 7 bits + 5 zero padding bits, identical to a genuine one-chunk zero field - the case the last-field-nonzero rule disambiguates.
// category_id: 5 encodes as kind-tag(2 bits) + one 5-bit chunk = 7 bits, padded with exactly
// 5 zero bits - the same 5 bits as a genuine one-chunk field of value 0. This is the concrete
// case the last-field-nonzero rule exists to disambiguate.
const token = encodeShortId([0, 5])
expect(token).toBe('~Cg')
expect(decodeShortId(token)).toEqual([0, 5])

View file

@ -128,8 +128,9 @@ export default {
},
methods: {
...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchStorageLocations", "fetchIdMap"]),
// Always the viewer's own items (fetchInventoryItems has no group filter); the owner
// handle is always included so /inventory/:handle/:id has exactly one shape.
// This list is always the viewer's own personal items (fetchInventoryItems has no
// group filter) - the owner handle is always their own, but it's always included
// rather than special-cased, so /inventory/:handle/:id has exactly one shape.
itemRoute(item) {
return `/inventory/${encodeHandleForUrl(this.user)}/${item.id}`
},
@ -150,11 +151,16 @@ export default {
if (owner_identity_id === undefined) return null
return shortenedRoute({kind: 'item', owner_identity_id, item_local_id: item.id})
},
// Routes to Print.vue with this item's raw identity rather than a pre-built link.
// See docs/implementation.md#print-link-shape-for-personal-items.
// Routes to Print.vue with this item's own raw identity - userHandle + id, the same shape
// InventoryDetail.vue's own Print label button sends - rather than any pre-built link, so
// the print page can derive every item template (item-handle, owner-handle, item-url,
// the short link, …) itself and isn't tied to whichever one this button "suggests".
// Group-owned items have no individual owner handle - short-id.js's group_item kind
// resolves them via owner_group instead (see shortIdLink above) - so there's no
// {userHandle, id} to build here yet; they get no print link until that's supported too.
printLinkFor(item) {
if (!item.owner) return null
return {path: '/print', query: {kind: 'item', userHandle: item.owner, item: item.id}}
return {path: '/print', query: {kind: 'item', userHandle: item.owner, id: item.id}}
},
},
async mounted() {

View file

@ -51,7 +51,7 @@
Delete
</button>
<button v-if="canPrint" class="btn btn-secondary"
@click="$router.push({path: '/print', query: {kind: 'item', userHandle: decodedHandle, item: id}})">
@click="$router.push({path: '/print', query: {kind: 'item', userHandle: decodedHandle, id}})">
<b-icon-printer></b-icon-printer>
Print label
</button>
@ -98,13 +98,16 @@ export default {
decodedHandle() {
return decodeHandleFromUrl(this.handle)
},
// Edit/Delete require actual authorization (own item or member group), not just view
// access - get_shared_item's friends_or_self() lets a friend view but never act.
// Edit/Delete apply once the viewer is actually authorized to act on this item - their
// own item, or a group they belong to - not just anyone who can view it
// (get_shared_item's friends_or_self() also lets a friend view a shared item, but never
// act on it).
canEdit() {
return this.decodedHandle === this.user || this.decodedHandle in this.groupIdByHandle
},
// Printed labels only support a personal owner handle so far (see label.js's
// splitUserHandle); group items get no print link until that's supported too.
// splitUserHandle/Inventory.vue's printLinkFor) - group items don't get a print link
// until that's supported too.
canPrint() {
return this.decodedHandle === this.user
}

View file

@ -98,9 +98,9 @@ export default {
},
data() {
return {
// Fetched fresh by {handle, id} on mount rather than looked up by id alone -
// loaded_items mixes personal/group/search fetches, and id is only unique per owner
// (see InventoryDetail.vue).
// Fetched fresh by {handle, id} on mount rather than found by id alone in whatever
// happens to already be cached (loaded_items mixes personal, group and search
// fetches, and id is only unique within one owner's own items - see InventoryDetail.vue).
item: {
tags: [],
properties: [],

View file

@ -6,10 +6,6 @@
<div v-if="error" class="alert alert-danger" role="alert">{{ error }}</div>
<div v-if="warnings.length" class="alert alert-warning" role="alert">
<div v-for="(w, i) in warnings" :key="i">{{ w.message }}</div>
</div>
<div v-if="!usbSupported" class="alert alert-warning">
This browser can't talk to USB label printers directly. Open this page in Chrome, Edge or
Opera over https:// (or http://localhost) to print straight from here, or use one of the
@ -57,7 +53,7 @@
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Ten pixel/bitmap-style fonts found in frontend/public, rendered live from the "Text"
Nine pixel/bitmap-style fonts found in frontend/public, rendered live from the "Text"
field above at a range of sizes so they can be judged the same way Tom Thumb/Silkscreen
were. <strong>CodersCrux</strong> and <strong>712Serif</strong> fail to load as web
fonts at all - Chromium's OTS sanitizer rejects their cmap table - so their cells below
@ -86,7 +82,7 @@
<h5 class="card-title mb-0">Label preview</h5>
</div>
<div class="card-body">
<div class="label-preview mb-3" v-show="fallbackReady">
<div class="label-preview mb-3" v-show="selectedContent">
<canvas ref="fallbackCanvas"></canvas>
</div>
@ -144,7 +140,7 @@
Connect a printer to preview and print a label.
</p>
<template v-else>
<div class="preview-row mb-3" v-show="labelBitmap">
<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"
@ -167,7 +163,10 @@
:key="'label-' + t.mm" class="tick-label"
:style="{left: t.pos + 'px'}">{{ t.mm }}</span>
</div>
<!-- Tape's full physical width; canvas is narrower/centered, margin is real tape. See docs/implementation.md#tape-full-print-margin. -->
<!-- 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">
@ -248,8 +247,7 @@
<div class="row">
<div class="col-12">
<label-layout-preview :fields="fields" :value="selectedTemplate" :tape="tape"
:recent-template-ids="recentTemplateIds"
<label-layout-preview :fields="fields" :value="selectedTemplate"
@input="selectedTemplate = $event"></label-layout-preview>
</div>
</div>
@ -264,30 +262,39 @@ import {markRaw, nextTick} from "vue";
import {mapActions, mapGetters} from "vuex";
import BaseLayout from "@/components/BaseLayout.vue";
import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
import printerManager from "@/printerManager.js";
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
import {tapeFromStatus, drawLabel, drawFallbackLabel, buildLabelContent, buildLabelFields, preloadQrEncoder} from "@/label.js";
import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, withDerivedVars, templateContent} from "@/label-layouts.js";
import {shortenedRoute} from "@/router";
// Print.vue-local calculated fields on top of label-layouts.js's DERIVED_VARS. See
// docs/implementation.md#calculated-short-link-fields.
// The extra "Calculated" fields (and label-layouts.js "Short link (QR code)" template input) this
// view adds on top of label-layouts.js's own DERIVED_VARS - resolving either needs the current
// identityIdByHandle map (see store.js's fetchIdMap) to turn a handle into the numeric
// owner_identity_id short-id.js's 'item'/'storage_location' kinds encode, so neither can be a pure
// fields->value calc like the others and both live here instead of in label-layouts.js. Excluded
// from baseVars below since, unlike every other entry KNOWN_VARS picks up from a template's
// required_vars, neither is ever typed directly.
const SHORT_URL_VAR = "shortUrl";
// See docs/implementation.md#calculated-short-link-fields.
// The bare short-id.js token itself (e.g. "~AbCd12"), with no domain or leading "/" - what
// shortUrl's own path is built from (see fields()/the shortId method below), for a label that
// wants just the compact code rather than a full scannable URL.
const SHORT_ID_VAR = "shortId";
// Served unbundled so its wasm sibling stays resolvable. See docs/implementation.md#libweblabel-served-unbundled.
// Served verbatim from public/vendor/ rather than bundled: libweblabel.js's
// own emscripten glue resolves its .wasm sibling relative to *its own*
// import.meta.url at runtime, so both files need to keep sitting together,
// unhashed, at a stable URL - not a Vite-fingerprinted asset path.
const BLOB_URL = "/vendor/libweblabel.js";
// localStorage key for the most-recently-printed template ids (see rememberPrintedTemplate/loadRecentTemplateIds), same naming style as cameraManager.js's recentCameraIds.
const RECENT_TEMPLATES_KEY = "recentLabelTemplateIds";
// How many recently-printed templates LabelLayoutPreview.vue bubbles to the front of the grid.
const MAX_RECENT_TEMPLATES = 4;
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 */
// Tick spacing tiers, coarser the longer the ruler runs. See docs/implementation.md#ruler-tier-selection.
// 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},
@ -302,16 +309,13 @@ export default {
...BIcons
},
props: {
// {kind, components} from the ?kind=…&… query params (router.js's /print route builds this prop); buildLabelContent turns it into the text field below.
// {kind, components} prefilled from the ?kind=…&… query params when arriving from e.g.
// an item's "Print label" button (see InventoryDetail.vue) - the router turns those
// query params into this prop (router.js's /print route), rather than the component
// reading $route directly. buildLabelContent turns it into the literal string below.
prefill: {
type: Object,
default: null
},
// Every query param other than `kind` (router.js), so any base var - e.g. ?text=... - can
// be set directly without needing a `kind` builder in label-layouts.js.
queryFields: {
type: Object,
default: () => ({})
}
},
data() {
@ -325,28 +329,40 @@ export default {
connected: null,
tape: null,
labelBitmap: null,
// Tracked reactively (not read off the canvas) so the mm ruler can recompute tick positions when either changes (see fitZoom/redraw).
// 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 (see label.js's drawLabel), shown beside the tape width so a too-small-to-render field reads as a suspiciously tiny number rather than silently absent.
// 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: [],
// Scan-reliability messages from label.js's drawLabel/drawFallbackLabel (too few px per QR module, or too small in mm) - the label still rendered/prints fine, just flagged as a risk.
warnings: [],
// One input per BASE_VARS entry; derived vars (userHandle/itemUrl/itemHandle) are calculated-only (see the `fields` computed), never stored here. Prefilled from query params but left editable. queryFields is applied last so an explicit ?text=... etc. always wins over a `kind` builder's computed value.
// 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 this page's own origin; editable since any frontend can resolve any handle, so a label needn't point back at this one.
// 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),
...Object.fromEntries(BASE_VARS.filter(v => v in this.queryFields).map(v => [v, this.queryFields[v]])),
},
copies: 1,
selectedTemplate: LABEL_TEMPLATES[0].id,
// Ids of the last MAX_RECENT_TEMPLATES distinct templates printed/downloaded, most recent first. See rememberPrintedTemplate/loadRecentTemplateIds.
recentTemplateIds: [],
// "along" reads along the tape's feed direction (usual case, width-constrained); "across" turns 90deg on that same width instead. See label.js's drawLabel/drawFallbackLabel.
// "along" draws a layout reading along the tape's feed direction (the usual case -
// constrained by the tape's cross-web width, growing as long as the content needs);
// "across" turns it 90deg, constrained by that same width but along the *other* axis
// instead, so it reads across the tape rather than along it. See label.js's
// drawLabel/drawFallbackLabel for how that turn is actually drawn.
orientation: "along",
fallbackReady: false,
@ -354,7 +370,11 @@ export default {
brotherCliExample: "brother_ql --model QL-000 --printer usb://0000:0000 print --label 00 label.png",
niimbotCliExample: "niimprint --model b00 --conn usb print --density 3 --image label.png",
// Ten pixel/bitmap font candidates for legibility testing only (see the disabled card above); not used by label.js's real FONT_TIERS.
// Nine pixel/bitmap-style font candidates found in frontend/public (see
// ../assets/fonts/pixel-candidates/LICENSE.md) - none of these are used by label.js's
// real PIXEL_FONT_TIERS; this card exists purely so they can be judged the same way
// Tom Thumb/Silkscreen were, against real (live-typed) content instead of a fixed
// example.
candidateFonts: [
{family: "Pixelon"},
{family: "Pixelbasel"},
@ -365,7 +385,6 @@ export default {
{family: "6pxExpert"},
{family: "Jersey10"},
{family: "Jersey15"},
{family: "Terminus"},
],
candidateSizes: [5, 6, 7, 8, 10, 12, 16, 20],
candidateZoom: 4,
@ -373,16 +392,27 @@ export default {
},
computed: {
...mapGetters(["identityIdByHandle"]),
// Plain passthroughs of the form's base/derived vars, keeping the template from importing
// label-layouts.js just for these. SHORT_URL_VAR/SHORT_ID_VAR exclusion: see
// docs/implementation.md#calculated-short-link-fields.
// 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. SHORT_URL_VAR is
// excluded here even though the "Short link (QR code)" template's required_vars puts it in
// BASE_VARS (it isn't a label-layouts.js DERIVED_VARS entry) - see fields() below for why
// it's calculated, not typed. SHORT_ID_VAR isn't referenced by any template's
// required_vars (so isn't actually in BASE_VARS today), filtered out too in case one ever
// is.
baseVars() {
return BASE_VARS.filter(v => v !== SHORT_URL_VAR && v !== SHORT_ID_VAR);
},
derivedVars() {
return [...Object.keys(DERIVED_VARS), SHORT_ID_VAR, SHORT_URL_VAR];
},
// Named content fields the templates draw from, dropping blank values. See docs/implementation.md#fields-computed-dropping-blank-values.
// 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.
fields() {
const base = {};
for (const v of BASE_VARS) {
@ -403,6 +433,9 @@ export default {
currentTemplate() {
return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
},
selectedContent() {
return templateContent(this.currentTemplate, this.fields);
},
deviceRows() {
if (!this.blob) {
return [];
@ -421,34 +454,48 @@ export default {
canPrint() {
return Boolean(this.tape && this.labelBitmap && !this.busy);
},
// px per real mm at current zoom; meaningful only for the tape-fed preview (the no-webusb fallback has no real tape/dpi, so no ruler).
// 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;
},
// Physical mm length each ruler axis must cover; see horizontal/verticalRulerTicks for what each measures.
// 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;
},
// Shared tier keyed off whichever axis is longer. See docs/implementation.md#ruler-tier-selection.
// 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 printed length, feed margins included (still real tape).
// 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 width, not just the printable area. See docs/implementation.md#tape-full-print-margin.
// 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) : [];
},
// e.g. "(5px, 23px)", or "" so it appends cleanly onto the tape-width <small> with nothing shown.
// "(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 "";
@ -482,7 +529,10 @@ export default {
this.redrawFallback();
}
},
// Catches printer connect/disconnect/switch; flush:'post' since the canvas only exists once `tape` is truthy (template's v-if).
// Covers connecting/disconnecting/switching printers - anything that changes the
// tape dimensions redraw() sizes the canvas from. flush: 'post' because the canvas
// itself only exists once `tape` is truthy (see the v-if/v-else in the template), so
// this has to run after Vue has actually mounted it, not before.
tape: {
handler() {
this.redraw();
@ -493,12 +543,24 @@ export default {
methods: {
...mapActions(["fetchIdMap"]),
// camelCase -> Title Case (e.g. "itemHandle" -> "Item Handle") so a new template var needs no hand-written label.
// 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());
},
// Builds the bare short-id.js token for the current fields. See docs/implementation.md#shortid-resolution.
// The same shortened link Inventory.vue/StorageLocation.vue's own shortIdLink builds for
// one of their rows, but as the bare token (see short-id.js's encodeShortId) rather than a
// router target or full URL - shortenedRoute's own leading "/" is stripped since this is a
// plain display field/potential label content, not something this view itself navigates
// to; fields() below reattaches a domain and slash to build shortUrl from this same value,
// so the two can never disagree. Which short-id.js kind applies depends on which id field
// the current prefill's LABEL_FIELD_BUILDERS populated (see label.js) - itemId for an
// item, locationId for a storage location - rather than trusting the prefill's own `kind`
// directly, so hand-typing userHandle+itemId with no prefill at all still resolves this
// the same way. Falls back to no value - same as an unresolved DERIVED_VARS entry - until
// identityIdByHandle has loaded (see mounted's fetchIdMap) or if the handle isn't in it.
shortId(f) {
if (!f.userHandle) {
return null;
@ -518,7 +580,10 @@ export default {
return null;
},
// Ticks 0..totalMm via tapePxPerMm, shared by both ruler computeds; spacing comes from the shared rulerTier, so both rulers coarsen together.
// 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 = [];
@ -528,31 +593,6 @@ export default {
return ticks;
},
// Reads the recently-printed template ids back from localStorage; same try/catch shape as
// cameraManager.js's getRecentCameras since either a disabled/full localStorage shouldn't
// break printing.
loadRecentTemplateIds() {
try {
const saved = localStorage.getItem(RECENT_TEMPLATES_KEY);
return saved ? JSON.parse(saved) : [];
} catch (e) {
return [];
}
},
// Moves `id` to the front of the recent-templates list (deduping any earlier occurrence),
// capped to MAX_RECENT_TEMPLATES, so LabelLayoutPreview.vue's grid always bubbles the
// last four printed/downloaded layouts to the top.
rememberPrintedTemplate(id) {
const updated = [id, ...this.recentTemplateIds.filter(t => t !== id)].slice(0, MAX_RECENT_TEMPLATES);
this.recentTemplateIds = updated;
try {
localStorage.setItem(RECENT_TEMPLATES_KEY, JSON.stringify(updated));
} catch (e) {
// Best-effort only - a disabled/full localStorage shouldn't block printing.
}
},
async guard(fn) {
this.error = null;
this.busy = true;
@ -566,8 +606,10 @@ export default {
},
async refreshDevices() {
this.devices = (await printerManager.enumerateDevices()).map((d) => markRaw(d));
/* A printer unplugged while open drops from the list: its handle is gone, so close the card rather than keep it open on a dead connection. */
this.devices = (await navigator.usb.getDevices()).map((d) => markRaw(d));
/* A printer unplugged while open is off the list: the handle it was
opened through is gone, so drop it rather than keep the label
card open on a connection that no longer exists. */
if (this.connected !== null && !this.devices.includes(this.connected)) {
this.connected = null;
this.tape = null;
@ -596,32 +638,20 @@ export default {
}
},
// Only one open connection at a time: connecting a different printer disconnects the current one first.
async connectToDevice(device) {
await this.closeConnection();
this.blob.setDevices([device]);
await this.blob.open(device.vendorId, device.productId);
this.connected = device;
printerManager.savePreferredPrinter(device);
const status = await this.blob.status();
// Setting `tape` alone is enough to redraw - see the tape watcher above.
this.tape = this.blob.can("print") ? tapeFromStatus(status) : null;
},
connect(index) {
this.guard(() => this.connectToDevice(this.devices[index]));
},
// Auto-connects to the most-recently-used printer once it's paired and visible again (page
// load, or plugged back in - see onUsbChange), mirroring cameraManager's preferred-camera
// auto-select. Gated on "nothing connected yet" since a manual connect() to a different
// paired printer shouldn't be silently overridden by a later device-list refresh.
autoConnectPreferred() {
if (this.connected) {
return Promise.resolve();
}
const preferred = printerManager.findPreferredDevice(this.devices);
return preferred ? this.connectToDevice(preferred) : Promise.resolve();
this.guard(async () => {
// Only one open device connection at a time - pressing Connect on a different
// printer while one is already open implicitly disconnects it first, rather
// than requiring an explicit Disconnect click.
await this.closeConnection();
const device = this.devices[index];
this.blob.setDevices([device]);
await this.blob.open(device.vendorId, device.productId);
this.connected = device;
const status = await this.blob.status();
// Setting `tape` alone is enough to redraw - see the tape watcher above.
this.tape = this.blob.can("print") ? tapeFromStatus(status) : null;
});
},
disconnect() {
@ -630,7 +660,8 @@ export default {
redraw() {
this.labelBitmap = null;
if (!this.tape) {
const content = this.selectedContent;
if (!this.tape || !content) {
return;
}
const canvas = this.$refs.labelCanvas;
@ -638,17 +669,15 @@ export default {
return;
}
this.resizeObserver.observe(canvas.parentElement);
let textSizesPx, warnings;
let textSizesPx;
try {
const content = templateContent(this.currentTemplate, this.fields);
({textSizesPx, warnings} = drawLabel(canvas, this.tape, content, this.orientation));
({textSizesPx} = drawLabel(canvas, this.tape, content, this.orientation));
} catch (e) {
this.error = e.message;
return;
}
this.error = null;
this.textSizesPx = textSizesPx;
this.warnings = warnings;
const bitmap = canvasToBitmap(canvas);
bitmapToCanvas(canvas, bitmap);
this.labelBitmap = bitmap;
@ -658,21 +687,22 @@ export default {
redrawFallback() {
this.fallbackReady = false;
const content = this.selectedContent;
if (!content) {
return;
}
const canvas = this.$refs.fallbackCanvas;
if (!canvas) {
return;
}
this.resizeObserver.observe(canvas.parentElement);
let warnings;
try {
const content = templateContent(this.currentTemplate, this.fields);
({warnings} = drawFallbackLabel(canvas, content, this.orientation));
drawFallbackLabel(canvas, content, this.orientation);
} catch (e) {
this.error = e.message;
return;
}
this.error = null;
this.warnings = warnings;
this.fallbackReady = true;
this.fitZoom(canvas);
},
@ -686,17 +716,29 @@ export default {
link.download = "label.png";
link.href = canvas.toDataURL("image/png");
link.click();
this.rememberPrintedTemplate(this.selectedTemplate);
},
// Fits the preview to its card, magnifying up to MAX_ZOOM/MAX_PREVIEW_HEIGHT_PX. See docs/implementation.md#fit-zoom-preview-scaling.
/* 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. */
fitZoom(canvas) {
const available = canvas.parentElement.clientWidth;
if (!(available > 0)) {
return 1;
}
const rawZoom = Math.min(MAX_ZOOM, available / canvas.width, MAX_PREVIEW_HEIGHT_PX / canvas.height);
// Floors rather than rounds/ceils when magnifying, to keep image-rendering:pixelated crisp. See docs/implementation.md#fit-zoom-preview-scaling.
// When magnifying, round DOWN to a whole number: image-rendering:pixelated below
// only actually looks crisp when every source pixel maps to the *same* number of
// screen pixels - at a fractional zoom (the overwhelmingly common case, since rawZoom
// is just whatever ratio the tape/card happen to produce) some source pixels get
// rounded up to one extra screen pixel and others don't, unevenly warping fine,
// already-pixel-perfect detail like a crisp QR module or a tiny bitmap font glyph.
// Flooring (never rounding/ceiling) keeps the same "never bigger than available
// space" guarantee rawZoom already had. Shrinking (zoom < 1) has no equivalent "whole
// factor" to snap to - downsampling always blends source pixels - so it's left as-is.
const zoom = rawZoom >= 1 ? Math.max(1, Math.floor(rawZoom)) : rawZoom;
canvas.style.width = `${canvas.width * zoom}px`;
canvas.style.height = `${canvas.height * zoom}px`;
@ -708,11 +750,12 @@ export default {
this.guard(async () => {
const copies = Math.max(1, Math.min(20, Number(this.copies) || 1));
await this.blob.printBitmap(this.labelBitmap, {copies});
this.rememberPrintedTemplate(this.selectedTemplate);
});
},
// Re-fits the resized container's canvas (window resize, sidebar toggle, font load, etc.); debounced since ResizeObserver can fire in bursts.
/* Re-fits whichever canvas sits in a resized container - covers a window resize, but
also a sidebar toggle, a font finishing loading, or any other layout change that
isn't a window resize at all. Debounced since ResizeObserver can fire in bursts. */
handleContainerResize(entries) {
clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(() => {
@ -726,13 +769,12 @@ export default {
},
onUsbChange() {
this.guard(async () => {
await this.refreshDevices();
await this.autoConnectPreferred();
});
this.guard(() => this.refreshDevices());
},
// Works around Vue's refInFor array-collecting behavior for :ref in v-for, same as LabelLayoutPreview.vue's setTemplateCanvasRef.
// A plain :ref="key" inside v-for would still get Vue's refInFor array-collecting
// behavior (see LabelLayoutPreview.vue's setTemplateCanvasRef for the same pattern), so
// this keys the canvases by the caller's own composite key string explicitly instead.
setCandidateCanvasRef(key, el) {
if (el) {
this.candidateCanvases[key] = el;
@ -741,7 +783,12 @@ export default {
}
},
// Renders the "Text" field at exact size/family with no layout snapping, so the font itself is judged; ink-centered vertically (like label.js's drawTextLeaf) so unreliable metrics don't clip. Thresholded through canvasToBitmap/bitmapToCanvas afterwards, same as redraw()'s real label preview, so a candidate is judged on the same 1-bit dots the printer would actually fire, not the anti-aliased canvas render.
// Draws the live "Text" field's value into a single canvas at the exact size/family
// given - no layout math, no snapping, just ctx.font as requested, so what's judged here
// is the font itself rather than anything label.js's real pipeline does to it. Canvas size
// is measured from the text itself, and ink-centered vertically (see label.js's
// drawTextLeaf for the same idea) so a font with unreliable declared metrics still lands
// fully inside the canvas instead of clipped off the top/bottom.
drawCandidateCell(canvas, family, fontPx) {
if (!canvas) {
return;
@ -760,7 +807,6 @@ export default {
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#000";
ctx.fillText(text, 2, (canvas.height + up - down) / 2);
bitmapToCanvas(canvas, canvasToBitmap(canvas));
canvas.style.width = `${canvas.width * this.candidateZoom}px`;
canvas.style.height = `${canvas.height * this.candidateZoom}px`;
},
@ -782,16 +828,20 @@ export default {
this.resizeObserver = null;
this.resizeTimer = null;
this.candidateCanvases = {};
this.recentTemplateIds = this.loadRecentTemplateIds();
},
async mounted() {
this.drawCandidateFontTests();
// Not awaited: resolving late just means shortUrl briefly shows "—" instead of blocking the unrelated printer/wasm setup below.
// Not awaited: itemShortUrl just reads whatever identityIdByHandle currently holds (see
// the fields computed), so this resolving after first render only means it shows "—"
// briefly rather than blocking the rest of mounted's (unrelated) printer/wasm setup.
this.fetchIdMap().catch(e => {
this.error = e.message;
});
this.resizeObserver = new ResizeObserver(this.handleContainerResize);
// Loaded concurrently with MultiPrinterBlob below rather than serialized. See docs/implementation.md#concurrent-wasm-loading.
// Kicked off here rather than awaited immediately, so it loads concurrently with
// MultiPrinterBlob below instead of serializing two independent wasm fetches - every
// redraw()/redrawFallback() call below still waits on it first, since a qr/mqr/rmqr leaf
// throws (see label.js's encodeQr) until it resolves.
const qrReady = preloadQrEncoder();
if (!("usb" in navigator)) {
this.usbSupported = false;
@ -809,10 +859,7 @@ export default {
navigator.usb.addEventListener("connect", this.onUsbChange);
navigator.usb.addEventListener("disconnect", this.onUsbChange);
await qrReady;
await this.guard(async () => {
await this.refreshDevices();
await this.autoConnectPreferred();
});
await this.guard(() => this.refreshDevices());
},
beforeUnmount() {
if ("usb" in navigator) {
@ -841,26 +888,40 @@ export default {
//box-shadow: 0 0 0 1px rgba(127, 127, 127, .5);
}
/* The tape-fed preview's mm ruler: horizontal track above canvas, vertical to its left, both ticked in real physical mm. See docs/implementation.md#mm-ruler-layout. */
/* 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;
}
/* Ruler/canvas container deliberately never scrollable. See docs/implementation.md#preview-track-no-scroll-invariant. */
/* 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;
}
/* Nested override: .label-preview must neither scroll nor center its canvas here. See docs/implementation.md#label-preview-override-in-preview-track. */
/* 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;
}
/* Tape's full physical width; canvas is narrower/centered, faint tint marks the real unprintable margin. See docs/implementation.md#tape-full-print-margin. */
/* 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;
@ -875,7 +936,8 @@ export default {
color: rgba(127, 127, 127, .9);
}
/* Matches .ruler-h's height so the vertical ruler's tick 0 lines up with the canvas's top edge. */
/* 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;
}
@ -895,7 +957,9 @@ export default {
background: currentColor;
}
/* Ticks anchor to the edge nearest the canvas and grow outward (pointing at the label); mm labels sit on the opposite outer edge. */
/* 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;
@ -923,7 +987,8 @@ export default {
white-space: nowrap;
}
/* transform (not a fixed em nudge) centers the label on the tick's mm position regardless of text size. */
/* 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%);

View file

@ -47,10 +47,6 @@
<div class="invalid-feedback">{{ errors.email }}</div>
</div>
<input type="text" class="visually-hidden" autocomplete="username"
tabindex="-1" aria-hidden="true"
v-model="fullHandle"/>
<div :class="errors.password?['mb-3','is-invalid']:['mb-3']">
<label class="form-label">Password</label>
<input class="form-control form-control-lg" type="password"
@ -121,39 +117,8 @@ export default {
domains: []
}
},
computed: {
fullHandle: {
get() {
return this.form.domain ? `${this.form.username}@${this.form.domain}` : this.form.username;
},
set(value) {
this.applyHandle(value);
}
}
},
watch: {
// Catches the same "user@domain" shape when it's typed or pasted
// directly into the visible username field instead.
'form.username'(value) {
if (value.includes('@')) {
this.applyHandle(value);
}
}
},
methods: {
...mapActions(['lookupServer']),
applyHandle(value) {
const atIndex = value.indexOf('@');
if (atIndex === -1) {
this.form.username = value;
return;
}
this.form.username = value.slice(0, atIndex);
const domain = value.slice(atIndex + 1);
if (this.domains.includes(domain)) {
this.form.domain = domain;
}
},
do_register() {
console.log('do_register');
console.log(this.form);

View file

@ -4,12 +4,6 @@
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Scan a code</h1>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch" id="visitFirstMatch"
v-model="visitFirstMatch">
<label class="form-check-label" for="visitFirstMatch">Visit first match</label>
</div>
<div v-if="error" class="alert alert-danger" role="alert">{{ error }}</div>
<div v-if="!insecureContext" class="alert alert-warning">
@ -30,20 +24,7 @@
<span class="scan-result-type">{{ c.type }}</span>
<span v-if="c.metaText" class="text-muted"> {{ c.metaText }}</span>
<span class="text-muted"> @ {{ c.time }}</span>
<div class="text-break">
<a v-if="c.link?.href" :href="c.link.href" target="_blank"
rel="noopener noreferrer">{{ c.text }}</a>
<template v-else-if="c.link">
<router-link :to="c.link.to">{{ c.text }}
<span v-if="c.error" class="text-danger"> &rarr; {{
c.error
}}</span>
<span v-else-if="c.description"> &rarr; {{ c.description }}</span>
<span v-else-if="c.description === undefined" class="text-muted"> &rarr; resolving&hellip;</span>
</router-link>
</template>
<template v-else>{{ c.text }}</template>
</div>
<div class="text-break">{{ c.text }}</div>
</li>
</ul>
</div>
@ -56,7 +37,53 @@
<h5 class="card-title mb-0">Scan from camera</h5>
</div>
<div class="card-body">
<CameraScanner @scan="onCameraScan" @error="e => error = e"/>
<p v-if="!cameraSupported" class="text-muted">
This browser has no camera API available.
</p>
<template v-else>
<div class="row g-2 align-items-end mb-3">
<div class="col-auto flex-grow-1">
<label class="form-label">Camera</label>
<select class="form-control" v-model="selectedCameraId">
<option value="">Auto (prefer recent, then rear camera)</option>
<option v-for="(c, i) in cameras" :key="c.deviceId"
:value="c.deviceId">
{{ cleanCameraLabel(c.label) || ('Camera ' + (i + 1)) }}
</option>
</select>
</div>
<div v-if="cameraRunning" class="col-auto">
<button class="btn btn-outline-secondary" @click="stopCamera">
<b-icon-stop-fill class="me-1"></b-icon-stop-fill>
Stop
</button>
</div>
</div>
<p v-if="cameraRes" class="text-muted small">{{ cameraRes }}</p>
<div class="video-wrap">
<video ref="video" autoplay muted playsinline></video>
<canvas ref="overlay"></canvas>
</div>
</template>
<input ref="fileInput" type="file" accept="image/*" class="form-control mb-3"
@change="onFileInputChange">
<div class="dropzone" :class="{'dropzone-hover': dropHover}"
@dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
Drag &amp; drop an image here, paste one, or use the file picker above.
</div>
<div class="scan-canvas-wrap" v-show="hasFileImage">
<canvas ref="fileCanvas" class="scan-canvas"></canvas>
</div>
<ul class="scan-results">
<li v-if="hasFileImage && !fileResults.length" class="scan-results-empty">
No codes found.
</li>
<li v-for="(r, i) in fileResults" :key="i">
<span class="scan-result-type">{{ r.type }}</span>
<span v-if="r.metaText" class="text-muted"> {{ r.metaText }}</span>
<div class="text-break">{{ r.text }}</div>
</li>
</ul>
</div>
</div>
</div>
@ -67,59 +94,14 @@
</template>
<script>
import {mapActions} from "vuex";
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import CameraScanner from "@/components/CameraScanner.vue";
import {encodeHandleForUrl, expandedRoute} from "@/router";
import {decodeShortId, deserializeShortId} from "@/short-id";
import {loadAnyDCode} from "../../vendor/anyd-qr.js";
import cameraManager from "@/cameraManager.js";
// A scanned label can encode any of these (see label-layouts.js's DERIVED_VARS): a full
// self-contained URL (itemUrl/shortUrl), a bare short-id.js token with no domain at all (the
// "mqr"/id-only layout), or the compact no-URL "<userHandle>:<itemId>" form (itemHandle). Detects
// which and returns a link target, or null if the text doesn't match any known format. `decoded`/
// `itemHandle` carry enough of the parsed token for describeLink (below) to also resolve a
// human-readable "what this points at" - not needed for the two URL cases, which link to
// somewhere already showing that.
const ITEM_HANDLE_RE = /^(#?[^\s@:/#+~]+@[^\s@:/#+~]+):(\d+)$/;
function classifyScanText(text) {
if (!text) {
return null;
}
try {
const url = new URL(text);
// Same-origin URLs (the common case - a label printed by this same app) get routed
// in-app instead of forcing a full page reload through an <a> tag. `immediate`: a URL
// needs no async lookup to confirm it's real (unlike the token/handle formats below), so
// it's already "resolved" the moment it's classified - see resolveDescription and
// maybeVisitFirstMatch.
return url.origin === window.location.origin
? {to: url.pathname + url.search + url.hash, immediate: true}
: {href: url.href};
} catch {
// Not an absolute URL - fall through to the other known formats below.
}
if (text.startsWith('~')) {
try {
const decoded = deserializeShortId(decodeShortId(text));
// expandedRoute needs idmap already loaded to resolve item/group_item (see
// router.js's NEEDS_IDMAP) - fall back to the token's own URL (ShortId.vue resolves
// it from there, same as a cold-opened short link) when it can't yet.
return {to: expandedRoute(decoded) || '/' + text, decoded};
} catch {
return null; // starts with '~' but isn't a real short id - leave as plain text
}
}
const handleMatch = text.match(ITEM_HANDLE_RE);
if (handleMatch) {
const [, handle, id] = handleMatch;
return {to: `/inventory/${encodeHandleForUrl(handle)}/${id}`, itemHandle: {handle, id}};
}
return null;
}
// Formats a decode result's metadata (see anyd-qr.js's SymbolMetadata) as a short string like
// "(v7, ec=M, mask=3)".
// A decode result's metadata (see anyd-qr.js's SymbolMetadata) as one short, human-readable
// string, e.g. "(v7, ec=M, mask=3)" - shared by both the "from image" and "from camera" result
// lists below rather than each formatting it its own way.
function metaSummary(metadata) {
const parts = [];
if (metadata.version != null) {
@ -137,11 +119,15 @@ function metaSummary(metadata) {
return parts.length ? `(${parts.join(", ")})` : "";
}
// How long a camera-detected code's bounding box stays drawn on the overlay before fading, so a
// one-off decode doesn't leave a stale box on screen once the code's moved out of frame.
const OVERLAY_CLEAR_MS = 2000;
export default {
name: "Scan",
components: {
BaseLayout,
CameraScanner,
...BIcons
},
data() {
return {
@ -149,154 +135,354 @@ export default {
insecureContext: window.isSecureContext,
insecureOrigin: `${window.location.protocol}//${window.location.hostname}`,
// One-shot: cleared by maybeVisitFirstMatch as soon as it navigates, so it doesn't
// keep firing router.push for every later scan/decode while left switched on.
// Defaults on when the page itself was opened with ?navigate=immediate (e.g. a link
// shared for a "scan and go" workflow), so the toggle doesn't need a manual flip first.
visitFirstMatch: this.$route.query.navigate === "immediate",
dropHover: false,
hasFileImage: false,
fileResults: [],
cameraSupported: Boolean(navigator.mediaDevices?.getUserMedia),
cameras: [],
localSelectedCameraId: "",
cameraRunning: false,
cameraRes: "",
cameraLog: [],
};
},
computed: {
// Switches camera the moment the select changes, rather than waiting for an explicit
// "apply" step - matches prototypes/camera-inputs/InputPhoto.vue's selectedCameraId.
selectedCameraId: {
get() {
return this.localSelectedCameraId;
},
set(value) {
this.localSelectedCameraId = value;
if (this.cameraRunning) {
this.switchCamera(value || null);
}
},
},
},
methods: {
...mapActions(["fetchItemByHandle", "fetchGroup", "fetchStorageLocations", "fetchIdMap"]),
// Resolves entry.link into entry.description ("[#7] Cordless drill") for the non-URL
// formats classifyScanText recognizes - mutates the already-rendered entry in place once
// the lookup lands, rather than delaying the log/result list from showing the raw scanned
// text and link immediately. Left as `undefined` (template shows "resolving...") while in
// flight, and settles to a string or `null` (nothing else known to show, e.g. a workflow
// short id, or the owner/item genuinely couldn't be resolved).
//
// descriptionCache (keyed by the raw scanned text) memoizes the outcome - a still-in-frame
// code gets re-decoded and re-logged several times a second (see logDecode), and
// re-scanning the same printed label later is common too, so without this every repeat
// would re-fire the same fetchItemByHandle/fetchGroup/fetchStorageLocations/fetchIdMap
// round trip. Caching the in-flight promise itself (not just its settled value) also
// dedupes concurrent lookups for the same still-in-frame code, rather than firing one
// request per decode.
resolveDescription(entry) {
const {link, text} = entry;
if (!link || link.href) {
return; // full URLs already show where they go - see this feature's ask
// Draws `file`/a pasted or dropped Blob onto fileCanvas and decodes whatever's in it -
// shared by the file input, drag&drop and paste handlers below rather than each
// duplicating the createImageBitmap/getImageData/decodeImage sequence.
async decodeBlob(file) {
this.error = null;
try {
const bitmap = await createImageBitmap(file);
const canvas = this.$refs.fileCanvas;
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(bitmap, 0, 0);
const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
const anyd = await this.anydPromise;
const results = anyd.decodeImage(imageData);
this.drawBoxes(ctx, results);
this.hasFileImage = true;
this.fileResults = results.map(r => ({...r, metaText: metaSummary(r.metadata)}));
} catch (e) {
this.error = e.message ?? String(e);
}
if (link.immediate) {
this.maybeVisitFirstMatch(link);
},
drawBoxes(ctx, results) {
ctx.lineWidth = Math.max(2, ctx.canvas.width / 300);
ctx.strokeStyle = "#3ce77c";
ctx.fillStyle = "#3ce77c";
ctx.font = `${Math.max(14, ctx.canvas.width / 60)}px sans-serif`;
for (const r of results) {
if (!r.box) {
continue;
}
const {x0, y0, x1, y1} = r.box;
ctx.strokeRect(x0, y0, x1 - x0, y1 - y0);
ctx.fillText(r.type, x0, Math.max(14, y0 - 4));
}
},
onFileInputChange() {
const file = this.$refs.fileInput.files[0];
if (file) {
this.decodeBlob(file);
}
},
onDragOver(e) {
e.preventDefault();
this.dropHover = true;
},
onDragLeave() {
this.dropHover = false;
},
onDrop(e) {
e.preventDefault();
this.dropHover = false;
const file = e.dataTransfer.files[0];
if (file) {
this.decodeBlob(file);
}
},
onPaste(e) {
const item = [...(e.clipboardData?.items ?? [])].find(i => i.type.startsWith("image/"));
if (item) {
this.decodeBlob(item.getAsFile());
}
},
// Device labels are only populated once camera permission has actually been granted, so
// this is called again right after getUserMedia resolves in startCamera, not just once
// up front.
async populateCameraList() {
if (!navigator.mediaDevices?.enumerateDevices) {
return;
}
if (!this.descriptionCache.has(text)) {
// Wrapped as {error} rather than swallowed to null: a lookup can fail for very
// different reasons (not logged in, item not shared with this viewer, a genuine
// network error) and collapsing them all to "no description" made every one of
// them look identical to "nothing to show" - undiagnosable from the UI.
this.descriptionCache.set(text, this.describeLink(link).catch(e => ({error: e.message ?? String(e)})));
}
this.descriptionCache.get(text).then(result => {
if (result && typeof result === "object") {
entry.description = null;
entry.error = result.error;
return;
}
entry.description = result;
// Only a truthy description confirms the target actually exists - a null/empty
// one (unresolvable, or a kind with no title lookup wired up) shouldn't count as
// a "match" to auto-visit.
if (result) {
this.maybeVisitFirstMatch(link);
}
});
await cameraManager.enumerateDevices();
this.cameras = cameraManager.getAvailableCameras();
},
// Sends the viewer straight to the first scan this session that's confirmed to resolve
// (immediately for a plain URL, or once resolveDescription confirms a real target for a
// token/handle) while the "Visit first match" toggle is on. One-shot: switches the toggle
// back off so it doesn't fire again for every later scan of the same or another code.
maybeVisitFirstMatch(link) {
if (!this.visitFirstMatch || !link?.to) {
cleanCameraLabel(label) {
if (!label) return "";
const parts = label.split(":").map((p) => p.trim());
if (parts.length === 2 && parts[0] === parts[1]) {
return parts[0];
}
return label;
},
syncOverlaySize() {
const video = this.$refs.video;
const overlay = this.$refs.overlay;
if (!video || !overlay) {
return;
}
this.visitFirstMatch = false;
this.$router.push(link.to);
},
async describeLink(link) {
if (link.itemHandle) {
return this.describeItem(link.itemHandle.handle, link.itemHandle.id);
}
const decoded = link.decoded;
if (!decoded) {
return null;
}
if (decoded.kind === "item" || decoded.kind === "group_item") {
const byId = decoded.kind === "item"
? this.$store.getters.identityHandleById
: this.$store.getters.groupHandleById;
const ownerId = decoded.kind === "item" ? decoded.owner_identity_id : decoded.owner_group_id;
let handle = byId[ownerId];
if (handle === undefined) {
await this.fetchIdMap();
handle = (decoded.kind === "item"
? this.$store.getters.identityHandleById
: this.$store.getters.groupHandleById)[ownerId];
}
return handle === undefined ? null : this.describeItem(handle, decoded.item_local_id);
}
if (decoded.kind === "group") {
const group = await this.fetchGroup({id: decoded.group_id});
return group ? group.handle : null;
}
if (decoded.kind === "storage_location") {
if (!this.$store.state.storage_locations.length) {
await this.fetchStorageLocations();
}
const location = this.$store.state.storage_locations.find(l => l.id === decoded.storage_location_id);
return location ? `[#${location.id}] ${location.name}` : null;
}
return null; // workflow, category, file: no per-item title lookup wired up yet
},
async describeItem(handle, id) {
const item = await this.fetchItemByHandle({handle, id});
return item ? `[#${item.id}] ${item.name}` : null;
},
// Handler for CameraScanner's "scan" event - logs every code from the batch it decoded.
onCameraScan(codes) {
codes.forEach(this.logDecode);
overlay.width = video.clientWidth;
overlay.height = video.clientHeight;
},
logDecode(code) {
const text = code.text ?? "";
const entry = {
this.cameraLog.unshift({
type: code.type,
text,
text: code.text ?? "",
metaText: metaSummary(code.metadata),
time: new Date().toLocaleTimeString(),
link: classifyScanText(text),
description: undefined,
error: null,
};
this.cameraLog.unshift(entry);
// Resolve against cameraLog[0], not the plain `entry` object above: Vue's reactivity
// tracks property sets through the reactive proxy unshift() just installed, and
// mutating the pre-insertion raw object later bypasses that proxy entirely, so the
// description would never appear to update (stuck on "resolving...") even once the
// lookup actually finished.
this.resolveDescription(this.cameraLog[0]);
// Caps the log (old entries trimmed) rather than growing forever, matching
// CameraScanner's own dedupe window that makes them stale for re-matching.
});
// Caps the log rather than letting it grow forever - old entries are trimmed off the
// end, same as CameraScanner's own dedupe window makes them stale for re-matching.
this.cameraLog.length = Math.min(this.cameraLog.length, 20);
},
// Draws every detected code's box (in the video's own native pixel space, scaled to
// however large the overlay is actually displayed) and fades them out after
// OVERLAY_CLEAR_MS - a one-off decode shouldn't leave a stale box on screen once the code
// has moved out of frame.
drawOverlay(codes) {
console.log("drawOverlay", codes);
const overlay = this.$refs.overlay;
const video = this.$refs.video;
if (!overlay || !video || !video.videoWidth) {
return;
}
const ctx = overlay.getContext("2d");
ctx.clearRect(0, 0, overlay.width, overlay.height);
const scaleX = overlay.width / video.videoWidth;
const scaleY = overlay.height / video.videoHeight;
ctx.lineWidth = 3;
ctx.strokeStyle = "#3ce77c";
ctx.fillStyle = "#3ce77c";
ctx.font = "14px sans-serif";
for (const code of codes) {
if (!code.box) {
continue;
}
const {x0, y0, x1, y1} = code.box;
ctx.strokeRect(x0 * scaleX, y0 * scaleY, (x1 - x0) * scaleX, (y1 - y0) * scaleY);
ctx.fillText(`${code.type}: ${code.text ?? ""}`, x0 * scaleX, Math.max(14, y0 * scaleY - 4));
}
clearTimeout(this.clearOverlayTimer);
this.clearOverlayTimer = setTimeout(
() => ctx.clearRect(0, 0, overlay.width, overlay.height), OVERLAY_CLEAR_MS);
},
// Attaches `stream` to the video element - shared by startCamera and the
// camera-switch/reconnect paths so the scanner (which just keeps reading frames off the
// same video element) never needs to be recreated. videoWidth/videoHeight aren't known yet
// right after play() - the video's own "resize" event (see onVideoResize) is what tells us
// the new aspect ratio has actually taken effect, which is also when the overlay needs to
// be resized to match.
setupVideoStream(stream) {
const video = this.$refs.video;
if (!video) return;
video.srcObject = stream;
video.play();
},
// Fires on the video element's own "resize"/"loadedmetadata" events - i.e. whenever its
// intrinsic width/height actually change (initial load, or switching to a camera with a
// different native resolution/aspect ratio) - so the overlay canvas is resized to match the
// video's *new* rendered size rather than the stale one from before the switch.
onVideoResize() {
const video = this.$refs.video;
if (!video || !video.videoWidth) {
return;
}
this.syncOverlaySize();
this.cameraRes = `Native resolution: ${video.videoWidth} x ${video.videoHeight}`;
},
async startCamera() {
this.error = null;
try {
const stream = await cameraManager.openStream(this.localSelectedCameraId || null);
this.setupVideoStream(stream);
await this.populateCameraList();
this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";
const anyd = await this.anydPromise;
this.scanner = anyd.createCameraScanner(this.$refs.video, {
fps: 4,
downscale: 2,
onDecode: (codes) => {
codes.forEach(this.logDecode);
this.drawOverlay(codes);
},
onError: (err) => console.error("[camera scan]", err),
});
this.scanner.start();
this.cameraRunning = true;
} catch (e) {
this.error = `Camera error: ${e.message ?? e}`;
}
},
async switchCamera(cameraId) {
if (!this.cameraRunning) return;
try {
const stream = await cameraManager.switchCamera(cameraId);
if (stream) this.setupVideoStream(stream);
} catch (e) {
this.error = `Camera error: ${e.message ?? e}`;
}
},
stopCamera() {
this.scanner?.stop();
this.scanner = null;
cameraManager.closeStream();
if (this.$refs.video) {
this.$refs.video.srcObject = null;
}
this.cameraRunning = false;
const overlay = this.$refs.overlay;
if (overlay) {
overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
}
},
// The camera manager already tries a fallback device on disconnect (see cameraManager.js)
// and only fires 'camera-disconnected' once none is left, so by the time this runs there's
// nothing left to fall back to and the running scan session has to stop.
handleCameraDisconnected(event) {
if (!this.cameraRunning) return;
this.error = `Camera error: ${event.detail?.error || "Camera disconnected"}`;
this.stopCamera();
},
handleCameraReconnected() {
if (!this.cameraRunning) return;
const stream = cameraManager.getActiveStream();
if (!stream) return;
this.error = null;
this.setupVideoStream(stream);
this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";
},
},
created() {
// Not component data: resolveDescription's cached values are read back into each entry's
// own (reactive) `description` field, so the cache itself never needs to be reactive.
this.descriptionCache = new Map();
// Kept as a promise (rather than awaited here) so every caller - decodeBlob, startCamera -
// shares the same in-flight load instead of each triggering its own; loadAnyDCode's own
// memoization (see anyd-qr.js) means a second call anywhere else in the app is free too.
this.anydPromise = loadAnyDCode();
this.scanner = null;
this.clearOverlayTimer = null;
},
mounted() {
window.addEventListener("paste", this.onPaste);
window.addEventListener("resize", this.syncOverlaySize);
window.addEventListener("camera-disconnected", this.handleCameraDisconnected);
window.addEventListener("camera-reconnected", this.handleCameraReconnected);
navigator.mediaDevices?.addEventListener("devicechange", this.populateCameraList);
this.$refs.video?.addEventListener("loadedmetadata", this.onVideoResize);
this.$refs.video?.addEventListener("resize", this.onVideoResize);
if (this.cameraSupported && this.insecureContext) {
this.startCamera();
} else {
this.populateCameraList();
}
},
beforeUnmount() {
this.stopCamera();
window.removeEventListener("paste", this.onPaste);
window.removeEventListener("resize", this.syncOverlaySize);
window.removeEventListener("camera-disconnected", this.handleCameraDisconnected);
window.removeEventListener("camera-reconnected", this.handleCameraReconnected);
navigator.mediaDevices?.removeEventListener("devicechange", this.populateCameraList);
this.$refs.video?.removeEventListener("loadedmetadata", this.onVideoResize);
this.$refs.video?.removeEventListener("resize", this.onVideoResize);
clearTimeout(this.clearOverlayTimer);
},
}
</script>
<style scoped>
.dropzone {
margin-bottom: .75rem;
border: 2px dashed var(--bs-gray-400);
border-radius: .35rem;
padding: 1.25rem;
text-align: center;
color: var(--bs-gray-600);
font-size: .9rem;
}
.dropzone-hover {
border-color: var(--bs-primary);
color: var(--bs-primary);
}
.scan-canvas-wrap {
text-align: center;
margin-bottom: .75rem;
}
.scan-canvas {
max-width: 100%;
border-radius: .35rem;
border: 1px solid var(--bs-gray-300);
}
.video-wrap {
position: relative;
margin-bottom: .75rem;
background: #000;
border-radius: .35rem;
overflow: hidden;
}
.video-wrap video {
display: block;
width: 100%;
height: auto;
}
.video-wrap canvas {
position: absolute;
inset: 0;
pointer-events: none;
}
.scan-results {
list-style: none;
padding: 0;
@ -318,4 +504,9 @@ export default {
font-weight: 600;
color: var(--bs-success);
}
.scan-results-empty {
color: var(--bs-gray-600);
border-style: dashed;
}
</style>

View file

@ -107,9 +107,9 @@ export default {
return files.filter(file => file.mime_type.startsWith("image/"));
},
loadResults() {
// Search results are always personal-or-friend (inventory_items() in
// toolshed/api/inventory.py never yields group items), so owner is always a plain
// user handle - one route shape, no owner-is-me special case.
// Search results are always personal-or-friend (see inventory_items() in
// toolshed/api/inventory.py - it never yields a group's items), so the owner is
// always a plain user handle - one route shape, no owner-is-me special case.
this.fetchSearchResults({query: this.query}).then((results) => {
this.search_results = results.map(e => (
{...e, route: `/inventory/${encodeHandleForUrl(e.owner)}/${e.id}`}))

View file

@ -77,7 +77,10 @@ const EXAMPLES = [
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
];
// Debug-only mirror of short-id.js's bit-packing (2-bit kind tag + 4-bit chunks, see docs/handles-and-shortids.md), recomputed from `ints` for display only.
// Debug-only display helper, kept out of short-id.js's production library: mirrors its bit-packing
// rules (2-bit kind tag with an all-ones escape, 4-bit continuation chunks, see
// docs/handles-and-shortids.md) just to show them, recomputed straight from an already-serialized
// `ints` list rather than re-parsing a token.
const KIND_TAG_BITS = 2;
const CHUNK_BITS = 4;
const DIRECT_KIND_COUNT = 2 ** KIND_TAG_BITS - 1;
@ -156,7 +159,10 @@ export default {
}
},
watch: {
// expandedRoute re-evaluates once fetchIdMap resolves (see buildExpandedRoute in router.js); finishes the redirect the router's synchronous guard couldn't.
// expandedRoute reads Vuex getters derived from state.idmap (see buildExpandedRoute in
// router.js), so it re-evaluates on its own once fetchIdMap resolves below - this just
// catches that and finishes the redirect the router's own (synchronous, can't-await)
// redirect couldn't.
expandedRoute(url) {
if (url) {
this.$router.replace(url);

View file

@ -133,10 +133,13 @@ export default {
if (owner_identity_id === undefined) return null
return shortenedRoute({kind: 'storage_location', owner_identity_id, storage_location_id: location.id})
},
// Routes to Print.vue with this location's raw identity, same shape as Inventory.vue's
// printLinkFor. See docs/implementation.md#print-link-shape-for-storage-locations.
// Routes to Print.vue with this location's own raw identity - userHandle + id - rather
// than any pre-built link, the same shape Inventory.vue's printLinkFor sends for an item
// (see label.js's "storage-location" LABEL_FIELD_BUILDERS entry). Locations are always
// individually owned (see StorageLocationViewSet.get_queryset), so unlike Inventory.vue's
// version this never has to fall back to "no metadata available".
printLinkFor(location) {
return {path: '/print', query: {kind: 'storage-location', userHandle: location.owner, location: location.id}}
return {path: '/print', query: {kind: 'storage-location', userHandle: location.owner, id: location.id}}
},
},
async mounted() {

View file

@ -3395,7 +3395,8 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
</div>
</div>
<!-- Navbar -->
<!-- Navbar
================================================== -->
<div class="bs-docs-section clearfix">
<div class="row">
<div class="col-sm-12">
@ -3506,7 +3507,8 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
</div>
</div>
<!-- Typography -->
<!-- Typography
================================================== -->
<div class="bs-docs-section">
<div class="row">
<div class="col-sm-12">
@ -3611,7 +3613,8 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
</div>
</div>
<!-- Tables -->
<!-- Tables
================================================== -->
<div class="bs-docs-section">
<div class="row">
@ -3698,7 +3701,8 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
</div>
</div>
<!-- Forms -->
<!-- Forms
================================================== -->
<div class="bs-docs-section">
<div class="row">
<div class="col-sm-12">
@ -3965,7 +3969,8 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
</div>
</div>
<!-- Navs -->
<!-- Navs
================================================== -->
<div class="bs-docs-section">
<div class="row">
@ -4219,7 +4224,8 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
</div>
</div>
<!-- Indicators -->
<!-- Indicators
================================================== -->
<div class="bs-docs-section">
<div class="row">
@ -4337,7 +4343,8 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
</div>
</div>
<!-- Progress -->
<!-- Progress
================================================== -->
<div class="bs-docs-section">
<div class="row">
@ -4436,7 +4443,8 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
</div>
</div>
<!-- Containers -->
<!-- Containers
================================================== -->
<div class="bs-docs-section">
<div class="row">
@ -4757,7 +4765,8 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
</div>
<!-- Dialogs -->
<!-- Dialogs
================================================== -->
<div class="bs-docs-section">
<div class="row">

View file

@ -260,7 +260,9 @@ export default {
},
workflowComponent() {
// The returned component decides what to render itself, based on the `step` prop it receives.
// Return the single component implementing the whole workflow, if any,
// using the workflow component registry. The component itself decides
// what to render based on the `step` prop it receives.
const workflowType = this.workflowInstance?.slug;
return workflowType ? getWorkflowComponent(workflowType) : null;
},

View file

@ -231,6 +231,8 @@ export default {
const newWorkflow = await this.createWorkflow(workflowData);
// Immediately navigate to the workflow detail view
// Get the first step from the workflow definition
const firstStep = workflow.stepDefinitions?.[0]?.step || 1;
this.$router.push({
name: 'workflow-detail',
@ -249,6 +251,8 @@ export default {
},
async viewWorkflowDetails(workflow) {
console.log('Viewing details for workflow:', workflow);
// Navigate to the workflow detail view
// Use the workflow's current step if available, otherwise use the first step
const currentStep = workflow.current_step ||
workflow.payload?.current_step ||
getWorkflow(workflow.name)?.stepDefinitions?.[0]?.step ||

View file

@ -1,4 +1,22 @@
// Workflow catalog: single source of truth built from each component's `meta`. See docs/implementation.md#workflow-catalog.
/**
* Workflow Catalog
*
* Single source of truth for every workflow type known to the frontend:
* what it's called, what category/description/icons it has, how many steps
* it has and what they're called, what its initial payload looks like, and
* which Vue component renders it.
*
* Each workflow has a fully co-located component + metadata as a static
* `meta` option on the component (`Component.meta`, right next to
* `name`/`props`/etc.) in `@/components/workflow/workflows/*.vue` - this
* file simply imports those components and reads `.meta` off of them to
* build the catalog below.
*
* This replaces the previous design of a parallel `BaseWorkflow` class
* hierarchy (metadata) plus a separate per-step `ComponentRegistry.js`
* (components) - both concerns now live in one flat array with each
* component responsible for its own metadata and UI implementation.
*/
import FotoFirstBulkImportWorkflow from '@/components/workflow/workflows/FotoFirstBulkImportWorkflow.vue';
import BulkItemImportWorkflow from '@/components/workflow/workflows/BulkItemImportWorkflow.vue';
import InventoryAuditWorkflow from '@/components/workflow/workflows/InventoryAuditWorkflow.vue';
@ -7,6 +25,9 @@ import MaintenanceScheduleWorkflow from '@/components/workflow/workflows/Mainten
import ExpiryCheckWorkflow from '@/components/workflow/workflows/ExpiryCheckWorkflow.vue';
import BackupRestoreWorkflow from '@/components/workflow/workflows/BackupRestoreWorkflow.vue';
/**
* Workflows with a fully co-located component + metadata.
*/
const workflows = [
{...FotoFirstBulkImportWorkflow.meta, component: FotoFirstBulkImportWorkflow},
{...BulkItemImportWorkflow.meta, component: BulkItemImportWorkflow},
@ -17,27 +38,56 @@ const workflows = [
{...BackupRestoreWorkflow.meta, component: BackupRestoreWorkflow},
];
/**
* Get every workflow in the catalog.
* @returns {Array<Object>}
*/
export function getAllWorkflows() {
return workflows;
}
/**
* Get a single workflow definition by id.
* @param {string} id
* @returns {Object|undefined}
*/
export function getWorkflow(slug) {
return workflows.find(workflow => workflow.slug === slug);
}
/**
* Get the Vue component implementing a workflow's UI, if any.
* @param {string} id
* @returns {Object|null}
*/
export function getWorkflowComponent(id) {
return getWorkflow(id)?.component || null;
}
/**
* Get all workflows belonging to a category.
* @param {string} category
* @returns {Array<Object>}
*/
export function getWorkflowsByCategory(category) {
return workflows.filter(workflow => workflow.category === category);
}
/**
* Get all unique categories present in the catalog.
* @returns {Array<string>}
*/
export function getWorkflowCategories() {
return [...new Set(workflows.map(workflow => workflow.category))];
}
// Builds the payload sent to the backend to start a new instance of this workflow.
/**
* Build the payload sent to the backend to start a new instance of a
* workflow, merging the common `workflow_config` metadata block with the
* workflow's own initial payload fields.
* @param {Object} workflow - A workflow definition, e.g. from getWorkflow()
* @returns {Object}
*/
export function buildWorkflowApiPayload(workflow) {
const ownPayload = workflow.getInitialPayload ? workflow.getInitialPayload() : {};
return {
@ -52,7 +102,11 @@ export function buildWorkflowApiPayload(workflow) {
};
}
// Payload is an opaque string to the backend; frontend serializes/deserializes it. See docs/implementation.md#workflow-payload-is-an-opaque-string.
/**
* The backend stores WorkflowInstance.payload as an opaque string - it never
* parses or understands it as JSON. The frontend is fully responsible for
* serializing it before sending and deserializing it after receiving.
*/
export function serializeWorkflowPayload(workflow) {
console.log(workflow);
if (!workflow || !('payload' in workflow)) return workflow;