From aed2652f1d078436be5655543a9165f6bd5fd8d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 15 Feb 2026 13:52:56 +0100 Subject: [PATCH 1/5] Added functions to retrieve associated parts of an element within twig labels This fixes #1239 --- docs/usage/labels.md | 26 ++++++----- .../LabelSystem/SandboxedTwigFactory.php | 1 + src/Twig/Sandbox/SandboxedLabelExtension.php | 46 ++++++++++++++++++- 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/docs/usage/labels.md b/docs/usage/labels.md index 4c3f8b32..7d0d2ed3 100644 --- a/docs/usage/labels.md +++ b/docs/usage/labels.md @@ -91,7 +91,7 @@ in [official documentation](https://twig.symfony.com/doc/3.x/). Twig allows you for much more complex and dynamic label generation. You can use loops, conditions, and functions to create the label content and you can access almost all data Part-DB offers. The label templates are evaluated in a special sandboxed environment, -where only certain operations are allowed. Only read access to entities is allowed. However as it circumvents Part-DB normal permission system, +where only certain operations are allowed. Only read access to entities is allowed. However, as it circumvents Part-DB normal permission system, the twig mode is only available to users with the "Twig mode" permission. The following variables are in injected into Twig and can be accessed using `{% raw %}{{ variable }}{% endraw %}` ( @@ -99,10 +99,10 @@ or `{% raw %}{{ variable.property }}{% endraw %}`): | Variable name | Description | |--------------------------------------------|--------------------------------------------------------------------------------------| -| `{% raw %}{{ element }}{% endraw %}` | The target element, selected in label dialog. | +| `{% raw %}{{ element }}{% endraw %}` | The target element, selected in label dialog. | | `{% raw %}{{ user }}{% endraw %}` | The current logged in user. Null if you are not logged in | | `{% raw %}{{ install_title }}{% endraw %}` | The name of the current Part-DB instance (similar to [[INSTALL_NAME]] placeholder). | -| `{% raw %}{{ page }}{% endraw %}` | The page number (the nth-element for which the label is generated | +| `{% raw %}{{ page }}{% endraw %}` | The page number (the nth-element for which the label is generated ) | | `{% raw %}{{ last_page }}{% endraw %}` | The page number of the last element. Equals the number of all pages / element labels | | `{% raw %}{{ paper_width }}{% endraw %}` | The width of the label paper in mm | | `{% raw %}{{ paper_height }}{% endraw %}` | The height of the label paper in mm | @@ -236,12 +236,16 @@ certain data: #### Functions -| Function name | Description | -|----------------------------------------------|-----------------------------------------------------------------------------------------------| -| `placeholder(placeholder, element)` | Get the value of a placeholder of an element | -| `entity_type(element)` | Get the type of an entity as string | -| `entity_url(element, type)` | Get the URL to a specific entity type page (e.g. `info`, `edit`, etc.) | -| `barcode_svg(content, type)` | Generate a barcode SVG from the content and type (e.g. `QRCODE`, `CODE128` etc.). A svg string is returned, which you need to data uri encode to inline it. | +| Function name | Description | +|------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `placeholder(placeholder, element)` | Get the value of a placeholder of an element | +| `entity_type(element)` | Get the type of an entity as string | +| `entity_url(element, type)` | Get the URL to a specific entity type page (e.g. `info`, `edit`, etc.) | +| `barcode_svg(content, type)` | Generate a barcode SVG from the content and type (e.g. `QRCODE`, `CODE128` etc.). A svg string is returned, which you need to data uri encode to inline it. | +| `associated_parts(element)` | Get the associated parts of an element like a storagelocation, footprint, etc. Only the directly associated parts are returned | +| `associated_parts_r(element)` | Get the associated parts of an element like a storagelocation, footprint, etc. including all sub-entities recursively (e.g. sub-locations) | +| `associated_parts_count(element)` | Get the count of associated parts of an element like a storagelocation, footprint, excluding sub-entities | +| `associated_parts_count_r(element)` | Get the count of associated parts of an element like a storagelocation, footprint, including all sub-entities recursively (e.g. sub-locations) | ### Filters @@ -285,5 +289,5 @@ If you want to use a different (more beautiful) font, you can use the [custom fo feature. There is the [Noto](https://www.google.com/get/noto/) font family from Google, which supports a lot of languages and is available in different styles (regular, bold, italic, bold-italic). -For example, you can use [Noto CJK](https://github.com/notofonts/noto-cjk) for more beautiful Chinese, Japanese, -and Korean characters. \ No newline at end of file +For example, you can use [Noto CJK](https://github.com/notofonts/noto-cjk) for more beautiful Chinese, Japanese, +and Korean characters. diff --git a/src/Services/LabelSystem/SandboxedTwigFactory.php b/src/Services/LabelSystem/SandboxedTwigFactory.php index b4f1d9f6..b89b3c0c 100644 --- a/src/Services/LabelSystem/SandboxedTwigFactory.php +++ b/src/Services/LabelSystem/SandboxedTwigFactory.php @@ -114,6 +114,7 @@ final class SandboxedTwigFactory 'barcode_svg', //SandboxedLabelExtension 'placeholder', + 'associated_parts', 'associated_parts_count', 'associated_parts_r', 'associated_parts_count_r', ]; private const ALLOWED_METHODS = [ diff --git a/src/Twig/Sandbox/SandboxedLabelExtension.php b/src/Twig/Sandbox/SandboxedLabelExtension.php index 59fb0af0..3b8eeed2 100644 --- a/src/Twig/Sandbox/SandboxedLabelExtension.php +++ b/src/Twig/Sandbox/SandboxedLabelExtension.php @@ -23,14 +23,18 @@ declare(strict_types=1); namespace App\Twig\Sandbox; +use App\Entity\Base\AbstractPartsContainingDBElement; +use App\Entity\Parts\Part; +use App\Repository\AbstractPartsContainingRepository; use App\Services\LabelSystem\LabelTextReplacer; +use Doctrine\ORM\EntityManagerInterface; use Twig\Extension\AbstractExtension; use Twig\TwigFilter; use Twig\TwigFunction; class SandboxedLabelExtension extends AbstractExtension { - public function __construct(private readonly LabelTextReplacer $labelTextReplacer) + public function __construct(private readonly LabelTextReplacer $labelTextReplacer, private readonly EntityManagerInterface $em) { } @@ -39,6 +43,11 @@ class SandboxedLabelExtension extends AbstractExtension { return [ new TwigFunction('placeholder', fn(string $text, object $label_target) => $this->labelTextReplacer->handlePlaceholderOrReturnNull($text, $label_target)), + + new TwigFunction("associated_parts", $this->associatedParts(...)), + new TwigFunction("associated_parts_count", $this->associatedPartsCount(...)), + new TwigFunction("associated_parts_r", $this->associatedPartsRecursive(...)), + new TwigFunction("associated_parts_count_r", $this->associatedPartsCountRecursive(...)), ]; } @@ -48,4 +57,37 @@ class SandboxedLabelExtension extends AbstractExtension new TwigFilter('placeholders', fn(string $text, object $label_target) => $this->labelTextReplacer->replace($text, $label_target)), ]; } -} \ No newline at end of file + + /** + * Returns all parts associated with the given element. + * @param AbstractPartsContainingDBElement $element + * @return Part[] + */ + public function associatedParts(AbstractPartsContainingDBElement $element): array + { + /** @var AbstractPartsContainingRepository $repo */ + $repo = $this->em->getRepository($element::class); + return $repo->getParts($element); + } + + public function associatedPartsCount(AbstractPartsContainingDBElement $element): int + { + /** @var AbstractPartsContainingRepository $repo */ + $repo = $this->em->getRepository($element::class); + return $repo->getPartsCount($element); + } + + public function associatedPartsRecursive(AbstractPartsContainingDBElement $element): array + { + /** @var AbstractPartsContainingRepository $repo */ + $repo = $this->em->getRepository($element::class); + return $repo->getPartsRecursive($element); + } + + public function associatedPartsCountRecursive(AbstractPartsContainingDBElement $element): int + { + /** @var AbstractPartsContainingRepository $repo */ + $repo = $this->em->getRepository($element::class); + return $repo->getPartsCountRecursive($element); + } +} From 1c6bf3f47292b3c1ab025b8ac5390466cd5b069e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 15 Feb 2026 14:07:50 +0100 Subject: [PATCH 2/5] Allow more useful functions in twig labels --- docs/usage/labels.md | 2 ++ .../LabelSystem/SandboxedTwigFactory.php | 20 +++++++++---------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/usage/labels.md b/docs/usage/labels.md index 7d0d2ed3..6896ccb7 100644 --- a/docs/usage/labels.md +++ b/docs/usage/labels.md @@ -246,6 +246,8 @@ certain data: | `associated_parts_r(element)` | Get the associated parts of an element like a storagelocation, footprint, etc. including all sub-entities recursively (e.g. sub-locations) | | `associated_parts_count(element)` | Get the count of associated parts of an element like a storagelocation, footprint, excluding sub-entities | | `associated_parts_count_r(element)` | Get the count of associated parts of an element like a storagelocation, footprint, including all sub-entities recursively (e.g. sub-locations) | +| `type_label(element)` | Get the name of the type of an element (e.g. "Part", "Storage location", etc.) | +| `type_label_p(element)` | Get the name of the type of an element in plural form (e.g. "Parts", "Storage locations", etc.) | ### Filters diff --git a/src/Services/LabelSystem/SandboxedTwigFactory.php b/src/Services/LabelSystem/SandboxedTwigFactory.php index b89b3c0c..fb3b6362 100644 --- a/src/Services/LabelSystem/SandboxedTwigFactory.php +++ b/src/Services/LabelSystem/SandboxedTwigFactory.php @@ -86,11 +86,11 @@ use Twig\Sandbox\SecurityPolicyInterface; */ final class SandboxedTwigFactory { - private const ALLOWED_TAGS = ['apply', 'autoescape', 'do', 'for', 'if', 'set', 'verbatim', 'with']; + private const ALLOWED_TAGS = ['apply', 'autoescape', 'do', 'for', 'if', 'set', 'types', 'verbatim', 'with']; private const ALLOWED_FILTERS = ['abs', 'batch', 'capitalize', 'column', 'country_name', - 'currency_name', 'currency_symbol', 'date', 'date_modify', 'data_uri', 'default', 'escape', 'filter', 'first', 'format', + 'currency_name', 'currency_symbol', 'date', 'date_modify', 'data_uri', 'default', 'escape', 'filter', 'find', 'first', 'format', 'format_currency', 'format_date', 'format_datetime', 'format_number', 'format_time', 'html_to_markdown', 'join', 'keys', - 'language_name', 'last', 'length', 'locale_name', 'lower', 'map', 'markdown_to_html', 'merge', 'nl2br', 'raw', 'number_format', + 'language_name', 'last', 'length', 'locale_name', 'lower', 'map', 'markdown_to_html', 'merge', 'nl2br', 'number_format', 'raw', 'reduce', 'replace', 'reverse', 'round', 'slice', 'slug', 'sort', 'spaceless', 'split', 'striptags', 'timezone_name', 'title', 'trim', 'u', 'upper', 'url_encode', @@ -104,12 +104,12 @@ final class SandboxedTwigFactory ]; private const ALLOWED_FUNCTIONS = ['country_names', 'country_timezones', 'currency_names', 'cycle', - 'date', 'html_classes', 'language_names', 'locale_names', 'max', 'min', 'random', 'range', 'script_names', - 'template_from_string', 'timezone_names', + 'date', 'enum', 'enum_cases', 'html_classes', 'language_names', 'locale_names', 'max', 'min', 'random', 'range', 'script_names', + 'timezone_names', //Part-DB specific extensions: //EntityExtension: - 'entity_type', 'entity_url', + 'entity_type', 'entity_url', 'type_label', 'type_label_plural', //BarcodeExtension: 'barcode_svg', //SandboxedLabelExtension @@ -131,7 +131,7 @@ final class SandboxedTwigFactory 'getValueTypical', 'getUnit', 'getValueText', ], MeasurementUnit::class => ['getUnit', 'isInteger', 'useSIPrefix'], PartLot::class => ['isExpired', 'getDescription', 'getComment', 'getExpirationDate', 'getStorageLocation', - 'getPart', 'isInstockUnknown', 'getAmount', 'getNeedsRefill', 'getVendorBarcode'], + 'getPart', 'isInstockUnknown', 'getAmount', 'getOwner', 'getLastStocktakeAt', 'getNeedsRefill', 'getVendorBarcode'], StorageLocation::class => ['isFull', 'isOnlySinglePart', 'isLimitToExistingParts', 'getStorageType'], Supplier::class => ['getShippingCosts', 'getDefaultCurrency'], Part::class => ['isNeedsReview', 'getTags', 'getMass', 'getIpn', 'getProviderReference', @@ -142,13 +142,13 @@ final class SandboxedTwigFactory 'getParameters', 'getGroupedParameters', 'isProjectBuildPart', 'getBuiltProject', 'getAssociatedPartsAsOwner', 'getAssociatedPartsAsOther', 'getAssociatedPartsAll', - 'getEdaInfo' + 'getEdaInfo', 'getGtin' ], Currency::class => ['getIsoCode', 'getInverseExchangeRate', 'getExchangeRate'], Orderdetail::class => ['getPart', 'getSupplier', 'getSupplierPartNr', 'getObsolete', - 'getPricedetails', 'findPriceForQty', 'isObsolete', 'getSupplierProductUrl'], + 'getPricedetails', 'findPriceForQty', 'isObsolete', 'getSupplierProductUrl', 'getPricesIncludesVAT'], Pricedetail::class => ['getOrderdetail', 'getPrice', 'getPricePerUnit', 'getPriceRelatedQuantity', - 'getMinDiscountQuantity', 'getCurrency', 'getCurrencyISOCode'], + 'getMinDiscountQuantity', 'getCurrency', 'getCurrencyISOCode', 'getIncludesVat'], InfoProviderReference:: class => ['getProviderKey', 'getProviderId', 'getProviderUrl', 'getLastUpdated', 'isProviderCreated'], PartAssociation::class => ['getType', 'getComment', 'getOwner', 'getOther', 'getOtherType'], From 5e9f7a11a368245a3eaf57a87090519395e22ffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 15 Feb 2026 14:11:31 +0100 Subject: [PATCH 3/5] Catch more errors of twig labels --- src/Exceptions/TwigModeException.php | 5 ++--- src/Services/LabelSystem/LabelHTMLGenerator.php | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Exceptions/TwigModeException.php b/src/Exceptions/TwigModeException.php index b76d14d3..ea84667a 100644 --- a/src/Exceptions/TwigModeException.php +++ b/src/Exceptions/TwigModeException.php @@ -42,15 +42,14 @@ declare(strict_types=1); namespace App\Exceptions; use RuntimeException; -use Twig\Error\Error; class TwigModeException extends RuntimeException { private const PROJECT_PATH = __DIR__ . '/../../'; - public function __construct(?Error $previous = null) + public function __construct(?\Throwable $previous = null) { - parent::__construct($previous->getMessage(), 0, $previous); + parent::__construct($previous?->getMessage() ?? "Unknown message", 0, $previous); } /** diff --git a/src/Services/LabelSystem/LabelHTMLGenerator.php b/src/Services/LabelSystem/LabelHTMLGenerator.php index 8a5201ff..31093953 100644 --- a/src/Services/LabelSystem/LabelHTMLGenerator.php +++ b/src/Services/LabelSystem/LabelHTMLGenerator.php @@ -95,7 +95,7 @@ final class LabelHTMLGenerator 'paper_height' => $options->getHeight(), ] ); - } catch (Error $exception) { + } catch (\Throwable $exception) { throw new TwigModeException($exception); } } else { From 7998cdcd714972f0be14b2377ddb03b1729af0aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 15 Feb 2026 14:24:31 +0100 Subject: [PATCH 4/5] Added hint about HTML block to twig label documentation --- docs/usage/labels.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/usage/labels.md b/docs/usage/labels.md index 6896ccb7..c804cebb 100644 --- a/docs/usage/labels.md +++ b/docs/usage/labels.md @@ -94,6 +94,8 @@ the label content and you can access almost all data Part-DB offers. The label t where only certain operations are allowed. Only read access to entities is allowed. However, as it circumvents Part-DB normal permission system, the twig mode is only available to users with the "Twig mode" permission. +It is useful to use the HTML embed feature of the editor, to have a block where you can write the twig code without worrying about the WYSIWYG editor messing with your code. + The following variables are in injected into Twig and can be accessed using `{% raw %}{{ variable }}{% endraw %}` ( or `{% raw %}{{ variable.property }}{% endraw %}`): From 97a74815d3e8647d581c255b8c87861c47269627 Mon Sep 17 00:00:00 2001 From: d-buchmann Date: Sun, 15 Feb 2026 14:41:25 +0100 Subject: [PATCH 5/5] Fix fallback filename (#1238) Fixes #1231. Modify tests to account for this case. --- src/Services/ImportExportSystem/EntityExporter.php | 5 ++++- .../ImportExportSystem/EntityExporterTest.php | 14 +++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/Services/ImportExportSystem/EntityExporter.php b/src/Services/ImportExportSystem/EntityExporter.php index 9ed027ae..ab87a905 100644 --- a/src/Services/ImportExportSystem/EntityExporter.php +++ b/src/Services/ImportExportSystem/EntityExporter.php @@ -266,11 +266,14 @@ class EntityExporter //Sanitize the filename $filename = FilenameSanatizer::sanitizeFilename($filename); + //Remove percent for fallback + $fallback = str_replace("%", "_", $filename); + // Create the disposition of the file $disposition = $response->headers->makeDisposition( ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename, - u($filename)->ascii()->toString(), + $fallback, ); // Set the content disposition $response->headers->set('Content-Disposition', $disposition); diff --git a/tests/Services/ImportExportSystem/EntityExporterTest.php b/tests/Services/ImportExportSystem/EntityExporterTest.php index 8029a2d9..e4518961 100644 --- a/tests/Services/ImportExportSystem/EntityExporterTest.php +++ b/tests/Services/ImportExportSystem/EntityExporterTest.php @@ -43,9 +43,9 @@ final class EntityExporterTest extends WebTestCase private function getEntities(): array { - $entity1 = (new Category())->setName('Enitity 1')->setComment('Test'); - $entity1_1 = (new Category())->setName('Enitity 1.1')->setParent($entity1); - $entity2 = (new Category())->setName('Enitity 2'); + $entity1 = (new Category())->setName('Entity%1')->setComment('Test'); + $entity1_1 = (new Category())->setName('Entity 1.1')->setParent($entity1); + $entity2 = (new Category())->setName('Entity 2'); return [$entity1, $entity1_1, $entity2]; } @@ -55,12 +55,12 @@ final class EntityExporterTest extends WebTestCase $entities = $this->getEntities(); $json_without_children = $this->service->exportEntities($entities, ['format' => 'json', 'level' => 'simple']); - $this->assertJsonStringEqualsJsonString('[{"name":"Enitity 1","type":"category","full_name":"Enitity 1"},{"name":"Enitity 1.1","type":"category","full_name":"Enitity 1->Enitity 1.1"},{"name":"Enitity 2","type":"category","full_name":"Enitity 2"}]', + $this->assertJsonStringEqualsJsonString('[{"name":"Entity%1","type":"category","full_name":"Entity%1"},{"name":"Entity 1.1","type":"category","full_name":"Entity%1->Entity 1.1"},{"name":"Entity 2","type":"category","full_name":"Entity 2"}]', $json_without_children); $json_with_children = $this->service->exportEntities($entities, ['format' => 'json', 'level' => 'simple', 'include_children' => true]); - $this->assertJsonStringEqualsJsonString('[{"children":[{"children":[],"name":"Enitity 1.1","type":"category","full_name":"Enitity 1->Enitity 1.1"}],"name":"Enitity 1","type":"category","full_name":"Enitity 1"},{"children":[],"name":"Enitity 1.1","type":"category","full_name":"Enitity 1->Enitity 1.1"},{"children":[],"name":"Enitity 2","type":"category","full_name":"Enitity 2"}]', + $this->assertJsonStringEqualsJsonString('[{"children":[{"children":[],"name":"Entity 1.1","type":"category","full_name":"Entity%1->Entity 1.1"}],"name":"Entity%1","type":"category","full_name":"Entity%1"},{"children":[],"name":"Entity 1.1","type":"category","full_name":"Entity%1->Entity 1.1"},{"children":[],"name":"Entity 2","type":"category","full_name":"Entity 2"}]', $json_with_children); } @@ -95,8 +95,8 @@ final class EntityExporterTest extends WebTestCase $this->assertSame('name', $worksheet->getCell('A1')->getValue()); $this->assertSame('full_name', $worksheet->getCell('B1')->getValue()); - $this->assertSame('Enitity 1', $worksheet->getCell('A2')->getValue()); - $this->assertSame('Enitity 1', $worksheet->getCell('B2')->getValue()); + $this->assertSame('Entity%1', $worksheet->getCell('A2')->getValue()); + $this->assertSame('Entity%1', $worksheet->getCell('B2')->getValue()); unlink($tempFile); }