From a0e8675b82f2e44bb1c66d7b7890aadfaeee2d20 Mon Sep 17 00:00:00 2001 From: Francis Rath Date: Wed, 28 Oct 2026 21:47:46 +0100 Subject: [PATCH 1/5] feat(labels): add print agent connect page Co-Authored-By: Claude Opus 5.5 --- src/Controller/LabelController.php | 11 +++ templates/label_system/agent.html.twig | 43 +++++++++++ .../ApplicationAvailabilityFunctionalTest.php | 1 + translations/messages.en.xlf | 72 +++++++++++++++++++ 4 files changed, 127 insertions(+) create mode 100644 templates/label_system/agent.html.twig diff --git a/src/Controller/LabelController.php b/src/Controller/LabelController.php index f5008c682..d93b6cf0b 100644 --- a/src/Controller/LabelController.php +++ b/src/Controller/LabelController.php @@ -211,6 +211,17 @@ public function generator(Request $request, ?LabelProfile $profile = null): Resp ]); } + /** + * Connects this browser to a local print agent (stored in the browser only, see docs/usage/labels.md). + */ + #[Route(path: '/agent', name: 'label_agent')] + public function agent(): Response + { + $this->denyAccessUnlessGranted('@labels.create_labels'); + + return $this->render('label_system/agent.html.twig'); + } + protected function getLabelName(AbstractDBElement $element, ?LabelProfile $profile = null): string { $ret = 'label_'.$this->elementTypeNameGenerator->getLocalizedTypeLabel($element); diff --git a/templates/label_system/agent.html.twig b/templates/label_system/agent.html.twig new file mode 100644 index 000000000..ebac35e53 --- /dev/null +++ b/templates/label_system/agent.html.twig @@ -0,0 +1,43 @@ +{% extends 'main_card.html.twig' %} + +{% block title %}{% trans %}label_generator.agent.title{% endtrans %}{% endblock %} + +{%- block card_title -%} + {% trans %}label_generator.agent.title{% endtrans %} +{%- endblock -%} + +{% block card_content %} +
+

{% trans %}label_generator.agent.explanation{% endtrans %} + {% trans %}label_generator.agent.docs{% endtrans %} +

+ +
+
+ {% trans %}label_generator.agent.confirm{% endtrans %} + +
+ +
+ +
+
+ {% trans %}label_generator.agent.connected{% endtrans %} + +
+ +
+ +
+ {% trans %}label_generator.agent.invalid{% endtrans %} +
+ +
+ {% trans %}label_generator.agent.none{% endtrans %} +
+
+{% endblock %} diff --git a/tests/ApplicationAvailabilityFunctionalTest.php b/tests/ApplicationAvailabilityFunctionalTest.php index 3bb222d07..5fbca2d33 100644 --- a/tests/ApplicationAvailabilityFunctionalTest.php +++ b/tests/ApplicationAvailabilityFunctionalTest.php @@ -123,6 +123,7 @@ public static function urlProvider(): ?Generator yield ['/label/dialog?target_id=1&target_type=part']; yield ['/label/1/dialog']; yield ['/label/1/dialog?target_id=1&target_type=part&generate=1']; + yield ['/label/agent']; //Scan test yield ['/scan']; //Interactive scan dialog diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 81c1c1c03..cf3b4f308 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -16046,5 +16046,77 @@ Buerklin-API Authentication server: The information provider (%host%) is temporarily unavailable (HTTP %status%). Please try again later. + + + label_generator.agent.title + Print agent + + + + + label_generator.agent.explanation + A print agent is a small program on this computer that prints labels directly on a label printer, without the browser's print dialog. Once this browser is connected, a print button appears next to the label download and in the label menus. + + + + + label_generator.agent.docs + How it works + + + + + label_generator.agent.confirm + Connect this browser to the print agent at + + + + + label_generator.agent.connect + Connect + + + + + label_generator.agent.connected + This browser prints labels with the print agent at + + + + + label_generator.agent.disconnect + Disconnect + + + + + label_generator.agent.invalid + This connection link is invalid. Open it again from your print agent. + + + + + label_generator.agent.none + This browser is not connected to a print agent. Open the connection link from your print agent's settings. + + + + + tree.tools.tools.print_agent + Print agent + + + + + label_generator.print + Print + + + + + label_generator.print.title + Print with your print agent (uses the saved label profile) + + From a14c2f6d63f18f88c6477544452dc9b28d2c2bd7 Mon Sep 17 00:00:00 2001 From: Francis Rath Date: Wed, 28 Oct 2026 21:49:11 +0100 Subject: [PATCH 2/5] feat(labels): store the print agent connection per browser Co-Authored-By: Claude Opus 5.5 --- .../pages/print_agent_connect_controller.js | 64 +++++++++++++++ assets/helpers/print_agent.js | 78 +++++++++++++++++++ templates/label_system/agent.html.twig | 4 +- 3 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 assets/controllers/pages/print_agent_connect_controller.js create mode 100644 assets/helpers/print_agent.js diff --git a/assets/controllers/pages/print_agent_connect_controller.js b/assets/controllers/pages/print_agent_connect_controller.js new file mode 100644 index 000000000..526b7a6f6 --- /dev/null +++ b/assets/controllers/pages/print_agent_connect_controller.js @@ -0,0 +1,64 @@ +/* + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {Controller} from "@hotwired/stimulus"; +import {clearAgent, getAgent, parseConnectHash, setAgent} from "../../helpers/print_agent"; + +/** + * Connects this browser to a print agent from the link the agent hands out (/label/agent#url=…&key=…). + * Saving needs a click, so a foreign link can't silently redirect labels to another agent. + */ +export default class extends Controller +{ + static targets = ['confirm', 'connected', 'invalid', 'none', 'url']; + + connect() { + this._pending = null; + if (location.hash.length > 1) { + this._pending = parseConnectHash(location.hash); + this._show(this._pending ? 'confirm' : 'invalid', this._pending?.url); + return; + } + this._showCurrent(); + } + + connectAgent() { + setAgent(this._pending.url, this._pending.key); + this._pending = null; + history.replaceState(null, '', location.pathname + location.search); + this._showCurrent(); + } + + disconnectAgent() { + clearAgent(); + this._showCurrent(); + } + + _showCurrent() { + const agent = getAgent(); + this._show(agent ? 'connected' : 'none', agent?.url); + } + + _show(state, url = '') { + for (const name of ['confirm', 'connected', 'invalid', 'none']) { + this[name + 'Target'].classList.toggle('d-none', name !== state); + } + this.urlTargets.forEach(el => el.textContent = url); + } +} diff --git a/assets/helpers/print_agent.js b/assets/helpers/print_agent.js new file mode 100644 index 000000000..ccccfa1f4 --- /dev/null +++ b/assets/helpers/print_agent.js @@ -0,0 +1,78 @@ +/* + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/* + * The connection to a local print agent lives only in this browser: the agent runs on this computer. + * See docs/usage/labels.md for the handoff contract. + */ + +const STORAGE_KEY = 'partdb.print_agent'; + +/** @returns {{url: string, key: string}|null} */ +export function getAgent() { + try { + const agent = JSON.parse(localStorage.getItem(STORAGE_KEY)); + return agent && typeof agent.url === 'string' && typeof agent.key === 'string' ? agent : null; + } catch { + return null; + } +} + +export function setAgent(url, key) { + localStorage.setItem(STORAGE_KEY, JSON.stringify({url, key})); +} + +export function clearAgent() { + localStorage.removeItem(STORAGE_KEY); +} + +/** + * Parses the connection link fragment "#url=&key=". + * @returns {{url: string, key: string}|null} null unless the url is http(s) and the key is hex + */ +export function parseConnectHash(hash) { + const params = new URLSearchParams(hash.replace(/^#/, '')); + const key = params.get('key') ?? ''; + let url; + try { + url = new URL(params.get('url') ?? ''); + } catch { + return null; + } + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !/^[0-9a-fA-F]+$/.test(key)) { + return null; + } + return {url: url.href.replace(/\/$/, ''), key}; +} + +/** + * Builds the agent's handoff URL, signed with the pairing key (HMAC-SHA256 over + * src, type, ids, profile, ts joined by newlines). Without WebCrypto (plain http) it stays + * unsigned and the agent asks for a confirmation click. + */ +export async function handoffUrl(agent, {type, ids, profile}) { + const p = {src: location.origin, type, ids, profile: String(profile), ts: String(Math.floor(Date.now() / 1000))}; + if (globalThis.crypto?.subtle) { + const enc = new TextEncoder(); + const key = await crypto.subtle.importKey('raw', enc.encode(agent.key), {name: 'HMAC', hash: 'SHA-256'}, false, ['sign']); + const sig = await crypto.subtle.sign('HMAC', key, enc.encode([p.src, p.type, p.ids, p.profile, p.ts].join('\n'))); + p.sig = Array.from(new Uint8Array(sig), b => b.toString(16).padStart(2, '0')).join(''); + } + return agent.url + '/v1/handoff?' + new URLSearchParams(p); +} diff --git a/templates/label_system/agent.html.twig b/templates/label_system/agent.html.twig index ebac35e53..7a2dda932 100644 --- a/templates/label_system/agent.html.twig +++ b/templates/label_system/agent.html.twig @@ -17,7 +17,7 @@ {% trans %}label_generator.agent.confirm{% endtrans %} - @@ -27,7 +27,7 @@ {% trans %}label_generator.agent.connected{% endtrans %} - From 6d98115b865b0d4eef2024a45043eca3b734f59a Mon Sep 17 00:00:00 2001 From: Francis Rath Date: Wed, 28 Oct 2026 21:51:34 +0100 Subject: [PATCH 3/5] feat(labels): print button for the local print agent Co-Authored-By: Claude Opus 5.5 --- .../pages/print_agent_print_controller.js | 54 ++++++++++++++++ templates/label_system/dialog.html.twig | 10 +++ tests/Controller/LabelControllerTest.php | 64 +++++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 assets/controllers/pages/print_agent_print_controller.js create mode 100644 tests/Controller/LabelControllerTest.php diff --git a/assets/controllers/pages/print_agent_print_controller.js b/assets/controllers/pages/print_agent_print_controller.js new file mode 100644 index 000000000..1abe8f757 --- /dev/null +++ b/assets/controllers/pages/print_agent_print_controller.js @@ -0,0 +1,54 @@ +/* + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {Controller} from "@hotwired/stimulus"; +import {getAgent, handoffUrl} from "../../helpers/print_agent"; + +/** + * Print button for the print agent connected on /label/agent. Hidden while this browser has no agent. + */ +export default class extends Controller +{ + static values = { + type: String, + ids: String, + profile: Number, + }; + + connect() { + this.element.classList.toggle('d-none', getAgent() === null); + } + + async print(event) { + event.preventDefault(); + const agent = getAgent(); + if (!agent) { + return; + } + // Open the window now, while the click still counts as a user action: after the + // async signing below, popup blockers would stop it. + const win = window.open('', 'pdb-print', 'width=420,height=320'); + const url = await handoffUrl(agent, {type: this.typeValue, ids: this.idsValue, profile: this.profileValue}); + if (win) { + win.location.href = url; + } else { + location.href = url; + } + } +} diff --git a/templates/label_system/dialog.html.twig b/templates/label_system/dialog.html.twig index b52aaccf0..ab1b3533b 100644 --- a/templates/label_system/dialog.html.twig +++ b/templates/label_system/dialog.html.twig @@ -128,6 +128,16 @@ {% trans %}label_generator.download{% endtrans %} + {% if profile %} + + {% endif %} {% endif %} diff --git a/tests/Controller/LabelControllerTest.php b/tests/Controller/LabelControllerTest.php new file mode 100644 index 000000000..64e90812a --- /dev/null +++ b/tests/Controller/LabelControllerTest.php @@ -0,0 +1,64 @@ +. + */ + +declare(strict_types=1); + +namespace App\Tests\Controller; + +use App\Entity\UserSystem\User; +use PHPUnit\Framework\Attributes\Group; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; + +#[Group("slow")] +#[Group("DB")] +final class LabelControllerTest extends WebTestCase +{ + private const PRINT_BUTTON = '[data-controller="pages--print-agent-print"]'; + + private function loginAsAdmin($client): void + { + $user = $client->getContainer()->get('doctrine')->getManager() + ->getRepository(User::class)->findOneBy(['name' => 'admin']); + $client->loginUser($user); + } + + public function testDialogWithProfileHasPrintButton(): void + { + $client = static::createClient(); + $this->loginAsAdmin($client); + $client->request('GET', '/en/label/1/dialog?target_id=1&target_type=part&generate=1'); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorExists(self::PRINT_BUTTON + .'[data-pages--print-agent-print-profile-value="1"]' + .'[data-pages--print-agent-print-ids-value="1"]' + .'[data-pages--print-agent-print-type-value="part"]'); + } + + public function testDialogWithoutProfileHasNoPrintButton(): void + { + $client = static::createClient(); + $this->loginAsAdmin($client); + $client->request('GET', '/en/label/dialog?target_id=1&target_type=part'); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorNotExists(self::PRINT_BUTTON); + } +} From eb503369a365242b90909216c4f0551935378c4f Mon Sep 17 00:00:00 2001 From: Francis Rath Date: Wed, 28 Oct 2026 21:52:53 +0100 Subject: [PATCH 4/5] feat(labels): print icons in the label menu Adds a Tools entry for the print agent page and documents the agent contract. Co-Authored-By: Claude Opus 5.5 --- docs/usage/labels.md | 32 +++++++++++++++++++ src/Services/Trees/ToolsTreeBuilder.php | 4 +++ .../label_system/dropdown_macro.html.twig | 11 ++++++- tests/Controller/LabelControllerTest.php | 14 ++++++++ translations/messages.en.xlf | 24 +++++++------- 5 files changed, 72 insertions(+), 13 deletions(-) diff --git a/docs/usage/labels.md b/docs/usage/labels.md index f06e9ff57..646156ee6 100644 --- a/docs/usage/labels.md +++ b/docs/usage/labels.md @@ -17,6 +17,38 @@ the content (or write HTML code). Using the "Label placeholder" menu in the editor, you can insert placeholders for the data of the parts. It will be replaced by the concrete data when the label is generated. +## Printing with a print agent + +Label PDFs printed through the browser's print dialog are often scaled by the printer driver, which blurs barcodes on +small labels. A *print agent* is a small program running on your computer that prints the label directly on a label +printer at the printer's native resolution. Part-DB does not ship an agent; one example is +[pdb-print](https://github.com/francisrath/partdb-print-agent) for Brother QL printers. + +To connect a browser, open the connection link from your agent's settings and click "Connect" (or open +"Tools -> Print agent"). The connection is stored in this browser only (local storage), not on the server. +Afterwards a "Print" button appears next to "Download" in the label generator for saved label profiles, and a printer +icon next to each profile in the label menus of parts, part lots and storage locations. Printing always uses the saved +label profile; unsaved changes in the label generator are not printed. + +### Agent contract + +The connection link has the form `/label/agent#url=&key=`, where the pairing key +is a hex string. The fragment is never sent to the Part-DB server. + +When printing, Part-DB opens the following URL in a new window: + +``` +/v1/handoff?src=&type=&ids=&profile=