stash
This commit is contained in:
parent
25cef95711
commit
7d7730354e
8 changed files with 381 additions and 39 deletions
|
|
@ -6,7 +6,7 @@ from rest_framework.permissions import IsAuthenticated
|
|||
from rest_framework.response import Response
|
||||
|
||||
from authentication.models import ToolshedUser, KnownIdentity
|
||||
from authentication.signature_auth import SignatureAuthentication
|
||||
from authentication.signature_auth import SignatureAuthentication, split_userhandle_or_throw
|
||||
from files.models import File
|
||||
from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance
|
||||
from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer, WorkflowInstanceSerializer
|
||||
|
|
@ -76,6 +76,37 @@ def search_inventory_items(request):
|
|||
return Response({'error': 'No query provided.'}, status=400)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@authentication_classes([SignatureAuthentication])
|
||||
@permission_classes([IsAuthenticated])
|
||||
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
|
||||
/i/<handle>/<id> item URL (see docs/design-in-progress/items-labels.md) or the
|
||||
/inventory/shared/<handle>/<id> in-app view. Unlike InventoryItemViewSet, which only ever
|
||||
returns the requester's own items, this looks the item up by owner instead of by requester,
|
||||
so it's the only endpoint that can serve a friend's item - subject to the same
|
||||
friends-or-self and availability_policy checks getUserProfile/_accessible_files already
|
||||
use elsewhere."""
|
||||
try:
|
||||
username, domain = split_userhandle_or_throw(handle)
|
||||
except ValueError:
|
||||
return Response(status=400)
|
||||
try:
|
||||
owner = ToolshedUser.objects.get(username=username, domain=domain)
|
||||
except ToolshedUser.DoesNotExist:
|
||||
return Response(status=404)
|
||||
if owner not in request.user.friends_or_self():
|
||||
return Response(status=403)
|
||||
try:
|
||||
item = owner.inventory_items.get(id=id)
|
||||
except InventoryItem.DoesNotExist:
|
||||
return Response(status=404)
|
||||
is_owner = request.user.user.filter(pk=owner.pk).exists()
|
||||
if item.availability_policy == 'private' and not is_owner:
|
||||
return Response(status=403)
|
||||
return Response(InventoryItemSerializer(item).data)
|
||||
|
||||
|
||||
class StorageLocationViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = StorageLocationSerializer
|
||||
authentication_classes = [SignatureAuthentication]
|
||||
|
|
@ -136,4 +167,5 @@ router.register(r'workflows', WorkflowInstanceViewSet, basename='workflows')
|
|||
|
||||
urlpatterns = router.urls + [
|
||||
path('search/', search_inventory_items, name='search_inventory_items'),
|
||||
path('inventory_items/<str:handle>/<int:id>/', get_shared_item, name='shared_inventory_item'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -213,6 +213,47 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
|
|||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 0)
|
||||
|
||||
def test_get_shared_item_as_friend(self):
|
||||
reply = client.get('/api/inventory_items/testuser1@example.com/' + str(self.f['item1'].id) + '/',
|
||||
self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(reply.json()['name'], 'test1')
|
||||
self.assertEqual(reply.json()['owner'], 'testuser1@example.com')
|
||||
|
||||
def test_get_shared_item_as_owner(self):
|
||||
reply = client.get('/api/inventory_items/testuser1@example.com/' + str(self.f['item1'].id) + '/',
|
||||
self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(reply.json()['name'], 'test1')
|
||||
|
||||
def test_get_shared_item_not_friend(self):
|
||||
reply = client.get('/api/inventory_items/testuser1@example.com/' + str(self.f['item1'].id) + '/',
|
||||
self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 403)
|
||||
|
||||
def test_get_shared_item_private(self):
|
||||
private_item = InventoryItem.objects.create(
|
||||
owner=self.f['local_user1'], owned_quantity=1, name='secret', availability_policy='private')
|
||||
reply = client.get('/api/inventory_items/testuser1@example.com/' + str(private_item.id) + '/',
|
||||
self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 403)
|
||||
reply = client.get('/api/inventory_items/testuser1@example.com/' + str(private_item.id) + '/',
|
||||
self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
|
||||
def test_get_shared_item_unknown_handle(self):
|
||||
reply = client.get('/api/inventory_items/nobody@example.com/' + str(self.f['item1'].id) + '/',
|
||||
self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
|
||||
def test_get_shared_item_unknown_id(self):
|
||||
reply = client.get('/api/inventory_items/testuser1@example.com/99999/', self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
|
||||
def test_get_shared_item_bad_handle(self):
|
||||
reply = client.get('/api/inventory_items/testuser1/' + str(self.f['item1'].id) + '/', self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 400)
|
||||
|
||||
|
||||
class TestInventoryItemWithFileApiTestCase(UserTestMixin, FilesTestMixin, InventoryTestMixin, ToolshedTestCase):
|
||||
def setUp(self):
|
||||
|
|
|
|||
|
|
@ -21,3 +21,96 @@ their own public key to the friend's server. This way both users can access each
|
|||
|
||||
The protocol is based on a simple HTTPS API exchanging JSON data that is signed with the user's private key. By default
|
||||
Toolshed servers provide a documentation of the API at [/docs/api](/docs/api).
|
||||
|
||||
## Unique Handles
|
||||
|
||||
Federation only works if every server can talk about the same thing without a central authority to ask. Toolshed's
|
||||
answer is that every kind of entity that needs to be referenced across servers gets a handle: a name that is unique
|
||||
within its own scope and that carries, as part of itself, enough information to say where it is authoritative. This
|
||||
keeps servers independent of each other while still letting them agree on what they're talking about.
|
||||
|
||||
### Users (and Groups)
|
||||
|
||||
A user's handle is their username paired with the domain their account belongs to, written the way an email address
|
||||
is, e.g. `user@toolsheddomain.tld`. Uniqueness is only required within a single domain, not across all of Toolshed,
|
||||
so two different domains can each have their own "alice" without conflict, the same way two different email
|
||||
providers can each have an "alice" mailbox. The domain half of the handle is what makes the name globally
|
||||
unambiguous, and it is also what tells any other backend where to look to find out who's currently authoritative for
|
||||
that identity, i.e. which backend holds the account and can vouch for its public key.
|
||||
|
||||
Groups aren't implemented yet, but they're intended to fit the same idea: a group would get its own handle on the
|
||||
domain of the server that hosts it, the same way a user does, so that group membership and group-owned data could be
|
||||
referenced by other servers without needing a separate mechanism. A group handle is written with a leading `#`, e.g.
|
||||
`#groupname@toolsheddomain.tld`, so that group and user handles occupy visibly distinct spaces on the same domain
|
||||
and a name can't be squatted as one to collide with the other. See [groups.md](design-in-progress/groups.md) for
|
||||
details.
|
||||
|
||||
### Servers
|
||||
|
||||
The domain half of a handle, e.g. `toolsheddomain.tld`, is an authority record, not a location. Owning a domain
|
||||
just means being able to say which backend is currently authoritative for handles under it; it says nothing about
|
||||
where that backend is hosted, who operates it, or how many other domains it might also be authoritative for. A
|
||||
single backend can just as easily host entities for one domain or for many unrelated ones at once, there's no
|
||||
assumption anywhere in the model that a domain and a backend are the same thing, or that the relationship is one to
|
||||
one.
|
||||
|
||||
The frontend application is a third, separate thing again. The app a user loads isn't necessarily served by, or
|
||||
even related to, the backend that ends up handling their requests: when given a handle, the frontend looks up which
|
||||
backend is currently authoritative for that handle's domain and talks to that backend directly from then on. So
|
||||
using the frontend at one domain to log into a backend authoritative for a completely different domain isn't a
|
||||
special case, it's the normal path, since "where the app was loaded from" and "which backend answers for a given
|
||||
handle" were never the same question to begin with. Servers, in the cryptographic sense described below, don't have
|
||||
an identity of their own beyond the handles they're currently authoritative for; a backend is, conceptually, just
|
||||
wherever a given domain's handles happen to resolve to right now.
|
||||
|
||||
### Tags, Properties, and Categories
|
||||
|
||||
Inventory items aren't just described in free text, they can be classified with tags, properties, and categories,
|
||||
and those get handles too, written as an origin followed by the kind and name, e.g. `origin#tag:drill` or
|
||||
`origin#category:power-tools`. This lets the same short name (e.g. a "drill" tag) exist independently under
|
||||
different origins without colliding, while a handle as a whole unambiguously says which taxonomy an entry belongs
|
||||
to.
|
||||
|
||||
An origin isn't necessarily a server; it's whatever the classification is considered to have come from, which could
|
||||
be a shared, canonical reference dataset that multiple servers import and reuse, just as easily as it could be a
|
||||
server's own locally-invented taxonomy. This lets independently-run servers converge on a shared vocabulary where it
|
||||
matters, without forcing every server to invent its own from scratch or requiring a central body to define one.
|
||||
|
||||
Handles are resolved strictly: a reference to an origin or entity a server doesn't know about is left unresolved
|
||||
rather than being guessed at or silently merged into something that looks similar. This mirrors the rest of
|
||||
Toolshed's federation philosophy, nothing is combined across servers implicitly; agreement always has to be
|
||||
traceable to an explicit, shared handle.
|
||||
|
||||
### Items
|
||||
|
||||
Inventory items don't get a handle of their own the way tags or categories do, because they don't need one: every
|
||||
item belongs to exactly one user, so a simple local identifier is already enough to tell two items apart within that
|
||||
user's inventory. Combined with the owner's user handle, that local identifier is automatically unique across all of
|
||||
Toolshed too, since no two users share a handle. Unlike a tag or category, an item isn't meant to be the same entity
|
||||
reused across servers, it describes something one specific person actually owns, so there's no shared-origin concept
|
||||
to design for here, ownership alone already provides the scope.
|
||||
|
||||
## Cryptography
|
||||
|
||||
Handles say who or what is being referred to; cryptography is what lets a server trust that the entity behind a
|
||||
handle really is who it claims to be, without needing to ask a central authority. Every user handle has exactly one
|
||||
asymmetric keypair backing it: a private key that never leaves the user's control, and a public key that gets handed
|
||||
out freely as part of establishing that handle elsewhere.
|
||||
|
||||
A server first learns a public key at the moment it has reason to trust it: for its own users, that's registration;
|
||||
for a friend's handle, that's the friend-request/accept exchange described above. From then on, a public key is
|
||||
permanently paired with the handle it arrived with, never with a server. This is why friending is really a
|
||||
key-exchange ceremony rather than just a social action, accepting a request is the moment a server starts trusting a
|
||||
new handle's signature.
|
||||
|
||||
Every request made on a user's behalf is signed with that user's private key, and whichever server receives it
|
||||
verifies the signature against the public key it holds for that handle. This is what makes it safe for a request to
|
||||
travel to a server that isn't the user's home server: the receiving server doesn't need to trust the network path or
|
||||
the sender, only the signature.
|
||||
|
||||
It's worth being explicit about what this layer of cryptography is for and what it isn't. Signing establishes
|
||||
authenticity and integrity, that a request genuinely came from the handle it claims to, unaltered, not
|
||||
confidentiality. The data itself isn't encrypted by the protocol; keeping it private in transit is what the
|
||||
underlying HTTPS layer is for. Only user handles carry a keypair; tags, categories, properties, and item handles are
|
||||
just names, their trustworthiness comes entirely from being reachable only through a signed request from the user
|
||||
handle that owns or created them, not from any cryptographic identity of their own.
|
||||
|
|
@ -52,6 +52,12 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
|||
component: InventoryDetailForeign,
|
||||
meta: {requiresAuth: true, foreign: true},
|
||||
props: true
|
||||
}, {
|
||||
path: '/i/:handle/:id',
|
||||
redirect: to => {
|
||||
const {handle, id} = to.params
|
||||
return handle === store.state.user ? '/inventory/' + id : '/inventory/shared/' + handle + '/' + id
|
||||
}
|
||||
}, {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, {
|
||||
path: '/friends',
|
||||
component: Friends,
|
||||
|
|
@ -68,7 +74,12 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
|||
}, {path: '/admin',
|
||||
component: Admin,
|
||||
meta: {requiresAuth: true}
|
||||
}, {path: '/swatch', component: Swatch, meta: {requiresAuth: true}}, {path: '/print', component: Print, meta: {requiresAuth: true}}, {
|
||||
}, {path: '/swatch', component: Swatch, meta: {requiresAuth: true}}, {
|
||||
path: '/print',
|
||||
component: Print,
|
||||
meta: {requiresAuth: true},
|
||||
props: route => ({prefill: route.query.text})
|
||||
}, {
|
||||
path: '/search/:query',
|
||||
component: Search,
|
||||
meta: {requiresAuth: true},
|
||||
|
|
|
|||
|
|
@ -412,6 +412,22 @@ export default createStore({
|
|||
return null;
|
||||
}
|
||||
},
|
||||
async fetchForeignItem({dispatch, getters}, {owner, id}) {
|
||||
try {
|
||||
const servers = await dispatch('getFriendServers', {username: owner});
|
||||
// owner here is a full handle (username@domain) - see the /api/inventory_items/<handle>/<id>/
|
||||
// endpoint (toolshed/api/inventory.py get_shared_item), which looks the item up by owner rather
|
||||
// than by requester, unlike the plain /api/inventory_items/ list/detail endpoints.
|
||||
const item = await servers.get(getters.signAuth, '/api/inventory_items/' + owner + '/' + id + '/');
|
||||
if (item && item.files) {
|
||||
item.files.forEach(file => file.owner = item.owner)
|
||||
}
|
||||
return item;
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch item ${id} for ${owner}:`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async fetchFriendRequests({state, dispatch, getters}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
return await servers.get(getters.signAuth, '/api/friendrequests/')
|
||||
|
|
|
|||
|
|
@ -46,6 +46,11 @@
|
|||
<b-icon-trash></b-icon-trash>
|
||||
Delete
|
||||
</button>
|
||||
<button class="btn btn-secondary"
|
||||
@click="$router.push({path: '/print', query: {text: itemUrl}})">
|
||||
<b-icon-printer></b-icon-printer>
|
||||
Print label
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
@ -75,12 +80,18 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
...mapGetters(["loaded_items", "getNameFromHandle"]),
|
||||
...mapState(["storage_locations"]),
|
||||
...mapState(["storage_locations", "user"]),
|
||||
item() {
|
||||
return this.loaded_items.find(item => item.id === parseInt(this.id)) || {}
|
||||
},
|
||||
location() {
|
||||
return this.storage_locations.find(loc => loc.id === this.item.storage_location) || null
|
||||
},
|
||||
itemUrl() {
|
||||
// The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what
|
||||
// a printed label actually encodes, since scanning it has to resolve the right
|
||||
// frontend/backend/item with no other context, not just this browser's history.
|
||||
return `${window.location.origin}/i/${this.user}/${this.id}`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
|||
|
|
@ -6,9 +6,13 @@
|
|||
<div class="card">
|
||||
<div class="card-header">{{ item.name }}</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="owner" class="form-label">Owner</label>
|
||||
{{ item.owner }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
Foreighn {{ item.description }}
|
||||
{{ item.description }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="tags" class="form-label">Tags</label>
|
||||
|
|
@ -36,18 +40,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<button class="btn btn-primary" @click="$router.push('/inventory/' + id + '/edit')">
|
||||
<b-icon-pencil-square></b-icon-pencil-square>
|
||||
Edit
|
||||
</button>
|
||||
<button type="submit" class="btn btn-danger"
|
||||
@click="deleteInventoryItem(item).then(() => $router.push('/inventory'))">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
|
@ -57,38 +49,46 @@
|
|||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import {mapActions, mapGetters, mapState} from "vuex";
|
||||
import {mapActions, mapGetters} from "vuex";
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
|
||||
export default {
|
||||
name: "InventoryDetail",
|
||||
name: "InventoryDetailForeign",
|
||||
components: {
|
||||
AuthenticatedImage,
|
||||
BaseLayout,
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
user: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(["loaded_items", "getNameFromHandle"]),
|
||||
...mapState(["storage_locations"]),
|
||||
item() {
|
||||
return this.loaded_items.find(item => item.id === parseInt(this.id)) || {}
|
||||
},
|
||||
location() {
|
||||
return this.storage_locations.find(loc => loc.id === this.item.storage_location) || null
|
||||
data() {
|
||||
return {
|
||||
item: {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchFilesByItem", "fetchStorageLocations"]),
|
||||
computed: {
|
||||
...mapGetters(["getNameFromHandle"]),
|
||||
},
|
||||
async mounted() {
|
||||
await this.fetchInventoryItems()
|
||||
await this.fetchStorageLocations()
|
||||
methods: {
|
||||
...mapActions(["fetchForeignItem"]),
|
||||
async loadItem() {
|
||||
this.item = await this.fetchForeignItem({owner: this.user, id: this.id}) || {}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
user: 'loadItem',
|
||||
id: 'loadItem'
|
||||
},
|
||||
mounted() {
|
||||
this.loadItem()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -7,8 +7,51 @@
|
|||
<div v-if="error" class="alert alert-danger" role="alert">{{ error }}</div>
|
||||
|
||||
<div v-if="!usbSupported" class="alert alert-warning">
|
||||
This browser can't talk to USB label printers. Open this page in Chrome, Edge or Opera
|
||||
over https:// (or http://localhost).
|
||||
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
|
||||
alternatives below.
|
||||
</div>
|
||||
|
||||
<div v-if="!usbSupported" class="row">
|
||||
<div class="col-lg-7">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Label preview</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">QR code content</label>
|
||||
<input type="text" class="form-control" v-model="value"
|
||||
placeholder="https://example.com/…" autofocus>
|
||||
</div>
|
||||
|
||||
<div class="label-preview mb-3" v-show="value">
|
||||
<canvas ref="fallbackCanvas"></canvas>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" :disabled="!fallbackReady" @click="downloadPng">
|
||||
<b-icon-download class="me-1"></b-icon-download>
|
||||
Download label as PNG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Print from the command line</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">
|
||||
Download the PNG above, then send it to a label printer with its CLI tool.
|
||||
</p>
|
||||
<p class="mb-1"><strong>Brother QL-series</strong></p>
|
||||
<pre class="code-block"><code>{{ brotherCliExample }}</code></pre>
|
||||
<p class="mb-1"><strong>Niimbot</strong></p>
|
||||
<pre class="code-block"><code>{{ niimbotCliExample }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="row">
|
||||
|
|
@ -172,12 +215,49 @@ function drawQrLabel(canvas, qr, tape) {
|
|||
}
|
||||
}
|
||||
|
||||
const FALLBACK_SCALE_PX = 8; /* pixels per QR module in the no-webusb preview/PNG */
|
||||
const FALLBACK_QUIET_ZONE_MODULES = 4; /* the spec's usual quiet zone - there's no printer feed margin to lean on here */
|
||||
|
||||
/* Same idea as drawQrLabel, but without a real device to ask for tape dimensions: just a
|
||||
plain square QR code, sized for a PNG someone downloads and prints some other way. */
|
||||
function drawQrSquare(canvas, qr) {
|
||||
const modules = qr.modules.size + FALLBACK_QUIET_ZONE_MODULES * 2;
|
||||
const size = modules * FALLBACK_SCALE_PX;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
|
||||
const ctx = canvas.getContext("2d", {willReadFrequently: true});
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
ctx.fillStyle = "#000";
|
||||
for (let row = 0; row < qr.modules.size; row++) {
|
||||
for (let col = 0; col < qr.modules.size; col++) {
|
||||
if (qr.modules.get(row, col)) {
|
||||
ctx.fillRect(
|
||||
(col + FALLBACK_QUIET_ZONE_MODULES) * FALLBACK_SCALE_PX,
|
||||
(row + FALLBACK_QUIET_ZONE_MODULES) * FALLBACK_SCALE_PX,
|
||||
FALLBACK_SCALE_PX, FALLBACK_SCALE_PX,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "Print",
|
||||
components: {
|
||||
BaseLayout,
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
// Prefilled from ?text=… when arriving from e.g. an item's "Print label" button
|
||||
// (see InventoryDetail.vue) - the router turns that query param into this prop
|
||||
// (router.js's /print route), rather than the component reading $route directly.
|
||||
prefill: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
usbSupported: true,
|
||||
|
|
@ -188,8 +268,13 @@ export default {
|
|||
devices: [],
|
||||
connected: null,
|
||||
|
||||
value: "",
|
||||
value: this.prefill || "",
|
||||
copies: 1,
|
||||
|
||||
fallbackReady: false,
|
||||
// TODO: replace with the real commands for our printers.
|
||||
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",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -214,7 +299,11 @@ export default {
|
|||
},
|
||||
watch: {
|
||||
value() {
|
||||
this.redraw();
|
||||
if (this.usbSupported) {
|
||||
this.redraw();
|
||||
} else {
|
||||
this.redrawFallback();
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -299,6 +388,38 @@ export default {
|
|||
this.fitZoom(canvas);
|
||||
},
|
||||
|
||||
redrawFallback() {
|
||||
this.fallbackReady = false;
|
||||
if (!this.value) {
|
||||
return;
|
||||
}
|
||||
const canvas = this.$refs.fallbackCanvas;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const qr = QRCode.create(this.value);
|
||||
drawQrSquare(canvas, qr);
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
return;
|
||||
}
|
||||
this.error = null;
|
||||
this.fallbackReady = true;
|
||||
this.fitZoom(canvas);
|
||||
},
|
||||
|
||||
downloadPng() {
|
||||
const canvas = this.$refs.fallbackCanvas;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
const link = document.createElement("a");
|
||||
link.download = "label.png";
|
||||
link.href = canvas.toDataURL("image/png");
|
||||
link.click();
|
||||
},
|
||||
|
||||
/* Fit the preview to its card without ever needing a horizontal
|
||||
scrollbar for a label this small, magnifying short labels up to
|
||||
MAX_ZOOM rather than showing them at native (tiny) size. */
|
||||
|
|
@ -322,7 +443,13 @@ export default {
|
|||
|
||||
handleResize() {
|
||||
clearTimeout(this.resizeTimer);
|
||||
this.resizeTimer = setTimeout(() => this.redraw(), 100);
|
||||
this.resizeTimer = setTimeout(() => {
|
||||
if (this.usbSupported) {
|
||||
this.redraw();
|
||||
} else {
|
||||
this.redrawFallback();
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
|
||||
onUsbChange() {
|
||||
|
|
@ -336,8 +463,11 @@ export default {
|
|||
this.resizeTimer = null;
|
||||
},
|
||||
async mounted() {
|
||||
window.addEventListener("resize", this.handleResize);
|
||||
if (!("usb" in navigator)) {
|
||||
this.usbSupported = false;
|
||||
await nextTick();
|
||||
this.redrawFallback();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -349,7 +479,6 @@ export default {
|
|||
this.blobReady = true;
|
||||
navigator.usb.addEventListener("connect", this.onUsbChange);
|
||||
navigator.usb.addEventListener("disconnect", this.onUsbChange);
|
||||
window.addEventListener("resize", this.handleResize);
|
||||
await this.guard(() => this.refreshDevices());
|
||||
},
|
||||
beforeUnmount() {
|
||||
|
|
@ -381,4 +510,13 @@ export default {
|
|||
.copies-input {
|
||||
width: 5.5rem;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: rgba(127, 127, 127, .08);
|
||||
border-radius: .35rem;
|
||||
padding: .75rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue