Compare commits

...

8 commits

Author SHA1 Message Date
Jan Böhmer
e8af0e9b4f Bumped version to 2.12.0
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
Docker Image Build / merge (push) Has been cancelled
Docker Image Build (FrankenPHP) / merge (push) Has been cancelled
2026-05-25 22:45:19 +02:00
Jan Böhmer
82cd5875fc
New Crowdin updates (#1364)
* New translations validators.en.xlf (Dutch)

[ci skip]

* New translations frontend.en.xlf (Dutch)

[ci skip]

* New translations messages.en.xlf (English)

[ci skip]

* New translations messages.en.xlf (German)

[ci skip]

* New translations messages.en.xlf (English)

[ci skip]

* Revert wrong german translations

* New translations messages.en.xlf (German)

[ci skip]

* Revert wrong german translations
2026-05-25 22:40:38 +02:00
Jan Böhmer
2e223f4ee4 Correctly parse @id referenced entities in API
Fixes issue #1370
2026-05-25 22:37:48 +02:00
Jan Böhmer
0da5befd7b Allow to directly use IRIs for attachmennts without the need to rely on _type 2026-05-25 22:22:55 +02:00
Jan Böhmer
87874230ef
Update KiCad symbols and footprints lists (#1367)
* Update KiCad symbols and footprints lists

* Update KiCad symbols and footprints lists

* Update KiCad symbols and footprints lists

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-25 21:35:59 +02:00
Jan Böhmer
cf5a9e5667 Updated marked and katex dependenncies 2026-05-25 21:35:16 +02:00
Jan Böhmer
f15e0ced2a Updated dependencies 2026-05-25 21:32:28 +02:00
Jan Böhmer
79c36494ea Added a "unsaved changed" warning dialog for part, entity edits and system settings
This fixes issue #1368
2026-05-25 21:29:10 +02:00
32 changed files with 1703 additions and 953 deletions

View file

@ -1 +1 @@
2.11.1
2.12.0

View file

@ -0,0 +1,274 @@
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Controller} from "@hotwired/stimulus";
import {visit} from "@hotwired/turbo";
import * as bootbox from "bootbox";
import "../../css/components/bootbox_extensions.css";
import "../../css/components/dirty_form.css";
/**
* Attach to a <form> element (or a wrapper containing a <form>) to prevent accidental navigation
* away when the form has unsaved changes.
*
* Dirty detection is event-driven: `change` and `input` events bubble up to the form and trigger
* a check of whether any element's current value differs from the DOM default recorded in the HTML
* (`defaultValue` / `defaultChecked` / `option.defaultSelected`). Using both events covers both
* native widgets (which fire `change`) and rich-text editors like CKEditor (which fire `input`
* when they sync their underlying textarea).
*
* Validation failures (server returns 200 with `.is-invalid` fields) are always treated as dirty:
* the submitted data was never saved, so navigating away would lose it. This removes the need for
* any snapshot mechanism the `.is-invalid` classes in the re-rendered HTML are the signal.
*
* Intercepts three navigation paths:
* 1. Any <a href> link click (capture phase)
* 2. window beforeunload
* 3. turbo:before-visit
*
* Values:
* - confirmTitle (String) dialog title
* - confirmMessage (String) dialog body text
*/
export default class extends Controller {
static values = {
confirmTitle: {type: String, default: 'Unsaved Changes'},
confirmMessage: {type: String, default: 'You have unsaved changes. Are you sure you want to leave this page?'},
};
connect() {
this._form = (this.element.tagName === 'FORM') ? this.element : this.element.querySelector('form');
this._isDirty = false;
this._submitting = false;
this._navigating = false;
this._changeHandler = this._handleChange.bind(this);
this._linkClickHandler = this._handleLinkClick.bind(this);
this._beforeUnloadHandler = this._handleBeforeUnload.bind(this);
this._turboBeforeVisitHandler = this._handleTurboBeforeVisit.bind(this);
this._turboSubmitEndHandler = this._handleTurboSubmitEnd.bind(this);
if (this._form) {
this._form.addEventListener('change', this._changeHandler);
// CKEditor (and other rich-text widgets) dispatch `input` rather than `change`
// when their underlying textarea value is updated.
this._form.addEventListener('input', this._changeHandler);
}
document.addEventListener('click', this._linkClickHandler, true);
window.addEventListener('beforeunload', this._beforeUnloadHandler);
document.addEventListener('turbo:before-visit', this._turboBeforeVisitHandler);
document.addEventListener('turbo:submit-end', this._turboSubmitEndHandler);
const modal = this.element.closest('.modal');
if (modal) {
this._modal = modal;
this._modalHideHandler = this._handleModalHide.bind(this);
modal.addEventListener('hide.bs.modal', this._modalHideHandler);
}
}
disconnect() {
if (this._form) {
this._form.removeEventListener('change', this._changeHandler);
this._form.removeEventListener('input', this._changeHandler);
}
document.removeEventListener('click', this._linkClickHandler, true);
window.removeEventListener('beforeunload', this._beforeUnloadHandler);
document.removeEventListener('turbo:before-visit', this._turboBeforeVisitHandler);
document.removeEventListener('turbo:submit-end', this._turboSubmitEndHandler);
if (this._modal && this._modalHideHandler) {
this._modal.removeEventListener('hide.bs.modal', this._modalHideHandler);
}
}
/** data-action="submit->common--dirty-form#submit" — suppresses the guard while saving. */
submit() {
this._submitting = true;
}
/**
* data-action="reset->common--dirty-form#resetDirtyState" marks the form as clean after
* a programmatic reset. Native change events are not fired by form.reset(), so we set the
* flag directly. Turbo also calls form.reset() internally before the post-submit redirect;
* the _submitting guard prevents that from incorrectly clearing the flag.
*/
resetDirtyState() {
if (this._submitting) return;
// Wait for a frame to allow the form's DOM state to update after the reset() call, then refresh markers and update the dirty flag.
requestAnimationFrame(() => {
this._isDirty = false;
this._clearDirtyMarkers();
});
}
_handleChange(event) {
const target = event?.target;
if (target?.name) {
this._updateDirtyMarker(target);
} else {
this._refreshDirtyMarkers();
}
this._isDirty = this._form?.querySelector('[data-dirty]') !== null;
}
/**
* Walk every named form element and update its `data-dirty` attribute.
* Un-named elements (e.g. the visible TristateCheckbox whose name was removed) are
* skipped they are not submitted and are not the source of truth for form data.
*/
_refreshDirtyMarkers() {
if (!this._form) return;
for (const el of this._form.elements) {
if (!el.name) continue;
this._updateDirtyMarker(el);
}
}
/**
* Set or clear `data-dirty` on a single named form element.
* Hidden inputs are not visually rendered, so special handling applies:
* - TristateCheckbox: the hidden backing input is preceded by a nameless visual checkbox
* mark that instead.
* - Other hidden inputs (e.g. CSRF tokens): ignored.
* TomSelect hides the <select> before .ts-wrapper (sibling); CSS targets .ts-control via the
* adjacent-sibling combinator on the select's data-dirty attribute.
*/
_updateDirtyMarker(el) {
if (el.type === 'hidden') {
const visual = el.previousElementSibling;
if (visual instanceof HTMLInputElement && !visual.name) {
visual.toggleAttribute('data-dirty', el.value !== el.defaultValue);
}
return;
}
const dirty = this._isElementDirty(el);
el.toggleAttribute('data-dirty', dirty);
}
_clearDirtyMarkers() {
this._form?.querySelectorAll('[data-dirty]').forEach(el => el.removeAttribute('data-dirty'));
}
_isElementDirty(el) {
//Disabled elements are not editable, so ignore them even if their value differs from the default.
if (el.disabled) return false;
if (el.type === 'file') return false;
if (el.type === 'checkbox' || el.type === 'radio') {
return el.checked !== el.defaultChecked;
}
if (el.tagName === 'SELECT') {
// TomSelect sets data-default-value to the value at init time.
// The native option.defaultSelected approach is unreliable when no option
// carries the `selected` attribute — the browser auto-selects option[0]
// (selected=true) while defaultSelected stays false, causing a false positive.
if (el.dataset.defaultValue !== undefined) {
return el.value !== el.dataset.defaultValue;
}
for (const option of el.options) {
if (option.selected !== option.defaultSelected) return true;
}
return false;
}
let defaultValue = el.defaultValue;
//If an element has an data-default-value, use that for dirty checking instead of the DOM default Value. Set for example by the ckeditor-controller
if (el.dataset.defaultValue !== undefined) {
defaultValue = el.dataset.defaultValue;
}
return el.value !== defaultValue;
}
_isFormDirty() {
if (this._submitting) return false;
// A form with validation errors was submitted but never saved — always treat as dirty.
if (this._form?.querySelector('.is-invalid')) return true;
return this._isDirty;
}
_confirmNavigation(onConfirm) {
bootbox.confirm({
title: this.confirmTitleValue,
message: this.confirmMessageValue,
callback: (result) => { if (result) onConfirm(); }
});
}
_handleLinkClick(event) {
if (this._navigating) return;
const link = event.target.closest('a[href]');
if (!link) return;
const href = link.getAttribute('href');
if (!href || href.startsWith('#')) return;
if (link.target === '_blank' || link.target === '_top' || link.target === '_parent') return;
if (link.hasAttribute('data-dirty-form-ignore')) return;
if (!this._isFormDirty()) return;
event.preventDefault();
event.stopPropagation();
this._confirmNavigation(() => { this._navigating = true; link.click(); });
}
_handleBeforeUnload(event) {
if (this._navigating || !this._isFormDirty()) return;
event.preventDefault();
event.returnValue = '';
}
_handleTurboBeforeVisit(event) {
if (this._navigating || !this._isFormDirty()) return;
event.preventDefault();
const url = event.detail.url;
const frame = event.detail.frame;
this._confirmNavigation(() => {
this._navigating = true;
if (frame) { window.Turbo.visit(url, { frame }); } else { visit(url); }
});
}
_handleTurboSubmitEnd(event) {
const submittedForm = event.detail?.formSubmission?.formElement;
if (submittedForm !== this._form) return;
// For a successful save (redirect), the controller will disconnect with the Turbo
// navigation; reset is only needed for validation errors where the form stays in the DOM.
const savedSuccessfully = event.detail.success && event.detail.fetchResponse?.redirected;
if (!savedSuccessfully) {
this._submitting = false;
}
}
_handleModalHide(event) {
if (this._navigating || !this._isFormDirty()) return;
event.preventDefault();
this._confirmNavigation(() => {
this._navigating = true;
window.bootstrap?.Modal?.getInstance(this._modal)?.hide();
});
}
}

View file

@ -25,9 +25,11 @@ import TomSelect from "tom-select";
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
export default class extends Controller {
_tomSelect;
@ -82,7 +84,8 @@ export default class extends Controller {
'autoselect_typed': {},
'click_to_edit': {},
'clear_button': {},
"restore_on_backspace": {}
'restore_on_backspace': {},
'form_reset_handler': {}
}
};

View file

@ -25,9 +25,11 @@ import TomSelect from "tom-select";
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
export default class extends Controller {
_tomSelect;
@ -64,7 +66,8 @@ export default class extends Controller {
'autoselect_typed': {},
'click_to_edit': {},
'clear_button': {},
"restore_on_backspace": {}
'restore_on_backspace': {},
'form_reset_handler': {}
}
};

View file

@ -91,6 +91,9 @@ export default class extends Controller {
config.translations = [window.CKEDITOR_TRANSLATIONS, translations];
}
//Apply the default value of the source element as data attribute, so that dirty-form-controller can detect changes
this.element.dataset.defaultValue = this.element.defaultValue;
const watchdog = new EditorWatchdog();
watchdog.setCreator((elementOrData, editorConfig) => {
return EDITOR_TYPE.create(elementOrData, editorConfig)
@ -111,10 +114,21 @@ export default class extends Controller {
editor.updateSourceElement();
// Dispatch the input event for further treatment
const event = new Event("input");
this.element.dispatchEvent(event);
this.element.dispatchEvent(new Event("input", { bubbles: true }));
});
//Set an reset listener to update the editor if the source element is reset (e.g. by a reset button)
if (this.element.form && this.element.name) {
this.element.form.addEventListener("reset", () => {
if (editor.isReadOnly) {
return;
}
if (this.element.dataset.defaultValue !== undefined) {
editor.setData(this.element.dataset.defaultValue);
}
});
}
//This return is important! Otherwise we get mysterious errors in the console
//See: https://github.com/ckeditor/ckeditor5/issues/5897#issuecomment-628471302
return editor;

View file

@ -4,6 +4,9 @@ import "tom-select/dist/css/tom-select.bootstrap5.css";
import '../../css/components/tom-select_extensions.css';
import TomSelect from "tom-select";
import {marked} from "marked";
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
export default class extends Controller {
_tomSelect;
@ -18,7 +21,7 @@ export default class extends Controller {
let settings = {
allowEmptyOption: true,
plugins: ['dropdown_input', this.element.required ? null : 'clear_button'],
plugins: ['dropdown_input', this.element.required ? null : 'clear_button', 'form_reset_handler'],
searchField: ["name", "description", "category", "footprint"],
valueField: "id",
labelField: "name",

View file

@ -21,6 +21,9 @@ import {Controller} from "@hotwired/stimulus";
import "tom-select/dist/css/tom-select.bootstrap5.css";
import '../../css/components/tom-select_extensions.css';
import TomSelect from "tom-select";
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
export default class extends Controller {
@ -44,7 +47,7 @@ export default class extends Controller {
}
let settings = {
plugins: ["clear_button"],
plugins: ["clear_button", "form_reset_handler"],
allowEmptyOption: true,
selectOnTab: true,
maxOptions: null,

View file

@ -25,9 +25,11 @@ import TomSelect from "tom-select";
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
/**
* This is the frontend controller for StaticFileAutocompleteType form element.
@ -64,7 +66,8 @@ export default class extends Controller {
'autoselect_typed': {},
'click_to_edit': {},
'clear_button': {},
'restore_on_backspace': {}
'restore_on_backspace': {},
'form_reset_handler': {}
}
};

View file

@ -23,9 +23,11 @@ import TomSelect from "tom-select";
import {Controller} from "@hotwired/stimulus";
import {trans} from '../../translator.js'
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
export default class extends Controller {
_tomSelect;
@ -96,6 +98,7 @@ export default class extends Controller {
plugins: {
"autoselect_typed": {},
"form_reset_handler": {},
}
};
@ -105,6 +108,7 @@ export default class extends Controller {
}
this._tomSelect = new TomSelect(this.element, settings);
//Do not do a sync here as this breaks the initial rendering of the empty option
//this._tomSelect.sync();
}

View file

@ -25,9 +25,11 @@ import TomSelect from "tom-select";
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
export default class extends Controller {
_tomSelect;
@ -43,6 +45,7 @@ export default class extends Controller {
remove_button:{},
'autoselect_typed': {},
'click_to_edit': {},
'form_reset_handler': {},
},
persistent: false,
selectOnTab: true,

View file

@ -102,7 +102,18 @@ export default class extends Controller {
onNodeSelected: (event) => {
const node = event.detail.node;
if (node.href) {
window.Turbo.visit(node.href, {action: "advance", frame: this._frame});
const url = node.href;
// Turbo.visit with a frame target bypasses turbo:before-visit, so dispatch it
// manually so that dirty-form guards can intercept it.
const beforeVisitEvent = new CustomEvent('turbo:before-visit', {
bubbles: true,
cancelable: true,
detail: { url, frame: this._frame },
});
document.dispatchEvent(beforeVisitEvent);
if (!beforeVisitEvent.defaultPrevented) {
window.Turbo.visit(url, {action: "advance", frame: this._frame});
}
}
},
}, [BS5Theme, BS53Theme, FAIconTheme]);

View file

@ -25,9 +25,11 @@ import "katex/dist/katex.css";
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
/* stimulusFetch: 'lazy' */
export default class extends Controller
@ -63,7 +65,8 @@ export default class extends Controller
'autoselect_typed': {},
'click_to_edit': {},
'clear_button': {},
'restore_on_backspace': {}
'restore_on_backspace': {},
'form_reset_handler': {}
},
persistent: false,
maxItems: 1,

View file

@ -0,0 +1,61 @@
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/* Standard Bootstrap form controls */
.form-control[data-dirty],
.form-select[data-dirty],
input[data-dirty] + .form-control,
select[data-dirty] + .form-select
{
border-color: var(--bs-info);
border-width: 2px;
}
.form-control[data-dirty]:focus,
.form-select[data-dirty]:focus,
input[data-dirty] + .form-control:focus,
select[data-dirty] + .form-select:focus
{
border-color: var(--bs-info);
border-width: 2px;
box-shadow: 0 0 0 0.25rem rgba(var(--bs-info-rgb), 0.25);
}
/* Checkboxes and radios */
.form-check-input[data-dirty] {
border-color: var(--bs-info);
border-width: 2px;
}
.form-check-input[data-dirty]:checked {
background-color: var(--bs-info);
border-color: var(--bs-info);
border-width: 2px;
}
/* CKEditor: the editable sits after the hidden source textarea in the DOM */
textarea[data-dirty] ~ .ck.ck-editor .ck-editor__editable:not(.ck-focused) {
border-color: var(--bs-info) !important;
border-width: 2px !important;
}
textarea[data-dirty] ~ .ck.ck-editor .ck-editor__editable.ck-focused {
border-width: 2px !important;
box-shadow: 0 0 0 0.25rem rgba(var(--bs-info-rgb), 0.25) !important;
}

View file

@ -83,6 +83,8 @@ export default class TristateCheckbox {
//Do a refresh to set the correct styling of the checkbox
this._refresh();
// Anchor the hidden input's default so dirty-form comparisons have a clean baseline.
this._hiddenInput.defaultValue = this._hiddenInput.value;
this._element.addEventListener('click', this.click.bind(this));
}
@ -202,6 +204,9 @@ export default class TristateCheckbox {
}
this._refresh();
// Notify change listeners (e.g. dirty-form controller) since programmatic
// value changes don't fire native change events.
this._hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
}
}

View file

@ -56,6 +56,7 @@ class TristateHelper {
document.addEventListener("turbo:load", listener);
document.addEventListener("turbo:render", listener);
document.addEventListener("turbo:frame-load", listener);
document.addEventListener("collection:elementAdded", listener);
}
}

View file

@ -0,0 +1,46 @@
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* TomSelect plugin for dirty-check integration and form reset support.
*
* Sets data-default-value on the underlying <select> so the dirty-check
* controller can detect changes, and restores that value when the form is reset.
*/
export default function form_reset_handler() {
const self = this;
const input = this.input;
// Multiple selects not yet supported
if (input.multiple) {
return;
}
// Always capture the initial value, even empty string.
// Empty string is falsy, so the old `|| null` guard would silently skip it,
// leaving data-default-value unset and breaking the dirty check for blank defaults.
input.dataset.defaultValue = input.value;
if (input.form) {
input.form.addEventListener('reset', () => {
input.value = input.dataset.defaultValue ?? '';
self.sync();
});
}
}

590
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -63,8 +63,8 @@
"exports-loader": "^5.0.0",
"json-formatter-js": "^2.3.4",
"jszip": "^3.2.0",
"katex": "^0.16.0",
"marked": "^17.0.1",
"katex": "^0.17.0",
"marked": "^18.0.0",
"marked-gfm-heading-id": "^4.1.1",
"marked-mangle": "^1.0.1",
"pdfmake": "^0.3.7",

View file

@ -1,4 +1,4 @@
# Generated on Mon May 4 05:40:05 UTC 2026
# Generated on Mon May 25 06:41:46 UTC 2026
# This file contains all footprints available in the offical KiCAD library
Audio_Module:Reverb_BTDR-1H
Audio_Module:Reverb_BTDR-1V
@ -11883,6 +11883,8 @@ Package_DFN_QFN:Texas_VQFN-HR-20_3x2.5mm_P0.5mm_RQQ0011A
Package_DFN_QFN:Texas_VQFN-RHL-20
Package_DFN_QFN:Texas_VQFN-RHL-20_ThermalVias
Package_DFN_QFN:Texas_VQFN-RNR0011A-11
Package_DFN_QFN:Texas_WQFN-40-1EP_3x6mm_P0.4mm_EP1.7x4.5mm
Package_DFN_QFN:Texas_WQFN-40-1EP_3x6mm_P0.4mm_EP1.7x4.5mm_ThermalVias
Package_DFN_QFN:Texas_WQFN-MR-100_3x3-DapStencil
Package_DFN_QFN:Texas_WQFN-MR-100_ThermalVias_3x3-DapStencil
Package_DFN_QFN:Texas_X2QFN-12_1.6x1.6mm_P0.4mm
@ -12956,6 +12958,7 @@ Package_SON:MPS_VSON-6_1x1.5mm_P0.5mm
Package_SON:MicroCrystal_C7_SON-8_1.5x3.2mm_P0.9mm
Package_SON:Microchip_USON-10-1EP_3x3mm_P0.5mm_EP1.8x2.5mm
Package_SON:Microchip_USON-10-1EP_3x3mm_P0.5mm_EP1.8x2.5mm_ThermalVias
Package_SON:NXP_LSON-16-1EP_3.5x4.5mm_P0.5mm_EP2x3.8mm
Package_SON:NXP_XSON-16
Package_SON:Nexperia_HUSON-12_USON-12-1EP_1.35x2.5mm_P0.4mm_EP0.4x2mm
Package_SON:Nexperia_HUSON-16_USON-16-1EP_1.35x3.3mm_P0.4mm_EP0.4x2.8mm

View file

@ -1,4 +1,4 @@
# Generated on Mon May 4 05:40:43 UTC 2026
# Generated on Mon May 25 06:42:27 UTC 2026
# This file contains all symbols available in the offical KiCAD library
4xxx:14528
4xxx:14529
@ -7545,6 +7545,7 @@ Driver_FET:ZXGD3003E6
Driver_FET:ZXGD3004E6
Driver_FET:ZXGD3006E6
Driver_FET:ZXGD3009E6
Driver_LED:AL5819W6
Driver_LED:AL8860MP
Driver_LED:AL8860WT
Driver_LED:AP3019AKTR
@ -7692,6 +7693,7 @@ Driver_Motor:DRV8311P
Driver_Motor:DRV8311S
Driver_Motor:DRV8412
Driver_Motor:DRV8432
Driver_Motor:DRV8434PWP
Driver_Motor:DRV8461SPWP
Driver_Motor:DRV8662
Driver_Motor:DRV8800PWP
@ -19322,6 +19324,9 @@ Regulator_Switching:MAX15062C
Regulator_Switching:MAX1522
Regulator_Switching:MAX1523
Regulator_Switching:MAX1524
Regulator_Switching:MAX15462A
Regulator_Switching:MAX15462B
Regulator_Switching:MAX15462C
Regulator_Switching:MAX17501AxTB
Regulator_Switching:MAX17501BxTB
Regulator_Switching:MAX17501ExTB
@ -21301,6 +21306,7 @@ Timer_RTC:MCP79512-xMS
Timer_RTC:MCP79520-xMS
Timer_RTC:MCP79521-xMS
Timer_RTC:MCP79522-xMS
Timer_RTC:PCA2131
Timer_RTC:PCF85063ATL
Timer_RTC:PCF8523T
Timer_RTC:PCF8523TK

View file

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Controller;
use Symfony\Component\Form\Extension\Core\Type\ResetType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use App\Settings\AppSettings;
use Jbtronics\SettingsBundle\Form\SettingsFormFactoryInterface;
@ -50,7 +51,9 @@ class SettingsController extends AbstractController
$settings = $this->settingsManager->createTemporaryCopy(AppSettings::class);
//Create a form builder for the settings object
$builder = $this->settingsFormFactory->createSettingsFormBuilder($settings);
$builder = $this->settingsFormFactory->createSettingsFormBuilder($settings, formOptions: [
'warn_on_unsaved_changes' => true,
]);
//Add a submit button to the form
$builder->add('submit', SubmitType::class, ['label' => 'save']);

View file

@ -57,6 +57,10 @@ class BaseEntityAdminForm extends AbstractType
$resolver->setRequired('attachment_class');
$resolver->setRequired('parameter_class');
$resolver->setAllowedTypes('parameter_class', ['string', 'null']);
$resolver->setDefaults([
'warn_on_unsaved_changes' => true,
]);
}
public function buildForm(FormBuilderInterface $builder, array $options): void

View file

@ -0,0 +1,84 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Form\Extension;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Adds a `warn_on_unsaved_changes` option to any root form. When set to true, the Stimulus
* `common--dirty-form` controller attributes are merged into the form element's HTML
* attributes, enabling unsaved-change detection without any template boilerplate.
*
* Usage in a form type:
*
* public function configureOptions(OptionsResolver $resolver): void
* {
* $resolver->setDefaults(['warn_on_unsaved_changes' => true]);
* }
*
* Or per-instance from a controller:
*
* $form = $this->createForm(MyFormType::class, $data, ['warn_on_unsaved_changes' => true]);
*/
class UnsavedChangesExtension extends AbstractTypeExtension
{
public function __construct(private readonly TranslatorInterface $translator)
{
}
public static function getExtendedTypes(): iterable
{
return [FormType::class];
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('warn_on_unsaved_changes', false);
$resolver->setAllowedTypes('warn_on_unsaved_changes', 'bool');
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
if (!$options['warn_on_unsaved_changes'] || $view->parent !== null) {
return;
}
$extraAttr = [
'data-controller' => 'common--dirty-form',
'data-common--dirty-form-confirm-title-value' => $this->translator->trans('form.dirty_form.unsaved_changes.title'),
'data-common--dirty-form-confirm-message-value' => $this->translator->trans('form.dirty_form.unsaved_changes.message'),
];
// Merge data-action so existing actions on the form element are preserved.
$existingAction = $view->vars['attr']['data-action'] ?? '';
$dirtyActions = 'submit->common--dirty-form#submit reset->common--dirty-form#resetDirtyState';
$extraAttr['data-action'] = $existingAction !== '' ? $existingAction . ' ' . $dirtyActions : $dirtyActions;
$view->vars['attr'] = array_merge($view->vars['attr'], $extraAttr);
}
}

View file

@ -353,6 +353,7 @@ class PartBaseType extends AbstractType
$resolver->setDefaults([
'data_class' => Part::class,
'info_provider_dto' => null,
'warn_on_unsaved_changes' => true,
]);
$resolver->setAllowedTypes('info_provider_dto', [PartDetailDTO::class, 'null']);

View file

@ -59,6 +59,10 @@ class UserAdminForm extends AbstractType
$resolver->setDefault('parameter_class', false);
$resolver->setDefault('validation_groups', ['Default', 'permissions:edit']);
$resolver->setDefaults([
'warn_on_unsaved_changes' => true,
]);
}
public function buildForm(FormBuilderInterface $builder, array $options): void

View file

@ -23,9 +23,13 @@ declare(strict_types=1);
namespace App\Serializer\APIPlatform;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\Metadata\Exception\ItemNotFoundException;
use ApiPlatform\Metadata\IriConverterInterface;
use ApiPlatform\Serializer\ItemNormalizer;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\SerializerAwareInterface;
@ -35,6 +39,10 @@ use Symfony\Component\Serializer\SerializerInterface;
* This class decorates API Platform's ItemNormalizer to allow skipping the normalization process by setting the
* DISABLE_ITEM_NORMALIZER context key to true. This is useful for all kind of serialization operations, where the API
* Platform subsystem should not be used.
*
* It also works around a bug in API Platform's AbstractItemNormalizer where IRI strings for abstract resource classes
* with a discriminator map fail deserialization when objectToPopulate is null (the discriminator is checked before
* the IRI string check). See: https://github.com/Part-DB/Part-DB-server/issues/1370
*/
#[AsDecorator("api_platform.serializer.normalizer.item")]
class SkippableItemNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface
@ -42,13 +50,44 @@ class SkippableItemNormalizer implements NormalizerInterface, DenormalizerInterf
public const DISABLE_ITEM_NORMALIZER = 'DISABLE_ITEM_NORMALIZER';
public function __construct(private readonly ItemNormalizer $inner)
{
public function __construct(
private readonly ItemNormalizer $inner,
private readonly IriConverterInterface $iriConverter,
) {
}
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
{
// API Platform's AbstractItemNormalizer has a bug: when objectToPopulate is null and data is an IRI
// string, it tries to resolve the discriminator class from [$iri_string] before reaching the IRI
// check (line 271). For abstract resource classes with a discriminator map (e.g. Attachment), this
// fails because the array has no _type key. Fix by resolving IRI strings directly.
// See: https://github.com/Part-DB/Part-DB-server/issues/1370
if (is_string($data) || (is_array($data) && isset($data['@id']) && is_string($data['@id']))) {
if (is_array($data)) {
$iri = $data['@id'];
} else {
$iri = $data;
}
try {
return $this->iriConverter->getResourceFromIri($iri, $context + ['fetch_data' => true]);
} catch (ItemNotFoundException $e) {
if (false === ($context['denormalize_throw_on_relation_not_found'] ?? true)) {
return null;
}
if (!isset($context['not_normalizable_value_exceptions'])) {
throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e);
}
throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $data, [$type], $context['deserialization_path'] ?? null, true, $e->getCode(), $e);
} catch (InvalidArgumentException $e) {
if (!isset($context['not_normalizable_value_exceptions'])) {
throw new UnexpectedValueException(sprintf('Invalid IRI "%s".', $iri), $e->getCode(), $e);
}
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Invalid IRI "%s".', $data), $data, [$type], $context['deserialization_path'] ?? null, true, $e->getCode(), $e);
}
}
return $this->inner->denormalize($data, $type, $format, $context);
}
@ -87,4 +126,4 @@ class SkippableItemNormalizer implements NormalizerInterface, DenormalizerInterf
'object' => false
];
}
}
}

View file

@ -69,4 +69,52 @@ final class PartEndpointTest extends CrudEndpointTestCase
{
$this->_testDeleteItem(1);
}
}
public function testMasterPictureAttachmentPatchWithIRI(): void
{
$client = static::createAuthenticatedClient();
// Create a new attachment with a picture URL for Part 1
$response = $client->request('POST', '/api/attachments', ['json' => [
'name' => 'Test Picture',
'url' => 'http://example.com/test.jpg',
'_type' => 'Part',
'element' => '/api/parts/1',
'attachment_type' => '/api/attachment_types/1',
]]);
self::assertResponseIsSuccessful();
$attachmentIri = $response->toArray()['@id'];
// Now PATCH Part 1 to set master_picture_attachment
$client->request('PATCH', '/api/parts/1', [
'json' => ['master_picture_attachment' => $attachmentIri],
'headers' => ['Content-Type' => 'application/merge-patch+json'],
]);
self::assertResponseIsSuccessful();
self::assertJsonContains(['master_picture_attachment' => ['@id' => $attachmentIri]]);
}
public function testMasterPictureAttachmentPatchWithArray(): void
{
$client = static::createAuthenticatedClient();
// Create a new attachment with a picture URL for Part 1
$response = $client->request('POST', '/api/attachments', ['json' => [
'name' => 'Test Picture',
'url' => 'http://example.com/test.jpg',
'_type' => 'Part',
'element' => '/api/parts/1',
'attachment_type' => '/api/attachment_types/1',
]]);
self::assertResponseIsSuccessful();
$attachmentIri = $response->toArray()['@id'];
// Now PATCH Part 1 to set master_picture_attachment
$client->request('PATCH', '/api/parts/1', [
'json' => ['master_picture_attachment' => ['@id' => $attachmentIri, '_type' => 'Part']],
'headers' => ['Content-Type' => 'application/merge-patch+json'],
]);
self::assertResponseIsSuccessful();
self::assertJsonContains(['master_picture_attachment' => ['@id' => $attachmentIri]]);
}
}

View file

@ -1,11 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="nl">
<file id="frontend.nl">
<unit id="lQ8QeGr" name="search.placeholder">
<segment state="translated">
<source>search.placeholder</source>
<target>Zoeken</target>
</segment>
</unit>
<file id="frontend.en">
<unit id="eLrezdb" name="search.placeholder">
<segment state="translated">
<source>search.placeholder</source>
<target>Zoeken</target>
</segment>
</unit>
<unit id="R4hoCqe" name="part.labelp">
<segment state="translated">
<source>part.labelp</source>
<target>Onderdelen</target>
</segment>
</unit>
<unit id="S4CxO.T" name="entity.select.group.new_not_added_to_DB">
<segment state="translated">
<source>entity.select.group.new_not_added_to_DB</source>
<target>Nieuw (nog niet toegevoegd aan de database)</target>
</segment>
</unit>
<unit id="9rnHbSK" name="user.password_strength.very_weak">
<segment state="translated">
<source>user.password_strength.very_weak</source>
<target>Zeer zwak</target>
</segment>
</unit>
<unit id="gKHmHwM" name="user.password_strength.weak">
<segment state="translated">
<source>user.password_strength.weak</source>
<target>Zwak</target>
</segment>
</unit>
<unit id="c44gN8b" name="user.password_strength.medium">
<segment state="translated">
<source>user.password_strength.medium</source>
<target>Gemiddeld</target>
</segment>
</unit>
<unit id="NwiBLHc" name="user.password_strength.strong">
<segment state="translated">
<source>user.password_strength.strong</source>
<target>Sterk</target>
</segment>
</unit>
<unit id="Bw.iCUm" name="user.password_strength.very_strong">
<segment state="translated">
<source>user.password_strength.very_strong</source>
<target>Zeer sterk</target>
</segment>
</unit>
<unit id="U5IhkwB" name="search.submit">
<segment state="translated">
<source>search.submit</source>
<target>Ga!</target>
</segment>
</unit>
</file>
</xliff>
</xliff>

View file

@ -1034,6 +1034,18 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
<target>Bearbeite Bauteileinformationen von</target>
</segment>
</unit>
<unit id="aznOqRU" name="form.dirty_form.unsaved_changes.title">
<segment state="translated">
<source>form.dirty_form.unsaved_changes.title</source>
<target>Ungespeicherte Änderungen</target>
</segment>
</unit>
<unit id="dfzAYsv" name="form.dirty_form.unsaved_changes.message">
<segment state="translated">
<source>form.dirty_form.unsaved_changes.message</source>
<target>Sie haben ungespeicherte Änderungen vorgenommen, die verloren gehen, wenn Sie diese Seite verlassen. Möchten Sie wirklich fortfahren?</target>
</segment>
</unit>
<unit id="EwY218_" name="part.edit.tab.common">
<segment state="translated">
<source>part.edit.tab.common</source>
@ -13605,5 +13617,35 @@ Buerklin-API-Authentication-Server:
<target>Host-URL</target>
</segment>
</unit>
<unit id="kuDv.So" name="browser_plugin.recent_pages.title">
<segment state="translated">
<source>browser_plugin.recent_pages.title</source>
<target>Zuletzt besuchte Seiten</target>
</segment>
</unit>
<unit id="AjNj8wk" name="browser_plugin.recent_pages.help">
<segment state="translated">
<source>browser_plugin.recent_pages.help</source>
<target>Hier werden die zuletzt besuchten Seiten angezeigt.</target>
</segment>
</unit>
<unit id="lVUU9s7" name="settings.ips.browser_plugin">
<segment state="translated">
<source>settings.ips.browser_plugin</source>
<target>Browser-Plugin</target>
</segment>
</unit>
<unit id="IrJs3fI" name="settings.ips.browser_plugin.description">
<segment state="translated">
<source>settings.ips.browser_plugin.description</source>
<target>Einstellungen für das Browser-Plugin</target>
</segment>
</unit>
<unit id="_8UrMCB" name="settings.ips.browser_plugin.enabled.help">
<segment state="translated">
<source>settings.ips.browser_plugin.enabled.help</source>
<target>Aktiviert das Browser-Plugin für diese Instanz.</target>
</segment>
</unit>
</file>
</xliff>

View file

@ -1035,6 +1035,18 @@ Sub elements will be moved upwards.</target>
<target>Edit [part]</target>
</segment>
</unit>
<unit id="aznOqRU" name="form.dirty_form.unsaved_changes.title">
<segment state="translated">
<source>form.dirty_form.unsaved_changes.title</source>
<target>Unsaved Changes</target>
</segment>
</unit>
<unit id="dfzAYsv" name="form.dirty_form.unsaved_changes.message">
<segment state="translated">
<source>form.dirty_form.unsaved_changes.message</source>
<target>You have unsaved changes that will be lost if you leave this page. Are you sure you want to continue?</target>
</segment>
</unit>
<unit id="EwY218_" name="part.edit.tab.common">
<segment state="translated">
<source>part.edit.tab.common</source>
@ -13620,19 +13632,19 @@ Buerklin-API Authentication server:
</segment>
</unit>
<unit id="lVUU9s7" name="settings.ips.browser_plugin">
<segment>
<segment state="translated">
<source>settings.ips.browser_plugin</source>
<target>Browser plugin</target>
</segment>
</unit>
<unit id="IrJs3fI" name="settings.ips.browser_plugin.description">
<segment>
<segment state="translated">
<source>settings.ips.browser_plugin.description</source>
<target>The browser plugin allows to submit pages to Part-DB directly from a browser to create new parts. HTML content is submitted, so that extraction even works on DDOS protected pages, or pages requiring javascript for correct rendering. The Generic Web or AI Web extractor needs to be enabled to be useful.</target>
</segment>
</unit>
<unit id="_8UrMCB" name="settings.ips.browser_plugin.enabled.help">
<segment>
<segment state="translated">
<source>settings.ips.browser_plugin.enabled.help</source>
<target>When enabled users with the info provider permission can submit pages to Part-DB and retrieve them later.</target>
</segment>

View file

@ -223,13 +223,13 @@
<target>Vanwege technische beperkingen is het niet mogelijk om datums na 2038-01-19 te selecteren op 32-bits systemen!</target>
</segment>
</unit>
<unit id="89nojXY" name="validator.fileSize.invalidFormat">
<unit id="iM9yb_p" name="validator.fileSize.invalidFormat">
<segment state="translated">
<source>validator.fileSize.invalidFormat</source>
<target>Ongeldig bestandsformaat. Gebruiker een geheel getal plus K, M of G als toevoeging voor Kilo, Mega of Gigabytes.</target>
</segment>
</unit>
<unit id="iXcU7ce" name="validator.invalid_range">
<unit id="ZFxQ0BZ" name="validator.invalid_range">
<segment state="translated">
<source>validator.invalid_range</source>
<target>De opgegeven reeks is niet geldig!</target>
@ -241,5 +241,17 @@
<target>Ongeldige code. Controleer of je authenticator-app correct is ingesteld en of zowel de server als het authenticatieapparaat de tijd correct hebben ingesteld.</target>
</segment>
</unit>
<unit id="I330cr5" name="settings.synonyms.type_synonyms.collection_type.duplicate">
<segment state="translated">
<source>settings.synonyms.type_synonyms.collection_type.duplicate</source>
<target>Er is al een vertaling gedefinieerd voor dit type en deze taal!</target>
</segment>
</unit>
<unit id="zT_j_oQ" name="validator.invalid_gtin">
<segment state="translated">
<source>validator.invalid_gtin</source>
<target>Dit is geen geldige GTIN / EAN!</target>
</segment>
</unit>
</file>
</xliff>
</xliff>

1251
yarn.lock

File diff suppressed because it is too large Load diff