Compare commits
4 commits
8d96bc97c4
...
0c025db799
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c025db799 | |||
| f87b689e27 | |||
| aa94c92000 | |||
| ed04d98bf1 |
64 changed files with 2270 additions and 1881 deletions
|
|
@ -18,9 +18,8 @@ from hostadmin.models import Domain
|
||||||
|
|
||||||
router = routers.SimpleRouter()
|
router = routers.SimpleRouter()
|
||||||
|
|
||||||
# Schema for the account-level preferences a client may store on the server (see
|
# Schema for account-level preferences a client may store server-side (see AccountPreference);
|
||||||
# AccountPreference). Device-level preferences are never sent here - they stay in the
|
# device-level preferences stay in the browser's local storage instead.
|
||||||
# browser's local storage since they describe the device, not the account.
|
|
||||||
PREFERENCE_DEFINITIONS = [
|
PREFERENCE_DEFINITIONS = [
|
||||||
{
|
{
|
||||||
'key': 'ui.compact_mode',
|
'key': 'ui.compact_mode',
|
||||||
|
|
@ -102,9 +101,8 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
@authentication_classes([SignatureAuthenticationLocal])
|
@authentication_classes([SignatureAuthenticationLocal])
|
||||||
def getUserInfo(request):
|
def getUserInfo(request):
|
||||||
"""Get or update the authenticated local user's own account info. Only usable by the
|
"""Get or update the authenticated local user's own account info; only the account owner may
|
||||||
account owner on their own home server - see getUserProfile for viewing another (friend)
|
call this on their own home server (see getUserProfile for viewing a friend's public profile)."""
|
||||||
user's public profile."""
|
|
||||||
user = request.user
|
user = request.user
|
||||||
if request.method == 'PATCH':
|
if request.method == 'PATCH':
|
||||||
old_file = user.profile_picture
|
old_file = user.profile_picture
|
||||||
|
|
@ -147,9 +145,9 @@ def getUserInfo(request):
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
@authentication_classes([SignatureAuthentication])
|
@authentication_classes([SignatureAuthentication])
|
||||||
def getUserProfile(request, handle):
|
def getUserProfile(request, handle):
|
||||||
"""Get another local user's public profile by handle (username@domain), e.g. so a friend
|
"""Get another local user's public profile by handle, e.g. so a friend can look up an avatar;
|
||||||
can look up someone's avatar. The caller must be a friend of that user (or the user
|
caller must be a friend of that user (or the user itself, signing with their own known
|
||||||
itself, signing with their own known identity rather than their local credentials)."""
|
identity rather than local credentials)."""
|
||||||
try:
|
try:
|
||||||
username, domain = split_userhandle_or_throw(handle)
|
username, domain = split_userhandle_or_throw(handle)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|
@ -213,11 +211,9 @@ def preference_definitions(request):
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
@authentication_classes([SignatureAuthenticationLocal])
|
@authentication_classes([SignatureAuthenticationLocal])
|
||||||
def account_preferences(request):
|
def account_preferences(request):
|
||||||
"""Get or bulk-upsert the authenticated user's account-level preferences.
|
"""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
|
||||||
GET returns the current preferences as a {key: value} dict. PUT accepts a {key: value}
|
keys untouched."""
|
||||||
dict of one or more preferences to set/overwrite; unspecified keys are left untouched.
|
|
||||||
"""
|
|
||||||
if request.method == 'PUT':
|
if request.method == 'PUT':
|
||||||
if not isinstance(request.data, dict):
|
if not isinstance(request.data, dict):
|
||||||
return Response({'detail': 'Expected an object of key/value pairs.'}, status=400)
|
return Response({'detail': 'Expected an object of key/value pairs.'}, status=400)
|
||||||
|
|
|
||||||
|
|
@ -114,11 +114,8 @@ class ToolshedUser(AbstractUser):
|
||||||
|
|
||||||
|
|
||||||
class AccountPreference(models.Model):
|
class AccountPreference(models.Model):
|
||||||
"""A single account-level (server-synced, cross-device) user preference, stored as a key/value pair.
|
"""A single account-level (server-synced, cross-device) preference as a key/value pair;
|
||||||
|
device-level preferences are intentionally *not* stored here."""
|
||||||
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')
|
user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='preferences')
|
||||||
key = models.CharField(max_length=255)
|
key = models.CharField(max_length=255)
|
||||||
value = models.JSONField()
|
value = models.JSONField()
|
||||||
|
|
@ -165,9 +162,9 @@ class Group(models.Model):
|
||||||
|
|
||||||
|
|
||||||
class GroupInvite(models.Model):
|
class GroupInvite(models.Model):
|
||||||
"""A pending invite tracked on the group's own home backend, checked when the invitee's
|
"""A pending invite tracked on the group's own home backend, checked when the invitee's accept
|
||||||
accept request arrives (see GroupInviteIncoming for the mirror record on the invitee's own
|
request arrives (mirror: GroupInviteIncoming on the invitee's backend; see
|
||||||
backend, and docs/design-in-progress/groups-mvp.md for the full invite/accept dance)."""
|
docs/design-in-progress/groups-mvp.md)."""
|
||||||
secret = models.CharField(max_length=255)
|
secret = models.CharField(max_length=255)
|
||||||
group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name='invites')
|
group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name='invites')
|
||||||
invitee_username = models.CharField(max_length=255)
|
invitee_username = models.CharField(max_length=255)
|
||||||
|
|
|
||||||
|
|
@ -81,12 +81,8 @@ def verify_incoming_friend_request(request, raw_request_body):
|
||||||
|
|
||||||
|
|
||||||
def verify_incoming_group_invite(request, raw_request_body, handle_field, key_field):
|
def verify_incoming_group_invite(request, raw_request_body, handle_field, key_field):
|
||||||
"""Self-certifying verifier for the two legs of the group invite/accept dance that land on a
|
"""Self-certifying verifier for the group invite/accept dance. See
|
||||||
backend which doesn't have the caller cached as a KnownIdentity yet (see
|
docs/implementation.md#group-invite-and-accept-self-certifying-verification."""
|
||||||
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:
|
try:
|
||||||
username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body)
|
username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|
@ -143,10 +139,8 @@ def authenticate_request_against_local_users(request, raw_request_body):
|
||||||
class SignatureAuthentication(authentication.BaseAuthentication):
|
class SignatureAuthentication(authentication.BaseAuthentication):
|
||||||
def authenticate(self, request):
|
def authenticate(self, request):
|
||||||
identity = authenticate_request_against_known_identities(request, request.body.decode('utf-8'))
|
identity = authenticate_request_against_known_identities(request, request.body.decode('utf-8'))
|
||||||
# Returning a bare None (rather than a (None, None) tuple) tells DRF this
|
# Bare None (not a (None, None) tuple) tells DRF to try the next authenticator, instead
|
||||||
# authenticator doesn't apply, so it moves on to the next authenticator in the
|
# of treating the request as authenticated with an empty user.
|
||||||
# authentication_classes list instead of treating the request as authenticated
|
|
||||||
# with an empty user.
|
|
||||||
if identity is None:
|
if identity is None:
|
||||||
return None
|
return None
|
||||||
return identity, None
|
return identity, None
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,3 @@
|
||||||
"""
|
|
||||||
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
|
import os
|
||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,3 @@
|
||||||
"""
|
|
||||||
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 os
|
||||||
import subprocess
|
import subprocess
|
||||||
import dotenv
|
import dotenv
|
||||||
|
|
@ -19,13 +8,9 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
def _git_commit():
|
def _git_commit():
|
||||||
# deploy/dev/docker-compose.yml bind-mounts the repo's real .git dir at
|
# Docker dev bind-mounts the real .git dir at /git (see docker-compose.yml); bare-metal dev
|
||||||
# /git (separate from BASE_DIR, which only has the backend/ subtree) -
|
# finds it by walking up from BASE_DIR instead. Prod has no .git at all, so both fail and we
|
||||||
# point git at it explicitly there. Bare-metal dev has no such mount, but
|
# fall back to the GIT_COMMIT build-arg/env var (see Dockerfile.backend/playbook.yml).
|
||||||
# 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']
|
cmd = ['git', '--git-dir=/git'] if os.path.isdir('/git') else ['git']
|
||||||
try:
|
try:
|
||||||
return subprocess.check_output(
|
return subprocess.check_output(
|
||||||
|
|
@ -36,9 +21,6 @@ def _git_commit():
|
||||||
|
|
||||||
dotenv.load_dotenv(BASE_DIR / '.env')
|
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)
|
SECRET_KEY = os.environ.get('SECRET_KEY', None)
|
||||||
if SECRET_KEY is None:
|
if SECRET_KEY is None:
|
||||||
raise Exception('environment variable SECRET_KEY not set. try running `configure.py` or setting it manually')
|
raise Exception('environment variable SECRET_KEY not set. try running `configure.py` or setting it manually')
|
||||||
|
|
@ -127,9 +109,6 @@ TEMPLATES = [
|
||||||
|
|
||||||
WSGI_APPLICATION = 'backend.wsgi.application'
|
WSGI_APPLICATION = 'backend.wsgi.application'
|
||||||
|
|
||||||
# Database
|
|
||||||
# https://docs.djangoproject.com/en/4.1/ref/settings/#databases
|
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
|
|
@ -139,9 +118,6 @@ DATABASES = {
|
||||||
|
|
||||||
AUTH_USER_MODEL = 'authentication.ToolshedUser'
|
AUTH_USER_MODEL = 'authentication.ToolshedUser'
|
||||||
|
|
||||||
# Password validation
|
|
||||||
# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators
|
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
|
|
@ -157,9 +133,6 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
# Internationalization
|
|
||||||
# https://docs.djangoproject.com/en/4.1/topics/i18n/
|
|
||||||
|
|
||||||
LANGUAGE_CODE = 'en-us'
|
LANGUAGE_CODE = 'en-us'
|
||||||
|
|
||||||
TIME_ZONE = 'UTC'
|
TIME_ZONE = 'UTC'
|
||||||
|
|
@ -168,26 +141,18 @@ USE_I18N = True
|
||||||
|
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
|
||||||
# https://docs.djangoproject.com/en/4.1/howto/static-files/
|
|
||||||
|
|
||||||
STATIC_ROOT = 'staticfiles'
|
STATIC_ROOT = 'staticfiles'
|
||||||
STATIC_URL = '/static/'
|
STATIC_URL = '/static/'
|
||||||
|
|
||||||
MEDIA_ROOT = os.environ.get('TOOLSHED_USERFILES_PATH', 'userfiles')
|
MEDIA_ROOT = os.environ.get('TOOLSHED_USERFILES_PATH', 'userfiles')
|
||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = '/media/'
|
||||||
|
|
||||||
# In prod, nginx (running as www-data) reads these files directly - see
|
# 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
|
||||||
# SERVE_X_ACCEL_REDIRECT and playbook.yml's `location /redirect_media/`.
|
# 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_PERMISSIONS = 0o640
|
||||||
FILE_UPLOAD_DIRECTORY_PERMISSIONS = 0o750
|
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'
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
STORAGES = {
|
STORAGES = {
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,3 @@
|
||||||
"""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.contrib import admin
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from drf_yasg import openapi
|
from drf_yasg import openapi
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,3 @@
|
||||||
"""
|
|
||||||
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
|
import os
|
||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,8 @@ def yesno(prompt, default=False):
|
||||||
|
|
||||||
|
|
||||||
def configure():
|
def configure():
|
||||||
# Keys this function may generate/update, tracked so that if .env turns
|
# Keys this function may generate/update; tracked so an unwritable .env (e.g. a prod
|
||||||
# out to be unwritable (e.g. a prod container running as an unprivileged
|
# container configured via --env-file) can still print them for the operator to apply manually.
|
||||||
# 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']
|
tracked_keys = ['SECRET_KEY', 'ALLOWED_HOSTS']
|
||||||
unwritable = False
|
unwritable = False
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,8 @@ THUMBNAIL_SIZES = (32, 64, 256)
|
||||||
|
|
||||||
|
|
||||||
def _accessible_files(request):
|
def _accessible_files(request):
|
||||||
# Shared by media_urls and thumbnail_urls so both endpoints always agree on who can see
|
# Shared by media_urls and thumbnail_urls: a file is visible if the requester is
|
||||||
# what - a file is visible if the requester is friends-or-self with whatever currently
|
# friends-or-self with whatever references it (item, profile picture), or it's their own staged photo.
|
||||||
# references it (an inventory item, a profile picture) or it's their own staged photo.
|
|
||||||
return File.objects.filter(
|
return File.objects.filter(
|
||||||
Q(connected_items__owner__in=request.user.friends_or_self()) |
|
Q(connected_items__owner__in=request.user.friends_or_self()) |
|
||||||
Q(profile_picture_users__in=request.user.friends_or_self()) |
|
Q(profile_picture_users__in=request.user.friends_or_self()) |
|
||||||
|
|
@ -35,8 +34,7 @@ def _accessible_files(request):
|
||||||
|
|
||||||
|
|
||||||
def _cache_headers(etag):
|
def _cache_headers(etag):
|
||||||
# Content is addressed by its own hash and can never change under a given URL, so caches
|
# Content is hash-addressed and can never change under a given URL, so it's cacheable forever.
|
||||||
# (and the conditional-GET checks in both views below) can treat it as immutable forever.
|
|
||||||
return {
|
return {
|
||||||
'ETag': etag,
|
'ETag': etag,
|
||||||
'Cache-Control': 'max-age=31536000, private, immutable',
|
'Cache-Control': 'max-age=31536000, private, immutable',
|
||||||
|
|
@ -49,23 +47,16 @@ def _cache_headers(etag):
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
@authentication_classes([SignatureAuthentication])
|
@authentication_classes([SignatureAuthentication])
|
||||||
def media_urls(request, hash_path):
|
def media_urls(request, hash_path):
|
||||||
# Note: CORS headers are NOT set here - django-cors-headers (CorsMiddleware,
|
# CORS is added automatically by middleware, except via X-Accel-Redirect, where nginx's
|
||||||
# configured in settings.py) adds them to every Django response automatically, so
|
# /redirect_media/ block must set it instead.
|
||||||
# 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 by raw hash) because FileSerializer.name
|
# Looked up by the derived storage path, not the raw hash, to match FileSerializer.name
|
||||||
# (files/serializers.py) - used everywhere a file URL is handed to the frontend, e.g.
|
# (used for AuthenticatedImage's `src`) and the existing test suite (MediaUrlTestCase).
|
||||||
# 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:
|
try:
|
||||||
file = _accessible_files(request).get(file=hash_path)
|
file = _accessible_files(request).get(file=hash_path)
|
||||||
|
|
||||||
# The access-control lookup above must happen before this check - otherwise a bare
|
# Must run before this check, else a bare hash + If-None-Match would let anyone probe
|
||||||
# hash + If-None-Match would let anyone probe "does a file with this hash exist" for
|
# file existence for files they can't see.
|
||||||
# files they can't actually see.
|
|
||||||
if request.META.get('HTTP_IF_NONE_MATCH') == file.hash:
|
if request.META.get('HTTP_IF_NONE_MATCH') == file.hash:
|
||||||
return HttpResponse(status=status.HTTP_304_NOT_MODIFIED)
|
return HttpResponse(status=status.HTTP_304_NOT_MODIFIED)
|
||||||
|
|
||||||
|
|
@ -79,9 +70,7 @@ def media_urls(request, hash_path):
|
||||||
**cache_headers,
|
**cache_headers,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# Read via the FieldFile itself (works against whatever storage backend is
|
# Reads via FieldFile.open() (not file.file.path) since tests swap in an in-memory storage backend.
|
||||||
# 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:
|
with file.file.open('rb') as fh:
|
||||||
content = fh.read()
|
content = fh.read()
|
||||||
return HttpResponse(status=status.HTTP_200_OK,
|
return HttpResponse(status=status.HTTP_200_OK,
|
||||||
|
|
@ -94,9 +83,7 @@ def media_urls(request, hash_path):
|
||||||
|
|
||||||
|
|
||||||
def _thumbnail_rel_path(file_hash, size):
|
def _thumbnail_rel_path(file_hash, size):
|
||||||
# Mirrors files/models.py's hash_upload() sharding, under its own `thumbnails/<size>/`
|
# Mirrors hash_upload()'s sharding under thumbnails/<size>/, reachable via the same nginx alias as originals.
|
||||||
# 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],
|
return os.path.join('thumbnails', str(size), file_hash[:2], file_hash[2:4], file_hash[4:6],
|
||||||
file_hash[6:] + '.jpg')
|
file_hash[6:] + '.jpg')
|
||||||
|
|
||||||
|
|
@ -116,22 +103,15 @@ def thumbnail_urls(request, size, hash_path):
|
||||||
if request.META.get('HTTP_IF_NONE_MATCH') == etag:
|
if request.META.get('HTTP_IF_NONE_MATCH') == etag:
|
||||||
return HttpResponse(status=status.HTTP_304_NOT_MODIFIED)
|
return HttpResponse(status=status.HTTP_304_NOT_MODIFIED)
|
||||||
|
|
||||||
# Read/write through the default storage backend, same as File.file itself, rather
|
# Read/write via default_storage, not a hand-rolled path, to work with both real-disk
|
||||||
# than a hand-rolled filesystem path - correct regardless of storage backend (real
|
# and in-memory test storage.
|
||||||
# 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)
|
rel_path = _thumbnail_rel_path(file.hash, size)
|
||||||
if not default_storage.exists(rel_path):
|
if not default_storage.exists(rel_path):
|
||||||
# Thumbnails are always re-encoded as JPEG regardless of the original format -
|
# Always re-encoded as JPEG regardless of original format - simpler than preserving transparency at this scale.
|
||||||
# smaller and simpler than preserving e.g. PNG transparency at this scale.
|
|
||||||
with file.file.open('rb') as fh:
|
with file.file.open('rb') as fh:
|
||||||
image = Image.open(fh)
|
image = Image.open(fh)
|
||||||
image.thumbnail((size, size))
|
image.thumbnail((size, size))
|
||||||
# Flatten through RGBA before dropping to RGB - some modes (grayscale+alpha,
|
# Flatten through RGBA before dropping to RGB. See docs/implementation.md#rgba-flattening-avoids-revealing-black-under-transparent-pixels.
|
||||||
# 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')
|
rgba = image.convert('RGBA')
|
||||||
flattened = Image.new('RGB', rgba.size, (255, 255, 255))
|
flattened = Image.new('RGB', rgba.size, (255, 255, 255))
|
||||||
flattened.paste(rgba, mask=rgba.getchannel('A'))
|
flattened.paste(rgba, mask=rgba.getchannel('A'))
|
||||||
|
|
|
||||||
|
|
@ -43,15 +43,7 @@ class FileManager(models.Manager):
|
||||||
else:
|
else:
|
||||||
raise ValueError('data must be a base64 encoded string or file and hash must be provided')
|
raise ValueError('data must be a base64 encoded string or file and hash must be provided')
|
||||||
if not self.filter(hash=kwargs['hash']).exists():
|
if not self.filter(hash=kwargs['hash']).exists():
|
||||||
# The upload path is derived entirely from the hash (hash_upload, above), and hash
|
# 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.
|
||||||
# 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']), '')
|
expected_path = hash_upload(SimpleNamespace(hash=kwargs['hash']), '')
|
||||||
if default_storage.exists(expected_path):
|
if default_storage.exists(expected_path):
|
||||||
default_storage.delete(expected_path)
|
default_storage.delete(expected_path)
|
||||||
|
|
|
||||||
|
|
@ -113,12 +113,7 @@ class FilesTestCase(FilesTestMixin, ToolshedTestCase):
|
||||||
self.assertEqual(countdir(DefaultStorage(), ''), 3)
|
self.assertEqual(countdir(DefaultStorage(), ''), 3)
|
||||||
|
|
||||||
def test_file_upload_reclaims_stale_orphan_at_canonical_path(self):
|
def test_file_upload_reclaims_stale_orphan_at_canonical_path(self):
|
||||||
# Reproduces a real incident: a File row gets deleted without its underlying stored
|
# Regression test for a stale orphan at the canonical hash path. See docs/implementation.md#stale-orphan-cleanup-at-the-canonical-hash-path.
|
||||||
# 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:]}"
|
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']))
|
default_storage.save(expected_path, ContentFile(self.f['test_content4']))
|
||||||
self.assertTrue(default_storage.exists(expected_path))
|
self.assertTrue(default_storage.exists(expected_path))
|
||||||
|
|
@ -231,11 +226,8 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To
|
||||||
self.prepare_properties()
|
self.prepare_properties()
|
||||||
self.prepare_inventory()
|
self.prepare_inventory()
|
||||||
|
|
||||||
# Each test method gets its own distinct image content (and therefore its own content
|
# Each test uses a distinct seeded image (own hash/cache path) since InMemoryStorage
|
||||||
# hash / thumbnail cache path) - InMemoryStorage isn't reset between test methods within
|
# isn't reset between test methods, so a shared image risks one test's cached thumbnail leaking into another's assertions.
|
||||||
# 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
|
seed = zlib.crc32(self._testMethodName.encode()) % 256
|
||||||
buffer = io.BytesIO()
|
buffer = io.BytesIO()
|
||||||
Image.new('RGB', (800, 600), (seed, 255 - seed, 128)).save(buffer, 'PNG')
|
Image.new('RGB', (800, 600), (seed, 255 - seed, 128)).save(buffer, 'PNG')
|
||||||
|
|
@ -254,8 +246,7 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To
|
||||||
return os.path.join('thumbnails', str(size), h[:2], h[2:4], h[4:6], h[6:] + '.jpg')
|
return os.path.join('thumbnails', str(size), h[:2], h[2:4], h[4:6], h[6:] + '.jpg')
|
||||||
|
|
||||||
def test_thumbnail_sizes_available(self):
|
def test_thumbnail_sizes_available(self):
|
||||||
# Documents the fixed size allow-list this test suite exercises against - update both
|
# Fixed size allow-list this suite exercises - update both if media_urls.py's THUMBNAIL_SIZES changes.
|
||||||
# if files/media_urls.py's THUMBNAIL_SIZES ever changes.
|
|
||||||
self.assertEqual(THUMBNAIL_SIZES, (32, 64, 256))
|
self.assertEqual(THUMBNAIL_SIZES, (32, 64, 256))
|
||||||
|
|
||||||
@override_settings(SERVE_X_ACCEL_REDIRECT=False)
|
@override_settings(SERVE_X_ACCEL_REDIRECT=False)
|
||||||
|
|
@ -274,10 +265,7 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To
|
||||||
|
|
||||||
@override_settings(SERVE_X_ACCEL_REDIRECT=False)
|
@override_settings(SERVE_X_ACCEL_REDIRECT=False)
|
||||||
def test_thumbnail_flattens_transparency_instead_of_going_black(self):
|
def test_thumbnail_flattens_transparency_instead_of_going_black(self):
|
||||||
# Reproduces a real incident: an 'LA' (grayscale + alpha) source whose fully-transparent
|
# Regression test for an 'LA' source with zeroed transparent-region luminance. See docs/implementation.md#rgba-flattening-avoids-revealing-black-under-transparent-pixels.
|
||||||
# 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))
|
half_transparent = Image.new('LA', (200, 200))
|
||||||
pixels = half_transparent.load()
|
pixels = half_transparent.load()
|
||||||
for x in range(200):
|
for x in range(200):
|
||||||
|
|
@ -310,8 +298,7 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To
|
||||||
with default_storage.open(rel_path, 'rb') as f:
|
with default_storage.open(rel_path, 'rb') as f:
|
||||||
cached_bytes = f.read()
|
cached_bytes = f.read()
|
||||||
|
|
||||||
# Overwrite the cached file with a marker so a correct implementation must serve this
|
# Overwrites the cache with a marker so a correct implementation must serve it back, not regenerate.
|
||||||
# exact content back rather than regenerating it from the original.
|
|
||||||
default_storage.delete(rel_path)
|
default_storage.delete(rel_path)
|
||||||
default_storage.save(rel_path, ContentFile(cached_bytes + b'MARKER'))
|
default_storage.save(rel_path, ContentFile(cached_bytes + b'MARKER'))
|
||||||
|
|
||||||
|
|
@ -333,8 +320,7 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To
|
||||||
self.assertEqual(reply.status_code, 403)
|
self.assertEqual(reply.status_code, 403)
|
||||||
|
|
||||||
def test_thumbnail_not_friend(self):
|
def test_thumbnail_not_friend(self):
|
||||||
# local_user1/local_user2 are friends in these fixtures (see prepare_inventory) - the
|
# local_user1/local_user2 are friends here (see prepare_inventory), so the denied case needs a stranger instead.
|
||||||
# denied case needs a stranger to that friendship instead.
|
|
||||||
reply = client.get(self._thumb_url(64), self.f['ext_user1'])
|
reply = client.get(self._thumb_url(64), self.f['ext_user1'])
|
||||||
self.assertEqual(reply.status_code, 404)
|
self.assertEqual(reply.status_code, 404)
|
||||||
self.assertFalse(default_storage.exists(self._thumb_rel_path(64)))
|
self.assertFalse(default_storage.exists(self._thumb_rel_path(64)))
|
||||||
|
|
|
||||||
|
|
@ -50,10 +50,7 @@ def post_item_file(request, item_id):
|
||||||
if item is None:
|
if item is None:
|
||||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
if 'file_hash' in request.data:
|
if 'file_hash' in request.data:
|
||||||
# Attach a file the caller already staged on one of their own workflows, identified
|
# Attaches an already-staged file by hash instead of re-uploading it. See docs/implementation.md#staged-files-are-identified-by-hash-alone.
|
||||||
# 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():
|
if not request.user.user.exists():
|
||||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
try:
|
try:
|
||||||
|
|
@ -74,10 +71,7 @@ def post_item_file(request, item_id):
|
||||||
def get_staged_files(request, workflow_id):
|
def get_staged_files(request, workflow_id):
|
||||||
try:
|
try:
|
||||||
workflow = WorkflowInstance.objects.get(id=workflow_id, owner=request.user)
|
workflow = WorkflowInstance.objects.get(id=workflow_id, owner=request.user)
|
||||||
# Hash alone identifies a staged file (client and server hash content the same way, and
|
# Hash alone is enough to discover what another session/device already staged. See docs/implementation.md#staged-files-are-identified-by-hash-alone.
|
||||||
# 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)))
|
return Response(list(workflow.staged_files.values_list('hash', flat=True)))
|
||||||
except WorkflowInstance.DoesNotExist:
|
except WorkflowInstance.DoesNotExist:
|
||||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
|
||||||
|
|
@ -35,26 +35,22 @@ class InventoryItemViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = InventoryItemSerializer
|
serializer_class = InventoryItemSerializer
|
||||||
authentication_classes = [SignatureAuthentication]
|
authentication_classes = [SignatureAuthentication]
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
# Detail routes address an item by its owner-scoped id, not the internal row id - the
|
# Detail routes address an item by its owner-scoped id, not the internal row id. See
|
||||||
# router still names the URL capture group 'pk', so keep that as lookup_url_kwarg and just
|
# docs/implementation.md#inventory-detail-routes-use-owner-scoped-ids.
|
||||||
# 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_field = 'id'
|
||||||
lookup_url_kwarg = 'pk'
|
lookup_url_kwarg = 'pk'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
# A KnownIdentity acting purely as a group member (e.g. a remote member on a group
|
# A pure group-member KnownIdentity may have no local ToolshedUser account; only
|
||||||
# hosted on this backend) never has a local ToolshedUser account here - group-owned
|
# personal items require .user.exists(). See
|
||||||
# items must stay reachable for such an identity, only personal ("owner=...") items
|
# docs/implementation.md#group-member-identities-without-local-accounts.
|
||||||
# require .user.exists().
|
|
||||||
if type(self.request.user) != KnownIdentity:
|
if type(self.request.user) != KnownIdentity:
|
||||||
return InventoryItem.objects.none()
|
return InventoryItem.objects.none()
|
||||||
identity = self.request.user
|
identity = self.request.user
|
||||||
group_items = InventoryItem.objects.filter(owner_group__in=identity.member_of_groups.all())
|
group_items = InventoryItem.objects.filter(owner_group__in=identity.member_of_groups.all())
|
||||||
if self.action != 'list':
|
if self.action != 'list':
|
||||||
# retrieve/update/destroy: anything the caller may act on - their own items, or any
|
# retrieve/update/destroy: any item the caller may act on, own or group. See
|
||||||
# group they're currently a member of. The narrower per-group listing below is only
|
# docs/implementation.md#inventory-queryset-scope-by-action.
|
||||||
# for the list action, so the main Inventory page stays scoped to personal items.
|
|
||||||
if identity.user.exists():
|
if identity.user.exists():
|
||||||
return InventoryItem.objects.filter(owner=identity.user.get()) | group_items
|
return InventoryItem.objects.filter(owner=identity.user.get()) | group_items
|
||||||
return group_items
|
return group_items
|
||||||
|
|
@ -127,13 +123,8 @@ def search_inventory_items(request):
|
||||||
@authentication_classes([SignatureAuthentication])
|
@authentication_classes([SignatureAuthentication])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def get_shared_item(request, handle, id):
|
def get_shared_item(request, handle, id):
|
||||||
"""Fetch a single item by its owner's handle (username@domain) and local id, e.g. for the
|
"""Fetch a single item by its owner's handle and local id, for /i/<handle>/<id> or
|
||||||
/i/<handle>/<id> item URL (see docs/design-in-progress/items-labels.md) or the
|
/inventory/shared/<handle>/<id>. See docs/implementation.md#get-shared-item-looks-up-by-owner."""
|
||||||
/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:
|
try:
|
||||||
username, domain = split_userhandle_or_throw(handle)
|
username, domain = split_userhandle_or_throw(handle)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|
@ -158,6 +149,10 @@ class StorageLocationViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = StorageLocationSerializer
|
serializer_class = StorageLocationSerializer
|
||||||
authentication_classes = [SignatureAuthentication]
|
authentication_classes = [SignatureAuthentication]
|
||||||
permission_classes = [IsAuthenticated]
|
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):
|
def get_queryset(self):
|
||||||
if type(self.request.user) == KnownIdentity and self.request.user.user.exists():
|
if type(self.request.user) == KnownIdentity and self.request.user.user.exists():
|
||||||
|
|
|
||||||
|
|
@ -83,9 +83,9 @@ class Tag(models.Model):
|
||||||
|
|
||||||
|
|
||||||
class OwnerItemSequence(models.Model):
|
class OwnerItemSequence(models.Model):
|
||||||
"""Tracks the last InventoryItem id handed out to a given owner or owner_group, so ids can
|
"""Tracks the last InventoryItem id handed out per owner/owner_group scope for sequential,
|
||||||
be allocated sequentially and without gaps within that scope (see InventoryItem.create_for_owner).
|
gapless allocation (see InventoryItem.create_for_owner); exactly one of owner/owner_group is
|
||||||
Exactly one of owner/owner_group is set, mirroring InventoryItem's own owner/owner_group split."""
|
set, mirroring InventoryItem's own split."""
|
||||||
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, null=True, blank=True, related_name='+')
|
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='+')
|
owner_group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True, blank=True, related_name='+')
|
||||||
last_id = models.PositiveIntegerField(default=0)
|
last_id = models.PositiveIntegerField(default=0)
|
||||||
|
|
@ -117,9 +117,8 @@ class InventoryItem(SoftDeleteModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
internal_id = models.AutoField(primary_key=True)
|
internal_id = models.AutoField(primary_key=True)
|
||||||
# The externally visible identifier: sequential and gapless within owner/owner_group's own
|
# Externally visible id, sequential/gapless within owner/owner_group's own items (see
|
||||||
# items (see OwnerItemSequence), never the internal_id above. Always allocate through
|
# OwnerItemSequence), never internal_id; always allocate via create_for_owner, not .objects.create().
|
||||||
# create_for_owner rather than InventoryItem.objects.create() directly.
|
|
||||||
id = models.PositiveIntegerField(editable=False)
|
id = models.PositiveIntegerField(editable=False)
|
||||||
published = models.BooleanField(default=False)
|
published = models.BooleanField(default=False)
|
||||||
name = models.CharField(max_length=255, null=True, blank=True)
|
name = models.CharField(max_length=255, null=True, blank=True)
|
||||||
|
|
@ -152,8 +151,8 @@ class InventoryItem(SoftDeleteModel):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_for_owner(cls, *, owner=None, owner_group=None, **kwargs):
|
def create_for_owner(cls, *, owner=None, owner_group=None, **kwargs):
|
||||||
"""The only supported way to create an InventoryItem: allocates the next id for this
|
"""The only supported way to create an InventoryItem: atomically allocates the next id
|
||||||
owner/owner_group scope and creates the item with it, atomically."""
|
for this owner/owner_group scope."""
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
next_id = OwnerItemSequence.allocate(owner=owner, owner_group=owner_group)
|
next_id = OwnerItemSequence.allocate(owner=owner, owner_group=owner_group)
|
||||||
return cls.objects.create(owner=owner, owner_group=owner_group, id=next_id, **kwargs)
|
return cls.objects.create(owner=owner, owner_group=owner_group, id=next_id, **kwargs)
|
||||||
|
|
@ -170,7 +169,27 @@ class ItemTag(models.Model):
|
||||||
inventory_item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE)
|
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):
|
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)
|
name = models.CharField(max_length=255)
|
||||||
description = models.TextField(null=True, blank=True)
|
description = models.TextField(null=True, blank=True)
|
||||||
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True,
|
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True,
|
||||||
|
|
@ -178,10 +197,23 @@ class StorageLocation(models.Model):
|
||||||
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')
|
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')
|
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):
|
def __str__(self):
|
||||||
parent = str(self.parent) + "/" if self.parent else ""
|
parent = str(self.parent) + "/" if self.parent else ""
|
||||||
return parent + self.name
|
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):
|
class WorkflowInstance(models.Model):
|
||||||
slug = models.CharField(max_length=255)
|
slug = models.CharField(max_length=255)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,4 @@
|
||||||
"""Data helpers for building/importing a user's offline export.
|
"""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."""
|
||||||
|
|
||||||
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):
|
def inventory_rows(user):
|
||||||
|
|
@ -76,11 +71,7 @@ def location_rows(user):
|
||||||
|
|
||||||
|
|
||||||
def inventory_files(user):
|
def inventory_files(user):
|
||||||
"""Generator that yields (arcname, data) for each unique file attached to the user's inventory items.
|
"""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."""
|
||||||
|
|
||||||
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
|
import mimetypes
|
||||||
|
|
||||||
from toolshed.models import InventoryItem
|
from toolshed.models import InventoryItem
|
||||||
|
|
@ -125,11 +116,7 @@ def profile_data(user):
|
||||||
|
|
||||||
|
|
||||||
def profile_picture_files(user):
|
def profile_picture_files(user):
|
||||||
"""Generator that yields (arcname, data) for the user's profile picture, if one is set.
|
"""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."""
|
||||||
|
|
||||||
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
|
import mimetypes
|
||||||
|
|
||||||
if not user.profile_picture:
|
if not user.profile_picture:
|
||||||
|
|
@ -148,21 +135,12 @@ def profile_picture_files(user):
|
||||||
|
|
||||||
|
|
||||||
def settings_data(user):
|
def settings_data(user):
|
||||||
"""Return the given user's account-level preferences as a {key: value} dict for settings.json.
|
"""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."""
|
||||||
|
|
||||||
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()}
|
return {pref.key: pref.value for pref in user.preferences.all()}
|
||||||
|
|
||||||
|
|
||||||
def import_profile(user, data, available_files):
|
def import_profile(user, data, available_files):
|
||||||
"""Fault-tolerant import of profile.json, updating the user's editable profile fields.
|
"""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."""
|
||||||
|
|
||||||
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
|
import json
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -189,11 +167,7 @@ def import_profile(user, data, available_files):
|
||||||
|
|
||||||
|
|
||||||
def import_settings(user, data):
|
def import_settings(user, data):
|
||||||
"""Fault-tolerant import of settings.json into the user's account-level preferences.
|
"""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."""
|
||||||
|
|
||||||
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
|
import json
|
||||||
|
|
||||||
from authentication.models import AccountPreference
|
from authentication.models import AccountPreference
|
||||||
|
|
@ -217,18 +191,7 @@ def import_settings(user, data):
|
||||||
|
|
||||||
|
|
||||||
def delete_user_data(user):
|
def delete_user_data(user):
|
||||||
"""Permanently delete everything that `user_data()` exports, keeping the account itself intact.
|
"""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."""
|
||||||
|
|
||||||
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 django.db import transaction
|
||||||
|
|
||||||
from toolshed.models import InventoryItem, StorageLocation
|
from toolshed.models import InventoryItem, StorageLocation
|
||||||
|
|
@ -262,15 +225,7 @@ def delete_user_data(user):
|
||||||
|
|
||||||
|
|
||||||
def delete_user_account(user):
|
def delete_user_account(user):
|
||||||
"""Permanently delete the local user's account, after wiping all of its data.
|
"""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."""
|
||||||
|
|
||||||
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
|
from django.db import transaction
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
|
|
@ -282,11 +237,7 @@ def delete_user_account(user):
|
||||||
|
|
||||||
|
|
||||||
def _delete_orphaned_files(file_ids):
|
def _delete_orphaned_files(file_ids):
|
||||||
"""Delete File rows (and their underlying blobs) in `file_ids` that are no longer referenced.
|
"""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."""
|
||||||
|
|
||||||
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
|
from files.models import File
|
||||||
|
|
||||||
deleted = 0
|
deleted = 0
|
||||||
|
|
@ -360,15 +311,7 @@ def get_or_create_category(path):
|
||||||
|
|
||||||
|
|
||||||
def import_locations(user, data):
|
def import_locations(user, data):
|
||||||
"""Fault-tolerant import of locations.csv into StorageLocations owned by `user`.
|
"""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."""
|
||||||
|
|
||||||
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 django.db import transaction
|
||||||
|
|
||||||
from toolshed.models import StorageLocation
|
from toolshed.models import StorageLocation
|
||||||
|
|
@ -396,13 +339,17 @@ def import_locations(user, data):
|
||||||
if category_path:
|
if category_path:
|
||||||
category = get_or_create_category(category_path)
|
category = get_or_create_category(category_path)
|
||||||
|
|
||||||
location, _ = StorageLocation.objects.update_or_create(
|
defaults = {
|
||||||
owner=user, name=name, parent=parent,
|
'description': row.get('description', '') or '',
|
||||||
defaults={
|
'category': category,
|
||||||
'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)
|
||||||
resolved_by_path[path] = location
|
resolved_by_path[path] = location
|
||||||
imported += 1
|
imported += 1
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
|
@ -444,22 +391,11 @@ def import_friends(user, data):
|
||||||
|
|
||||||
|
|
||||||
class _HandleNotFound(Exception):
|
class _HandleNotFound(Exception):
|
||||||
"""Raised when a fully qualified handle (e.g. 'git:base#tag:drill') can't be resolved.
|
"""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."""
|
||||||
|
|
||||||
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):
|
def _resolve_handle(value, entity_type, model):
|
||||||
"""Resolve a fully qualified handle (e.g. 'git:base#tag:drill') to an *existing* model instance.
|
"""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."""
|
||||||
|
|
||||||
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)
|
origin, rest = value.split('#', 1)
|
||||||
if ':' in rest:
|
if ':' in rest:
|
||||||
found_type, name = rest.split(':', 1)
|
found_type, name = rest.split(':', 1)
|
||||||
|
|
@ -475,20 +411,14 @@ def _resolve_handle(value, entity_type, model):
|
||||||
|
|
||||||
|
|
||||||
def _quote_value_if_needed(value):
|
def _quote_value_if_needed(value):
|
||||||
"""Wrap `value` in double quotes (CSV-style, doubling any embedded quotes) if it contains a
|
"""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."""
|
||||||
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 ',"'):
|
if any(ch in value for ch in ',"'):
|
||||||
return '"' + value.replace('"', '""') + '"'
|
return '"' + value.replace('"', '""') + '"'
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _encode_properties_cell(item_properties):
|
def _encode_properties_cell(item_properties):
|
||||||
"""Encode an item's properties as a comma-separated "handle=value" list for the '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."""
|
||||||
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 = [
|
entries = [
|
||||||
f"{ip.property.get_handle()}={_quote_value_if_needed(ip.value or '')}"
|
f"{ip.property.get_handle()}={_quote_value_if_needed(ip.value or '')}"
|
||||||
for ip in item_properties
|
for ip in item_properties
|
||||||
|
|
@ -497,15 +427,7 @@ def _encode_properties_cell(item_properties):
|
||||||
|
|
||||||
|
|
||||||
def _split_quoted_comma_list(raw_value):
|
def _split_quoted_comma_list(raw_value):
|
||||||
"""Split a comma-separated list into entries, honouring double-quoted substrings (CSV-style,
|
"""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."""
|
||||||
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 = []
|
entries = []
|
||||||
current = []
|
current = []
|
||||||
in_quotes = False
|
in_quotes = False
|
||||||
|
|
@ -537,12 +459,7 @@ def _split_quoted_comma_list(raw_value):
|
||||||
|
|
||||||
|
|
||||||
def _parse_properties_cell(raw_value, resolve_property):
|
def _parse_properties_cell(raw_value, resolve_property):
|
||||||
"""Parse the 'properties' CSV cell into a list of (Property, value) tuples.
|
"""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()`."""
|
||||||
|
|
||||||
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()
|
raw_value = (raw_value or '').strip()
|
||||||
if not raw_value:
|
if not raw_value:
|
||||||
return []
|
return []
|
||||||
|
|
@ -561,17 +478,7 @@ def _parse_properties_cell(raw_value, resolve_property):
|
||||||
|
|
||||||
|
|
||||||
def import_inventory(user, data, available_files):
|
def import_inventory(user, data, available_files):
|
||||||
"""Fault-tolerant import of inventory.csv into InventoryItems owned by `user`.
|
"""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."""
|
||||||
|
|
||||||
`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 django.db import transaction
|
||||||
|
|
||||||
from toolshed.models import Category, InventoryItem, ItemProperty, StorageLocation, Tag, Property
|
from toolshed.models import Category, InventoryItem, ItemProperty, StorageLocation, Tag, Property
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming
|
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming
|
||||||
from authentication.serializers import OwnerSerializer, GroupOwnerSerializer
|
from authentication.serializers import OwnerSerializer, GroupOwnerSerializer
|
||||||
|
|
@ -150,15 +151,50 @@ class CategorySerializer(serializers.ModelSerializer):
|
||||||
return resolve_category_handle(data.split("/")[-1])
|
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):
|
class StorageLocationSerializer(serializers.ModelSerializer):
|
||||||
owner = OwnerSerializer(read_only=True)
|
owner = OwnerSerializer(read_only=True)
|
||||||
category = serializers.CharField(required=False, allow_null=True, allow_blank=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()
|
path = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = StorageLocation
|
model = StorageLocation
|
||||||
fields = ['id', 'name', 'description', 'path', 'category', 'owner', 'parent']
|
fields = ['id', 'name', 'description', 'path', 'category', 'owner', 'parent']
|
||||||
read_only_fields = ['path']
|
read_only_fields = ['id', 'path']
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_path(obj):
|
def get_path(obj):
|
||||||
|
|
@ -166,6 +202,9 @@ class StorageLocationSerializer(serializers.ModelSerializer):
|
||||||
return StorageLocationSerializer.get_path(obj.parent) + "/" + obj.name
|
return StorageLocationSerializer.get_path(obj.parent) + "/" + obj.name
|
||||||
return obj.name
|
return obj.name
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
return StorageLocation.create_for_owner(**validated_data)
|
||||||
|
|
||||||
|
|
||||||
class ItemPropertySerializer(serializers.ModelSerializer):
|
class ItemPropertySerializer(serializers.ModelSerializer):
|
||||||
property = PropertySerializer(read_only=True)
|
property = PropertySerializer(read_only=True)
|
||||||
|
|
@ -195,6 +234,8 @@ class InventoryItemSerializer(serializers.ModelSerializer):
|
||||||
properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set')
|
properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set')
|
||||||
category = CategorySerializer(required=False, allow_null=True)
|
category = CategorySerializer(required=False, allow_null=True)
|
||||||
files = FileSerializer(many=True, read_only=True)
|
files = FileSerializer(many=True, read_only=True)
|
||||||
|
storage_location = OwnerScopedPrimaryKeyRelatedField(queryset=StorageLocation.objects.all(), required=False,
|
||||||
|
allow_null=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InventoryItem
|
model = InventoryItem
|
||||||
|
|
@ -255,11 +296,7 @@ class InventoryItemSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class WorkflowInstanceSerializer(serializers.ModelSerializer):
|
class WorkflowInstanceSerializer(serializers.ModelSerializer):
|
||||||
owner = serializers.StringRelatedField(read_only=True)
|
owner = serializers.StringRelatedField(read_only=True)
|
||||||
# Hash is enough to identify a staged file (the client computes the same SHA-256 the backend
|
# Only the hash is needed to identify a staged file, unlike InventoryItemSerializer.files. See docs/implementation.md#staged-files-are-identified-by-hash-alone.
|
||||||
# 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()
|
staged_files = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
||||||
|
|
@ -49,12 +49,12 @@ class InventoryTestMixin(CategoryTestMixin, TagTestMixin, PropertyTestMixin):
|
||||||
|
|
||||||
class LocationTestMixin:
|
class LocationTestMixin:
|
||||||
def prepare_locations(self):
|
def prepare_locations(self):
|
||||||
self.f['loc1'] = StorageLocation.objects.create(name='loc1', owner=self.f['local_user1'])
|
self.f['loc1'] = StorageLocation.create_for_owner(name='loc1', owner=self.f['local_user1'])
|
||||||
self.f['loc2'] = StorageLocation.objects.create(name='loc2', owner=self.f['local_user1'],
|
self.f['loc2'] = StorageLocation.create_for_owner(name='loc2', owner=self.f['local_user1'],
|
||||||
category=self.f['cat1'])
|
category=self.f['cat1'])
|
||||||
self.f['loc3'] = StorageLocation.objects.create(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1'])
|
self.f['loc3'] = StorageLocation.create_for_owner(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'],
|
self.f['loc4'] = StorageLocation.create_for_owner(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'],
|
||||||
category=self.f['cat1'])
|
category=self.f['cat1'])
|
||||||
|
|
||||||
|
|
||||||
class WorkflowTestMixin:
|
class WorkflowTestMixin:
|
||||||
|
|
|
||||||
|
|
@ -85,20 +85,8 @@ class FriendApiTestCase(UserTestMixin, ToolshedTestCase):
|
||||||
self.assertEqual(self.f['local_user1'].friends.count(), 1)
|
self.assertEqual(self.f['local_user1'].friends.count(), 1)
|
||||||
|
|
||||||
|
|
||||||
# what ~should~ happen:
|
# Friend request/accept protocol walkthrough. See
|
||||||
# 1. user x@A sends a friend request to user y@B
|
# docs/implementation.md#friend-request-and-accept-protocol-flow.
|
||||||
# 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):
|
class FriendRequestListTestCase(UserTestMixin, ToolshedTestCase):
|
||||||
|
|
|
||||||
|
|
@ -388,9 +388,8 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
|
||||||
self.assertEqual(InventoryItem.objects.filter(id=item_id).count(), 0)
|
self.assertEqual(InventoryItem.objects.filter(id=item_id).count(), 0)
|
||||||
|
|
||||||
def test_remote_member_without_local_account_can_edit(self):
|
def test_remote_member_without_local_account_can_edit(self):
|
||||||
# A remote member (no ToolshedUser row at all on this backend, only a KnownIdentity -
|
# A remote member (KnownIdentity, no ToolshedUser row) must still act on group-owned
|
||||||
# see docs/design-in-progress/groups-mvp.md) must still be able to act on group-owned
|
# items - not unauthorized just because .user.exists() is False.
|
||||||
# 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)
|
self.f['group1'].members.add(self.f['ext_user1'].public_identity)
|
||||||
item_id = self.create_group_item().json()['id']
|
item_id = self.create_group_item().json()['id']
|
||||||
reply = client.get('/api/inventory_items/{}/'.format(item_id), self.f['ext_user1'])
|
reply = client.get('/api/inventory_items/{}/'.format(item_id), self.f['ext_user1'])
|
||||||
|
|
@ -426,8 +425,8 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
|
||||||
self.assertEqual(len(reply.json()), 0)
|
self.assertEqual(len(reply.json()), 0)
|
||||||
|
|
||||||
def test_create_group_owned_item_with_full_fields(self):
|
def test_create_group_owned_item_with_full_fields(self):
|
||||||
# Parity with InventoryApiTestCase.test_post_new_item - tags/properties/category must
|
# Parity with InventoryApiTestCase.test_post_new_item: tags/properties/category attach
|
||||||
# attach to a group-owned item exactly the same way they do for a personal one.
|
# to a group-owned item the same way as a personal one.
|
||||||
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
|
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
|
||||||
'availability_policy': 'rent',
|
'availability_policy': 'rent',
|
||||||
'category': 'cat2',
|
'category': 'cat2',
|
||||||
|
|
@ -450,8 +449,7 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
|
||||||
self.assertEqual([p.value for p in item.itemproperty_set.all()], ['value1', 'value2'])
|
self.assertEqual([p.value for p in item.itemproperty_set.all()], ['value1', 'value2'])
|
||||||
|
|
||||||
def test_create_group_owned_item_empty_fails(self):
|
def test_create_group_owned_item_empty_fails(self):
|
||||||
# Parity with InventoryApiTestCase.test_post_new_item_empty - clean()'s name-or-files
|
# Parity with InventoryApiTestCase.test_post_new_item_empty: clean()'s name-or-files validation still applies.
|
||||||
# validation must still apply to group-owned items.
|
|
||||||
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
|
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
|
||||||
'availability_policy': 'private', 'owned_quantity': 1, 'owner_group': self.f['group1'].id,
|
'availability_policy': 'private', 'owned_quantity': 1, 'owner_group': self.f['group1'].id,
|
||||||
})
|
})
|
||||||
|
|
@ -471,9 +469,8 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
|
||||||
self.assertEqual(len(reply.json()), 0)
|
self.assertEqual(len(reply.json()), 0)
|
||||||
|
|
||||||
def test_put_group_item(self):
|
def test_put_group_item(self):
|
||||||
# Parity with InventoryApiTestCase.test_put_item - full replace, by a different member
|
# Parity with InventoryApiTestCase.test_put_item, but as a PUT by a different member than
|
||||||
# than the one who created it, exercising the group_items_id -> _is_authorized branch
|
# the creator, to exercise the _is_authorized branch in perform_update for PUT too.
|
||||||
# in perform_update for a PUT (not just PATCH).
|
|
||||||
item_id = self.create_group_item().json()['id']
|
item_id = self.create_group_item().json()['id']
|
||||||
reply = client.put('/api/inventory_items/{}/'.format(item_id), self.f['local_user2'], {
|
reply = client.put('/api/inventory_items/{}/'.format(item_id), self.f['local_user2'], {
|
||||||
'availability_policy': 'sell',
|
'availability_policy': 'sell',
|
||||||
|
|
@ -539,9 +536,8 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
|
||||||
self.assertEqual([f for f in item.files.all()], [self.f['test_file3']])
|
self.assertEqual([f for f in item.files.all()], [self.f['test_file3']])
|
||||||
|
|
||||||
def test_group_items_excluded_from_search(self):
|
def test_group_items_excluded_from_search(self):
|
||||||
# Group-owned items are only ever reachable via the group's own detail page for MVP
|
# Group-owned items are reachable only via the group's own detail page for MVP (see
|
||||||
# (see docs/design-in-progress/groups-mvp.md) - search must not surface them, same as
|
# docs/design-in-progress/groups-mvp.md) - search must not surface them either.
|
||||||
# the main Inventory list already doesn't.
|
|
||||||
self.create_group_item(name='searchable-drill')
|
self.create_group_item(name='searchable-drill')
|
||||||
InventoryItem.create_for_owner(owner=self.f['local_user1'], owned_quantity=1, name='searchable-personal')
|
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'])
|
reply = client.get('/api/search/?query=searchable', self.f['local_user1'])
|
||||||
|
|
@ -551,8 +547,8 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
|
||||||
|
|
||||||
|
|
||||||
class InventoryItemIdAllocationTestCase(UserTestMixin, ToolshedTestCase):
|
class InventoryItemIdAllocationTestCase(UserTestMixin, ToolshedTestCase):
|
||||||
"""InventoryItem.id is sequential and gapless within each owner/owner_group's own items,
|
"""InventoryItem.id is sequential, gapless, and never reused within each owner/owner_group's
|
||||||
never reused, and allocated independently per scope - see OwnerItemSequence."""
|
own items, allocated independently per scope (see OwnerItemSequence)."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
|
||||||
|
|
@ -123,10 +123,7 @@ class DeleteAccountTestCase(_DeleteTestDataMixin, ToolshedTestCase):
|
||||||
|
|
||||||
class ImportInventoryPropertiesTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, PropertyTestMixin,
|
class ImportInventoryPropertiesTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, PropertyTestMixin,
|
||||||
ToolshedTestCase):
|
ToolshedTestCase):
|
||||||
"""Properties must round-trip through export/import even when their value contains a
|
"""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."""
|
||||||
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):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
@ -222,10 +219,7 @@ class ImportInventoryPropertiesTestCase(UserTestMixin, CategoryTestMixin, TagTes
|
||||||
|
|
||||||
class ExportImportApiRoundTripTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, PropertyTestMixin,
|
class ExportImportApiRoundTripTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, PropertyTestMixin,
|
||||||
ToolshedTestCase):
|
ToolshedTestCase):
|
||||||
"""End-to-end coverage of the /api/export/ + /api/import/ endpoints (as actually used by
|
"""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."""
|
||||||
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):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1 @@
|
||||||
FROM nginx:bookworm
|
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 \
|
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,14 @@ services:
|
||||||
- ./instance_a/dns.json:/var/www/dns.json:ro
|
- ./instance_a/dns.json:/var/www/dns.json:ro
|
||||||
- ./instance_a/domains.json:/var/www/domains.json:ro
|
- ./instance_a/domains.json:/var/www/domains.json:ro
|
||||||
- ./instance_a/userfiles:/var/www/userfiles: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:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
- "127.0.0.3:5353:5353"
|
- "127.0.0.3:5353:5353"
|
||||||
|
|
@ -83,6 +91,8 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ./instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro
|
- ./instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro
|
||||||
- ./instance_b/userfiles:/var/www/userfiles: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:
|
ports:
|
||||||
- "127.0.0.2:8080:8080"
|
- "127.0.0.2:8080:8080"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,17 +48,27 @@ toolshed:
|
||||||
```
|
```
|
||||||
|
|
||||||
- `toolshed_domain` — the **web domain**: the nginx `server_name`, Django
|
- `toolshed_domain` — the **web domain**: the nginx `server_name`, Django
|
||||||
`ALLOWED_HOSTS`, and the hostname you'll point a TLS cert at — e.g.
|
`ALLOWED_HOSTS`, and the hostname(s) you'll point a TLS cert at — e.g.
|
||||||
`toolshed.webdomain.tld`. Required, no default. This is not necessarily the
|
`toolshed.webdomain.tld`. Required, no default. May be a single domain (as
|
||||||
same as the **handle domain** your users log in with (the part after `@`
|
above) or a list, e.g. to also answer on a `www.` alias:
|
||||||
in `user@yourtoolshed.tld`) — see [DNS](#3-dns) for how those two relate.
|
```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.
|
||||||
- `toolshed_handle_domain` — the **handle domain**, only needed when it's
|
- `toolshed_handle_domain` — the **handle domain**, only needed when it's
|
||||||
different from `toolshed_domain`. Omit it when the two are the same (it
|
different from `toolshed_domain`. Omit it when the two are the same (it
|
||||||
then defaults to `toolshed_domain`). It doesn't affect nginx/Django at all
|
then defaults to `toolshed_domain`). Like `toolshed_domain`, it may be a
|
||||||
(they only ever accept `toolshed_domain` as the `Host` header) — it's used
|
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
|
||||||
solely to populate the `/local/domains` registration fixture (see
|
solely to populate the `/local/domains` registration fixture (see
|
||||||
`toolshed_register_domains` in `playbook.yml`); publishing the SRV record is a
|
`toolshed_register_domains` in `playbook.yml`); publishing the SRV record for
|
||||||
separate, manual DNS step either way.
|
each handle domain is a separate, manual DNS step either way.
|
||||||
- `toolshed_repo_url` — the git remote the playbook checks out and builds
|
- `toolshed_repo_url` — the git remote the playbook checks out and builds
|
||||||
from. Required, no default.
|
from. Required, no default.
|
||||||
- `toolshed_version` — the branch, tag or commit to check out and build.
|
- `toolshed_version` — the branch, tag or commit to check out and build.
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,21 @@ toolshed:
|
||||||
ansible_user: deploy
|
ansible_user: deploy
|
||||||
# toolshed_domain is the "web domain" - see the README's DNS section
|
# toolshed_domain is the "web domain" - see the README's DNS section
|
||||||
# for how this relates to the separate "handle domain" your users
|
# for how this relates to the separate "handle domain" your users
|
||||||
# log in with (user@yourtoolshed.tld).
|
# 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.
|
||||||
toolshed_domain: toolshed.webdomain.tld
|
toolshed_domain: toolshed.webdomain.tld
|
||||||
# Optional - only needed if the handle domain differs from the web
|
# Optional - only needed if the handle domain differs from the web
|
||||||
# domain above. Omit it entirely when they're the same.
|
# 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
|
||||||
toolshed_handle_domain: yourtoolshed.tld
|
toolshed_handle_domain: yourtoolshed.tld
|
||||||
toolshed_repo_url: git@example.com:your-org/toolshed.git
|
toolshed_repo_url: git@example.com:your-org/toolshed.git
|
||||||
# Optional - branch, tag or commit to deploy. Defaults to "stable".
|
# Optional - branch, tag or commit to deploy. Defaults to "stable".
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,9 @@
|
||||||
# at a time, never the whole "toolshed" group in one run):
|
# at a time, never the whole "toolshed" group in one run):
|
||||||
# ansible-playbook -i inventory.yml playbook.yml --limit my-server
|
# ansible-playbook -i inventory.yml playbook.yml --limit my-server
|
||||||
#
|
#
|
||||||
# toolshed_repo_url, toolshed_domain, toolshed_handle_domain (optional),
|
# toolshed_repo_url, toolshed_domain, toolshed_handle_domain (optional,
|
||||||
# toolshed_version (optional, defaults to "stable"), behind_tls_proxy and
|
# either may be a single domain or a list of domains), toolshed_version
|
||||||
|
# (optional, defaults to "stable"), behind_tls_proxy and
|
||||||
# toolshed_letsencrypt_email (required unless behind_tls_proxy is true) are
|
# toolshed_letsencrypt_email (required unless behind_tls_proxy is true) are
|
||||||
# per-deployment and must be set as host_vars in inventory.yml (copy
|
# 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
|
# inventory.example.yml) rather than here or via -e, so that each host in
|
||||||
|
|
@ -53,11 +54,17 @@
|
||||||
# proxying to gunicorn for every asset request.
|
# proxying to gunicorn for every asset request.
|
||||||
toolshed_static_dir: /var/www/toolshed-static
|
toolshed_static_dir: /var/www/toolshed-static
|
||||||
# Domain(s) this server accepts registrations for (the "handle domain" -
|
# Domain(s) this server accepts registrations for (the "handle domain" -
|
||||||
# see the README's DNS section). Served as a static /local/domains
|
# see the README's DNS section). toolshed_handle_domain may be a single
|
||||||
# fixture that the frontend's registration/pairing forms fetch to
|
# domain or a list; when unset it falls back to toolshed_domain (whole
|
||||||
# populate their domain dropdown (frontend/src/views/Register.vue,
|
# list, if that's a list too). Served as a static /local/domains fixture
|
||||||
# Pairing.vue) - without it that dropdown is just empty.
|
# that the frontend's registration/pairing forms fetch to populate their
|
||||||
toolshed_register_domains: "{{ [toolshed_handle_domain | default(toolshed_domain)] | unique }}"
|
# 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 }}
|
||||||
# DoH resolvers the frontend falls back to for SRV lookups when it has
|
# 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
|
# no cached preference yet, served as a static /local/dns fixture. These
|
||||||
# match the frontend's own hardcoded fallback (frontend/src/dns.js), so
|
# match the frontend's own hardcoded fallback (frontend/src/dns.js), so
|
||||||
|
|
@ -90,8 +97,18 @@
|
||||||
# and doesn't necessarily have an A record pointing at this host at all
|
# 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
|
# (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.
|
# 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_hostnames: >-
|
||||||
{{ [toolshed_domain | mandatory('toolshed_domain must be set as a host_var for ' ~ inventory_hostname)] }}
|
{{ ([toolshed_domain_checked]
|
||||||
|
if toolshed_domain_checked is string
|
||||||
|
else toolshed_domain_checked) | unique }}
|
||||||
# Generated once per host on the controller and reused on every
|
# Generated once per host on the controller and reused on every
|
||||||
# subsequent run against that host, keyed by inventory_hostname so
|
# subsequent run against that host, keyed by inventory_hostname so
|
||||||
# separate deployments never end up sharing a Django SECRET_KEY.
|
# separate deployments never end up sharing a Django SECRET_KEY.
|
||||||
|
|
@ -243,8 +260,8 @@
|
||||||
listen [::]:443 ssl;
|
listen [::]:443 ssl;
|
||||||
server_name {{ toolshed_hostnames | join(' ') }};
|
server_name {{ toolshed_hostnames | join(' ') }};
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/{{ toolshed_hostnames[0] }}/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/{{ toolshed_domain }}/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/{{ toolshed_hostnames[0] }}/privkey.pem;
|
||||||
|
|
||||||
client_max_body_size 128M;
|
client_max_body_size 128M;
|
||||||
root {{ toolshed_dist_dir }};
|
root {{ toolshed_dist_dir }};
|
||||||
|
|
@ -330,19 +347,13 @@
|
||||||
# until reloaded.
|
# until reloaded.
|
||||||
notify: reload nginx
|
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
|
- name: Create frontend static output directory
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: "{{ toolshed_dist_dir }}"
|
path: "{{ toolshed_dist_dir }}"
|
||||||
state: directory
|
state: directory
|
||||||
owner: "{{ ansible_user }}"
|
owner: "www-data"
|
||||||
mode: "0755"
|
group: "www-data"
|
||||||
|
mode: "0750"
|
||||||
|
|
||||||
- name: Create backend static output directory
|
- name: Create backend static output directory
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
|
|
@ -350,7 +361,7 @@
|
||||||
state: directory
|
state: directory
|
||||||
owner: www-data
|
owner: www-data
|
||||||
group: www-data
|
group: www-data
|
||||||
mode: "0755"
|
mode: "0750"
|
||||||
|
|
||||||
- name: Write backend environment file
|
- name: Write backend environment file
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
|
|
@ -570,7 +581,7 @@
|
||||||
|
|
||||||
- name: Check for an existing Let's Encrypt certificate
|
- name: Check for an existing Let's Encrypt certificate
|
||||||
ansible.builtin.stat:
|
ansible.builtin.stat:
|
||||||
path: "/etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem"
|
path: "/etc/letsencrypt/live/{{ toolshed_hostnames[0] }}/fullchain.pem"
|
||||||
register: toolshed_cert
|
register: toolshed_cert
|
||||||
when: not (behind_tls_proxy | default(false) | bool)
|
when: not (behind_tls_proxy | default(false) | bool)
|
||||||
|
|
||||||
|
|
@ -633,7 +644,7 @@
|
||||||
|
|
||||||
- name: Re-check the certificate now that certbot has run
|
- name: Re-check the certificate now that certbot has run
|
||||||
ansible.builtin.stat:
|
ansible.builtin.stat:
|
||||||
path: "/etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem"
|
path: "/etc/letsencrypt/live/{{ toolshed_hostnames[0] }}/fullchain.pem"
|
||||||
register: toolshed_cert
|
register: toolshed_cert
|
||||||
when: not (behind_tls_proxy | default(false) | bool)
|
when: not (behind_tls_proxy | default(false) | bool)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,6 @@ This is the documentation for the Toolshed project. It is a work in progress.
|
||||||
- [Development Setup](development.md)
|
- [Development Setup](development.md)
|
||||||
- [About Federation](federation.md)
|
- [About Federation](federation.md)
|
||||||
- [Handles and Short IDs](handles-and-shortids.md)
|
- [Handles and Short IDs](handles-and-shortids.md)
|
||||||
|
- [Implementation Notes](implementation.md)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
491
frontend/package-lock.json
generated
491
frontend/package-lock.json
generated
|
|
@ -5,31 +5,30 @@
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": {
|
"@babel/helper-string-parser": {
|
||||||
"version": "7.24.8",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ=="
|
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="
|
||||||
},
|
},
|
||||||
"@babel/helper-validator-identifier": {
|
"@babel/helper-validator-identifier": {
|
||||||
"version": "7.24.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||||
"integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w=="
|
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="
|
||||||
},
|
},
|
||||||
"@babel/parser": {
|
"@babel/parser": {
|
||||||
"version": "7.25.6",
|
"version": "7.29.8",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
|
||||||
"integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==",
|
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@babel/types": "^7.25.6"
|
"@babel/types": "^7.29.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@babel/types": {
|
"@babel/types": {
|
||||||
"version": "7.25.6",
|
"version": "7.29.8",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
|
||||||
"integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==",
|
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@babel/helper-string-parser": "^7.24.8",
|
"@babel/helper-string-parser": "^7.29.7",
|
||||||
"@babel/helper-validator-identifier": "^7.24.7",
|
"@babel/helper-validator-identifier": "^7.29.7"
|
||||||
"to-fast-properties": "^2.0.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@esbuild/android-arm": {
|
"@esbuild/android-arm": {
|
||||||
|
|
@ -201,9 +200,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@jridgewell/sourcemap-codec": {
|
"@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
|
||||||
},
|
},
|
||||||
"@leichtgewicht/base64-codec": {
|
"@leichtgewicht/base64-codec": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
|
@ -250,33 +249,30 @@
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@tootallnate/once": {
|
"@tootallnate/once": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz",
|
||||||
"integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==",
|
"integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"@types/chai": {
|
"@types/chai": {
|
||||||
"version": "4.3.19",
|
"version": "4.3.20",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.19.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz",
|
||||||
"integrity": "sha512-2hHHvQBVE2FiSK4eN0Br6snX9MtolHaTo/batnLjlGRhoQzlCL61iVpxoqO7SfFyOw+P/pwv+0zNHzKoGWz9Cw==",
|
"integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"@types/chai-subset": {
|
"@types/chai-subset": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.6.tgz",
|
||||||
"integrity": "sha512-c2mPnw+xHtXDoHmdtcCXGwyLMiauiAyxWMzhGpqHC4nqI/Y5G2XhTampslK2rb59kpcuHon03UH8W6iYUzw88A==",
|
"integrity": "sha512-m8lERkkQj+uek18hXOZuec3W/fCRTrU4hrnXjH3qhHy96ytuPaPiWGgu7sJb7tZxZonO75vYAjCvpe/e4VUwRw==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"requires": {
|
|
||||||
"@types/chai": "*"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"@types/node": {
|
"@types/node": {
|
||||||
"version": "22.5.4",
|
"version": "26.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
|
||||||
"integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==",
|
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"undici-types": "~6.19.2"
|
"undici-types": "~8.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@vitejs/plugin-vue": {
|
"@vitejs/plugin-vue": {
|
||||||
|
|
@ -455,15 +451,15 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"acorn": {
|
"acorn": {
|
||||||
"version": "8.12.1",
|
"version": "8.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||||
"integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
|
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"acorn-walk": {
|
"acorn-walk": {
|
||||||
"version": "8.3.4",
|
"version": "8.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
|
||||||
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
|
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"acorn": "^8.11.0"
|
"acorn": "^8.11.0"
|
||||||
|
|
@ -479,15 +475,15 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ansi-regex": {
|
"ansi-regex": {
|
||||||
"version": "6.1.0",
|
"version": "6.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
|
||||||
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
|
"integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"ansi-styles": {
|
"ansi-styles": {
|
||||||
"version": "6.2.1",
|
"version": "6.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||||
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
|
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"anymatch": {
|
"anymatch": {
|
||||||
|
|
@ -541,9 +537,9 @@
|
||||||
"integrity": "sha512-Xba1GTDYon8KYSDTKiiAtiyfk4clhdKQYvCQPMkE58+F5loVwEmh0Wi+ECCfowNc9SGwpoSLpSkvg7rhgZBttw=="
|
"integrity": "sha512-Xba1GTDYon8KYSDTKiiAtiyfk4clhdKQYvCQPMkE58+F5loVwEmh0Wi+ECCfowNc9SGwpoSLpSkvg7rhgZBttw=="
|
||||||
},
|
},
|
||||||
"brace-expansion": {
|
"brace-expansion": {
|
||||||
"version": "2.0.1",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
|
|
@ -569,6 +565,16 @@
|
||||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||||
"dev": true
|
"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": {
|
"chai": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
|
||||||
|
|
@ -656,9 +662,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"confbox": {
|
"confbox": {
|
||||||
"version": "0.1.7",
|
"version": "0.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
|
||||||
"integrity": "sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==",
|
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"config-chain": {
|
"config-chain": {
|
||||||
|
|
@ -672,9 +678,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cross-spawn": {
|
"cross-spawn": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"path-key": "^3.1.0",
|
"path-key": "^3.1.0",
|
||||||
|
|
@ -692,9 +698,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"csstype": {
|
"csstype": {
|
||||||
"version": "3.1.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
||||||
},
|
},
|
||||||
"data-urls": {
|
"data-urls": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
|
|
@ -717,18 +723,18 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"debug": {
|
"debug": {
|
||||||
"version": "4.3.7",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"decimal.js": {
|
"decimal.js": {
|
||||||
"version": "10.4.3",
|
"version": "10.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||||
"integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==",
|
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"deep-eql": {
|
"deep-eql": {
|
||||||
|
|
@ -767,6 +773,17 @@
|
||||||
"webidl-conversions": "^7.0.0"
|
"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": {
|
"eastasianwidth": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
|
|
@ -774,14 +791,14 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"editorconfig": {
|
"editorconfig": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz",
|
||||||
"integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
|
"integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"@one-ini/wasm": "0.1.1",
|
"@one-ini/wasm": "0.1.1",
|
||||||
"commander": "^10.0.0",
|
"commander": "^10.0.0",
|
||||||
"minimatch": "9.0.1",
|
"minimatch": "^9.0.1",
|
||||||
"semver": "^7.5.3"
|
"semver": "^7.5.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -796,6 +813,39 @@
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="
|
"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": {
|
"esbuild": {
|
||||||
"version": "0.18.20",
|
"version": "0.18.20",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
|
||||||
|
|
@ -856,24 +906,26 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"foreground-child": {
|
"foreground-child": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
"integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==",
|
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"cross-spawn": "^7.0.0",
|
"cross-spawn": "^7.0.6",
|
||||||
"signal-exit": "^4.0.1"
|
"signal-exit": "^4.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"form-data": {
|
"form-data": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
"combined-stream": "^1.0.8",
|
"combined-stream": "^1.0.8",
|
||||||
"mime-types": "^2.1.12"
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.4",
|
||||||
|
"mime-types": "^2.1.35"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fsevents": {
|
"fsevents": {
|
||||||
|
|
@ -883,16 +935,50 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": 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": {
|
"get-func-name": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
|
||||||
"integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
|
"integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
|
||||||
"dev": true
|
"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": {
|
"glob": {
|
||||||
"version": "10.4.5",
|
"version": "10.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||||
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"foreground-child": "^3.1.0",
|
"foreground-child": "^3.1.0",
|
||||||
|
|
@ -901,17 +987,6 @@
|
||||||
"minipass": "^7.1.2",
|
"minipass": "^7.1.2",
|
||||||
"package-json-from-dist": "^1.0.0",
|
"package-json-from-dist": "^1.0.0",
|
||||||
"path-scurry": "^1.11.1"
|
"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": {
|
"glob-parent": {
|
||||||
|
|
@ -923,6 +998,36 @@
|
||||||
"is-glob": "^4.0.1"
|
"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": {
|
"html-encoding-sniffer": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
|
||||||
|
|
@ -963,9 +1068,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"immutable": {
|
"immutable": {
|
||||||
"version": "4.3.7",
|
"version": "4.3.9",
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz",
|
||||||
"integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==",
|
"integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"ini": {
|
"ini": {
|
||||||
|
|
@ -1038,22 +1143,22 @@
|
||||||
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg=="
|
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg=="
|
||||||
},
|
},
|
||||||
"js-beautify": {
|
"js-beautify": {
|
||||||
"version": "1.15.1",
|
"version": "1.15.4",
|
||||||
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz",
|
||||||
"integrity": "sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==",
|
"integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"config-chain": "^1.1.13",
|
"config-chain": "^1.1.13",
|
||||||
"editorconfig": "^1.0.4",
|
"editorconfig": "^1.0.4",
|
||||||
"glob": "^10.3.3",
|
"glob": "^10.4.2",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"nopt": "^7.2.0"
|
"nopt": "^7.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"js-cookie": {
|
"js-cookie": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz",
|
||||||
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
|
"integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"js-nacl": {
|
"js-nacl": {
|
||||||
|
|
@ -1105,9 +1210,9 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"lodash": {
|
"lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"loupe": {
|
"loupe": {
|
||||||
|
|
@ -1133,6 +1238,12 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
"@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": {
|
"md5-hex": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/md5-hex/-/md5-hex-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/md5-hex/-/md5-hex-3.0.1.tgz",
|
||||||
|
|
@ -1158,30 +1269,38 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"minimatch": {
|
"minimatch": {
|
||||||
"version": "9.0.1",
|
"version": "9.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||||
"integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
|
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"brace-expansion": "^2.0.1"
|
"brace-expansion": "^2.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"minipass": {
|
"minipass": {
|
||||||
"version": "7.1.2",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"mlly": {
|
"mlly": {
|
||||||
"version": "1.7.1",
|
"version": "1.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
|
||||||
"integrity": "sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==",
|
"integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"acorn": "^8.11.3",
|
"acorn": "^8.16.0",
|
||||||
"pathe": "^1.1.2",
|
"pathe": "^2.0.3",
|
||||||
"pkg-types": "^1.1.1",
|
"pkg-types": "^1.3.1",
|
||||||
"ufo": "^1.5.3"
|
"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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"moment": {
|
"moment": {
|
||||||
|
|
@ -1196,9 +1315,9 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"nanoid": {
|
"nanoid": {
|
||||||
"version": "3.3.7",
|
"version": "3.3.18",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g=="
|
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="
|
||||||
},
|
},
|
||||||
"nopt": {
|
"nopt": {
|
||||||
"version": "7.2.1",
|
"version": "7.2.1",
|
||||||
|
|
@ -1216,9 +1335,9 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"nwsapi": {
|
"nwsapi": {
|
||||||
"version": "2.2.12",
|
"version": "2.2.24",
|
||||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
|
||||||
"integrity": "sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w==",
|
"integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"p-limit": {
|
"p-limit": {
|
||||||
|
|
@ -1231,18 +1350,26 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"package-json-from-dist": {
|
"package-json-from-dist": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
"integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==",
|
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"parse5": {
|
"parse5": {
|
||||||
"version": "7.1.2",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||||
"integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==",
|
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"entities": "^4.4.0"
|
"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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"path-key": {
|
"path-key": {
|
||||||
|
|
@ -1279,20 +1406,28 @@
|
||||||
"integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw=="
|
"integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw=="
|
||||||
},
|
},
|
||||||
"picomatch": {
|
"picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"pkg-types": {
|
"pkg-types": {
|
||||||
"version": "1.2.0",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
|
||||||
"integrity": "sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==",
|
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"confbox": "^0.1.7",
|
"confbox": "^0.1.8",
|
||||||
"mlly": "^1.7.1",
|
"mlly": "^1.7.4",
|
||||||
"pathe": "^1.1.2"
|
"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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"popper.js": {
|
"popper.js": {
|
||||||
|
|
@ -1342,10 +1477,13 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"psl": {
|
"psl": {
|
||||||
"version": "1.9.0",
|
"version": "1.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
||||||
"integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==",
|
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
|
||||||
"dev": true
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"punycode": "^2.3.1"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"punycode": {
|
"punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
|
|
@ -1381,9 +1519,9 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"rollup": {
|
"rollup": {
|
||||||
"version": "3.29.4",
|
"version": "3.30.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz",
|
||||||
"integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==",
|
"integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
|
|
@ -1422,9 +1560,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"semver": {
|
"semver": {
|
||||||
"version": "7.6.3",
|
"version": "7.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"shebang-command": {
|
"shebang-command": {
|
||||||
|
|
@ -1466,9 +1604,9 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"std-env": {
|
"std-env": {
|
||||||
"version": "3.7.0",
|
"version": "3.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||||
"integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==",
|
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"string-width": {
|
"string-width": {
|
||||||
|
|
@ -1517,12 +1655,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"strip-ansi": {
|
"strip-ansi": {
|
||||||
"version": "7.1.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||||
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
|
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"ansi-regex": "^6.0.1"
|
"ansi-regex": "^6.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"strip-ansi-cjs": {
|
"strip-ansi-cjs": {
|
||||||
|
|
@ -1581,11 +1719,6 @@
|
||||||
"integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==",
|
"integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==",
|
||||||
"dev": true
|
"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": {
|
"to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
|
|
@ -1623,15 +1756,15 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"ufo": {
|
"ufo": {
|
||||||
"version": "1.5.4",
|
"version": "1.6.4",
|
||||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
|
||||||
"integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==",
|
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"undici-types": {
|
"undici-types": {
|
||||||
"version": "6.19.8",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||||
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
|
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"universalify": {
|
"universalify": {
|
||||||
|
|
@ -1742,9 +1875,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"vue-component-type-helpers": {
|
"vue-component-type-helpers": {
|
||||||
"version": "2.1.6",
|
"version": "2.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz",
|
||||||
"integrity": "sha512-ng11B8B/ZADUMMOsRbqv0arc442q7lifSubD0v8oDXIFoMg/mXwAPUunrroIDkY+mcD0dHKccdaznSVp8EoX3w==",
|
"integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"vue-multiselect": {
|
"vue-multiselect": {
|
||||||
|
|
@ -1899,9 +2032,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ws": {
|
"ws": {
|
||||||
"version": "8.18.0",
|
"version": "8.21.3",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||||
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
|
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"xml-name-validator": {
|
"xml-name-validator": {
|
||||||
|
|
@ -1917,9 +2050,9 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"yocto-queue": {
|
"yocto-queue": {
|
||||||
"version": "1.1.1",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
|
||||||
"integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==",
|
"integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
frontend/src/assets/fonts/label/Inter-Regular.woff2
Normal file
BIN
frontend/src/assets/fonts/label/Inter-Regular.woff2
Normal file
Binary file not shown.
|
|
@ -1,7 +1,5 @@
|
||||||
// Shared camera stream manager used by any component that needs webcam access
|
// Shared camera stream manager for webcam access (WebcamFileSource, Scan) - centralizes device
|
||||||
// (WebcamFileSource, Scan). Centralizing this means every consumer gets device
|
// enumeration, preferred-camera memory, stream reuse, and disconnect/reconnect handling.
|
||||||
// enumeration, preferred-camera memory, stream reuse and disconnect/reconnect
|
|
||||||
// handling for free instead of re-implementing it per component.
|
|
||||||
class CameraManager {
|
class CameraManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.availableCameras = [];
|
this.availableCameras = [];
|
||||||
|
|
|
||||||
|
|
@ -73,9 +73,8 @@ export default {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Cached by src alone (content is hash-addressed and immutable - see
|
// Cached by src alone: content is hash-addressed and immutable (see fileCache.js),
|
||||||
// fileCache.js) so every AuthenticatedImage instance showing the same
|
// so every instance showing the same file shares one fetch and blob.
|
||||||
// file, across the whole app, shares one fetch and one decoded blob.
|
|
||||||
const url = await fileCache.get(this.src, async () => {
|
const url = await fileCache.get(this.src, async () => {
|
||||||
this.servers = await this.getFriendServers({username: this.owner});
|
this.servers = await this.getFriendServers({username: this.owner});
|
||||||
const response = await this.servers.getRaw(this.signAuth, this.src);
|
const response = await this.servers.getRaw(this.signAuth, this.src);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@
|
||||||
<SearchBox/>
|
<SearchBox/>
|
||||||
<div class="navbar-collapse collapse">
|
<div class="navbar-collapse collapse">
|
||||||
<ul class="navbar-nav navbar-align">
|
<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"/>
|
<Notifications :notifications="notifications"/>
|
||||||
<Messages :messages="messages"/>
|
<Messages :messages="messages"/>
|
||||||
<UserDropdown/>
|
<UserDropdown/>
|
||||||
|
|
|
||||||
618
frontend/src/components/CameraScanner.vue
Normal file
618
frontend/src/components/CameraScanner.vue
Normal file
|
|
@ -0,0 +1,618 @@
|
||||||
|
<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>
|
||||||
|
|
@ -4,19 +4,51 @@
|
||||||
<h5 class="card-title mb-0">Label layout</h5>
|
<h5 class="card-title mb-0">Label layout</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<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 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"
|
<div v-for="t in labelTemplates" :key="t.id" class="template-option d-flex flex-column text-center"
|
||||||
:class="{'template-option-disabled': !isAvailable(t)}"
|
:class="{'template-option-disabled': !isSelectable(t)}"
|
||||||
:title="isAvailable(t) ? '' : 'Not available - fill in the fields this layout needs above.'"
|
:title="unavailableReason(t)"
|
||||||
role="button" @click="isAvailable(t) && $emit('input', t.id)">
|
role="button" @click="isSelectable(t) && $emit('input', t.id)">
|
||||||
<canvas :ref="el => setTemplateCanvasRef(t.id, el)"
|
<div class="template-thumb-wrap">
|
||||||
class="img-thumbnail template-thumb-canvas"
|
<canvas :ref="el => setTemplateCanvasRef(t.id, el)"
|
||||||
:class="{'border-primary': value === t.id}"></canvas>
|
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>
|
||||||
<div class="small"
|
<div class="small"
|
||||||
:class="{'fw-bold text-primary': value === t.id}">
|
:class="{'fw-bold text-primary': value === t.id}">
|
||||||
{{ t.name }}
|
{{ t.name }}
|
||||||
|
<span v-for="tag in t.tags" :key="tag" class="badge bg-secondary">{{ tagLabel(tag) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="small text-muted">{{ t.description }}</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -24,8 +56,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* This build pins Bootstrap 4 (no gap-* utilities, those are Bootstrap 5.1+), so the spacing
|
/* This build pins Bootstrap 4 (no gap-* utilities, those are 5.1+) - hence plain CSS gap here. */
|
||||||
here is plain CSS gap rather than a Bootstrap gap-N class. */
|
|
||||||
.template-grid {
|
.template-grid {
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
@ -39,28 +70,70 @@
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.template-option .badge {
|
||||||
|
font-size: .65rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: .35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-thumb-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.template-thumb-canvas {
|
.template-thumb-canvas {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 10rem;
|
height: 10rem;
|
||||||
/* The canvas itself is drawn at whatever size fits its content (see redraw/drawFallbackLabel)
|
/* Canvas draws at whatever size fits its content (see redraw/drawFallbackLabel); object-fit
|
||||||
- object-fit scales that down to the thumbnail box the same way it would for an <img>,
|
scales it into the thumbnail box like it would an <img>, no manual zoom math needed. */
|
||||||
no manual zoom math needed. */
|
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
background: #fff;
|
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>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import {drawFallbackLabel, preloadQrEncoder} from "@/label.js";
|
import {drawLabel, drawFallbackLabel, preloadQrEncoder} from "@/label.js";
|
||||||
import {LABEL_TEMPLATES, templateIsAvailable, templateContent} from "@/label-layouts.js";
|
import {LABEL_TEMPLATES, LABEL_TAGS, templateIsAvailable, templateContent} from "@/label-layouts.js";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "LabelLayoutPreview",
|
name: "LabelLayoutPreview",
|
||||||
props: {
|
props: {
|
||||||
// Named content fields the templates draw from (see label.js's buildLabelFields)
|
// Named content fields templates draw from (see label.js's buildLabelFields), kept in
|
||||||
// - kept in sync by the parent, not owned here. A field missing from this object (rather
|
// sync by the parent. A field's absence (not just empty) means a template needing it is
|
||||||
// than present-but-empty) means a template that needs it is unavailable right now.
|
// unavailable.
|
||||||
fields: {
|
fields: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true
|
required: true
|
||||||
|
|
@ -70,12 +143,20 @@ export default {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
// Print.vue's global qr/micro-qr/rmqr choice (see label.js's QR_CODE_TYPES) - passed
|
// Ids of the most-recently printed/downloaded templates, most recent first (see
|
||||||
// straight through to drawFallbackLabel so these thumbnails match whatever symbology the
|
// Print.vue's rememberPrintedTemplate); bubbled to the front of the grid below.
|
||||||
// main preview is actually using.
|
recentTemplateIds: {
|
||||||
codeType: {
|
type: Array,
|
||||||
type: String,
|
default: () => []
|
||||||
default: "qr"
|
},
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
model: {
|
model: {
|
||||||
|
|
@ -83,20 +164,69 @@ export default {
|
||||||
event: "input"
|
event: "input"
|
||||||
},
|
},
|
||||||
emits: ["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: {
|
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() {
|
labelTemplates() {
|
||||||
return LABEL_TEMPLATES;
|
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));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
fields() {
|
fields() {
|
||||||
this.redraw();
|
this.redraw();
|
||||||
},
|
},
|
||||||
codeType() {
|
// 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() {
|
||||||
this.redraw();
|
this.redraw();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
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) {
|
setTemplateCanvasRef(id, el) {
|
||||||
if (el) {
|
if (el) {
|
||||||
this.templateCanvases[id] = el;
|
this.templateCanvases[id] = el;
|
||||||
|
|
@ -109,41 +239,88 @@ export default {
|
||||||
return templateIsAvailable(t, this.fields);
|
return templateIsAvailable(t, this.fields);
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Live per-template thumbnails. Always uses the content-fit fallback renderer (rather
|
// Greyed out (and unclickable) either because a needed field isn't filled in yet
|
||||||
than the tape-fed one), regardless of whether a real printer is connected - these are
|
// (isAvailable) or its content failed to render (see redraw()'s catch) - either way
|
||||||
illustrative previews sized by CSS object-fit, not the accurate to-be-printed canvas
|
// there's nothing a click could select that would actually show anything.
|
||||||
the main preview is. */
|
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.
|
||||||
redraw() {
|
redraw() {
|
||||||
for (const t of LABEL_TEMPLATES) {
|
for (const t of LABEL_TEMPLATES) {
|
||||||
const canvas = this.templateCanvases[t.id];
|
const canvas = this.templateCanvases[t.id];
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const content = templateContent(t, this.fields);
|
try {
|
||||||
if (!this.isAvailable(t) || !content) {
|
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.
|
||||||
canvas.width = 1;
|
canvas.width = 1;
|
||||||
canvas.height = 1;
|
canvas.height = 1;
|
||||||
continue;
|
this.failed[t.id] = {short: e.short ?? "error", message: e.message};
|
||||||
}
|
delete this.warned[t.id];
|
||||||
try {
|
delete this.qrInfo[t.id];
|
||||||
drawFallbackLabel(canvas, content, "along", this.codeType);
|
delete this.textInfo[t.id];
|
||||||
} catch (e) {
|
|
||||||
// A thumbnail that can't render at this content length just stays blank
|
|
||||||
// rather than surfacing an error for every keystroke.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
// Keyed by template id, populated by setTemplateCanvasRef() - a plain :ref="t.id" string
|
// Keyed by template id, populated by setTemplateCanvasRef() - a plain :ref="t.id" string
|
||||||
// inside v-for would still get Vue's refInFor array-collecting behavior even though
|
// in v-for still gets Vue's refInFor array-collecting behavior despite distinct names per
|
||||||
// each iteration uses a different name, turning this.$refs[t.id] into a one-element
|
// iteration, turning this.$refs[t.id] into a one-element array; a function ref avoids that.
|
||||||
// array rather than the canvas itself. A function ref sidesteps that entirely.
|
|
||||||
this.templateCanvases = {};
|
this.templateCanvases = {};
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
// See Print.vue's mounted() - every thumbnail here has a "qrcode" leaf, so there's
|
// See Print.vue's mounted() - every thumbnail here has a qr/mqr/rmqr leaf, so nothing's
|
||||||
// nothing worth drawing before this resolves.
|
// worth drawing before the wasm resource resolves.
|
||||||
await preloadQrEncoder();
|
await preloadQrEncoder();
|
||||||
this.redraw();
|
this.redraw();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,7 @@ export default {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = new Uint8Array(buffer);
|
const data = new Uint8Array(buffer);
|
||||||
// SHA-256 via Web Crypto - must match the backend's own content hash
|
// SHA-256 must match the backend's content hash for file identification. See docs/implementation.md#sha-256-file-hashing-must-match-the-backend.
|
||||||
// (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 hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||||
const hash = Array.from(new Uint8Array(hashBuffer))
|
const hash = Array.from(new Uint8Array(hashBuffer))
|
||||||
.map(b => b.toString(16).padStart(2, "0")).join("");
|
.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
|
|
||||||
|
|
@ -61,9 +61,7 @@ export default {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = new Uint8Array(buffer);
|
const data = new Uint8Array(buffer);
|
||||||
// SHA-256 via Web Crypto - must match the backend's own content hash
|
// SHA-256 must match the backend's content hash for file identification. See docs/implementation.md#sha-256-file-hashing-must-match-the-backend.
|
||||||
// (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 hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||||
const hash = Array.from(new Uint8Array(hashBuffer))
|
const hash = Array.from(new Uint8Array(hashBuffer))
|
||||||
.map(b => b.toString(16).padStart(2, "0")).join("");
|
.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,7 @@ export default {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = new Uint8Array(buffer);
|
const data = new Uint8Array(buffer);
|
||||||
// SHA-256 via Web Crypto - must match the backend's own content hash
|
// SHA-256 must match the backend's content hash for file identification. See docs/implementation.md#sha-256-file-hashing-must-match-the-backend.
|
||||||
// (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 hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||||
const hash = Array.from(new Uint8Array(hashBuffer))
|
const hash = Array.from(new Uint8Array(hashBuffer))
|
||||||
.map(b => b.toString(16).padStart(2, "0")).join("");
|
.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
|
|
||||||
|
|
@ -206,9 +206,7 @@ export default {
|
||||||
const mimeType = this.dataImage.split(';')[0].split(':')[1];
|
const mimeType = this.dataImage.split(';')[0].split(':')[1];
|
||||||
const data = this.dataImage.split(',')[1];
|
const data = this.dataImage.split(',')[1];
|
||||||
const raw_data = atob(data);
|
const raw_data = atob(data);
|
||||||
// SHA-256 via Web Crypto - must match the backend's own content hash (files/models.py,
|
// SHA-256 must match the backend's content hash for file identification. See docs/implementation.md#sha-256-file-hashing-must-match-the-backend.
|
||||||
// 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 bytes = Uint8Array.from(raw_data, c => c.charCodeAt(0));
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes);
|
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes);
|
||||||
const hash = Array.from(new Uint8Array(hashBuffer))
|
const hash = Array.from(new Uint8Array(hashBuffer))
|
||||||
|
|
|
||||||
|
|
@ -263,21 +263,10 @@
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
import { mapActions } from 'vuex';
|
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 {
|
export default {
|
||||||
name: 'BulkItemImportWorkflow',
|
name: 'BulkItemImportWorkflow',
|
||||||
// Metadata describing this workflow, co-located with its implementation
|
// Co-located workflow metadata, single source of truth per workflow type. See docs/implementation.md#workflow-meta-co-location.
|
||||||
// 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: {
|
meta: {
|
||||||
slug: 'import-items',
|
slug: 'import-items',
|
||||||
title: 'Bulk Item Import',
|
title: 'Bulk Item Import',
|
||||||
|
|
@ -426,15 +415,12 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
async analyzeFile(file) {
|
async analyzeFile(file) {
|
||||||
// Parsing happens entirely client-side. The backend never sees the
|
// Parsing happens entirely client-side; the backend only ever sees the resulting `items` list.
|
||||||
// raw file - only the structured `items` list produced here, sent
|
|
||||||
// later as generic payload/request body.
|
|
||||||
if (file.name.endsWith('.csv')) {
|
if (file.name.endsWith('.csv')) {
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
this.parsedRows = this.parseCsv(text);
|
this.parsedRows = this.parseCsv(text);
|
||||||
} else {
|
} else {
|
||||||
// For Excel files, you'd use a library like SheetJS to parse
|
// Mock implementation; a real Excel parser would use a library like SheetJS.
|
||||||
// client-side. This is a mock implementation.
|
|
||||||
this.detectedColumns = ['Name', 'Category', 'Quantity', 'Unit', 'Description'];
|
this.detectedColumns = ['Name', 'Category', 'Quantity', 'Unit', 'Description'];
|
||||||
this.parsedRows = [
|
this.parsedRows = [
|
||||||
{ Name: 'Sample Item 1', Category: 'Tools', Quantity: '1', Unit: 'piece', Description: 'Sample description' },
|
{ Name: 'Sample Item 1', Category: 'Tools', Quantity: '1', Unit: 'piece', Description: 'Sample description' },
|
||||||
|
|
@ -472,9 +458,7 @@ export default {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Convert the fully parsed rows + column mapping into the generic item
|
// Converts parsed rows + column mapping into item dicts for the "Import Items" step.
|
||||||
// dicts consumed by the "Import Items" step, which creates each one via
|
|
||||||
// the standard generic InventoryItem create endpoint.
|
|
||||||
buildItemsFromMapping() {
|
buildItemsFromMapping() {
|
||||||
return this.parsedRows.map(row => ({
|
return this.parsedRows.map(row => ({
|
||||||
name: this.columnMapping.name ? row[this.columnMapping.name] : '',
|
name: this.columnMapping.name ? row[this.columnMapping.name] : '',
|
||||||
|
|
@ -595,10 +579,7 @@ export default {
|
||||||
this.importError = null;
|
this.importError = null;
|
||||||
const created_item_ids = [];
|
const created_item_ids = [];
|
||||||
const errors = [];
|
const errors = [];
|
||||||
// Items were fully parsed client-side (File Upload step). The backend
|
// Each row is created via the same generic POST /api/inventory_items/ endpoint any client would use.
|
||||||
// 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()) {
|
for (const [index, row] of this.items.entries()) {
|
||||||
if (!row.name) {
|
if (!row.name) {
|
||||||
errors.push(`Row ${index + 1}: missing required "name" field, skipped.`);
|
errors.push(`Row ${index + 1}: missing required "name" field, skipped.`);
|
||||||
|
|
|
||||||
|
|
@ -890,11 +890,7 @@ export default {
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(['stageFile', 'unstageFile']),
|
...mapActions(['stageFile', 'unstageFile']),
|
||||||
loadFromPayload() {
|
loadFromPayload() {
|
||||||
// `photos`' durable state is the WorkflowInstance.staged_files relation itself (kept
|
// `photos` is seeded from workflowInstance.staged_files, not payload. See docs/implementation.md#staged-photos-are-the-durable-state.
|
||||||
// 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 => ({
|
this.photos = (this.workflowInstance.staged_files || []).map(hash => ({
|
||||||
hash,
|
hash,
|
||||||
name: null,
|
name: null,
|
||||||
|
|
@ -919,11 +915,7 @@ export default {
|
||||||
|
|
||||||
// --- Step 1: Photo capture ---
|
// --- Step 1: Photo capture ---
|
||||||
thumbnailPathForHash(hash, size = 256) {
|
thumbnailPathForHash(hash, size = 256) {
|
||||||
// files/media_urls.py's thumbnail_urls generates (and disk-caches) a resized JPEG
|
// Derived storage path for a disk-cached resized thumbnail. See docs/implementation.md#thumbnail-lookup-by-hash.
|
||||||
// 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)}/`;
|
return `/media/${size}/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash.slice(4, 6)}/${hash.slice(6)}/`;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -943,21 +935,14 @@ export default {
|
||||||
}));
|
}));
|
||||||
this.photos.push(...staged);
|
this.photos.push(...staged);
|
||||||
|
|
||||||
// Persist each photo server-side right away, keyed to this workflow instance, so it
|
// Persists each photo server-side immediately so it survives a reload/device switch. See docs/implementation.md#staged-photos-are-the-durable-state.
|
||||||
// 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}) => {
|
await Promise.all(staged.map(async ({hash, data, mime_type}) => {
|
||||||
try {
|
try {
|
||||||
await this.stageFile({
|
await this.stageFile({
|
||||||
lifetime_id: this.workflowInstance.id,
|
lifetime_id: this.workflowInstance.id,
|
||||||
file: {data, mime_type}
|
file: {data, mime_type}
|
||||||
});
|
});
|
||||||
// Once persisted, the gallery can show the server-fetched thumbnail instead
|
// 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.
|
||||||
// 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);
|
const photo = this.photos.find(p => p.hash === hash);
|
||||||
if (photo) photo.uploaded = true;
|
if (photo) photo.uploaded = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -1254,8 +1239,7 @@ export default {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stacks the local dataUrl preview and the server-fetched AuthenticatedImage on top of each
|
/* Stacks the dataUrl preview and server-fetched image during their crossfade so neither disappears before the other lays out. */
|
||||||
other during their crossfade, instead of one disappearing before the other lays out. */
|
|
||||||
.photo-thumb-wrap {
|
.photo-thumb-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 150px;
|
height: 150px;
|
||||||
|
|
|
||||||
|
|
@ -756,21 +756,10 @@
|
||||||
<script>
|
<script>
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
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 {
|
export default {
|
||||||
name: 'FotoFirstBulkImportWorkflow',
|
name: 'FotoFirstBulkImportWorkflow',
|
||||||
// Metadata describing this workflow, co-located with its implementation
|
// Co-located workflow metadata, single source of truth per workflow type. See docs/implementation.md#workflow-meta-co-location.
|
||||||
// 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: {
|
meta: {
|
||||||
slug: 'foto-first-bulk-import',
|
slug: 'foto-first-bulk-import',
|
||||||
title: 'Foto First Bulk Import',
|
title: 'Foto First Bulk Import',
|
||||||
|
|
|
||||||
|
|
@ -282,11 +282,9 @@ class ServerSet {
|
||||||
function ServerSetUnion(serverSets) {
|
function ServerSetUnion(serverSets) {
|
||||||
return new Proxy(serverSets, {
|
return new Proxy(serverSets, {
|
||||||
get: function (target, prop, receiver) {
|
get: function (target, prop, receiver) {
|
||||||
// Note: 'add' must be checked before the generic funcs-forwarding branch below,
|
// Must precede the generic forwarding check below: ServerSet.prototype also has its
|
||||||
// because ServerSet.prototype also defines its own `add(server)` method (for
|
// own add(server), so funcs.includes('add') would otherwise always be true and this
|
||||||
// adding a raw server address string to a single ServerSet). Without this check
|
// union-specific add would never run.
|
||||||
// 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') {
|
if (prop === 'add') {
|
||||||
return function (serverset) {
|
return function (serverset) {
|
||||||
if (!serverset || !(serverset instanceof ServerSet)) {
|
if (!serverset || !(serverset instanceof ServerSet)) {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,4 @@
|
||||||
// Shared, session-lifetime cache for the bytes behind AuthenticatedImage's `src` (a
|
// Session cache for hash-addressed image bytes, deliberately not Vuex. See docs/implementation.md#filecache-design-rationale.
|
||||||
// `/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
|
const MAX_BYTES = 150 * 1024 * 1024; // budget for decoded image bytes before evicting LRU entries
|
||||||
|
|
||||||
|
|
@ -18,8 +10,7 @@ class FileCache {
|
||||||
}
|
}
|
||||||
|
|
||||||
_touch(key) {
|
_touch(key) {
|
||||||
// Delete+re-insert moves this entry to the "most recently used" end of the
|
// Delete+re-insert moves this entry to the MRU end of the Map's iteration order.
|
||||||
// Map's iteration order, without needing a separate linked list.
|
|
||||||
const entry = this._entries.get(key);
|
const entry = this._entries.get(key);
|
||||||
this._entries.delete(key);
|
this._entries.delete(key);
|
||||||
this._entries.set(key, entry);
|
this._entries.set(key, entry);
|
||||||
|
|
@ -40,8 +31,7 @@ class FileCache {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetcher: () => Promise<Blob>. Called at most once per key even if many components
|
// fetcher: () => Promise<Blob>, called at most once per key even under concurrent callers.
|
||||||
// ask for the same key while the first request is still in flight.
|
|
||||||
async get(key, fetcher) {
|
async get(key, fetcher) {
|
||||||
if (this._entries.has(key)) {
|
if (this._entries.has(key)) {
|
||||||
this._touch(key);
|
this._touch(key);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,3 @@
|
||||||
// Embeds/extracts a handle in a URL path segment. See docs/federation.md's "Embedding a
|
// Escapes `#` as `+` for use in a URL path segment. See docs/implementation.md#escaping-hash-in-handles-for-url-path-segments.
|
||||||
// `#`-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.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,9 @@
|
||||||
// Each template's `layout` is a tree as described in label.js, with leaves whose `type` is one of
|
// A template's layout tree, content resolution, and selectability contract. See
|
||||||
// label.js's QR_LEAF_TYPES keys or "text", and whose `content` is a function from the resolved
|
// docs/implementation.md#template-layout-tree.
|
||||||
// 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"};
|
const GAP = {type: "empty", "min-width": "1mm", "min-height": "1mm"};
|
||||||
|
|
||||||
// The full "just the code" matrix: every {symbology, error-correction level, [rMQR] size
|
// Generated matrix of "just the code" templates covering every QR_LEAF_TYPES combo. See
|
||||||
// strategy} label.js's QR_LEAF_TYPES supports, one template each - a plain `id` (no suffix) is
|
// docs/implementation.md#generated-qr-only-template-matrix.
|
||||||
// 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 = [
|
const QR_ONLY_TEMPLATES = [
|
||||||
{
|
{
|
||||||
id: "qr-l", name: "QR code only (low error correction)",
|
id: "qr-l", name: "QR code only (low error correction)",
|
||||||
|
|
@ -87,14 +76,49 @@ const QR_ONLY_TEMPLATES = [
|
||||||
export const LABEL_TEMPLATES = [
|
export const LABEL_TEMPLATES = [
|
||||||
{
|
{
|
||||||
id: "mqr-token", name: "MQR Token", description: "The code with the encoded text printed next to it.",
|
id: "mqr-token", name: "MQR Token", description: "The code with the encoded text printed next to it.",
|
||||||
required_vars: ["shortId"],
|
required_vars: ["shortId"], tags: ["internal"],
|
||||||
layout: [{type: "mqr", content: c => c.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.",
|
id: "qr-url", name: "MQR Token", description: "The code with the encoded text printed next to it.",
|
||||||
required_vars: ["shortUrl"],
|
required_vars: ["shortUrl"], tags: ["external"],
|
||||||
layout: [{type: "qr-h", content: c => c.shortUrl}]
|
layout: [{type: "qr-h", content: c => c.shortUrl}]
|
||||||
},...QR_ONLY_TEMPLATES,
|
},
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
|
||||||
{
|
{
|
||||||
id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.",
|
id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.",
|
||||||
|
|
@ -111,7 +135,7 @@ export const LABEL_TEMPLATES = [
|
||||||
id: "id-qr-text-vertical", name: "ID + QR code + text below",
|
id: "id-qr-text-vertical", name: "ID + QR code + text below",
|
||||||
description: "The code with the encoded text printed below it.",
|
description: "The code with the encoded text printed below it.",
|
||||||
required_vars: ["itemId", "text", "userHandle"],
|
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",
|
type: "qr",
|
||||||
content: c => c.text
|
content: c => c.text
|
||||||
}, GAP, {type: "text", content: c => c.userHandle}]]
|
}, GAP, {type: "text", content: c => c.userHandle}]]
|
||||||
|
|
@ -124,66 +148,77 @@ export const LABEL_TEMPLATES = [
|
||||||
{
|
{
|
||||||
id: "item-handle", name: "Item handle",
|
id: "item-handle", name: "Item handle",
|
||||||
description: "The compact owner@domain:id handle - meaningful in-app, not scannable on its own.",
|
description: "The compact owner@domain:id handle - meaningful in-app, not scannable on its own.",
|
||||||
required_vars: ["itemHandle"],
|
required_vars: ["itemHandle"], tags: ["internal"],
|
||||||
layout: [{type: "text", content: c => c.itemHandle}]
|
layout: [{type: "text", content: c => c.itemHandle}]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "item-url", name: "Item URL",
|
id: "item-url", name: "Item URL",
|
||||||
description: "The full item URL as text, with no code - for copying rather than scanning.",
|
description: "The full item URL as text, with no code - for copying rather than scanning.",
|
||||||
required_vars: ["itemUrl"],
|
required_vars: ["itemUrl"], tags: ["external"],
|
||||||
layout: [{type: "text", content: c => c.itemUrl}]
|
layout: [{type: "text", content: c => c.itemUrl}]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "owner-handle", name: "Owner handle", description: "Just the owning user's handle, as text only.",
|
id: "owner-handle", name: "Owner handle", description: "Just the owning user's handle, as text only.",
|
||||||
required_vars: ["userHandle"],
|
required_vars: ["userHandle"], tags: ["internal"],
|
||||||
layout: [{type: "text", content: c => c.userHandle}]
|
layout: [{type: "text", content: c => c.userHandle}]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "item-id", name: "Item ID", description: "Just the bare item id, as text only.",
|
id: "item-id", name: "Item ID", description: "Just the bare item id, as text only.",
|
||||||
required_vars: ["itemId"],
|
required_vars: ["itemId"], tags: ["internal"],
|
||||||
layout: [{type: "text", content: c => c.itemId}]
|
layout: [{type: "text", content: c => c.itemId}]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "owner-id-text", name: "Owner + item ID",
|
id: "owner-id-text", name: "Owner + item ID",
|
||||||
description: "The owner's handle and the item id, as two lines of text - no code.",
|
description: "The owner's handle and the item id, as two lines of text - no code.",
|
||||||
required_vars: ["userHandle", "itemId"],
|
required_vars: ["userHandle", "itemId"], tags: ["internal"],
|
||||||
layout: [{type: "text", content: c => [c.userHandle, c.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",
|
id: "item-url-qr-handle", name: "Item URL + handle",
|
||||||
description: "Scannable item URL, with the item's compact handle printed alongside.",
|
description: "Scannable item URL, with the item's compact handle printed alongside.",
|
||||||
required_vars: ["itemUrl", "itemHandle"],
|
required_vars: ["itemUrl", "itemHandle"], tags: ["external"],
|
||||||
layout: [{type: "qr", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.itemHandle}]
|
layout: [{type: "qr", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.itemHandle}]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "item-url-qr-owner", name: "Item URL + owner",
|
id: "item-url-qr-owner", name: "Item URL + owner",
|
||||||
description: "Scannable item URL, with the owner's handle printed alongside.",
|
description: "Scannable item URL, with the owner's handle printed alongside.",
|
||||||
required_vars: ["itemUrl", "userHandle"],
|
required_vars: ["itemUrl", "userHandle"], tags: ["external"],
|
||||||
layout: [{type: "qr", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.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",
|
id: "item-url-qr-id", name: "Item URL + item ID",
|
||||||
description: "Scannable item URL, with the bare item id printed alongside.",
|
description: "Scannable item URL, with the bare item id printed alongside.",
|
||||||
required_vars: ["itemUrl", "itemId"],
|
required_vars: ["itemUrl", "itemId"], tags: ["external"],
|
||||||
layout: [{type: "qr", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.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",
|
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.",
|
description: "Scannable item URL, with the owner's handle and the item id on two lines alongside.",
|
||||||
required_vars: ["itemUrl", "userHandle", "itemId"],
|
required_vars: ["itemUrl", "userHandle", "itemId"], tags: ["external"],
|
||||||
layout: [{type: "qr", content: c => c.itemUrl}, GAP, {type: "text", content: c => [c.userHandle, c.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)",
|
id: "short-url-qr", name: "Short link (QR code)",
|
||||||
description: "Scannable short link for this item or storage location - more compact than "
|
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.",
|
+ "the full URL. Available for any element with a resolvable short link, not just items.",
|
||||||
required_vars: ["shortUrl"],
|
required_vars: ["shortUrl"], tags: ["external"],
|
||||||
layout: [{type: "qr", content: c => c.shortUrl}]
|
layout: [{type: "qr", content: c => c.shortUrl}]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "item-url-qr-owner-id2", name: "Item URL + owner + ID",
|
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.",
|
description: "Scannable item URL, with the owner's handle and the item id on two lines alongside.",
|
||||||
required_vars: ["itemUrl", "userHandle", "itemId"],
|
required_vars: ["itemUrl", "userHandle", "itemId"], tags: ["external"],
|
||||||
layout: [{type: "qr", content: c => c.itemUrl}, GAP, [{
|
layout: [{type: "qr", content: c => c.itemUrl}, GAP, [{
|
||||||
type: "text",
|
type: "text",
|
||||||
content: c => c.userHandle
|
content: c => c.userHandle
|
||||||
|
|
@ -191,55 +226,47 @@ 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.
|
// 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))];
|
export const KNOWN_VARS = [...new Set(LABEL_TEMPLATES.flatMap(t => t.required_vars))];
|
||||||
|
|
||||||
// A derived var is a format string calculated from other vars rather than typed directly - it
|
// DERIVED_VARS shape and declaration-order invariant. See
|
||||||
// doesn't get its own input, just a read-only, live-recalculated display next to the ones that
|
// docs/implementation.md#derived-vars-shape-and-ordering.
|
||||||
// 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 = {
|
export const DERIVED_VARS = {
|
||||||
// The full owner handle (see federation.md's Unique Handles section / ToolshedUser's
|
// Full user@domain handle, kept as separate user/domain base vars since that's how the
|
||||||
// separate username/domain columns) - kept as two base vars (user, domain) rather than one,
|
// account is actually shaped (see federation.md's Unique Handles); this is just the
|
||||||
// since that's how the account itself is actually shaped, with this just the display/URL form.
|
// display/URL form.
|
||||||
userHandle: {
|
userHandle: {
|
||||||
inputs: ["user", "domain"],
|
inputs: ["user", "domain"],
|
||||||
calc: (f) => `${f.user}@${f.domain}`,
|
calc: (f) => `${f.user}@${f.domain}`,
|
||||||
},
|
},
|
||||||
// The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a printed
|
// 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
|
// label encodes, since scanning it must resolve everything with no other context. `webdomain`
|
||||||
// with no other context, not just this browser's history. `webdomain` defaults to this
|
// defaults to this origin but is editable, since any frontend can resolve any handle.
|
||||||
// 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: {
|
itemUrl: {
|
||||||
inputs: ["webdomain", "userHandle", "itemId"],
|
inputs: ["webdomain", "userHandle", "itemId"],
|
||||||
calc: (f) => `${f.webdomain}/i/${f.userHandle}/${f.itemId}`,
|
calc: (f) => `${f.webdomain}/i/${f.userHandle}/${f.itemId}`,
|
||||||
},
|
},
|
||||||
// The compact "owner handle + id" form from docs/design-in-progress/items-labels.md -
|
// Compact "owner handle + id" form (see docs/design-in-progress/items-labels.md) - meaningful
|
||||||
// meaningful only where context already makes clear it's a Toolshed item, unlike itemUrl.
|
// only where context already makes clear it's a Toolshed item, unlike itemUrl.
|
||||||
itemHandle: {
|
itemHandle: {
|
||||||
inputs: ["userHandle", "itemId"],
|
inputs: ["userHandle", "itemId"],
|
||||||
calc: (f) => `${f.userHandle}:${f.itemId}`,
|
calc: (f) => `${f.userHandle}:${f.itemId}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// What Print.vue's content form offers a plain text input for: every KNOWN_VAR a template
|
// Text-input vars = template KNOWN_VARS minus derived ones, plus any var a DERIVED_VARS calc
|
||||||
// references directly, minus the derived ones, plus every var a DERIVED_VARS calculation itself
|
// needs (e.g. "webdomain") even if no template names it directly.
|
||||||
// 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([
|
export const BASE_VARS = [...new Set([
|
||||||
...KNOWN_VARS.filter(v => !(v in DERIVED_VARS)),
|
...KNOWN_VARS.filter(v => !(v in DERIVED_VARS)),
|
||||||
...Object.values(DERIVED_VARS).flatMap(d => d.inputs).filter(v => !(v in DERIVED_VARS)),
|
...Object.values(DERIVED_VARS).flatMap(d => d.inputs).filter(v => !(v in DERIVED_VARS)),
|
||||||
])];
|
])];
|
||||||
|
|
||||||
// Runs every DERIVED_VARS calculation against `fields` (already holding the base vars - see
|
// Runs each DERIVED_VARS calc against `fields`, adding the result wherever its inputs are
|
||||||
// Print.vue), returning a copy with each one's result added wherever all of its own inputs are
|
// present, so callers never need to know DERIVED_VARS' {inputs, calc} shape.
|
||||||
// present, so a caller never has to know DERIVED_VARS' internal {inputs, calc} shape.
|
|
||||||
export function withDerivedVars(fields) {
|
export function withDerivedVars(fields) {
|
||||||
const result = {...fields};
|
const result = {...fields};
|
||||||
for (const [name, {inputs, calc}] of Object.entries(DERIVED_VARS)) {
|
for (const [name, {inputs, calc}] of Object.entries(DERIVED_VARS)) {
|
||||||
|
|
@ -262,16 +289,14 @@ function mapTree(node, fn) {
|
||||||
return Array.isArray(node) ? node.map(child => mapTree(child, fn)) : fn(node);
|
return Array.isArray(node) ? node.map(child => mapTree(child, fn)) : fn(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A content leaf's resolved value counts as present only if every part of it is - a single
|
// Resolved only if every part is - a single string for a QR-family/"text" leaf, every line for a
|
||||||
// string for a QR-family leaf (any label.js QR_LEAF_TYPES entry) or plain "text", every line for a
|
// multi-line "text" (see "owner-id-text" and "item-url-qr-owner-id" above).
|
||||||
// multi-line "text" (see LABEL_TEMPLATES' "owner-id-text" and "item-url-qr-owner-id").
|
|
||||||
function isResolved(value) {
|
function isResolved(value) {
|
||||||
return Array.isArray(value) ? value.every(isResolved) : value !== undefined && value !== null;
|
return Array.isArray(value) ? value.every(isResolved) : value !== undefined && value !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// LabelLayoutPreview.vue's thumbnail grid and Print.vue's big preview both resolve a template
|
// Shared by LabelLayoutPreview.vue's grid and Print.vue's preview, so neither reimplements
|
||||||
// through these two functions rather than each re-implementing the leaf-walking/field-resolving
|
// leaf-walking/field-resolving itself.
|
||||||
// logic itself.
|
|
||||||
export function templateIsAvailable(t, fields) {
|
export function templateIsAvailable(t, fields) {
|
||||||
let available = true;
|
let available = true;
|
||||||
walkLeaves(t.layout, leaf => {
|
walkLeaves(t.layout, leaf => {
|
||||||
|
|
@ -282,16 +307,16 @@ export function templateIsAvailable(t, fields) {
|
||||||
return available;
|
return available;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Resolves a template's `content` functions against actual field values, turning its layout
|
// Resolves t's content leaves against fields into a tree for label.js's
|
||||||
tree into one ready for label.js's drawLabel/drawFallbackLabel - or null if there's nothing to
|
// drawLabel/drawFallbackLabel. Throws the same {message, short} shape as label.js's own
|
||||||
render yet (every leaf's value is still empty, e.g. before the user has typed anything). */
|
// 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.
|
||||||
export function templateContent(t, fields) {
|
export function templateContent(t, fields) {
|
||||||
const tree = mapTree(t.layout, leaf => leaf.type === "empty" ? leaf : {...leaf, value: leaf.content(fields)});
|
if (!templateIsAvailable(t, fields)) {
|
||||||
let hasContent = false;
|
const err = new Error("This layout needs more fields filled in before it can be drawn.");
|
||||||
walkLeaves(tree, leaf => {
|
err.short = "missing data";
|
||||||
if (leaf.type !== "empty" && leaf.value && (!Array.isArray(leaf.value) || leaf.value.some(Boolean))) {
|
throw err;
|
||||||
hasContent = true;
|
}
|
||||||
}
|
return mapTree(t.layout, leaf => leaf.type === "empty" ? leaf : {...leaf, value: leaf.content(fields)});
|
||||||
});
|
|
||||||
return hasContent ? tree : null;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,16 @@
|
||||||
import {loadAnyDCode} from "../vendor/anyd-qr.js";
|
import {loadAnyDCode} from "../vendor/anyd-qr.js";
|
||||||
import {encodeHandleForUrl} from "@/router"
|
import {encodeHandleForUrl} from "@/router"
|
||||||
|
|
||||||
// anyd-qr.js's own loadAnyDCode() memoizes the wasm instantiation itself, so calling it more
|
// Mirrors loadAnyDCode()'s memoized wasm instance for synchronous use in buildRenderTree. See docs/implementation.md#qr-encoder-loading.
|
||||||
// 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;
|
let anyd = null;
|
||||||
|
|
||||||
export function preloadQrEncoder() {
|
export function preloadQrEncoder() {
|
||||||
return loadAnyDCode().then(instance => { anyd = instance; });
|
return loadAnyDCode().then(instance => { anyd = instance; });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maps each of label-layouts.js's LABEL_TEMPLATES leaf types that draw a code to the anyd-qr.js
|
// Maps label-layouts.js leaf types to anyd-qr.js symbology/ecc/size options, via a naming
|
||||||
// symbology/error-correction level (and, for rMQR, size strategy) it renders as (see anyd's
|
// convention (plain id = anyd defaults; "-<name>" suffix names an ecc letter or rMQR size
|
||||||
// EncodeOptions - `ecc`/`size` - and its per-symbology EcLevel enums, `wasm.rs`'s
|
// strategy). See docs/implementation.md#qr-leaf-type-mapping.
|
||||||
// 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 = {
|
const QR_LEAF_TYPES = {
|
||||||
"qr-l": {codeType: "qr", ecc: "L"},
|
"qr-l": {codeType: "qr", ecc: "L"},
|
||||||
qr: {codeType: "qr", ecc: "M"},
|
qr: {codeType: "qr", ecc: "M"},
|
||||||
|
|
@ -56,16 +34,24 @@ function isQrLeaf(node) {
|
||||||
|
|
||||||
function encodeQr(text, codeType, options) {
|
function encodeQr(text, codeType, options) {
|
||||||
if (!anyd) {
|
if (!anyd) {
|
||||||
throw new Error("The QR encoder is still loading — try again in a moment.");
|
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;
|
||||||
}
|
}
|
||||||
// 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 */
|
const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */
|
||||||
|
|
@ -89,66 +75,46 @@ export function tapeFromStatus(status) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
// A layout tree alternates row/column split nodes by nesting depth, with QR/text/empty leaves.
|
||||||
A layout is a tree built from two shapes, alternating orientation by nesting depth:
|
// See docs/implementation.md#layout-tree-structure.
|
||||||
|
|
||||||
- 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 */
|
const TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural aspect ratio at */
|
||||||
|
|
||||||
// Below 10px, a general-purpose sans-serif gets blurry/illegible, so drawTextLeaf switches to one
|
// Below 10px, a general-purpose sans-serif gets illegible, so drawTextLeaf switches to a bitmap
|
||||||
// of these bitmap-style fonts instead (see ../scss/_pixel-fonts.scss) - Tom Thumb for the smallest
|
// font (Tom Thumb/Silkscreen) instead; see the empirical rationale (font choice, `scale`, and why
|
||||||
// sizes, Silkscreen once there's enough room for its more conventional letterforms.
|
// 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
|
||||||
// Both were chosen only after rendering single letters in a real browser *at raw canvas pixel
|
// "sans-serif" keyword, which resolves to a different real font per browser/OS and would make the
|
||||||
// sizes* and inspecting the actual pixels - checking that fillText was merely *called* doesn't
|
// same label print differently depending on where it was rendered from.
|
||||||
// confirm anything legible got drawn, and neither does a DPI-adjusted size that was never the
|
const FONT_TIERS = [
|
||||||
// number actually handed to ctx.font. Silkscreen confirmed clean at 8px+. A third candidate,
|
{belowPx: 8, family: "Tom Thumb", scale: 3.2, pixel: true},
|
||||||
// PICO-8, also rendered cleanly across the whole range, but has no lowercase glyphs at all - it
|
{belowPx: 10, family: "Silkscreen", pixel: true},
|
||||||
// silently draws lowercase input as uppercase - which rules it out for real label content (item
|
{belowPx: Infinity, family: "Inter"},
|
||||||
// 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) {
|
function fontFamilyFor(fontPx) {
|
||||||
return PIXEL_FONT_TIERS.find(t => fontPx < t.belowPx) ?? {family: "sans-serif"};
|
return FONT_TIERS.find(t => fontPx < t.belowPx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tom Thumb (see PIXEL_FONT_TIERS above) held up down to 5px in the same real-Chromium pixel-level
|
// Below this font size (px) or physical height (mm) - Tom Thumb's real-Chromium-tested legibility
|
||||||
// verification - a plain sans-serif this small would fail the old MIN_READABLE_TEXT_PX=8 floor
|
// floor - text is a hard failure, the same two-metric shape as MIN_RECOMMENDED_QR_PX_PER_MODULE/
|
||||||
// that predates it. Below this, drawTextLeaf leaves that one field blank rather than drawing
|
// MIN_RECOMMENDED_QR_MODULE_MM below (see checkTextSizes). No longer just silently blanked.
|
||||||
// illegible ink - see there for why that's a quieter failure than rejecting the whole label over
|
const MIN_TEXT_PX = 5;
|
||||||
// it.
|
const MIN_TEXT_MM = 0.5;
|
||||||
const MIN_READABLE_TEXT_PX = 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;
|
||||||
|
|
||||||
function isSplit(node) {
|
function isSplit(node) {
|
||||||
return Array.isArray(node);
|
return Array.isArray(node);
|
||||||
|
|
@ -162,20 +128,9 @@ function parseMm(value, key) {
|
||||||
return parseFloat(match[1]);
|
return parseFloat(match[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Every node's width/height relate to each other affinely - width = A*height + B for a node
|
// Every node's width/height relate affinely (width = A*height + B, or symmetrically); `ownAxis`
|
||||||
read in row context, height = A*width + B in column context - because a leaf is either
|
// and `wantWidth` pick which direction and against which split axis. See
|
||||||
scale-free (a text block, whose aspect ratio holds at any size: A = aspect or 1/aspect,
|
// docs/implementation.md#affine-width-height-relations.
|
||||||
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) {
|
function relation(node, ownAxis, wantWidth, pxPerMm) {
|
||||||
if (!isSplit(node)) {
|
if (!isSplit(node)) {
|
||||||
if (isQrLeaf(node) && node.crispWidth !== undefined) {
|
if (isQrLeaf(node) && node.crispWidth !== undefined) {
|
||||||
|
|
@ -198,23 +153,16 @@ function relation(node, ownAxis, wantWidth, pxPerMm) {
|
||||||
return {a, b};
|
return {a, b};
|
||||||
}
|
}
|
||||||
if (a === 0) {
|
if (a === 0) {
|
||||||
// Every child is a fixed size (a === 0) in the combining direction - e.g. a row that's
|
// Every child is fixed-size (a === 0) in the combining direction, so inverting would
|
||||||
// just one crisp QR leaf, with no scale-free (text) sibling to invert against. Inverting
|
// divide by zero. See docs/implementation.md#fixed-size-relation-edge-case.
|
||||||
// "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));
|
const otherParts = node.map(child => relation(child, axis, wantWidth, pxPerMm));
|
||||||
return {a: 0, b: Math.max(...otherParts.map(p => p.b))};
|
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
|
return {a: 1 / a, b: -b / a}; // invert: solve the affine relation the other way
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Top-down: given the fixed (width, height) box `node` must exactly fill, assigns that box to
|
// Top-down: assigns the fixed (width, height) box `node` must exactly fill, recursively, to
|
||||||
it and, recursively, an appropriately-shaped box to every descendant. `ownAxis` carries the
|
// every descendant; `ownAxis` carries the same meaning as in relation() above.
|
||||||
same meaning as in relation() above. */
|
|
||||||
function layoutTree(node, ownAxis, width, height, pxPerMm) {
|
function layoutTree(node, ownAxis, width, height, pxPerMm) {
|
||||||
node.box = {width, height};
|
node.box = {width, height};
|
||||||
if (!isSplit(node)) {
|
if (!isSplit(node)) {
|
||||||
|
|
@ -232,9 +180,8 @@ function layoutTree(node, ownAxis, width, height, pxPerMm) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Second top-down pass: turns each node's already-sized box into an absolute (x, y) position,
|
// Second top-down pass: turns each already-sized box into an absolute (x, y) position; kept
|
||||||
placing a row's children left to right and a column's top to bottom. Kept separate from
|
// separate from layoutTree since a node's size doesn't depend on its position.
|
||||||
layoutTree since a node's size doesn't depend on its position, only on its box dimensions. */
|
|
||||||
function positionTree(node, ownAxis, x, y) {
|
function positionTree(node, ownAxis, x, y) {
|
||||||
node.box.x = x;
|
node.box.x = x;
|
||||||
node.box.y = y;
|
node.box.y = y;
|
||||||
|
|
@ -254,56 +201,113 @@ function positionTree(node, ownAxis, x, y) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A QR code needs an integer number of pixels per module to render crisply rather than blurring
|
// Pins each QR-family leaf's real crisp-pixel box.width/box.height so relation() above starts
|
||||||
at a fractional scale, so its true size is whatever that rounds down to - almost never the
|
// treating it as fixed-size. Appends a {short, message} entry to `warnings` for each of
|
||||||
scale-free box its aspect ratio alone would suggest. Called once every QR-family leaf has a
|
// MIN_RECOMMENDED_QR_PX_PER_MODULE/MIN_RECOMMENDED_QR_MODULE_MM the leaf falls short of (still
|
||||||
provisional (scale-free) box from a first layoutTree pass, this pins each one's real
|
// printable, just flagged as a scan-reliability risk), and unconditionally appends its raw
|
||||||
box.width/box.height as `crispWidth`/`crispHeight`, so relation() above starts treating it as a
|
// {scale, moduleMm} to `qrInfo` - callers needing to show those numbers regardless of whether
|
||||||
fixed size, the same as an "empty" leaf, instead of one that scales with whatever height/width
|
// they tripped a threshold (see LabelLayoutPreview.vue's debug line) shouldn't have to re-derive
|
||||||
it's offered. A second relation()/layoutTree() pass (see layoutContent) then resizes everything
|
// them. See docs/implementation.md#crisp-qr-sizing.
|
||||||
else around that real footprint, so nothing downstream reserves - and leaves unfilled - room
|
function snapQrToCrispSize(node, pxPerMm, warnings, qrInfo) {
|
||||||
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)) {
|
if (isSplit(node)) {
|
||||||
node.forEach(snapQrToCrispSize);
|
node.forEach(child => snapQrToCrispSize(child, pxPerMm, warnings, qrInfo));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isQrLeaf(node)) {
|
if (isQrLeaf(node)) {
|
||||||
const {width: modulesW, height: modulesH} = node.qr;
|
const {width: modulesW, height: modulesH} = node.qr;
|
||||||
const scale = Math.floor(Math.min(node.box.width / modulesW, node.box.height / modulesH));
|
const scale = Math.floor(Math.min(node.box.width / modulesW, node.box.height / modulesH));
|
||||||
if (!(scale >= 1)) {
|
if (!(scale >= 1)) {
|
||||||
throw new Error("This text needs a bigger code than the tape allows — "
|
const err = new Error("This text needs a bigger code than the tape allows — "
|
||||||
+ "try a shorter value or a wider tape.");
|
+ "try a shorter value or a wider tape.");
|
||||||
|
err.short = "too big";
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
node.crispWidth = modulesW * scale;
|
node.crispWidth = modulesW * scale;
|
||||||
node.crispHeight = modulesH * 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.",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always measures in sans-serif at the fixed reference size, even though drawTextLeaf may end up
|
// The rendered font size, floored to a whole pixel - same reasoning as snapQrToCrispSize's
|
||||||
// actually drawing in one of PIXEL_FONT_TIERS' fonts - the final effective size (and so which
|
// integer module scale (rounding up could overflow the box a fractional size would have fit
|
||||||
// font applies) isn't known until layoutTree has already sized the box this aspect ratio feeds
|
// exactly). checkTextSizes and drawTextLeaf both call this rather than each computing their own
|
||||||
// into. The pixel fonts are close enough in proportion for basic Latin/digits that the tiny
|
// raw fraction, so what gets measured/threshold-checked is exactly what gets drawn.
|
||||||
// resulting mismatch is invisible in practice at these sizes, and the MIN_READABLE_TEXT_PX check
|
function effectiveFontPx(node, referencePx) {
|
||||||
// still catches anything that genuinely doesn't fit.
|
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.
|
||||||
function measureTextBlock(ctx, lines, referencePx) {
|
function measureTextBlock(ctx, lines, referencePx) {
|
||||||
ctx.font = `${referencePx}px sans-serif`;
|
ctx.font = `${referencePx}px "Inter"`;
|
||||||
const width = Math.max(...lines.map(line => ctx.measureText(line).width));
|
const width = Math.max(...lines.map(line => ctx.measureText(line).width));
|
||||||
const height = referencePx * 1.15 * lines.length;
|
const height = referencePx * 1.15 * lines.length;
|
||||||
return {width, height};
|
return {width, height};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Turns a resolved content tree (see templateContent below - leaf objects carry a `value`
|
// Converts a resolved content tree (see templateContent) into one ready for layout. See
|
||||||
rather than a `content` function) into one ready for layout: a QR-family leaf gets its
|
// docs/implementation.md#render-tree-construction.
|
||||||
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) {
|
function buildRenderTree(ctx, node, referencePx) {
|
||||||
if (isSplit(node)) {
|
if (isSplit(node)) {
|
||||||
return node.map(child => buildRenderTree(ctx, child, referencePx));
|
return node.map(child => buildRenderTree(ctx, child, referencePx));
|
||||||
|
|
@ -337,54 +341,34 @@ function drawQrLeaf(ctx, node) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the effective (raw, un-normalized) font size drawn at - or that would have been, if
|
// Returns the effective (un-normalized) font size drawn at; drawTree collects these into
|
||||||
// it's too small to draw, see below - drawTree collects these into drawLabel/drawFallbackLabel's
|
// drawLabel/drawFallbackLabel's textSizesPx. Always draws - checkTextSizes (run earlier, on the
|
||||||
// textSizesPx.
|
// 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.
|
||||||
function drawTextLeaf(ctx, node, referencePx) {
|
function drawTextLeaf(ctx, node, referencePx) {
|
||||||
const fontPx = referencePx * (node.box.height / node.naturalHeight);
|
const fontPx = effectiveFontPx(node, referencePx);
|
||||||
// Even the smallest PIXEL_FONT_TIERS entry stops being legible below this - rather than
|
const {family, scale = 1, pixel: isPixelFont = false} = fontFamilyFor(fontPx);
|
||||||
// reject the whole label over one field that's too small (the old behavior), just leave this
|
// Position (not size - fontPx is already a whole pixel) still needs snapping for pixel fonts
|
||||||
// leaf blank; its box was already accounted for, so nothing else in the layout shifts.
|
// to stay grid-aligned, since node.box.x/y are ordinary (fractional) layout math; Inter is
|
||||||
if (fontPx < MIN_READABLE_TEXT_PX) {
|
// left exact since anti-aliasing handles fractional positions fine.
|
||||||
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;
|
const snap = isPixelFont ? Math.round : (v) => v;
|
||||||
const drawFontPx = snap(fontPx);
|
// `scale` (Tom Thumb only, see FONT_TIERS above) corrects the size handed to ctx.font for its
|
||||||
// `scale` (Tom Thumb only, see PIXEL_FONT_TIERS above) corrects for a font whose declared
|
// real ink; fontPx itself stays the logical size used for centering/stacking math.
|
||||||
// size doesn't match its real visible ink - the size actually handed to ctx.font, not
|
ctx.font = `${fontPx * scale}px "${family}"`;
|
||||||
// drawFontPx itself, which stays the logical size everything else here (box centering, line
|
// Canvas text silently falls back if drawn before a not-yet-loaded font resolves, unlike DOM
|
||||||
// stacking) is measured against.
|
// text. See docs/implementation.md#canvas-font-loading. Every tier now names a real,
|
||||||
ctx.font = `${drawFontPx * scale}px "${family}"`;
|
// self-hosted webfont (see FONT_TIERS above), so this always applies, not just to pixel fonts.
|
||||||
// A @font-face family already in use elsewhere on the page loads in time for this, but canvas
|
document.fonts.load(ctx.font);
|
||||||
// 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";
|
ctx.textAlign = "center";
|
||||||
const centerX = snap(node.box.x + node.box.width / 2);
|
const centerX = snap(node.box.x + node.box.width / 2);
|
||||||
const lineHeight = node.box.height / node.lines.length;
|
const lineHeight = node.box.height / node.lines.length;
|
||||||
// Lines stack as a block, each centered under the last - keeps a multi-line field reading as
|
// Lines stack as a block, each centered under the last, so a multi-line field reads as one unit.
|
||||||
// one unit rather than drifting apart.
|
|
||||||
let sliceTop = node.box.y;
|
let sliceTop = node.box.y;
|
||||||
for (const line of node.lines) {
|
for (const line of node.lines) {
|
||||||
if (isPixelFont) {
|
if (isPixelFont) {
|
||||||
// textBaseline:"middle" centers on the font's *declared* ascent/descent (its line
|
// textBaseline:"middle" centers on declared ascent/descent, which is backwards for
|
||||||
// height) - Tom Thumb's are backwards (see PIXEL_FONT_TIERS above) and would center on
|
// Tom Thumb; centering on actualBoundingBox{Ascent,Descent} instead measures this
|
||||||
// nonsense. Centering on actualBoundingBox{Ascent,Descent} instead - this specific
|
// string's real rendered ink and stays correct regardless.
|
||||||
// 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";
|
ctx.textBaseline = "alphabetic";
|
||||||
const {actualBoundingBoxAscent: up, actualBoundingBoxDescent: down} = ctx.measureText(line);
|
const {actualBoundingBoxAscent: up, actualBoundingBoxDescent: down} = ctx.measureText(line);
|
||||||
ctx.fillText(line, centerX, snap(sliceTop + (lineHeight + up - down) / 2));
|
ctx.fillText(line, centerX, snap(sliceTop + (lineHeight + up - down) / 2));
|
||||||
|
|
@ -397,26 +381,22 @@ function drawTextLeaf(ctx, node, referencePx) {
|
||||||
return fontPx;
|
return fontPx;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flip this to true (in a debugger or a local edit) to outline every leaf's box - including
|
// Manual debug toggle (flip in a debugger) to outline every leaf's box, including
|
||||||
// "empty" ones, normally invisible - in a color that can't be mistaken for real label ink. Handy
|
// normally-invisible "empty" ones, in a color that can't be mistaken for real label ink; never
|
||||||
// for checking a layout's actual padding/alignment; never wanted on a real printed label, so it's
|
// wired up to any UI.
|
||||||
// a manual toggle rather than something wired up to any UI.
|
|
||||||
let DEBUG_LEAF_BORDERS = false;
|
let DEBUG_LEAF_BORDERS = false;
|
||||||
|
|
||||||
function drawDebugBorder(ctx, node) {
|
function drawDebugBorder(ctx, node) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.strokeStyle = "red";
|
ctx.strokeStyle = "red";
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
// Inset by half a pixel so the 1px stroke lands crisply on-pixel instead of straddling the
|
// Inset by half a pixel so the 1px stroke lands crisply on-pixel instead of straddling the edge.
|
||||||
// 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.strokeRect(node.box.x + 0.5, node.box.y + 0.5, node.box.width - 1, node.box.height - 1);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
// `textSizesPx` collects each "text" leaf's effective font size as drawTree walks the tree - see
|
// Collects each text leaf's effective font size so callers (Print.vue) can spot a suspiciously
|
||||||
// drawLabel/drawFallbackLabel, which hand it back to the caller (Print.vue shows it alongside the
|
// small field (checkTextSizes' warnings/textInfo cover the same numbers in more structured form).
|
||||||
// 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) {
|
function drawTree(ctx, node, referencePx, textSizesPx) {
|
||||||
if (isSplit(node)) {
|
if (isSplit(node)) {
|
||||||
node.forEach(child => drawTree(ctx, child, referencePx, textSizesPx));
|
node.forEach(child => drawTree(ctx, child, referencePx, textSizesPx));
|
||||||
|
|
@ -433,27 +413,9 @@ function drawTree(ctx, node, referencePx, textSizesPx) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Builds, sizes and validates the tree for a fixed `fixedSize` (the tape's cross-web printAreaPx,
|
// Builds, sizes and validates the tree for a fixed dimension plus pxPerMm; runs sizing twice so
|
||||||
or the fallback preview's reference height) - the one dimension every layout scales from, plus
|
// QR-family leaves' real crisp size is known before the tree is finally resolved, then checks
|
||||||
`pxPerMm` to turn "empty" leaves' physical sizes into pixels. `fixedSize` and the tree's content
|
// every text leaf's final font size. See docs/implementation.md#label-content-layout.
|
||||||
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) {
|
function layoutContent(ctx, content, fixedSize, maxLength, referencePx, pxPerMm, orientation) {
|
||||||
const tree = buildRenderTree(ctx, content, referencePx);
|
const tree = buildRenderTree(ctx, content, referencePx);
|
||||||
const alongTape = orientation !== "across";
|
const alongTape = orientation !== "across";
|
||||||
|
|
@ -467,35 +429,38 @@ function layoutContent(ctx, content, fixedSize, maxLength, referencePx, pxPerMm,
|
||||||
|
|
||||||
let {width, height} = solve();
|
let {width, height} = solve();
|
||||||
layoutTree(tree, false, width, height, pxPerMm);
|
layoutTree(tree, false, width, height, pxPerMm);
|
||||||
snapQrToCrispSize(tree);
|
const warnings = [];
|
||||||
|
const qrInfo = [];
|
||||||
|
snapQrToCrispSize(tree, pxPerMm, warnings, qrInfo);
|
||||||
|
|
||||||
({width, height} = solve());
|
({width, height} = solve());
|
||||||
const length = alongTape ? width : height;
|
const length = alongTape ? width : height;
|
||||||
if (maxLength !== Infinity && length > maxLength) {
|
if (maxLength !== Infinity && length > maxLength) {
|
||||||
throw new Error("This doesn't fit on this tape — "
|
const err = new Error("This doesn't fit on this tape — "
|
||||||
+ "try a shorter value, a different layout, or a bigger label.");
|
+ "try a shorter value, a different layout, or a bigger label.");
|
||||||
|
err.short = "too big";
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
layoutTree(tree, false, width, height, pxPerMm);
|
layoutTree(tree, false, width, height, pxPerMm);
|
||||||
return {tree, length};
|
const textInfo = [];
|
||||||
|
checkTextSizes(tree, referencePx, pxPerMm, warnings, textInfo);
|
||||||
|
return {tree, length, warnings, qrInfo, textInfo};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The tape-fed layout - draws a fully resolved content tree (see templateContent) at the tape's
|
// 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
|
// real pixel dimensions. See docs/implementation.md#tape-fed-label-drawing. Returns
|
||||||
along the tape's feed direction, or "across" to turn it 90deg so it reads across the tape
|
// {textSizesPx}: each "text" leaf's effective font size, in the tree's own left-to-right,
|
||||||
instead - either way the physical raster this returns is still exactly
|
// top-to-bottom order; {warnings}: {short, message} scan-reliability entries from
|
||||||
printedLength x tape.printAreaPx (that's fixed by the tape/print head, not a choice this
|
// snapQrToCrispSize/checkTextSizes, if any; {qrInfo}: each QR-family leaf's raw
|
||||||
makes); "across" just draws the (now width-fixed, see layoutContent) tree through a rotated
|
// {scale, moduleMm}; {textInfo}: each text leaf's raw {fontPx, fontMm} - both regardless of
|
||||||
canvas transform so it lands correctly in that same raster, rather than transposing every box
|
// whether they tripped a warning.
|
||||||
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") {
|
export function drawLabel(canvas, tape, content, orientation = "along") {
|
||||||
const maxLength = tape.printLengthPx
|
const maxLength = tape.printLengthPx
|
||||||
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
|
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
|
||||||
: Infinity;
|
: Infinity;
|
||||||
const measureCtx = canvas.getContext("2d");
|
const measureCtx = canvas.getContext("2d");
|
||||||
const pxPerMm = tape.dpi / 25.4;
|
const pxPerMm = tape.dpi / 25.4;
|
||||||
const {tree, length: contentLength} = layoutContent(
|
const {tree, length: contentLength, warnings, qrInfo, textInfo} = layoutContent(
|
||||||
measureCtx, content, tape.printAreaPx, maxLength, TEXT_REFERENCE_PX, pxPerMm, orientation);
|
measureCtx, content, tape.printAreaPx, maxLength, TEXT_REFERENCE_PX, pxPerMm, orientation);
|
||||||
|
|
||||||
const printedLength = tape.printLengthPx || Math.ceil(contentLength + tape.leadPx + TRAILING_PADDING_PX);
|
const printedLength = tape.printLengthPx || Math.ceil(contentLength + tape.leadPx + TRAILING_PADDING_PX);
|
||||||
|
|
@ -511,10 +476,8 @@ export function drawLabel(canvas, tape, content, orientation = "along") {
|
||||||
+ Math.floor((printedLength - tape.leadPx - TRAILING_PADDING_PX - contentLength) / 2);
|
+ Math.floor((printedLength - tape.leadPx - TRAILING_PADDING_PX - contentLength) / 2);
|
||||||
const textSizesPx = [];
|
const textSizesPx = [];
|
||||||
if (orientation === "across") {
|
if (orientation === "across") {
|
||||||
// The tree was solved width-fixed (see layoutContent) - its width already exactly fills
|
// Rotates/translates the width-fixed tree into the physical raster a quarter turn at a
|
||||||
// tape.printAreaPx, so only its (growing) height needs the same along-the-feed centering
|
// time. See docs/implementation.md#across-orientation-rotation.
|
||||||
// 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);
|
positionTree(tree, false, 0, origin);
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(0, tape.printAreaPx);
|
ctx.translate(0, tape.printAreaPx);
|
||||||
|
|
@ -525,21 +488,19 @@ export function drawLabel(canvas, tape, content, orientation = "along") {
|
||||||
positionTree(tree, false, origin, 0);
|
positionTree(tree, false, origin, 0);
|
||||||
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
|
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
|
||||||
}
|
}
|
||||||
return {textSizesPx};
|
return {textSizesPx, warnings, qrInfo, textInfo};
|
||||||
}
|
}
|
||||||
|
|
||||||
const FALLBACK_LABEL_HEIGHT_PX = 200; /* reference height the no-webusb preview/PNG scales from */
|
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 */
|
const FALLBACK_DPI = 203; /* reference resolution for turning "empty" leaves' mm sizes into px */
|
||||||
|
|
||||||
/* The no-webusb preview/PNG - same layout tree and renderer as drawLabel, just scaled from a
|
// The no-webusb preview/PNG: same layout tree/renderer as drawLabel, scaled from a fixed
|
||||||
fixed reference height instead of a real tape's, and with no maxLength (there's no physical
|
// reference height instead. See docs/implementation.md#fallback-label-preview. `orientation` and
|
||||||
tape to run out of, so the canvas just grows to fit) and no printer feed margin, since there's
|
// the {textSizesPx, warnings, qrInfo, textInfo} return, see drawLabel.
|
||||||
no real print head here to keep clear of. `orientation`, see drawLabel. Returns {textSizesPx},
|
|
||||||
see drawLabel. */
|
|
||||||
export function drawFallbackLabel(canvas, content, orientation = "along") {
|
export function drawFallbackLabel(canvas, content, orientation = "along") {
|
||||||
const measureCtx = canvas.getContext("2d");
|
const measureCtx = canvas.getContext("2d");
|
||||||
const pxPerMm = FALLBACK_DPI / 25.4;
|
const pxPerMm = FALLBACK_DPI / 25.4;
|
||||||
const {tree, length: contentLength} = layoutContent(
|
const {tree, length: contentLength, warnings, qrInfo, textInfo} = layoutContent(
|
||||||
measureCtx, content, FALLBACK_LABEL_HEIGHT_PX, Infinity, TEXT_REFERENCE_PX, pxPerMm, orientation);
|
measureCtx, content, FALLBACK_LABEL_HEIGHT_PX, Infinity, TEXT_REFERENCE_PX, pxPerMm, orientation);
|
||||||
|
|
||||||
canvas.width = Math.ceil(contentLength);
|
canvas.width = Math.ceil(contentLength);
|
||||||
|
|
@ -562,25 +523,18 @@ export function drawFallbackLabel(canvas, content, orientation = "along") {
|
||||||
positionTree(tree, false, 0, 0);
|
positionTree(tree, false, 0, 0);
|
||||||
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
|
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
|
||||||
}
|
}
|
||||||
return {textSizesPx};
|
return {textSizesPx, warnings, qrInfo, textInfo};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Turns a {kind, components} prefill (see Print.vue's `prefill` prop) into the literal string a
|
// Turns a {kind, components} prefill (see Print.vue's `prefill` prop) into the literal string a
|
||||||
// print label should show/encode. Keeping this keyed by `kind` rather than having each caller
|
// print label should show/encode; keyed by `kind` so each kind's format is defined in one place.
|
||||||
// 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 = {
|
export const LABEL_CONTENT_BUILDERS = {
|
||||||
// The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a
|
// The self-contained Item URL (see docs/design-in-progress/items-labels.md), built from just
|
||||||
// printed label actually encodes, since scanning it has to resolve the right
|
// the prefill's {userHandle, item}; the short link (Print.vue's `shortUrl`) needs an async store
|
||||||
// frontend/backend/item with no other context, not just this browser's history. Nothing here
|
// lookup, so it stays a separate field/template rather than being baked in here.
|
||||||
// needs anything beyond the prefill's own {userHandle, id} - the short link (see Print.vue's
|
"item": ({userHandle, item}) => `${window.location.origin}/i/${encodeHandleForUrl(userHandle)}/${item}`,
|
||||||
// `shortUrl` computed) needs a store lookup no synchronous builder can do, so it's never baked
|
// Storage locations have no long-form URL route yet (see router.js), so `text` starts blank;
|
||||||
// into `text` this way; it's just another field/template a user can pick once the page is up.
|
// the short link and any future location template still work via the base vars below.
|
||||||
"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) {
|
export function buildLabelContent(prefill) {
|
||||||
|
|
@ -591,10 +545,9 @@ export function buildLabelContent(prefill) {
|
||||||
return build ? build(prefill.components) : "";
|
return build ? build(prefill.components) : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// A prefill's {userHandle, id} is the same raw identity for either resource kind below - this
|
// Splits a prefill's {userHandle, item/location} into label-layouts.js's user/domain base vars (the same way
|
||||||
// just splits the handle into label-layouts.js's separate `user`/`domain` base vars the same way
|
// store.js's lookupServer does), tagging on whichever id field the resource's templates key
|
||||||
// store.js's own lookupServer does, and tags on whichever id field the resource's own templates
|
// required_vars by.
|
||||||
// key their required_vars by.
|
|
||||||
function splitUserHandle(userHandle) {
|
function splitUserHandle(userHandle) {
|
||||||
if (!userHandle) {
|
if (!userHandle) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -606,27 +559,23 @@ function splitUserHandle(userHandle) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seeds for the *base* label-layouts.js vars (see BASE_VARS there) - keyed by `kind` for the same
|
// Seeds label-layouts.js's BASE_VARS, keyed by `kind`; derived vars (userHandle, itemUrl, …) are
|
||||||
// reason LABEL_CONTENT_BUILDERS is. Format-string vars derived from these (userHandle, itemUrl,
|
// computed live elsewhere (see DERIVED_VARS, Print.vue's `shortUrl`), and omitting a field
|
||||||
// itemHandle, …) aren't built here; they're calculated live from whatever the base vars currently
|
// (rather than leaving it present-but-empty) signals "not available" to templateIsAvailable.
|
||||||
// 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 = {
|
const LABEL_FIELD_BUILDERS = {
|
||||||
"item": ({userHandle, id}) => {
|
"item": ({userHandle, item}) => {
|
||||||
const split = splitUserHandle(userHandle);
|
const split = splitUserHandle(userHandle);
|
||||||
if (!split || !id) {
|
if (!split || !item) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return {...split, itemId: String(id)};
|
return {...split, itemId: String(item)};
|
||||||
},
|
},
|
||||||
"storage-location": ({userHandle, id}) => {
|
"storage-location": ({userHandle, location}) => {
|
||||||
const split = splitUserHandle(userHandle);
|
const split = splitUserHandle(userHandle);
|
||||||
if (!split || !id) {
|
if (!split || !location) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return {...split, locationId: String(id)};
|
return {...split, locationId: String(location)};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
66
frontend/src/printerManager.js
Normal file
66
frontend/src/printerManager.js
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
// 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();
|
||||||
|
|
@ -46,9 +46,7 @@ export function decodeHandleFromUrl(segment) {
|
||||||
return segment.replace(/\+/g, "#");
|
return segment.replace(/\+/g, "#");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both item-ish kinds land on the same /inventory/:handle/:id shape - only how they resolve
|
// item/group_item share this /inventory/:handle/:id shape; only owner-handle resolution (identity vs. group, see store.js) differs.
|
||||||
// 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) {
|
function itemDetailRoute(handle, item_local_id) {
|
||||||
return handle ? `/inventory/${encodeHandleForUrl(handle)}/${item_local_id}` : null;
|
return handle ? `/inventory/${encodeHandleForUrl(handle)}/${item_local_id}` : null;
|
||||||
}
|
}
|
||||||
|
|
@ -63,10 +61,7 @@ const EXPANDED_ROUTE_BUILDERS = {
|
||||||
workflow: ({workflow_id}) => `/workflows/${workflow_id}`,
|
workflow: ({workflow_id}) => `/workflows/${workflow_id}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only these two builders read identityHandleById/groupHandleById (derived from state.idmap) -
|
// Kinds whose route needs identityHandleById/groupHandleById (from state.idmap); ShortId.vue only waits on an idmap fetch for these.
|
||||||
// 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 const NEEDS_IDMAP = new Set(['item', 'group_item']);
|
||||||
|
|
||||||
export function expandedRoute({kind, ...fields}) {
|
export function expandedRoute({kind, ...fields}) {
|
||||||
|
|
@ -93,24 +88,11 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||||
meta: {requiresAuth: true},
|
meta: {requiresAuth: true},
|
||||||
props: true
|
props: true
|
||||||
}, {
|
}, {
|
||||||
// The self-contained label/short-link entry point (see label.js's LABEL_CONTENT_BUILDERS
|
// Label/short-link entry point; alias of /inventory/:handle/:id. See docs/implementation.md#item-short-link-redirect-route.
|
||||||
// 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',
|
path: '/i/:handle/:id',
|
||||||
redirect: to => `/inventory/${to.params.handle}/${to.params.id}`
|
redirect: to => `/inventory/${to.params.handle}/${to.params.id}`
|
||||||
}, {
|
}, {
|
||||||
// A beforeEnter guard, not `redirect`: `redirect` is called synchronously and its return value
|
// 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.
|
||||||
// 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',
|
path: '/:short_id',
|
||||||
component: ShortId,
|
component: ShortId,
|
||||||
props: true,
|
props: true,
|
||||||
|
|
@ -157,7 +139,9 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||||
meta: {requiresAuth: true},
|
meta: {requiresAuth: true},
|
||||||
props: route => {
|
props: route => {
|
||||||
const {kind, ...components} = route.query;
|
const {kind, ...components} = route.query;
|
||||||
return {prefill: kind ? {kind, components} : null};
|
// 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};
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
path: '/scan',
|
path: '/scan',
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,12 @@
|
||||||
// Specialized bitmap-style fonts label.js's drawTextLeaf switches to below an effective text
|
// Bitmap-style fonts label.js's drawTextLeaf switches to below 10px, where sans-serif gets
|
||||||
// size of 10px, where a general-purpose sans-serif gets blurry/illegible - each is designed for
|
// illegible (see ../assets/fonts/pixel/LICENSE.md for sources); label.js's PIXEL_FONT_TIERS uses
|
||||||
// (and named after) roughly the pixel size it's used at. See
|
// only these two of three candidates - the third, PICO-8, has no lowercase glyphs.
|
||||||
// ../assets/fonts/pixel/LICENSE.md for sources/licenses.
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Tom Thumb";
|
font-family: "Tom Thumb";
|
||||||
src: url("../assets/fonts/pixel/TomThumb.ttf") format("truetype");
|
src: url("../assets/fonts/pixel/TomThumb.ttf") format("truetype");
|
||||||
font-display: block;
|
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-face {
|
||||||
font-family: "Silkscreen";
|
font-family: "Silkscreen";
|
||||||
src: url("../assets/fonts/pixel/Silkscreen-Regular.woff2") format("woff2");
|
src: url("../assets/fonts/pixel/Silkscreen-Regular.woff2") format("woff2");
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,8 @@ $body-color: $gray-700;
|
||||||
@import "tags";
|
@import "tags";
|
||||||
@import "dropdown";
|
@import "dropdown";
|
||||||
@import "pixel-fonts";
|
@import "pixel-fonts";
|
||||||
|
@import "pixel-fonts-candidates";
|
||||||
|
@import "label-fonts";
|
||||||
|
|
||||||
#root, body, html {
|
#root, body, html {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
|
||||||
|
|
@ -329,22 +329,17 @@ export default createStore({
|
||||||
const request = '_toolshed-server._tcp.' + domain + '.'
|
const request = '_toolshed-server._tcp.' + domain + '.'
|
||||||
return await state.resolver.query(request, 'SRV').then(
|
return await state.resolver.query(request, 'SRV').then(
|
||||||
(result) => result.map(
|
(result) => result.map(
|
||||||
// Must match what the browser actually puts in the Host header for
|
// Must match the browser's real Host header (federation.js signs/fetches
|
||||||
// the request this gets used to build (federation.js always signs
|
// "https://"+server+target); browser omits :443 for default HTTPS, so
|
||||||
// and fetches "https://" + server + target) - it omits a :443 for
|
// keeping it here would break signature checks on the receiving end.
|
||||||
// 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))
|
(answer) => answer.port === 443 ? answer.target : answer.target + ':' + answer.port))
|
||||||
},
|
},
|
||||||
async getHomeServers({state, dispatch, commit, getters}) {
|
async getHomeServers({state, dispatch, commit, getters}) {
|
||||||
if (state.home_servers)
|
if (state.home_servers)
|
||||||
return state.home_servers
|
return state.home_servers
|
||||||
// isLoggedIn (store.js's getters) is what lazily hydrates state.user/token/keypair
|
// Reading isLoggedIn first forces its lazy hydration of state.user from localStorage,
|
||||||
// from localStorage on first read - a route with no requiresAuth meta (e.g. the
|
// needed here since routes without requiresAuth (e.g. short-id redirect) skip that
|
||||||
// short-id redirect) never triggers that beforeEach check, so state.user can still be
|
// check; fail clearly if still not logged in rather than crashing on username.split.
|
||||||
// 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) {
|
if (!getters.isLoggedIn) {
|
||||||
throw new Error('Not logged in')
|
throw new Error('Not logged in')
|
||||||
}
|
}
|
||||||
|
|
@ -365,8 +360,7 @@ export default createStore({
|
||||||
const s = await dispatch('lookupServer', {username: friend.username})
|
const s = await dispatch('lookupServer', {username: friend.username})
|
||||||
servers.add(new ServerSet(s, state.unreachable_neighbors))
|
servers.add(new ServerSet(s, state.unreachable_neighbors))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Don't let a single unresolvable/unreachable friend abort the whole
|
// Skip an unresolvable/unreachable friend rather than aborting the whole lookup.
|
||||||
// search/federation lookup - just skip them and continue.
|
|
||||||
console.error('could not resolve server for friend', friend.username, e)
|
console.error('could not resolve server for friend', friend.username, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -442,9 +436,8 @@ export default createStore({
|
||||||
async fetchForeignItem({dispatch, getters}, {owner, id}) {
|
async fetchForeignItem({dispatch, getters}, {owner, id}) {
|
||||||
try {
|
try {
|
||||||
const servers = await dispatch('getFriendServers', {username: owner});
|
const servers = await dispatch('getFriendServers', {username: owner});
|
||||||
// owner here is a full handle (username@domain) - see the /api/inventory_items/<handle>/<id>/
|
// owner is a full handle (username@domain); this endpoint looks the item up by
|
||||||
// endpoint (toolshed/api/inventory.py get_shared_item), which looks the item up by owner rather
|
// owner, not requester (see toolshed/api/inventory.py get_shared_item).
|
||||||
// than by requester, unlike the plain /api/inventory_items/ list/detail endpoints.
|
|
||||||
const item = await servers.get(getters.signAuth, '/api/inventory_items/' + owner + '/' + id + '/');
|
const item = await servers.get(getters.signAuth, '/api/inventory_items/' + owner + '/' + id + '/');
|
||||||
if (item && item.files) {
|
if (item && item.files) {
|
||||||
item.files.forEach(file => file.owner = item.owner)
|
item.files.forEach(file => file.owner = item.owner)
|
||||||
|
|
@ -455,21 +448,12 @@ export default createStore({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// A group handle (leading '#') has no working owner-handle GET route yet
|
// Group handles have no owner-handle GET route yet, so they resolve differently than
|
||||||
// (get_shared_item, which fetchForeignItem calls, only resolves a personal
|
// personal handles here. See docs/implementation.md#fetch-item-by-handle-group-vs-personal-handles.
|
||||||
// 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}) {
|
async fetchItemByHandle({dispatch, getters}, {handle, id}) {
|
||||||
if (handle.startsWith('#')) {
|
if (handle.startsWith('#')) {
|
||||||
// groupIdByHandle is derived from state.idmap (see store.js's getters), which
|
// idmap isn't guaranteed loaded on a direct/refreshed visit here; fetchIdMap is
|
||||||
// nothing guarantees is loaded yet at this point - unlike Inventory.vue/
|
// cheap and already called unconditionally by every other caller too.
|
||||||
// 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')
|
await dispatch('fetchIdMap')
|
||||||
const groupId = getters.groupIdByHandle[handle]
|
const groupId = getters.groupIdByHandle[handle]
|
||||||
if (groupId === undefined) {
|
if (groupId === undefined) {
|
||||||
|
|
@ -528,10 +512,8 @@ export default createStore({
|
||||||
return await servers.delete(getters.signAuth, '/api/friends/' + id + '/')
|
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
|
// Groups are only ever hosted on the current user's own home backend for now (see
|
||||||
// docs/design-in-progress/groups-mvp.md) - a remote member's edit/delete rights on a
|
// docs/design-in-progress/groups-mvp.md), so every group action below uses getHomeServers
|
||||||
// group-owned item work regardless, but "My Groups" has no way to discover a group hosted
|
// rather than resolving a per-group domain.
|
||||||
// elsewhere, so every group action below talks to getHomeServers rather than resolving a
|
|
||||||
// per-group domain.
|
|
||||||
async fetchGroups({commit, dispatch, getters}) {
|
async fetchGroups({commit, dispatch, getters}) {
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
const data = await servers.get(getters.signAuth, '/api/groups/')
|
const data = await servers.get(getters.signAuth, '/api/groups/')
|
||||||
|
|
@ -769,8 +751,8 @@ export default createStore({
|
||||||
},
|
},
|
||||||
async createWorkflow({state, commit, dispatch, getters}, workflowData) {
|
async createWorkflow({state, commit, dispatch, getters}, workflowData) {
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
// The backend stores `payload` as an opaque string - the frontend is
|
// The backend stores payload as an opaque string; the frontend (de)serializes it.
|
||||||
// responsible for serializing/deserializing the JSON itself.
|
// See docs/implementation.md#workflow-payload-is-an-opaque-string.
|
||||||
const data = await servers.post(getters.signAuth, '/api/workflows/', serializeWorkflowPayload(workflowData))
|
const data = await servers.post(getters.signAuth, '/api/workflows/', serializeWorkflowPayload(workflowData))
|
||||||
state.last_load.active_workflows = 0 // Invalidate cache
|
state.last_load.active_workflows = 0 // Invalidate cache
|
||||||
return deserializeWorkflowPayload(data)
|
return deserializeWorkflowPayload(data)
|
||||||
|
|
@ -838,9 +820,8 @@ export default createStore({
|
||||||
groupIdByHandle(state) {
|
groupIdByHandle(state) {
|
||||||
return Object.fromEntries(state.idmap.groups.map(g => [g.handle, g.id]))
|
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/
|
// Reverse of the two getters above: turns a short-id's raw owner_identity_id/owner_group_id
|
||||||
// owner_group_id back into a handle (see router.js's EXPANDED_ROUTE_BUILDERS), without
|
// back into a handle (see router.js EXPANDED_ROUTE_BUILDERS), no backend lookup needed.
|
||||||
// a separate backend lookup since the idmap already has both directions of this data.
|
|
||||||
identityHandleById(state) {
|
identityHandleById(state) {
|
||||||
return Object.fromEntries(state.idmap.identities.map(i => [i.id, i.username]))
|
return Object.fromEntries(state.idmap.identities.map(i => [i.id, i.username]))
|
||||||
},
|
},
|
||||||
|
|
@ -901,11 +882,7 @@ export default createStore({
|
||||||
}
|
}
|
||||||
return fallbackDefault
|
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) => {
|
getNameFromHandle: () => (handle) => {
|
||||||
if (typeof handle !== 'string') {
|
if (typeof handle !== 'string') {
|
||||||
return handle;
|
return handle;
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,7 @@ 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 ' +
|
test('decoding never over-reads: a single-chunk field that exactly exhausts padding is not ' +
|
||||||
'mistaken for a second field', () => {
|
'mistaken for a second field', () => {
|
||||||
// category_id: 5 encodes as kind-tag(2 bits) + one 5-bit chunk = 7 bits, padded with exactly
|
// 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.
|
||||||
// 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])
|
const token = encodeShortId([0, 5])
|
||||||
expect(token).toBe('~Cg')
|
expect(token).toBe('~Cg')
|
||||||
expect(decodeShortId(token)).toEqual([0, 5])
|
expect(decodeShortId(token)).toEqual([0, 5])
|
||||||
|
|
|
||||||
|
|
@ -128,9 +128,8 @@ export default {
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchStorageLocations", "fetchIdMap"]),
|
...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchStorageLocations", "fetchIdMap"]),
|
||||||
// This list is always the viewer's own personal items (fetchInventoryItems has no
|
// Always the viewer's own items (fetchInventoryItems has no group filter); the owner
|
||||||
// group filter) - the owner handle is always their own, but it's always included
|
// handle is always included so /inventory/:handle/:id has exactly one shape.
|
||||||
// rather than special-cased, so /inventory/:handle/:id has exactly one shape.
|
|
||||||
itemRoute(item) {
|
itemRoute(item) {
|
||||||
return `/inventory/${encodeHandleForUrl(this.user)}/${item.id}`
|
return `/inventory/${encodeHandleForUrl(this.user)}/${item.id}`
|
||||||
},
|
},
|
||||||
|
|
@ -151,16 +150,11 @@ export default {
|
||||||
if (owner_identity_id === undefined) return null
|
if (owner_identity_id === undefined) return null
|
||||||
return shortenedRoute({kind: 'item', owner_identity_id, item_local_id: item.id})
|
return shortenedRoute({kind: 'item', owner_identity_id, item_local_id: item.id})
|
||||||
},
|
},
|
||||||
// Routes to Print.vue with this item's own raw identity - userHandle + id, the same shape
|
// Routes to Print.vue with this item's raw identity rather than a pre-built link.
|
||||||
// InventoryDetail.vue's own Print label button sends - rather than any pre-built link, so
|
// See docs/implementation.md#print-link-shape-for-personal-items.
|
||||||
// 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) {
|
printLinkFor(item) {
|
||||||
if (!item.owner) return null
|
if (!item.owner) return null
|
||||||
return {path: '/print', query: {kind: 'item', userHandle: item.owner, id: item.id}}
|
return {path: '/print', query: {kind: 'item', userHandle: item.owner, item: item.id}}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
<button v-if="canPrint" class="btn btn-secondary"
|
<button v-if="canPrint" class="btn btn-secondary"
|
||||||
@click="$router.push({path: '/print', query: {kind: 'item', userHandle: decodedHandle, id}})">
|
@click="$router.push({path: '/print', query: {kind: 'item', userHandle: decodedHandle, item: id}})">
|
||||||
<b-icon-printer></b-icon-printer>
|
<b-icon-printer></b-icon-printer>
|
||||||
Print label
|
Print label
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -98,16 +98,13 @@ export default {
|
||||||
decodedHandle() {
|
decodedHandle() {
|
||||||
return decodeHandleFromUrl(this.handle)
|
return decodeHandleFromUrl(this.handle)
|
||||||
},
|
},
|
||||||
// Edit/Delete apply once the viewer is actually authorized to act on this item - their
|
// Edit/Delete require actual authorization (own item or member group), not just view
|
||||||
// own item, or a group they belong to - not just anyone who can view it
|
// access - get_shared_item's friends_or_self() lets a friend view but never act.
|
||||||
// (get_shared_item's friends_or_self() also lets a friend view a shared item, but never
|
|
||||||
// act on it).
|
|
||||||
canEdit() {
|
canEdit() {
|
||||||
return this.decodedHandle === this.user || this.decodedHandle in this.groupIdByHandle
|
return this.decodedHandle === this.user || this.decodedHandle in this.groupIdByHandle
|
||||||
},
|
},
|
||||||
// Printed labels only support a personal owner handle so far (see label.js's
|
// Printed labels only support a personal owner handle so far (see label.js's
|
||||||
// splitUserHandle/Inventory.vue's printLinkFor) - group items don't get a print link
|
// splitUserHandle); group items get no print link until that's supported too.
|
||||||
// until that's supported too.
|
|
||||||
canPrint() {
|
canPrint() {
|
||||||
return this.decodedHandle === this.user
|
return this.decodedHandle === this.user
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -98,9 +98,9 @@ export default {
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
// Fetched fresh by {handle, id} on mount rather than found by id alone in whatever
|
// Fetched fresh by {handle, id} on mount rather than looked up by id alone -
|
||||||
// happens to already be cached (loaded_items mixes personal, group and search
|
// loaded_items mixes personal/group/search fetches, and id is only unique per owner
|
||||||
// fetches, and id is only unique within one owner's own items - see InventoryDetail.vue).
|
// (see InventoryDetail.vue).
|
||||||
item: {
|
item: {
|
||||||
tags: [],
|
tags: [],
|
||||||
properties: [],
|
properties: [],
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@
|
||||||
|
|
||||||
<div v-if="error" class="alert alert-danger" role="alert">{{ error }}</div>
|
<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">
|
<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
|
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
|
Opera over https:// (or http://localhost) to print straight from here, or use one of the
|
||||||
|
|
@ -53,7 +57,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="text-muted small mb-3">
|
<p class="text-muted small mb-3">
|
||||||
Nine pixel/bitmap-style fonts found in frontend/public, rendered live from the "Text"
|
Ten 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
|
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
|
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
|
fonts at all - Chromium's OTS sanitizer rejects their cmap table - so their cells below
|
||||||
|
|
@ -82,7 +86,7 @@
|
||||||
<h5 class="card-title mb-0">Label preview</h5>
|
<h5 class="card-title mb-0">Label preview</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="label-preview mb-3" v-show="selectedContent">
|
<div class="label-preview mb-3" v-show="fallbackReady">
|
||||||
<canvas ref="fallbackCanvas"></canvas>
|
<canvas ref="fallbackCanvas"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -140,7 +144,7 @@
|
||||||
Connect a printer to preview and print a label.
|
Connect a printer to preview and print a label.
|
||||||
</p>
|
</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="preview-row mb-3" v-show="selectedContent">
|
<div class="preview-row mb-3" v-show="labelBitmap">
|
||||||
<div class="ruler-v">
|
<div class="ruler-v">
|
||||||
<div class="ruler-v-corner"></div>
|
<div class="ruler-v-corner"></div>
|
||||||
<div class="ruler ruler-v-ticks"
|
<div class="ruler ruler-v-ticks"
|
||||||
|
|
@ -163,10 +167,7 @@
|
||||||
:key="'label-' + t.mm" class="tick-label"
|
:key="'label-' + t.mm" class="tick-label"
|
||||||
:style="{left: t.pos + 'px'}">{{ t.mm }}</span>
|
:style="{left: t.pos + 'px'}">{{ t.mm }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- The tape's full physical width, printable area included - the
|
<!-- Tape's full physical width; canvas is narrower/centered, margin is real tape. See docs/implementation.md#tape-full-print-margin. -->
|
||||||
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"
|
<div class="tape-full"
|
||||||
:style="{height: (tape.mediaWidthMm * tapePxPerMm) + 'px'}">
|
:style="{height: (tape.mediaWidthMm * tapePxPerMm) + 'px'}">
|
||||||
<div class="label-preview">
|
<div class="label-preview">
|
||||||
|
|
@ -247,7 +248,8 @@
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label-layout-preview :fields="fields" :value="selectedTemplate"
|
<label-layout-preview :fields="fields" :value="selectedTemplate" :tape="tape"
|
||||||
|
:recent-template-ids="recentTemplateIds"
|
||||||
@input="selectedTemplate = $event"></label-layout-preview>
|
@input="selectedTemplate = $event"></label-layout-preview>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -262,39 +264,30 @@ import {markRaw, nextTick} from "vue";
|
||||||
import {mapActions, mapGetters} from "vuex";
|
import {mapActions, mapGetters} from "vuex";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
|
import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
|
||||||
|
import printerManager from "@/printerManager.js";
|
||||||
|
|
||||||
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
|
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
|
||||||
import {tapeFromStatus, drawLabel, drawFallbackLabel, buildLabelContent, buildLabelFields, preloadQrEncoder} from "@/label.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 {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, withDerivedVars, templateContent} from "@/label-layouts.js";
|
||||||
import {shortenedRoute} from "@/router";
|
import {shortenedRoute} from "@/router";
|
||||||
|
|
||||||
// The extra "Calculated" fields (and label-layouts.js "Short link (QR code)" template input) this
|
// Print.vue-local calculated fields on top of label-layouts.js's DERIVED_VARS. See
|
||||||
// view adds on top of label-layouts.js's own DERIVED_VARS - resolving either needs the current
|
// docs/implementation.md#calculated-short-link-fields.
|
||||||
// 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";
|
const SHORT_URL_VAR = "shortUrl";
|
||||||
// The bare short-id.js token itself (e.g. "~AbCd12"), with no domain or leading "/" - what
|
// See docs/implementation.md#calculated-short-link-fields.
|
||||||
// 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";
|
const SHORT_ID_VAR = "shortId";
|
||||||
|
|
||||||
// Served verbatim from public/vendor/ rather than bundled: libweblabel.js's
|
// Served unbundled so its wasm sibling stays resolvable. See docs/implementation.md#libweblabel-served-unbundled.
|
||||||
// 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";
|
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_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 */
|
const MAX_PREVIEW_HEIGHT_PX = 300; /* never let the on-screen preview grow taller than this */
|
||||||
// How far apart plain and labeled/major ticks sit, both coarser the longer the ruler itself runs
|
// Tick spacing tiers, coarser the longer the ruler runs. See docs/implementation.md#ruler-tier-selection.
|
||||||
// - 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 = [
|
const RULER_TIERS = [
|
||||||
{aboveMm: 0, tickMm: 1, majorEveryMm: 5},
|
{aboveMm: 0, tickMm: 1, majorEveryMm: 5},
|
||||||
{aboveMm: 100, tickMm: 1, majorEveryMm: 10},
|
{aboveMm: 100, tickMm: 1, majorEveryMm: 10},
|
||||||
|
|
@ -309,13 +302,16 @@ export default {
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
// {kind, components} prefilled from the ?kind=…&… query params when arriving from e.g.
|
// {kind, components} from the ?kind=…&… query params (router.js's /print route builds this prop); buildLabelContent turns it into the text field below.
|
||||||
// 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: {
|
prefill: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: null
|
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() {
|
data() {
|
||||||
|
|
@ -329,40 +325,28 @@ export default {
|
||||||
connected: null,
|
connected: null,
|
||||||
tape: null,
|
tape: null,
|
||||||
labelBitmap: null,
|
labelBitmap: null,
|
||||||
// The tape-fed preview's current on-screen scale and printed pixel width (see
|
// Tracked reactively (not read off the canvas) so the mm ruler can recompute tick positions when either changes (see fitZoom/redraw).
|
||||||
// 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,
|
zoom: 1,
|
||||||
printedWidthPx: 0,
|
printedWidthPx: 0,
|
||||||
// Each "text" leaf's effective font size in the current render (see label.js's
|
// 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.
|
||||||
// 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: [],
|
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* template variable (see label-layouts.js's BASE_VARS) - the
|
// 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.
|
||||||
// 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: {
|
varValues: {
|
||||||
...Object.fromEntries(BASE_VARS.map(v => [v, ""])),
|
...Object.fromEntries(BASE_VARS.map(v => [v, ""])),
|
||||||
text: buildLabelContent(this.prefill),
|
text: buildLabelContent(this.prefill),
|
||||||
// Defaults to wherever this page itself is being served from - editable since any
|
// 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.
|
||||||
// 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,
|
webdomain: window.location.origin,
|
||||||
...buildLabelFields(this.prefill),
|
...buildLabelFields(this.prefill),
|
||||||
|
...Object.fromEntries(BASE_VARS.filter(v => v in this.queryFields).map(v => [v, this.queryFields[v]])),
|
||||||
},
|
},
|
||||||
copies: 1,
|
copies: 1,
|
||||||
selectedTemplate: LABEL_TEMPLATES[0].id,
|
selectedTemplate: LABEL_TEMPLATES[0].id,
|
||||||
// "along" draws a layout reading along the tape's feed direction (the usual case -
|
// Ids of the last MAX_RECENT_TEMPLATES distinct templates printed/downloaded, most recent first. See rememberPrintedTemplate/loadRecentTemplateIds.
|
||||||
// constrained by the tape's cross-web width, growing as long as the content needs);
|
recentTemplateIds: [],
|
||||||
// "across" turns it 90deg, constrained by that same width but along the *other* axis
|
// "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.
|
||||||
// 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",
|
orientation: "along",
|
||||||
|
|
||||||
fallbackReady: false,
|
fallbackReady: false,
|
||||||
|
|
@ -370,11 +354,7 @@ export default {
|
||||||
brotherCliExample: "brother_ql --model QL-000 --printer usb://0000:0000 print --label 00 label.png",
|
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",
|
niimbotCliExample: "niimprint --model b00 --conn usb print --density 3 --image label.png",
|
||||||
|
|
||||||
// Nine pixel/bitmap-style font candidates found in frontend/public (see
|
// Ten pixel/bitmap font candidates for legibility testing only (see the disabled card above); not used by label.js's real FONT_TIERS.
|
||||||
// ../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: [
|
candidateFonts: [
|
||||||
{family: "Pixelon"},
|
{family: "Pixelon"},
|
||||||
{family: "Pixelbasel"},
|
{family: "Pixelbasel"},
|
||||||
|
|
@ -385,6 +365,7 @@ export default {
|
||||||
{family: "6pxExpert"},
|
{family: "6pxExpert"},
|
||||||
{family: "Jersey10"},
|
{family: "Jersey10"},
|
||||||
{family: "Jersey15"},
|
{family: "Jersey15"},
|
||||||
|
{family: "Terminus"},
|
||||||
],
|
],
|
||||||
candidateSizes: [5, 6, 7, 8, 10, 12, 16, 20],
|
candidateSizes: [5, 6, 7, 8, 10, 12, 16, 20],
|
||||||
candidateZoom: 4,
|
candidateZoom: 4,
|
||||||
|
|
@ -392,27 +373,16 @@ export default {
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters(["identityIdByHandle"]),
|
...mapGetters(["identityIdByHandle"]),
|
||||||
// The base variables the "Label content" form renders an input for, and the derived ones
|
// Plain passthroughs of the form's base/derived vars, keeping the template from importing
|
||||||
// it instead calculates and lists read-only beside that form - plain passthroughs, but
|
// label-layouts.js just for these. SHORT_URL_VAR/SHORT_ID_VAR exclusion: see
|
||||||
// keep the template from importing label-layouts.js just for these. SHORT_URL_VAR is
|
// docs/implementation.md#calculated-short-link-fields.
|
||||||
// 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() {
|
baseVars() {
|
||||||
return BASE_VARS.filter(v => v !== SHORT_URL_VAR && v !== SHORT_ID_VAR);
|
return BASE_VARS.filter(v => v !== SHORT_URL_VAR && v !== SHORT_ID_VAR);
|
||||||
},
|
},
|
||||||
derivedVars() {
|
derivedVars() {
|
||||||
return [...Object.keys(DERIVED_VARS), SHORT_ID_VAR, SHORT_URL_VAR];
|
return [...Object.keys(DERIVED_VARS), SHORT_ID_VAR, SHORT_URL_VAR];
|
||||||
},
|
},
|
||||||
// Named content fields the templates draw from: the form's own base vars, plus every
|
// Named content fields the templates draw from, dropping blank values. See docs/implementation.md#fields-computed-dropping-blank-values.
|
||||||
// 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() {
|
fields() {
|
||||||
const base = {};
|
const base = {};
|
||||||
for (const v of BASE_VARS) {
|
for (const v of BASE_VARS) {
|
||||||
|
|
@ -433,9 +403,6 @@ export default {
|
||||||
currentTemplate() {
|
currentTemplate() {
|
||||||
return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
|
return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
|
||||||
},
|
},
|
||||||
selectedContent() {
|
|
||||||
return templateContent(this.currentTemplate, this.fields);
|
|
||||||
},
|
|
||||||
deviceRows() {
|
deviceRows() {
|
||||||
if (!this.blob) {
|
if (!this.blob) {
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -454,48 +421,34 @@ export default {
|
||||||
canPrint() {
|
canPrint() {
|
||||||
return Boolean(this.tape && this.labelBitmap && !this.busy);
|
return Boolean(this.tape && this.labelBitmap && !this.busy);
|
||||||
},
|
},
|
||||||
// On-screen pixels per real millimeter of tape, at the preview's current zoom - what
|
// 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).
|
||||||
// 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() {
|
tapePxPerMm() {
|
||||||
return this.tape ? (this.tape.dpi / 25.4) * this.zoom : 0;
|
return this.tape ? (this.tape.dpi / 25.4) * this.zoom : 0;
|
||||||
},
|
},
|
||||||
// The physical length, in mm, each ruler axis actually needs to cover - see
|
// Physical mm length each ruler axis must cover; see horizontal/verticalRulerTicks for what each measures.
|
||||||
// horizontalRulerTicks/verticalRulerTicks below for what each one measures and why.
|
|
||||||
horizontalTotalMm() {
|
horizontalTotalMm() {
|
||||||
return (this.tape && this.printedWidthPx) ? this.printedWidthPx / (this.tape.dpi / 25.4) : 0;
|
return (this.tape && this.printedWidthPx) ? this.printedWidthPx / (this.tape.dpi / 25.4) : 0;
|
||||||
},
|
},
|
||||||
verticalTotalMm() {
|
verticalTotalMm() {
|
||||||
return this.tape ? this.tape.mediaWidthMm : 0;
|
return this.tape ? this.tape.mediaWidthMm : 0;
|
||||||
},
|
},
|
||||||
// The single RULER_TIERS entry both rulers draw from, keyed off whichever axis is
|
// Shared tier keyed off whichever axis is longer. See docs/implementation.md#ruler-tier-selection.
|
||||||
// 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() {
|
rulerTier() {
|
||||||
return RULER_TIERS.filter(t => Math.max(this.horizontalTotalMm, this.verticalTotalMm) >= t.aboveMm)
|
return RULER_TIERS.filter(t => Math.max(this.horizontalTotalMm, this.verticalTotalMm) >= t.aboveMm)
|
||||||
.at(-1);
|
.at(-1);
|
||||||
},
|
},
|
||||||
// Ticks along the tape's length (the printed bitmap's actual width, lead/trailing feed
|
// Ticks along the tape's printed length, feed margins included (still real tape).
|
||||||
// margin included, since that's real physical tape too).
|
|
||||||
horizontalRulerTicks() {
|
horizontalRulerTicks() {
|
||||||
if (!this.tape || !this.printedWidthPx) {
|
if (!this.tape || !this.printedWidthPx) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return this.rulerTicks(this.horizontalTotalMm);
|
return this.rulerTicks(this.horizontalTotalMm);
|
||||||
},
|
},
|
||||||
// Ticks across the tape's full physical width, mediaWidthMm - not printAreaPx/dpi: a
|
// Ticks across the tape's full width, not just the printable area. See docs/implementation.md#tape-full-print-margin.
|
||||||
// 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() {
|
verticalRulerTicks() {
|
||||||
return this.tape ? this.rulerTicks(this.verticalTotalMm) : [];
|
return this.tape ? this.rulerTicks(this.verticalTotalMm) : [];
|
||||||
},
|
},
|
||||||
// "(5px, 23px)" for the current render's text leaves (see data's textSizesPx), or "" once
|
// e.g. "(5px, 23px)", or "" so it appends cleanly onto the tape-width <small> with nothing shown.
|
||||||
// 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() {
|
textSizesSummary() {
|
||||||
if (!this.textSizesPx.length) {
|
if (!this.textSizesPx.length) {
|
||||||
return "";
|
return "";
|
||||||
|
|
@ -529,10 +482,7 @@ export default {
|
||||||
this.redrawFallback();
|
this.redrawFallback();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Covers connecting/disconnecting/switching printers - anything that changes the
|
// Catches printer connect/disconnect/switch; flush:'post' since the canvas only exists once `tape` is truthy (template's v-if).
|
||||||
// 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: {
|
tape: {
|
||||||
handler() {
|
handler() {
|
||||||
this.redraw();
|
this.redraw();
|
||||||
|
|
@ -543,24 +493,12 @@ export default {
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(["fetchIdMap"]),
|
...mapActions(["fetchIdMap"]),
|
||||||
|
|
||||||
// Turns a camelCase variable name (see label-layouts.js's KNOWN_VARS) into a form label,
|
// camelCase -> Title Case (e.g. "itemHandle" -> "Item Handle") so a new template var needs no hand-written 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) {
|
varLabel(v) {
|
||||||
return v.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase());
|
return v.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase());
|
||||||
},
|
},
|
||||||
|
|
||||||
// The same shortened link Inventory.vue/StorageLocation.vue's own shortIdLink builds for
|
// Builds the bare short-id.js token for the current fields. See docs/implementation.md#shortid-resolution.
|
||||||
// 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) {
|
shortId(f) {
|
||||||
if (!f.userHandle) {
|
if (!f.userHandle) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -580,10 +518,7 @@ export default {
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Ticks from 0 up to totalMm, each positioned in on-screen pixels via tapePxPerMm - shared
|
// Ticks 0..totalMm via tapePxPerMm, shared by both ruler computeds; spacing comes from the shared rulerTier, so both rulers coarsen together.
|
||||||
// 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) {
|
rulerTicks(totalMm) {
|
||||||
const {tickMm, majorEveryMm} = this.rulerTier;
|
const {tickMm, majorEveryMm} = this.rulerTier;
|
||||||
const ticks = [];
|
const ticks = [];
|
||||||
|
|
@ -593,6 +528,31 @@ export default {
|
||||||
return ticks;
|
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) {
|
async guard(fn) {
|
||||||
this.error = null;
|
this.error = null;
|
||||||
this.busy = true;
|
this.busy = true;
|
||||||
|
|
@ -606,10 +566,8 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
async refreshDevices() {
|
async refreshDevices() {
|
||||||
this.devices = (await navigator.usb.getDevices()).map((d) => markRaw(d));
|
this.devices = (await printerManager.enumerateDevices()).map((d) => markRaw(d));
|
||||||
/* A printer unplugged while open is off the list: the handle it was
|
/* 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. */
|
||||||
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)) {
|
if (this.connected !== null && !this.devices.includes(this.connected)) {
|
||||||
this.connected = null;
|
this.connected = null;
|
||||||
this.tape = null;
|
this.tape = null;
|
||||||
|
|
@ -638,20 +596,32 @@ 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) {
|
connect(index) {
|
||||||
this.guard(async () => {
|
this.guard(() => this.connectToDevice(this.devices[index]));
|
||||||
// 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.
|
// Auto-connects to the most-recently-used printer once it's paired and visible again (page
|
||||||
await this.closeConnection();
|
// load, or plugged back in - see onUsbChange), mirroring cameraManager's preferred-camera
|
||||||
const device = this.devices[index];
|
// auto-select. Gated on "nothing connected yet" since a manual connect() to a different
|
||||||
this.blob.setDevices([device]);
|
// paired printer shouldn't be silently overridden by a later device-list refresh.
|
||||||
await this.blob.open(device.vendorId, device.productId);
|
autoConnectPreferred() {
|
||||||
this.connected = device;
|
if (this.connected) {
|
||||||
const status = await this.blob.status();
|
return Promise.resolve();
|
||||||
// Setting `tape` alone is enough to redraw - see the tape watcher above.
|
}
|
||||||
this.tape = this.blob.can("print") ? tapeFromStatus(status) : null;
|
const preferred = printerManager.findPreferredDevice(this.devices);
|
||||||
});
|
return preferred ? this.connectToDevice(preferred) : Promise.resolve();
|
||||||
},
|
},
|
||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
|
|
@ -660,8 +630,7 @@ export default {
|
||||||
|
|
||||||
redraw() {
|
redraw() {
|
||||||
this.labelBitmap = null;
|
this.labelBitmap = null;
|
||||||
const content = this.selectedContent;
|
if (!this.tape) {
|
||||||
if (!this.tape || !content) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const canvas = this.$refs.labelCanvas;
|
const canvas = this.$refs.labelCanvas;
|
||||||
|
|
@ -669,15 +638,17 @@ export default {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.resizeObserver.observe(canvas.parentElement);
|
this.resizeObserver.observe(canvas.parentElement);
|
||||||
let textSizesPx;
|
let textSizesPx, warnings;
|
||||||
try {
|
try {
|
||||||
({textSizesPx} = drawLabel(canvas, this.tape, content, this.orientation));
|
const content = templateContent(this.currentTemplate, this.fields);
|
||||||
|
({textSizesPx, warnings} = drawLabel(canvas, this.tape, content, this.orientation));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = e.message;
|
this.error = e.message;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.error = null;
|
this.error = null;
|
||||||
this.textSizesPx = textSizesPx;
|
this.textSizesPx = textSizesPx;
|
||||||
|
this.warnings = warnings;
|
||||||
const bitmap = canvasToBitmap(canvas);
|
const bitmap = canvasToBitmap(canvas);
|
||||||
bitmapToCanvas(canvas, bitmap);
|
bitmapToCanvas(canvas, bitmap);
|
||||||
this.labelBitmap = bitmap;
|
this.labelBitmap = bitmap;
|
||||||
|
|
@ -687,22 +658,21 @@ export default {
|
||||||
|
|
||||||
redrawFallback() {
|
redrawFallback() {
|
||||||
this.fallbackReady = false;
|
this.fallbackReady = false;
|
||||||
const content = this.selectedContent;
|
|
||||||
if (!content) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const canvas = this.$refs.fallbackCanvas;
|
const canvas = this.$refs.fallbackCanvas;
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.resizeObserver.observe(canvas.parentElement);
|
this.resizeObserver.observe(canvas.parentElement);
|
||||||
|
let warnings;
|
||||||
try {
|
try {
|
||||||
drawFallbackLabel(canvas, content, this.orientation);
|
const content = templateContent(this.currentTemplate, this.fields);
|
||||||
|
({warnings} = drawFallbackLabel(canvas, content, this.orientation));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = e.message;
|
this.error = e.message;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
this.warnings = warnings;
|
||||||
this.fallbackReady = true;
|
this.fallbackReady = true;
|
||||||
this.fitZoom(canvas);
|
this.fitZoom(canvas);
|
||||||
},
|
},
|
||||||
|
|
@ -716,29 +686,17 @@ export default {
|
||||||
link.download = "label.png";
|
link.download = "label.png";
|
||||||
link.href = canvas.toDataURL("image/png");
|
link.href = canvas.toDataURL("image/png");
|
||||||
link.click();
|
link.click();
|
||||||
|
this.rememberPrintedTemplate(this.selectedTemplate);
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Fit the preview to its card without ever needing a horizontal
|
// Fits the preview to its card, magnifying up to MAX_ZOOM/MAX_PREVIEW_HEIGHT_PX. See docs/implementation.md#fit-zoom-preview-scaling.
|
||||||
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) {
|
fitZoom(canvas) {
|
||||||
const available = canvas.parentElement.clientWidth;
|
const available = canvas.parentElement.clientWidth;
|
||||||
if (!(available > 0)) {
|
if (!(available > 0)) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
const rawZoom = Math.min(MAX_ZOOM, available / canvas.width, MAX_PREVIEW_HEIGHT_PX / canvas.height);
|
const rawZoom = Math.min(MAX_ZOOM, available / canvas.width, MAX_PREVIEW_HEIGHT_PX / canvas.height);
|
||||||
// When magnifying, round DOWN to a whole number: image-rendering:pixelated below
|
// Floors rather than rounds/ceils when magnifying, to keep image-rendering:pixelated crisp. See docs/implementation.md#fit-zoom-preview-scaling.
|
||||||
// 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;
|
const zoom = rawZoom >= 1 ? Math.max(1, Math.floor(rawZoom)) : rawZoom;
|
||||||
canvas.style.width = `${canvas.width * zoom}px`;
|
canvas.style.width = `${canvas.width * zoom}px`;
|
||||||
canvas.style.height = `${canvas.height * zoom}px`;
|
canvas.style.height = `${canvas.height * zoom}px`;
|
||||||
|
|
@ -750,12 +708,11 @@ export default {
|
||||||
this.guard(async () => {
|
this.guard(async () => {
|
||||||
const copies = Math.max(1, Math.min(20, Number(this.copies) || 1));
|
const copies = Math.max(1, Math.min(20, Number(this.copies) || 1));
|
||||||
await this.blob.printBitmap(this.labelBitmap, {copies});
|
await this.blob.printBitmap(this.labelBitmap, {copies});
|
||||||
|
this.rememberPrintedTemplate(this.selectedTemplate);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Re-fits whichever canvas sits in a resized container - covers a window resize, but
|
// Re-fits the resized container's canvas (window resize, sidebar toggle, font load, etc.); debounced since ResizeObserver can fire in bursts.
|
||||||
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) {
|
handleContainerResize(entries) {
|
||||||
clearTimeout(this.resizeTimer);
|
clearTimeout(this.resizeTimer);
|
||||||
this.resizeTimer = setTimeout(() => {
|
this.resizeTimer = setTimeout(() => {
|
||||||
|
|
@ -769,12 +726,13 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
onUsbChange() {
|
onUsbChange() {
|
||||||
this.guard(() => this.refreshDevices());
|
this.guard(async () => {
|
||||||
|
await this.refreshDevices();
|
||||||
|
await this.autoConnectPreferred();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// A plain :ref="key" inside v-for would still get Vue's refInFor array-collecting
|
// Works around Vue's refInFor array-collecting behavior for :ref in v-for, same as LabelLayoutPreview.vue's setTemplateCanvasRef.
|
||||||
// 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) {
|
setCandidateCanvasRef(key, el) {
|
||||||
if (el) {
|
if (el) {
|
||||||
this.candidateCanvases[key] = el;
|
this.candidateCanvases[key] = el;
|
||||||
|
|
@ -783,12 +741,7 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Draws the live "Text" field's value into a single canvas at the exact size/family
|
// 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.
|
||||||
// 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) {
|
drawCandidateCell(canvas, family, fontPx) {
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -807,6 +760,7 @@ export default {
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
ctx.fillStyle = "#000";
|
ctx.fillStyle = "#000";
|
||||||
ctx.fillText(text, 2, (canvas.height + up - down) / 2);
|
ctx.fillText(text, 2, (canvas.height + up - down) / 2);
|
||||||
|
bitmapToCanvas(canvas, canvasToBitmap(canvas));
|
||||||
canvas.style.width = `${canvas.width * this.candidateZoom}px`;
|
canvas.style.width = `${canvas.width * this.candidateZoom}px`;
|
||||||
canvas.style.height = `${canvas.height * this.candidateZoom}px`;
|
canvas.style.height = `${canvas.height * this.candidateZoom}px`;
|
||||||
},
|
},
|
||||||
|
|
@ -828,20 +782,16 @@ export default {
|
||||||
this.resizeObserver = null;
|
this.resizeObserver = null;
|
||||||
this.resizeTimer = null;
|
this.resizeTimer = null;
|
||||||
this.candidateCanvases = {};
|
this.candidateCanvases = {};
|
||||||
|
this.recentTemplateIds = this.loadRecentTemplateIds();
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
this.drawCandidateFontTests();
|
this.drawCandidateFontTests();
|
||||||
// Not awaited: itemShortUrl just reads whatever identityIdByHandle currently holds (see
|
// Not awaited: resolving late just means shortUrl briefly shows "—" instead of blocking the unrelated printer/wasm setup below.
|
||||||
// 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.fetchIdMap().catch(e => {
|
||||||
this.error = e.message;
|
this.error = e.message;
|
||||||
});
|
});
|
||||||
this.resizeObserver = new ResizeObserver(this.handleContainerResize);
|
this.resizeObserver = new ResizeObserver(this.handleContainerResize);
|
||||||
// Kicked off here rather than awaited immediately, so it loads concurrently with
|
// Loaded concurrently with MultiPrinterBlob below rather than serialized. See docs/implementation.md#concurrent-wasm-loading.
|
||||||
// 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();
|
const qrReady = preloadQrEncoder();
|
||||||
if (!("usb" in navigator)) {
|
if (!("usb" in navigator)) {
|
||||||
this.usbSupported = false;
|
this.usbSupported = false;
|
||||||
|
|
@ -859,7 +809,10 @@ export default {
|
||||||
navigator.usb.addEventListener("connect", this.onUsbChange);
|
navigator.usb.addEventListener("connect", this.onUsbChange);
|
||||||
navigator.usb.addEventListener("disconnect", this.onUsbChange);
|
navigator.usb.addEventListener("disconnect", this.onUsbChange);
|
||||||
await qrReady;
|
await qrReady;
|
||||||
await this.guard(() => this.refreshDevices());
|
await this.guard(async () => {
|
||||||
|
await this.refreshDevices();
|
||||||
|
await this.autoConnectPreferred();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
if ("usb" in navigator) {
|
if ("usb" in navigator) {
|
||||||
|
|
@ -888,40 +841,26 @@ export default {
|
||||||
//box-shadow: 0 0 0 1px rgba(127, 127, 127, .5);
|
//box-shadow: 0 0 0 1px rgba(127, 127, 127, .5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The tape-fed preview's mm ruler (see Print.vue's template/script) - a horizontal track above
|
/* The 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 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 {
|
.preview-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Holds the horizontal ruler and the canvas - deliberately never scrollable (no overflow-x:auto):
|
/* Ruler/canvas container deliberately never scrollable. See docs/implementation.md#preview-track-no-scroll-invariant. */
|
||||||
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 {
|
.preview-track {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Overrides the standalone rule above: nested here, .label-preview must neither scroll nor center
|
/* Nested override: .label-preview must neither scroll nor center its canvas here. See docs/implementation.md#label-preview-override-in-preview-track. */
|
||||||
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 {
|
.preview-track .label-preview {
|
||||||
overflow-x: visible;
|
overflow-x: visible;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The tape's full physical width (see the template) - a print head can't mark all the way to a
|
/* Tape's full physical width; canvas is narrower/centered, faint tint marks the real unprintable margin. See docs/implementation.md#tape-full-print-margin. */
|
||||||
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 {
|
.tape-full {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -936,8 +875,7 @@ export default {
|
||||||
color: rgba(127, 127, 127, .9);
|
color: rgba(127, 127, 127, .9);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Matches .ruler-h's own height below - the vertical ruler's ticks start only after this, so tick
|
/* Matches .ruler-h's height so the vertical ruler's tick 0 lines up with the canvas's top edge. */
|
||||||
0 lines up with the canvas's top edge rather than the horizontal ruler sitting above it. */
|
|
||||||
.ruler-v-corner {
|
.ruler-v-corner {
|
||||||
height: 1.6rem;
|
height: 1.6rem;
|
||||||
}
|
}
|
||||||
|
|
@ -957,9 +895,7 @@ export default {
|
||||||
background: currentColor;
|
background: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ticks anchor to the edge nearest the canvas (right for the vertical ruler, bottom for the
|
/* Ticks anchor to the edge nearest the canvas and grow outward (pointing at the label); mm labels sit on the opposite outer edge. */
|
||||||
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 {
|
.ruler-v-ticks .tick {
|
||||||
right: 0;
|
right: 0;
|
||||||
width: .4rem;
|
width: .4rem;
|
||||||
|
|
@ -987,8 +923,7 @@ export default {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* transform, not a fixed em nudge, so the label's actual center - not its edge - lands on the
|
/* transform (not a fixed em nudge) centers the label on the tick's mm position regardless of text size. */
|
||||||
tick's mm position (t.pos, set inline), whatever the text's width/height happens to be. */
|
|
||||||
.ruler-v-ticks .tick-label {
|
.ruler-v-ticks .tick-label {
|
||||||
left: 0;
|
left: 0;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,10 @@
|
||||||
<div class="invalid-feedback">{{ errors.email }}</div>
|
<div class="invalid-feedback">{{ errors.email }}</div>
|
||||||
</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']">
|
<div :class="errors.password?['mb-3','is-invalid']:['mb-3']">
|
||||||
<label class="form-label">Password</label>
|
<label class="form-label">Password</label>
|
||||||
<input class="form-control form-control-lg" type="password"
|
<input class="form-control form-control-lg" type="password"
|
||||||
|
|
@ -117,8 +121,39 @@ export default {
|
||||||
domains: []
|
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: {
|
methods: {
|
||||||
...mapActions(['lookupServer']),
|
...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() {
|
do_register() {
|
||||||
console.log('do_register');
|
console.log('do_register');
|
||||||
console.log(this.form);
|
console.log(this.form);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@
|
||||||
<div class="container-fluid p-0">
|
<div class="container-fluid p-0">
|
||||||
<h1 class="h3 mb-3">Scan a code</h1>
|
<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="error" class="alert alert-danger" role="alert">{{ error }}</div>
|
||||||
|
|
||||||
<div v-if="!insecureContext" class="alert alert-warning">
|
<div v-if="!insecureContext" class="alert alert-warning">
|
||||||
|
|
@ -24,7 +30,20 @@
|
||||||
<span class="scan-result-type">{{ c.type }}</span>
|
<span class="scan-result-type">{{ c.type }}</span>
|
||||||
<span v-if="c.metaText" class="text-muted"> {{ c.metaText }}</span>
|
<span v-if="c.metaText" class="text-muted"> {{ c.metaText }}</span>
|
||||||
<span class="text-muted"> @ {{ c.time }}</span>
|
<span class="text-muted"> @ {{ c.time }}</span>
|
||||||
<div class="text-break">{{ c.text }}</div>
|
<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"> → {{
|
||||||
|
c.error
|
||||||
|
}}</span>
|
||||||
|
<span v-else-if="c.description"> → {{ c.description }}</span>
|
||||||
|
<span v-else-if="c.description === undefined" class="text-muted"> → resolving…</span>
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ c.text }}</template>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -37,53 +56,7 @@
|
||||||
<h5 class="card-title mb-0">Scan from camera</h5>
|
<h5 class="card-title mb-0">Scan from camera</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p v-if="!cameraSupported" class="text-muted">
|
<CameraScanner @scan="onCameraScan" @error="e => error = e"/>
|
||||||
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 & 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -94,14 +67,59 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
import {mapActions} from "vuex";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
import {loadAnyDCode} from "../../vendor/anyd-qr.js";
|
import CameraScanner from "@/components/CameraScanner.vue";
|
||||||
import cameraManager from "@/cameraManager.js";
|
import {encodeHandleForUrl, expandedRoute} from "@/router";
|
||||||
|
import {decodeShortId, deserializeShortId} from "@/short-id";
|
||||||
|
|
||||||
// A decode result's metadata (see anyd-qr.js's SymbolMetadata) as one short, human-readable
|
// A scanned label can encode any of these (see label-layouts.js's DERIVED_VARS): a full
|
||||||
// string, e.g. "(v7, ec=M, mask=3)" - shared by both the "from image" and "from camera" result
|
// self-contained URL (itemUrl/shortUrl), a bare short-id.js token with no domain at all (the
|
||||||
// lists below rather than each formatting it its own way.
|
// "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)".
|
||||||
function metaSummary(metadata) {
|
function metaSummary(metadata) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (metadata.version != null) {
|
if (metadata.version != null) {
|
||||||
|
|
@ -119,15 +137,11 @@ function metaSummary(metadata) {
|
||||||
return parts.length ? `(${parts.join(", ")})` : "";
|
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 {
|
export default {
|
||||||
name: "Scan",
|
name: "Scan",
|
||||||
components: {
|
components: {
|
||||||
BaseLayout,
|
BaseLayout,
|
||||||
...BIcons
|
CameraScanner,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -135,354 +149,154 @@ export default {
|
||||||
insecureContext: window.isSecureContext,
|
insecureContext: window.isSecureContext,
|
||||||
insecureOrigin: `${window.location.protocol}//${window.location.hostname}`,
|
insecureOrigin: `${window.location.protocol}//${window.location.hostname}`,
|
||||||
|
|
||||||
dropHover: false,
|
// One-shot: cleared by maybeVisitFirstMatch as soon as it navigates, so it doesn't
|
||||||
hasFileImage: false,
|
// keep firing router.push for every later scan/decode while left switched on.
|
||||||
fileResults: [],
|
// 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",
|
||||||
|
|
||||||
cameraSupported: Boolean(navigator.mediaDevices?.getUserMedia),
|
|
||||||
cameras: [],
|
|
||||||
localSelectedCameraId: "",
|
|
||||||
cameraRunning: false,
|
|
||||||
cameraRes: "",
|
|
||||||
cameraLog: [],
|
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: {
|
methods: {
|
||||||
// Draws `file`/a pasted or dropped Blob onto fileCanvas and decodes whatever's in it -
|
...mapActions(["fetchItemByHandle", "fetchGroup", "fetchStorageLocations", "fetchIdMap"]),
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
drawBoxes(ctx, results) {
|
// Resolves entry.link into entry.description ("[#7] Cordless drill") for the non-URL
|
||||||
ctx.lineWidth = Math.max(2, ctx.canvas.width / 300);
|
// formats classifyScanText recognizes - mutates the already-rendered entry in place once
|
||||||
ctx.strokeStyle = "#3ce77c";
|
// the lookup lands, rather than delaying the log/result list from showing the raw scanned
|
||||||
ctx.fillStyle = "#3ce77c";
|
// text and link immediately. Left as `undefined` (template shows "resolving...") while in
|
||||||
ctx.font = `${Math.max(14, ctx.canvas.width / 60)}px sans-serif`;
|
// flight, and settles to a string or `null` (nothing else known to show, e.g. a workflow
|
||||||
for (const r of results) {
|
// short id, or the owner/item genuinely couldn't be resolved).
|
||||||
if (!r.box) {
|
//
|
||||||
continue;
|
// 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
|
||||||
|
}
|
||||||
|
if (link.immediate) {
|
||||||
|
this.maybeVisitFirstMatch(link);
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
const {x0, y0, x1, y1} = r.box;
|
entry.description = result;
|
||||||
ctx.strokeRect(x0, y0, x1 - x0, y1 - y0);
|
// Only a truthy description confirms the target actually exists - a null/empty
|
||||||
ctx.fillText(r.type, x0, Math.max(14, y0 - 4));
|
// 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
onFileInputChange() {
|
// Sends the viewer straight to the first scan this session that's confirmed to resolve
|
||||||
const file = this.$refs.fileInput.files[0];
|
// (immediately for a plain URL, or once resolveDescription confirms a real target for a
|
||||||
if (file) {
|
// token/handle) while the "Visit first match" toggle is on. One-shot: switches the toggle
|
||||||
this.decodeBlob(file);
|
// 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) {
|
||||||
|
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
await cameraManager.enumerateDevices();
|
this.visitFirstMatch = false;
|
||||||
this.cameras = cameraManager.getAvailableCameras();
|
this.$router.push(link.to);
|
||||||
},
|
},
|
||||||
|
|
||||||
cleanCameraLabel(label) {
|
async describeLink(link) {
|
||||||
if (!label) return "";
|
if (link.itemHandle) {
|
||||||
const parts = label.split(":").map((p) => p.trim());
|
return this.describeItem(link.itemHandle.handle, link.itemHandle.id);
|
||||||
if (parts.length === 2 && parts[0] === parts[1]) {
|
|
||||||
return parts[0];
|
|
||||||
}
|
}
|
||||||
return label;
|
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
|
||||||
},
|
},
|
||||||
|
|
||||||
syncOverlaySize() {
|
async describeItem(handle, id) {
|
||||||
const video = this.$refs.video;
|
const item = await this.fetchItemByHandle({handle, id});
|
||||||
const overlay = this.$refs.overlay;
|
return item ? `[#${item.id}] ${item.name}` : null;
|
||||||
if (!video || !overlay) {
|
},
|
||||||
return;
|
|
||||||
}
|
// Handler for CameraScanner's "scan" event - logs every code from the batch it decoded.
|
||||||
overlay.width = video.clientWidth;
|
onCameraScan(codes) {
|
||||||
overlay.height = video.clientHeight;
|
codes.forEach(this.logDecode);
|
||||||
},
|
},
|
||||||
|
|
||||||
logDecode(code) {
|
logDecode(code) {
|
||||||
this.cameraLog.unshift({
|
const text = code.text ?? "";
|
||||||
|
const entry = {
|
||||||
type: code.type,
|
type: code.type,
|
||||||
text: code.text ?? "",
|
text,
|
||||||
metaText: metaSummary(code.metadata),
|
metaText: metaSummary(code.metadata),
|
||||||
time: new Date().toLocaleTimeString(),
|
time: new Date().toLocaleTimeString(),
|
||||||
});
|
link: classifyScanText(text),
|
||||||
// Caps the log rather than letting it grow forever - old entries are trimmed off the
|
description: undefined,
|
||||||
// end, same as CameraScanner's own dedupe window makes them stale for re-matching.
|
error: null,
|
||||||
|
};
|
||||||
|
this.cameraLog.unshift(entry);
|
||||||
|
// Resolve against cameraLog[0], not the plain `entry` object above: Vue's reactivity
|
||||||
|
// tracks property sets through the reactive proxy unshift() just installed, and
|
||||||
|
// mutating the pre-insertion raw object later bypasses that proxy entirely, so the
|
||||||
|
// description would never appear to update (stuck on "resolving...") even once the
|
||||||
|
// lookup actually finished.
|
||||||
|
this.resolveDescription(this.cameraLog[0]);
|
||||||
|
// Caps the log (old entries trimmed) rather than growing forever, matching
|
||||||
|
// CameraScanner's own dedupe window that makes them stale for re-matching.
|
||||||
this.cameraLog.length = Math.min(this.cameraLog.length, 20);
|
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() {
|
created() {
|
||||||
// Kept as a promise (rather than awaited here) so every caller - decodeBlob, startCamera -
|
// Not component data: resolveDescription's cached values are read back into each entry's
|
||||||
// shares the same in-flight load instead of each triggering its own; loadAnyDCode's own
|
// own (reactive) `description` field, so the cache itself never needs to be reactive.
|
||||||
// memoization (see anyd-qr.js) means a second call anywhere else in the app is free too.
|
this.descriptionCache = new Map();
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.scan-results {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
@ -504,9 +318,4 @@ export default {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--bs-success);
|
color: var(--bs-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scan-results-empty {
|
|
||||||
color: var(--bs-gray-600);
|
|
||||||
border-style: dashed;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -107,9 +107,9 @@ export default {
|
||||||
return files.filter(file => file.mime_type.startsWith("image/"));
|
return files.filter(file => file.mime_type.startsWith("image/"));
|
||||||
},
|
},
|
||||||
loadResults() {
|
loadResults() {
|
||||||
// Search results are always personal-or-friend (see inventory_items() in
|
// Search results are always personal-or-friend (inventory_items() in
|
||||||
// toolshed/api/inventory.py - it never yields a group's items), so the owner is
|
// toolshed/api/inventory.py never yields group items), so owner is always a plain
|
||||||
// always a plain user handle - one route shape, no owner-is-me special case.
|
// user handle - one route shape, no owner-is-me special case.
|
||||||
this.fetchSearchResults({query: this.query}).then((results) => {
|
this.fetchSearchResults({query: this.query}).then((results) => {
|
||||||
this.search_results = results.map(e => (
|
this.search_results = results.map(e => (
|
||||||
{...e, route: `/inventory/${encodeHandleForUrl(e.owner)}/${e.id}`}))
|
{...e, route: `/inventory/${encodeHandleForUrl(e.owner)}/${e.id}`}))
|
||||||
|
|
|
||||||
|
|
@ -77,10 +77,7 @@ const EXAMPLES = [
|
||||||
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
|
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Debug-only display helper, kept out of short-id.js's production library: mirrors its bit-packing
|
// 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.
|
||||||
// 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 KIND_TAG_BITS = 2;
|
||||||
const CHUNK_BITS = 4;
|
const CHUNK_BITS = 4;
|
||||||
const DIRECT_KIND_COUNT = 2 ** KIND_TAG_BITS - 1;
|
const DIRECT_KIND_COUNT = 2 ** KIND_TAG_BITS - 1;
|
||||||
|
|
@ -159,10 +156,7 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
// expandedRoute reads Vuex getters derived from state.idmap (see buildExpandedRoute in
|
// expandedRoute re-evaluates once fetchIdMap resolves (see buildExpandedRoute in router.js); finishes the redirect the router's synchronous guard couldn't.
|
||||||
// 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) {
|
expandedRoute(url) {
|
||||||
if (url) {
|
if (url) {
|
||||||
this.$router.replace(url);
|
this.$router.replace(url);
|
||||||
|
|
|
||||||
|
|
@ -133,13 +133,10 @@ export default {
|
||||||
if (owner_identity_id === undefined) return null
|
if (owner_identity_id === undefined) return null
|
||||||
return shortenedRoute({kind: 'storage_location', owner_identity_id, storage_location_id: location.id})
|
return shortenedRoute({kind: 'storage_location', owner_identity_id, storage_location_id: location.id})
|
||||||
},
|
},
|
||||||
// Routes to Print.vue with this location's own raw identity - userHandle + id - rather
|
// Routes to Print.vue with this location's raw identity, same shape as Inventory.vue's
|
||||||
// than any pre-built link, the same shape Inventory.vue's printLinkFor sends for an item
|
// printLinkFor. See docs/implementation.md#print-link-shape-for-storage-locations.
|
||||||
// (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) {
|
printLinkFor(location) {
|
||||||
return {path: '/print', query: {kind: 'storage-location', userHandle: location.owner, id: location.id}}
|
return {path: '/print', query: {kind: 'storage-location', userHandle: location.owner, location: location.id}}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
|
|
|
||||||
|
|
@ -3395,8 +3395,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navbar
|
<!-- Navbar -->
|
||||||
================================================== -->
|
|
||||||
<div class="bs-docs-section clearfix">
|
<div class="bs-docs-section clearfix">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
|
|
@ -3507,8 +3506,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Typography
|
<!-- Typography -->
|
||||||
================================================== -->
|
|
||||||
<div class="bs-docs-section">
|
<div class="bs-docs-section">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
|
|
@ -3613,8 +3611,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tables
|
<!-- Tables -->
|
||||||
================================================== -->
|
|
||||||
<div class="bs-docs-section">
|
<div class="bs-docs-section">
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
@ -3701,8 +3698,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Forms
|
<!-- Forms -->
|
||||||
================================================== -->
|
|
||||||
<div class="bs-docs-section">
|
<div class="bs-docs-section">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
|
|
@ -3969,8 +3965,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navs
|
<!-- Navs -->
|
||||||
================================================== -->
|
|
||||||
<div class="bs-docs-section">
|
<div class="bs-docs-section">
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
@ -4224,8 +4219,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Indicators
|
<!-- Indicators -->
|
||||||
================================================== -->
|
|
||||||
<div class="bs-docs-section">
|
<div class="bs-docs-section">
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
@ -4343,8 +4337,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress
|
<!-- Progress -->
|
||||||
================================================== -->
|
|
||||||
<div class="bs-docs-section">
|
<div class="bs-docs-section">
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
@ -4443,8 +4436,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Containers
|
<!-- Containers -->
|
||||||
================================================== -->
|
|
||||||
<div class="bs-docs-section">
|
<div class="bs-docs-section">
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
@ -4765,8 +4757,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dialogs
|
<!-- Dialogs -->
|
||||||
================================================== -->
|
|
||||||
<div class="bs-docs-section">
|
<div class="bs-docs-section">
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
|
||||||
|
|
@ -260,9 +260,7 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
workflowComponent() {
|
workflowComponent() {
|
||||||
// Return the single component implementing the whole workflow, if any,
|
// The returned component decides what to render itself, based on the `step` prop it receives.
|
||||||
// using the workflow component registry. The component itself decides
|
|
||||||
// what to render based on the `step` prop it receives.
|
|
||||||
const workflowType = this.workflowInstance?.slug;
|
const workflowType = this.workflowInstance?.slug;
|
||||||
return workflowType ? getWorkflowComponent(workflowType) : null;
|
return workflowType ? getWorkflowComponent(workflowType) : null;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -231,8 +231,6 @@ export default {
|
||||||
|
|
||||||
const newWorkflow = await this.createWorkflow(workflowData);
|
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;
|
const firstStep = workflow.stepDefinitions?.[0]?.step || 1;
|
||||||
this.$router.push({
|
this.$router.push({
|
||||||
name: 'workflow-detail',
|
name: 'workflow-detail',
|
||||||
|
|
@ -251,8 +249,6 @@ export default {
|
||||||
},
|
},
|
||||||
async viewWorkflowDetails(workflow) {
|
async viewWorkflowDetails(workflow) {
|
||||||
console.log('Viewing details for workflow:', 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 ||
|
const currentStep = workflow.current_step ||
|
||||||
workflow.payload?.current_step ||
|
workflow.payload?.current_step ||
|
||||||
getWorkflow(workflow.name)?.stepDefinitions?.[0]?.step ||
|
getWorkflow(workflow.name)?.stepDefinitions?.[0]?.step ||
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,4 @@
|
||||||
/**
|
// 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 FotoFirstBulkImportWorkflow from '@/components/workflow/workflows/FotoFirstBulkImportWorkflow.vue';
|
||||||
import BulkItemImportWorkflow from '@/components/workflow/workflows/BulkItemImportWorkflow.vue';
|
import BulkItemImportWorkflow from '@/components/workflow/workflows/BulkItemImportWorkflow.vue';
|
||||||
import InventoryAuditWorkflow from '@/components/workflow/workflows/InventoryAuditWorkflow.vue';
|
import InventoryAuditWorkflow from '@/components/workflow/workflows/InventoryAuditWorkflow.vue';
|
||||||
|
|
@ -25,9 +7,6 @@ import MaintenanceScheduleWorkflow from '@/components/workflow/workflows/Mainten
|
||||||
import ExpiryCheckWorkflow from '@/components/workflow/workflows/ExpiryCheckWorkflow.vue';
|
import ExpiryCheckWorkflow from '@/components/workflow/workflows/ExpiryCheckWorkflow.vue';
|
||||||
import BackupRestoreWorkflow from '@/components/workflow/workflows/BackupRestoreWorkflow.vue';
|
import BackupRestoreWorkflow from '@/components/workflow/workflows/BackupRestoreWorkflow.vue';
|
||||||
|
|
||||||
/**
|
|
||||||
* Workflows with a fully co-located component + metadata.
|
|
||||||
*/
|
|
||||||
const workflows = [
|
const workflows = [
|
||||||
{...FotoFirstBulkImportWorkflow.meta, component: FotoFirstBulkImportWorkflow},
|
{...FotoFirstBulkImportWorkflow.meta, component: FotoFirstBulkImportWorkflow},
|
||||||
{...BulkItemImportWorkflow.meta, component: BulkItemImportWorkflow},
|
{...BulkItemImportWorkflow.meta, component: BulkItemImportWorkflow},
|
||||||
|
|
@ -38,56 +17,27 @@ const workflows = [
|
||||||
{...BackupRestoreWorkflow.meta, component: BackupRestoreWorkflow},
|
{...BackupRestoreWorkflow.meta, component: BackupRestoreWorkflow},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Get every workflow in the catalog.
|
|
||||||
* @returns {Array<Object>}
|
|
||||||
*/
|
|
||||||
export function getAllWorkflows() {
|
export function getAllWorkflows() {
|
||||||
return workflows;
|
return workflows;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a single workflow definition by id.
|
|
||||||
* @param {string} id
|
|
||||||
* @returns {Object|undefined}
|
|
||||||
*/
|
|
||||||
export function getWorkflow(slug) {
|
export function getWorkflow(slug) {
|
||||||
return workflows.find(workflow => workflow.slug === 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) {
|
export function getWorkflowComponent(id) {
|
||||||
return getWorkflow(id)?.component || null;
|
return getWorkflow(id)?.component || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all workflows belonging to a category.
|
|
||||||
* @param {string} category
|
|
||||||
* @returns {Array<Object>}
|
|
||||||
*/
|
|
||||||
export function getWorkflowsByCategory(category) {
|
export function getWorkflowsByCategory(category) {
|
||||||
return workflows.filter(workflow => workflow.category === category);
|
return workflows.filter(workflow => workflow.category === category);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all unique categories present in the catalog.
|
|
||||||
* @returns {Array<string>}
|
|
||||||
*/
|
|
||||||
export function getWorkflowCategories() {
|
export function getWorkflowCategories() {
|
||||||
return [...new Set(workflows.map(workflow => workflow.category))];
|
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) {
|
export function buildWorkflowApiPayload(workflow) {
|
||||||
const ownPayload = workflow.getInitialPayload ? workflow.getInitialPayload() : {};
|
const ownPayload = workflow.getInitialPayload ? workflow.getInitialPayload() : {};
|
||||||
return {
|
return {
|
||||||
|
|
@ -102,11 +52,7 @@ 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) {
|
export function serializeWorkflowPayload(workflow) {
|
||||||
console.log(workflow);
|
console.log(workflow);
|
||||||
if (!workflow || !('payload' in workflow)) return workflow;
|
if (!workflow || !('payload' in workflow)) return workflow;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue