diff --git a/assets/controllers/elements/ckeditor_controller.js b/assets/controllers/elements/ckeditor_controller.js index 46e78fd5..b7c87dab 100644 --- a/assets/controllers/elements/ckeditor_controller.js +++ b/assets/controllers/elements/ckeditor_controller.js @@ -106,6 +106,15 @@ export default class extends Controller { editor_div.classList.add(...new_classes.split(",")); } + // Automatic synchronization of source input + editor.model.document.on("change:data", () => { + editor.updateSourceElement(); + + // Dispatch the input event for further treatment + const event = new Event("input"); + this.element.dispatchEvent(event); + }); + //This return is important! Otherwise we get mysterious errors in the console //See: https://github.com/ckeditor/ckeditor5/issues/5897#issuecomment-628471302 return editor; diff --git a/assets/controllers/elements/ipn_suggestion_controller.js b/assets/controllers/elements/ipn_suggestion_controller.js new file mode 100644 index 00000000..c8b543cb --- /dev/null +++ b/assets/controllers/elements/ipn_suggestion_controller.js @@ -0,0 +1,250 @@ +import { Controller } from "@hotwired/stimulus"; +import "../../css/components/autocomplete_bootstrap_theme.css"; + +export default class extends Controller { + static targets = ["input"]; + static values = { + partId: Number, + partCategoryId: Number, + partDescription: String, + suggestions: Object, + commonSectionHeader: String, // Dynamic header for common Prefixes + partIncrementHeader: String, // Dynamic header for new possible part increment + suggestUrl: String, + }; + + connect() { + this.configureAutocomplete(); + this.watchCategoryChanges(); + this.watchDescriptionChanges(); + } + + templates = { + commonSectionHeader({ title, html }) { + return html` +
+
+ ${title} +
+
+
+ `; + }, + partIncrementHeader({ title, html }) { + return html` +
+
+ ${title} +
+
+
+ `; + }, + list({ html }) { + return html` + + `; + }, + item({ suggestion, description, html }) { + return html` +
  • +
    +
    +
    + + + +
    +
    +
    ${suggestion}
    +
    ${description}
    +
    +
    +
    +
  • + `; + }, + }; + + configureAutocomplete() { + const inputField = this.inputTarget; + const commonPrefixes = this.suggestionsValue.commonPrefixes || []; + const prefixesPartIncrement = this.suggestionsValue.prefixesPartIncrement || []; + const commonHeader = this.commonSectionHeaderValue; + const partIncrementHeader = this.partIncrementHeaderValue; + + if (!inputField || (!commonPrefixes.length && !prefixesPartIncrement.length)) return; + + // Check whether the panel should be created at the update + if (this.isPanelInitialized) { + const existingPanel = inputField.parentNode.querySelector(".aa-Panel"); + if (existingPanel) { + // Only remove the panel in the update phase + + existingPanel.remove(); + } + } + + // Create panel + const panel = document.createElement("div"); + panel.classList.add("aa-Panel"); + panel.style.display = "none"; + + // Create panel layout + const panelLayout = document.createElement("div"); + panelLayout.classList.add("aa-PanelLayout", "aa-Panel--scrollable"); + + // Section for prefixes part increment + if (prefixesPartIncrement.length) { + const partIncrementSection = document.createElement("section"); + partIncrementSection.classList.add("aa-Source"); + + const partIncrementHeaderHtml = this.templates.partIncrementHeader({ + title: partIncrementHeader, + html: String.raw, + }); + partIncrementSection.innerHTML += partIncrementHeaderHtml; + + const partIncrementList = document.createElement("ul"); + partIncrementList.classList.add("aa-List"); + partIncrementList.setAttribute("role", "listbox"); + + prefixesPartIncrement.forEach((prefix) => { + const itemHTML = this.templates.item({ + suggestion: prefix.title, + description: prefix.description, + html: String.raw, + }); + partIncrementList.innerHTML += itemHTML; + }); + + partIncrementSection.appendChild(partIncrementList); + panelLayout.appendChild(partIncrementSection); + } + + // Section for common prefixes + if (commonPrefixes.length) { + const commonSection = document.createElement("section"); + commonSection.classList.add("aa-Source"); + + const commonSectionHeader = this.templates.commonSectionHeader({ + title: commonHeader, + html: String.raw, + }); + commonSection.innerHTML += commonSectionHeader; + + const commonList = document.createElement("ul"); + commonList.classList.add("aa-List"); + commonList.setAttribute("role", "listbox"); + + commonPrefixes.forEach((prefix) => { + const itemHTML = this.templates.item({ + suggestion: prefix.title, + description: prefix.description, + html: String.raw, + }); + commonList.innerHTML += itemHTML; + }); + + commonSection.appendChild(commonList); + panelLayout.appendChild(commonSection); + } + + panel.appendChild(panelLayout); + inputField.parentNode.appendChild(panel); + + inputField.addEventListener("focus", () => { + panel.style.display = "block"; + }); + + inputField.addEventListener("blur", () => { + setTimeout(() => { + panel.style.display = "none"; + }, 100); + }); + + // Selection of an item + panelLayout.addEventListener("mousedown", (event) => { + const target = event.target.closest("li"); + + if (target) { + inputField.value = target.dataset.suggestion; + panel.style.display = "none"; + } + }); + + this.isPanelInitialized = true; + }; + + watchCategoryChanges() { + const categoryField = document.querySelector('[data-ipn-suggestion="categoryField"]'); + const descriptionField = document.querySelector('[data-ipn-suggestion="descriptionField"]'); + this.previousCategoryId = Number(this.partCategoryIdValue); + + if (categoryField) { + categoryField.addEventListener("change", () => { + const categoryId = Number(categoryField.value); + const description = String(descriptionField?.value ?? ''); + + // Check whether the category has changed compared to the previous ID + if (categoryId !== this.previousCategoryId) { + this.fetchNewSuggestions(categoryId, description); + this.previousCategoryId = categoryId; + } + }); + } + } + + watchDescriptionChanges() { + const categoryField = document.querySelector('[data-ipn-suggestion="categoryField"]'); + const descriptionField = document.querySelector('[data-ipn-suggestion="descriptionField"]'); + this.previousDescription = String(this.partDescriptionValue); + + if (descriptionField) { + descriptionField.addEventListener("input", () => { + const categoryId = Number(categoryField.value); + const description = String(descriptionField?.value ?? ''); + + // Check whether the description has changed compared to the previous one + if (description !== this.previousDescription) { + this.fetchNewSuggestions(categoryId, description); + this.previousDescription = description; + } + }); + } + } + + fetchNewSuggestions(categoryId, description) { + const baseUrl = this.suggestUrlValue; + const partId = this.partIdValue; + const truncatedDescription = description.length > 150 ? description.substring(0, 150) : description; + const encodedDescription = this.base64EncodeUtf8(truncatedDescription); + const url = `${baseUrl}?partId=${partId}&categoryId=${categoryId}` + (description !== '' ? `&description=${encodedDescription}` : ''); + + fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + }, + }) + .then((response) => { + if (!response.ok) { + throw new Error(`Error when calling up the IPN-suggestions: ${response.status}`); + } + return response.json(); + }) + .then((data) => { + this.suggestionsValue = data; + this.configureAutocomplete(); + }) + .catch((error) => { + console.error("Errors when loading the new IPN-suggestions:", error); + }); + }; + + base64EncodeUtf8(text) { + const utf8Bytes = new TextEncoder().encode(text); + return btoa(String.fromCharCode(...utf8Bytes)); + }; +} diff --git a/config/services.yaml b/config/services.yaml index 17611cea..b48b3eff 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -231,6 +231,16 @@ services: tags: - { name: 'doctrine.fixtures.purger_factory', alias: 'reset_autoincrement_purger' } + App\Repository\PartRepository: + arguments: + $translator: '@translator' + tags: ['doctrine.repository_service'] + + App\EventSubscriber\UserSystem\PartUniqueIpnSubscriber: + tags: + - { name: doctrine.event_listener, event: onFlush, connection: default } + + # We are needing this service inside a migration, where only the container is injected. So we need to define it as public, to access it from the container. App\Services\UserSystem\PermissionPresetsHelper: public: true diff --git a/docs/configuration.md b/docs/configuration.md index 2ba5ce90..4bb46d08 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -116,6 +116,16 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept value should be handled as confidential data and not shared publicly. * `SHOW_PART_IMAGE_OVERLAY`: Set to 0 to disable the part image overlay, which appears if you hover over an image in the part image gallery +* `IPN_SUGGEST_REGEX`: A global regular expression, that part IPNs have to fullfill. Enforce your own format for your users. +* `IPN_SUGGEST_REGEX_HELP`: Define your own user help text for the Regex format specification. +* `IPN_AUTO_APPEND_SUFFIX`: When enabled, an incremental suffix will be added to the user input when entering an existing +* IPN again upon saving. +* `IPN_SUGGEST_PART_DIGITS`: Defines the fixed number of digits used as the increment at the end of an IPN (Internal Part Number). + IPN prefixes, maintained within part categories and their hierarchy, form the foundation for suggesting complete IPNs. + These suggestions become accessible during IPN input of a part. The constant specifies the digits used to calculate and assign + unique increments for parts within a category hierarchy, ensuring consistency and uniqueness in IPN generation. +* `IPN_USE_DUPLICATE_DESCRIPTION`: When enabled, the part’s description is used to find existing parts with the same + description and to determine the next available IPN by incrementing their numeric suffix for the suggestion list. ### E-Mail settings (all env only) diff --git a/migrations/Version20250325073036.php b/migrations/Version20250325073036.php new file mode 100644 index 00000000..3bae80ab --- /dev/null +++ b/migrations/Version20250325073036.php @@ -0,0 +1,307 @@ +addSql(<<<'SQL' + ALTER TABLE categories ADD COLUMN part_ipn_prefix VARCHAR(255) NOT NULL DEFAULT '' + SQL); + } + + public function mySQLDown(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE categories DROP part_ipn_prefix + SQL); + } + + public function sqLiteUp(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE TEMPORARY TABLE __temp__categories AS + SELECT + id, + parent_id, + id_preview_attachment, + partname_hint, + partname_regex, + disable_footprints, + disable_manufacturers, + disable_autodatasheets, + disable_properties, + default_description, + default_comment, + comment, + not_selectable, + name, + last_modified, + datetime_added, + alternative_names, + eda_info_reference_prefix, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol + FROM categories + SQL); + + $this->addSql('DROP TABLE categories'); + + $this->addSql(<<<'SQL' + CREATE TABLE categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + parent_id INTEGER DEFAULT NULL, + id_preview_attachment INTEGER DEFAULT NULL, + partname_hint CLOB NOT NULL, + partname_regex CLOB NOT NULL, + part_ipn_prefix VARCHAR(255) DEFAULT '' NOT NULL, + disable_footprints BOOLEAN NOT NULL, + disable_manufacturers BOOLEAN NOT NULL, + disable_autodatasheets BOOLEAN NOT NULL, + disable_properties BOOLEAN NOT NULL, + default_description CLOB NOT NULL, + default_comment CLOB NOT NULL, + comment CLOB NOT NULL, + not_selectable BOOLEAN NOT NULL, + name VARCHAR(255) NOT NULL, + last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + alternative_names CLOB DEFAULT NULL, + eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, + eda_info_invisible BOOLEAN DEFAULT NULL, + eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, + eda_info_exclude_from_board BOOLEAN DEFAULT NULL, + eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, + eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, + CONSTRAINT FK_3AF34668727ACA70 FOREIGN KEY (parent_id) REFERENCES categories (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_3AF34668EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE + ) + SQL); + + $this->addSql(<<<'SQL' + INSERT INTO categories ( + id, + parent_id, + id_preview_attachment, + partname_hint, + partname_regex, + disable_footprints, + disable_manufacturers, + disable_autodatasheets, + disable_properties, + default_description, + default_comment, + comment, + not_selectable, + name, + last_modified, + datetime_added, + alternative_names, + eda_info_reference_prefix, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol + ) SELECT + id, + parent_id, + id_preview_attachment, + partname_hint, + partname_regex, + disable_footprints, + disable_manufacturers, + disable_autodatasheets, + disable_properties, + default_description, + default_comment, + comment, + not_selectable, + name, + last_modified, + datetime_added, + alternative_names, + eda_info_reference_prefix, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol + FROM __temp__categories + SQL); + + $this->addSql('DROP TABLE __temp__categories'); + + $this->addSql(<<<'SQL' + CREATE INDEX IDX_3AF34668727ACA70 ON categories (parent_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_3AF34668EA7100A1 ON categories (id_preview_attachment) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX category_idx_name ON categories (name) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX category_idx_parent_name ON categories (parent_id, name) + SQL); + } + + public function sqLiteDown(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE TEMPORARY TABLE __temp__categories AS + SELECT + id, + parent_id, + id_preview_attachment, + partname_hint, + partname_regex, + disable_footprints, + disable_manufacturers, + disable_autodatasheets, + disable_properties, + default_description, + default_comment, + comment, + not_selectable, + name, + last_modified, + datetime_added, + alternative_names, + eda_info_reference_prefix, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol + FROM categories + SQL); + + $this->addSql('DROP TABLE categories'); + + $this->addSql(<<<'SQL' + CREATE TABLE categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + parent_id INTEGER DEFAULT NULL, + id_preview_attachment INTEGER DEFAULT NULL, + partname_hint CLOB NOT NULL, + partname_regex CLOB NOT NULL, + disable_footprints BOOLEAN NOT NULL, + disable_manufacturers BOOLEAN NOT NULL, + disable_autodatasheets BOOLEAN NOT NULL, + disable_properties BOOLEAN NOT NULL, + default_description CLOB NOT NULL, + default_comment CLOB NOT NULL, + comment CLOB NOT NULL, + not_selectable BOOLEAN NOT NULL, + name VARCHAR(255) NOT NULL, + last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + alternative_names CLOB DEFAULT NULL, + eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, + eda_info_invisible BOOLEAN DEFAULT NULL, + eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, + eda_info_exclude_from_board BOOLEAN DEFAULT NULL, + eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, + eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, + CONSTRAINT FK_3AF34668727ACA70 FOREIGN KEY (parent_id) REFERENCES categories (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_3AF34668EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE + ) + SQL); + + $this->addSql(<<<'SQL' + INSERT INTO categories ( + id, + parent_id, + id_preview_attachment, + partname_hint, + partname_regex, + disable_footprints, + disable_manufacturers, + disable_autodatasheets, + disable_properties, + default_description, + default_comment, + comment, + not_selectable, + name, + last_modified, + datetime_added, + alternative_names, + eda_info_reference_prefix, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol + ) SELECT + id, + parent_id, + id_preview_attachment, + partname_hint, + partname_regex, + disable_footprints, + disable_manufacturers, + disable_autodatasheets, + disable_properties, + default_description, + default_comment, + comment, + not_selectable, + name, + last_modified, + datetime_added, + alternative_names, + eda_info_reference_prefix, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol + FROM __temp__categories + SQL); + + $this->addSql('DROP TABLE __temp__categories'); + + $this->addSql(<<<'SQL' + CREATE INDEX IDX_3AF34668727ACA70 ON categories (parent_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_3AF34668EA7100A1 ON categories (id_preview_attachment) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX category_idx_name ON categories (name) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX category_idx_parent_name ON categories (parent_id, name) + SQL); + } + + public function postgreSQLUp(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE categories ADD part_ipn_prefix VARCHAR(255) DEFAULT '' NOT NULL + SQL); + } + + public function postgreSQLDown(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE "categories" DROP part_ipn_prefix + SQL); + } +} diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php index aeb2664e..3a121ad2 100644 --- a/src/Controller/PartController.php +++ b/src/Controller/PartController.php @@ -47,6 +47,7 @@ use App\Services\Parts\PartLotWithdrawAddHelper; use App\Services\Parts\PricedetailHelper; use App\Services\ProjectSystem\ProjectBuildPartHelper; use App\Settings\BehaviorSettings\PartInfoSettings; +use App\Settings\MiscSettings\IpnSuggestSettings; use DateTime; use Doctrine\ORM\EntityManagerInterface; use Exception; @@ -74,6 +75,7 @@ final class PartController extends AbstractController private readonly EntityManagerInterface $em, private readonly EventCommentHelper $commentHelper, private readonly PartInfoSettings $partInfoSettings, + private readonly IpnSuggestSettings $ipnSuggestSettings, ) { } @@ -444,10 +446,13 @@ final class PartController extends AbstractController $template = 'parts/edit/update_from_ip.html.twig'; } + $partRepository = $this->em->getRepository(Part::class); + return $this->render( $template, [ 'part' => $new_part, + 'ipnSuggestions' => $partRepository->autoCompleteIpn($data, $data->getDescription(), $this->ipnSuggestSettings->suggestPartDigits), 'form' => $form, 'merge_old_name' => $merge_infos['tname_before'] ?? null, 'merge_other' => $merge_infos['other_part'] ?? null, @@ -457,7 +462,6 @@ final class PartController extends AbstractController ); } - #[Route(path: '/{id}/add_withdraw', name: 'part_add_withdraw', methods: ['POST'])] public function withdrawAddHandler(Part $part, Request $request, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper): Response { diff --git a/src/Controller/TypeaheadController.php b/src/Controller/TypeaheadController.php index 89eac7ff..39821f59 100644 --- a/src/Controller/TypeaheadController.php +++ b/src/Controller/TypeaheadController.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Controller; use App\Entity\Parameters\AbstractParameter; +use App\Settings\MiscSettings\IpnSuggestSettings; use Symfony\Component\HttpFoundation\Response; use App\Entity\Attachments\Attachment; use App\Entity\Parts\Category; @@ -60,8 +61,11 @@ use Symfony\Component\Serializer\Serializer; #[Route(path: '/typeahead')] class TypeaheadController extends AbstractController { - public function __construct(protected AttachmentURLGenerator $urlGenerator, protected Packages $assets) - { + public function __construct( + protected AttachmentURLGenerator $urlGenerator, + protected Packages $assets, + protected IpnSuggestSettings $ipnSuggestSettings, + ) { } #[Route(path: '/builtInResources/search', name: 'typeahead_builtInRessources')] @@ -183,4 +187,30 @@ class TypeaheadController extends AbstractController return new JsonResponse($data, Response::HTTP_OK, [], true); } + + #[Route(path: '/parts/ipn-suggestions', name: 'ipn_suggestions', methods: ['GET'])] + public function ipnSuggestions( + Request $request, + EntityManagerInterface $entityManager + ): JsonResponse { + $partId = $request->query->get('partId'); + if ($partId === '0' || $partId === 'undefined' || $partId === 'null') { + $partId = null; + } + $categoryId = $request->query->getInt('categoryId'); + $description = base64_decode($request->query->getString('description'), true); + + /** @var Part $part */ + $part = $partId !== null ? $entityManager->getRepository(Part::class)->find($partId) : new Part(); + /** @var Category|null $category */ + $category = $entityManager->getRepository(Category::class)->find($categoryId); + + $clonedPart = clone $part; + $clonedPart->setCategory($category); + + $partRepository = $entityManager->getRepository(Part::class); + $ipnSuggestions = $partRepository->autoCompleteIpn($clonedPart, $description, $this->ipnSuggestSettings->suggestPartDigits); + + return new JsonResponse($ipnSuggestions); + } } diff --git a/src/Entity/Parts/Category.php b/src/Entity/Parts/Category.php index 99ed3c6d..7fca81bc 100644 --- a/src/Entity/Parts/Category.php +++ b/src/Entity/Parts/Category.php @@ -118,6 +118,13 @@ class Category extends AbstractPartsContainingDBElement #[ORM\Column(type: Types::TEXT)] protected string $partname_regex = ''; + /** + * @var string The prefix for ipn generation for created parts in this category. + */ + #[Groups(['full', 'import', 'category:read', 'category:write'])] + #[ORM\Column(type: Types::STRING, length: 255, nullable: false, options: ['default' => ''])] + protected string $part_ipn_prefix = ''; + /** * @var bool Set to true, if the footprints should be disabled for parts this category (not implemented yet). */ @@ -225,6 +232,16 @@ class Category extends AbstractPartsContainingDBElement return $this; } + public function getPartIpnPrefix(): string + { + return $this->part_ipn_prefix; + } + + public function setPartIpnPrefix(string $part_ipn_prefix): void + { + $this->part_ipn_prefix = $part_ipn_prefix; + } + public function isDisableFootprints(): bool { return $this->disable_footprints; diff --git a/src/Entity/Parts/Part.php b/src/Entity/Parts/Part.php index f1dd6040..d0a279e3 100644 --- a/src/Entity/Parts/Part.php +++ b/src/Entity/Parts/Part.php @@ -61,7 +61,6 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Criteria; use Doctrine\ORM\Mapping as ORM; -use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; @@ -75,7 +74,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; * @extends AttachmentContainingDBElement * @template-use ParametersTrait */ -#[UniqueEntity(fields: ['ipn'], message: 'part.ipn.must_be_unique')] #[ORM\Entity(repositoryClass: PartRepository::class)] #[ORM\EntityListeners([TreeCacheInvalidationListener::class])] #[ORM\Table('`parts`')] diff --git a/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php b/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php index dd541d79..2cee7f1a 100644 --- a/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php +++ b/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php @@ -30,6 +30,7 @@ use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints\Length; +use App\Validator\Constraints\UniquePartIpnConstraint; /** * Advanced properties of a part, not related to a more specific group. @@ -65,6 +66,7 @@ trait AdvancedPropertyTrait #[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])] #[ORM\Column(type: Types::STRING, length: 100, unique: true, nullable: true)] #[Length(max: 100)] + #[UniquePartIpnConstraint] protected ?string $ipn = null; /** diff --git a/src/EventSubscriber/UserSystem/PartUniqueIpnSubscriber.php b/src/EventSubscriber/UserSystem/PartUniqueIpnSubscriber.php new file mode 100644 index 00000000..ecc25b4f --- /dev/null +++ b/src/EventSubscriber/UserSystem/PartUniqueIpnSubscriber.php @@ -0,0 +1,97 @@ +ipnSuggestSettings->autoAppendSuffix) { + return; + } + + $em = $args->getObjectManager(); + $uow = $em->getUnitOfWork(); + $meta = $em->getClassMetadata(Part::class); + + // Collect all IPNs already reserved in the current flush (so new entities do not collide with each other) + $reservedIpns = []; + + // Helper to assign a collision-free IPN for a Part entity + $ensureUnique = function (Part $part) use ($em, $uow, $meta, &$reservedIpns) { + $ipn = $part->getIpn(); + if ($ipn === null || $ipn === '') { + return; + } + + // Check against IPNs already reserved in the current flush (except itself) + $originalIpn = $ipn; + $candidate = $originalIpn; + $increment = 1; + + $conflicts = function (string $candidate) use ($em, $part, $reservedIpns) { + // Collision within the current flush session? + if (isset($reservedIpns[$candidate]) && $reservedIpns[$candidate] !== $part) { + return true; + } + // Collision with an existing DB row? + $existing = $em->getRepository(Part::class)->findOneBy(['ipn' => $candidate]); + return $existing !== null && $existing->getId() !== $part->getId(); + }; + + while ($conflicts($candidate)) { + $candidate = $originalIpn . '_' . $increment; + $increment++; + } + + if ($candidate !== $ipn) { + $before = $part->getIpn(); + $part->setIpn($candidate); + + // Recompute the change set so Doctrine writes the change + $uow->recomputeSingleEntityChangeSet($meta, $part); + $reservedIpns[$candidate] = $part; + + // If the old IPN was reserved already, clean it up + if ($before !== null && isset($reservedIpns[$before]) && $reservedIpns[$before] === $part) { + unset($reservedIpns[$before]); + } + } else { + // Candidate unchanged, but reserve it so subsequent entities see it + $reservedIpns[$candidate] = $part; + } + }; + + // 1) Iterate over new entities + foreach ($uow->getScheduledEntityInsertions() as $entity) { + if ($entity instanceof Part) { + $ensureUnique($entity); + } + } + + // 2) Iterate over updates (if IPN changed, ensure uniqueness again) + foreach ($uow->getScheduledEntityUpdates() as $entity) { + if ($entity instanceof Part) { + $ensureUnique($entity); + } + } + } +} diff --git a/src/Form/AdminPages/CategoryAdminForm.php b/src/Form/AdminPages/CategoryAdminForm.php index 44c1dede..489649ed 100644 --- a/src/Form/AdminPages/CategoryAdminForm.php +++ b/src/Form/AdminPages/CategoryAdminForm.php @@ -84,6 +84,17 @@ class CategoryAdminForm extends BaseEntityAdminForm 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity), ]); + $builder->add('part_ipn_prefix', TextType::class, [ + 'required' => false, + 'empty_data' => '', + 'label' => 'category.edit.part_ipn_prefix', + 'help' => 'category.edit.part_ipn_prefix.help', + 'attr' => [ + 'placeholder' => 'category.edit.part_ipn_prefix.placeholder', + ], + 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity), + ]); + $builder->add('default_description', RichTextEditorType::class, [ 'required' => false, 'empty_data' => '', diff --git a/src/Form/Part/PartBaseType.php b/src/Form/Part/PartBaseType.php index f0ba180e..b8276589 100644 --- a/src/Form/Part/PartBaseType.php +++ b/src/Form/Part/PartBaseType.php @@ -42,6 +42,7 @@ use App\Form\Type\StructuralEntityType; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\LogSystem\EventCommentNeededHelper; use App\Services\LogSystem\EventCommentType; +use App\Settings\MiscSettings\IpnSuggestSettings; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; @@ -57,8 +58,12 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface; class PartBaseType extends AbstractType { - public function __construct(protected Security $security, protected UrlGeneratorInterface $urlGenerator, protected EventCommentNeededHelper $event_comment_needed_helper) - { + public function __construct( + protected Security $security, + protected UrlGeneratorInterface $urlGenerator, + protected EventCommentNeededHelper $event_comment_needed_helper, + protected IpnSuggestSettings $ipnSuggestSettings, + ) { } public function buildForm(FormBuilderInterface $builder, array $options): void @@ -70,6 +75,39 @@ class PartBaseType extends AbstractType /** @var PartDetailDTO|null $dto */ $dto = $options['info_provider_dto']; + $descriptionAttr = [ + 'placeholder' => 'part.edit.description.placeholder', + 'rows' => 2, + ]; + + if ($this->ipnSuggestSettings->useDuplicateDescription) { + // Only add attribute when duplicate description feature is enabled + $descriptionAttr['data-ipn-suggestion'] = 'descriptionField'; + } + + $ipnAttr = [ + 'class' => 'ipn-suggestion-field', + 'data-elements--ipn-suggestion-target' => 'input', + 'autocomplete' => 'off', + ]; + + if ($this->ipnSuggestSettings->regex !== null && $this->ipnSuggestSettings->regex !== '') { + $ipnAttr['pattern'] = $this->ipnSuggestSettings->regex; + $ipnAttr['placeholder'] = $this->ipnSuggestSettings->regex; + $ipnAttr['title'] = $this->ipnSuggestSettings->regexHelp; + } + + $ipnOptions = [ + 'required' => false, + 'empty_data' => null, + 'label' => 'part.edit.ipn', + 'attr' => $ipnAttr, + ]; + + if (isset($ipnAttr['pattern']) && $this->ipnSuggestSettings->regexHelp !== null && $this->ipnSuggestSettings->regexHelp !== '') { + $ipnOptions['help'] = $this->ipnSuggestSettings->regexHelp; + } + //Common section $builder ->add('name', TextType::class, [ @@ -84,10 +122,7 @@ class PartBaseType extends AbstractType 'empty_data' => '', 'label' => 'part.edit.description', 'mode' => 'markdown-single_line', - 'attr' => [ - 'placeholder' => 'part.edit.description.placeholder', - 'rows' => 2, - ], + 'attr' => $descriptionAttr, ]) ->add('minAmount', SIUnitType::class, [ 'attr' => [ @@ -105,6 +140,9 @@ class PartBaseType extends AbstractType 'disable_not_selectable' => true, //Do not require category for new parts, so that the user must select the category by hand and cannot forget it (the requirement is handled by the constraint in the entity) 'required' => !$new_part, + 'attr' => [ + 'data-ipn-suggestion' => 'categoryField', + ] ]) ->add('footprint', StructuralEntityType::class, [ 'class' => Footprint::class, @@ -178,11 +216,7 @@ class PartBaseType extends AbstractType 'disable_not_selectable' => true, 'label' => 'part.edit.partCustomState', ]) - ->add('ipn', TextType::class, [ - 'required' => false, - 'empty_data' => null, - 'label' => 'part.edit.ipn', - ]); + ->add('ipn', TextType::class, $ipnOptions); //Comment section $builder->add('comment', RichTextEditorType::class, [ diff --git a/src/Repository/PartRepository.php b/src/Repository/PartRepository.php index edccd74b..3c83001a 100644 --- a/src/Repository/PartRepository.php +++ b/src/Repository/PartRepository.php @@ -22,17 +22,35 @@ declare(strict_types=1); namespace App\Repository; +use App\Entity\Parts\Category; use App\Entity\Parts\Part; use App\Entity\Parts\PartLot; +use App\Settings\MiscSettings\IpnSuggestSettings; use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\NoResultException; use Doctrine\ORM\QueryBuilder; +use Symfony\Contracts\Translation\TranslatorInterface; +use Doctrine\ORM\EntityManagerInterface; /** * @extends NamedDBElementRepository */ class PartRepository extends NamedDBElementRepository { + private TranslatorInterface $translator; + private IpnSuggestSettings $ipnSuggestSettings; + + public function __construct( + EntityManagerInterface $em, + TranslatorInterface $translator, + IpnSuggestSettings $ipnSuggestSettings, + ) { + parent::__construct($em, $em->getClassMetadata(Part::class)); + + $this->translator = $translator; + $this->ipnSuggestSettings = $ipnSuggestSettings; + } + /** * Gets the summed up instock of all parts (only parts without a measurement unit). * @@ -84,8 +102,7 @@ class PartRepository extends NamedDBElementRepository ->where('ILIKE(part.name, :query) = TRUE') ->orWhere('ILIKE(part.description, :query) = TRUE') ->orWhere('ILIKE(category.name, :query) = TRUE') - ->orWhere('ILIKE(footprint.name, :query) = TRUE') - ; + ->orWhere('ILIKE(footprint.name, :query) = TRUE'); $qb->setParameter('query', '%'.$query.'%'); @@ -94,4 +111,240 @@ class PartRepository extends NamedDBElementRepository return $qb->getQuery()->getResult(); } + + /** + * Provides IPN (Internal Part Number) suggestions for a given part based on its category, description, + * and configured autocomplete digit length. + * + * This function generates suggestions for common prefixes and incremented prefixes based on + * the part's current category and its hierarchy. If the part is unsaved, a default "n.a." prefix is returned. + * + * @param Part $part The part for which autocomplete suggestions are generated. + * @param string $description description to assist in generating suggestions. + * @param int $suggestPartDigits The number of digits used in autocomplete increments. + * + * @return array An associative array containing the following keys: + * - 'commonPrefixes': List of common prefixes found for the part. + * - 'prefixesPartIncrement': Increments for the generated prefixes, including hierarchical prefixes. + */ + public function autoCompleteIpn(Part $part, string $description, int $suggestPartDigits): array + { + $category = $part->getCategory(); + $ipnSuggestions = ['commonPrefixes' => [], 'prefixesPartIncrement' => []]; + + if (strlen($description) > 150) { + $description = substr($description, 0, 150); + } + + if ($description !== '' && $this->ipnSuggestSettings->useDuplicateDescription) { + // Check if the description is already used in another part, + + $suggestionByDescription = $this->getIpnSuggestByDescription($description); + + if ($suggestionByDescription !== null && $suggestionByDescription !== $part->getIpn() && $part->getIpn() !== null && $part->getIpn() !== '') { + $ipnSuggestions['prefixesPartIncrement'][] = [ + 'title' => $part->getIpn(), + 'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.description.current-increment') + ]; + } + + if ($suggestionByDescription !== null) { + $ipnSuggestions['prefixesPartIncrement'][] = [ + 'title' => $suggestionByDescription, + 'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.description.increment') + ]; + } + } + + // Validate the category and ensure it's an instance of Category + if ($category instanceof Category) { + $currentPath = $category->getPartIpnPrefix(); + $directIpnPrefixEmpty = $category->getPartIpnPrefix() === ''; + $currentPath = $currentPath === '' ? 'n.a.' : $currentPath; + + $increment = $this->generateNextPossiblePartIncrement($currentPath, $part, $suggestPartDigits); + + $ipnSuggestions['commonPrefixes'][] = [ + 'title' => $currentPath . '-', + 'description' => $directIpnPrefixEmpty ? $this->translator->trans('part.edit.tab.advanced.ipn.prefix_empty.direct_category', ['%name%' => $category->getName()]) : $this->translator->trans('part.edit.tab.advanced.ipn.prefix.direct_category') + ]; + + $ipnSuggestions['prefixesPartIncrement'][] = [ + 'title' => $currentPath . '-' . $increment, + 'description' => $directIpnPrefixEmpty ? $this->translator->trans('part.edit.tab.advanced.ipn.prefix_empty.direct_category', ['%name%' => $category->getName()]) : $this->translator->trans('part.edit.tab.advanced.ipn.prefix.direct_category.increment') + ]; + + // Process parent categories + $parentCategory = $category->getParent(); + + while ($parentCategory instanceof Category) { + // Prepend the parent category's prefix to the current path + $currentPath = $parentCategory->getPartIpnPrefix() . '-' . $currentPath; + $currentPath = $parentCategory->getPartIpnPrefix() === '' ? 'n.a.-' . $currentPath : $currentPath; + + $ipnSuggestions['commonPrefixes'][] = [ + 'title' => $currentPath . '-', + 'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment') + ]; + + $increment = $this->generateNextPossiblePartIncrement($currentPath, $part, $suggestPartDigits); + + $ipnSuggestions['prefixesPartIncrement'][] = [ + 'title' => $currentPath . '-' . $increment, + 'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.hierarchical.increment') + ]; + + // Move to the next parent category + $parentCategory = $parentCategory->getParent(); + } + } elseif ($part->getID() === null) { + $ipnSuggestions['commonPrefixes'][] = [ + 'title' => 'n.a.', + 'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.not_saved') + ]; + } + + return $ipnSuggestions; + } + + /** + * Suggests the next IPN (Internal Part Number) based on the provided part description. + * + * Searches for parts with similar descriptions and retrieves their existing IPNs to calculate the next suggestion. + * Returns null if the description is empty or no suggestion can be generated. + * + * @param string $description The part description to search for. + * + * @return string|null The suggested IPN, or null if no suggestion is possible. + * + * @throws NonUniqueResultException + */ + public function getIpnSuggestByDescription(string $description): ?string + { + if ($description === '') { + return null; + } + + $qb = $this->createQueryBuilder('part'); + + $qb->select('part') + ->where('part.description LIKE :descriptionPattern') + ->setParameter('descriptionPattern', $description.'%') + ->orderBy('part.id', 'ASC'); + + $partsBySameDescription = $qb->getQuery()->getResult(); + $givenIpnsWithSameDescription = []; + + foreach ($partsBySameDescription as $part) { + if ($part->getIpn() === null || $part->getIpn() === '') { + continue; + } + + $givenIpnsWithSameDescription[] = $part->getIpn(); + } + + return $this->getNextIpnSuggestion($givenIpnsWithSameDescription); + } + + /** + * Generates the next possible increment for a part within a given category, while ensuring uniqueness. + * + * This method calculates the next available increment for a part's identifier (`ipn`) based on the current path + * and the number of digits specified for the autocomplete feature. It ensures that the generated identifier + * aligns with the expected length and does not conflict with already existing identifiers in the same category. + * + * @param string $currentPath The base path or prefix for the part's identifier. + * @param Part $currentPart The part entity for which the increment is being generated. + * @param int $suggestPartDigits The number of digits reserved for the increment. + * + * @return string The next possible increment as a zero-padded string. + * + * @throws NonUniqueResultException If the query returns non-unique results. + * @throws NoResultException If the query fails to return a result. + */ + private function generateNextPossiblePartIncrement(string $currentPath, Part $currentPart, int $suggestPartDigits): string + { + $qb = $this->createQueryBuilder('part'); + + $expectedLength = strlen($currentPath) + 1 + $suggestPartDigits; // Path + '-' + $suggestPartDigits digits + + // Fetch all parts in the given category, sorted by their ID in ascending order + $qb->select('part') + ->where('part.ipn LIKE :ipnPattern') + ->andWhere('LENGTH(part.ipn) = :expectedLength') + ->setParameter('ipnPattern', $currentPath . '%') + ->setParameter('expectedLength', $expectedLength) + ->orderBy('part.id', 'ASC'); + + $parts = $qb->getQuery()->getResult(); + + // Collect all used increments in the category + $usedIncrements = []; + foreach ($parts as $part) { + if ($part->getIpn() === null || $part->getIpn() === '') { + continue; + } + + if ($part->getId() === $currentPart->getId() && $currentPart->getID() !== null) { + // Extract and return the current part's increment directly + $incrementPart = substr($part->getIpn(), -$suggestPartDigits); + if (is_numeric($incrementPart)) { + return str_pad((string) $incrementPart, $suggestPartDigits, '0', STR_PAD_LEFT); + } + } + + // Extract last $autocompletePartDigits digits for possible available part increment + $incrementPart = substr($part->getIpn(), -$suggestPartDigits); + if (is_numeric($incrementPart)) { + $usedIncrements[] = (int) $incrementPart; + } + + } + + // Generate the next free $autocompletePartDigits-digit increment + $nextIncrement = 1; // Start at the beginning + + while (in_array($nextIncrement, $usedIncrements, true)) { + $nextIncrement++; + } + + return str_pad((string) $nextIncrement, $suggestPartDigits, '0', STR_PAD_LEFT); + } + + /** + * Generates the next IPN suggestion based on the maximum numeric suffix found in the given IPNs. + * + * The new IPN is constructed using the base format of the first provided IPN, + * incremented by the next free numeric suffix. If no base IPNs are found, + * returns null. + * + * @param array $givenIpns List of IPNs to analyze. + * + * @return string|null The next suggested IPN, or null if no base IPNs can be derived. + */ + private function getNextIpnSuggestion(array $givenIpns): ?string { + $maxSuffix = 0; + + foreach ($givenIpns as $ipn) { + // Check whether the IPN contains a suffix "_ " + if (preg_match('/_(\d+)$/', $ipn, $matches)) { + $suffix = (int)$matches[1]; + if ($suffix > $maxSuffix) { + $maxSuffix = $suffix; // Höchste Nummer speichern + } + } + } + + // Find the basic format (the IPN without suffix) from the first IPN + $baseIpn = $givenIpns[0] ?? ''; + $baseIpn = preg_replace('/_\d+$/', '', $baseIpn); // Remove existing "_ " + + if ($baseIpn === '') { + return null; + } + + // Generate next free possible IPN + return $baseIpn . '_' . ($maxSuffix + 1); + } + } diff --git a/src/Settings/MiscSettings/IpnSuggestSettings.php b/src/Settings/MiscSettings/IpnSuggestSettings.php new file mode 100644 index 00000000..891b885c --- /dev/null +++ b/src/Settings/MiscSettings/IpnSuggestSettings.php @@ -0,0 +1,80 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\MiscSettings; + +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\ParameterTypes\StringType; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Translation\TranslatableMessage as TM; +use Symfony\Component\Validator\Constraints as Assert; + +#[Settings(label: new TM("settings.misc.ipn_suggest"))] +#[SettingsIcon("fa-list")] +class IpnSuggestSettings +{ + use SettingsTrait; + + #[SettingsParameter( + label: new TM("settings.misc.ipn_suggest.regex"), + description: new TM("settings.misc.ipn_suggest.regex.help"), + options: ['type' => StringType::class], + formOptions: ['attr' => ['placeholder' => '^[A-Za-z0-9]{3,4}(?:-[A-Za-z0-9]{3,4})*-\d{4}$']], + envVar: "IPN_SUGGEST_REGEX", envVarMode: EnvVarMode::OVERWRITE, + )] + public ?string $regex = null; + + #[SettingsParameter( + label: new TM("settings.misc.ipn_suggest.regex_help"), + description: new TM("settings.misc.ipn_suggest.regex_help_description"), + options: ['type' => StringType::class], + formOptions: ['attr' => ['placeholder' => 'Format: 3–4 alphanumeric segments (any number) separated by "-", followed by "-" and 4 digits, e.g., PCOM-RES-0001']], + envVar: "IPN_SUGGEST_REGEX_HELP", envVarMode: EnvVarMode::OVERWRITE, + )] + public ?string $regexHelp = null; + + #[SettingsParameter( + label: new TM("settings.misc.ipn_suggest.autoAppendSuffix"), + envVar: "bool:IPN_AUTO_APPEND_SUFFIX", envVarMode: EnvVarMode::OVERWRITE, + )] + public bool $autoAppendSuffix = false; + + #[SettingsParameter(label: new TM("settings.misc.ipn_suggest.suggestPartDigits"), + description: new TM("settings.misc.ipn_suggest.suggestPartDigits.help"), + formOptions: ['attr' => ['min' => 1, 'max' => 8]], + envVar: "int:IPN_SUGGEST_PART_DIGITS", envVarMode: EnvVarMode::OVERWRITE + )] + #[Assert\Range(min: 1, max: 8)] + public int $suggestPartDigits = 4; + + #[SettingsParameter( + label: new TM("settings.misc.ipn_suggest.useDuplicateDescription"), + description: new TM("settings.misc.ipn_suggest.useDuplicateDescription.help"), + envVar: "bool:IPN_USE_DUPLICATE_DESCRIPTION", envVarMode: EnvVarMode::OVERWRITE, + )] + public bool $useDuplicateDescription = false; +} diff --git a/src/Settings/MiscSettings/MiscSettings.php b/src/Settings/MiscSettings/MiscSettings.php index 1c304d4a..050dbcbc 100644 --- a/src/Settings/MiscSettings/MiscSettings.php +++ b/src/Settings/MiscSettings/MiscSettings.php @@ -35,4 +35,7 @@ class MiscSettings #[EmbeddedSettings] public ?ExchangeRateSettings $exchangeRate = null; + + #[EmbeddedSettings] + public ?IpnSuggestSettings $ipnSuggestSettings = null; } diff --git a/src/Validator/Constraints/UniquePartIpnConstraint.php b/src/Validator/Constraints/UniquePartIpnConstraint.php new file mode 100644 index 00000000..ca32f9ef --- /dev/null +++ b/src/Validator/Constraints/UniquePartIpnConstraint.php @@ -0,0 +1,22 @@ +entityManager = $entityManager; + $this->ipnSuggestSettings = $ipnSuggestSettings; + } + + public function validate($value, Constraint $constraint): void + { + if (null === $value || '' === $value) { + return; + } + + //If the autoAppendSuffix option is enabled, the IPN becomes unique automatically later + if ($this->ipnSuggestSettings->autoAppendSuffix) { + return; + } + + if (!$constraint instanceof UniquePartIpnConstraint) { + return; + } + + /** @var Part $currentPart */ + $currentPart = $this->context->getObject(); + + if (!$currentPart instanceof Part) { + return; + } + + $repository = $this->entityManager->getRepository(Part::class); + $existingParts = $repository->findBy(['ipn' => $value]); + + foreach ($existingParts as $existingPart) { + if ($currentPart->getId() !== $existingPart->getId()) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ value }}', $value) + ->addViolation(); + } + } + } +} diff --git a/templates/admin/category_admin.html.twig b/templates/admin/category_admin.html.twig index 5811640b..d87cee7f 100644 --- a/templates/admin/category_admin.html.twig +++ b/templates/admin/category_admin.html.twig @@ -31,6 +31,7 @@
    {{ form_row(form.partname_regex) }} {{ form_row(form.partname_hint) }} + {{ form_row(form.part_ipn_prefix) }}
    {{ form_row(form.default_description) }} {{ form_row(form.default_comment) }} diff --git a/templates/parts/edit/_advanced.html.twig b/templates/parts/edit/_advanced.html.twig index 46aac7b6..b0f1ff86 100644 --- a/templates/parts/edit/_advanced.html.twig +++ b/templates/parts/edit/_advanced.html.twig @@ -1,6 +1,16 @@ {{ form_row(form.needsReview) }} {{ form_row(form.favorite) }} {{ form_row(form.mass) }} -{{ form_row(form.ipn) }} +
    + {{ form_row(form.ipn) }} +
    {{ form_row(form.partUnit) }} {{ form_row(form.partCustomState) }} \ No newline at end of file diff --git a/tests/Repository/PartRepositoryTest.php b/tests/Repository/PartRepositoryTest.php new file mode 100644 index 00000000..68b75abb --- /dev/null +++ b/tests/Repository/PartRepositoryTest.php @@ -0,0 +1,297 @@ +. + */ + +declare(strict_types=1); + +namespace App\Tests\Repository; + +use App\Entity\Parts\Category; +use App\Entity\Parts\Part; +use App\Settings\MiscSettings\IpnSuggestSettings; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\ClassMetadata; +use PHPUnit\Framework\TestCase; +use Doctrine\ORM\Query; +use Doctrine\ORM\QueryBuilder; +use Symfony\Contracts\Translation\TranslatorInterface; +use App\Repository\PartRepository; + +final class PartRepositoryTest extends TestCase +{ + public function test_autocompleteSearch_builds_expected_query_without_db(): void + { + $qb = $this->getMockBuilder(QueryBuilder::class) + ->disableOriginalConstructor() + ->onlyMethods([ + 'select', 'leftJoin', 'where', 'orWhere', + 'setParameter', 'setMaxResults', 'orderBy', 'getQuery' + ])->getMock(); + + $qb->expects(self::once())->method('select')->with('part')->willReturnSelf(); + + $qb->expects(self::exactly(2))->method('leftJoin')->with($this->anything(), $this->anything())->willReturnSelf(); + + $qb->expects(self::atLeastOnce())->method('where')->with($this->anything())->willReturnSelf(); + $qb->method('orWhere')->with($this->anything())->willReturnSelf(); + + $searchQuery = 'res'; + $qb->expects(self::once())->method('setParameter')->with('query', '%'.$searchQuery.'%')->willReturnSelf(); + $qb->expects(self::once())->method('setMaxResults')->with(10)->willReturnSelf(); + $qb->expects(self::once())->method('orderBy')->with('NATSORT(part.name)', 'ASC')->willReturnSelf(); + + $emMock = $this->createMock(EntityManagerInterface::class); + $classMetadata = new ClassMetadata(Part::class); + $emMock->method('getClassMetadata')->with(Part::class)->willReturn($classMetadata); + + $translatorMock = $this->createMock(TranslatorInterface::class); + $ipnSuggestSettings = $this->createMock(IpnSuggestSettings::class); + + $repo = $this->getMockBuilder(PartRepository::class) + ->setConstructorArgs([$emMock, $translatorMock, $ipnSuggestSettings]) + ->onlyMethods(['createQueryBuilder']) + ->getMock(); + + $repo->expects(self::once()) + ->method('createQueryBuilder') + ->with('part') + ->willReturn($qb); + + $part = new Part(); // create found part, because it is not saved in DB + $part->setName('Resistor'); + + $queryMock = $this->getMockBuilder(Query::class) + ->disableOriginalConstructor() + ->onlyMethods(['getResult']) + ->getMock(); + $queryMock->expects(self::once())->method('getResult')->willReturn([$part]); + + $qb->method('getQuery')->willReturn($queryMock); + + $result = $repo->autocompleteSearch($searchQuery, 10); + + // Check one part found and returned + self::assertIsArray($result); + self::assertCount(1, $result); + self::assertSame($part, $result[0]); + } + + public function test_autoCompleteIpn_with_unsaved_part_and_category_without_part_description(): void + { + $qb = $this->getMockBuilder(QueryBuilder::class) + ->disableOriginalConstructor() + ->onlyMethods([ + 'select', 'leftJoin', 'where', 'andWhere', 'orWhere', + 'setParameter', 'setMaxResults', 'orderBy', 'getQuery' + ])->getMock(); + + $qb->method('select')->willReturnSelf(); + $qb->method('leftJoin')->willReturnSelf(); + $qb->method('where')->willReturnSelf(); + $qb->method('andWhere')->willReturnSelf(); + $qb->method('orWhere')->willReturnSelf(); + $qb->method('setParameter')->willReturnSelf(); + $qb->method('setMaxResults')->willReturnSelf(); + $qb->method('orderBy')->willReturnSelf(); + + $emMock = $this->createMock(EntityManagerInterface::class); + $classMetadata = new ClassMetadata(Part::class); + $emMock->method('getClassMetadata')->with(Part::class)->willReturn($classMetadata); + + $translatorMock = $this->createMock(TranslatorInterface::class); + $translatorMock->method('trans') + ->willReturnCallback(static function (string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string { + return $id; + }); + + $ipnSuggestSettings = $this->createMock(IpnSuggestSettings::class); + + $ipnSuggestSettings->suggestPartDigits = 4; + $ipnSuggestSettings->useDuplicateDescription = false; + + $repo = $this->getMockBuilder(PartRepository::class) + ->setConstructorArgs([$emMock, $translatorMock, $ipnSuggestSettings]) + ->onlyMethods(['createQueryBuilder']) + ->getMock(); + + $repo->expects(self::atLeastOnce()) + ->method('createQueryBuilder') + ->with('part') + ->willReturn($qb); + + $queryMock = $this->getMockBuilder(Query::class) + ->disableOriginalConstructor() + ->onlyMethods(['getResult']) + ->getMock(); + + $categoryParent = new Category(); + $categoryParent->setName('Passive components'); + $categoryParent->setPartIpnPrefix('PCOM'); + + $categoryChild = new Category(); + $categoryChild->setName('Resistors'); + $categoryChild->setPartIpnPrefix('RES'); + $categoryChild->setParent($categoryParent); + + $partForSuggestGeneration = new Part(); // create found part, because it is not saved in DB + $partForSuggestGeneration->setIpn('RES-0001'); + $partForSuggestGeneration->setCategory($categoryChild); + + $queryMock->method('getResult')->willReturn([$partForSuggestGeneration]); + $qb->method('getQuery')->willReturn($queryMock); + $suggestions = $repo->autoCompleteIpn($partForSuggestGeneration, '', 4); + + // Check structure available + self::assertIsArray($suggestions); + self::assertArrayHasKey('commonPrefixes', $suggestions); + self::assertArrayHasKey('prefixesPartIncrement', $suggestions); + self::assertNotEmpty($suggestions['commonPrefixes']); + self::assertNotEmpty($suggestions['prefixesPartIncrement']); + + // Check expected values + self::assertSame('RES-', $suggestions['commonPrefixes'][0]['title']); + self::assertSame('part.edit.tab.advanced.ipn.prefix.direct_category', $suggestions['commonPrefixes'][0]['description']); + self::assertSame('PCOM-RES-', $suggestions['commonPrefixes'][1]['title']); + self::assertSame('part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment', $suggestions['commonPrefixes'][1]['description']); + + self::assertSame('RES-0002', $suggestions['prefixesPartIncrement'][0]['title']); // next possible free increment for given part category + self::assertSame('part.edit.tab.advanced.ipn.prefix.direct_category.increment', $suggestions['prefixesPartIncrement'][0]['description']); + self::assertSame('PCOM-RES-0002', $suggestions['prefixesPartIncrement'][1]['title']); // next possible free increment for given part category + self::assertSame('part.edit.tab.advanced.ipn.prefix.hierarchical.increment', $suggestions['prefixesPartIncrement'][1]['description']); + } + + public function test_autoCompleteIpn_with_unsaved_part_and_category_with_part_description(): void + { + $qb = $this->getMockBuilder(QueryBuilder::class) + ->disableOriginalConstructor() + ->onlyMethods([ + 'select', 'leftJoin', 'where', 'andWhere', 'orWhere', + 'setParameter', 'setMaxResults', 'orderBy', 'getQuery' + ])->getMock(); + + $qb->method('select')->willReturnSelf(); + $qb->method('leftJoin')->willReturnSelf(); + $qb->method('where')->willReturnSelf(); + $qb->method('andWhere')->willReturnSelf(); + $qb->method('orWhere')->willReturnSelf(); + $qb->method('setParameter')->willReturnSelf(); + $qb->method('setMaxResults')->willReturnSelf(); + $qb->method('orderBy')->willReturnSelf(); + + $emMock = $this->createMock(EntityManagerInterface::class); + $classMetadata = new ClassMetadata(Part::class); + $emMock->method('getClassMetadata')->with(Part::class)->willReturn($classMetadata); + + $translatorMock = $this->createMock(TranslatorInterface::class); + $translatorMock->method('trans') + ->willReturnCallback(static function (string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string { + return $id; + }); + + $ipnSuggestSettings = $this->createMock(IpnSuggestSettings::class); + + $ipnSuggestSettings->suggestPartDigits = 4; + $ipnSuggestSettings->useDuplicateDescription = false; + + $repo = $this->getMockBuilder(PartRepository::class) + ->setConstructorArgs([$emMock, $translatorMock, $ipnSuggestSettings]) + ->onlyMethods(['createQueryBuilder']) + ->getMock(); + + $repo->expects(self::atLeastOnce()) + ->method('createQueryBuilder') + ->with('part') + ->willReturn($qb); + + $queryMock = $this->getMockBuilder(Query::class) + ->disableOriginalConstructor() + ->onlyMethods(['getResult']) + ->getMock(); + + $categoryParent = new Category(); + $categoryParent->setName('Passive components'); + $categoryParent->setPartIpnPrefix('PCOM'); + + $categoryChild = new Category(); + $categoryChild->setName('Resistors'); + $categoryChild->setPartIpnPrefix('RES'); + $categoryChild->setParent($categoryParent); + + $partForSuggestGeneration = new Part(); // create found part, because it is not saved in DB + $partForSuggestGeneration->setCategory($categoryChild); + $partForSuggestGeneration->setIpn('1810-1679_1'); + $partForSuggestGeneration->setDescription('NETWORK-RESISTOR 4 0 OHM +5PCT 0.063W TKF SMT'); + + $queryMock->method('getResult')->willReturn([$partForSuggestGeneration]); + $qb->method('getQuery')->willReturn($queryMock); + $suggestions = $repo->autoCompleteIpn($partForSuggestGeneration, 'NETWORK-RESISTOR 4 0 OHM +5PCT 0.063W TKF SMT', 4); + + // Check structure available + self::assertIsArray($suggestions); + self::assertArrayHasKey('commonPrefixes', $suggestions); + self::assertArrayHasKey('prefixesPartIncrement', $suggestions); + self::assertNotEmpty($suggestions['commonPrefixes']); + self::assertCount(2, $suggestions['commonPrefixes']); + self::assertNotEmpty($suggestions['prefixesPartIncrement']); + self::assertCount(2, $suggestions['prefixesPartIncrement']); + + // Check expected values without any increment, for user to decide + self::assertSame('RES-', $suggestions['commonPrefixes'][0]['title']); + self::assertSame('part.edit.tab.advanced.ipn.prefix.direct_category', $suggestions['commonPrefixes'][0]['description']); + self::assertSame('PCOM-RES-', $suggestions['commonPrefixes'][1]['title']); + self::assertSame('part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment', $suggestions['commonPrefixes'][1]['description']); + + // Check expected values with next possible increment at category level + self::assertSame('RES-0001', $suggestions['prefixesPartIncrement'][0]['title']); // next possible free increment for given part category + self::assertSame('part.edit.tab.advanced.ipn.prefix.direct_category.increment', $suggestions['prefixesPartIncrement'][0]['description']); + self::assertSame('PCOM-RES-0001', $suggestions['prefixesPartIncrement'][1]['title']); // next possible free increment for given part category + self::assertSame('part.edit.tab.advanced.ipn.prefix.hierarchical.increment', $suggestions['prefixesPartIncrement'][1]['description']); + + $ipnSuggestSettings->useDuplicateDescription = true; + + $suggestionsWithSameDescription = $repo->autoCompleteIpn($partForSuggestGeneration, 'NETWORK-RESISTOR 4 0 OHM +5PCT 0.063W TKF SMT', 4); + + // Check structure available + self::assertIsArray($suggestionsWithSameDescription); + self::assertArrayHasKey('commonPrefixes', $suggestionsWithSameDescription); + self::assertArrayHasKey('prefixesPartIncrement', $suggestionsWithSameDescription); + self::assertNotEmpty($suggestionsWithSameDescription['commonPrefixes']); + self::assertCount(2, $suggestionsWithSameDescription['commonPrefixes']); + self::assertNotEmpty($suggestionsWithSameDescription['prefixesPartIncrement']); + self::assertCount(4, $suggestionsWithSameDescription['prefixesPartIncrement']); + + // Check expected values without any increment, for user to decide + self::assertSame('RES-', $suggestionsWithSameDescription['commonPrefixes'][0]['title']); + self::assertSame('part.edit.tab.advanced.ipn.prefix.direct_category', $suggestionsWithSameDescription['commonPrefixes'][0]['description']); + self::assertSame('PCOM-RES-', $suggestionsWithSameDescription['commonPrefixes'][1]['title']); + self::assertSame('part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment', $suggestionsWithSameDescription['commonPrefixes'][1]['description']); + + // Check expected values with next possible increment at part description level + self::assertSame('1810-1679_1', $suggestionsWithSameDescription['prefixesPartIncrement'][0]['title']); // current given value + self::assertSame('part.edit.tab.advanced.ipn.prefix.description.current-increment', $suggestionsWithSameDescription['prefixesPartIncrement'][0]['description']); + self::assertSame('1810-1679_2', $suggestionsWithSameDescription['prefixesPartIncrement'][1]['title']); // next possible value + self::assertSame('part.edit.tab.advanced.ipn.prefix.description.increment', $suggestionsWithSameDescription['prefixesPartIncrement'][1]['description']); + + // Check expected values with next possible increment at category level + self::assertSame('RES-0001', $suggestionsWithSameDescription['prefixesPartIncrement'][2]['title']); // next possible free increment for given part category + self::assertSame('part.edit.tab.advanced.ipn.prefix.direct_category.increment', $suggestionsWithSameDescription['prefixesPartIncrement'][2]['description']); + self::assertSame('PCOM-RES-0001', $suggestionsWithSameDescription['prefixesPartIncrement'][3]['title']); // next possible free increment for given part category + self::assertSame('part.edit.tab.advanced.ipn.prefix.hierarchical.increment', $suggestionsWithSameDescription['prefixesPartIncrement'][3]['description']); + } +} diff --git a/translations/messages.cs.xlf b/translations/messages.cs.xlf index 2f6d8cc2..cd572dae 100644 --- a/translations/messages.cs.xlf +++ b/translations/messages.cs.xlf @@ -1848,6 +1848,66 @@ Související prvky budou přesunuty nahoru. Pokročilé + + + part.edit.tab.advanced.ipn.commonSectionHeader + Návrhy bez přírůstku části + + + + + part.edit.tab.advanced.ipn.partIncrementHeader + Návrhy s číselnými přírůstky částí + + + + + part.edit.tab.advanced.ipn.prefix.description.current-increment + Aktuální specifikace IPN pro součást + + + + + part.edit.tab.advanced.ipn.prefix.description.increment + Další možná specifikace IPN na základě identického popisu součásti + + + + + part.edit.tab.advanced.ipn.prefix_empty.direct_category + IPN předpona přímé kategorie je prázdná, zadejte ji v kategorii „%name%“ + + + + + part.edit.tab.advanced.ipn.prefix.direct_category + IPN prefix přímé kategorie + + + + + part.edit.tab.advanced.ipn.prefix.direct_category.increment + IPN prefix přímé kategorie a specifického přírůstku pro část + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment + IPN prefixy s hierarchickým pořadím kategorií rodičovských prefixů + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.increment + IPN prefixy s hierarchickým pořadím kategorií rodičovských prefixů a specifickým přírůstkem pro část + + + + + part.edit.tab.advanced.ipn.prefix.not_saved + Nejprve vytvořte součást a přiřaďte ji do kategorie: s dostupnými kategoriemi a jejich vlastními IPN prefixy lze automaticky navrhnout IPN označení pro danou součást + + Part-DB1\templates\Parts\edit\edit_part_info.html.twig:40 @@ -6989,6 +7049,12 @@ Pokud jste to provedli nesprávně nebo pokud počítač již není důvěryhodn Filtr názvů + + + category.edit.part_ipn_prefix + Předpona součásti IPN + + obsolete @@ -10290,12 +10356,24 @@ Element 3 např. "/Kondenzátor \d+ nF/i" + + + category.edit.part_ipn_prefix.placeholder + např. "B12A" + + category.edit.partname_regex.help Regulární výraz kompatibilní s PCRE, kterému musí název dílu odpovídat. + + + category.edit.part_ipn_prefix.help + Předpona navrhovaná při zadávání IPN části. + + entity.select.add_hint @@ -13029,6 +13107,54 @@ Vezměte prosím na vědomí, že se nemůžete vydávat za uživatele se zakáz Pokud potřebujete směnné kurzy mezi měnami mimo eurozónu, můžete zde zadat API klíč z fixer.io. + + + settings.misc.ipn_suggest + Seznam návrhů IPN součástek + + + + + settings.misc.ipn_suggest.regex + Regex + + + + + settings.misc.ipn_suggest.regex_help + Nápověda text + + + + + settings.misc.ipn_suggest.regex_help_description + Definujte svůj vlastní text nápovědy pro specifikaci formátu Regex. + + + + + settings.misc.ipn_suggest.autoAppendSuffix + Pokud je tato možnost povolena, bude při opětovném zadání existujícího IPN při ukládání k vstupu přidána přírůstková přípona. + + + + + settings.misc.ipn_suggest.suggestPartDigits + Počet čísel pro inkrement + + + + + settings.misc.ipn_suggest.useDuplicateDescription + Je-li povoleno, použije se popis součástky k nalezení existujících součástek se stejným popisem a k určení další volné IPN navýšením její číselné přípony pro seznam návrhů. + + + + + settings.misc.ipn_suggest.suggestPartDigits.help + Počet číslic použitých pro inkrementální číslování součástí v návrhovém systému IPN (Interní číslo součástky). + + settings.behavior.part_info diff --git a/translations/messages.da.xlf b/translations/messages.da.xlf index d88ad77e..530d91aa 100644 --- a/translations/messages.da.xlf +++ b/translations/messages.da.xlf @@ -1856,6 +1856,66 @@ Underelementer vil blive flyttet opad. Advanceret + + + part.edit.tab.advanced.ipn.commonSectionHeader + Forslag uden del-inkrement + + + + + part.edit.tab.advanced.ipn.partIncrementHeader + Forslag med numeriske deleforøgelser + + + + + part.edit.tab.advanced.ipn.prefix.description.current-increment + Aktuel IPN-specifikation for delen + + + + + part.edit.tab.advanced.ipn.prefix.description.increment + Næste mulige IPN-specifikation baseret på en identisk delebeskrivelse + + + + + part.edit.tab.advanced.ipn.prefix_empty.direct_category + IPN-præfikset for den direkte kategori er tomt, angiv det i kategorien "%name%" + + + + + part.edit.tab.advanced.ipn.prefix.direct_category + IPN-præfiks for direkte kategori + + + + + part.edit.tab.advanced.ipn.prefix.direct_category.increment + IPN-præfiks for den direkte kategori og en delspecifik inkrement + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment + IPN-præfikser med hierarkisk rækkefølge af overordnede præfikser + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.increment + IPN-præfikser med hierarkisk rækkefølge af overordnede præfikser og en del-specifik inkrement + + + + + part.edit.tab.advanced.ipn.prefix.not_saved + Opret først en komponent, og tildel den en kategori: med eksisterende kategorier og deres egne IPN-præfikser kan IPN-betegnelsen for komponenten foreslås automatisk + + Part-DB1\templates\Parts\edit\edit_part_info.html.twig:40 @@ -6996,6 +7056,12 @@ Bemærk også, at uden to-faktor-godkendelse er din konto ikke længere så godt Navnefilter + + + category.edit.part_ipn_prefix + IPN-komponentförstavelse + + obsolete @@ -10316,12 +10382,24 @@ Element 3 f.eks. "/Kondensator \d+ nF/i" + + + category.edit.part_ipn_prefix.placeholder + f.eks. "B12A" + + category.edit.partname_regex.help Et PCRE-kompatibelt regulært udtryk, som delnavnet skal opfylde. + + + category.edit.part_ipn_prefix.help + Et prefix foreslået, når IPN for en del indtastes. + + entity.select.add_hint diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index ada8a1d2..806c2e52 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -1,6 +1,6 @@ - + Part-DB1\templates\AdminPages\AttachmentTypeAdmin.html.twig:4 @@ -242,7 +242,7 @@ part.info.timetravel_hint - So sah das Bauteil vor %timestamp% aus. <i>Beachten Sie, dass dieses Feature experimentell ist und die angezeigten Infos daher nicht unbedingt korrekt sind.</i> + Beachten Sie, dass dieses Feature experimentell ist und die angezeigten Infos daher nicht unbedingt korrekt sind.]]> @@ -737,9 +737,9 @@ user.edit.tfa.disable_tfa_message - Dies wird <b>alle aktiven Zwei-Faktor-Authentifizierungsmethoden des Nutzers deaktivieren</b> und die <b>Backupcodes löschen</b>! <br> -Der Benutzer wird alle Zwei-Faktor-Authentifizierungmethoden neu einrichten müssen und neue Backupcodes ausdrucken müssen! <br><br> -<b>Führen sie dies nur durch, wenn Sie über die Identität des (um Hilfe suchenden) Benutzers absolut sicher sind, da ansonsten eine Kompromittierung des Accounts durch einen Angreifer erfolgen könnte!</b> + alle aktiven Zwei-Faktor-Authentifizierungsmethoden des Nutzers deaktivieren und die Backupcodes löschen!
    +Der Benutzer wird alle Zwei-Faktor-Authentifizierungmethoden neu einrichten müssen und neue Backupcodes ausdrucken müssen!

    +Führen sie dies nur durch, wenn Sie über die Identität des (um Hilfe suchenden) Benutzers absolut sicher sind, da ansonsten eine Kompromittierung des Accounts durch einen Angreifer erfolgen könnte!]]>
    @@ -1446,7 +1446,7 @@ Subelemente werden beim Löschen nach oben verschoben. homepage.github.text - Quellcode, Downloads, Bugreports, ToDo-Liste usw. gibts auf der <a class="link-external" target="_blank" href="%href%">GitHub Projektseite</a> + GitHub Projektseite]]> @@ -1468,7 +1468,7 @@ Subelemente werden beim Löschen nach oben verschoben. homepage.help.text - Hilfe und Tipps finden sie im <a class="link-external" rel="noopener" target="_blank" href="%href%">Wiki</a> der GitHub Seite. + Wiki der GitHub Seite.]]> @@ -1710,7 +1710,7 @@ Subelemente werden beim Löschen nach oben verschoben. email.pw_reset.fallback - Wenn dies nicht funktioniert, rufen Sie <a href="%url%">%url%</a> auf und geben Sie die folgenden Daten ein + %url% auf und geben Sie die folgenden Daten ein]]> @@ -1740,7 +1740,7 @@ Subelemente werden beim Löschen nach oben verschoben. email.pw_reset.valid_unit %date% - Das Reset-Token ist gültig bis <i>%date%</i> + %date%]]> @@ -1847,6 +1847,66 @@ Subelemente werden beim Löschen nach oben verschoben. Erweiterte Optionen
    + + + part.edit.tab.advanced.ipn.commonSectionHeader + Vorschläge ohne Teil-Inkrement + + + + + part.edit.tab.advanced.ipn.partIncrementHeader + Vorschläge mit numerischen Teil-Inkrement + + + + + part.edit.tab.advanced.ipn.prefix.description.current-increment + Aktuelle IPN-Angabe des Bauteils + + + + + part.edit.tab.advanced.ipn.prefix.description.increment + Nächstmögliche IPN-Angabe auf Basis der identischen Bauteil-Beschreibung + + + + + part.edit.tab.advanced.ipn.prefix_empty.direct_category + IPN-Präfix der direkten Kategorie leer, geben Sie einen Präfix in Kategorie "%name%" an + + + + + part.edit.tab.advanced.ipn.prefix.direct_category + IPN-Präfix der direkten Kategorie + + + + + part.edit.tab.advanced.ipn.prefix.direct_category.increment + IPN-Präfix der direkten Kategorie und eines teilspezifischen Inkrements + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment + IPN-Präfixe mit hierarchischer Kategorienreihenfolge der Elternpräfixe + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.increment + IPN-Präfixe mit hierarchischer Kategorienreihenfolge der Elternpräfixe und ein teilsspezifisches Inkrement + + + + + part.edit.tab.advanced.ipn.prefix.not_saved + Bitte erstellen Sie zuerst ein Bauteil und weisen Sie dieses einer Kategorie zu: mit vorhandenen Kategorien und derene eigenen IPN-Präfix kann die IPN-Angabe für das jeweilige Teil automatisch vorgeschlagen werden + + Part-DB1\templates\Parts\edit\edit_part_info.html.twig:40 @@ -3583,8 +3643,8 @@ Subelemente werden beim Löschen nach oben verschoben. tfa_google.disable.confirm_message - Wenn Sie die Authenticator App deaktivieren, werden alle Backupcodes gelöscht, daher sie müssen sie evtl. neu ausdrucken.<br> -Beachten Sie außerdem, dass ihr Account ohne Zwei-Faktor-Authentifizierung nicht mehr so gut gegen Angreifer geschützt ist! + +Beachten Sie außerdem, dass ihr Account ohne Zwei-Faktor-Authentifizierung nicht mehr so gut gegen Angreifer geschützt ist!]]> @@ -3604,7 +3664,7 @@ Beachten Sie außerdem, dass ihr Account ohne Zwei-Faktor-Authentifizierung nich tfa_google.step.download - Laden Sie eine Authenticator App herunter (z.B. <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Google Authenticator</a> oder <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp">FreeOTP Authenticator</a>) + Google Authenticator oder FreeOTP Authenticator)]]> @@ -3846,8 +3906,8 @@ Beachten Sie außerdem, dass ihr Account ohne Zwei-Faktor-Authentifizierung nich tfa_trustedDevices.explanation - Bei der Überprüfung des zweiten Faktors, kann der aktuelle Computer als vertrauenswürdig gekennzeichnet werden, daher werden keine Zwei-Faktor-Überprüfungen mehr an diesem Computer benötigt. -Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertrauenswürdig ist, können Sie hier den Status <i>aller </i>Computer zurücksetzen. + aller Computer zurücksetzen.]]> @@ -5324,7 +5384,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr label_options.lines_mode.help - Wenn Sie hier Twig auswählen, wird das Contentfeld als Twig-Template interpretiert. Weitere Hilfe gibt es in der <a href="https://twig.symfony.com/doc/3.x/templates.html">Twig Dokumentation</a> und dem <a href="https://docs.part-db.de/usage/labels.html#twig-mode">Wiki</a>. + Twig Dokumentation und dem Wiki.]]> @@ -6988,6 +7048,12 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr Namensfilter + + + category.edit.part_ipn_prefix + Bauteil IPN-Präfix + + obsolete @@ -7186,15 +7252,15 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr mass_creation.lines.placeholder - Element 1 + +Element 1 -> Element 1.1 +Element 1 -> Element 1.2]]> @@ -9479,25 +9545,25 @@ Element 1 -> Element 1.2 filter.parameter_value_constraint.operator.< - Typ. Wert < + filter.parameter_value_constraint.operator.> - Typ. Wert > + ]]> filter.parameter_value_constraint.operator.<= - Typ. Wert <= + filter.parameter_value_constraint.operator.>= - Typ. Wert >= + =]]> @@ -9605,7 +9671,7 @@ Element 1 -> Element 1.2 parts_list.search.searching_for - Suche Teile mit dem Suchbegriff <b>%keyword%</b> + %keyword%]]> @@ -10265,13 +10331,13 @@ Element 1 -> Element 1.2 project.builds.number_of_builds_possible - Sie haben genug Bauteile auf Lager, um <b>%max_builds%</b> Exemplare dieses Projektes zu bauen. + %max_builds% Exemplare dieses Projektes zu bauen.]]> project.builds.check_project_status - Der aktuelle Projektstatus ist <b>"%project_status%"</b>. Sie sollten überprüfen, ob sie das Projekt mit diesem Status wirklich bauen wollen! + "%project_status%". Sie sollten überprüfen, ob sie das Projekt mit diesem Status wirklich bauen wollen!]]> @@ -10364,16 +10430,28 @@ Element 1 -> Element 1.2 z.B. "/Kondensator \d+ nF/i" + + + category.edit.part_ipn_prefix.placeholder + z.B. "B12A" + + category.edit.partname_regex.help Ein PCRE-kompatibler regulärer Ausdruck, den der Bauteilename erfüllen muss. + + + category.edit.part_ipn_prefix.help + Ein Präfix, der bei der IPN-Eingabe eines Bauteils vorgeschlagen wird. + + entity.select.add_hint - Nutzen Sie -> um verschachtelte Strukturen anzulegen, z.B. "Element 1->Element 1.1" + um verschachtelte Strukturen anzulegen, z.B. "Element 1->Element 1.1"]]> @@ -10397,13 +10475,13 @@ Element 1 -> Element 1.2 homepage.first_steps.introduction - Die Datenbank ist momentan noch leer. Sie möchten möglicherweise die <a href="%url%">Dokumentation</a> lesen oder anfangen, die folgenden Datenstrukturen anzulegen. + Dokumentation lesen oder anfangen, die folgenden Datenstrukturen anzulegen.]]> homepage.first_steps.create_part - Oder Sie können direkt ein <a href="%url%">neues Bauteil erstellen</a>. + neues Bauteil erstellen.]]> @@ -10415,7 +10493,7 @@ Element 1 -> Element 1.2 homepage.forum.text - Für Fragen rund um Part-DB, nutze das <a class="link-external" rel="noopener" target="_blank" href="%href%">Diskussionsforum</a> + Diskussionsforum]]> @@ -11081,7 +11159,7 @@ Element 1 -> Element 1.2 parts.import.help_documentation - Konsultieren Sie die <a href="%link%">Dokumentation</a> für weiter Informationen über das Dateiformat. + Dokumentation für weiter Informationen über das Dateiformat.]]> @@ -11273,7 +11351,7 @@ Element 1 -> Element 1.2 part.filter.lessThanDesired - Weniger vorhanden als gewünscht (Gesamtmenge < Mindestmenge) + @@ -12085,13 +12163,13 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön part.merge.confirm.title - Möchten Sie wirklich <b>%other%</b> in <b>%target%</b> zusammenführen? + %other% in %target% zusammenführen?]]> part.merge.confirm.message - <b>%other%</b> wird gelöscht, und das aktuelle Bauteil wird mit den angezeigten Daten gespeichert. + %other% wird gelöscht, und das aktuelle Bauteil wird mit den angezeigten Daten gespeichert.]]> @@ -12445,7 +12523,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.ips.element14.apiKey.help - Sie können sich unter <a href="https://partner.element14.com/">https://partner.element14.com/</a> für einen API-Schlüssel registrieren. + https://partner.element14.com/ für einen API-Schlüssel registrieren.]]> @@ -12457,7 +12535,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.ips.element14.storeId.help - Die Domain des Shops, aus dem die Daten abgerufen werden sollen. Diese bestimmt die Sprache und Währung der Ergebnisse. Eine Liste der gültigen Domains finden Sie <a href="https://partner.element14.com/docs/Product_Search_API_REST__Description">hier</a>. + hier.]]> @@ -12475,7 +12553,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.ips.tme.token.help - Sie können einen API-Token und einen geheimen Schlüssel unter <a href="https://developers.tme.eu/en/">https://developers.tme.eu/en/</a> erhalten. + https://developers.tme.eu/en/ erhalten.]]> @@ -12523,7 +12601,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.ips.mouser.apiKey.help - Sie können sich unter <a href="https://eu.mouser.com/api-hub/">https://eu.mouser.com/api-hub/</a> für einen API-Schlüssel registrieren. + https://eu.mouser.com/api-hub/ für einen API-Schlüssel registrieren.]]> @@ -12571,7 +12649,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.ips.mouser.searchOptions.rohsAndInStock - Sofort verfügbar & RoHS konform + @@ -12601,7 +12679,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.system.attachments - Anhänge & Dateien + @@ -12625,7 +12703,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.system.attachments.allowDownloads.help - Mit dieser Option können Benutzer externe Dateien in die Part-DB herunterladen, indem sie eine URL angeben. <b>Achtung: Dies kann ein Sicherheitsrisiko darstellen, da Benutzer dadurch möglicherweise über die Part-DB auf Intranet-Ressourcen zugreifen können!</b> + Achtung: Dies kann ein Sicherheitsrisiko darstellen, da Benutzer dadurch möglicherweise über die Part-DB auf Intranet-Ressourcen zugreifen können!]]> @@ -12799,8 +12877,8 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.system.localization.base_currency_description - Die Währung, in der Preisinformationen und Wechselkurse gespeichert werden. Diese Währung wird angenommen, wenn für eine Preisinformation keine Währung festgelegt ist. -<b>Bitte beachten Sie, dass die Währungen bei einer Änderung dieses Wertes nicht umgerechnet werden. Wenn Sie also die Basiswährung ändern, nachdem Sie bereits Preisinformationen hinzugefügt haben, führt dies zu falschen Preisen!</b> + Bitte beachten Sie, dass die Währungen bei einer Änderung dieses Wertes nicht umgerechnet werden. Wenn Sie also die Basiswährung ändern, nachdem Sie bereits Preisinformationen hinzugefügt haben, führt dies zu falschen Preisen!]]> @@ -12830,7 +12908,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.misc.kicad_eda.category_depth.help - Dieser Wert bestimmt die Tiefe des Kategoriebaums, der in KiCad sichtbar ist. 0 bedeutet, dass nur die Kategorien der obersten Ebene sichtbar sind. Setzen Sie den Wert auf > 0, um weitere Ebenen anzuzeigen. Setzen Sie den Wert auf -1, um alle Teile der Part-DB innerhalb einer einzigen Kategorie in KiCad anzuzeigen. + 0, um weitere Ebenen anzuzeigen. Setzen Sie den Wert auf -1, um alle Teile der Part-DB innerhalb einer einzigen Kategorie in KiCad anzuzeigen.]]> @@ -12848,7 +12926,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.behavior.sidebar.items.help - Die Menüs, die standardmäßig in der Seitenleiste angezeigt werden. Die Reihenfolge der Elemente kann per Drag & Drop geändert werden. + @@ -12896,7 +12974,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.behavior.table.parts_default_columns.help - Die Spalten, die standardmäßig in Bauteiltabellen angezeigt werden sollen. Die Reihenfolge der Elemente kann per Drag & Drop geändert werden. + @@ -12950,7 +13028,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.ips.oemsecrets.sortMode.M - Vollständigkeit & Herstellername + @@ -13109,6 +13187,54 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön Wenn Sie Wechselkurse zwischen Nicht-Euro-Währungen benötigen, können Sie hier einen API-Schlüssel von fixer.io eingeben. + + + settings.misc.ipn_suggest + Bauteil IPN-Vorschlagsliste + + + + + settings.misc.ipn_suggest.regex + Regex + + + + + settings.misc.ipn_suggest.regex_help + Hilfetext + + + + + settings.misc.ipn_suggest.regex_help_description + Definieren Sie Ihren eigenen Nutzer-Hilfetext zur Regex Formatvorgabe. + + + + + settings.misc.ipn_suggest.autoAppendSuffix + Hänge ein inkrementelles Suffix an, wenn eine IPN bereits durch ein anderes Bauteil verwendet wird. + + + + + settings.misc.ipn_suggest.suggestPartDigits + Stellen für numerisches Inkrement + + + + + settings.misc.ipn_suggest.useDuplicateDescription + Verwende Bauteilebeschreibung zur Ermittlung der nächsten IPN + + + + + settings.misc.ipn_suggest.suggestPartDigits.help + Die Anzahl der Ziffern, die für die inkrementale Nummerierung von Teilen im IPN-Vorschlagssystem verwendet werden. + + settings.behavior.part_info @@ -13562,7 +13688,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.behavior.homepage.items.help - Die Elemente, die auf der Startseite angezeigt werden sollen. Die Reihenfolge kann per Drag & Drop geändert werden. + @@ -14304,5 +14430,11 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön Dies ist auf der Informationsquellen Übersichtsseite möglich. + + + settings.misc.ipn_suggest.useDuplicateDescription.help + Wenn aktiviert, wird die Bauteil-Beschreibung verwendet, um vorhandene Teile mit derselben Beschreibung zu finden und die nächste verfügbare IPN für die Vorschlagsliste zu ermitteln, indem der numerische Suffix entsprechend erhöht wird. + + diff --git a/translations/messages.el.xlf b/translations/messages.el.xlf index 4ea8a2e4..3618fa3d 100644 --- a/translations/messages.el.xlf +++ b/translations/messages.el.xlf @@ -1589,5 +1589,83 @@ Προσαρμοσμένη κατάσταση μέρους + + + category.edit.part_ipn_prefix + Πρόθεμα εξαρτήματος IPN + + + + + category.edit.part_ipn_prefix.placeholder + π.χ. "B12A" + + + + + category.edit.part_ipn_prefix.help + Μια προτεινόμενη πρόθεμα κατά την εισαγωγή του IPN ενός τμήματος. + + + + + part.edit.tab.advanced.ipn.commonSectionHeader + Προτάσεις χωρίς αύξηση μέρους + + + + + part.edit.tab.advanced.ipn.partIncrementHeader + Προτάσεις με αριθμητικές αυξήσεις μερών + + + + + part.edit.tab.advanced.ipn.prefix.description.current-increment + Τρέχουσα προδιαγραφή IPN του εξαρτήματος + + + + + part.edit.tab.advanced.ipn.prefix.description.increment + Επόμενη δυνατή προδιαγραφή IPN βάσει της ίδιας περιγραφής εξαρτήματος + + + + + part.edit.tab.advanced.ipn.prefix_empty.direct_category + Το IPN πρόθεμα της άμεσης κατηγορίας είναι κενό, καθορίστε το στην κατηγορία "%name%" + + + + + part.edit.tab.advanced.ipn.prefix.direct_category + Πρόθεμα IPN για την άμεση κατηγορία + + + + + part.edit.tab.advanced.ipn.prefix.direct_category.increment + Πρόθεμα IPN της άμεσης κατηγορίας και μιας ειδικής για μέρος αύξησης + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment + Προθέματα IPN με ιεραρχική σειρά κατηγοριών των προθέτων γονέων + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.increment + Προθέματα IPN με ιεραρχική σειρά κατηγοριών των προθέτων γονέων και συγκεκριμένη αύξηση για το μέρος + + + + + part.edit.tab.advanced.ipn.prefix.not_saved + Δημιουργήστε πρώτα ένα εξάρτημα και αντιστοιχίστε το σε μια κατηγορία: με τις υπάρχουσες κατηγορίες και τα δικά τους προθέματα IPN, η ονομασία IPN για το εξάρτημα μπορεί να προταθεί αυτόματα + + diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 5f9f8deb..a5d86338 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -242,7 +242,7 @@ part.info.timetravel_hint - This is how the part appeared before %timestamp%. <i>Please note that this feature is experimental, so the info may not be correct.</i> + Please note that this feature is experimental, so the info may not be correct.]]> @@ -737,10 +737,10 @@ user.edit.tfa.disable_tfa_message - This will disable <b>all active two-factor authentication methods of the user</b> and delete the <b>backup codes</b>! -<br> -The user will have to set up all two-factor authentication methods again and print new backup codes! <br><br> -<b>Only do this if you are absolutely sure about the identity of the user (seeking help), otherwise the account could be compromised by an attacker!</b> + all active two-factor authentication methods of the user and delete the backup codes! +
    +The user will have to set up all two-factor authentication methods again and print new backup codes!

    +Only do this if you are absolutely sure about the identity of the user (seeking help), otherwise the account could be compromised by an attacker!]]>
    @@ -891,9 +891,9 @@ The user will have to set up all two-factor authentication methods again and pri entity.delete.message - This can not be undone! -<br> -Sub elements will be moved upwards. + +Sub elements will be moved upwards.]]> @@ -1447,7 +1447,7 @@ Sub elements will be moved upwards. homepage.github.text - Source, downloads, bug reports, to-do-list etc. can be found on <a href="%href%" class="link-external" target="_blank">GitHub project page</a> + GitHub project page]]> @@ -1469,7 +1469,7 @@ Sub elements will be moved upwards. homepage.help.text - Help and tips can be found in Wiki the <a href="%href%" class="link-external" target="_blank">GitHub page</a> + GitHub page]]> @@ -1711,7 +1711,7 @@ Sub elements will be moved upwards. email.pw_reset.fallback - If this does not work for you, go to <a href="%url%">%url%</a> and enter the following info + %url% and enter the following info]]> @@ -1741,7 +1741,7 @@ Sub elements will be moved upwards. email.pw_reset.valid_unit %date% - The reset token will be valid until <i>%date%</i>. + %date%.]]> @@ -1848,6 +1848,66 @@ Sub elements will be moved upwards. Advanced + + + part.edit.tab.advanced.ipn.commonSectionHeader + Suggestions without part increment + + + + + part.edit.tab.advanced.ipn.partIncrementHeader + Suggestions with numeric part increment + + + + + part.edit.tab.advanced.ipn.prefix.description.current-increment + Current IPN specification of the part + + + + + part.edit.tab.advanced.ipn.prefix.description.increment + Next possible IPN specification based on an identical part description + + + + + part.edit.tab.advanced.ipn.prefix_empty.direct_category + IPN prefix of direct category empty, specify one in category "%name%" + + + + + part.edit.tab.advanced.ipn.prefix.direct_category + IPN prefix of direct category + + + + + part.edit.tab.advanced.ipn.prefix.direct_category.increment + IPN prefix of direct category and part-specific increment + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment + IPN prefixes with hierarchical category order of parent-prefix(es) + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.increment + IPN prefixes with hierarchical category order of parent-prefix(es) and part-specific increment + + + + + part.edit.tab.advanced.ipn.prefix.not_saved + Please create part at first and assign it to a category: with existing categories and their own IPN prefix, the IPN for the part can be suggested automatically + + Part-DB1\templates\Parts\edit\edit_part_info.html.twig:40 @@ -3584,8 +3644,8 @@ Sub elements will be moved upwards. tfa_google.disable.confirm_message - If you disable the Authenticator App, all backup codes will be deleted, so you may need to reprint them.<br> -Also note that without two-factor authentication, your account is no longer as well protected against attackers! + +Also note that without two-factor authentication, your account is no longer as well protected against attackers!]]> @@ -3605,7 +3665,7 @@ Also note that without two-factor authentication, your account is no longer as w tfa_google.step.download - Download an authenticator app (e.g. <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Google Authenticator</a> oder <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp">FreeOTP Authenticator</a>) + Google Authenticator oder FreeOTP Authenticator)]]> @@ -3847,8 +3907,8 @@ Also note that without two-factor authentication, your account is no longer as w tfa_trustedDevices.explanation - When checking the second factor, the current computer can be marked as trustworthy, so no more two-factor checks on this computer are needed. -If you have done this incorrectly or if a computer is no longer trusted, you can reset the status of <i>all </i>computers here. + all computers here.]]> @@ -5325,7 +5385,7 @@ If you have done this incorrectly or if a computer is no longer trusted, you can label_options.lines_mode.help - If you select Twig here, the content field is interpreted as Twig template. See <a href="https://twig.symfony.com/doc/3.x/templates.html">Twig documentation</a> and <a href="https://docs.part-db.de/usage/labels.html#twig-mode">Wiki</a> for more information. + Twig documentation and Wiki for more information.]]> @@ -6989,6 +7049,12 @@ If you have done this incorrectly or if a computer is no longer trusted, you can Name filter + + + category.edit.part_ipn_prefix + Part IPN Prefix + + obsolete @@ -7187,15 +7253,15 @@ Exampletown mass_creation.lines.placeholder - Element 1 + +Element 1 -> Element 1.1 +Element 1 -> Element 1.2]]> @@ -9480,25 +9546,25 @@ Element 1 -> Element 1.2 filter.parameter_value_constraint.operator.< - Typ. Value < + filter.parameter_value_constraint.operator.> - Typ. Value > + ]]> filter.parameter_value_constraint.operator.<= - Typ. Value <= + filter.parameter_value_constraint.operator.>= - Typ. Value >= + =]]> @@ -9606,7 +9672,7 @@ Element 1 -> Element 1.2 parts_list.search.searching_for - Searching parts with keyword <b>%keyword%</b> + %keyword%]]> @@ -10266,13 +10332,13 @@ Element 1 -> Element 1.2 project.builds.number_of_builds_possible - You have enough stocked to build <b>%max_builds%</b> builds of this project. + %max_builds% builds of this project.]]> project.builds.check_project_status - The current project status is <b>"%project_status%"</b>. You should check if you really want to build the project with this status! + "%project_status%". You should check if you really want to build the project with this status!]]> @@ -10365,16 +10431,28 @@ Element 1 -> Element 1.2 e.g "/Capacitor \d+ nF/i" + + + category.edit.part_ipn_prefix.placeholder + e.g "B12A" + + category.edit.partname_regex.help A PCRE-compatible regular expression, which a part name have to match. + + + category.edit.part_ipn_prefix.help + A prefix suggested when entering the IPN of a part. + + entity.select.add_hint - Use -> to create nested structures, e.g. "Node 1->Node 1.1" + to create nested structures, e.g. "Node 1->Node 1.1"]]> @@ -10398,13 +10476,13 @@ Element 1 -> Element 1.2 homepage.first_steps.introduction - Your database is still empty. You might want to read the <a href="%url%">documentation</a> or start to creating the following data structures: + documentation or start to creating the following data structures:]]> homepage.first_steps.create_part - Or you can directly <a href="%url%">create a new part</a>. + create a new part.]]> @@ -10416,7 +10494,7 @@ Element 1 -> Element 1.2 homepage.forum.text - For questions about Part-DB use the <a href="%href%" class="link-external" target="_blank">discussion forum</a> + discussion forum]]> @@ -11082,7 +11160,7 @@ Element 1 -> Element 1.2 parts.import.help_documentation - See the <a href="%link%">documentation</a> for more information on the file format. + documentation for more information on the file format.]]> @@ -11274,7 +11352,7 @@ Element 1 -> Element 1.2 part.filter.lessThanDesired - In stock less than desired (total amount < min. amount) + @@ -12086,13 +12164,13 @@ Please note, that you can not impersonate a disabled user. If you try you will g part.merge.confirm.title - Do you really want to merge <b>%other%</b> into <b>%target%</b>? + %other% into %target%?]]> part.merge.confirm.message - <b>%other%</b> will be deleted, and the part will be saved with the shown information. + %other% will be deleted, and the part will be saved with the shown information.]]> @@ -12446,7 +12524,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.ips.element14.apiKey.help - You can register for an API key on <a href="https://partner.element14.com/">https://partner.element14.com/</a>. + https://partner.element14.com/.]]> @@ -12458,7 +12536,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.ips.element14.storeId.help - The store domain to retrieve the data from. This decides the language and currency of results. See <a href="https://partner.element14.com/docs/Product_Search_API_REST__Description">here</a> for a list of valid domains. + here for a list of valid domains.]]> @@ -12476,7 +12554,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.ips.tme.token.help - You can get an API token and secret on <a href="https://developers.tme.eu/en/">https://developers.tme.eu/en/</a>. + https://developers.tme.eu/en/.]]> @@ -12524,7 +12602,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.ips.mouser.apiKey.help - You can register for an API key on <a href="https://eu.mouser.com/api-hub/">https://eu.mouser.com/api-hub/</a>. + https://eu.mouser.com/api-hub/.]]> @@ -12602,7 +12680,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.system.attachments - Attachments & Files + @@ -12626,7 +12704,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.system.attachments.allowDownloads.help - With this option users can download external files into Part-DB by providing an URL. <b>Attention: This can be a security issue, as it might allow users to access intranet ressources via Part-DB!</b> + Attention: This can be a security issue, as it might allow users to access intranet ressources via Part-DB!]]> @@ -12800,8 +12878,8 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.system.localization.base_currency_description - The currency that is used to store price information and exchange rates in. This currency is assumed, when no currency is set for a price information. -<b>Please note that the currencies are not converted, when changing this value. So changing the default currency after you already added price information, will result in wrong prices!</b> + Please note that the currencies are not converted, when changing this value. So changing the default currency after you already added price information, will result in wrong prices!]]> @@ -12831,7 +12909,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.misc.kicad_eda.category_depth.help - This value determines the depth of the category tree, that is visible inside KiCad. 0 means that only the top level categories are visible. Set to a value > 0 to show more levels. Set to -1, to show all parts of Part-DB inside a sigle cnategory in KiCad. + 0 to show more levels. Set to -1, to show all parts of Part-DB inside a sigle cnategory in KiCad.]]> @@ -12849,7 +12927,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.behavior.sidebar.items.help - The menus which appear at the sidebar by default. Order of items can be changed via drag & drop. + @@ -12897,7 +12975,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.behavior.table.parts_default_columns.help - The columns to show by default in part tables. Order of items can be changed via drag & drop. + @@ -12951,7 +13029,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.ips.oemsecrets.sortMode.M - Completeness & Manufacturer name + @@ -13110,6 +13188,54 @@ Please note, that you can not impersonate a disabled user. If you try you will g If you need exchange rates between non-euro currencies, you can input an API key from fixer.io here. + + + settings.misc.ipn_suggest + Part IPN Suggest + + + + + settings.misc.ipn_suggest.regex + Regex + + + + + settings.misc.ipn_suggest.regex_help + Help text + + + + + settings.misc.ipn_suggest.regex_help_description + Define your own user help text for the Regex format specification. + + + + + settings.misc.ipn_suggest.autoAppendSuffix + Add incremental suffix to IPN, if the value is already used by another part + + + + + settings.misc.ipn_suggest.suggestPartDigits + Increment Digits + + + + + settings.misc.ipn_suggest.useDuplicateDescription + Use part description to find next available IPN + + + + + settings.misc.ipn_suggest.suggestPartDigits.help + The number of digits used for the incremental numbering of parts in the IPN (Internal Part Number) suggestion system. + + settings.behavior.part_info @@ -13563,7 +13689,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.behavior.homepage.items.help - The items to show at the homepage. Order can be changed via drag & drop. + @@ -14330,5 +14456,17 @@ You can do this in the provider info list. You can do this in the provider info list. + + + settings.misc.ipn_suggest.useDuplicateDescription.help + When enabled, the part’s description is used to find existing parts with the same description and to determine the next available IPN by incrementing their numeric suffix for the suggestion list. + + + + + settings.misc.ipn_suggest.regex.help + A PCRE-compatible regular expression every IPN has to fulfill. Leave empty to allow all everything as IPN. + + diff --git a/translations/messages.es.xlf b/translations/messages.es.xlf index a79119aa..57ac5c85 100644 --- a/translations/messages.es.xlf +++ b/translations/messages.es.xlf @@ -1848,6 +1848,66 @@ Subelementos serán desplazados hacia arriba. Avanzado + + + part.edit.tab.advanced.ipn.commonSectionHeader + Sugerencias sin incremento de parte + + + + + part.edit.tab.advanced.ipn.partIncrementHeader + Sugerencias con incrementos numéricos de partes + + + + + part.edit.tab.advanced.ipn.prefix.description.current-increment + Especificación actual de IPN de la pieza + + + + + part.edit.tab.advanced.ipn.prefix.description.increment + Siguiente especificación de IPN posible basada en una descripción idéntica de la pieza + + + + + part.edit.tab.advanced.ipn.prefix_empty.direct_category + El prefijo IPN de la categoría directa está vacío, especifíquelo en la categoría "%name%" + + + + + part.edit.tab.advanced.ipn.prefix.direct_category + Prefijo IPN de la categoría directa + + + + + part.edit.tab.advanced.ipn.prefix.direct_category.increment + Prefijo IPN de la categoría directa y un incremento específico de la pieza + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment + Prefijos IPN con orden jerárquico de categorías de prefijos principales + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.increment + Prefijos IPN con orden jerárquico de categorías de prefijos principales y un incremento específico para la parte + + + + + part.edit.tab.advanced.ipn.prefix.not_saved + Primero cree un componente y asígnele una categoría: con las categorías existentes y sus propios prefijos IPN, el identificador IPN para el componente puede ser sugerido automáticamente + + Part-DB1\templates\Parts\edit\edit_part_info.html.twig:40 @@ -6988,6 +7048,12 @@ Subelementos serán desplazados hacia arriba. Filtro de nombre + + + category.edit.part_ipn_prefix + Prefijo de IPN de la pieza + + obsolete @@ -10308,12 +10374,24 @@ Elemento 3 p.ej. "/Condensador \d+ nF/i" + + + category.edit.part_ipn_prefix.placeholder + p.ej. "B12A" + + category.edit.partname_regex.help Una expresión regular compatible con PCRE, la cual debe coincidir con el nombre de un componente. + + + category.edit.part_ipn_prefix.help + Un prefijo sugerido al ingresar el IPN de una parte. + + entity.select.add_hint diff --git a/translations/messages.fr.xlf b/translations/messages.fr.xlf index 39ff97d3..cb3936ef 100644 --- a/translations/messages.fr.xlf +++ b/translations/messages.fr.xlf @@ -1826,6 +1826,66 @@ Show/Hide sidebar Avancé + + + part.edit.tab.advanced.ipn.commonSectionHeader + Suggestions sans incrément de partie + + + + + part.edit.tab.advanced.ipn.partIncrementHeader + Propositions avec incréments numériques de parties + + + + + part.edit.tab.advanced.ipn.prefix.description.current-increment + Spécification IPN actuelle pour la pièce + + + + + part.edit.tab.advanced.ipn.prefix.description.increment + Prochaine spécification IPN possible basée sur une description identique de la pièce + + + + + part.edit.tab.advanced.ipn.prefix_empty.direct_category + Le préfixe IPN de la catégorie directe est vide, veuillez le spécifier dans la catégorie "%name%" + + + + + part.edit.tab.advanced.ipn.prefix.direct_category + Préfixe IPN de la catégorie directe + + + + + part.edit.tab.advanced.ipn.prefix.direct_category.increment + Préfixe IPN de la catégorie directe et d'un incrément spécifique à la partie + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment + Préfixes IPN avec un ordre hiérarchique des catégories des préfixes parents + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.increment + Préfixes IPN avec un ordre hiérarchique des catégories des préfixes parents et un incrément spécifique à la pièce + + + + + part.edit.tab.advanced.ipn.prefix.not_saved + Créez d'abord une pièce et assignez-la à une catégorie : avec les catégories existantes et leurs propres préfixes IPN, l'identifiant IPN pour la pièce peut être proposé automatiquement + + Part-DB1\templates\Parts\edit\edit_part_info.html.twig:40 @@ -6930,6 +6990,12 @@ Si vous avez fait cela de manière incorrecte ou si un ordinateur n'est plus fia Filtre de nom + + + category.edit.part_ipn_prefix + Préfixe de pièce IPN + + obsolete @@ -9151,5 +9217,17 @@ exemple de ville État personnalisé de la pièce + + + category.edit.part_ipn_prefix.placeholder + par ex. "B12A" + + + + + category.edit.part_ipn_prefix.help + Un préfixe suggéré lors de la saisie de l'IPN d'une pièce. + + diff --git a/translations/messages.it.xlf b/translations/messages.it.xlf index 781ca49f..34540da1 100644 --- a/translations/messages.it.xlf +++ b/translations/messages.it.xlf @@ -1848,6 +1848,66 @@ I sub elementi saranno spostati verso l'alto. Avanzate + + + part.edit.tab.advanced.ipn.commonSectionHeader + Suggerimenti senza incremento di parte + + + + + part.edit.tab.advanced.ipn.partIncrementHeader + Suggerimenti con incrementi numerici delle parti + + + + + part.edit.tab.advanced.ipn.prefix.description.current-increment + Specifica IPN attuale per il pezzo + + + + + part.edit.tab.advanced.ipn.prefix.description.increment + Prossima specifica IPN possibile basata su una descrizione identica del pezzo + + + + + part.edit.tab.advanced.ipn.prefix_empty.direct_category + Il prefisso IPN della categoria diretta è vuoto, specificarlo nella categoria "%name%" + + + + + part.edit.tab.advanced.ipn.prefix.direct_category + Prefisso IPN della categoria diretta + + + + + part.edit.tab.advanced.ipn.prefix.direct_category.increment + Prefisso IPN della categoria diretta e di un incremento specifico della parte + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment + Prefissi IPN con ordine gerarchico delle categorie dei prefissi padre + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.increment + Prefissi IPN con ordine gerarchico delle categorie dei prefissi padre e un incremento specifico per il pezzo + + + + + part.edit.tab.advanced.ipn.prefix.not_saved + Crea prima un componente e assegnagli una categoria: con le categorie esistenti e i loro propri prefissi IPN, l'identificativo IPN per il componente può essere suggerito automaticamente + + Part-DB1\templates\Parts\edit\edit_part_info.html.twig:40 @@ -6990,6 +7050,12 @@ Se è stato fatto in modo errato o se un computer non è più attendibile, puoi Filtro nome + + + category.edit.part_ipn_prefix + Prefisso parte IPN + + obsolete @@ -10310,12 +10376,24 @@ Element 3 es. "/Condensatore \d+ nF/i" + + + category.edit.part_ipn_prefix.placeholder + es. "B12A" + + category.edit.partname_regex.help Un'espressione regolare compatibile con PCRE che il nome del componente deve soddisfare. + + + category.edit.part_ipn_prefix.help + Un prefisso suggerito durante l'inserimento dell'IPN di una parte. + + entity.select.add_hint diff --git a/translations/messages.ja.xlf b/translations/messages.ja.xlf index a0a146dc..668c51c1 100644 --- a/translations/messages.ja.xlf +++ b/translations/messages.ja.xlf @@ -1826,6 +1826,66 @@ 詳細 + + + part.edit.tab.advanced.ipn.commonSectionHeader + 部品の増加なしの提案。 + + + + + part.edit.tab.advanced.ipn.partIncrementHeader + パーツの数値インクリメントを含む提案 + + + + + part.edit.tab.advanced.ipn.prefix.description.current-increment + 部品の現在のIPN仕様 + + + + + part.edit.tab.advanced.ipn.prefix.description.increment + 同じ部品説明に基づく次の可能なIPN仕様 + + + + + part.edit.tab.advanced.ipn.prefix_empty.direct_category + 直接カテゴリの IPN プレフィックスが空です。「%name%」カテゴリで指定してください + + + + + part.edit.tab.advanced.ipn.prefix.direct_category + 直接カテゴリのIPNプレフィックス + + + + + part.edit.tab.advanced.ipn.prefix.direct_category.increment + 直接カテゴリのIPNプレフィックスと部品特有のインクリメント + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment + 親プレフィックスの階層カテゴリ順のIPNプレフィックス + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.increment + 親プレフィックスの階層カテゴリ順とパーツ固有の増分のIPNプレフィックス + + + + + part.edit.tab.advanced.ipn.prefix.not_saved + まずはコンポーネントを作成し、それをカテゴリに割り当ててください:既存のカテゴリとそれぞれのIPNプレフィックスを基に、コンポーネントのIPNを自動的に提案できます + + Part-DB1\templates\Parts\edit\edit_part_info.html.twig:40 @@ -6931,6 +6991,12 @@ 名前のフィルター + + + category.edit.part_ipn_prefix + 部品 IPN 接頭辞 + + obsolete @@ -8888,5 +8954,17 @@ Exampletown 部品のカスタム状態 + + + category.edit.part_ipn_prefix.placeholder + 例: "B12A" + + + + + category.edit.part_ipn_prefix.help + 部品のIPN入力時に提案される接頭辞。 + + diff --git a/translations/messages.nl.xlf b/translations/messages.nl.xlf index 4afc28aa..1c063187 100644 --- a/translations/messages.nl.xlf +++ b/translations/messages.nl.xlf @@ -778,5 +778,83 @@ Aangepaste status van onderdeel + + + category.edit.part_ipn_prefix + IPN-voorvoegsel van onderdeel + + + + + category.edit.part_ipn_prefix.placeholder + bijv. "B12A" + + + + + category.edit.part_ipn_prefix.help + Een voorgesteld voorvoegsel bij het invoeren van de IPN van een onderdeel. + + + + + part.edit.tab.advanced.ipn.commonSectionHeader + Suggesties zonder toename van onderdelen + + + + + part.edit.tab.advanced.ipn.partIncrementHeader + Suggesties met numerieke verhogingen van onderdelen + + + + + part.edit.tab.advanced.ipn.prefix.description.current-increment + Huidige IPN-specificatie voor het onderdeel + + + + + part.edit.tab.advanced.ipn.prefix.description.increment + Volgende mogelijke IPN-specificatie op basis van een identieke onderdeelbeschrijving + + + + + part.edit.tab.advanced.ipn.prefix_empty.direct_category + Het IPN-prefix van de directe categorie is leeg, geef het op in de categorie "%name%" + + + + + part.edit.tab.advanced.ipn.prefix.direct_category + IPN-prefix van de directe categorie + + + + + part.edit.tab.advanced.ipn.prefix.direct_category.increment + IPN-voorvoegsel van de directe categorie en een onderdeel specifiek increment + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment + IPN-prefixen met een hiërarchische volgorde van hoofdcategorieën + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.increment + IPN-prefixen met een hiërarchische volgorde van hoofdcategorieën en een specifieke toename voor het onderdeel + + + + + part.edit.tab.advanced.ipn.prefix.not_saved + Maak eerst een component en wijs het toe aan een categorie: met de bestaande categorieën en hun eigen IPN-prefixen kan de IPN voor het component automatisch worden voorgesteld + + diff --git a/translations/messages.pl.xlf b/translations/messages.pl.xlf index cbecc3bf..0a9353fb 100644 --- a/translations/messages.pl.xlf +++ b/translations/messages.pl.xlf @@ -1853,6 +1853,66 @@ Po usunięciu pod elementy zostaną przeniesione na górę. Zaawansowane + + + part.edit.tab.advanced.ipn.commonSectionHeader + Sugestie bez zwiększenia części + + + + + part.edit.tab.advanced.ipn.partIncrementHeader + Propozycje z numerycznymi przyrostami części + + + + + part.edit.tab.advanced.ipn.prefix.description.current-increment + Aktualna specyfikacja IPN dla części + + + + + part.edit.tab.advanced.ipn.prefix.description.increment + Następna możliwa specyfikacja IPN na podstawie identycznego opisu części + + + + + part.edit.tab.advanced.ipn.prefix_empty.direct_category + Prefiks IPN kategorii bezpośredniej jest pusty, podaj go w kategorii "%name%". + + + + + part.edit.tab.advanced.ipn.prefix.direct_category + Prefiks IPN kategorii bezpośredniej + + + + + part.edit.tab.advanced.ipn.prefix.direct_category.increment + Prefiks IPN bezpośredniej kategorii i specyficzny dla części przyrost + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment + Prefiksy IPN z hierarchiczną kolejnością kategorii prefiksów nadrzędnych + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.increment + Prefiksy IPN z hierarchiczną kolejnością kategorii prefiksów nadrzędnych i specyficznym przyrostem dla części + + + + + part.edit.tab.advanced.ipn.prefix.not_saved + Najpierw utwórz komponent i przypisz go do kategorii: dzięki istniejącym kategoriom i ich własnym prefiksom IPN identyfikator IPN dla komponentu może być proponowany automatycznie + + Part-DB1\templates\Parts\edit\edit_part_info.html.twig:40 @@ -6993,6 +7053,12 @@ Jeśli zrobiłeś to niepoprawnie lub komputer nie jest już godny zaufania, mo Filtr nazwy + + + category.edit.part_ipn_prefix + Prefiks IPN części + + obsolete @@ -10313,12 +10379,24 @@ Element 3 np. "/Kondensator \d+ nF/i" + + + category.edit.part_ipn_prefix.placeholder + np. "B12A" + + category.edit.partname_regex.help Wyrażenie regularne zgodne z PCRE, do którego musi pasować nazwa komponentu. + + + category.edit.part_ipn_prefix.help + Een voorgesteld voorvoegsel bij het invoeren van de IPN van een onderdeel. + + entity.select.add_hint diff --git a/translations/messages.ru.xlf b/translations/messages.ru.xlf index 517aca8e..9c91a4b1 100644 --- a/translations/messages.ru.xlf +++ b/translations/messages.ru.xlf @@ -1856,6 +1856,66 @@ Расширенные + + + part.edit.tab.advanced.ipn.commonSectionHeader + Предложения без увеличения частей. + + + + + part.edit.tab.advanced.ipn.partIncrementHeader + Предложения с числовыми приращениями частей + + + + + part.edit.tab.advanced.ipn.prefix.description.current-increment + Текущая спецификация IPN для детали + + + + + part.edit.tab.advanced.ipn.prefix.description.increment + Следующая возможная спецификация IPN на основе идентичного описания детали + + + + + part.edit.tab.advanced.ipn.prefix_empty.direct_category + Префикс IPN для прямой категории пуст, укажите его в категории «%name%». + + + + + part.edit.tab.advanced.ipn.prefix.direct_category + Префикс IPN для прямой категории + + + + + part.edit.tab.advanced.ipn.prefix.direct_category.increment + Префикс IPN прямой категории и специфическое для части приращение + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment + IPN-префиксы с иерархическим порядком категорий родительских префиксов + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.increment + IPN-префиксы с иерархическим порядком категорий родительских префиксов и специфическим увеличением для компонента + + + + + part.edit.tab.advanced.ipn.prefix.not_saved + Сначала создайте компонент и назначьте ему категорию: на основе существующих категорий и их собственных IPN-префиксов идентификатор IPN для компонента может быть предложен автоматически + + Part-DB1\templates\Parts\edit\edit_part_info.html.twig:40 @@ -7000,6 +7060,12 @@ Фильтр по имени + + + category.edit.part_ipn_prefix + Префикс IPN детали + + obsolete @@ -10317,12 +10383,24 @@ e.g "/Конденсатор \d+ nF/i" + + + category.edit.part_ipn_prefix.placeholder + e.g "B12A" + + category.edit.partname_regex.help PCRE-совместимое регулярное выражение которому должно соответствовать имя компонента. + + + category.edit.part_ipn_prefix.help + Предлагаемый префикс при вводе IPN детали. + + entity.select.add_hint diff --git a/translations/messages.zh.xlf b/translations/messages.zh.xlf index 4a5f5896..ee912800 100644 --- a/translations/messages.zh.xlf +++ b/translations/messages.zh.xlf @@ -1856,6 +1856,66 @@ 高级 + + + part.edit.tab.advanced.ipn.commonSectionHeader + Sugestie bez zwiększenia części + + + + + part.edit.tab.advanced.ipn.partIncrementHeader + 包含部件数值增量的建议 + + + + + part.edit.tab.advanced.ipn.prefix.description.current-increment + 部件的当前IPN规格 + + + + + part.edit.tab.advanced.ipn.prefix.description.increment + 基于相同部件描述的下一个可能的IPN规格 + + + + + part.edit.tab.advanced.ipn.prefix_empty.direct_category + 直接类别的 IPN 前缀为空,请在类别“%name%”中指定。 + + + + + part.edit.tab.advanced.ipn.prefix.direct_category + 直接类别的IPN前缀 + + + + + part.edit.tab.advanced.ipn.prefix.direct_category.increment + 直接类别的IPN前缀和部件特定的增量 + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment + 具有父级前缀层级类别顺序的IPN前缀 + + + + + part.edit.tab.advanced.ipn.prefix.hierarchical.increment + 具有父级前缀层级类别顺序和组件特定增量的IPN前缀 + + + + + part.edit.tab.advanced.ipn.prefix.not_saved + 请先创建组件并将其分配到类别:基于现有类别及其专属的IPN前缀,可以自动建议组件的IPN + + Part-DB1\templates\Parts\edit\edit_part_info.html.twig:40 @@ -6997,6 +7057,12 @@ 名称过滤器 + + + category.edit.part_ipn_prefix + 部件 IPN 前缀 + + obsolete @@ -10316,12 +10382,24 @@ Element 3 + + + category.edit.part_ipn_prefix.placeholder + 例如:"B12A" + + category.edit.partname_regex.help 与PCRE兼容的正则表达式,部分名称必须匹配。 + + + category.edit.part_ipn_prefix.help + 输入零件IPN时建议的前缀。 + + entity.select.add_hint