Compare commits

..

No commits in common. "master" and "v1.14.3" have entirely different histories.

234 changed files with 9624 additions and 16717 deletions

View file

@ -42,48 +42,6 @@ fi
# Start PHP-FPM (the PHP_VERSION is replaced by the configured version in the Dockerfile) # Start PHP-FPM (the PHP_VERSION is replaced by the configured version in the Dockerfile)
service phpPHP_VERSION-fpm start service phpPHP_VERSION-fpm start
# Run migrations if automigration is enabled via env variable DB_AUTOMIGRATE
if [ "$DB_AUTOMIGRATE" = "true" ]; then
echo "Waiting for database to be ready..."
ATTEMPTS_LEFT_TO_REACH_DATABASE=60
until [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ] || DATABASE_ERROR=$(sudo -E -u www-data php bin/console dbal:run-sql -q "SELECT 1" 2>&1); do
if [ $? -eq 255 ]; then
# If the Doctrine command exits with 255, an unrecoverable error occurred
ATTEMPTS_LEFT_TO_REACH_DATABASE=0
break
fi
sleep 1
ATTEMPTS_LEFT_TO_REACH_DATABASE=$((ATTEMPTS_LEFT_TO_REACH_DATABASE - 1))
echo "Still waiting for database to be ready... Or maybe the database is not reachable. $ATTEMPTS_LEFT_TO_REACH_DATABASE attempts left."
done
if [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ]; then
echo "The database is not up or not reachable:"
echo "$DATABASE_ERROR"
exit 1
else
echo "The database is now ready and reachable"
fi
# Check if there are any available migrations to do, by executing doctrine:migrations:up-to-date
# and checking if the exit code is 0 (up to date) or 1 (not up to date)
if sudo -E -u www-data php bin/console doctrine:migrations:up-to-date --no-interaction; then
echo "Database is up to date, no migrations necessary."
else
echo "Migrations available..."
echo "Do backup of database..."
sudo -E -u www-data mkdir -p /var/www/html/uploads/.automigration-backup/
# Backup the database
sudo -E -u www-data php bin/console partdb:backup -n --database /var/www/html/uploads/.automigration-backup/backup-$(date +%Y-%m-%d_%H-%M-%S).zip
# Check if there are any migration files
sudo -E -u www-data php bin/console doctrine:migrations:migrate --no-interaction
fi
fi
# first arg is `-f` or `--some-option` (taken from https://github.com/docker-library/php/blob/master/8.2/bullseye/apache/docker-php-entrypoint) # first arg is `-f` or `--some-option` (taken from https://github.com/docker-library/php/blob/master/8.2/bullseye/apache/docker-php-entrypoint)
if [ "${1#-}" != "$1" ]; then if [ "${1#-}" != "$1" ]; then
set -- apache2-foreground "$@" set -- apache2-foreground "$@"

View file

@ -44,8 +44,6 @@
PassEnv PROVIDER_MOUSER_KEY PROVIDER_MOUSER_SEARCH_OPTION PROVIDER_MOUSER_SEARCH_LIMIT PROVIDER_MOUSER_SEARCH_WITH_SIGNUP_LANGUAGE PassEnv PROVIDER_MOUSER_KEY PROVIDER_MOUSER_SEARCH_OPTION PROVIDER_MOUSER_SEARCH_LIMIT PROVIDER_MOUSER_SEARCH_WITH_SIGNUP_LANGUAGE
PassEnv PROVIDER_LCSC_ENABLED PROVIDER_LCSC_CURRENCY PassEnv PROVIDER_LCSC_ENABLED PROVIDER_LCSC_CURRENCY
PassEnv PROVIDER_OEMSECRETS_KEY PROVIDER_OEMSECRETS_COUNTRY_CODE PROVIDER_OEMSECRETS_CURRENCY PROVIDER_OEMSECRETS_ZERO_PRICE PROVIDER_OEMSECRETS_SET_PARAM PROVIDER_OEMSECRETS_SORT_CRITERIA PassEnv PROVIDER_OEMSECRETS_KEY PROVIDER_OEMSECRETS_COUNTRY_CODE PROVIDER_OEMSECRETS_CURRENCY PROVIDER_OEMSECRETS_ZERO_PRICE PROVIDER_OEMSECRETS_SET_PARAM PROVIDER_OEMSECRETS_SORT_CRITERIA
PassEnv PROVIDER_REICHELT_ENABLED PROVIDER_REICHELT_CURRENCY PROVIDER_REICHELT_COUNTRY PROVIDER_REICHELT_LANGUAGE PROVIDER_REICHELT_INCLUDE_VAT
PassEnv PROVIDER_POLLIN_ENABLED
PassEnv EDA_KICAD_CATEGORY_DEPTH PassEnv EDA_KICAD_CATEGORY_DEPTH
# For most configuration files from conf-available/, which are # For most configuration files from conf-available/, which are

24
.env
View file

@ -143,8 +143,7 @@ PROVIDER_TME_CURRENCY=EUR
PROVIDER_TME_LANGUAGE=en PROVIDER_TME_LANGUAGE=en
# The country to get results for # The country to get results for
PROVIDER_TME_COUNTRY=DE PROVIDER_TME_COUNTRY=DE
# [DEPRECATED] Set this to 1 to get gross prices (including VAT) instead of net prices # Set this to 1 to get gross prices (including VAT) instead of net prices
# With private API keys, this option cannot be used anymore is ignored by Part-DB. The VAT inclusion depends on your TME account settings.
PROVIDER_TME_GET_GROSS_PRICES=1 PROVIDER_TME_GET_GROSS_PRICES=1
# Octopart / Nexar Provider: # Octopart / Nexar Provider:
@ -216,27 +215,6 @@ PROVIDER_OEMSECRETS_SET_PARAM=1
#If unset or set to any other value, no sorting is performed. #If unset or set to any other value, no sorting is performed.
PROVIDER_OEMSECRETS_SORT_CRITERIA=C PROVIDER_OEMSECRETS_SORT_CRITERIA=C
# Reichelt provider:
# Reichelt.com offers no official API, so this info provider webscrapes the website to extract info
# It could break at any time, use it at your own risk
# We dont require an API key for Reichelt, just set this to 1 to enable Reichelt support
PROVIDER_REICHELT_ENABLED=0
# The country to get prices for
PROVIDER_REICHELT_COUNTRY=DE
# The language to get results in (en, de, fr, nl, pl, it, es)
PROVIDER_REICHELT_LANGUAGE=en
# Include VAT in prices (set to 1 to include VAT, 0 to exclude VAT)
PROVIDER_REICHELT_INCLUDE_VAT=1
# The currency to get prices in (only for countries with countries other than EUR)
PROVIDER_REICHELT_CURRENCY=EUR
# Pollin provider:
# Pollin.de offers no official API, so this info provider webscrapes the website to extract info
# It could break at any time, use it at your own risk
# We dont require an API key for Pollin, just set this to 1 to enable Pollin support
PROVIDER_POLLIN_ENABLED=0
################################################################################## ##################################################################################
# EDA integration related settings # EDA integration related settings
################################################################################## ##################################################################################

View file

@ -18,7 +18,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
php-versions: [ '8.1', '8.2', '8.3', '8.4' ] php-versions: [ '8.1', '8.2', '8.3' ]
db-type: [ 'mysql', 'sqlite', 'postgres' ] db-type: [ 'mysql', 'sqlite', 'postgres' ]
env: env:
@ -126,7 +126,7 @@ jobs:
run: ./bin/phpunit --coverage-clover=coverage.xml run: ./bin/phpunit --coverage-clover=coverage.xml
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v5 uses: codecov/codecov-action@v4
with: with:
env_vars: PHP_VERSION,DB_TYPE env_vars: PHP_VERSION,DB_TYPE
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}

View file

@ -1,5 +1,5 @@
ARG BASE_IMAGE=debian:bookworm-slim ARG BASE_IMAGE=debian:bookworm-slim
ARG PHP_VERSION=8.3 ARG PHP_VERSION=8.2
FROM ${BASE_IMAGE} AS base FROM ${BASE_IMAGE} AS base
ARG PHP_VERSION ARG PHP_VERSION
@ -125,7 +125,6 @@ upload_max_filesize=256M
post_max_size=300M post_max_size=300M
opcache.preload_user=www-data opcache.preload_user=www-data
opcache.preload=/var/www/html/config/preload.php opcache.preload=/var/www/html/config/preload.php
log_limit=8096
EOF EOF
COPY ./.docker/symfony.conf /etc/apache2/sites-available/symfony.conf COPY ./.docker/symfony.conf /etc/apache2/sites-available/symfony.conf

View file

@ -1 +1 @@
1.17.1 1.14.3

View file

@ -128,8 +128,6 @@ const PLACEHOLDERS = [
['[[BARCODE_QR]]', 'QR code linking to this element'], ['[[BARCODE_QR]]', 'QR code linking to this element'],
['[[BARCODE_C128]]', 'Code 128 barcode linking to this element'], ['[[BARCODE_C128]]', 'Code 128 barcode linking to this element'],
['[[BARCODE_C39]]', 'Code 39 barcode linking to this element'], ['[[BARCODE_C39]]', 'Code 39 barcode linking to this element'],
['[[BARCODE_C93]]', 'Code 93 barcode linking to this element'],
['[[BARCODE_DATAMATRIX]]', 'Datamatrix code linking to this element'],
] ]
}, },
{ {

View file

@ -69,8 +69,6 @@ Object.assign( window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary, {
'QR code linking to this element': 'QR Code verknüpft mit diesem Element', 'QR code linking to this element': 'QR Code verknüpft mit diesem Element',
'Code 128 barcode linking to this element': 'Code 128 Barcode verknüpft mit diesem Element', 'Code 128 barcode linking to this element': 'Code 128 Barcode verknüpft mit diesem Element',
'Code 39 barcode linking to this element': 'Code 39 Barcode verknüpft mit diesem Element', 'Code 39 barcode linking to this element': 'Code 39 Barcode verknüpft mit diesem Element',
'Code 93 barcode linking to this element': 'Code 93 Barcode verknüpft mit diesem Element',
'Datamatrix code linking to this element': 'Datamatrix Code verknüpft mit diesem Element',
'Location ID': 'Lagerort ID', 'Location ID': 'Lagerort ID',
'Name': 'Name', 'Name': 'Name',

View file

@ -23,12 +23,6 @@ import "tom-select/dist/css/tom-select.bootstrap5.css";
import '../../css/components/tom-select_extensions.css'; import '../../css/components/tom-select_extensions.css';
import TomSelect from "tom-select"; 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'
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
export default class extends Controller { export default class extends Controller {
_tomSelect; _tomSelect;
@ -52,12 +46,6 @@ export default class extends Controller {
} }
return '<div>' + escape(data.label) + '</div>'; return '<div>' + escape(data.label) + '</div>';
} }
},
plugins: {
'autoselect_typed': {},
'click_to_edit': {},
'clear_button': {},
"restore_on_backspace": {}
} }
}; };

View file

@ -53,7 +53,6 @@ export default class extends Controller {
const config = { const config = {
language: language, language: language,
licenseKey: "GPL",
} }
const watchdog = new EditorWatchdog(); const watchdog = new EditorWatchdog();

View file

@ -23,12 +23,6 @@ import "tom-select/dist/css/tom-select.bootstrap5.css";
import '../../css/components/tom-select_extensions.css'; import '../../css/components/tom-select_extensions.css';
import TomSelect from "tom-select"; 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'
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
/** /**
* This is the frontend controller for StaticFileAutocompleteType form element. * This is the frontend controller for StaticFileAutocompleteType form element.
* Basically it loads a text file from the given url (via data-url) and uses it as a source for the autocomplete. * Basically it loads a text file from the given url (via data-url) and uses it as a source for the autocomplete.
@ -52,13 +46,7 @@ export default class extends Controller {
orderField: 'text', orderField: 'text',
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin //This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING', delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING'
plugins: {
'autoselect_typed': {},
'click_to_edit': {},
'clear_button': {},
'restore_on_backspace': {}
}
}; };
if (this.element.dataset.url) { if (this.element.dataset.url) {

View file

@ -24,9 +24,6 @@ import {Controller} from "@hotwired/stimulus";
import {trans, ENTITY_SELECT_GROUP_NEW_NOT_ADDED_TO_DB} from '../../translator.js' import {trans, ENTITY_SELECT_GROUP_NEW_NOT_ADDED_TO_DB} from '../../translator.js'
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
export default class extends Controller { export default class extends Controller {
_tomSelect; _tomSelect;
@ -40,15 +37,11 @@ export default class extends Controller {
const allowAdd = this.element.getAttribute("data-allow-add") === "true"; const allowAdd = this.element.getAttribute("data-allow-add") === "true";
const addHint = this.element.getAttribute("data-add-hint") ?? ""; const addHint = this.element.getAttribute("data-add-hint") ?? "";
let settings = { let settings = {
allowEmptyOption: true, allowEmptyOption: true,
selectOnTab: true, selectOnTab: true,
maxOptions: null, maxOptions: null,
create: allowAdd ? this.createItem.bind(this) : false, create: allowAdd ? this.createItem.bind(this) : false,
createFilter: this.createFilter.bind(this),
// This three options allow us to paste element names with commas: (see issue #538) // This three options allow us to paste element names with commas: (see issue #538)
maxItems: 1, maxItems: 1,
@ -88,17 +81,8 @@ export default class extends Controller {
//Add callbacks to update validity //Add callbacks to update validity
onInitialize: this.updateValidity.bind(this), onInitialize: this.updateValidity.bind(this),
onChange: this.updateValidity.bind(this), onChange: this.updateValidity.bind(this),
plugins: {
"autoselect_typed": {},
}
}; };
//Add clear button plugin, if an empty option is present
if (this.element.querySelector("option[value='']") !== null) {
settings.plugins["clear_button"] = {};
}
this._tomSelect = new TomSelect(this.element, settings); this._tomSelect = new TomSelect(this.element, settings);
//Do not do a sync here as this breaks the initial rendering of the empty option //Do not do a sync here as this breaks the initial rendering of the empty option
//this._tomSelect.sync(); //this._tomSelect.sync();
@ -129,31 +113,6 @@ export default class extends Controller {
}); });
} }
createFilter(input) {
//Normalize the input (replace spacing around arrows)
if (input.includes("->")) {
const inputs = input.split("->");
inputs.forEach((value, index) => {
inputs[index] = value.trim();
});
input = inputs.join("->");
} else {
input = input.trim();
}
const options = this._tomSelect.options;
//Iterate over all options and check if the input is already present
for (let index in options) {
const option = options[index];
if (option.path === input) {
return false;
}
}
return true;
}
updateValidity() { updateValidity() {
//Mark this input as invalid, if the selected option is disabled //Mark this input as invalid, if the selected option is disabled

View file

@ -23,21 +23,14 @@ import "tom-select/dist/css/tom-select.bootstrap5.css";
import '../../css/components/tom-select_extensions.css'; import '../../css/components/tom-select_extensions.css';
import TomSelect from "tom-select"; 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'
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
export default class extends Controller { export default class extends Controller {
_tomSelect; _tomSelect;
connect() { connect() {
let settings = { let settings = {
plugins: { plugins: {
remove_button:{}, remove_button:{
'autoselect_typed': {}, }
'click_to_edit': {},
}, },
persistent: false, persistent: false,
selectOnTab: true, selectOnTab: true,

View file

@ -20,7 +20,7 @@
import {Controller} from "@hotwired/stimulus"; import {Controller} from "@hotwired/stimulus";
//import * as ZXing from "@zxing/library"; //import * as ZXing from "@zxing/library";
import {Html5QrcodeScanner, Html5Qrcode} from "@part-db/html5-qrcode"; import {Html5QrcodeScanner, Html5Qrcode} from "html5-qrcode";
/* stimulusFetch: 'lazy' */ /* stimulusFetch: 'lazy' */
export default class extends Controller { export default class extends Controller {
@ -50,7 +50,7 @@ export default class extends Controller {
}); });
this._scanner = new Html5QrcodeScanner(this.element.id, { this._scanner = new Html5QrcodeScanner(this.element.id, {
fps: 10, fps: 2,
qrbox: qrboxFunction, qrbox: qrboxFunction,
experimentalFeatures: { experimentalFeatures: {
//This option improves reading quality on android chrome //This option improves reading quality on android chrome
@ -61,11 +61,6 @@ export default class extends Controller {
this._scanner.render(this.onScanSuccess.bind(this)); this._scanner.render(this.onScanSuccess.bind(this));
} }
disconnect() {
this._scanner.pause();
this._scanner.clear();
}
onScanSuccess(decodedText, decodedResult) { onScanSuccess(decodedText, decodedResult) {
//Put our decoded Text into the input box //Put our decoded Text into the input box
document.getElementById('scan_dialog_input').value = decodedText; document.getElementById('scan_dialog_input').value = decodedText;

View file

@ -25,20 +25,9 @@ import "katex/dist/katex.css";
export default class extends Controller { export default class extends Controller {
static targets = ["input", "preview"]; static targets = ["input", "preview"];
static values = {
unit: {type: Boolean, default: false} //Render as upstanding (non-italic) text, useful for units
}
updatePreview() updatePreview()
{ {
let value = ""; katex.render(this.inputTarget.value, this.previewTarget, {
if (this.unitValue) {
value = "\\mathrm{" + this.inputTarget.value + "}";
} else {
value = this.inputTarget.value;
}
katex.render(value, this.previewTarget, {
throwOnError: false, throwOnError: false,
}); });
} }

View file

@ -22,13 +22,6 @@ import TomSelect from "tom-select";
import katex from "katex"; import katex from "katex";
import "katex/dist/katex.css"; 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'
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
/* stimulusFetch: 'lazy' */ /* stimulusFetch: 'lazy' */
export default class extends Controller export default class extends Controller
{ {
@ -60,10 +53,7 @@ export default class extends Controller
connect() { connect() {
const settings = { const settings = {
plugins: { plugins: {
'autoselect_typed': {}, clear_button:{}
'click_to_edit': {},
'clear_button': {},
'restore_on_backspace': {}
}, },
persistent: false, persistent: false,
maxItems: 1, maxItems: 1,

View file

@ -112,10 +112,3 @@ ul.structural_link li a:hover {
background-color: var(--bs-success); background-color: var(--bs-success);
border-color: var(--bs-success); border-color: var(--bs-success);
} }
/***********************************************
* Katex rendering with same height as text
***********************************************/
.katex-same-height-as-text .katex {
font-size: 1.0em;
}

View file

@ -24,8 +24,9 @@
/** Should be the same settings, as in label_style.css */ /** Should be the same settings, as in label_style.css */
.ck-html-label .ck-content { .ck-html-label .ck-content {
font-family: "DejaVu Sans Mono", monospace; font-family: "DejaVu Sans Mono", monospace;
font-size: 12pt; font-size: 12px;
line-height: 1.0; line-height: 1.0;
font-size-adjust: 1.5;
} }
.ck-html-label .ck-content p { .ck-html-label .ck-content p {

View file

@ -44,18 +44,4 @@ import "./register_events";
import "./tristate_checkboxes"; import "./tristate_checkboxes";
//Define jquery globally //Define jquery globally
window.$ = window.jQuery = require("jquery"); window.$ = window.jQuery = require("jquery")
//Use the local WASM file for the ZXing library
import {
setZXingModuleOverrides,
} from "barcode-detector/pure";
import wasmFile from "../../node_modules/zxing-wasm/dist/reader/zxing_reader.wasm";
setZXingModuleOverrides({
locateFile: (path, prefix) => {
if (path.endsWith(".wasm")) {
return wasmFile;
}
return prefix + path;
},
});

View file

@ -1,63 +0,0 @@
/**
* Autoselect Typed plugin for Tomselect
*
* This plugin allows automatically selecting an option matching the typed text when the Tomselect element goes out of
* focus (is blurred) and/or when the delimiter is typed.
*
* #select_on_blur option
* Tomselect natively supports the "createOnBlur" option. This option picks up any remaining text in the input field
* and uses it to create a new option and selects that option. It does behave a bit strangely though, in that it will
* not select an already existing option when the input is blurred, so if you typed something that matches an option in
* the list and then click outside the box (without pressing enter) the entered text is just removed (unless you have
* allow duplicates on in which case it will create a new option).
* This plugin fixes that, such that Tomselect will first try to select an option matching the remaining uncommitted
* text and only when no matching option is found tries to create a new one (if createOnBlur and create is on)
*
* #select_on_delimiter option
* Normally when typing the delimiter (space by default) Tomselect will try to create a new option (and select it) (if
* create is on), but if the typed text matches an option (and allow duplicates is off) it refuses to react at all until
* you press enter. With this option, the delimiter will also allow selecting an option, not just creating it.
*/
function select_current_input(self){
if(self.isLocked){
return
}
const val = self.inputValue()
//Do nothing if the input is empty
if (!val) {
return
}
if (self.options[val]) {
self.addItem(val)
self.setTextboxValue()
}
}
export default function(plugin_options_) {
const plugin_options = Object.assign({
//Autoselect the typed text when the input element goes out of focus
select_on_blur: true,
//Autoselect the typed text when the delimiter is typed
select_on_delimiter: true,
}, plugin_options_);
const self = this
if(plugin_options.select_on_blur) {
this.hook("before", "onBlur", function () {
select_current_input(self)
})
}
if(plugin_options.select_on_delimiter) {
this.hook("before", "onKeyPress", function (e) {
const character = String.fromCharCode(e.keyCode || e.which);
if (self.settings.mode === 'multi' && character === self.settings.delimiter) {
select_current_input(self)
}
})
}
}

View file

@ -1,93 +0,0 @@
/**
* click_to_edit plugin for Tomselect
*
* This plugin allows editing (and selecting text in) any selected item by clicking it.
*
* Usually, when the user typed some text and created an item in Tomselect that item cannot be edited anymore. To make
* a change, the item has to be deleted and retyped completely. There is also generally no way to copy text out of a
* tomselect item. The "restore_on_backspace" plugin improves that somewhat, by allowing the user to edit an item after
* pressing backspace. However, it is somewhat confusing to first have to focus the field an then hit backspace in order
* to copy a piece of text. It may also not be immediately obvious for editing.
* This plugin transforms an item into editable text when it is clicked, e.g. when the user tries to place the caret
* within an item or when they try to drag across the text to highlight it.
* It also plays nice with the remove_button plugin which still removes (deselects) an option entirely.
*
* It is recommended to also enable the autoselect_typed plugin when using this plugin. Without it, the text in the
* input field (i.e. the item that was just clicked) is lost when the user clicks outside the field. Also, when the user
* clicks an option (making it text) and then tries to enter another one by entering the delimiter (e.g. space) nothing
* happens until enter is pressed or the text is changed from what it was.
*/
/**
* Return a dom element from either a dom query string, jQuery object, a dom element or html string
* https://stackoverflow.com/questions/494143/creating-a-new-dom-element-from-an-html-string-using-built-in-dom-methods-or-pro/35385518#35385518
*
* param query should be {}
*/
const getDom = query => {
if (query.jquery) {
return query[0];
}
if (query instanceof HTMLElement) {
return query;
}
if (isHtmlString(query)) {
var tpl = document.createElement('template');
tpl.innerHTML = query.trim(); // Never return a text node of whitespace as the result
return tpl.content.firstChild;
}
return document.querySelector(query);
};
const isHtmlString = arg => {
if (typeof arg === 'string' && arg.indexOf('<') > -1) {
return true;
}
return false;
};
function plugin(plugin_options_) {
const self = this
const plugin_options = Object.assign({
//If there is unsubmitted text in the input field, should that text be automatically used to select a matching
//element? If this is off, clicking on item1 and then clicking on item2 will result in item1 being deselected
auto_select_before_edit: true,
//If there is unsubmitted text in the input field, should that text be automatically used to create a matching
//element if no matching element was found or auto_select_before_edit is off?
auto_create_before_edit: true,
//customize this function to change which text the item is replaced with when clicking on it
text: option => {
return option[self.settings.labelField];
}
}, plugin_options_);
self.hook('after', 'setupTemplates', () => {
const orig_render_item = self.settings.render.item;
self.settings.render.item = (data, escape) => {
const item = getDom(orig_render_item.call(self, data, escape));
item.addEventListener('click', evt => {
if (self.isLocked) {
return;
}
const val = self.inputValue();
if (self.options[val]) {
self.addItem(val)
} else if (self.settings.create) {
self.createItem();
}
const option = self.options[item.dataset.value]
self.setTextboxValue(plugin_options.text.call(self, option));
self.focus();
self.removeItem(item);
}
);
return item;
}
});
}
export { plugin as default };

View file

@ -4,10 +4,6 @@
use App\Kernel; use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Console\Application;
if (!is_dir(dirname(__DIR__).'/vendor')) {
throw new LogicException('Dependencies are missing. Try running "composer install".');
}
//Increase xdebug.max_nesting_level to 1000 if required (see issue #411) //Increase xdebug.max_nesting_level to 1000 if required (see issue #411)
//Check if xdebug extension is active, and xdebug.max_nesting_level is set to 256 or lower //Check if xdebug extension is active, and xdebug.max_nesting_level is set to 256 or lower
if (extension_loaded('xdebug') && ((int) ini_get('xdebug.max_nesting_level')) <= 256) { if (extension_loaded('xdebug') && ((int) ini_get('xdebug.max_nesting_level')) <= 256) {

View file

@ -1,5 +1,4 @@
{ {
"name": "part-db/part-db-server",
"type": "project", "type": "project",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"require": { "require": {
@ -15,9 +14,9 @@
"api-platform/core": "^3.1", "api-platform/core": "^3.1",
"beberlei/doctrineextensions": "^1.2", "beberlei/doctrineextensions": "^1.2",
"brick/math": "0.12.1 as 0.11.0", "brick/math": "0.12.1 as 0.11.0",
"composer/ca-bundle": "^1.5", "composer/ca-bundle": "^1.3",
"composer/package-versions-deprecated": "^1.11.99.5", "composer/package-versions-deprecated": "^1.11.99.5",
"doctrine/data-fixtures": "^2.0.0", "doctrine/data-fixtures": "^1.6.6",
"doctrine/dbal": "^4.0.0", "doctrine/dbal": "^4.0.0",
"doctrine/doctrine-bundle": "^2.0", "doctrine/doctrine-bundle": "^2.0",
"doctrine/doctrine-migrations-bundle": "^3.0", "doctrine/doctrine-migrations-bundle": "^3.0",
@ -40,10 +39,12 @@
"nelmio/cors-bundle": "^2.3", "nelmio/cors-bundle": "^2.3",
"nelmio/security-bundle": "^3.0", "nelmio/security-bundle": "^3.0",
"nyholm/psr7": "^1.1", "nyholm/psr7": "^1.1",
"omines/datatables-bundle": "^0.9.1", "ocramius/proxy-manager": "2.2.*",
"omines/datatables-bundle": "^0.8.0",
"paragonie/sodium_compat": "^1.21", "paragonie/sodium_compat": "^1.21",
"part-db/label-fonts": "^1.0", "part-db/label-fonts": "^1.0",
"rhukster/dom-sanitizer": "^1.0", "phpdocumentor/reflection-docblock": "^5.2",
"phpstan/phpdoc-parser": "^1.23",
"runtime/frankenphp-symfony": "^0.2.0", "runtime/frankenphp-symfony": "^0.2.0",
"s9e/text-formatter": "^2.1", "s9e/text-formatter": "^2.1",
"scheb/2fa-backup-code": "^6.8.0", "scheb/2fa-backup-code": "^6.8.0",
@ -55,8 +56,6 @@
"symfony/apache-pack": "^1.0", "symfony/apache-pack": "^1.0",
"symfony/asset": "6.4.*", "symfony/asset": "6.4.*",
"symfony/console": "6.4.*", "symfony/console": "6.4.*",
"symfony/css-selector": "6.4.*",
"symfony/dom-crawler": "6.4.*",
"symfony/dotenv": "6.4.*", "symfony/dotenv": "6.4.*",
"symfony/expression-language": "6.4.*", "symfony/expression-language": "6.4.*",
"symfony/flex": "^v2.3.1", "symfony/flex": "^v2.3.1",
@ -70,6 +69,7 @@
"symfony/process": "6.4.*", "symfony/process": "6.4.*",
"symfony/property-access": "6.4.*", "symfony/property-access": "6.4.*",
"symfony/property-info": "6.4.*", "symfony/property-info": "6.4.*",
"symfony/proxy-manager-bridge": "6.4.*",
"symfony/rate-limiter": "6.4.*", "symfony/rate-limiter": "6.4.*",
"symfony/runtime": "6.4.*", "symfony/runtime": "6.4.*",
"symfony/security-bundle": "6.4.*", "symfony/security-bundle": "6.4.*",
@ -91,22 +91,24 @@
"twig/intl-extra": "^3.8", "twig/intl-extra": "^3.8",
"twig/markdown-extra": "^3.8", "twig/markdown-extra": "^3.8",
"twig/string-extra": "^3.8", "twig/string-extra": "^3.8",
"web-auth/webauthn-symfony-bundle": "^4.0.0" "web-auth/webauthn-symfony-bundle": "^4.0.0",
"webmozart/assert": "^1.4"
}, },
"require-dev": { "require-dev": {
"dama/doctrine-test-bundle": "^v8.0.0", "dama/doctrine-test-bundle": "^v8.0.0",
"doctrine/doctrine-fixtures-bundle": "^4.0.0", "doctrine/doctrine-fixtures-bundle": "^3.2",
"ekino/phpstan-banned-code": "^v3.0.0", "ekino/phpstan-banned-code": "^v1.0.0",
"jbtronics/translation-editor-bundle": "^1.0", "jbtronics/translation-editor-bundle": "^1.0",
"phpstan/extension-installer": "^1.0", "phpstan/extension-installer": "^1.0",
"phpstan/phpstan": "^2.0.4", "phpstan/phpstan": "^1.4.7",
"phpstan/phpstan-doctrine": "^2.0.1", "phpstan/phpstan-doctrine": "^1.2.11",
"phpstan/phpstan-strict-rules": "^2.0.1", "phpstan/phpstan-strict-rules": "^1.5",
"phpstan/phpstan-symfony": "^2.0.0", "phpstan/phpstan-symfony": "^1.1.7",
"phpunit/phpunit": "^9.5", "phpunit/phpunit": "^9.5",
"rector/rector": "^2.0.4", "rector/rector": "^1.1.1",
"roave/security-advisories": "dev-latest", "roave/security-advisories": "dev-latest",
"symfony/browser-kit": "6.4.*", "symfony/browser-kit": "6.4.*",
"symfony/css-selector": "6.4.*",
"symfony/debug-bundle": "6.4.*", "symfony/debug-bundle": "6.4.*",
"symfony/maker-bundle": "^1.13", "symfony/maker-bundle": "^1.13",
"symfony/phpunit-bridge": "6.4.*", "symfony/phpunit-bridge": "6.4.*",

3269
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -35,7 +35,3 @@ api_platform:
keep_legacy_inflector: false keep_legacy_inflector: false
# Need to be true, or some tests will fail # Need to be true, or some tests will fail
use_symfony_listeners: true use_symfony_listeners: true
serializer:
# Change this to false later, to remove the hydra prefix on the API
hydra_prefix: true

View file

@ -8,7 +8,7 @@ datatables:
# Set options, as documented at https://datatables.net/reference/option/ # Set options, as documented at https://datatables.net/reference/option/
options: options:
lengthMenu : [[10, 25, 50, 100], [10, 25, 50, 100]] # We add the "All" option, when part tables are generated lengthMenu : [[10, 25, 50, 100, -1], [10, 25, 50, 100, "All"]]
pageLength: '%partdb.table.default_page_size%' # Set to -1 to disable pagination (i.e. show all rows) by default pageLength: '%partdb.table.default_page_size%' # Set to -1 to disable pagination (i.e. show all rows) by default
dom: " <'row' <'col mb-2 input-group flex-nowrap' B l > <'col-auto mb-2' < p >>> dom: " <'row' <'col mb-2 input-group flex-nowrap' B l > <'col-auto mb-2' < p >>>
<'card' <'card'

View file

@ -57,7 +57,6 @@ doctrine:
field2: App\Doctrine\Functions\Field2 field2: App\Doctrine\Functions\Field2
natsort: App\Doctrine\Functions\Natsort natsort: App\Doctrine\Functions\Natsort
array_position: App\Doctrine\Functions\ArrayPosition array_position: App\Doctrine\Functions\ArrayPosition
ilike: App\Doctrine\Functions\ILike
when@test: when@test:
doctrine: doctrine:

View file

@ -50,6 +50,7 @@ when@prod:
type: stream type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log" path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug level: debug
formatter: monolog.formatter.json
console: console:
type: console type: console
process_psr_3_messages: false process_psr_3_messages: false
@ -73,6 +74,7 @@ when@docker:
type: stream type: stream
path: "php://stderr" path: "php://stderr"
level: debug level: debug
formatter: monolog.formatter.json
console: console:
type: console type: console
process_psr_3_messages: false process_psr_3_messages: false

View file

@ -51,16 +51,12 @@ nelmio_security:
img-src: img-src:
- '*' - '*'
- 'data:' - 'data:'
# Required for be able to load pictures in the QR code scanner
- 'blob:'
style-src: style-src:
- 'self' - 'self'
- 'unsafe-inline' - 'unsafe-inline'
- 'data:' - 'data:'
script-src: script-src:
- 'self' - 'self'
# Required for loading the Wasm for the barcode scanner:
- 'wasm-unsafe-eval'
object-src: object-src:
- 'self' - 'self'
- 'data:' - 'data:'

View file

@ -23,7 +23,7 @@ each other so that it does not matter which one of your 1000 things of Part you
A part entity has many fields, which can be used to describe it better. Most of the fields are optional: A part entity has many fields, which can be used to describe it better. Most of the fields are optional:
* **Name** (Required): The name of the part or how you want to call it. This could be a manufacturer-provided name, or a * **Name** (Required): The name of the part or how you want to call it. This could be a manufacturer-provided name, or a
name you thought of yourself. Each name needs to be unique and must exist in a single category. name you thought of yourself. The name have to be unique in a single category.
* **Description**: A short (single-line) description of what this part is/does. For longer information, you should use * **Description**: A short (single-line) description of what this part is/does. For longer information, you should use
the comment field or the specifications the comment field or the specifications
* **Category** (Required): The category (see there) to which this part belongs to. * **Category** (Required): The category (see there) to which this part belongs to.

View file

@ -150,9 +150,9 @@ In the `serverVersion` parameter you can specify the version of the PostgreSQL s
The `charset` parameter specify the character set of the database. It should be set to `utf8` to ensure that all characters are stored correctly. The `charset` parameter specify the character set of the database. It should be set to `utf8` to ensure that all characters are stored correctly.
If you want to use a unix socket for the connection instead of a TCP connnection, you can specify the socket path in the `host` parameter. If you want to use a unix socket for the connection instead of a TCP connnection, you can specify the socket path in the `unix_socket` parameter.
```shell ```shell
DATABASE_URL="postgresql://db_user@localhost/db_name?serverVersion=16.6&charset=utf8&host=/var/run/postgresql" DATABASE_URL="postgresql://db_user:db_password@localhost/db_name?serverVersion=12.19&charset=utf8&unix_socket=/var/run/postgresql/.s.PGSQL.5432"
``` ```

View file

@ -7,5 +7,3 @@ has_children: true
# Installation # Installation
Below you can find some guides to install Part-DB. Below you can find some guides to install Part-DB.
For the hobbyists without much experience, we recommend the docker installation or direct installation on debian.

View file

@ -48,12 +48,6 @@ services:
# In docker env logs will be redirected to stderr # In docker env logs will be redirected to stderr
- APP_ENV=docker - APP_ENV=docker
# Uncomment this, if you want to use the automatic database migration feature. With this you have you do not have to
# run the doctrine:migrations:migrate commands on installation or upgrade. A database backup is written to the uploads/
# folder (under .automigration-backup), so you can restore it, if the migration fails.
# This feature is currently experimental, so use it at your own risk!
# - DB_AUTOMIGRATE=true
# You can configure Part-DB using environment variables # You can configure Part-DB using environment variables
# Below you can find the most essential ones predefined # Below you can find the most essential ones predefined
# However you can add any other environment configuration you want here # However you can add any other environment configuration you want here
@ -136,12 +130,6 @@ services:
# In docker env logs will be redirected to stderr # In docker env logs will be redirected to stderr
- APP_ENV=docker - APP_ENV=docker
# Uncomment this, if you want to use the automatic database migration feature. With this you have you do not have to
# run the doctrine:migrations:migrate commands on installation or upgrade. A database backup is written to the uploads/
# folder (under .automigration-backup), so you can restore it, if the migration fails.
# This feature is currently experimental, so use it at your own risk!
# - DB_AUTOMIGRATE=true
# You can configure Part-DB using environment variables # You can configure Part-DB using environment variables
# Below you can find the most essential ones predefined # Below you can find the most essential ones predefined
# However you can add add any other environment configuration you want here # However you can add add any other environment configuration you want here
@ -213,10 +201,6 @@ You also have to create the database as described above in step 4.
You can run the console commands described in README by You can run the console commands described in README by
executing `docker exec --user=www-data -it partdb bin/console [command]` executing `docker exec --user=www-data -it partdb bin/console [command]`
{: .warning }
> If you run a root console inside the container, and wanna execute commands on the webserver behalf, be sure to use `sudo -E` command (with the `-E` flag) to preserve env variables from the current shell.
> Otherwise Part-DB console might use the wrong configuration to execute commands.
## Troubleshooting ## Troubleshooting
*Login is not possible. Login page is just reloading and no error message is shown or something like "CSFR token invalid"*: *Login is not possible. Login page is just reloading and no error message is shown or something like "CSFR token invalid"*:

View file

@ -1,42 +0,0 @@
---
title: Kubernetes / Helm
layout: default
parent: Installation
nav_order: 5
---
# Kubernetes / Helm Charts
If you are using Kubernetes, you can use the [helm charts](https://helm.sh/) provided in this [repository](https://github.com/Part-DB/helm-charts).
## Usage
[Helm](https://helm.sh) must be installed to use the charts. Please refer to
Helm's [documentation](https://helm.sh/docs) to get started.
Once Helm has been set up correctly, add the repo as follows:
`helm repo add part-db https://part-db.github.io/helm-charts`
If you had already added this repo earlier, run `helm repo update` to retrieve
the latest versions of the packages. You can then run `helm search repo
part-db` to see the charts.
To install the part-db chart:
helm install my-part-db part-db/part-db
To uninstall the chart:
helm delete my-part-db
This repository is also available at [ArtifactHUB](https://artifacthub.io/packages/search?repo=part-db).
## Configuration
See the README in the [chart directory](https://github.com/Part-DB/helm-charts/tree/main/charts/part-db) for more
information on the available configuration options.
## Bugreports
If you find issues related to the helm charts, please open an issue in the [helm-charts repository](https://github.com/Part-DB/helm-charts).

View file

@ -53,11 +53,6 @@ server {
return 404; return 404;
} }
# Set Content-Security-Policy for svg files, to block embedded javascript in there
location ~* \.svg$ {
add_header Content-Security-Policy "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none';";
}
error_log /var/log/nginx/parts.error.log; error_log /var/log/nginx/parts.error.log;
access_log /var/log/nginx/parts.access.log; access_log /var/log/nginx/parts.access.log;

View file

@ -1,31 +0,0 @@
---
title: Proxmox VE LXC
layout: default
parent: Installation
nav_order: 6
---
# Proxmox VE LXC
{: .warning }
> The proxmox VE LXC script for Part-DB is developed and maintained by [Proxmox VE Helper-Scripts](https://community-scripts.github.io/ProxmoxVE/)
> and not by the Part-DB developers. Keep in mind that the script is not officially supported by the Part-DB developers.
If you are using Proxmox VE you can use the scripts provided by [Proxmox VE Helper-Scripts community](https://community-scripts.github.io/ProxmoxVE/scripts?id=part-db)
to easily install Part-DB in a LXC container.
## Usage
To create a new LXC container with Part-DB, you can use the following command in the Proxmox VE shell:
```bash
bash -c "$(wget -qLO - https://github.com/community-scripts/ProxmoxVE/raw/main/ct/part-db.sh)"
```
The same command can be used to update an existing Part-DB container.
See the [helper script website](https://community-scripts.github.io/ProxmoxVE/scripts?id=part-db) for more information.
## Bugreports
If you find issues related to the proxmox VE LXC script, please open an issue in the [Proxmox VE Helper-Scripts repository](https://github.com/community-scripts/ProxmoxVE).

View file

@ -25,12 +25,6 @@ is named `partdb`, you can execute the command `php bin/console cache:clear` wit
docker exec --user=www-data partdb php bin/console cache:clear docker exec --user=www-data partdb php bin/console cache:clear
``` ```
{: .warning }
> If you run a root console inside the docker container, and wanna execute commands on the webserver behalf, be sure to use `sudo -E` command (with the `-E` flag) to preserve env variables from the current shell.
> Otherwise Part-DB console might use the wrong configuration to execute commands.
## Troubleshooting
## User management commands ## User management commands
* `php bin/console partdb:users:list`: List all users of this Part-DB instance * `php bin/console partdb:users:list`: List all users of this Part-DB instance
@ -71,9 +65,3 @@ docker exec --user=www-data partdb php bin/console cache:clear
* `php bin/console doctrine:migrations:migrate`: Migrate the database to the latest version * `php bin/console doctrine:migrations:migrate`: Migrate the database to the latest version
* `php bin/console doctrine:migrations:up-to-date`: Check if the database is up-to-date * `php bin/console doctrine:migrations:up-to-date`: Check if the database is up-to-date
## Attachment commands
* `php bin/console partdb:attachments:download`: Download all attachments, which are not already downloaded, to the
local filesystem. This is useful to create local backups of the attachments, no matter what happens on the remote and
also makes pictures thumbnails available for the frontend for them

View file

@ -107,7 +107,7 @@ The following env configuration options are available:
default: `EUR`). If an offer is only available in a certain currency, default: `EUR`). If an offer is only available in a certain currency,
Part-DB will save the prices in their native currency, and you can use Part-DB currency conversion feature to convert Part-DB will save the prices in their native currency, and you can use Part-DB currency conversion feature to convert
it to your preferred currency. it to your preferred currency.
* `PROVIDER_OCTOPART_COUNTRY`: The country you want to get prices in if available (optional, 2 letter ISO-code, * `PROVIDER_OCOTPART_COUNTRY`: The country you want to get prices in if available (optional, 2 letter ISO-code,
default: `DE`). To get the correct prices, you have to set this and the currency setting to the correct value. default: `DE`). To get the correct prices, you have to set this and the currency setting to the correct value.
* `PROVIDER_OCTOPART_SEARCH_LIMIT`: The maximum number of results to return per search (optional, default: `10`). This * `PROVIDER_OCTOPART_SEARCH_LIMIT`: The maximum number of results to return per search (optional, default: `10`). This
affects how quickly your monthly limit is used up. affects how quickly your monthly limit is used up.
@ -232,26 +232,6 @@ The following env configuration options are available:
completeness (prioritizing items with the most detailed information). If set to 'M', it further sorts by manufacturer name. completeness (prioritizing items with the most detailed information). If set to 'M', it further sorts by manufacturer name.
If set to any other value, no sorting is performed. If set to any other value, no sorting is performed.
### Reichelt
The reichelt provider uses webscraping from [reichelt.com](https://reichelt.com/) to get part information.
This is not an official API and could break at any time. So use it at your own risk.
The following env configuration options are available:
* `PROVIDER_REICHELT_ENABLED`: Set this to `1` to enable the Reichelt provider
* `PROVIDER_REICHELT_CURRENCY`: The currency you want to get prices in. Only possible for countries which use Non-EUR (optional, default: `EUR`)
* `PROVIDER_REICHELT_COUNTRY`: The country you want to get the prices for (optional, default: `DE`)
* `PROVIDER_REICHELT_LANGUAGE`: The language you want to get the descriptions in (optional, default: `en`)
* `PROVIDER_REICHELT_INCLUDE_VAT`: If set to `1`, the prices will be gross prices (including tax), otherwise net prices (optional, default: `1`)
### Pollin
The pollin provider uses webscraping from [pollin.de](https://www.pollin.de/) to get part information.
This is not an official API and could break at any time. So use it at your own risk.
The following env configuration options are available:
* `PROVIDER_POLLIN_ENABLED`: Set this to `1` to enable the Pollin provider
### Custom provider ### Custom provider
To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long

View file

@ -117,6 +117,6 @@ For a German keyboard layout, replace `[` with `0`, and `]` with `´`.
| Key | Character | | Key | Character |
|--------------------------------|--------------------| |--------------------------------|--------------------|
| **Alt + [** (code 219) | © (Copyright char) | | **Alt + [** (code 219) | © (Copyright char) |
| **Alt + Shift + [** (code 219) | ® (Registered char) | | **Alt + Shift + [** (code 219) | (Registered char) |
| **Alt + ]** (code 221) | ™ (Trademark char) | | **Alt + ]** (code 221) | ™ (Trademark char) |
| **Alt + Shift + ]** (code 221) | ° (Degree char) | | **Alt + Shift + ]** (code 221) | (Degree char) |

View file

@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250220215048 extends AbstractMigration
{
public function getDescription(): string
{
return 'Split $path property for attachments into $internal_path and $external_path';
}
public function up(Schema $schema): void
{
//Create the new columns as nullable (that is easier modifying them)
$this->addSql('ALTER TABLE attachments ADD internal_path VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE attachments ADD external_path VARCHAR(255) DEFAULT NULL');
//Copy the data from path to external_path and remove the path column
$this->addSql('UPDATE attachments SET external_path=path');
$this->addSql('ALTER TABLE attachments DROP COLUMN path');
$this->addSql('UPDATE attachments SET internal_path=external_path WHERE external_path LIKE \'#%MEDIA#%%\' ESCAPE \'#\'');
$this->addSql('UPDATE attachments SET internal_path=external_path WHERE external_path LIKE \'#%BASE#%%\' ESCAPE \'#\'');
$this->addSql('UPDATE attachments SET internal_path=external_path WHERE external_path LIKE \'#%SECURE#%%\' ESCAPE \'#\'');
$this->addSql('UPDATE attachments SET internal_path=external_path WHERE external_path LIKE \'#%FOOTPRINTS#%%\' ESCAPE \'#\'');
$this->addSql('UPDATE attachments SET internal_path=external_path WHERE external_path LIKE \'#%FOOTPRINTS3D#%%\' ESCAPE \'#\'');
$this->addSql('UPDATE attachments SET external_path=NULL WHERE internal_path IS NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('UPDATE attachments SET external_path=internal_path WHERE internal_path IS NOT NULL');
$this->addSql('ALTER TABLE attachments DROP COLUMN internal_path');
$this->addSql('ALTER TABLE attachments RENAME COLUMN external_path TO path');
}
}

View file

@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250222165240 extends AbstractMigration
{
public function getDescription(): string
{
return 'Migrate the old attachment class discriminator values from legacy Part-DB to the modern format, so that there is just one unified value';
}
public function up(Schema $schema): void
{
//Change the old discriminator values to the new ones
$this->addSql("UPDATE attachments SET class_name = 'Part' WHERE class_name = 'PartDB\Part'");
$this->addSql("UPDATE attachments SET class_name = 'Device' WHERE class_name = 'PartDB\Device'");
}
public function down(Schema $schema): void
{
//No down required, as the new format can also be read by older Part-DB version
}
}

View file

@ -9,7 +9,7 @@
"@symfony/stimulus-bridge": "^3.2.0", "@symfony/stimulus-bridge": "^3.2.0",
"@symfony/ux-translator": "file:vendor/symfony/ux-translator/assets", "@symfony/ux-translator": "file:vendor/symfony/ux-translator/assets",
"@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets", "@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets",
"@symfony/webpack-encore": "^5.0.0", "@symfony/webpack-encore": "^4.1.0",
"bootstrap": "^5.1.3", "bootstrap": "^5.1.3",
"core-js": "^3.23.0", "core-js": "^3.23.0",
"intl-messageformat": "^10.2.5", "intl-messageformat": "^10.2.5",
@ -18,7 +18,7 @@
"regenerator-runtime": "^0.13.9", "regenerator-runtime": "^0.13.9",
"webpack": "^5.74.0", "webpack": "^5.74.0",
"webpack-bundle-analyzer": "^4.3.0", "webpack-bundle-analyzer": "^4.3.0",
"webpack-cli": "^5.1.0", "webpack-cli": "^4.10.0",
"webpack-notifier": "^1.15.0" "webpack-notifier": "^1.15.0"
}, },
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
@ -33,52 +33,50 @@
"@algolia/autocomplete-js": "^1.17.0", "@algolia/autocomplete-js": "^1.17.0",
"@algolia/autocomplete-plugin-recent-searches": "^1.17.0", "@algolia/autocomplete-plugin-recent-searches": "^1.17.0",
"@algolia/autocomplete-theme-classic": "^1.17.0", "@algolia/autocomplete-theme-classic": "^1.17.0",
"@ckeditor/ckeditor5-alignment": "^44.0.0", "@ckeditor/ckeditor5-alignment": "^41.0.0",
"@ckeditor/ckeditor5-autoformat": "^44.0.0", "@ckeditor/ckeditor5-autoformat": "^41.0.0",
"@ckeditor/ckeditor5-basic-styles": "^44.0.0", "@ckeditor/ckeditor5-basic-styles": "^41.0.0",
"@ckeditor/ckeditor5-block-quote": "^44.0.0", "@ckeditor/ckeditor5-block-quote": "^41.0.0",
"@ckeditor/ckeditor5-code-block": "^44.0.0", "@ckeditor/ckeditor5-code-block": "^41.0.0",
"@ckeditor/ckeditor5-dev-translations": "^43.0.1", "@ckeditor/ckeditor5-dev-translations": "^39.1.0",
"@ckeditor/ckeditor5-dev-utils": "^43.0.1", "@ckeditor/ckeditor5-dev-utils": "^39.1.0",
"@ckeditor/ckeditor5-editor-classic": "^44.0.0", "@ckeditor/ckeditor5-editor-classic": "^41.0.0",
"@ckeditor/ckeditor5-essentials": "^44.0.0", "@ckeditor/ckeditor5-essentials": "^41.0.0",
"@ckeditor/ckeditor5-find-and-replace": "^44.0.0", "@ckeditor/ckeditor5-find-and-replace": "^41.0.0",
"@ckeditor/ckeditor5-font": "^44.0.0", "@ckeditor/ckeditor5-font": "^41.0.0",
"@ckeditor/ckeditor5-heading": "^44.0.0", "@ckeditor/ckeditor5-heading": "^41.0.0",
"@ckeditor/ckeditor5-highlight": "^44.0.0", "@ckeditor/ckeditor5-highlight": "^41.0.0",
"@ckeditor/ckeditor5-horizontal-line": "^44.0.0", "@ckeditor/ckeditor5-horizontal-line": "^41.0.0",
"@ckeditor/ckeditor5-html-embed": "^44.0.0", "@ckeditor/ckeditor5-html-embed": "^41.0.0",
"@ckeditor/ckeditor5-html-support": "^44.0.0", "@ckeditor/ckeditor5-html-support": "^41.0.0",
"@ckeditor/ckeditor5-image": "^44.0.0", "@ckeditor/ckeditor5-image": "^41.0.0",
"@ckeditor/ckeditor5-indent": "^44.0.0", "@ckeditor/ckeditor5-indent": "^41.0.0",
"@ckeditor/ckeditor5-link": "^44.0.0", "@ckeditor/ckeditor5-link": "^41.0.0",
"@ckeditor/ckeditor5-list": "^44.0.0", "@ckeditor/ckeditor5-list": "^41.0.0",
"@ckeditor/ckeditor5-markdown-gfm": "^44.0.0", "@ckeditor/ckeditor5-markdown-gfm": "^41.0.0",
"@ckeditor/ckeditor5-media-embed": "^44.0.0", "@ckeditor/ckeditor5-media-embed": "^41.0.0",
"@ckeditor/ckeditor5-paragraph": "^44.0.0", "@ckeditor/ckeditor5-paragraph": "^41.0.0",
"@ckeditor/ckeditor5-paste-from-office": "^44.0.0", "@ckeditor/ckeditor5-paste-from-office": "^41.0.0",
"@ckeditor/ckeditor5-remove-format": "^44.0.0", "@ckeditor/ckeditor5-remove-format": "^41.0.0",
"@ckeditor/ckeditor5-source-editing": "^44.0.0", "@ckeditor/ckeditor5-source-editing": "^41.0.0",
"@ckeditor/ckeditor5-special-characters": "^44.0.0", "@ckeditor/ckeditor5-special-characters": "^41.0.0",
"@ckeditor/ckeditor5-table": "^44.0.0", "@ckeditor/ckeditor5-table": "^41.0.0",
"@ckeditor/ckeditor5-theme-lark": "^44.0.0", "@ckeditor/ckeditor5-theme-lark": "^41.0.0",
"@ckeditor/ckeditor5-upload": "^44.0.0", "@ckeditor/ckeditor5-upload": "^41.0.0",
"@ckeditor/ckeditor5-watchdog": "^44.0.0", "@ckeditor/ckeditor5-watchdog": "^41.0.0",
"@ckeditor/ckeditor5-word-count": "^44.0.0", "@ckeditor/ckeditor5-word-count": "^41.0.0",
"@jbtronics/bs-treeview": "^1.0.1", "@jbtronics/bs-treeview": "^1.0.1",
"@part-db/html5-qrcode": "^3.1.0",
"@zxcvbn-ts/core": "^3.0.2", "@zxcvbn-ts/core": "^3.0.2",
"@zxcvbn-ts/language-common": "^3.0.3", "@zxcvbn-ts/language-common": "^3.0.3",
"@zxcvbn-ts/language-de": "^3.0.1", "@zxcvbn-ts/language-de": "^3.0.1",
"@zxcvbn-ts/language-en": "^3.0.1", "@zxcvbn-ts/language-en": "^3.0.1",
"@zxcvbn-ts/language-fr": "^3.0.1", "@zxcvbn-ts/language-fr": "^3.0.1",
"@zxcvbn-ts/language-ja": "^3.0.1", "@zxcvbn-ts/language-ja": "^3.0.1",
"barcode-detector": "^2.3.1",
"bootbox": "^6.0.0", "bootbox": "^6.0.0",
"bootswatch": "^5.1.3", "bootswatch": "^5.1.3",
"bs-custom-file-input": "^1.3.4", "bs-custom-file-input": "^1.3.4",
"clipboard": "^2.0.4", "clipboard": "^2.0.4",
"compression-webpack-plugin": "^11.1.0", "compression-webpack-plugin": "^10.0.0",
"datatables.net": "^2.0.0", "datatables.net": "^2.0.0",
"datatables.net-bs5": "^2.0.0", "datatables.net-bs5": "^2.0.0",
"datatables.net-buttons-bs5": "^3.0.0", "datatables.net-buttons-bs5": "^3.0.0",
@ -88,17 +86,18 @@
"datatables.net-select-bs5": "^2.0.0", "datatables.net-select-bs5": "^2.0.0",
"dompurify": "^3.0.3", "dompurify": "^3.0.3",
"emoji.json": "^15.0.0", "emoji.json": "^15.0.0",
"exports-loader": "^5.0.0", "exports-loader": "^3.0.0",
"html5-qrcode": "^2.2.1",
"json-formatter-js": "^2.3.4", "json-formatter-js": "^2.3.4",
"jszip": "^3.2.0", "jszip": "^3.2.0",
"katex": "^0.16.0", "katex": "^0.16.0",
"marked": "^15.0.4", "marked": "^12.0.0",
"marked-gfm-heading-id": "^4.1.1", "marked-gfm-heading-id": "^3.0.4",
"marked-mangle": "^1.0.1", "marked-mangle": "^1.0.1",
"pdfmake": "^0.2.2", "pdfmake": "^0.2.2",
"stimulus-use": "^0.52.0", "stimulus-use": "^0.52.0",
"tom-select": "^2.1.0", "tom-select": "^2.1.0",
"ts-loader": "^9.2.6", "ts-loader": "^9.2.6",
"typescript": "^5.7.2" "typescript": "^4.0.2"
} }
} }

View file

@ -20,7 +20,7 @@ parameters:
treatPhpDocTypesAsCertain: false treatPhpDocTypesAsCertain: false
symfony: symfony:
containerXmlPath: '%rootDir%/../../../var/cache/dev/App_KernelDevDebugContainer.xml' container_xml_path: '%rootDir%/../../../var/cache/dev/App_KernelDevDebugContainer.xml'
doctrine: doctrine:
objectManagerLoader: tests/object-manager.php objectManagerLoader: tests/object-manager.php
@ -30,6 +30,11 @@ parameters:
checkFunctionNameCase: false checkFunctionNameCase: false
checkAlwaysTrueInstanceof: false
checkAlwaysTrueCheckTypeFunctionCall: false
checkAlwaysTrueStrictComparison: false
reportAlwaysTrueInLastCondition: false
reportMaybesInPropertyPhpDocTypes: false reportMaybesInPropertyPhpDocTypes: false
reportMaybesInMethodSignatures: false reportMaybesInMethodSignatures: false
@ -38,14 +43,14 @@ parameters:
booleansInConditions: false booleansInConditions: false
uselessCast: false uselessCast: false
requireParentConstructorCall: true requireParentConstructorCall: true
disallowedConstructs: false
overwriteVariablesWithLoop: false overwriteVariablesWithLoop: false
closureUsesThis: false closureUsesThis: false
matchingInheritedMethodNames: true matchingInheritedMethodNames: true
numericOperandsInArithmeticOperators: true numericOperandsInArithmeticOperators: true
strictCalls: true
switchConditionsMatchingType: false switchConditionsMatchingType: false
noVariableVariables: false noVariableVariables: false
disallowedEmpty: false
disallowedShortTernary: false
ignoreErrors: ignoreErrors:
# Ignore errors caused by complex mapping with AbstractStructuralDBElement # Ignore errors caused by complex mapping with AbstractStructuralDBElement
@ -58,6 +63,3 @@ parameters:
# Ignore doctrine type mapping mismatch # Ignore doctrine type mapping mismatch
- '#Property .* type mapping mismatch: property can contain .* but database expects .*#' - '#Property .* type mapping mismatch: property can contain .* but database expects .*#'
# Ignore error of unused WithPermPresetsTrait, as it is used in the migrations which are not analyzed by Phpstan
- '#Trait App\\Migration\\WithPermPresetsTrait is used zero times and is not analysed#'

View file

@ -118,10 +118,3 @@ DirectoryIndex index.php
# RedirectTemp cannot be used instead # RedirectTemp cannot be used instead
</IfModule> </IfModule>
</IfModule> </IfModule>
# Set Content-Security-Policy for svg files (and compressed variants), to block embedded javascript in there
<IfModule mod_headers.c>
<FilesMatch "\.(svg|svg\.gz|svg\.br)$">
Header set Content-Security-Policy "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none';"
</FilesMatch>
</IfModule>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,116 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 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\ApiPlatform;
use ApiPlatform\JsonSchema\Schema;
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
use ApiPlatform\Metadata\Operation;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
/**
* This decorator adds the properties given by DocumentedAPIProperty attributes on the classes to the schema.
*/
#[AsDecorator('api_platform.json_schema.schema_factory')]
class AddDocumentedAPIPropertiesJSONSchemaFactory implements SchemaFactoryInterface
{
public function __construct(private readonly SchemaFactoryInterface $decorated)
{
}
public function buildSchema(
string $className,
string $format = 'json',
string $type = Schema::TYPE_OUTPUT,
Operation $operation = null,
Schema $schema = null,
array $serializerContext = null,
bool $forceCollection = false
): Schema {
$schema = $this->decorated->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection);
//Check if there is are DocumentedAPIProperty attributes on the class
$reflectionClass = new \ReflectionClass($className);
$attributes = $reflectionClass->getAttributes(DocumentedAPIProperty::class);
foreach ($attributes as $attribute) {
/** @var DocumentedAPIProperty $api_property */
$api_property = $attribute->newInstance();
$this->addPropertyToSchema($schema, $api_property->schemaName, $api_property->property,
$api_property, $serializerContext ?? [], $format);
}
return $schema;
}
private function addPropertyToSchema(Schema $schema, string $definitionName, string $normalizedPropertyName, DocumentedAPIProperty $propertyMetadata, array $serializerContext, string $format): void
{
$version = $schema->getVersion();
$swagger = Schema::VERSION_SWAGGER === $version;
$propertySchema = [];
if (false === $propertyMetadata->writeable) {
$propertySchema['readOnly'] = true;
}
if (!$swagger && false === $propertyMetadata->readable) {
$propertySchema['writeOnly'] = true;
}
if (null !== $description = $propertyMetadata->description) {
$propertySchema['description'] = $description;
}
$deprecationReason = $propertyMetadata->deprecationReason;
// see https://github.com/json-schema-org/json-schema-spec/pull/737
if (!$swagger && null !== $deprecationReason) {
$propertySchema['deprecated'] = true;
}
if (!empty($default = $propertyMetadata->default)) {
if ($default instanceof \BackedEnum) {
$default = $default->value;
}
$propertySchema['default'] = $default;
}
if (!empty($example = $propertyMetadata->example)) {
$propertySchema['example'] = $example;
}
if (!isset($propertySchema['example']) && isset($propertySchema['default'])) {
$propertySchema['example'] = $propertySchema['default'];
}
$propertySchema['type'] = $propertyMetadata->type;
$propertySchema['nullable'] = $propertyMetadata->nullable;
$propertySchema = new \ArrayObject($propertySchema);
$schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = $propertySchema;
}
}

View file

@ -1,73 +0,0 @@
<?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\ApiPlatform\DocumentedAPIProperties;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ReflectionClass;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
/**
* This decorator adds the virtual properties defined by the DocumentedAPIProperty attribute to the property metadata
* which then get picked up by the openapi schema generator
*/
#[AsDecorator('api_platform.metadata.property.metadata_factory')]
class PropertyMetadataFactory implements PropertyMetadataFactoryInterface
{
public function __construct(private PropertyMetadataFactoryInterface $decorated)
{
}
public function create(string $resourceClass, string $property, array $options = []): ApiProperty
{
$metadata = $this->decorated->create($resourceClass, $property, $options);
//Only become active in the context of the openapi schema generation
if (!isset($options['schema_type'])) {
return $metadata;
}
if (!class_exists($resourceClass)) {
return $metadata;
}
$refClass = new ReflectionClass($resourceClass);
$attributes = $refClass->getAttributes(DocumentedAPIProperty::class);
//Look for the DocumentedAPIProperty attribute with the given property name
foreach ($attributes as $attribute) {
/** @var DocumentedAPIProperty $api_property */
$api_property = $attribute->newInstance();
//If attribute not matches the property name, skip it
if ($api_property->property !== $property) {
continue;
}
//Return the virtual property
return $api_property->toAPIProperty();
}
return $metadata;
}
}

View file

@ -1,68 +0,0 @@
<?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\ApiPlatform\DocumentedAPIProperties;
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use ApiPlatform\Metadata\Property\PropertyNameCollection;
use ReflectionClass;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
/**
* This decorator adds the virtual property names defined by the DocumentedAPIProperty attribute to the property name collection
* which then get picked up by the openapi schema generator
*/
#[AsDecorator('api_platform.metadata.property.name_collection_factory')]
class PropertyNameCollectionFactory implements PropertyNameCollectionFactoryInterface
{
public function __construct(private readonly PropertyNameCollectionFactoryInterface $decorated)
{
}
public function create(string $resourceClass, array $options = []): PropertyNameCollection
{
// Get the default properties from the decorated service
$propertyNames = $this->decorated->create($resourceClass, $options);
//Only become active in the context of the openapi schema generation
if (!isset($options['schema_type'])) {
return $propertyNames;
}
if (!class_exists($resourceClass)) {
return $propertyNames;
}
$properties = iterator_to_array($propertyNames);
$refClass = new ReflectionClass($resourceClass);
foreach ($refClass->getAttributes(DocumentedAPIProperty::class) as $attribute) {
/** @var DocumentedAPIProperty $instance */
$instance = $attribute->newInstance();
$properties[] = $instance->property;
}
return new PropertyNameCollection($properties);
}
}

View file

@ -21,9 +21,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\ApiPlatform\DocumentedAPIProperties; namespace App\ApiPlatform;
use ApiPlatform\Metadata\ApiProperty;
/** /**
* When this attribute is applied to a class, an property will be added to the API documentation using the given parameters. * When this attribute is applied to a class, an property will be added to the API documentation using the given parameters.
@ -66,55 +64,4 @@ final class DocumentedAPIProperty
) )
{ {
} }
public function toAPIProperty(bool $use_swagger = false): ApiProperty
{
$openApiContext = [];
if (false === $this->writeable) {
$openApiContext['readOnly'] = true;
}
if (!$use_swagger && false === $this->readable) {
$openApiContext['writeOnly'] = true;
}
if (null !== $description = $this->description) {
$openApiContext['description'] = $description;
}
$deprecationReason = $this->deprecationReason;
// see https://github.com/json-schema-org/json-schema-spec/pull/737
if (!$use_swagger && null !== $deprecationReason) {
$openApiContext['deprecated'] = true;
}
if (!empty($default = $this->default)) {
if ($default instanceof \BackedEnum) {
$default = $default->value;
}
$openApiContext['default'] = $default;
}
if (!empty($example = $this->example)) {
$openApiContext['example'] = $example;
}
if (!isset($openApiContext['example']) && isset($openApiContext['default'])) {
$openApiContext['example'] = $openApiContext['default'];
}
$openApiContext['type'] = $this->type;
$openApiContext['nullable'] = $this->nullable;
return new ApiProperty(
description: $this->description,
readable: $this->readable,
writable: $this->writeable,
openapiContext: $openApiContext,
types: $this->type,
property: $this->property
);
}
} }

View file

@ -37,7 +37,7 @@ class EntityFilter extends AbstractFilter
public function __construct( public function __construct(
ManagerRegistry $managerRegistry, ManagerRegistry $managerRegistry,
private readonly EntityFilterHelper $filter_helper, private readonly EntityFilterHelper $filter_helper,
?LoggerInterface $logger = null, LoggerInterface $logger = null,
?array $properties = null, ?array $properties = null,
?NameConverterInterface $nameConverter = null ?NameConverterInterface $nameConverter = null
) { ) {
@ -50,7 +50,7 @@ class EntityFilter extends AbstractFilter
QueryBuilder $queryBuilder, QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator, QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass, string $resourceClass,
?Operation $operation = null, Operation $operation = null,
array $context = [] array $context = []
): void { ): void {
if ( if (

View file

@ -92,6 +92,12 @@ class EntityFilterHelper
'type' => Type::BUILTIN_TYPE_STRING, 'type' => Type::BUILTIN_TYPE_STRING,
'required' => false, 'required' => false,
'description' => 'Filter using a comma seperated list of element IDs. Use + to include all direct children and ++ to include all children recursively.', 'description' => 'Filter using a comma seperated list of element IDs. Use + to include all direct children and ++ to include all children recursively.',
'openapi' => [
'example' => '',
'allowReserved' => false,// if true, query parameters will be not percent-encoded
'allowEmptyValue' => true,
'explode' => false, // to be true, the type must be Type::BUILTIN_TYPE_ARRAY, ?product=blue,green will be ?product=blue&product=green
],
]; ];
} }
return $description; return $description;

View file

@ -38,7 +38,7 @@ final class LikeFilter extends AbstractFilter
QueryBuilder $queryBuilder, QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator, QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass, string $resourceClass,
?Operation $operation = null, Operation $operation = null,
array $context = [] array $context = []
): void { ): void {
// Otherwise filter is applied to order and page as well // Otherwise filter is applied to order and page as well
@ -50,7 +50,7 @@ final class LikeFilter extends AbstractFilter
} }
$parameterName = $queryNameGenerator->generateParameterName($property); // Generate a unique parameter name to avoid collisions with other filters $parameterName = $queryNameGenerator->generateParameterName($property); // Generate a unique parameter name to avoid collisions with other filters
$queryBuilder $queryBuilder
->andWhere(sprintf('ILIKE(o.%s, :%s) = TRUE', $property, $parameterName)) ->andWhere(sprintf('o.%s LIKE :%s', $property, $parameterName))
->setParameter($parameterName, $value); ->setParameter($parameterName, $value);
} }
@ -67,6 +67,12 @@ final class LikeFilter extends AbstractFilter
'type' => Type::BUILTIN_TYPE_STRING, 'type' => Type::BUILTIN_TYPE_STRING,
'required' => false, 'required' => false,
'description' => 'Filter using a LIKE SQL expression. Use % as wildcard for multiple characters and _ for single characters. For example, to search for all items containing foo, use foo. To search for all items starting with foo, use foo%. To search for all items ending with foo, use %foo', 'description' => 'Filter using a LIKE SQL expression. Use % as wildcard for multiple characters and _ for single characters. For example, to search for all items containing foo, use foo. To search for all items starting with foo, use foo%. To search for all items ending with foo, use %foo',
'openapi' => [
'example' => '',
'allowReserved' => false,// if true, query parameters will be not percent-encoded
'allowEmptyValue' => true,
'explode' => false, // to be true, the type must be Type::BUILTIN_TYPE_ARRAY, ?product=blue,green will be ?product=blue&product=green
],
]; ];
} }
return $description; return $description;

View file

@ -38,7 +38,7 @@ class PartStoragelocationFilter extends AbstractFilter
public function __construct( public function __construct(
ManagerRegistry $managerRegistry, ManagerRegistry $managerRegistry,
private readonly EntityFilterHelper $filter_helper, private readonly EntityFilterHelper $filter_helper,
?LoggerInterface $logger = null, LoggerInterface $logger = null,
?array $properties = null, ?array $properties = null,
?NameConverterInterface $nameConverter = null ?NameConverterInterface $nameConverter = null
) { ) {
@ -51,7 +51,7 @@ class PartStoragelocationFilter extends AbstractFilter
QueryBuilder $queryBuilder, QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator, QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass, string $resourceClass,
?Operation $operation = null, Operation $operation = null,
array $context = [] array $context = []
): void { ): void {
//Do not check for mapping here, as we are using a virtual property //Do not check for mapping here, as we are using a virtual property

View file

@ -61,10 +61,10 @@ final class TagFilter extends AbstractFilter
$expr = $queryBuilder->expr(); $expr = $queryBuilder->expr();
$tmp = $expr->orX( $tmp = $expr->orX(
'ILIKE(o.'.$property.', :' . $tag_identifier_prefix . '_1) = TRUE', $expr->like('o.'.$property, ':' . $tag_identifier_prefix . '_1'),
'ILIKE(o.'.$property.', :' . $tag_identifier_prefix . '_2) = TRUE', $expr->like('o.'.$property, ':' . $tag_identifier_prefix . '_2'),
'ILIKE(o.'.$property.', :' . $tag_identifier_prefix . '_3) = TRUE', $expr->like('o.'.$property, ':' . $tag_identifier_prefix . '_3'),
'ILIKE(o.'.$property.', :' . $tag_identifier_prefix . '_4) = TRUE', $expr->eq('o.'.$property, ':' . $tag_identifier_prefix . '_4'),
); );
$queryBuilder->andWhere($tmp); $queryBuilder->andWhere($tmp);
@ -89,6 +89,12 @@ final class TagFilter extends AbstractFilter
'type' => Type::BUILTIN_TYPE_STRING, 'type' => Type::BUILTIN_TYPE_STRING,
'required' => false, 'required' => false,
'description' => 'Filter for tags of a part', 'description' => 'Filter for tags of a part',
'openapi' => [
'example' => '',
'allowReserved' => false,// if true, query parameters will be not percent-encoded
'allowEmptyValue' => true,
'explode' => false, // to be true, the type must be Type::BUILTIN_TYPE_ARRAY, ?product=blue,green will be ?product=blue&product=green
],
]; ];
} }
return $description; return $description;

View file

@ -1,77 +0,0 @@
<?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\ApiPlatform;
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use ApiPlatform\Metadata\Property\PropertyNameCollection;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use function Symfony\Component\String\u;
/**
* This decorator removes all camelCase property names from the property name collection, if a snake_case version exists.
* This is a fix for https://github.com/Part-DB/Part-DB-server/issues/862, as the openapi schema generator wrongly collects
* both camelCase and snake_case property names, which leads to duplicate properties in the schema.
* This seems to come from the fact that the openapi schema generator uses no serializerContext, which seems then to collect
* the getters too...
*/
#[AsDecorator('api_platform.metadata.property.name_collection_factory')]
class NormalizePropertyNameCollectionFactory implements PropertyNameCollectionFactoryInterface
{
public function __construct(private readonly PropertyNameCollectionFactoryInterface $decorated)
{
}
public function create(string $resourceClass, array $options = []): PropertyNameCollection
{
// Get the default properties from the decorated service
$propertyNames = $this->decorated->create($resourceClass, $options);
//Only become active in the context of the openapi schema generation
if (!isset($options['schema_type'])) {
return $propertyNames;
}
//If we are not in the jsonapi generator (which sets no serializer groups), return the property names as is
if (isset($options['serializer_groups'])) {
return $propertyNames;
}
//Remove all camelCase property names from the collection, if a snake_case version exists
$properties = iterator_to_array($propertyNames);
foreach ($properties as $property) {
if (str_contains($property, '_')) {
$camelized = u($property)->camel()->toString();
//If the camelized version exists, remove it from the collection
$index = array_search($camelized, $properties, true);
if ($index !== false) {
unset($properties[$index]);
}
}
}
return new PropertyNameCollection($properties);
}
}

View file

@ -73,9 +73,6 @@ class CleanAttachmentsCommand extends Command
//Ignore image cache folder //Ignore image cache folder
$finder->exclude('cache'); $finder->exclude('cache');
//Ignore automigration folder
$finder->exclude('.automigration-backup');
$fs = new Filesystem(); $fs = new Filesystem();
$file_list = []; $file_list = [];

View file

@ -1,136 +0,0 @@
<?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\Command\Attachments;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentUpload;
use App\Exceptions\AttachmentDownloadException;
use App\Services\Attachments\AttachmentManager;
use App\Services\Attachments\AttachmentSubmitHandler;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand('partdb:attachments:download', "Downloads all attachments which have only an external URL to the local filesystem.")]
class DownloadAttachmentsCommand extends Command
{
public function __construct(private readonly AttachmentSubmitHandler $attachmentSubmitHandler,
private EntityManagerInterface $entityManager)
{
parent::__construct();
}
public function configure(): void
{
$this->setHelp('This command downloads all attachments, which only have an external URL, to the local filesystem, so that you have an offline copy of the attachments.');
$this->addOption('--private', null, null, 'If set, the attachments will be downloaded to the private storage.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$qb = $this->entityManager->createQueryBuilder();
$qb->select('attachment')
->from(Attachment::class, 'attachment')
->where('attachment.external_path IS NOT NULL')
->andWhere('attachment.external_path != \'\'')
->andWhere('attachment.internal_path IS NULL');
$query = $qb->getQuery();
$attachments = $query->getResult();
if (count($attachments) === 0) {
$io->success('No attachments with external URL found.');
return Command::SUCCESS;
}
$io->note('Found ' . count($attachments) . ' attachments with external URL, that will be downloaded.');
//If the option --private is set, the attachments will be downloaded to the private storage.
$private = $input->getOption('private');
if ($private) {
if (!$io->confirm('Attachments will be downloaded to the private storage. Continue?')) {
return Command::SUCCESS;
}
} else {
if (!$io->confirm('Attachments will be downloaded to the public storage, where everybody knowing the correct URL can access it. Continue?')){
return Command::SUCCESS;
}
}
$progressBar = $io->createProgressBar(count($attachments));
$progressBar->setFormat("%current%/%max% [%bar%] %percent:3s%% %elapsed:16s%/%estimated:-16s% \n%message%");
$progressBar->setMessage('Starting download...');
$progressBar->start();
$errors = [];
foreach ($attachments as $attachment) {
/** @var Attachment $attachment */
$progressBar->setMessage(sprintf('%s (ID: %s) from %s', $attachment->getName(), $attachment->getID(), $attachment->getHost()));
$progressBar->advance();
try {
$attachmentUpload = new AttachmentUpload(file: null, downloadUrl: true, private: $private);
$this->attachmentSubmitHandler->handleUpload($attachment, $attachmentUpload);
//Write changes to the database
$this->entityManager->flush();
} catch (AttachmentDownloadException $e) {
$errors[] = [
'attachment' => $attachment,
'error' => $e->getMessage()
];
}
}
$progressBar->finish();
//Fix the line break after the progress bar
$io->newLine();
$io->newLine();
if (count($errors) > 0) {
$io->warning('Some attachments could not be downloaded:');
foreach ($errors as $error) {
$io->warning(sprintf("Attachment %s (ID %s) could not be downloaded from %s:\n%s",
$error['attachment']->getName(),
$error['attachment']->getID(),
$error['attachment']->getExternalPath(),
$error['error'])
);
}
} else {
$io->success('All attachments downloaded successfully.');
}
return Command::SUCCESS;
}
}

View file

@ -1,90 +0,0 @@
<?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\Command\Attachments;
use App\Entity\Attachments\Attachment;
use App\Services\Attachments\AttachmentSubmitHandler;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand('partdb:attachments:sanitize-svg', "Sanitize uploaded SVG files.")]
class SanitizeSVGAttachmentsCommand extends Command
{
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly AttachmentSubmitHandler $attachmentSubmitHandler, ?string $name = null)
{
parent::__construct($name);
}
public function configure(): void
{
$this->setHelp('This command allows to sanitize SVG files uploaded via attachments. This happens automatically since version 1.17.1, this command is intended to be used for older files.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->info('This command will sanitize all uploaded SVG files. This is only required if you have uploaded (untrusted) SVG files before version 1.17.1. If you are running a newer version, you don\'t need to run this command (again).');
if (!$io->confirm('Do you want to continue?', false)) {
$io->success('Command aborted.');
return Command::FAILURE;
}
$io->info('Sanitizing SVG files...');
//Finding all attachments with svg files
$qb = $this->entityManager->createQueryBuilder();
$qb->select('a')
->from(Attachment::class, 'a')
->where('a.internal_path LIKE :pattern ESCAPE \'#\'')
->orWhere('a.original_filename LIKE :pattern ESCAPE \'#\'')
->setParameter('pattern', '%.svg');
$attachments = $qb->getQuery()->getResult();
$io->note('Found '.count($attachments).' attachments with SVG files.');
if (count($attachments) === 0) {
$io->success('No SVG files found.');
return Command::FAILURE;
}
$io->info('Sanitizing SVG files...');
$io->progressStart(count($attachments));
foreach ($attachments as $attachment) {
/** @var Attachment $attachment */
$io->note('Sanitizing attachment '.$attachment->getId().' ('.($attachment->getFilename() ?? '???').')');
$this->attachmentSubmitHandler->sanitizeSVGAttachment($attachment);
$io->progressAdvance();
}
$io->progressFinish();
$io->success('Sanitization finished. All SVG files have been sanitized.');
return Command::SUCCESS;
}
}

View file

@ -79,7 +79,7 @@ class CheckRequirementsCommand extends Command
//Checking 32-bit system //Checking 32-bit system
if (PHP_INT_SIZE === 4) { if (PHP_INT_SIZE === 4) {
$io->warning('You are using a 32-bit system. You will have problems with working with dates after the year 2038, therefore a 64-bit system is recommended.'); $io->warning('You are using a 32-bit system. You will have problems with working with dates after the year 2038, therefore a 64-bit system is recommended.');
} elseif (PHP_INT_SIZE === 8) { //@phpstan-ignore-line //PHP_INT_SIZE is always 4 or 8 } elseif (PHP_INT_SIZE === 8) {
if (!$only_issues) { if (!$only_issues) {
$io->success('You are using a 64-bit system.'); $io->success('You are using a 64-bit system.');
} }

View file

@ -79,7 +79,6 @@ class ConvertBBCodeCommand extends Command
/** /**
* Returns a list which entities and which properties need to be checked. * Returns a list which entities and which properties need to be checked.
* @return array<class-string<AbstractNamedDBElement>, string[]>
*/ */
protected function getTargetsLists(): array protected function getTargetsLists(): array
{ {
@ -110,6 +109,7 @@ class ConvertBBCodeCommand extends Command
$class $class
)); ));
//Determine which entities of this type we need to modify //Determine which entities of this type we need to modify
/** @var EntityRepository $repo */
$repo = $this->em->getRepository($class); $repo = $this->em->getRepository($class);
$qb = $repo->createQueryBuilder('e') $qb = $repo->createQueryBuilder('e')
->select('e'); ->select('e');

View file

@ -35,7 +35,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand('partdb:users:enable|partdb:user:enable', 'Enables/Disable the login of one or more users')] #[AsCommand('partdb:users:enable|partdb:user:enable', 'Enables/Disable the login of one or more users')]
class UserEnableCommand extends Command class UserEnableCommand extends Command
{ {
public function __construct(protected EntityManagerInterface $entityManager, ?string $name = null) public function __construct(protected EntityManagerInterface $entityManager, string $name = null)
{ {
parent::__construct($name); parent::__construct($name);
} }

View file

@ -206,15 +206,12 @@ class UsersPermissionsCommand extends Command
return '<fg=green>Allow</>'; return '<fg=green>Allow</>';
} elseif ($permission_value === false) { } elseif ($permission_value === false) {
return '<fg=red>Disallow</>'; return '<fg=red>Disallow</>';
} } elseif ($permission_value === null && !$inherit) {
// Permission value is null by this point
elseif (!$inherit) {
return '<fg=blue>Inherit</>'; return '<fg=blue>Inherit</>';
} elseif ($inherit) { } elseif ($permission_value === null && $inherit) {
return '<fg=red>Disallow (Inherited)</>'; return '<fg=red>Disallow (Inherited)</>';
} }
//@phpstan-ignore-next-line This line is never reached, but PHPstorm complains otherwise
return '???'; return '???';
} }
} }

View file

@ -221,6 +221,7 @@ abstract class BaseAdminController extends AbstractController
} }
} }
/** @var AbstractPartsContainingRepository $repo */
$repo = $this->entityManager->getRepository($this->entity_class); $repo = $this->entityManager->getRepository($this->entity_class);
return $this->render($this->twig_template, [ return $this->render($this->twig_template, [
@ -396,7 +397,7 @@ abstract class BaseAdminController extends AbstractController
{ {
if ($entity instanceof AbstractPartsContainingDBElement) { if ($entity instanceof AbstractPartsContainingDBElement) {
/** @var AbstractPartsContainingRepository $repo */ /** @var AbstractPartsContainingRepository $repo */
$repo = $this->entityManager->getRepository($this->entity_class); //@phpstan-ignore-line $repo = $this->entityManager->getRepository($this->entity_class);
if ($repo->getPartsCount($entity) > 0) { if ($repo->getPartsCount($entity) > 0) {
$this->addFlash('error', t('entity.delete.must_not_contain_parts', ['%PATH%' => $entity->getFullPath()])); $this->addFlash('error', t('entity.delete.must_not_contain_parts', ['%PATH%' => $entity->getFullPath()]));
@ -467,11 +468,6 @@ abstract class BaseAdminController extends AbstractController
$this->denyAccessUnlessGranted('read', $entity); $this->denyAccessUnlessGranted('read', $entity);
$entities = $em->getRepository($this->entity_class)->findAll(); $entities = $em->getRepository($this->entity_class)->findAll();
if (count($entities) === 0) {
$this->addFlash('error', 'entity.export.flash.error.no_entities');
return $this->redirectToRoute($this->route_base.'_new');
}
return $exporter->exportEntityFromRequest($entities, $request); return $exporter->exportEntityFromRequest($entities, $request);
} }

View file

@ -51,15 +51,15 @@ class AttachmentFileController extends AbstractController
$this->denyAccessUnlessGranted('show_private', $attachment); $this->denyAccessUnlessGranted('show_private', $attachment);
} }
if (!$attachment->hasInternal()) { if ($attachment->isExternal()) {
throw $this->createNotFoundException('The file for this attachment is external and not stored locally!'); throw $this->createNotFoundException('The file for this attachment is external and can not stored locally!');
} }
if (!$helper->isInternalFileExisting($attachment)) { if (!$helper->isFileExisting($attachment)) {
throw $this->createNotFoundException('The file associated with the attachment is not existing!'); throw $this->createNotFoundException('The file associated with the attachment is not existing!');
} }
$file_path = $helper->toAbsoluteInternalFilePath($attachment); $file_path = $helper->toAbsoluteFilePath($attachment);
$response = new BinaryFileResponse($file_path); $response = new BinaryFileResponse($file_path);
//Set header content disposition, so that the file will be downloaded //Set header content disposition, so that the file will be downloaded
@ -80,15 +80,15 @@ class AttachmentFileController extends AbstractController
$this->denyAccessUnlessGranted('show_private', $attachment); $this->denyAccessUnlessGranted('show_private', $attachment);
} }
if (!$attachment->hasInternal()) { if ($attachment->isExternal()) {
throw $this->createNotFoundException('The file for this attachment is external and not stored locally!'); throw $this->createNotFoundException('The file for this attachment is external and can not stored locally!');
} }
if (!$helper->isInternalFileExisting($attachment)) { if (!$helper->isFileExisting($attachment)) {
throw $this->createNotFoundException('The file associated with the attachment is not existing!'); throw $this->createNotFoundException('The file associated with the attachment is not existing!');
} }
$file_path = $helper->toAbsoluteInternalFilePath($attachment); $file_path = $helper->toAbsoluteFilePath($attachment);
$response = new BinaryFileResponse($file_path); $response = new BinaryFileResponse($file_path);
//Set header content disposition, so that the file will be downloaded //Set header content disposition, so that the file will be downloaded

View file

@ -23,13 +23,10 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part; use App\Entity\Parts\Part;
use App\Form\InfoProviderSystem\PartSearchType; use App\Form\InfoProviderSystem\PartSearchType;
use App\Services\InfoProviderSystem\ExistingPartFinder;
use App\Services\InfoProviderSystem\PartInfoRetriever; use App\Services\InfoProviderSystem\PartInfoRetriever;
use App\Services\InfoProviderSystem\ProviderRegistry; use App\Services\InfoProviderSystem\ProviderRegistry;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -45,9 +42,7 @@ class InfoProviderController extends AbstractController
{ {
public function __construct(private readonly ProviderRegistry $providerRegistry, public function __construct(private readonly ProviderRegistry $providerRegistry,
private readonly PartInfoRetriever $infoRetriever, private readonly PartInfoRetriever $infoRetriever)
private readonly ExistingPartFinder $existingPartFinder
)
{ {
} }
@ -77,49 +72,21 @@ class InfoProviderController extends AbstractController
//When we are updating a part, use its name as keyword, to make searching easier //When we are updating a part, use its name as keyword, to make searching easier
//However we can only do this, if the form was not submitted yet //However we can only do this, if the form was not submitted yet
if ($update_target !== null && !$form->isSubmitted()) { if ($update_target !== null && !$form->isSubmitted()) {
//Use the provider reference if available, otherwise use the manufacturer product number $form->get('keyword')->setData($update_target->getName());
$keyword = $update_target->getProviderReference()->getProviderId() ?? $update_target->getManufacturerProductNumber();
//Or the name if both are not available
if ($keyword === "") {
$keyword = $update_target->getName();
}
$form->get('keyword')->setData($keyword);
//If we are updating a part, which already has a provider, preselect that provider in the form
if ($update_target->getProviderReference()->getProviderKey() !== null) {
try {
$form->get('providers')->setData([$this->providerRegistry->getProviderByKey($update_target->getProviderReference()->getProviderKey())]);
} catch (\InvalidArgumentException $e) {
//If the provider is not found, just ignore it
}
}
} }
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$keyword = $form->get('keyword')->getData(); $keyword = $form->get('keyword')->getData();
$providers = $form->get('providers')->getData(); $providers = $form->get('providers')->getData();
$dtos = [];
try { try {
$dtos = $this->infoRetriever->searchByKeyword(keyword: $keyword, providers: $providers); $results = $this->infoRetriever->searchByKeyword(keyword: $keyword, providers: $providers);
} catch (ClientException $e) { } catch (ClientException $e) {
$this->addFlash('error', t('info_providers.search.error.client_exception')); $this->addFlash('error', t('info_providers.search.error.client_exception'));
$this->addFlash('error',$e->getMessage()); $this->addFlash('error',$e->getMessage());
//Log the exception //Log the exception
$exceptionLogger->error('Error during info provider search: ' . $e->getMessage(), ['exception' => $e]); $exceptionLogger->error('Error during info provider search: ' . $e->getMessage(), ['exception' => $e]);
} }
// modify the array to an array of arrays that has a field for a matching local Part
// the advantage to use that format even when we don't look for local parts is that we
// always work with the same interface
$results = array_map(function ($result) {return ['dto' => $result, 'localPart' => null];}, $dtos);
if(!$update_target) {
foreach ($results as $index => $result) {
$results[$index]['localPart'] = $this->existingPartFinder->findFirstExisting($result['dto']);
}
}
} }
return $this->render('info_providers/search/part_search.html.twig', [ return $this->render('info_providers/search/part_search.html.twig', [

View file

@ -108,31 +108,8 @@ class LabelController extends AbstractController
$pdf_data = null; $pdf_data = null;
$filename = 'invalid.pdf'; $filename = 'invalid.pdf';
//Generate PDF either when the form is submitted and valid, or the form was not submit yet, and generate is set
if (($form->isSubmitted() && $form->isValid()) || ($generate && !$form->isSubmitted() && $profile instanceof LabelProfile)) { if (($form->isSubmitted() && $form->isValid()) || ($generate && !$form->isSubmitted() && $profile instanceof LabelProfile)) {
//Check if the label should be saved as profile
if ($form->get('save_profile')->isClicked() && $this->isGranted('@labels.create_profiles')) { //@phpstan-ignore-line Phpstan does not recognize the isClicked method
//Retrieve the profile name from the form
$new_name = $form->get('save_profile_name')->getData();
//ensure that the name is not empty
if ($new_name === '' || $new_name === null) {
$form->get('save_profile_name')->addError(new FormError($this->translator->trans('label_generator.profile_name_empty')));
goto render;
}
$profile = new LabelProfile();
$profile->setName($form->get('save_profile_name')->getData());
$profile->setOptions($form_options);
$this->em->persist($profile);
$this->em->flush();
$this->addFlash('success', 'label_generator.profile_saved');
return $this->redirectToRoute('label_dialog_profile', [
'profile' => $profile->getID(),
'target_id' => (string) $form->get('target_id')->getData()
]);
}
$target_id = (string) $form->get('target_id')->getData(); $target_id = (string) $form->get('target_id')->getData();
$targets = $this->findObjects($form_options->getSupportedElement(), $target_id); $targets = $this->findObjects($form_options->getSupportedElement(), $target_id);
if ($targets !== []) { if ($targets !== []) {
@ -155,7 +132,6 @@ class LabelController extends AbstractController
} }
} }
render:
return $this->render('label_system/dialog.html.twig', [ return $this->render('label_system/dialog.html.twig', [
'form' => $form, 'form' => $form,
'pdf_data' => $pdf_data, 'pdf_data' => $pdf_data,
@ -176,7 +152,7 @@ class LabelController extends AbstractController
{ {
$id_array = $this->rangeParser->parse($ids); $id_array = $this->rangeParser->parse($ids);
/** @var DBElementRepository<AbstractDBElement> $repo */ /** @var DBElementRepository $repo */
$repo = $this->em->getRepository($type->getEntityClass()); $repo = $this->em->getRepository($type->getEntityClass());
return $repo->getElementsFromIDArray($id_array); return $repo->getElementsFromIDArray($id_array);

View file

@ -229,10 +229,6 @@ class PartController extends AbstractController
$dto = $infoRetriever->getDetails($providerKey, $providerId); $dto = $infoRetriever->getDetails($providerKey, $providerId);
$new_part = $infoRetriever->dtoToPart($dto); $new_part = $infoRetriever->dtoToPart($dto);
if ($new_part->getCategory() === null || $new_part->getCategory()->getID() === null) {
$this->addFlash('warning', t("part.create_from_info_provider.no_category_yet"));
}
return $this->renderPartForm('new', $request, $new_part, [ return $this->renderPartForm('new', $request, $new_part, [
'info_provider_dto' => $dto, 'info_provider_dto' => $dto,
]); ]);

View file

@ -112,9 +112,8 @@ class PartImportExportController extends AbstractController
$ids = $request->query->get('ids', ''); $ids = $request->query->get('ids', '');
$parts = $this->partsTableActionHandler->idStringToArray($ids); $parts = $this->partsTableActionHandler->idStringToArray($ids);
if (count($parts) === 0) { if ($parts === []) {
$this->addFlash('error', 'entity.export.flash.error.no_entities'); throw new \RuntimeException('No parts found!');
return $this->redirectToRoute('homepage');
} }
//Ensure that we have access to the parts //Ensure that we have access to the parts

View file

@ -29,7 +29,6 @@ use App\DataTables\PartsDataTable;
use App\Entity\Parts\Category; use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint; use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer; use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part;
use App\Entity\Parts\StorageLocation; use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier; use App\Entity\Parts\Supplier;
use App\Exceptions\InvalidRegexException; use App\Exceptions\InvalidRegexException;
@ -44,11 +43,8 @@ use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
use function Symfony\Component\Translation\t;
class PartListsController extends AbstractController class PartListsController extends AbstractController
{ {
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly NodesListBuilder $nodesListBuilder, private readonly DataTableFactory $dataTableFactory, private readonly TranslatorInterface $translator) public function __construct(private readonly EntityManagerInterface $entityManager, private readonly NodesListBuilder $nodesListBuilder, private readonly DataTableFactory $dataTableFactory, private readonly TranslatorInterface $translator)
@ -64,7 +60,6 @@ class PartListsController extends AbstractController
$ids = $request->request->get('ids'); $ids = $request->request->get('ids');
$action = $request->request->get('action'); $action = $request->request->get('action');
$target = $request->request->get('target'); $target = $request->request->get('target');
$redirectResponse = null;
if (!$this->isCsrfTokenValid('table_action', $request->request->get('_token'))) { if (!$this->isCsrfTokenValid('table_action', $request->request->get('_token'))) {
$this->addFlash('error', 'csfr_invalid'); $this->addFlash('error', 'csfr_invalid');
@ -75,36 +70,17 @@ class PartListsController extends AbstractController
if (null === $action || null === $ids) { if (null === $action || null === $ids) {
$this->addFlash('error', 'part.table.actions.no_params_given'); $this->addFlash('error', 'part.table.actions.no_params_given');
} else { } else {
$errors = [];
$parts = $actionHandler->idStringToArray($ids); $parts = $actionHandler->idStringToArray($ids);
$redirectResponse = $actionHandler->handleAction($action, $parts, $target ? (int) $target : null, $redirect, $errors); $redirectResponse = $actionHandler->handleAction($action, $parts, $target ? (int) $target : null, $redirect);
//Save changes //Save changes
$this->entityManager->flush(); $this->entityManager->flush();
if (count($errors) === 0) {
$this->addFlash('success', 'part.table.actions.success'); $this->addFlash('success', 'part.table.actions.success');
} else {
$this->addFlash('error', t('part.table.actions.error', ['%count%' => count($errors)]));
//Create a flash message for each error
foreach ($errors as $error) {
/** @var Part $part */
$part = $error['part'];
$this->addFlash('error',
t('part.table.actions.error_detail', [
'%part_name%' => $part->getName(),
'%part_id%' => $part->getID(),
'%message%' => $error['message']
])
);
}
}
} }
//If the action handler returned a response, we use it, otherwise we redirect back to the previous page. //If the action handler returned a response, we use it, otherwise we redirect back to the previous page.
if ($redirectResponse !== null) { if (isset($redirectResponse) && $redirectResponse instanceof Response) {
return $redirectResponse; return $redirectResponse;
} }
@ -155,11 +131,7 @@ class PartListsController extends AbstractController
$filterForm->handleRequest($formRequest); $filterForm->handleRequest($formRequest);
$table = $this->dataTableFactory->createFromType( $table = $this->dataTableFactory->createFromType(PartsDataTable::class, array_merge(['filter' => $filter], $additional_table_vars))
PartsDataTable::class,
array_merge(['filter' => $filter], $additional_table_vars),
['lengthMenu' => PartsDataTable::LENGTH_MENU]
)
->handleRequest($request); ->handleRequest($request);
if ($table->isCallback()) { if ($table->isCallback()) {

View file

@ -42,10 +42,10 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Form\LabelSystem\ScanDialogType; use App\Form\LabelSystem\ScanDialogType;
use App\Services\LabelSystem\BarcodeScanner\BarcodeRedirector; use App\Services\LabelSystem\Barcodes\BarcodeScanHelper;
use App\Services\LabelSystem\BarcodeScanner\BarcodeScanHelper; use App\Services\LabelSystem\Barcodes\BarcodeRedirector;
use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType; use App\Services\LabelSystem\Barcodes\BarcodeScanResult;
use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult; use App\Services\LabelSystem\Barcodes\BarcodeSourceType;
use Doctrine\ORM\EntityNotFoundException; use Doctrine\ORM\EntityNotFoundException;
use InvalidArgumentException; use InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -77,22 +77,14 @@ class ScanController extends AbstractController
$mode = $form['mode']->getData(); $mode = $form['mode']->getData();
} }
$infoModeData = null;
if ($input !== null) { if ($input !== null) {
try { try {
$scan_result = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null); $scan_result = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null);
//Perform a redirect if the info mode is not enabled
if (!$form['info_mode']->getData()) {
try { try {
return $this->redirect($this->barcodeParser->getRedirectURL($scan_result)); return $this->redirect($this->barcodeParser->getRedirectURL($scan_result));
} catch (EntityNotFoundException) { } catch (EntityNotFoundException) {
$this->addFlash('success', 'scan.qr_not_found'); $this->addFlash('success', 'scan.qr_not_found');
} }
} else { //Otherwise retrieve infoModeData
$infoModeData = $scan_result->getDecodedForInfoMode();
}
} catch (InvalidArgumentException) { } catch (InvalidArgumentException) {
$this->addFlash('error', 'scan.format_unknown'); $this->addFlash('error', 'scan.format_unknown');
} }
@ -100,7 +92,6 @@ class ScanController extends AbstractController
return $this->render('label_system/scanner/scanner.html.twig', [ return $this->render('label_system/scanner/scanner.html.twig', [
'form' => $form, 'form' => $form,
'infoModeData' => $infoModeData,
]); ]);
} }
@ -118,7 +109,7 @@ class ScanController extends AbstractController
throw new InvalidArgumentException('Unknown type: '.$type); throw new InvalidArgumentException('Unknown type: '.$type);
} }
//Construct the scan result manually, as we don't have a barcode here //Construct the scan result manually, as we don't have a barcode here
$scan_result = new LocalBarcodeScanResult( $scan_result = new BarcodeScanResult(
target_type: BarcodeScanHelper::QR_TYPE_MAP[$type], target_type: BarcodeScanHelper::QR_TYPE_MAP[$type],
target_id: $id, target_id: $id,
//The routes are only used on the internal generated QR codes //The routes are only used on the internal generated QR codes

View file

@ -29,7 +29,6 @@ use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint; use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer; use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\StorageLocation;
use App\Entity\ProjectSystem\Project; use App\Entity\ProjectSystem\Project;
use App\Form\Type\Helper\StructuralEntityChoiceHelper; use App\Form\Type\Helper\StructuralEntityChoiceHelper;
use App\Services\Trees\NodesListBuilder; use App\Services\Trees\NodesListBuilder;
@ -79,12 +78,6 @@ class SelectAPIController extends AbstractController
return $this->getResponseForClass(Project::class, false); return $this->getResponseForClass(Project::class, false);
} }
#[Route(path: '/storage_location', name: 'select_storage_location')]
public function locations(): Response
{
return $this->getResponseForClass(StorageLocation::class, true);
}
#[Route(path: '/export_level', name: 'select_export_level')] #[Route(path: '/export_level', name: 'select_export_level')]
public function exportLevel(): Response public function exportLevel(): Response
{ {

View file

@ -61,10 +61,10 @@ class ToolsController extends AbstractController
'default_theme' => $this->getParameter('partdb.global_theme'), 'default_theme' => $this->getParameter('partdb.global_theme'),
'enabled_locales' => $this->getParameter('partdb.locale_menu'), 'enabled_locales' => $this->getParameter('partdb.locale_menu'),
'demo_mode' => $this->getParameter('partdb.demo_mode'), 'demo_mode' => $this->getParameter('partdb.demo_mode'),
'gdpr_compliance' => $this->getParameter('partdb.gdpr_compliance'), 'gpdr_compliance' => $this->getParameter('partdb.gdpr_compliance'),
'use_gravatar' => $this->getParameter('partdb.users.use_gravatar'), 'use_gravatar' => $this->getParameter('partdb.users.use_gravatar'),
'email_password_reset' => $this->getParameter('partdb.users.email_pw_reset'), 'email_password_reset' => $this->getParameter('partdb.users.email_pw_reset'),
'environment' => $this->getParameter('kernel.environment'), 'enviroment' => $this->getParameter('kernel.environment'),
'is_debug' => $this->getParameter('kernel.debug'), 'is_debug' => $this->getParameter('kernel.debug'),
'email_sender' => $this->getParameter('partdb.mail.sender_email'), 'email_sender' => $this->getParameter('partdb.mail.sender_email'),
'email_sender_name' => $this->getParameter('partdb.mail.sender_name'), 'email_sender_name' => $this->getParameter('partdb.mail.sender_name'),

View file

@ -22,7 +22,6 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Entity\Parameters\AbstractParameter;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use App\Entity\Attachments\Attachment; use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Category; use App\Entity\Parts\Category;
@ -93,7 +92,7 @@ class TypeaheadController extends AbstractController
/** /**
* This function map the parameter type to the class, so we can access its repository * This function map the parameter type to the class, so we can access its repository
* @return class-string<AbstractParameter> * @return class-string
*/ */
private function typeToParameterClass(string $type): string private function typeToParameterClass(string $type): string
{ {
@ -156,7 +155,7 @@ class TypeaheadController extends AbstractController
//Ensure user has the correct permissions //Ensure user has the correct permissions
$this->denyAccessUnlessGranted('read', $test_obj); $this->denyAccessUnlessGranted('read', $test_obj);
/** @var ParameterRepository<AbstractParameter> $repository */ /** @var ParameterRepository $repository */
$repository = $entityManager->getRepository($class); $repository = $entityManager->getRepository($class);
$data = $repository->autocompleteParamName($query); $data = $repository->autocompleteParamName($query);

View file

@ -240,10 +240,7 @@ class UserSettingsController extends AbstractController
$page_need_reload = true; $page_need_reload = true;
} }
if (!$form instanceof Form) { /** @var Form $form We need a form implementation for the next calls */
throw new RuntimeException('Form is not an instance of Form, so we cannot retrieve the clicked button!');
}
//Remove the avatar attachment from the user if requested //Remove the avatar attachment from the user if requested
if ($form->getClickedButton() && 'remove_avatar' === $form->getClickedButton()->getName() && $user->getMasterPictureAttachment() instanceof Attachment) { if ($form->getClickedButton() && 'remove_avatar' === $form->getClickedButton()->getName() && $user->getMasterPictureAttachment() instanceof Attachment) {
$em->remove($user->getMasterPictureAttachment()); $em->remove($user->getMasterPictureAttachment());

View file

@ -41,7 +41,7 @@ class APITokenFixtures extends Fixture implements DependentFixtureInterface
public function load(ObjectManager $manager): void public function load(ObjectManager $manager): void
{ {
/** @var User $admin_user */ /** @var User $admin_user */
$admin_user = $this->getReference(UserFixtures::ADMIN, User::class); $admin_user = $this->getReference(UserFixtures::ADMIN);
$read_only_token = new ApiToken(); $read_only_token = new ApiToken();
$read_only_token->setUser($admin_user); $read_only_token->setUser($admin_user);

View file

@ -35,7 +35,7 @@ use Doctrine\Persistence\ObjectManager;
class LogEntryFixtures extends Fixture implements DependentFixtureInterface class LogEntryFixtures extends Fixture implements DependentFixtureInterface
{ {
public function load(ObjectManager $manager): void public function load(ObjectManager $manager)
{ {
$this->createCategoryEntries($manager); $this->createCategoryEntries($manager);
$this->createDeletedCategory($manager); $this->createDeletedCategory($manager);

View file

@ -106,7 +106,7 @@ class PartFixtures extends Fixture implements DependentFixtureInterface
$partLot2->setComment('Test'); $partLot2->setComment('Test');
$partLot2->setNeedsRefill(true); $partLot2->setNeedsRefill(true);
$partLot2->setStorageLocation($manager->find(StorageLocation::class, 3)); $partLot2->setStorageLocation($manager->find(StorageLocation::class, 3));
$partLot2->setUserBarcode('lot2_vendor_barcode'); $partLot2->setVendorBarcode('lot2_vendor_barcode');
$part->addPartLot($partLot2); $part->addPartLot($partLot2);
$orderdetail = new Orderdetail(); $orderdetail = new Orderdetail();
@ -131,7 +131,7 @@ class PartFixtures extends Fixture implements DependentFixtureInterface
$attachment = new PartAttachment(); $attachment = new PartAttachment();
$attachment->setName('Test2'); $attachment->setName('Test2');
$attachment->setInternalPath('invalid'); $attachment->setPath('invalid');
$attachment->setShowInTable(true); $attachment->setShowInTable(true);
$attachment->setAttachmentType($manager->find(AttachmentType::class, 1)); $attachment->setAttachmentType($manager->find(AttachmentType::class, 1));
$part->addAttachment($attachment); $part->addAttachment($attachment);

View file

@ -22,7 +22,6 @@ declare(strict_types=1);
namespace App\DataFixtures; namespace App\DataFixtures;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User; use App\Entity\UserSystem\User;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Common\DataFixtures\DependentFixtureInterface;
@ -42,7 +41,7 @@ class UserFixtures extends Fixture implements DependentFixtureInterface
{ {
$anonymous = new User(); $anonymous = new User();
$anonymous->setName('anonymous'); $anonymous->setName('anonymous');
$anonymous->setGroup($this->getReference(GroupFixtures::READONLY, Group::class)); $anonymous->setGroup($this->getReference(GroupFixtures::READONLY));
$anonymous->setNeedPwChange(false); $anonymous->setNeedPwChange(false);
$anonymous->setPassword($this->encoder->hashPassword($anonymous, 'test')); $anonymous->setPassword($this->encoder->hashPassword($anonymous, 'test'));
$manager->persist($anonymous); $manager->persist($anonymous);
@ -51,7 +50,7 @@ class UserFixtures extends Fixture implements DependentFixtureInterface
$admin->setName('admin'); $admin->setName('admin');
$admin->setPassword($this->encoder->hashPassword($admin, 'test')); $admin->setPassword($this->encoder->hashPassword($admin, 'test'));
$admin->setNeedPwChange(false); $admin->setNeedPwChange(false);
$admin->setGroup($this->getReference(GroupFixtures::ADMINS, Group::class)); $admin->setGroup($this->getReference(GroupFixtures::ADMINS));
$manager->persist($admin); $manager->persist($admin);
$this->addReference(self::ADMIN, $admin); $this->addReference(self::ADMIN, $admin);
@ -61,7 +60,7 @@ class UserFixtures extends Fixture implements DependentFixtureInterface
$user->setEmail('user@invalid.invalid'); $user->setEmail('user@invalid.invalid');
$user->setFirstName('Test')->setLastName('User'); $user->setFirstName('Test')->setLastName('User');
$user->setPassword($this->encoder->hashPassword($user, 'test')); $user->setPassword($this->encoder->hashPassword($user, 'test'));
$user->setGroup($this->getReference(GroupFixtures::USERS, Group::class)); $user->setGroup($this->getReference(GroupFixtures::USERS));
$manager->persist($user); $manager->persist($user);
$noread = new User(); $noread = new User();

View file

@ -54,7 +54,7 @@ class TwoStepORMAdapter extends ORMAdapter
private \Closure|null $query_modifier = null; private \Closure|null $query_modifier = null;
public function __construct(?ManagerRegistry $registry = null) public function __construct(ManagerRegistry $registry = null)
{ {
parent::__construct($registry); parent::__construct($registry);
$this->detailQueryCallable = static function (QueryBuilder $qb, array $ids): never { $this->detailQueryCallable = static function (QueryBuilder $qb, array $ids): never {

View file

@ -50,8 +50,8 @@ final class AttachmentDataTable implements DataTableTypeInterface
{ {
$dataTable->add('dont_matter', RowClassColumn::class, [ $dataTable->add('dont_matter', RowClassColumn::class, [
'render' => function ($value, Attachment $context): string { 'render' => function ($value, Attachment $context): string {
//Mark attachments yellow which have an internal file linked that doesn't exist //Mark attachments with missing files yellow
if($context->hasInternal() && !$this->attachmentHelper->isInternalFileExisting($context)){ if(!$this->attachmentHelper->isFileExisting($context)){
return 'table-warning'; return 'table-warning';
} }
@ -64,8 +64,8 @@ final class AttachmentDataTable implements DataTableTypeInterface
'className' => 'no-colvis', 'className' => 'no-colvis',
'render' => function ($value, Attachment $context): string { 'render' => function ($value, Attachment $context): string {
if ($context->isPicture() if ($context->isPicture()
&& $this->attachmentHelper->isInternalFileExisting($context)) { && !$context->isExternal()
&& $this->attachmentHelper->isFileExisting($context)) {
$title = htmlspecialchars($context->getName()); $title = htmlspecialchars($context->getName());
if ($context->getFilename()) { if ($context->getFilename()) {
$title .= ' ('.htmlspecialchars($context->getFilename()).')'; $title .= ' ('.htmlspecialchars($context->getFilename()).')';
@ -93,6 +93,26 @@ final class AttachmentDataTable implements DataTableTypeInterface
$dataTable->add('name', TextColumn::class, [ $dataTable->add('name', TextColumn::class, [
'label' => 'attachment.edit.name', 'label' => 'attachment.edit.name',
'orderField' => 'NATSORT(attachment.name)', 'orderField' => 'NATSORT(attachment.name)',
'render' => function ($value, Attachment $context) {
//Link to external source
if ($context->isExternal()) {
return sprintf(
'<a href="%s" class="link-external">%s</a>',
htmlspecialchars((string) $context->getURL()),
htmlspecialchars($value)
);
}
if ($this->attachmentHelper->isFileExisting($context)) {
return sprintf(
'<a href="%s" target="_blank" data-no-ajax>%s</a>',
$this->entityURLGenerator->viewURL($context),
htmlspecialchars($value)
);
}
return $value;
},
]); ]);
$dataTable->add('attachment_type', TextColumn::class, [ $dataTable->add('attachment_type', TextColumn::class, [
@ -116,60 +136,25 @@ final class AttachmentDataTable implements DataTableTypeInterface
), ),
]); ]);
$dataTable->add('internal_link', TextColumn::class, [ $dataTable->add('filename', TextColumn::class, [
'label' => 'attachment.table.internal_file', 'label' => $this->translator->trans('attachment.table.filename'),
'propertyPath' => 'filename', 'propertyPath' => 'filename',
'orderField' => 'NATSORT(attachment.original_filename)',
'render' => function ($value, Attachment $context) {
if ($this->attachmentHelper->isInternalFileExisting($context)) {
return sprintf(
'<a href="%s" target="_blank" data-no-ajax>%s</a>',
$this->entityURLGenerator->viewURL($context),
htmlspecialchars($value)
);
}
return $value;
}
]);
$dataTable->add('external_link', TextColumn::class, [
'label' => 'attachment.table.external_link',
'propertyPath' => 'host',
'orderField' => 'attachment.external_path',
'render' => function ($value, Attachment $context) {
if ($context->hasExternal()) {
return sprintf(
'<a href="%s" class="link-external" title="%s" target="_blank" rel="noopener">%s</a>',
htmlspecialchars((string) $context->getExternalPath()),
htmlspecialchars((string) $context->getExternalPath()),
htmlspecialchars($value),
);
}
return $value;
}
]); ]);
$dataTable->add('filesize', TextColumn::class, [ $dataTable->add('filesize', TextColumn::class, [
'label' => $this->translator->trans('attachment.table.filesize'), 'label' => $this->translator->trans('attachment.table.filesize'),
'render' => function ($value, Attachment $context) { 'render' => function ($value, Attachment $context) {
if (!$context->hasInternal()) { if ($context->isExternal()) {
return sprintf( return sprintf(
'<span class="badge bg-primary"> '<span class="badge bg-primary">
<i class="fas fa-globe fa-fw"></i>%s <i class="fas fa-globe fa-fw"></i>%s
</span>', </span>',
$this->translator->trans('attachment.external_only') $this->translator->trans('attachment.external')
); );
} }
if ($this->attachmentHelper->isInternalFileExisting($context)) { if ($this->attachmentHelper->isFileExisting($context)) {
return sprintf( return $this->attachmentHelper->getHumanFileSize($context);
'<span class="badge bg-secondary">
<i class="fas fa-hdd fa-fw"></i> %s
</span>',
$this->attachmentHelper->getHumanFileSize($context)
);
} }
return sprintf( return sprintf(

View file

@ -45,9 +45,6 @@ class AttachmentFilter implements FilterInterface
public readonly DateTimeConstraint $lastModified; public readonly DateTimeConstraint $lastModified;
public readonly DateTimeConstraint $addedDate; public readonly DateTimeConstraint $addedDate;
public readonly TextConstraint $originalFileName;
public readonly TextConstraint $externalLink;
public function __construct(NodesListBuilder $nodesListBuilder) public function __construct(NodesListBuilder $nodesListBuilder)
{ {
@ -58,9 +55,6 @@ class AttachmentFilter implements FilterInterface
$this->lastModified = new DateTimeConstraint('attachment.lastModified'); $this->lastModified = new DateTimeConstraint('attachment.lastModified');
$this->addedDate = new DateTimeConstraint('attachment.addedDate'); $this->addedDate = new DateTimeConstraint('attachment.addedDate');
$this->showInTable = new BooleanConstraint('attachment.show_in_table'); $this->showInTable = new BooleanConstraint('attachment.show_in_table');
$this->originalFileName = new TextConstraint('attachment.original_filename');
$this->externalLink = new TextConstraint('attachment.external_path');
} }
public function apply(QueryBuilder $queryBuilder): void public function apply(QueryBuilder $queryBuilder): void

View file

@ -45,7 +45,7 @@ abstract class AbstractConstraint implements FilterInterface
* @var string The property where this BooleanConstraint should apply to * @var string The property where this BooleanConstraint should apply to
*/ */
protected string $property, protected string $property,
?string $identifier = null) string $identifier = null)
{ {
$this->identifier = $identifier ?? $this->generateParameterIdentifier($property); $this->identifier = $identifier ?? $this->generateParameterIdentifier($property);
} }

View file

@ -28,7 +28,7 @@ class BooleanConstraint extends AbstractConstraint
{ {
public function __construct( public function __construct(
string $property, string $property,
?string $identifier = null, string $identifier = null,
/** @var bool|null The value of our constraint */ /** @var bool|null The value of our constraint */
protected ?bool $value = null protected ?bool $value = null
) )

View file

@ -34,7 +34,7 @@ class DateTimeConstraint extends AbstractConstraint
public function __construct( public function __construct(
string $property, string $property,
?string $identifier = null, string $identifier = null,
/** /**
* The value1 used for comparison (this is the main one used for all mono-value comparisons) * The value1 used for comparison (this is the main one used for all mono-value comparisons)
*/ */

View file

@ -46,7 +46,7 @@ class EntityConstraint extends AbstractConstraint
public function __construct(protected ?NodesListBuilder $nodesListBuilder, public function __construct(protected ?NodesListBuilder $nodesListBuilder,
protected string $class, protected string $class,
string $property, string $property,
?string $identifier = null, string $identifier = null,
protected ?AbstractDBElement $value = null, protected ?AbstractDBElement $value = null,
protected ?string $operator = null) protected ?string $operator = null)
{ {

View file

@ -31,7 +31,7 @@ class NumberConstraint extends AbstractConstraint
public function __construct( public function __construct(
string $property, string $property,
?string $identifier = null, string $identifier = null,
/** /**
* The value1 used for comparison (this is the main one used for all mono-value comparisons) * The value1 used for comparison (this is the main one used for all mono-value comparisons)
*/ */

View file

@ -28,7 +28,7 @@ use Doctrine\ORM\QueryBuilder;
class LessThanDesiredConstraint extends BooleanConstraint class LessThanDesiredConstraint extends BooleanConstraint
{ {
public function __construct(?string $property = null, ?string $identifier = null, ?bool $default_value = null) public function __construct(string $property = null, string $identifier = null, ?bool $default_value = null)
{ {
parent::__construct($property ?? '( parent::__construct($property ?? '(
SELECT COALESCE(SUM(ld_partLot.amount), 0.0) SELECT COALESCE(SUM(ld_partLot.amount), 0.0)

View file

@ -30,7 +30,7 @@ class TagsConstraint extends AbstractConstraint
{ {
final public const ALLOWED_OPERATOR_VALUES = ['ANY', 'ALL', 'NONE']; final public const ALLOWED_OPERATOR_VALUES = ['ANY', 'ALL', 'NONE'];
public function __construct(string $property, ?string $identifier = null, public function __construct(string $property, string $identifier = null,
protected ?string $value = null, protected ?string $value = null,
protected ?string $operator = '') protected ?string $operator = '')
{ {
@ -93,10 +93,10 @@ class TagsConstraint extends AbstractConstraint
$expr = $queryBuilder->expr(); $expr = $queryBuilder->expr();
$tmp = $expr->orX( $tmp = $expr->orX(
'ILIKE(' . $this->property . ', :' . $tag_identifier_prefix . '_1) = TRUE', $expr->like($this->property, ':' . $tag_identifier_prefix . '_1'),
'ILIKE(' . $this->property . ', :' . $tag_identifier_prefix . '_2) = TRUE', $expr->like($this->property, ':' . $tag_identifier_prefix . '_2'),
'ILIKE(' . $this->property . ', :' . $tag_identifier_prefix . '_3) = TRUE', $expr->like($this->property, ':' . $tag_identifier_prefix . '_3'),
'ILIKE(' . $this->property . ', :' . $tag_identifier_prefix . '_4) = TRUE', $expr->eq($this->property, ':' . $tag_identifier_prefix . '_4'),
); );
//Set the parameters for the LIKE expression, in each variation of the tag (so with a comma, at the end, at the beginning, and on both ends, and equaling the tag) //Set the parameters for the LIKE expression, in each variation of the tag (so with a comma, at the end, at the beginning, and on both ends, and equaling the tag)
@ -133,7 +133,6 @@ class TagsConstraint extends AbstractConstraint
return; return;
} }
//@phpstan-ignore-next-line Keep this check to ensure that everything has the same structure even if we add a new operator
if ($this->operator === 'NONE') { if ($this->operator === 'NONE') {
$queryBuilder->andWhere($queryBuilder->expr()->not($queryBuilder->expr()->orX(...$tagsExpressions))); $queryBuilder->andWhere($queryBuilder->expr()->not($queryBuilder->expr()->orX(...$tagsExpressions)));
return; return;

View file

@ -32,7 +32,7 @@ class TextConstraint extends AbstractConstraint
/** /**
* @param string $value * @param string $value
*/ */
public function __construct(string $property, ?string $identifier = null, /** public function __construct(string $property, string $identifier = null, /**
* @var string|null The value to compare to * @var string|null The value to compare to
*/ */
protected ?string $value = null, /** protected ?string $value = null, /**
@ -107,8 +107,7 @@ class TextConstraint extends AbstractConstraint
} }
if ($like_value !== null) { if ($like_value !== null) {
$queryBuilder->andWhere(sprintf('ILIKE(%s, :%s) = TRUE', $this->property, $this->identifier)); $this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, 'LIKE', $like_value);
$queryBuilder->setParameter($this->identifier, $like_value);
return; return;
} }

View file

@ -21,6 +21,7 @@ declare(strict_types=1);
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
namespace App\DataTables\Filters; namespace App\DataTables\Filters;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
class PartSearchFilter implements FilterInterface class PartSearchFilter implements FilterInterface
@ -131,15 +132,15 @@ class PartSearchFilter implements FilterInterface
return sprintf("REGEXP(%s, :search_query) = TRUE", $field); return sprintf("REGEXP(%s, :search_query) = TRUE", $field);
} }
return sprintf("ILIKE(%s, :search_query) = TRUE", $field); return sprintf("%s LIKE :search_query", $field);
}, $fields_to_search); }, $fields_to_search);
//Add Or concatenation of the expressions to our query //Add Or concatation of the expressions to our query
$queryBuilder->andWhere( $queryBuilder->andWhere(
$queryBuilder->expr()->orX(...$expressions) $queryBuilder->expr()->orX(...$expressions)
); );
//For regex, we pass the query as is, for like we add % to the start and end as wildcards //For regex we pass the query as is, for like we add % to the start and end as wildcards
if ($this->regex) { if ($this->regex) {
$queryBuilder->setParameter('search_query', $this->keyword); $queryBuilder->setParameter('search_query', $this->keyword);
} else { } else {

View file

@ -57,8 +57,6 @@ use Symfony\Contracts\Translation\TranslatorInterface;
final class PartsDataTable implements DataTableTypeInterface final class PartsDataTable implements DataTableTypeInterface
{ {
const LENGTH_MENU = [[10, 25, 50, 100, -1], [10, 25, 50, 100, "All"]];
public function __construct( public function __construct(
private readonly EntityURLGenerator $urlGenerator, private readonly EntityURLGenerator $urlGenerator,
private readonly TranslatorInterface $translator, private readonly TranslatorInterface $translator,

View file

@ -87,14 +87,16 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
if(!$context->getPart() instanceof Part) { if(!$context->getPart() instanceof Part) {
return htmlspecialchars((string) $context->getName()); return htmlspecialchars((string) $context->getName());
} }
if($context->getPart() instanceof Part) {
//Part exists if we reach this point
$tmp = $this->partDataTableHelper->renderName($context->getPart()); $tmp = $this->partDataTableHelper->renderName($context->getPart());
if($context->getName() !== null && $context->getName() !== '') { if($context->getName() !== null && $context->getName() !== '') {
$tmp .= '<br><b>'.htmlspecialchars($context->getName()).'</b>'; $tmp .= '<br><b>'.htmlspecialchars($context->getName()).'</b>';
} }
return $tmp; return $tmp;
}
//@phpstan-ignore-next-line
throw new \RuntimeException('This should never happen!');
}, },
]) ])
->add('ipn', TextColumn::class, [ ->add('ipn', TextColumn::class, [

View file

@ -1,71 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 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\Doctrine\Functions;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\DBAL\Platforms\SQLitePlatform;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\TokenType;
/**
* A platform invariant version of the case-insensitive LIKE operation.
* On MySQL and SQLite this is the normal LIKE, but on PostgreSQL it is the ILIKE operator.
*/
class ILike extends FunctionNode
{
public $value = null;
public $expr = null;
public function parse(Parser $parser): void
{
$parser->match(TokenType::T_IDENTIFIER);
$parser->match(TokenType::T_OPEN_PARENTHESIS);
$this->value = $parser->StringPrimary();
$parser->match(TokenType::T_COMMA);
$this->expr = $parser->StringExpression();
$parser->match(TokenType::T_CLOSE_PARENTHESIS);
}
public function getSql(SqlWalker $sqlWalker): string
{
$platform = $sqlWalker->getConnection()->getDatabasePlatform();
//
if ($platform instanceof AbstractMySQLPlatform || $platform instanceof SQLitePlatform) {
$operator = 'LIKE';
} elseif ($platform instanceof PostgreSQLPlatform) {
//Use the case-insensitive operator, to have the same behavior as MySQL
$operator = 'ILIKE';
} else {
throw new \RuntimeException('Platform ' . gettype($platform) . ' does not support case insensitive like expressions.');
}
return '(' . $this->value->dispatch($sqlWalker) . ' ' . $operator . ' ' . $this->expr->dispatch($sqlWalker) . ')';
}
}

View file

@ -44,15 +44,17 @@ class SQLiteRegexExtensionMiddlewareDriver extends AbstractDriverMiddleware
$native_connection = $connection->getNativeConnection(); $native_connection = $connection->getNativeConnection();
//Ensure that the function really exists on the connection, as it is marked as experimental according to PHP documentation //Ensure that the function really exists on the connection, as it is marked as experimental according to PHP documentation
if($native_connection instanceof \PDO) { if($native_connection instanceof \PDO && method_exists($native_connection, 'sqliteCreateFunction' )) {
$native_connection->sqliteCreateFunction('REGEXP', self::regexp(...), 2, \PDO::SQLITE_DETERMINISTIC); $native_connection->sqliteCreateFunction('REGEXP', self::regexp(...), 2, \PDO::SQLITE_DETERMINISTIC);
$native_connection->sqliteCreateFunction('FIELD', self::field(...), -1, \PDO::SQLITE_DETERMINISTIC); $native_connection->sqliteCreateFunction('FIELD', self::field(...), -1, \PDO::SQLITE_DETERMINISTIC);
$native_connection->sqliteCreateFunction('FIELD2', self::field2(...), 2, \PDO::SQLITE_DETERMINISTIC); $native_connection->sqliteCreateFunction('FIELD2', self::field2(...), 2, \PDO::SQLITE_DETERMINISTIC);
//Create a new collation for natural sorting //Create a new collation for natural sorting
if (method_exists($native_connection, 'sqliteCreateCollation')) {
$native_connection->sqliteCreateCollation('NATURAL_CMP', strnatcmp(...)); $native_connection->sqliteCreateCollation('NATURAL_CMP', strnatcmp(...));
} }
} }
}
return $connection; return $connection;

View file

@ -44,7 +44,7 @@ class DoNotUsePurgerFactory implements PurgerFactory
throw new \LogicException('Do not use doctrine:fixtures:load directly. Use partdb:fixtures:load instead!'); throw new \LogicException('Do not use doctrine:fixtures:load directly. Use partdb:fixtures:load instead!');
} }
public function setEntityManager(EntityManagerInterface $em): void public function setEntityManager(EntityManagerInterface $em)
{ {
// TODO: Implement setEntityManager() method. // TODO: Implement setEntityManager() method.
} }

View file

@ -33,24 +33,23 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use App\ApiPlatform\DocumentedAPIProperties\DocumentedAPIProperty; use App\ApiPlatform\DocumentedAPIProperty;
use App\ApiPlatform\Filter\EntityFilter; use App\ApiPlatform\Filter\EntityFilter;
use App\ApiPlatform\Filter\LikeFilter; use App\ApiPlatform\Filter\LikeFilter;
use App\ApiPlatform\HandleAttachmentsUploadsProcessor; use App\ApiPlatform\HandleAttachmentsUploadsProcessor;
use App\Entity\Base\AbstractNamedDBElement;
use App\EntityListeners\AttachmentDeleteListener;
use App\Repository\AttachmentRepository; use App\Repository\AttachmentRepository;
use App\Validator\Constraints\Selectable; use App\EntityListeners\AttachmentDeleteListener;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractNamedDBElement;
use App\Validator\Constraints\Selectable;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
use LogicException;
use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName; use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Serializer\Attribute\DiscriminatorMap; use Symfony\Component\Serializer\Attribute\DiscriminatorMap;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
use function in_array; use function in_array;
use InvalidArgumentException;
use LogicException;
/** /**
* Class Attachment. * Class Attachment.
@ -79,16 +78,11 @@ use function in_array;
denormalizationContext: ['groups' => ['attachment:write', 'attachment:write:standalone', 'api:basic:write'], 'openapi_definition_name' => 'Write'], denormalizationContext: ['groups' => ['attachment:write', 'attachment:write:standalone', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
processor: HandleAttachmentsUploadsProcessor::class, processor: HandleAttachmentsUploadsProcessor::class,
)] )]
//This property is added by the denormalizer in order to resolve the placeholder #[DocumentedAPIProperty(schemaName: 'Attachment-Read', property: 'media_url', type: 'string', nullable: true,
#[DocumentedAPIProperty( description: 'The URL to the file, where the attachment file can be downloaded. This can be an internal or external URL.',
schemaName: 'Attachment-Read', property: 'internal_path', type: 'string', nullable: false, example: '/media/part/2/bc547-6508afa5a79c8.pdf')]
description: 'The URL to the internally saved copy of the file, if one exists', #[DocumentedAPIProperty(schemaName: 'Attachment-Read', property: 'thumbnail_url', type: 'string', nullable: true,
example: '/media/part/2/bc547-6508afa5a79c8.pdf' description: 'The URL to a thumbnail version of this file. This only exists for internal picture attachments.')]
)]
#[DocumentedAPIProperty(
schemaName: 'Attachment-Read', property: 'thumbnail_url', type: 'string', nullable: true,
description: 'The URL to a thumbnail version of this file. This only exists for internal picture attachments.'
)]
#[ApiFilter(LikeFilter::class, properties: ["name"])] #[ApiFilter(LikeFilter::class, properties: ["name"])]
#[ApiFilter(EntityFilter::class, properties: ["attachment_type"])] #[ApiFilter(EntityFilter::class, properties: ["attachment_type"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)] #[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
@ -97,8 +91,8 @@ use function in_array;
#[DiscriminatorMap(typeProperty: '_type', mapping: self::API_DISCRIMINATOR_MAP)] #[DiscriminatorMap(typeProperty: '_type', mapping: self::API_DISCRIMINATOR_MAP)]
abstract class Attachment extends AbstractNamedDBElement abstract class Attachment extends AbstractNamedDBElement
{ {
private const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'Device' => ProjectAttachment::class, private const ORM_DISCRIMINATOR_MAP = ['PartDB\Part' => PartAttachment::class, 'Part' => PartAttachment::class,
'AttachmentType' => AttachmentTypeAttachment::class, 'PartDB\Device' => ProjectAttachment::class, 'Device' => ProjectAttachment::class, 'AttachmentType' => AttachmentTypeAttachment::class,
'Category' => CategoryAttachment::class, 'Footprint' => FootprintAttachment::class, 'Manufacturer' => ManufacturerAttachment::class, 'Category' => CategoryAttachment::class, 'Footprint' => FootprintAttachment::class, 'Manufacturer' => ManufacturerAttachment::class,
'Currency' => CurrencyAttachment::class, 'Group' => GroupAttachment::class, 'MeasurementUnit' => MeasurementUnitAttachment::class, 'Currency' => CurrencyAttachment::class, 'Group' => GroupAttachment::class, 'MeasurementUnit' => MeasurementUnitAttachment::class,
'Storelocation' => StorageLocationAttachment::class, 'Supplier' => SupplierAttachment::class, 'Storelocation' => StorageLocationAttachment::class, 'Supplier' => SupplierAttachment::class,
@ -125,6 +119,10 @@ abstract class Attachment extends AbstractNamedDBElement
*/ */
final public const MODEL_EXTS = ['x3d']; final public const MODEL_EXTS = ['x3d'];
/**
* When the path begins with one of the placeholders.
*/
final public const INTERNAL_PLACEHOLDER = ['%BASE%', '%MEDIA%', '%SECURE%'];
/** /**
* @var array placeholders for attachments which using built in files * @var array placeholders for attachments which using built in files
@ -154,21 +152,10 @@ abstract class Attachment extends AbstractNamedDBElement
protected ?string $original_filename = null; protected ?string $original_filename = null;
/** /**
* @var string|null If a copy of the file is stored internally, the path to the file relative to a placeholder * @var string The path to the file relative to a placeholder path like %MEDIA%
* path like %MEDIA%
*/ */
#[ORM\Column(type: Types::STRING, nullable: true)] #[ORM\Column(name: 'path', type: Types::STRING)]
protected ?string $internal_path = null; protected string $path = '';
/**
* @var string|null The path to the external source if the file is stored externally or was downloaded from an
* external source. Null if there is no external source.
*/
#[ORM\Column(type: Types::STRING, nullable: true)]
#[Groups(['attachment:read'])]
#[ApiProperty(example: 'http://example.com/image.jpg')]
protected ?string $external_path = null;
/** /**
* @var string the name of this element * @var string the name of this element
@ -250,7 +237,7 @@ abstract class Attachment extends AbstractNamedDBElement
/** /**
* Check if this attachment is a picture (analyse the file's extension). * Check if this attachment is a picture (analyse the file's extension).
* If the link is only external and doesn't contain an extension, it is assumed that this is true. * If the link is external, it is assumed that this is true.
* *
* @return bool * true if the file extension is a picture extension * @return bool * true if the file extension is a picture extension
* * otherwise false * * otherwise false
@ -258,67 +245,54 @@ abstract class Attachment extends AbstractNamedDBElement
#[Groups(['attachment:read'])] #[Groups(['attachment:read'])]
public function isPicture(): bool public function isPicture(): bool
{ {
if($this->hasInternal()){ if ($this->isExternal()) {
$extension = pathinfo($this->getInternalPath(), PATHINFO_EXTENSION);
return in_array(strtolower($extension), static::PICTURE_EXTS, true);
}
if ($this->hasExternal()) {
//Check if we can extract a file extension from the URL //Check if we can extract a file extension from the URL
$extension = pathinfo(parse_url($this->getExternalPath(), PHP_URL_PATH) ?? '', PATHINFO_EXTENSION); $extension = pathinfo(parse_url($this->path, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION);
//If no extension is found or it is known picture extension, we assume that this is a picture extension //If no extension is found or it is known picture extension, we assume that this is a picture extension
return $extension === '' || in_array(strtolower($extension), static::PICTURE_EXTS, true); return $extension === '' || in_array(strtolower($extension), static::PICTURE_EXTS, true);
} }
//File doesn't have an internal, nor an external copy. This shouldn't happen, but it certainly isn't a picture...
return false; $extension = pathinfo($this->getPath(), PATHINFO_EXTENSION);
return in_array(strtolower($extension), static::PICTURE_EXTS, true);
} }
/** /**
* Check if this attachment is a 3D model and therefore can be directly shown to user. * Check if this attachment is a 3D model and therefore can be directly shown to user.
* If no internal copy exists, false is returned (3D Models must be internal). * If the attachment is external, false is returned (3D Models must be internal).
*/ */
#[Groups(['attachment:read'])] #[Groups(['attachment:read'])]
#[SerializedName('3d_model')] #[SerializedName('3d_model')]
public function is3DModel(): bool public function is3DModel(): bool
{ {
//We just assume that 3D Models are internally saved, otherwise we get problems loading them. //We just assume that 3D Models are internally saved, otherwise we get problems loading them.
if (!$this->hasInternal()) { if ($this->isExternal()) {
return false; return false;
} }
$extension = pathinfo($this->getInternalPath(), PATHINFO_EXTENSION); $extension = pathinfo($this->getPath(), PATHINFO_EXTENSION);
return in_array(strtolower($extension), static::MODEL_EXTS, true); return in_array(strtolower($extension), static::MODEL_EXTS, true);
} }
/** /**
* Checks if this attachment has a path to an external file * Checks if the attachment file is externally saved (the database saves an URL).
* *
* @return bool true, if there is a path to an external file * @return bool true, if the file is saved externally
* @phpstan-assert-if-true non-empty-string $this->external_path
* @phpstan-assert-if-true non-empty-string $this->getExternalPath())
*/ */
#[Groups(['attachment:read'])] #[Groups(['attachment:read'])]
public function hasExternal(): bool public function isExternal(): bool
{ {
return $this->external_path !== null && $this->external_path !== ''; //When path is empty, this attachment can not be external
if ($this->path === '') {
return false;
} }
/** //After the %PLACEHOLDER% comes a slash, so we can check if we have a placeholder via explode
* Checks if this attachment has a path to an internal file. $tmp = explode('/', $this->path);
* Does not check if the file exists.
* return !in_array($tmp[0], array_merge(static::INTERNAL_PLACEHOLDER, static::BUILTIN_PLACEHOLDER), true);
* @return bool true, if there is a path to an internal file
* @phpstan-assert-if-true non-empty-string $this->internal_path
* @phpstan-assert-if-true non-empty-string $this->getInternalPath())
*/
#[Groups(['attachment:read'])]
public function hasInternal(): bool
{
return $this->internal_path !== null && $this->internal_path !== '';
} }
/** /**
@ -331,12 +305,8 @@ abstract class Attachment extends AbstractNamedDBElement
#[SerializedName('private')] #[SerializedName('private')]
public function isSecure(): bool public function isSecure(): bool
{ {
if ($this->internal_path === null) {
return false;
}
//After the %PLACEHOLDER% comes a slash, so we can check if we have a placeholder via explode //After the %PLACEHOLDER% comes a slash, so we can check if we have a placeholder via explode
$tmp = explode('/', $this->internal_path); $tmp = explode('/', $this->path);
return '%SECURE%' === $tmp[0]; return '%SECURE%' === $tmp[0];
} }
@ -350,11 +320,7 @@ abstract class Attachment extends AbstractNamedDBElement
#[Groups(['attachment:read'])] #[Groups(['attachment:read'])]
public function isBuiltIn(): bool public function isBuiltIn(): bool
{ {
if ($this->internal_path === null) { return static::checkIfBuiltin($this->path);
return false;
}
return static::checkIfBuiltin($this->internal_path);
} }
/******************************************************************************** /********************************************************************************
@ -366,13 +332,13 @@ abstract class Attachment extends AbstractNamedDBElement
/** /**
* Returns the extension of the file referenced via the attachment. * Returns the extension of the file referenced via the attachment.
* For a path like %BASE/path/foo.bar, bar will be returned. * For a path like %BASE/path/foo.bar, bar will be returned.
* If this attachment is only external null is returned. * If this attachment is external null is returned.
* *
* @return string|null the file extension in lower case * @return string|null the file extension in lower case
*/ */
public function getExtension(): ?string public function getExtension(): ?string
{ {
if (!$this->hasInternal()) { if ($this->isExternal()) {
return null; return null;
} }
@ -380,7 +346,7 @@ abstract class Attachment extends AbstractNamedDBElement
return strtolower(pathinfo($this->original_filename, PATHINFO_EXTENSION)); return strtolower(pathinfo($this->original_filename, PATHINFO_EXTENSION));
} }
return strtolower(pathinfo($this->getInternalPath(), PATHINFO_EXTENSION)); return strtolower(pathinfo($this->getPath(), PATHINFO_EXTENSION));
} }
/** /**
@ -395,54 +361,52 @@ abstract class Attachment extends AbstractNamedDBElement
} }
/** /**
* The URL to the external file, or the path to the built-in file, but not paths to uploaded files. * The URL to the external file, or the path to the built-in file.
* Returns null, if the file is not external (and not builtin). * Returns null, if the file is not external (and not builtin).
* The output of this function is such, that no changes occur when it is fed back into setURL().
* Required for the Attachment form field.
*/ */
#[Groups(['attachment:read'])]
#[SerializedName('url')]
public function getURL(): ?string public function getURL(): ?string
{ {
if($this->hasExternal()){ if (!$this->isExternal() && !$this->isBuiltIn()) {
return $this->getExternalPath();
}
if($this->isBuiltIn()){
return $this->getInternalPath();
}
return null; return null;
} }
return $this->path;
}
/** /**
* Returns the hostname where the external file is stored. * Returns the hostname where the external file is stored.
* Returns null, if there is no external path. * Returns null, if the file is not external.
*/ */
public function getHost(): ?string public function getHost(): ?string
{ {
if (!$this->hasExternal()) { if (!$this->isExternal()) {
return null; return null;
} }
return parse_url($this->getExternalPath(), PHP_URL_HOST); return parse_url((string) $this->getURL(), PHP_URL_HOST);
} }
public function getInternalPath(): ?string /**
* Get the filepath, relative to %BASE%.
*
* @return string A string like %BASE/path/foo.bar
*/
public function getPath(): string
{ {
return $this->internal_path; return $this->path;
}
public function getExternalPath(): ?string
{
return $this->external_path;
} }
/** /**
* Returns the filename of the attachment. * Returns the filename of the attachment.
* For a path like %BASE/path/foo.bar, foo.bar will be returned. * For a path like %BASE/path/foo.bar, foo.bar will be returned.
* *
* If there is no internal copy of the file, null will be returned. * If the path is a URL (can be checked via isExternal()), null will be returned.
*/ */
public function getFilename(): ?string public function getFilename(): ?string
{ {
if (!$this->hasInternal()) { if ($this->isExternal()) {
return null; return null;
} }
@ -451,7 +415,7 @@ abstract class Attachment extends AbstractNamedDBElement
return $this->original_filename; return $this->original_filename;
} }
return pathinfo($this->getInternalPath(), PATHINFO_BASENAME); return pathinfo($this->getPath(), PATHINFO_BASENAME);
} }
/** /**
@ -524,12 +488,15 @@ abstract class Attachment extends AbstractNamedDBElement
} }
/** /**
* Sets the path to a file hosted internally. If you set this path to a file that was not downloaded from the * Sets the filepath (with relative placeholder) for this attachment.
* external source in external_path, make sure to reset external_path. *
* @param string $path the new filepath of the attachment
*
* @return Attachment
*/ */
public function setInternalPath(?string $internal_path): self public function setPath(string $path): self
{ {
$this->internal_path = $internal_path; $this->path = $path;
return $this; return $this;
} }
@ -545,60 +512,34 @@ abstract class Attachment extends AbstractNamedDBElement
} }
/** /**
* Sets up the paths using a user provided string which might contain an external path or a builtin path. Allows * Sets the url associated with this attachment.
* resetting the external path if an internal path exists. Resets any other paths if a (nonempty) new path is set. * If the url is empty nothing is changed, to not override the file path.
*
* @return Attachment
*/ */
#[Groups(['attachment:write'])] #[Groups(['attachment:write'])]
#[SerializedName('url')] #[SerializedName('url')]
#[ApiProperty(description: 'Set the path of the attachment here.
Provide either an external URL, a path to a builtin file (like %FOOTPRINTS%/Active/ICs/IC_DFS.png) or an empty
string if the attachment has an internal file associated and you\'d like to reset the external source.
If you set a new (nonempty) file path any associated internal file will be removed!')]
public function setURL(?string $url): self public function setURL(?string $url): self
{ {
//Don't allow the user to set an empty external path if the internal path is empty already //Do nothing if the URL is empty
if (($url === null || $url === "") && !$this->hasInternal()) { if ($url === null || $url === '') {
return $this; return $this;
} }
//The URL field can also contain the special builtin internal paths, so we need to distinguish here $url = trim($url);
if ($this::checkIfBuiltin($url)) {
$this->setInternalPath($url);
//make sure the external path isn't still pointing to something unrelated
$this->setExternalPath(null);
} else {
$this->setExternalPath($url);
}
return $this;
}
/**
* Sets the path to a file hosted on an external server. Setting the external path to a (nonempty) value different
* from the the old one _clears_ the internal path, so that the external path reflects where any associated internal
* file came from.
*/
public function setExternalPath(?string $external_path): self
{
//If we only clear the external path, don't reset the internal path, since that could be confusing
if($external_path === null || $external_path === '') {
$this->external_path = null;
return $this;
}
$external_path = trim($external_path);
//Escape spaces in URL //Escape spaces in URL
$external_path = str_replace(' ', '%20', $external_path); $url = str_replace(' ', '%20', $url);
if($this->external_path === $external_path) { //Only set if the URL is not empty
//Nothing changed, nothing to do if ($url !== null && $url !== '') {
return $this; if (str_contains($url, '%BASE%') || str_contains($url, '%MEDIA%')) {
throw new InvalidArgumentException('You can not reference internal files via the url field! But nice try!');
} }
$this->external_path = $external_path; $this->path = $url;
$this->internal_path = null;
//Reset internal filename //Reset internal filename
$this->original_filename = null; $this->original_filename = null;
}
return $this; return $this;
} }
@ -610,17 +551,12 @@ abstract class Attachment extends AbstractNamedDBElement
/** /**
* Checks if the given path is a path to a builtin resource. * Checks if the given path is a path to a builtin resource.
* *
* @param string|null $path The path that should be checked * @param string $path The path that should be checked
* *
* @return bool true if the path is pointing to a builtin resource * @return bool true if the path is pointing to a builtin resource
*/ */
public static function checkIfBuiltin(?string $path): bool public static function checkIfBuiltin(string $path): bool
{ {
//An empty path can't be a builtin
if ($path === null) {
return false;
}
//After the %PLACEHOLDER% comes a slash, so we can check if we have a placeholder via explode //After the %PLACEHOLDER% comes a slash, so we can check if we have a placeholder via explode
$tmp = explode('/', $path); $tmp = explode('/', $path);
//Builtins must have a %PLACEHOLDER% construction //Builtins must have a %PLACEHOLDER% construction

View file

@ -33,7 +33,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Serializer\Annotation\Groups;
/** /**
* @template AT of Attachment * @template-covariant AT of Attachment
*/ */
#[ORM\MappedSuperclass(repositoryClass: AttachmentContainingDBElementRepository::class)] #[ORM\MappedSuperclass(repositoryClass: AttachmentContainingDBElementRepository::class)]
abstract class AttachmentContainingDBElement extends AbstractNamedDBElement implements HasMasterAttachmentInterface, HasAttachmentsInterface abstract class AttachmentContainingDBElement extends AbstractNamedDBElement implements HasMasterAttachmentInterface, HasAttachmentsInterface

View file

@ -33,8 +33,8 @@ use Symfony\Component\Validator\Constraints as Assert;
/** /**
* This abstract class is used for companies like suppliers or manufacturers. * This abstract class is used for companies like suppliers or manufacturers.
* *
* @template AT of Attachment * @template-covariant AT of Attachment
* @template PT of AbstractParameter * @template-covariant PT of AbstractParameter
* @extends AbstractPartsContainingDBElement<AT, PT> * @extends AbstractPartsContainingDBElement<AT, PT>
*/ */
#[ORM\MappedSuperclass] #[ORM\MappedSuperclass]
@ -162,7 +162,7 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement
* *
* @return string the link to the article * @return string the link to the article
*/ */
public function getAutoProductUrl(?string $partnr = null): string public function getAutoProductUrl(string $partnr = null): string
{ {
if (is_string($partnr)) { if (is_string($partnr)) {
return str_replace('%PARTNUMBER%', $partnr, $this->auto_product_url); return str_replace('%PARTNUMBER%', $partnr, $this->auto_product_url);

Some files were not shown because too many files have changed in this diff Show more