mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2025-06-20 17:15:51 +02:00
Merge branch 'master' into settings-bundle
This commit is contained in:
commit
442457f11b
131 changed files with 12759 additions and 6750 deletions
|
@ -42,6 +42,48 @@ fi
|
|||
# Start PHP-FPM (the PHP_VERSION is replaced by the configured version in the Dockerfile)
|
||||
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)
|
||||
if [ "${1#-}" != "$1" ]; then
|
||||
set -- apache2-foreground "$@"
|
||||
|
|
|
@ -44,6 +44,8 @@
|
|||
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_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
|
||||
|
||||
# For most configuration files from conf-available/, which are
|
||||
|
|
24
.env
24
.env
|
@ -108,7 +108,8 @@ PROVIDER_TME_CURRENCY=EUR
|
|||
PROVIDER_TME_LANGUAGE=en
|
||||
# The country to get results for
|
||||
PROVIDER_TME_COUNTRY=DE
|
||||
# Set this to 1 to get gross prices (including VAT) instead of net prices
|
||||
# [DEPRECATED] 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
|
||||
|
||||
# Octopart / Nexar Provider:
|
||||
|
@ -180,6 +181,27 @@ PROVIDER_OEMSECRETS_SET_PARAM=1
|
|||
#If unset or set to any other value, no sorting is performed.
|
||||
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
|
||||
##################################################################################
|
||||
|
|
|
@ -128,6 +128,8 @@ const PLACEHOLDERS = [
|
|||
['[[BARCODE_QR]]', 'QR code linking to this element'],
|
||||
['[[BARCODE_C128]]', 'Code 128 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'],
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
@ -69,6 +69,8 @@ Object.assign( window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary, {
|
|||
'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 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',
|
||||
'Name': 'Name',
|
||||
|
|
|
@ -23,6 +23,12 @@ import "tom-select/dist/css/tom-select.bootstrap5.css";
|
|||
import '../../css/components/tom-select_extensions.css';
|
||||
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 {
|
||||
_tomSelect;
|
||||
|
||||
|
@ -46,6 +52,12 @@ export default class extends Controller {
|
|||
}
|
||||
return '<div>' + escape(data.label) + '</div>';
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
'autoselect_typed': {},
|
||||
'click_to_edit': {},
|
||||
'clear_button': {},
|
||||
"restore_on_backspace": {}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -23,6 +23,12 @@ import "tom-select/dist/css/tom-select.bootstrap5.css";
|
|||
import '../../css/components/tom-select_extensions.css';
|
||||
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.
|
||||
* Basically it loads a text file from the given url (via data-url) and uses it as a source for the autocomplete.
|
||||
|
@ -46,7 +52,13 @@ export default class extends Controller {
|
|||
orderField: 'text',
|
||||
|
||||
//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) {
|
||||
|
|
|
@ -24,6 +24,9 @@ import {Controller} from "@hotwired/stimulus";
|
|||
|
||||
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 {
|
||||
_tomSelect;
|
||||
|
||||
|
@ -37,11 +40,15 @@ export default class extends Controller {
|
|||
const allowAdd = this.element.getAttribute("data-allow-add") === "true";
|
||||
const addHint = this.element.getAttribute("data-add-hint") ?? "";
|
||||
|
||||
|
||||
|
||||
|
||||
let settings = {
|
||||
allowEmptyOption: true,
|
||||
selectOnTab: true,
|
||||
maxOptions: null,
|
||||
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)
|
||||
maxItems: 1,
|
||||
|
@ -81,8 +88,17 @@ export default class extends Controller {
|
|||
//Add callbacks to update validity
|
||||
onInitialize: 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);
|
||||
//Do not do a sync here as this breaks the initial rendering of the empty option
|
||||
//this._tomSelect.sync();
|
||||
|
@ -113,6 +129,31 @@ 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() {
|
||||
//Mark this input as invalid, if the selected option is disabled
|
||||
|
|
|
@ -23,14 +23,21 @@ import "tom-select/dist/css/tom-select.bootstrap5.css";
|
|||
import '../../css/components/tom-select_extensions.css';
|
||||
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 {
|
||||
_tomSelect;
|
||||
|
||||
connect() {
|
||||
let settings = {
|
||||
plugins: {
|
||||
remove_button:{
|
||||
}
|
||||
remove_button:{},
|
||||
'autoselect_typed': {},
|
||||
'click_to_edit': {},
|
||||
},
|
||||
persistent: false,
|
||||
selectOnTab: true,
|
||||
|
|
|
@ -25,9 +25,20 @@ import "katex/dist/katex.css";
|
|||
export default class extends Controller {
|
||||
static targets = ["input", "preview"];
|
||||
|
||||
static values = {
|
||||
unit: {type: Boolean, default: false} //Render as upstanding (non-italic) text, useful for units
|
||||
}
|
||||
|
||||
updatePreview()
|
||||
{
|
||||
katex.render(this.inputTarget.value, this.previewTarget, {
|
||||
let value = "";
|
||||
if (this.unitValue) {
|
||||
value = "\\mathrm{" + this.inputTarget.value + "}";
|
||||
} else {
|
||||
value = this.inputTarget.value;
|
||||
}
|
||||
|
||||
katex.render(value, this.previewTarget, {
|
||||
throwOnError: false,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -22,6 +22,13 @@ import TomSelect from "tom-select";
|
|||
import katex from "katex";
|
||||
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' */
|
||||
export default class extends Controller
|
||||
{
|
||||
|
@ -53,7 +60,10 @@ export default class extends Controller
|
|||
connect() {
|
||||
const settings = {
|
||||
plugins: {
|
||||
clear_button:{}
|
||||
'autoselect_typed': {},
|
||||
'click_to_edit': {},
|
||||
'clear_button': {},
|
||||
'restore_on_backspace': {}
|
||||
},
|
||||
persistent: false,
|
||||
maxItems: 1,
|
||||
|
|
|
@ -112,3 +112,10 @@ ul.structural_link li a:hover {
|
|||
background-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;
|
||||
}
|
63
assets/tomselect/autoselect_typed/autoselect_typed.js
Normal file
63
assets/tomselect/autoselect_typed/autoselect_typed.js
Normal file
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
93
assets/tomselect/click_to_edit/click_to_edit.js
Normal file
93
assets/tomselect/click_to_edit/click_to_edit.js
Normal file
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* 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 };
|
|
@ -15,7 +15,7 @@
|
|||
"api-platform/core": "^3.1",
|
||||
"beberlei/doctrineextensions": "^1.2",
|
||||
"brick/math": "0.12.1 as 0.11.0",
|
||||
"composer/ca-bundle": "^1.3",
|
||||
"composer/ca-bundle": "^1.5",
|
||||
"composer/package-versions-deprecated": "^1.11.99.5",
|
||||
"doctrine/data-fixtures": "^2.0.0",
|
||||
"doctrine/dbal": "^4.0.0",
|
||||
|
@ -44,6 +44,7 @@
|
|||
"omines/datatables-bundle": "^0.9.1",
|
||||
"paragonie/sodium_compat": "^1.21",
|
||||
"part-db/label-fonts": "^1.0",
|
||||
"rhukster/dom-sanitizer": "^1.0",
|
||||
"runtime/frankenphp-symfony": "^0.2.0",
|
||||
"s9e/text-formatter": "^2.1",
|
||||
"scheb/2fa-backup-code": "^6.8.0",
|
||||
|
@ -55,6 +56,8 @@
|
|||
"symfony/apache-pack": "^1.0",
|
||||
"symfony/asset": "6.4.*",
|
||||
"symfony/console": "6.4.*",
|
||||
"symfony/css-selector": "6.4.*",
|
||||
"symfony/dom-crawler": "6.4.*",
|
||||
"symfony/dotenv": "6.4.*",
|
||||
"symfony/expression-language": "6.4.*",
|
||||
"symfony/flex": "^v2.3.1",
|
||||
|
@ -105,7 +108,6 @@
|
|||
"rector/rector": "^2.0.4",
|
||||
"roave/security-advisories": "dev-latest",
|
||||
"symfony/browser-kit": "6.4.*",
|
||||
"symfony/css-selector": "6.4.*",
|
||||
"symfony/debug-bundle": "6.4.*",
|
||||
"symfony/maker-bundle": "^1.13",
|
||||
"symfony/phpunit-bridge": "6.4.*",
|
||||
|
|
2142
composer.lock
generated
2142
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -35,3 +35,7 @@ api_platform:
|
|||
keep_legacy_inflector: false
|
||||
# Need to be true, or some tests will fail
|
||||
use_symfony_listeners: true
|
||||
|
||||
serializer:
|
||||
# Change this to false later, to remove the hydra prefix on the API
|
||||
hydra_prefix: true
|
|
@ -8,7 +8,7 @@ datatables:
|
|||
|
||||
# Set options, as documented at https://datatables.net/reference/option/
|
||||
options:
|
||||
lengthMenu : [[10, 25, 50, 100, -1], [10, 25, 50, 100, "All"]]
|
||||
lengthMenu : [[10, 25, 50, 100], [10, 25, 50, 100]] # We add the "All" option, when part tables are generated
|
||||
#pageLength: '%partdb.table.default_page_size%' # Set to -1 to disable pagination (i.e. show all rows) by default
|
||||
pageLength: 50 #TODO
|
||||
dom: " <'row' <'col mb-2 input-group flex-nowrap' B l > <'col-auto mb-2' < p >>>
|
||||
|
|
|
@ -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.
|
||||
|
||||
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.
|
||||
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.
|
||||
```shell
|
||||
DATABASE_URL="postgresql://db_user:db_password@localhost/db_name?serverVersion=12.19&charset=utf8&unix_socket=/var/run/postgresql/.s.PGSQL.5432"
|
||||
DATABASE_URL="postgresql://db_user@localhost/db_name?serverVersion=16.6&charset=utf8&host=/var/run/postgresql"
|
||||
```
|
||||
|
||||
|
||||
|
|
|
@ -48,6 +48,12 @@ services:
|
|||
# In docker env logs will be redirected to stderr
|
||||
- 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
|
||||
# Below you can find the most essential ones predefined
|
||||
# However you can add any other environment configuration you want here
|
||||
|
@ -130,6 +136,12 @@ services:
|
|||
# In docker env logs will be redirected to stderr
|
||||
- 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
|
||||
# Below you can find the most essential ones predefined
|
||||
# However you can add add any other environment configuration you want here
|
||||
|
@ -201,6 +213,10 @@ You also have to create the database as described above in step 4.
|
|||
You can run the console commands described in README by
|
||||
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
|
||||
|
||||
*Login is not possible. Login page is just reloading and no error message is shown or something like "CSFR token invalid"*:
|
||||
|
|
|
@ -53,6 +53,11 @@ server {
|
|||
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;
|
||||
access_log /var/log/nginx/parts.access.log;
|
||||
|
||||
|
|
|
@ -25,6 +25,12 @@ 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
|
||||
```
|
||||
|
||||
{: .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
|
||||
|
||||
* `php bin/console partdb:users:list`: List all users of this Part-DB instance
|
||||
|
@ -65,3 +71,9 @@ 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: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
|
|
@ -232,6 +232,26 @@ 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.
|
||||
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
|
||||
|
||||
To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long
|
||||
|
|
|
@ -117,6 +117,6 @@ For a German keyboard layout, replace `[` with `0`, and `]` with `´`.
|
|||
| Key | Character |
|
||||
|--------------------------------|--------------------|
|
||||
| **Alt + [** (code 219) | © (Copyright char) |
|
||||
| **Alt + Shift + [** (code 219) | (Registered char) |
|
||||
| **Alt + Shift + [** (code 219) | ® (Registered char) |
|
||||
| **Alt + ]** (code 221) | ™ (Trademark char) |
|
||||
| **Alt + Shift + ]** (code 221) | (Degree char) |
|
||||
| **Alt + Shift + ]** (code 221) | ° (Degree char) |
|
||||
|
|
42
migrations/Version20250220215048.php
Normal file
42
migrations/Version20250220215048.php
Normal file
|
@ -0,0 +1,42 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
31
migrations/Version20250222165240.php
Normal file
31
migrations/Version20250222165240.php
Normal file
|
@ -0,0 +1,31 @@
|
|||
<?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
|
||||
}
|
||||
}
|
|
@ -118,3 +118,10 @@ DirectoryIndex index.php
|
|||
# RedirectTemp cannot be used instead
|
||||
</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
|
@ -1,116 +0,0 @@
|
|||
<?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;
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -21,7 +21,9 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\ApiPlatform;
|
||||
namespace App\ApiPlatform\DocumentedAPIProperties;
|
||||
|
||||
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.
|
||||
|
@ -64,4 +66,55 @@ 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
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
|
@ -37,7 +37,7 @@ class EntityFilter extends AbstractFilter
|
|||
public function __construct(
|
||||
ManagerRegistry $managerRegistry,
|
||||
private readonly EntityFilterHelper $filter_helper,
|
||||
LoggerInterface $logger = null,
|
||||
?LoggerInterface $logger = null,
|
||||
?array $properties = null,
|
||||
?NameConverterInterface $nameConverter = null
|
||||
) {
|
||||
|
@ -50,7 +50,7 @@ class EntityFilter extends AbstractFilter
|
|||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
Operation $operation = null,
|
||||
?Operation $operation = null,
|
||||
array $context = []
|
||||
): void {
|
||||
if (
|
||||
|
|
|
@ -92,12 +92,6 @@ class EntityFilterHelper
|
|||
'type' => Type::BUILTIN_TYPE_STRING,
|
||||
'required' => false,
|
||||
'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;
|
||||
|
|
|
@ -38,7 +38,7 @@ final class LikeFilter extends AbstractFilter
|
|||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
Operation $operation = null,
|
||||
?Operation $operation = null,
|
||||
array $context = []
|
||||
): void {
|
||||
// Otherwise filter is applied to order and page as well
|
||||
|
@ -67,12 +67,6 @@ final class LikeFilter extends AbstractFilter
|
|||
'type' => Type::BUILTIN_TYPE_STRING,
|
||||
'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',
|
||||
'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;
|
||||
|
|
|
@ -38,7 +38,7 @@ class PartStoragelocationFilter extends AbstractFilter
|
|||
public function __construct(
|
||||
ManagerRegistry $managerRegistry,
|
||||
private readonly EntityFilterHelper $filter_helper,
|
||||
LoggerInterface $logger = null,
|
||||
?LoggerInterface $logger = null,
|
||||
?array $properties = null,
|
||||
?NameConverterInterface $nameConverter = null
|
||||
) {
|
||||
|
@ -51,7 +51,7 @@ class PartStoragelocationFilter extends AbstractFilter
|
|||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
Operation $operation = null,
|
||||
?Operation $operation = null,
|
||||
array $context = []
|
||||
): void {
|
||||
//Do not check for mapping here, as we are using a virtual property
|
||||
|
|
|
@ -89,12 +89,6 @@ final class TagFilter extends AbstractFilter
|
|||
'type' => Type::BUILTIN_TYPE_STRING,
|
||||
'required' => false,
|
||||
'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;
|
||||
|
|
77
src/ApiPlatform/NormalizePropertyNameCollectionFactory.php
Normal file
77
src/ApiPlatform/NormalizePropertyNameCollectionFactory.php
Normal file
|
@ -0,0 +1,77 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
|
@ -73,6 +73,9 @@ class CleanAttachmentsCommand extends Command
|
|||
//Ignore image cache folder
|
||||
$finder->exclude('cache');
|
||||
|
||||
//Ignore automigration folder
|
||||
$finder->exclude('.automigration-backup');
|
||||
|
||||
$fs = new Filesystem();
|
||||
|
||||
$file_list = [];
|
||||
|
|
136
src/Command/Attachments/DownloadAttachmentsCommand.php
Normal file
136
src/Command/Attachments/DownloadAttachmentsCommand.php
Normal file
|
@ -0,0 +1,136 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
90
src/Command/Attachments/SanitizeSVGAttachmentsCommand.php
Normal file
90
src/Command/Attachments/SanitizeSVGAttachmentsCommand.php
Normal file
|
@ -0,0 +1,90 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
|
@ -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')]
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -467,6 +467,11 @@ abstract class BaseAdminController extends AbstractController
|
|||
$this->denyAccessUnlessGranted('read', $entity);
|
||||
$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);
|
||||
}
|
||||
|
||||
|
|
|
@ -52,15 +52,15 @@ class AttachmentFileController extends AbstractController
|
|||
$this->denyAccessUnlessGranted('show_private', $attachment);
|
||||
}
|
||||
|
||||
if ($attachment->isExternal()) {
|
||||
throw $this->createNotFoundException('The file for this attachment is external and can not stored locally!');
|
||||
if (!$attachment->hasInternal()) {
|
||||
throw $this->createNotFoundException('The file for this attachment is external and not stored locally!');
|
||||
}
|
||||
|
||||
if (!$helper->isFileExisting($attachment)) {
|
||||
if (!$helper->isInternalFileExisting($attachment)) {
|
||||
throw $this->createNotFoundException('The file associated with the attachment is not existing!');
|
||||
}
|
||||
|
||||
$file_path = $helper->toAbsoluteFilePath($attachment);
|
||||
$file_path = $helper->toAbsoluteInternalFilePath($attachment);
|
||||
$response = new BinaryFileResponse($file_path);
|
||||
|
||||
//Set header content disposition, so that the file will be downloaded
|
||||
|
@ -81,15 +81,15 @@ class AttachmentFileController extends AbstractController
|
|||
$this->denyAccessUnlessGranted('show_private', $attachment);
|
||||
}
|
||||
|
||||
if ($attachment->isExternal()) {
|
||||
throw $this->createNotFoundException('The file for this attachment is external and can not stored locally!');
|
||||
if (!$attachment->hasInternal()) {
|
||||
throw $this->createNotFoundException('The file for this attachment is external and not stored locally!');
|
||||
}
|
||||
|
||||
if (!$helper->isFileExisting($attachment)) {
|
||||
if (!$helper->isInternalFileExisting($attachment)) {
|
||||
throw $this->createNotFoundException('The file associated with the attachment is not existing!');
|
||||
}
|
||||
|
||||
$file_path = $helper->toAbsoluteFilePath($attachment);
|
||||
$file_path = $helper->toAbsoluteInternalFilePath($attachment);
|
||||
$response = new BinaryFileResponse($file_path);
|
||||
|
||||
//Set header content disposition, so that the file will be downloaded
|
||||
|
|
|
@ -112,8 +112,9 @@ class PartImportExportController extends AbstractController
|
|||
$ids = $request->query->get('ids', '');
|
||||
$parts = $this->partsTableActionHandler->idStringToArray($ids);
|
||||
|
||||
if ($parts === []) {
|
||||
throw new \RuntimeException('No parts found!');
|
||||
if (count($parts) === 0) {
|
||||
$this->addFlash('error', 'entity.export.flash.error.no_entities');
|
||||
return $this->redirectToRoute('homepage');
|
||||
}
|
||||
|
||||
//Ensure that we have access to the parts
|
||||
|
|
|
@ -29,6 +29,7 @@ use App\DataTables\PartsDataTable;
|
|||
use App\Entity\Parts\Category;
|
||||
use App\Entity\Parts\Footprint;
|
||||
use App\Entity\Parts\Manufacturer;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\StorageLocation;
|
||||
use App\Entity\Parts\Supplier;
|
||||
use App\Exceptions\InvalidRegexException;
|
||||
|
@ -44,8 +45,11 @@ use Symfony\Component\Form\FormInterface;
|
|||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Translation\TranslatableMessage;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
use function Symfony\Component\Translation\t;
|
||||
|
||||
class PartListsController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager,
|
||||
|
@ -77,13 +81,32 @@ class PartListsController extends AbstractController
|
|||
if (null === $action || null === $ids) {
|
||||
$this->addFlash('error', 'part.table.actions.no_params_given');
|
||||
} else {
|
||||
$errors = [];
|
||||
|
||||
$parts = $actionHandler->idStringToArray($ids);
|
||||
$redirectResponse = $actionHandler->handleAction($action, $parts, $target ? (int) $target : null, $redirect);
|
||||
$redirectResponse = $actionHandler->handleAction($action, $parts, $target ? (int) $target : null, $redirect, $errors);
|
||||
|
||||
//Save changes
|
||||
$this->entityManager->flush();
|
||||
|
||||
if (count($errors) === 0) {
|
||||
$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.
|
||||
|
|
|
@ -29,6 +29,7 @@ use App\Entity\Parts\Category;
|
|||
use App\Entity\Parts\Footprint;
|
||||
use App\Entity\Parts\Manufacturer;
|
||||
use App\Entity\Parts\MeasurementUnit;
|
||||
use App\Entity\Parts\StorageLocation;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Form\Type\Helper\StructuralEntityChoiceHelper;
|
||||
use App\Services\Trees\NodesListBuilder;
|
||||
|
@ -78,6 +79,12 @@ class SelectAPIController extends AbstractController
|
|||
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')]
|
||||
public function exportLevel(): Response
|
||||
{
|
||||
|
|
|
@ -131,7 +131,7 @@ class PartFixtures extends Fixture implements DependentFixtureInterface
|
|||
|
||||
$attachment = new PartAttachment();
|
||||
$attachment->setName('Test2');
|
||||
$attachment->setPath('invalid');
|
||||
$attachment->setInternalPath('invalid');
|
||||
$attachment->setShowInTable(true);
|
||||
$attachment->setAttachmentType($manager->find(AttachmentType::class, 1));
|
||||
$part->addAttachment($attachment);
|
||||
|
|
|
@ -54,7 +54,7 @@ class TwoStepORMAdapter extends ORMAdapter
|
|||
|
||||
private \Closure|null $query_modifier = null;
|
||||
|
||||
public function __construct(ManagerRegistry $registry = null)
|
||||
public function __construct(?ManagerRegistry $registry = null)
|
||||
{
|
||||
parent::__construct($registry);
|
||||
$this->detailQueryCallable = static function (QueryBuilder $qb, array $ids): never {
|
||||
|
|
|
@ -50,8 +50,8 @@ final class AttachmentDataTable implements DataTableTypeInterface
|
|||
{
|
||||
$dataTable->add('dont_matter', RowClassColumn::class, [
|
||||
'render' => function ($value, Attachment $context): string {
|
||||
//Mark attachments with missing files yellow
|
||||
if(!$this->attachmentHelper->isFileExisting($context)){
|
||||
//Mark attachments yellow which have an internal file linked that doesn't exist
|
||||
if($context->hasInternal() && !$this->attachmentHelper->isInternalFileExisting($context)){
|
||||
return 'table-warning';
|
||||
}
|
||||
|
||||
|
@ -64,8 +64,8 @@ final class AttachmentDataTable implements DataTableTypeInterface
|
|||
'className' => 'no-colvis',
|
||||
'render' => function ($value, Attachment $context): string {
|
||||
if ($context->isPicture()
|
||||
&& !$context->isExternal()
|
||||
&& $this->attachmentHelper->isFileExisting($context)) {
|
||||
&& $this->attachmentHelper->isInternalFileExisting($context)) {
|
||||
|
||||
$title = htmlspecialchars($context->getName());
|
||||
if ($context->getFilename()) {
|
||||
$title .= ' ('.htmlspecialchars($context->getFilename()).')';
|
||||
|
@ -93,26 +93,6 @@ final class AttachmentDataTable implements DataTableTypeInterface
|
|||
$dataTable->add('name', TextColumn::class, [
|
||||
'label' => 'attachment.edit.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, [
|
||||
|
@ -136,25 +116,60 @@ final class AttachmentDataTable implements DataTableTypeInterface
|
|||
),
|
||||
]);
|
||||
|
||||
$dataTable->add('filename', TextColumn::class, [
|
||||
'label' => $this->translator->trans('attachment.table.filename'),
|
||||
$dataTable->add('internal_link', TextColumn::class, [
|
||||
'label' => 'attachment.table.internal_file',
|
||||
'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, [
|
||||
'label' => $this->translator->trans('attachment.table.filesize'),
|
||||
'render' => function ($value, Attachment $context) {
|
||||
if ($context->isExternal()) {
|
||||
if (!$context->hasInternal()) {
|
||||
return sprintf(
|
||||
'<span class="badge bg-primary">
|
||||
<i class="fas fa-globe fa-fw"></i>%s
|
||||
</span>',
|
||||
$this->translator->trans('attachment.external')
|
||||
$this->translator->trans('attachment.external_only')
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->attachmentHelper->isFileExisting($context)) {
|
||||
return $this->attachmentHelper->getHumanFileSize($context);
|
||||
if ($this->attachmentHelper->isInternalFileExisting($context)) {
|
||||
return sprintf(
|
||||
'<span class="badge bg-secondary">
|
||||
<i class="fas fa-hdd fa-fw"></i> %s
|
||||
</span>',
|
||||
$this->attachmentHelper->getHumanFileSize($context)
|
||||
);
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
|
|
|
@ -45,6 +45,9 @@ class AttachmentFilter implements FilterInterface
|
|||
public readonly DateTimeConstraint $lastModified;
|
||||
public readonly DateTimeConstraint $addedDate;
|
||||
|
||||
public readonly TextConstraint $originalFileName;
|
||||
public readonly TextConstraint $externalLink;
|
||||
|
||||
|
||||
public function __construct(NodesListBuilder $nodesListBuilder)
|
||||
{
|
||||
|
@ -55,6 +58,9 @@ class AttachmentFilter implements FilterInterface
|
|||
$this->lastModified = new DateTimeConstraint('attachment.lastModified');
|
||||
$this->addedDate = new DateTimeConstraint('attachment.addedDate');
|
||||
$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
|
||||
|
|
|
@ -45,7 +45,7 @@ abstract class AbstractConstraint implements FilterInterface
|
|||
* @var string The property where this BooleanConstraint should apply to
|
||||
*/
|
||||
protected string $property,
|
||||
string $identifier = null)
|
||||
?string $identifier = null)
|
||||
{
|
||||
$this->identifier = $identifier ?? $this->generateParameterIdentifier($property);
|
||||
}
|
||||
|
|
|
@ -28,7 +28,7 @@ class BooleanConstraint extends AbstractConstraint
|
|||
{
|
||||
public function __construct(
|
||||
string $property,
|
||||
string $identifier = null,
|
||||
?string $identifier = null,
|
||||
/** @var bool|null The value of our constraint */
|
||||
protected ?bool $value = null
|
||||
)
|
||||
|
|
|
@ -34,7 +34,7 @@ class DateTimeConstraint extends AbstractConstraint
|
|||
|
||||
public function __construct(
|
||||
string $property,
|
||||
string $identifier = null,
|
||||
?string $identifier = null,
|
||||
/**
|
||||
* The value1 used for comparison (this is the main one used for all mono-value comparisons)
|
||||
*/
|
||||
|
|
|
@ -46,7 +46,7 @@ class EntityConstraint extends AbstractConstraint
|
|||
public function __construct(protected ?NodesListBuilder $nodesListBuilder,
|
||||
protected string $class,
|
||||
string $property,
|
||||
string $identifier = null,
|
||||
?string $identifier = null,
|
||||
protected ?AbstractDBElement $value = null,
|
||||
protected ?string $operator = null)
|
||||
{
|
||||
|
|
|
@ -31,7 +31,7 @@ class NumberConstraint extends AbstractConstraint
|
|||
|
||||
public function __construct(
|
||||
string $property,
|
||||
string $identifier = null,
|
||||
?string $identifier = null,
|
||||
/**
|
||||
* The value1 used for comparison (this is the main one used for all mono-value comparisons)
|
||||
*/
|
||||
|
|
|
@ -28,7 +28,7 @@ use Doctrine\ORM\QueryBuilder;
|
|||
|
||||
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 ?? '(
|
||||
SELECT COALESCE(SUM(ld_partLot.amount), 0.0)
|
||||
|
|
|
@ -30,7 +30,7 @@ class TagsConstraint extends AbstractConstraint
|
|||
{
|
||||
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 $operator = '')
|
||||
{
|
||||
|
|
|
@ -32,7 +32,7 @@ class TextConstraint extends AbstractConstraint
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
protected ?string $value = null, /**
|
||||
|
|
|
@ -58,6 +58,8 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
|||
|
||||
final class PartsDataTable implements DataTableTypeInterface
|
||||
{
|
||||
const LENGTH_MENU = [[10, 25, 50, 100, -1], [10, 25, 50, 100, "All"]];
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityURLGenerator $urlGenerator,
|
||||
private readonly TranslatorInterface $translator,
|
||||
|
|
|
@ -33,23 +33,24 @@ use ApiPlatform\Metadata\Get;
|
|||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\ApiPlatform\DocumentedAPIProperty;
|
||||
use App\ApiPlatform\DocumentedAPIProperties\DocumentedAPIProperty;
|
||||
use App\ApiPlatform\Filter\EntityFilter;
|
||||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\ApiPlatform\HandleAttachmentsUploadsProcessor;
|
||||
use App\Repository\AttachmentRepository;
|
||||
use App\EntityListeners\AttachmentDeleteListener;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use App\Entity\Base\AbstractNamedDBElement;
|
||||
use App\EntityListeners\AttachmentDeleteListener;
|
||||
use App\Repository\AttachmentRepository;
|
||||
use App\Validator\Constraints\Selectable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use InvalidArgumentException;
|
||||
use LogicException;
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
use Symfony\Component\Serializer\Annotation\SerializedName;
|
||||
use Symfony\Component\Serializer\Attribute\DiscriminatorMap;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
use function in_array;
|
||||
use InvalidArgumentException;
|
||||
use LogicException;
|
||||
|
||||
/**
|
||||
* Class Attachment.
|
||||
|
@ -78,11 +79,16 @@ use LogicException;
|
|||
denormalizationContext: ['groups' => ['attachment:write', 'attachment:write:standalone', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
|
||||
processor: HandleAttachmentsUploadsProcessor::class,
|
||||
)]
|
||||
#[DocumentedAPIProperty(schemaName: 'Attachment-Read', property: 'media_url', type: 'string', nullable: true,
|
||||
description: 'The URL to the file, where the attachment file can be downloaded. This can be an internal or external URL.',
|
||||
example: '/media/part/2/bc547-6508afa5a79c8.pdf')]
|
||||
#[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.')]
|
||||
//This property is added by the denormalizer in order to resolve the placeholder
|
||||
#[DocumentedAPIProperty(
|
||||
schemaName: 'Attachment-Read', property: 'internal_path', type: 'string', nullable: false,
|
||||
description: 'The URL to the internally saved copy of the file, if one exists',
|
||||
example: '/media/part/2/bc547-6508afa5a79c8.pdf'
|
||||
)]
|
||||
#[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(EntityFilter::class, properties: ["attachment_type"])]
|
||||
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
|
||||
|
@ -91,8 +97,8 @@ use LogicException;
|
|||
#[DiscriminatorMap(typeProperty: '_type', mapping: self::API_DISCRIMINATOR_MAP)]
|
||||
abstract class Attachment extends AbstractNamedDBElement
|
||||
{
|
||||
private const ORM_DISCRIMINATOR_MAP = ['PartDB\Part' => PartAttachment::class, 'Part' => PartAttachment::class,
|
||||
'PartDB\Device' => ProjectAttachment::class, 'Device' => ProjectAttachment::class, 'AttachmentType' => AttachmentTypeAttachment::class,
|
||||
private const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'Device' => ProjectAttachment::class,
|
||||
'AttachmentType' => AttachmentTypeAttachment::class,
|
||||
'Category' => CategoryAttachment::class, 'Footprint' => FootprintAttachment::class, 'Manufacturer' => ManufacturerAttachment::class,
|
||||
'Currency' => CurrencyAttachment::class, 'Group' => GroupAttachment::class, 'MeasurementUnit' => MeasurementUnitAttachment::class,
|
||||
'Storelocation' => StorageLocationAttachment::class, 'Supplier' => SupplierAttachment::class,
|
||||
|
@ -119,10 +125,6 @@ abstract class Attachment extends AbstractNamedDBElement
|
|||
*/
|
||||
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
|
||||
|
@ -152,10 +154,21 @@ abstract class Attachment extends AbstractNamedDBElement
|
|||
protected ?string $original_filename = null;
|
||||
|
||||
/**
|
||||
* @var string The path to the file relative to a placeholder path like %MEDIA%
|
||||
* @var string|null If a copy of the file is stored internally, the path to the file relative to a placeholder
|
||||
* path like %MEDIA%
|
||||
*/
|
||||
#[ORM\Column(name: 'path', type: Types::STRING)]
|
||||
protected string $path = '';
|
||||
#[ORM\Column(type: Types::STRING, nullable: true)]
|
||||
protected ?string $internal_path = null;
|
||||
|
||||
|
||||
/**
|
||||
* @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
|
||||
|
@ -237,7 +250,7 @@ abstract class Attachment extends AbstractNamedDBElement
|
|||
|
||||
/**
|
||||
* Check if this attachment is a picture (analyse the file's extension).
|
||||
* If the link is external, it is assumed that this is true.
|
||||
* If the link is only external and doesn't contain an extension, it is assumed that this is true.
|
||||
*
|
||||
* @return bool * true if the file extension is a picture extension
|
||||
* * otherwise false
|
||||
|
@ -245,54 +258,67 @@ abstract class Attachment extends AbstractNamedDBElement
|
|||
#[Groups(['attachment:read'])]
|
||||
public function isPicture(): bool
|
||||
{
|
||||
if ($this->isExternal()) {
|
||||
if($this->hasInternal()){
|
||||
|
||||
$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
|
||||
$extension = pathinfo(parse_url($this->path, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION);
|
||||
$extension = pathinfo(parse_url($this->getExternalPath(), PHP_URL_PATH) ?? '', PATHINFO_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);
|
||||
}
|
||||
|
||||
$extension = pathinfo($this->getPath(), PATHINFO_EXTENSION);
|
||||
|
||||
return 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this attachment is a 3D model and therefore can be directly shown to user.
|
||||
* If the attachment is external, false is returned (3D Models must be internal).
|
||||
* If no internal copy exists, false is returned (3D Models must be internal).
|
||||
*/
|
||||
#[Groups(['attachment:read'])]
|
||||
#[SerializedName('3d_model')]
|
||||
public function is3DModel(): bool
|
||||
{
|
||||
//We just assume that 3D Models are internally saved, otherwise we get problems loading them.
|
||||
if ($this->isExternal()) {
|
||||
if (!$this->hasInternal()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$extension = pathinfo($this->getPath(), PATHINFO_EXTENSION);
|
||||
$extension = pathinfo($this->getInternalPath(), PATHINFO_EXTENSION);
|
||||
|
||||
return in_array(strtolower($extension), static::MODEL_EXTS, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the attachment file is externally saved (the database saves an URL).
|
||||
* Checks if this attachment has a path to an external file
|
||||
*
|
||||
* @return bool true, if the file is saved externally
|
||||
* @return bool true, if there is a path to an external file
|
||||
* @phpstan-assert-if-true non-empty-string $this->external_path
|
||||
* @phpstan-assert-if-true non-empty-string $this->getExternalPath())
|
||||
*/
|
||||
#[Groups(['attachment:read'])]
|
||||
public function isExternal(): bool
|
||||
public function hasExternal(): bool
|
||||
{
|
||||
//When path is empty, this attachment can not be external
|
||||
if ($this->path === '') {
|
||||
return false;
|
||||
return $this->external_path !== null && $this->external_path !== '';
|
||||
}
|
||||
|
||||
//After the %PLACEHOLDER% comes a slash, so we can check if we have a placeholder via explode
|
||||
$tmp = explode('/', $this->path);
|
||||
|
||||
return !in_array($tmp[0], array_merge(static::INTERNAL_PLACEHOLDER, static::BUILTIN_PLACEHOLDER), true);
|
||||
/**
|
||||
* Checks if this attachment has a path to an internal file.
|
||||
* Does not check if the file exists.
|
||||
*
|
||||
* @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 !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -305,8 +331,12 @@ abstract class Attachment extends AbstractNamedDBElement
|
|||
#[SerializedName('private')]
|
||||
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
|
||||
$tmp = explode('/', $this->path);
|
||||
$tmp = explode('/', $this->internal_path);
|
||||
|
||||
return '%SECURE%' === $tmp[0];
|
||||
}
|
||||
|
@ -320,7 +350,11 @@ abstract class Attachment extends AbstractNamedDBElement
|
|||
#[Groups(['attachment:read'])]
|
||||
public function isBuiltIn(): bool
|
||||
{
|
||||
return static::checkIfBuiltin($this->path);
|
||||
if ($this->internal_path === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return static::checkIfBuiltin($this->internal_path);
|
||||
}
|
||||
|
||||
/********************************************************************************
|
||||
|
@ -332,13 +366,13 @@ abstract class Attachment extends AbstractNamedDBElement
|
|||
/**
|
||||
* Returns the extension of the file referenced via the attachment.
|
||||
* For a path like %BASE/path/foo.bar, bar will be returned.
|
||||
* If this attachment is external null is returned.
|
||||
* If this attachment is only external null is returned.
|
||||
*
|
||||
* @return string|null the file extension in lower case
|
||||
*/
|
||||
public function getExtension(): ?string
|
||||
{
|
||||
if ($this->isExternal()) {
|
||||
if (!$this->hasInternal()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -346,7 +380,7 @@ abstract class Attachment extends AbstractNamedDBElement
|
|||
return strtolower(pathinfo($this->original_filename, PATHINFO_EXTENSION));
|
||||
}
|
||||
|
||||
return strtolower(pathinfo($this->getPath(), PATHINFO_EXTENSION));
|
||||
return strtolower(pathinfo($this->getInternalPath(), PATHINFO_EXTENSION));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -361,52 +395,54 @@ abstract class Attachment extends AbstractNamedDBElement
|
|||
}
|
||||
|
||||
/**
|
||||
* The URL to the external file, or the path to the built-in file.
|
||||
* The URL to the external file, or the path to the built-in file, but not paths to uploaded files.
|
||||
* 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
|
||||
{
|
||||
if (!$this->isExternal() && !$this->isBuiltIn()) {
|
||||
return null;
|
||||
if($this->hasExternal()){
|
||||
return $this->getExternalPath();
|
||||
}
|
||||
|
||||
return $this->path;
|
||||
if($this->isBuiltIn()){
|
||||
return $this->getInternalPath();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the hostname where the external file is stored.
|
||||
* Returns null, if the file is not external.
|
||||
* Returns null, if there is no external path.
|
||||
*/
|
||||
public function getHost(): ?string
|
||||
{
|
||||
if (!$this->isExternal()) {
|
||||
if (!$this->hasExternal()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parse_url((string) $this->getURL(), PHP_URL_HOST);
|
||||
return parse_url($this->getExternalPath(), PHP_URL_HOST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the filepath, relative to %BASE%.
|
||||
*
|
||||
* @return string A string like %BASE/path/foo.bar
|
||||
*/
|
||||
public function getPath(): string
|
||||
public function getInternalPath(): ?string
|
||||
{
|
||||
return $this->path;
|
||||
return $this->internal_path;
|
||||
}
|
||||
|
||||
public function getExternalPath(): ?string
|
||||
{
|
||||
return $this->external_path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the filename of the attachment.
|
||||
* For a path like %BASE/path/foo.bar, foo.bar will be returned.
|
||||
*
|
||||
* If the path is a URL (can be checked via isExternal()), null will be returned.
|
||||
* If there is no internal copy of the file, null will be returned.
|
||||
*/
|
||||
public function getFilename(): ?string
|
||||
{
|
||||
if ($this->isExternal()) {
|
||||
if (!$this->hasInternal()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -415,7 +451,7 @@ abstract class Attachment extends AbstractNamedDBElement
|
|||
return $this->original_filename;
|
||||
}
|
||||
|
||||
return pathinfo($this->getPath(), PATHINFO_BASENAME);
|
||||
return pathinfo($this->getInternalPath(), PATHINFO_BASENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -488,15 +524,12 @@ abstract class Attachment extends AbstractNamedDBElement
|
|||
}
|
||||
|
||||
/**
|
||||
* Sets the filepath (with relative placeholder) for this attachment.
|
||||
*
|
||||
* @param string $path the new filepath of the attachment
|
||||
*
|
||||
* @return Attachment
|
||||
* Sets the path to a file hosted internally. If you set this path to a file that was not downloaded from the
|
||||
* external source in external_path, make sure to reset external_path.
|
||||
*/
|
||||
public function setPath(string $path): self
|
||||
public function setInternalPath(?string $internal_path): self
|
||||
{
|
||||
$this->path = $path;
|
||||
$this->internal_path = $internal_path;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
@ -512,34 +545,60 @@ abstract class Attachment extends AbstractNamedDBElement
|
|||
}
|
||||
|
||||
/**
|
||||
* Sets the url associated with this attachment.
|
||||
* If the url is empty nothing is changed, to not override the file path.
|
||||
*
|
||||
* @return Attachment
|
||||
* Sets up the paths using a user provided string which might contain an external path or a builtin path. Allows
|
||||
* resetting the external path if an internal path exists. Resets any other paths if a (nonempty) new path is set.
|
||||
*/
|
||||
#[Groups(['attachment:write'])]
|
||||
#[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
|
||||
{
|
||||
//Do nothing if the URL is empty
|
||||
if ($url === null || $url === '') {
|
||||
//Don't allow the user to set an empty external path if the internal path is empty already
|
||||
if (($url === null || $url === "") && !$this->hasInternal()) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$url = trim($url);
|
||||
//Escape spaces in URL
|
||||
$url = str_replace(' ', '%20', $url);
|
||||
|
||||
//Only set if the URL is not empty
|
||||
if ($url !== '') {
|
||||
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!');
|
||||
//The URL field can also contain the special builtin internal paths, so we need to distinguish here
|
||||
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;
|
||||
}
|
||||
|
||||
$this->path = $url;
|
||||
|
||||
/**
|
||||
* 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
|
||||
$external_path = str_replace(' ', '%20', $external_path);
|
||||
|
||||
if($this->external_path === $external_path) {
|
||||
//Nothing changed, nothing to do
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->external_path = $external_path;
|
||||
$this->internal_path = null;
|
||||
//Reset internal filename
|
||||
$this->original_filename = null;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
@ -551,12 +610,17 @@ abstract class Attachment extends AbstractNamedDBElement
|
|||
/**
|
||||
* Checks if the given path is a path to a builtin resource.
|
||||
*
|
||||
* @param string $path The path that should be checked
|
||||
* @param string|null $path The path that should be checked
|
||||
*
|
||||
* @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
|
||||
$tmp = explode('/', $path);
|
||||
//Builtins must have a %PLACEHOLDER% construction
|
||||
|
|
|
@ -162,7 +162,7 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement
|
|||
*
|
||||
* @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)) {
|
||||
return str_replace('%PARTNUMBER%', $partnr, $this->auto_product_url);
|
||||
|
|
|
@ -318,6 +318,7 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement
|
|||
return new ArrayCollection();
|
||||
}
|
||||
|
||||
//@phpstan-ignore-next-line
|
||||
return $this->children ?? new ArrayCollection();
|
||||
}
|
||||
|
||||
|
|
|
@ -54,7 +54,7 @@ class OAuthToken extends AbstractNamedDBElement implements AccessTokenInterface
|
|||
*/
|
||||
private const DEFAULT_EXPIRATION_TIME = 3600;
|
||||
|
||||
public function __construct(string $name, ?string $refresh_token, ?string $token = null, \DateTimeImmutable $expires_at = null)
|
||||
public function __construct(string $name, ?string $refresh_token, ?string $token = null, ?\DateTimeImmutable $expires_at = null)
|
||||
{
|
||||
//If token is given, you also have to give the expires_at date
|
||||
if ($token !== null && $expires_at === null) {
|
||||
|
|
|
@ -208,7 +208,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
|
|||
*/
|
||||
#[Groups(['parameter:read', 'full'])]
|
||||
#[SerializedName('formatted')]
|
||||
public function getFormattedValue(): string
|
||||
public function getFormattedValue(bool $latex_formatted = false): string
|
||||
{
|
||||
//If we just only have text value, return early
|
||||
if (null === $this->value_typical && null === $this->value_min && null === $this->value_max) {
|
||||
|
@ -218,7 +218,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
|
|||
$str = '';
|
||||
$bracket_opened = false;
|
||||
if ($this->value_typical) {
|
||||
$str .= $this->getValueTypicalWithUnit();
|
||||
$str .= $this->getValueTypicalWithUnit($latex_formatted);
|
||||
if ($this->value_min || $this->value_max) {
|
||||
$bracket_opened = true;
|
||||
$str .= ' (';
|
||||
|
@ -226,11 +226,11 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
|
|||
}
|
||||
|
||||
if ($this->value_max && $this->value_min) {
|
||||
$str .= $this->getValueMinWithUnit().' ... '.$this->getValueMaxWithUnit();
|
||||
$str .= $this->getValueMinWithUnit($latex_formatted).' ... '.$this->getValueMaxWithUnit($latex_formatted);
|
||||
} elseif ($this->value_max) {
|
||||
$str .= 'max. '.$this->getValueMaxWithUnit();
|
||||
$str .= 'max. '.$this->getValueMaxWithUnit($latex_formatted);
|
||||
} elseif ($this->value_min) {
|
||||
$str .= 'min. '.$this->getValueMinWithUnit();
|
||||
$str .= 'min. '.$this->getValueMinWithUnit($latex_formatted);
|
||||
}
|
||||
|
||||
//Add closing bracket
|
||||
|
@ -344,25 +344,25 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
|
|||
/**
|
||||
* Return a formatted version with the minimum value with the unit of this parameter.
|
||||
*/
|
||||
public function getValueTypicalWithUnit(): string
|
||||
public function getValueTypicalWithUnit(bool $with_latex = false): string
|
||||
{
|
||||
return $this->formatWithUnit($this->value_typical);
|
||||
return $this->formatWithUnit($this->value_typical, with_latex: $with_latex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a formatted version with the maximum value with the unit of this parameter.
|
||||
*/
|
||||
public function getValueMaxWithUnit(): string
|
||||
public function getValueMaxWithUnit(bool $with_latex = false): string
|
||||
{
|
||||
return $this->formatWithUnit($this->value_max);
|
||||
return $this->formatWithUnit($this->value_max, with_latex: $with_latex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a formatted version with the typical value with the unit of this parameter.
|
||||
*/
|
||||
public function getValueMinWithUnit(): string
|
||||
public function getValueMinWithUnit(bool $with_latex = false): string
|
||||
{
|
||||
return $this->formatWithUnit($this->value_min);
|
||||
return $this->formatWithUnit($this->value_min, with_latex: $with_latex);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -441,11 +441,18 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
|
|||
/**
|
||||
* Return a string representation and (if possible) with its unit.
|
||||
*/
|
||||
protected function formatWithUnit(float $value, string $format = '%g'): string
|
||||
protected function formatWithUnit(float $value, string $format = '%g', bool $with_latex = false): string
|
||||
{
|
||||
$str = sprintf($format, $value);
|
||||
if ($this->unit !== '') {
|
||||
return $str.' '.$this->unit;
|
||||
|
||||
if (!$with_latex) {
|
||||
$unit = $this->unit;
|
||||
} else {
|
||||
$unit = '$\mathrm{'.$this->unit.'}$';
|
||||
}
|
||||
|
||||
return $str.' '.$unit;
|
||||
}
|
||||
|
||||
return $str;
|
||||
|
|
|
@ -52,8 +52,8 @@ class AttachmentDeleteListener
|
|||
#[PreUpdate]
|
||||
public function preUpdateHandler(Attachment $attachment, PreUpdateEventArgs $event): void
|
||||
{
|
||||
if ($event->hasChangedField('path')) {
|
||||
$old_path = $event->getOldValue('path');
|
||||
if ($event->hasChangedField('internal_path')) {
|
||||
$old_path = $event->getOldValue('internal_path');
|
||||
|
||||
//Dont delete file if the attachment uses a builtin ressource:
|
||||
if (Attachment::checkIfBuiltin($old_path)) {
|
||||
|
|
|
@ -100,6 +100,15 @@ class AttachmentFilterType extends AbstractType
|
|||
'label' => 'attachment.edit.show_in_table'
|
||||
]);
|
||||
|
||||
$builder->add('originalFileName', TextConstraintType::class, [
|
||||
'label' => 'attachment.file_name'
|
||||
]);
|
||||
|
||||
$builder->add('externalLink', TextConstraintType::class, [
|
||||
'label' => 'attachment.table.external_link'
|
||||
]);
|
||||
|
||||
|
||||
$builder->add('lastModified', DateTimeConstraintType::class, [
|
||||
'label' => 'lastModified'
|
||||
]);
|
||||
|
|
|
@ -62,7 +62,7 @@ trait WithPermPresetsTrait
|
|||
return json_encode($user->getPermissions());
|
||||
}
|
||||
|
||||
public function setContainer(ContainerInterface $container = null): void
|
||||
public function setContainer(?ContainerInterface $container = null): void
|
||||
{
|
||||
if ($container !== null) {
|
||||
$this->container = $container;
|
||||
|
|
|
@ -58,15 +58,15 @@ class AttachmentRepository extends DBElementRepository
|
|||
{
|
||||
$qb = $this->createQueryBuilder('attachment');
|
||||
$qb->select('COUNT(attachment)')
|
||||
->where('attachment.path LIKE :like');
|
||||
$qb->setParameter('like', '\\%SECURE\\%%');
|
||||
->where('attachment.internal_path LIKE :like ESCAPE \'#\'');
|
||||
$qb->setParameter('like', '#%SECURE#%%');
|
||||
$query = $qb->getQuery();
|
||||
|
||||
return (int) $query->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the count of all external attachments (attachments only containing a URL).
|
||||
* Gets the count of all external attachments (attachments containing only an external path).
|
||||
*
|
||||
* @throws NoResultException
|
||||
* @throws NonUniqueResultException
|
||||
|
@ -75,17 +75,16 @@ class AttachmentRepository extends DBElementRepository
|
|||
{
|
||||
$qb = $this->createQueryBuilder('attachment');
|
||||
$qb->select('COUNT(attachment)')
|
||||
->where('ILIKE(attachment.path, :http) = TRUE')
|
||||
->orWhere('ILIKE(attachment.path, :https) = TRUE');
|
||||
$qb->setParameter('http', 'http://%');
|
||||
$qb->setParameter('https', 'https://%');
|
||||
->where('attachment.external_path IS NOT NULL')
|
||||
->andWhere('attachment.internal_path IS NULL');
|
||||
|
||||
$query = $qb->getQuery();
|
||||
|
||||
return (int) $query->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the count of all attachments where a user uploaded a file.
|
||||
* Gets the count of all attachments where a user uploaded a file or a file was downloaded from an external source.
|
||||
*
|
||||
* @throws NoResultException
|
||||
* @throws NonUniqueResultException
|
||||
|
@ -94,12 +93,12 @@ class AttachmentRepository extends DBElementRepository
|
|||
{
|
||||
$qb = $this->createQueryBuilder('attachment');
|
||||
$qb->select('COUNT(attachment)')
|
||||
->where('attachment.path LIKE :base')
|
||||
->orWhere('attachment.path LIKE :media')
|
||||
->orWhere('attachment.path LIKE :secure');
|
||||
$qb->setParameter('secure', '\\%SECURE\\%%');
|
||||
$qb->setParameter('base', '\\%BASE\\%%');
|
||||
$qb->setParameter('media', '\\%MEDIA\\%%');
|
||||
->where('attachment.internal_path LIKE :base ESCAPE \'#\'')
|
||||
->orWhere('attachment.internal_path LIKE :media ESCAPE \'#\'')
|
||||
->orWhere('attachment.internal_path LIKE :secure ESCAPE \'#\'');
|
||||
$qb->setParameter('secure', '#%SECURE#%%');
|
||||
$qb->setParameter('base', '#%BASE#%%');
|
||||
$qb->setParameter('media', '#%MEDIA#%%');
|
||||
$query = $qb->getQuery();
|
||||
|
||||
return (int) $query->getSingleScalarResult();
|
||||
|
|
|
@ -160,7 +160,7 @@ class LogEntryRepository extends DBElementRepository
|
|||
* @param int|null $limit
|
||||
* @param int|null $offset
|
||||
*/
|
||||
public function getLogsOrderedByTimestamp(string $order = 'DESC', int $limit = null, int $offset = null): array
|
||||
public function getLogsOrderedByTimestamp(string $order = 'DESC', ?int $limit = null, ?int $offset = null): array
|
||||
{
|
||||
return $this->findBy([], ['timestamp' => $order], $limit, $offset);
|
||||
}
|
||||
|
|
|
@ -131,7 +131,7 @@ class ApiTokenAuthenticator implements AuthenticatorInterface
|
|||
/**
|
||||
* @see https://datatracker.ietf.org/doc/html/rfc6750#section-3
|
||||
*/
|
||||
private function getAuthenticateHeader(string $errorDescription = null): string
|
||||
private function getAuthenticateHeader(?string $errorDescription = null): string
|
||||
{
|
||||
$data = [
|
||||
'realm' => $this->realm,
|
||||
|
|
|
@ -47,7 +47,7 @@ class AuthenticationEntryPoint implements AuthenticationEntryPointInterface
|
|||
) {
|
||||
}
|
||||
|
||||
public function start(Request $request, AuthenticationException $authException = null): Response
|
||||
public function start(Request $request, ?AuthenticationException $authException = null): Response
|
||||
{
|
||||
//Check if the request is an API request
|
||||
if ($this->isJSONRequest($request)) {
|
||||
|
|
|
@ -116,10 +116,10 @@ class SamlUserFactory implements SamlUserFactoryInterface, EventSubscriberInterf
|
|||
* Maps a list of SAML roles to a local group ID.
|
||||
* The first available mapping will be used (so the order of the $map is important, first match wins).
|
||||
* @param array $roles The list of SAML roles
|
||||
* @param array $map|null The mapping from SAML roles. If null, the global mapping will be used.
|
||||
* @param array|null $map The mapping from SAML roles. If null, the global mapping will be used.
|
||||
* @return int|null The ID of the local group or null if no mapping was found.
|
||||
*/
|
||||
public function mapSAMLRolesToLocalGroupID(array $roles, array $map = null): ?int
|
||||
public function mapSAMLRolesToLocalGroupID(array $roles, ?array $map = null): ?int
|
||||
{
|
||||
$map ??= $this->saml_role_mapping;
|
||||
|
||||
|
|
|
@ -42,7 +42,7 @@ class AttachmentNormalizer implements NormalizerInterface, NormalizerAwareInterf
|
|||
{
|
||||
}
|
||||
|
||||
public function normalize(mixed $object, string $format = null, array $context = []): array|null
|
||||
public function normalize(mixed $object, ?string $format = null, array $context = []): array|null
|
||||
{
|
||||
if (!$object instanceof Attachment) {
|
||||
throw new \InvalidArgumentException('This normalizer only supports Attachment objects!');
|
||||
|
@ -52,15 +52,19 @@ class AttachmentNormalizer implements NormalizerInterface, NormalizerAwareInterf
|
|||
$context[self::ALREADY_CALLED] = true;
|
||||
|
||||
$data = $this->normalizer->normalize($object, $format, $context);
|
||||
$data['internal_path'] = $this->attachmentURLGenerator->getInternalViewURL($object);
|
||||
|
||||
$data['media_url'] = $this->attachmentURLGenerator->getViewURL($object);
|
||||
//Add thumbnail url if the attachment is a picture
|
||||
$data['thumbnail_url'] = $object->isPicture() ? $this->attachmentURLGenerator->getThumbnailURL($object) : null;
|
||||
|
||||
//For backwards compatibility reasons
|
||||
//Deprecated: Use internal_path and external_path instead
|
||||
$data['media_url'] = $data['internal_path'] ?? $object->getExternalPath();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
|
||||
public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
|
||||
{
|
||||
// avoid recursion: only call once per object
|
||||
if (isset($context[self::ALREADY_CALLED])) {
|
||||
|
|
|
@ -33,12 +33,12 @@ use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
|||
class BigNumberNormalizer implements NormalizerInterface, DenormalizerInterface
|
||||
{
|
||||
|
||||
public function supportsNormalization($data, string $format = null, array $context = []): bool
|
||||
public function supportsNormalization($data, ?string $format = null, array $context = []): bool
|
||||
{
|
||||
return $data instanceof BigNumber;
|
||||
}
|
||||
|
||||
public function normalize($object, string $format = null, array $context = []): string
|
||||
public function normalize($object, ?string $format = null, array $context = []): string
|
||||
{
|
||||
if (!$object instanceof BigNumber) {
|
||||
throw new \InvalidArgumentException('This normalizer only supports BigNumber objects!');
|
||||
|
@ -58,7 +58,7 @@ class BigNumberNormalizer implements NormalizerInterface, DenormalizerInterface
|
|||
];
|
||||
}
|
||||
|
||||
public function denormalize(mixed $data, string $type, string $format = null, array $context = []): BigNumber|null
|
||||
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): BigNumber|null
|
||||
{
|
||||
if (!is_a($type, BigNumber::class, true)) {
|
||||
throw new \InvalidArgumentException('This normalizer only supports BigNumber objects!');
|
||||
|
@ -67,7 +67,7 @@ class BigNumberNormalizer implements NormalizerInterface, DenormalizerInterface
|
|||
return $type::of($data);
|
||||
}
|
||||
|
||||
public function supportsDenormalization(mixed $data, string $type, string $format = null, array $context = []): bool
|
||||
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
|
||||
{
|
||||
//data must be a string or a number (int, float, etc.) and the type must be BigNumber or BigDecimal
|
||||
return (is_string($data) || is_numeric($data)) && (is_subclass_of($type, BigNumber::class));
|
||||
|
|
|
@ -63,13 +63,13 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm
|
|||
{
|
||||
}
|
||||
|
||||
public function supportsNormalization($data, string $format = null, array $context = []): bool
|
||||
public function supportsNormalization($data, ?string $format = null, array $context = []): bool
|
||||
{
|
||||
//We only remove the type field for CSV export
|
||||
return !isset($context[self::ALREADY_CALLED]) && $format === 'csv' && $data instanceof Part ;
|
||||
}
|
||||
|
||||
public function normalize($object, string $format = null, array $context = []): array
|
||||
public function normalize($object, ?string $format = null, array $context = []): array
|
||||
{
|
||||
if (!$object instanceof Part) {
|
||||
throw new \InvalidArgumentException('This normalizer only supports Part objects!');
|
||||
|
@ -117,7 +117,7 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm
|
|||
return $data;
|
||||
}
|
||||
|
||||
public function denormalize($data, string $type, string $format = null, array $context = []): ?Part
|
||||
public function denormalize($data, string $type, ?string $format = null, array $context = []): ?Part
|
||||
{
|
||||
$this->normalizeKeys($data);
|
||||
|
||||
|
|
|
@ -49,7 +49,7 @@ class StructuralElementDenormalizer implements DenormalizerInterface, Denormaliz
|
|||
{
|
||||
}
|
||||
|
||||
public function supportsDenormalization($data, string $type, string $format = null, array $context = []): bool
|
||||
public function supportsDenormalization($data, string $type, ?string $format = null, array $context = []): bool
|
||||
{
|
||||
//Only denormalize if we are doing a file import operation
|
||||
if (!($context['partdb_import'] ?? false)) {
|
||||
|
@ -78,7 +78,7 @@ class StructuralElementDenormalizer implements DenormalizerInterface, Denormaliz
|
|||
* @return AbstractStructuralDBElement|null
|
||||
* @phpstan-return T|null
|
||||
*/
|
||||
public function denormalize($data, string $type, string $format = null, array $context = []): ?AbstractStructuralDBElement
|
||||
public function denormalize($data, string $type, ?string $format = null, array $context = []): ?AbstractStructuralDBElement
|
||||
{
|
||||
//Do not use API Platform's denormalizer
|
||||
$context[SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER] = true;
|
||||
|
|
|
@ -36,7 +36,7 @@ class StructuralElementFromNameDenormalizer implements DenormalizerInterface
|
|||
{
|
||||
}
|
||||
|
||||
public function supportsDenormalization($data, string $type, string $format = null, array $context = []): bool
|
||||
public function supportsDenormalization($data, string $type, ?string $format = null, array $context = []): bool
|
||||
{
|
||||
//Only denormalize if we are doing a file import operation
|
||||
if (!($context['partdb_import'] ?? false)) {
|
||||
|
@ -51,7 +51,7 @@ class StructuralElementFromNameDenormalizer implements DenormalizerInterface
|
|||
* @phpstan-param class-string<T> $type
|
||||
* @phpstan-return T|null
|
||||
*/
|
||||
public function denormalize($data, string $type, string $format = null, array $context = []): AbstractStructuralDBElement|null
|
||||
public function denormalize($data, string $type, ?string $format = null, array $context = []): AbstractStructuralDBElement|null
|
||||
{
|
||||
//Retrieve the repository for the given type
|
||||
/** @var StructuralDBElementRepository<T> $repo */
|
||||
|
|
|
@ -38,7 +38,7 @@ class StructuralElementNormalizer implements NormalizerInterface
|
|||
{
|
||||
}
|
||||
|
||||
public function supportsNormalization($data, string $format = null, array $context = []): bool
|
||||
public function supportsNormalization($data, ?string $format = null, array $context = []): bool
|
||||
{
|
||||
//Only normalize if we are doing a file export operation
|
||||
if (!($context['partdb_export'] ?? false)) {
|
||||
|
@ -48,7 +48,7 @@ class StructuralElementNormalizer implements NormalizerInterface
|
|||
return $data instanceof AbstractStructuralDBElement;
|
||||
}
|
||||
|
||||
public function normalize($object, string $format = null, array $context = []): mixed
|
||||
public function normalize($object, ?string $format = null, array $context = []): mixed
|
||||
{
|
||||
if (!$object instanceof AbstractStructuralDBElement) {
|
||||
throw new \InvalidArgumentException('This normalizer only supports AbstractStructural objects!');
|
||||
|
|
|
@ -44,35 +44,31 @@ class AttachmentManager
|
|||
*
|
||||
* @param Attachment $attachment The attachment for which the file should be generated
|
||||
*
|
||||
* @return SplFileInfo|null The fileinfo for the attachment file. Null, if the attachment is external or has
|
||||
* @return SplFileInfo|null The fileinfo for the attachment file. Null, if the attachment is only external or has
|
||||
* invalid file.
|
||||
*/
|
||||
public function attachmentToFile(Attachment $attachment): ?SplFileInfo
|
||||
{
|
||||
if ($attachment->isExternal() || !$this->isFileExisting($attachment)) {
|
||||
if (!$this->isInternalFileExisting($attachment)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SplFileInfo($this->toAbsoluteFilePath($attachment));
|
||||
return new SplFileInfo($this->toAbsoluteInternalFilePath($attachment));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute filepath of the attachment. Null is returned, if the attachment is externally saved,
|
||||
* or is not existing.
|
||||
* Returns the absolute filepath to the internal copy of the attachment. Null is returned, if the attachment is
|
||||
* only externally saved, or is not existing.
|
||||
*
|
||||
* @param Attachment $attachment The attachment for which the filepath should be determined
|
||||
*/
|
||||
public function toAbsoluteFilePath(Attachment $attachment): ?string
|
||||
public function toAbsoluteInternalFilePath(Attachment $attachment): ?string
|
||||
{
|
||||
if ($attachment->getPath() === '') {
|
||||
if (!$attachment->hasInternal()){
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($attachment->isExternal()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = $this->pathResolver->placeholderToRealPath($attachment->getPath());
|
||||
$path = $this->pathResolver->placeholderToRealPath($attachment->getInternalPath());
|
||||
|
||||
//realpath does not work with null as argument
|
||||
if (null === $path) {
|
||||
|
@ -89,8 +85,8 @@ class AttachmentManager
|
|||
}
|
||||
|
||||
/**
|
||||
* Checks if the file in this attachement is existing. This works for files on the HDD, and for URLs
|
||||
* (it's not checked if the ressource behind the URL is really existing, so for every external attachment true is returned).
|
||||
* Checks if the file in this attachment is existing. This works for files on the HDD, and for URLs
|
||||
* (it's not checked if the resource behind the URL is really existing, so for every external attachment true is returned).
|
||||
*
|
||||
* @param Attachment $attachment The attachment for which the existence should be checked
|
||||
*
|
||||
|
@ -98,15 +94,23 @@ class AttachmentManager
|
|||
*/
|
||||
public function isFileExisting(Attachment $attachment): bool
|
||||
{
|
||||
if ($attachment->getPath() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($attachment->isExternal()) {
|
||||
if($attachment->hasExternal()){
|
||||
return true;
|
||||
}
|
||||
return $this->isInternalFileExisting($attachment);
|
||||
}
|
||||
|
||||
$absolute_path = $this->toAbsoluteFilePath($attachment);
|
||||
/**
|
||||
* Checks if the internal file in this attachment is existing. Returns false if the attachment doesn't have an
|
||||
* internal file.
|
||||
*
|
||||
* @param Attachment $attachment The attachment for which the existence should be checked
|
||||
*
|
||||
* @return bool true if the file is existing
|
||||
*/
|
||||
public function isInternalFileExisting(Attachment $attachment): bool
|
||||
{
|
||||
$absolute_path = $this->toAbsoluteInternalFilePath($attachment);
|
||||
|
||||
if (null === $absolute_path) {
|
||||
return false;
|
||||
|
@ -117,21 +121,17 @@ class AttachmentManager
|
|||
|
||||
/**
|
||||
* Returns the filesize of the attachments in bytes.
|
||||
* For external attachments or not existing attachments, null is returned.
|
||||
* For purely external attachments or inexistent attachments, null is returned.
|
||||
*
|
||||
* @param Attachment $attachment the filesize for which the filesize should be calculated
|
||||
*/
|
||||
public function getFileSize(Attachment $attachment): ?int
|
||||
{
|
||||
if ($attachment->isExternal()) {
|
||||
if (!$this->isInternalFileExisting($attachment)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->isFileExisting($attachment)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tmp = filesize($this->toAbsoluteFilePath($attachment));
|
||||
$tmp = filesize($this->toAbsoluteInternalFilePath($attachment));
|
||||
|
||||
return false !== $tmp ? $tmp : null;
|
||||
}
|
||||
|
|
|
@ -115,12 +115,16 @@ class AttachmentPathResolver
|
|||
* Converts an relative placeholder filepath (with %MEDIA% or older %BASE%) to an absolute filepath on disk.
|
||||
* The directory separator is always /. Relative pathes are not realy possible (.. is striped).
|
||||
*
|
||||
* @param string $placeholder_path the filepath with placeholder for which the real path should be determined
|
||||
* @param string|null $placeholder_path the filepath with placeholder for which the real path should be determined
|
||||
*
|
||||
* @return string|null The absolute real path of the file, or null if the placeholder path is invalid
|
||||
*/
|
||||
public function placeholderToRealPath(string $placeholder_path): ?string
|
||||
public function placeholderToRealPath(?string $placeholder_path): ?string
|
||||
{
|
||||
if (null === $placeholder_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
//The new attachments use %MEDIA% as placeholders, which is the directory set in media_directory
|
||||
//Older path entries are given via %BASE% which was the project root
|
||||
|
||||
|
|
|
@ -55,7 +55,7 @@ class AttachmentReverseSearch
|
|||
$repo = $this->em->getRepository(Attachment::class);
|
||||
|
||||
return $repo->findBy([
|
||||
'path' => [$relative_path_new, $relative_path_old],
|
||||
'internal_path' => [$relative_path_new, $relative_path_old],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
@ -71,6 +71,7 @@ class AttachmentSubmitHandler
|
|||
protected MimeTypesInterface $mimeTypes,
|
||||
protected FileTypeFilterTools $filterTools,
|
||||
protected AttachmentsSettings $settings,
|
||||
protected readonly SVGSanitizer $SVGSanitizer,
|
||||
)
|
||||
{
|
||||
//The mapping used to determine which folder will be used for an attachment type
|
||||
|
@ -209,13 +210,16 @@ class AttachmentSubmitHandler
|
|||
if ($file instanceof UploadedFile) {
|
||||
|
||||
$this->upload($attachment, $file, $secure_attachment);
|
||||
} elseif ($upload->downloadUrl && $attachment->isExternal()) {
|
||||
} elseif ($upload->downloadUrl && $attachment->hasExternal()) {
|
||||
$this->downloadURL($attachment, $secure_attachment);
|
||||
}
|
||||
|
||||
//Move the attachment files to secure location (and back) if needed
|
||||
$this->moveFile($attachment, $secure_attachment);
|
||||
|
||||
//Sanitize the SVG if needed
|
||||
$this->sanitizeSVGAttachment($attachment);
|
||||
|
||||
//Rename blacklisted (unsecure) files to a better extension
|
||||
$this->renameBlacklistedExtensions($attachment);
|
||||
|
||||
|
@ -246,12 +250,12 @@ class AttachmentSubmitHandler
|
|||
protected function renameBlacklistedExtensions(Attachment $attachment): Attachment
|
||||
{
|
||||
//We can not do anything on builtins or external ressources
|
||||
if ($attachment->isBuiltIn() || $attachment->isExternal()) {
|
||||
if ($attachment->isBuiltIn() || !$attachment->hasInternal()) {
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
//Determine the old filepath
|
||||
$old_path = $this->pathResolver->placeholderToRealPath($attachment->getPath());
|
||||
$old_path = $this->pathResolver->placeholderToRealPath($attachment->getInternalPath());
|
||||
if ($old_path === null || $old_path === '' || !file_exists($old_path)) {
|
||||
return $attachment;
|
||||
}
|
||||
|
@ -269,7 +273,7 @@ class AttachmentSubmitHandler
|
|||
$fs->rename($old_path, $new_path);
|
||||
|
||||
//Update the attachment
|
||||
$attachment->setPath($this->pathResolver->realPathToPlaceholder($new_path));
|
||||
$attachment->setInternalPath($this->pathResolver->realPathToPlaceholder($new_path));
|
||||
}
|
||||
|
||||
|
||||
|
@ -277,17 +281,17 @@ class AttachmentSubmitHandler
|
|||
}
|
||||
|
||||
/**
|
||||
* Move the given attachment to secure location (or back to public folder) if needed.
|
||||
* Move the internal copy of the given attachment to a secure location (or back to public folder) if needed.
|
||||
*
|
||||
* @param Attachment $attachment the attachment for which the file should be moved
|
||||
* @param bool $secure_location this value determines, if the attachment is moved to the secure or public folder
|
||||
*
|
||||
* @return Attachment The attachment with the updated filepath
|
||||
* @return Attachment The attachment with the updated internal filepath
|
||||
*/
|
||||
protected function moveFile(Attachment $attachment, bool $secure_location): Attachment
|
||||
{
|
||||
//We can not do anything on builtins or external ressources
|
||||
if ($attachment->isBuiltIn() || $attachment->isExternal()) {
|
||||
if ($attachment->isBuiltIn() || !$attachment->hasInternal()) {
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
|
@ -297,7 +301,7 @@ class AttachmentSubmitHandler
|
|||
}
|
||||
|
||||
//Determine the old filepath
|
||||
$old_path = $this->pathResolver->placeholderToRealPath($attachment->getPath());
|
||||
$old_path = $this->pathResolver->placeholderToRealPath($attachment->getInternalPath());
|
||||
if (!file_exists($old_path)) {
|
||||
return $attachment;
|
||||
}
|
||||
|
@ -321,7 +325,7 @@ class AttachmentSubmitHandler
|
|||
|
||||
//Save info to attachment entity
|
||||
$new_path = $this->pathResolver->realPathToPlaceholder($new_path);
|
||||
$attachment->setPath($new_path);
|
||||
$attachment->setInternalPath($new_path);
|
||||
|
||||
return $attachment;
|
||||
}
|
||||
|
@ -331,7 +335,7 @@ class AttachmentSubmitHandler
|
|||
*
|
||||
* @param bool $secureAttachment True if the file should be moved to the secure attachment storage
|
||||
*
|
||||
* @return Attachment The attachment with the new filepath
|
||||
* @return Attachment The attachment with the downloaded copy
|
||||
*/
|
||||
protected function downloadURL(Attachment $attachment, bool $secureAttachment): Attachment
|
||||
{
|
||||
|
@ -340,16 +344,35 @@ class AttachmentSubmitHandler
|
|||
throw new RuntimeException('Download of attachments is not allowed!');
|
||||
}
|
||||
|
||||
$url = $attachment->getURL();
|
||||
$url = $attachment->getExternalPath();
|
||||
|
||||
$fs = new Filesystem();
|
||||
$attachment_folder = $this->generateAttachmentPath($attachment, $secureAttachment);
|
||||
$tmp_path = $attachment_folder.DIRECTORY_SEPARATOR.$this->generateAttachmentFilename($attachment, 'tmp');
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', $url, [
|
||||
$opts = [
|
||||
'buffer' => false,
|
||||
]);
|
||||
//Use user-agent and other headers to make the server think we are a browser
|
||||
'headers' => [
|
||||
"sec-ch-ua" => "\"Not(A:Brand\";v=\"99\", \"Google Chrome\";v=\"133\", \"Chromium\";v=\"133\"",
|
||||
"sec-ch-ua-mobile" => "?0",
|
||||
"sec-ch-ua-platform" => "\"Windows\"",
|
||||
"user-agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
|
||||
"sec-fetch-site" => "none",
|
||||
"sec-fetch-mode" => "navigate",
|
||||
],
|
||||
|
||||
];
|
||||
$response = $this->httpClient->request('GET', $url, $opts);
|
||||
//Digikey wants TLSv1.3, so try again with that if we get a 403
|
||||
if ($response->getStatusCode() === 403) {
|
||||
$opts['crypto_method'] = STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT;
|
||||
$response = $this->httpClient->request('GET', $url, $opts);
|
||||
}
|
||||
# if you have these changes and downloads still fail, check if it's due to an unknown certificate. Curl by
|
||||
# default uses the systems ca store and that doesn't contain all the intermediate certificates needed to
|
||||
# verify the leafs
|
||||
|
||||
if (200 !== $response->getStatusCode()) {
|
||||
throw new AttachmentDownloadException('Status code: '.$response->getStatusCode());
|
||||
|
@ -401,7 +424,7 @@ class AttachmentSubmitHandler
|
|||
//Make our file path relative to %BASE%
|
||||
$new_path = $this->pathResolver->realPathToPlaceholder($new_path);
|
||||
//Save the path to the attachment
|
||||
$attachment->setPath($new_path);
|
||||
$attachment->setInternalPath($new_path);
|
||||
} catch (TransportExceptionInterface) {
|
||||
throw new AttachmentDownloadException('Transport error!');
|
||||
}
|
||||
|
@ -429,7 +452,9 @@ class AttachmentSubmitHandler
|
|||
//Make our file path relative to %BASE%
|
||||
$file_path = $this->pathResolver->realPathToPlaceholder($file_path);
|
||||
//Save the path to the attachment
|
||||
$attachment->setPath($file_path);
|
||||
$attachment->setInternalPath($file_path);
|
||||
//reset any external paths the attachment might have had
|
||||
$attachment->setExternalPath(null);
|
||||
//And save original filename
|
||||
$attachment->setFilename($file->getClientOriginalName());
|
||||
|
||||
|
@ -479,4 +504,32 @@ class AttachmentSubmitHandler
|
|||
|
||||
return $this->max_upload_size_bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes the given SVG file, if the attachment is an internal SVG file.
|
||||
* @param Attachment $attachment
|
||||
* @return Attachment
|
||||
*/
|
||||
public function sanitizeSVGAttachment(Attachment $attachment): Attachment
|
||||
{
|
||||
//We can not do anything on builtins or external ressources
|
||||
if ($attachment->isBuiltIn() || !$attachment->hasInternal()) {
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
//Resolve the path to the file
|
||||
$path = $this->pathResolver->placeholderToRealPath($attachment->getInternalPath());
|
||||
|
||||
//Check if the file exists
|
||||
if (!file_exists($path)) {
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
//Check if the file is an SVG
|
||||
if ($attachment->getExtension() === "svg") {
|
||||
$this->SVGSanitizer->sanitizeFile($path);
|
||||
}
|
||||
|
||||
return $attachment;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -92,9 +92,9 @@ class AttachmentURLGenerator
|
|||
* Returns a URL under which the attachment file can be viewed.
|
||||
* @return string|null The URL or null if the attachment file is not existing
|
||||
*/
|
||||
public function getViewURL(Attachment $attachment): ?string
|
||||
public function getInternalViewURL(Attachment $attachment): ?string
|
||||
{
|
||||
$absolute_path = $this->attachmentHelper->toAbsoluteFilePath($attachment);
|
||||
$absolute_path = $this->attachmentHelper->toAbsoluteInternalFilePath($attachment);
|
||||
if (null === $absolute_path) {
|
||||
return null;
|
||||
}
|
||||
|
@ -111,6 +111,7 @@ class AttachmentURLGenerator
|
|||
|
||||
/**
|
||||
* Returns a URL to a thumbnail of the attachment file.
|
||||
* For external files the original URL is returned.
|
||||
* @return string|null The URL or null if the attachment file is not existing
|
||||
*/
|
||||
public function getThumbnailURL(Attachment $attachment, string $filter_name = 'thumbnail_sm'): ?string
|
||||
|
@ -119,11 +120,14 @@ class AttachmentURLGenerator
|
|||
throw new InvalidArgumentException('Thumbnail creation only works for picture attachments!');
|
||||
}
|
||||
|
||||
if ($attachment->isExternal() && ($attachment->getURL() !== null && $attachment->getURL() !== '')) {
|
||||
return $attachment->getURL();
|
||||
if (!$attachment->hasInternal()){
|
||||
if($attachment->hasExternal()) {
|
||||
return $attachment->getExternalPath();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$absolute_path = $this->attachmentHelper->toAbsoluteFilePath($attachment);
|
||||
$absolute_path = $this->attachmentHelper->toAbsoluteInternalFilePath($attachment);
|
||||
if (null === $absolute_path) {
|
||||
return null;
|
||||
}
|
||||
|
@ -137,7 +141,7 @@ class AttachmentURLGenerator
|
|||
//GD can not work with SVG, so serve it directly...
|
||||
//We can not use getExtension here, because it uses the original filename and not the real extension
|
||||
//Instead we use the logic, which is also used to determine if the attachment is a picture
|
||||
$extension = pathinfo(parse_url($attachment->getPath(), PHP_URL_PATH) ?? '', PATHINFO_EXTENSION);
|
||||
$extension = pathinfo(parse_url($attachment->getInternalPath(), PHP_URL_PATH) ?? '', PATHINFO_EXTENSION);
|
||||
if ('svg' === $extension) {
|
||||
return $this->assets->getUrl($asset_path);
|
||||
}
|
||||
|
@ -157,7 +161,7 @@ class AttachmentURLGenerator
|
|||
/**
|
||||
* Returns a download link to the file associated with the attachment.
|
||||
*/
|
||||
public function getDownloadURL(Attachment $attachment): string
|
||||
public function getInternalDownloadURL(Attachment $attachment): string
|
||||
{
|
||||
//Redirect always to download controller, which sets the correct headers for downloading:
|
||||
return $this->urlGenerator->generate('attachment_download', ['id' => $attachment->getID()]);
|
||||
|
|
58
src/Services/Attachments/SVGSanitizer.php
Normal file
58
src/Services/Attachments/SVGSanitizer.php
Normal file
|
@ -0,0 +1,58 @@
|
|||
<?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\Services\Attachments;
|
||||
|
||||
use Rhukster\DomSanitizer\DOMSanitizer;
|
||||
|
||||
class SVGSanitizer
|
||||
{
|
||||
|
||||
/**
|
||||
* Sanitizes the given SVG string by removing any potentially harmful content (like inline scripts).
|
||||
* @param string $input
|
||||
* @return string
|
||||
*/
|
||||
public function sanitizeString(string $input): string
|
||||
{
|
||||
return (new DOMSanitizer(DOMSanitizer::SVG))->sanitize($input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes the given SVG file by removing any potentially harmful content (like inline scripts).
|
||||
* The sanitized content is written back to the file.
|
||||
* @param string $filepath
|
||||
*/
|
||||
public function sanitizeFile(string $filepath): void
|
||||
{
|
||||
//Open the file and read the content
|
||||
$content = file_get_contents($filepath);
|
||||
if ($content === false) {
|
||||
throw new \RuntimeException('Could not read file: ' . $filepath);
|
||||
}
|
||||
//Sanitize the content
|
||||
$sanitizedContent = $this->sanitizeString($content);
|
||||
//Write the sanitized content back to the file
|
||||
file_put_contents($filepath, $sanitizedContent);
|
||||
}
|
||||
}
|
|
@ -27,6 +27,7 @@ use App\Entity\Parts\Category;
|
|||
use App\Entity\Parts\Footprint;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Services\Cache\ElementCacheTagGenerator;
|
||||
use App\Services\EntityURLGenerator;
|
||||
use App\Services\Trees\NodesListBuilder;
|
||||
use App\Settings\MiscSettings\KiCadEDASettings;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
@ -48,6 +49,7 @@ class KiCadHelper
|
|||
private readonly EntityManagerInterface $em,
|
||||
private readonly ElementCacheTagGenerator $tagGenerator,
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
private readonly EntityURLGenerator $entityURLGenerator,
|
||||
private readonly TranslatorInterface $translator,
|
||||
KiCadEDASettings $kiCadEDASettings,
|
||||
) {
|
||||
|
@ -68,6 +70,10 @@ class KiCadHelper
|
|||
$secure_class_name = $this->tagGenerator->getElementTypeCacheTag(Category::class);
|
||||
$item->tag($secure_class_name);
|
||||
|
||||
//Invalidate the cache on part changes (as the visibility depends on parts, and the parts can change)
|
||||
$secure_class_name = $this->tagGenerator->getElementTypeCacheTag(Part::class);
|
||||
$item->tag($secure_class_name);
|
||||
|
||||
//If the category depth is smaller than 0, create only one dummy category
|
||||
if ($this->category_depth < 0) {
|
||||
return [
|
||||
|
@ -112,6 +118,8 @@ class KiCadHelper
|
|||
$result[] = [
|
||||
'id' => (string)$category->getId(),
|
||||
'name' => $category->getFullPath('/'),
|
||||
//Show the category link as the category description, this also fixes an segfault in KiCad see issue #878
|
||||
'description' => $this->entityURLGenerator->listPartsURL($category),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
@ -247,7 +247,8 @@ trait EntityMergerHelperTrait
|
|||
{
|
||||
return $this->mergeCollections($target, $other, 'attachments', fn(Attachment $t, Attachment $o): bool => $t->getName() === $o->getName()
|
||||
&& $t->getAttachmentType() === $o->getAttachmentType()
|
||||
&& $t->getPath() === $o->getPath());
|
||||
&& $t->getExternalPath() === $o->getExternalPath()
|
||||
&& $t->getInternalPath() === $o->getInternalPath());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -156,27 +156,36 @@ class EntityURLGenerator
|
|||
|
||||
public function viewURL(Attachment $entity): string
|
||||
{
|
||||
if ($entity->isExternal()) { //For external attachments, return the link to external path
|
||||
return $entity->getURL() ?? throw new \RuntimeException('External attachment has no URL!');
|
||||
//If the underlying file path is invalid, null gets returned, which is not allowed here.
|
||||
//We still have the chance to use an external path, if it is set.
|
||||
if ($entity->hasInternal() && ($url = $this->attachmentURLGenerator->getInternalViewURL($entity)) !== null) {
|
||||
return $url;
|
||||
}
|
||||
//return $this->urlGenerator->generate('attachment_view', ['id' => $entity->getID()]);
|
||||
return $this->attachmentURLGenerator->getViewURL($entity) ?? '';
|
||||
|
||||
if($entity->hasExternal()) {
|
||||
return $entity->getExternalPath();
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Attachment has no internal nor external path!');
|
||||
}
|
||||
|
||||
public function downloadURL($entity): string
|
||||
{
|
||||
if ($entity instanceof Attachment) {
|
||||
if ($entity->isExternal()) { //For external attachments, return the link to external path
|
||||
return $entity->getURL() ?? throw new \RuntimeException('External attachment has no URL!');
|
||||
}
|
||||
|
||||
return $this->attachmentURLGenerator->getDownloadURL($entity);
|
||||
}
|
||||
|
||||
//Otherwise throw an error
|
||||
if (!($entity instanceof Attachment)) {
|
||||
throw new EntityNotSupportedException(sprintf('The given entity is not supported yet! Passed class type: %s', $entity::class));
|
||||
}
|
||||
|
||||
if ($entity->hasInternal()) {
|
||||
return $this->attachmentURLGenerator->getInternalDownloadURL($entity);
|
||||
}
|
||||
|
||||
if($entity->hasExternal()) {
|
||||
return $entity->getExternalPath();
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Attachment has not internal or external path!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an URL to a page, where info about this entity can be viewed.
|
||||
*
|
||||
|
|
|
@ -357,7 +357,7 @@ class EntityImporter
|
|||
* @param iterable $entities the list of entities that should be fixed
|
||||
* @param AbstractStructuralDBElement|null $parent the parent, to which the entity should be set
|
||||
*/
|
||||
protected function correctParentEntites(iterable $entities, AbstractStructuralDBElement $parent = null): void
|
||||
protected function correctParentEntites(iterable $entities, ?AbstractStructuralDBElement $parent = null): void
|
||||
{
|
||||
foreach ($entities as $entity) {
|
||||
/** @var AbstractStructuralDBElement $entity */
|
||||
|
|
|
@ -105,7 +105,7 @@ trait PKImportHelperTrait
|
|||
//Next comes the filename plus extension
|
||||
$path .= '/'.$attachment_row['filename'].'.'.$attachment_row['extension'];
|
||||
|
||||
$attachment->setPath($path);
|
||||
$attachment->setInternalPath($path);
|
||||
|
||||
return $attachment;
|
||||
}
|
||||
|
|
|
@ -72,9 +72,9 @@ class ParameterDTO
|
|||
group: $group);
|
||||
}
|
||||
|
||||
//If the attribute contains "..." or a tilde we assume it is a range
|
||||
if (preg_match('/(\.{3}|~)/', $value) === 1) {
|
||||
$parts = preg_split('/\s*(\.{3}|~)\s*/', $value);
|
||||
//If the attribute contains ".." or "..." or a tilde we assume it is a range
|
||||
if (preg_match('/(\.{2,3}|~)/', $value) === 1) {
|
||||
$parts = preg_split('/\s*(\.{2,3}|~)\s*/', $value);
|
||||
if (count($parts) === 2) {
|
||||
//Try to extract number and unit from value (allow leading +)
|
||||
if ($unit === null || trim($unit) === '') {
|
||||
|
|
|
@ -178,9 +178,21 @@ final class DTOtoEntityConverter
|
|||
//Set the provider reference on the part
|
||||
$entity->setProviderReference(InfoProviderReference::fromPartDTO($dto));
|
||||
|
||||
$param_groups = [];
|
||||
|
||||
//Add parameters
|
||||
foreach ($dto->parameters ?? [] as $parameter) {
|
||||
$entity->addParameter($this->convertParameter($parameter));
|
||||
$new_param = $this->convertParameter($parameter);
|
||||
|
||||
$key = $new_param->getName() . '##' . $new_param->getGroup();
|
||||
//If there is already an parameter with the same name and group, rename the new parameter, by suffixing a number
|
||||
if (count($param_groups[$key] ?? []) > 0) {
|
||||
$new_param->setName($new_param->getName() . ' (' . (count($param_groups[$key]) + 1) . ')');
|
||||
}
|
||||
|
||||
$param_groups[$key][] = $new_param;
|
||||
|
||||
$entity->addParameter($new_param);
|
||||
}
|
||||
|
||||
//Add preview image
|
||||
|
@ -196,6 +208,8 @@ final class DTOtoEntityConverter
|
|||
$entity->setMasterPictureAttachment($preview_image);
|
||||
}
|
||||
|
||||
$attachments_grouped = [];
|
||||
|
||||
//Add other images
|
||||
$images = $this->files_unique($dto->images ?? []);
|
||||
foreach ($images as $image) {
|
||||
|
@ -204,14 +218,29 @@ final class DTOtoEntityConverter
|
|||
continue;
|
||||
}
|
||||
|
||||
$entity->addAttachment($this->convertFile($image, $image_type));
|
||||
$attachment = $this->convertFile($image, $image_type);
|
||||
|
||||
$attachments_grouped[$attachment->getName()][] = $attachment;
|
||||
if (count($attachments_grouped[$attachment->getName()] ?? []) > 1) {
|
||||
$attachment->setName($attachment->getName() . ' (' . (count($attachments_grouped[$attachment->getName()]) + 1) . ')');
|
||||
}
|
||||
|
||||
|
||||
$entity->addAttachment($attachment);
|
||||
}
|
||||
|
||||
//Add datasheets
|
||||
$datasheet_type = $this->getDatasheetType();
|
||||
$datasheets = $this->files_unique($dto->datasheets ?? []);
|
||||
foreach ($datasheets as $datasheet) {
|
||||
$entity->addAttachment($this->convertFile($datasheet, $datasheet_type));
|
||||
$attachment = $this->convertFile($datasheet, $datasheet_type);
|
||||
|
||||
$attachments_grouped[$attachment->getName()][] = $attachment;
|
||||
if (count($attachments_grouped[$attachment->getName()] ?? []) > 1) {
|
||||
$attachment->setName($attachment->getName() . ' (' . (count($attachments_grouped[$attachment->getName()])) . ')');
|
||||
}
|
||||
|
||||
$entity->addAttachment($attachment);
|
||||
}
|
||||
|
||||
//Add orderdetails and prices
|
||||
|
|
|
@ -27,6 +27,7 @@ use App\Entity\Parts\Part;
|
|||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
||||
|
@ -34,10 +35,12 @@ final class PartInfoRetriever
|
|||
{
|
||||
|
||||
private const CACHE_DETAIL_EXPIRATION = 60 * 60 * 24 * 4; // 4 days
|
||||
private const CACHE_RESULT_EXPIRATION = 60 * 60 * 24 * 7; // 7 days
|
||||
private const CACHE_RESULT_EXPIRATION = 60 * 60 * 24 * 4; // 7 days
|
||||
|
||||
public function __construct(private readonly ProviderRegistry $provider_registry,
|
||||
private readonly DTOtoEntityConverter $dto_to_entity_converter, private readonly CacheInterface $partInfoCache)
|
||||
private readonly DTOtoEntityConverter $dto_to_entity_converter, private readonly CacheInterface $partInfoCache,
|
||||
#[Autowire(param: "kernel.debug")]
|
||||
private readonly bool $debugMode = false)
|
||||
{
|
||||
}
|
||||
|
||||
|
@ -56,6 +59,11 @@ final class PartInfoRetriever
|
|||
$provider = $this->provider_registry->getProviderByKey($provider);
|
||||
}
|
||||
|
||||
//Ensure that the provider is active
|
||||
if (!$provider->isActive()) {
|
||||
throw new \RuntimeException("The provider with key {$provider->getProviderKey()} is not active!");
|
||||
}
|
||||
|
||||
if (!$provider instanceof InfoProviderInterface) {
|
||||
throw new \InvalidArgumentException("The provider must be either a provider key or a provider instance!");
|
||||
}
|
||||
|
@ -77,7 +85,7 @@ final class PartInfoRetriever
|
|||
$escaped_keyword = urlencode($keyword);
|
||||
return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) {
|
||||
//Set the expiration time
|
||||
$item->expiresAfter(self::CACHE_RESULT_EXPIRATION);
|
||||
$item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 1);
|
||||
|
||||
return $provider->searchByKeyword($keyword);
|
||||
});
|
||||
|
@ -94,11 +102,16 @@ final class PartInfoRetriever
|
|||
{
|
||||
$provider = $this->provider_registry->getProviderByKey($provider_key);
|
||||
|
||||
//Ensure that the provider is active
|
||||
if (!$provider->isActive()) {
|
||||
throw new \RuntimeException("The provider with key $provider_key is not active!");
|
||||
}
|
||||
|
||||
//Generate key and escape reserved characters from the provider id
|
||||
$escaped_part_id = urlencode($part_id);
|
||||
return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) {
|
||||
//Set the expiration time
|
||||
$item->expiresAfter(self::CACHE_DETAIL_EXPIRATION);
|
||||
$item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 1);
|
||||
|
||||
return $provider->getDetails($part_id);
|
||||
});
|
||||
|
|
|
@ -108,12 +108,15 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
{
|
||||
$request = [
|
||||
'Keywords' => $keyword,
|
||||
'RecordCount' => 50,
|
||||
'RecordStartPosition' => 0,
|
||||
'ExcludeMarketPlaceProducts' => 'true',
|
||||
'Limit' => 50,
|
||||
'Offset' => 0,
|
||||
'FilterOptionsRequest' => [
|
||||
'MarketPlaceFilter' => 'ExcludeMarketPlace',
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->digikeyClient->request('POST', '/Search/v3/Products/Keyword', [
|
||||
//$response = $this->digikeyClient->request('POST', '/Search/v3/Products/Keyword', [
|
||||
$response = $this->digikeyClient->request('POST', '/products/v4/search/keyword', [
|
||||
'json' => $request,
|
||||
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
|
||||
]);
|
||||
|
@ -124,81 +127,101 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
$result = [];
|
||||
$products = $response_array['Products'];
|
||||
foreach ($products as $product) {
|
||||
foreach ($product['ProductVariations'] as $variation) {
|
||||
$result[] = new SearchResultDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $product['DigiKeyPartNumber'],
|
||||
name: $product['ManufacturerPartNumber'],
|
||||
description: $product['DetailedDescription'] ?? $product['ProductDescription'],
|
||||
provider_id: $variation['DigiKeyProductNumber'],
|
||||
name: $product['ManufacturerProductNumber'],
|
||||
description: $product['Description']['DetailedDescription'] ?? $product['Description']['ProductDescription'],
|
||||
category: $this->getCategoryString($product),
|
||||
manufacturer: $product['Manufacturer']['Value'] ?? null,
|
||||
mpn: $product['ManufacturerPartNumber'],
|
||||
preview_image_url: $product['PrimaryPhoto'] ?? null,
|
||||
manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']),
|
||||
manufacturer: $product['Manufacturer']['Name'] ?? null,
|
||||
mpn: $product['ManufacturerProductNumber'],
|
||||
preview_image_url: $product['PhotoUrl'] ?? null,
|
||||
manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']['Id']),
|
||||
provider_url: $product['ProductUrl'],
|
||||
footprint: $variation['PackageType']['Name'], //Use the footprint field, to show the user the package type (Tape & Reel, etc., as digikey has many different package types)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
{
|
||||
$response = $this->digikeyClient->request('GET', '/Search/v3/Products/' . urlencode($id), [
|
||||
$response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/productdetails', [
|
||||
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
|
||||
]);
|
||||
|
||||
$product = $response->toArray();
|
||||
$response_array = $response->toArray();
|
||||
$product = $response_array['Product'];
|
||||
|
||||
$footprint = null;
|
||||
$parameters = $this->parametersToDTOs($product['Parameters'] ?? [], $footprint);
|
||||
$media = $this->mediaToDTOs($product['MediaLinks']);
|
||||
$media = $this->mediaToDTOs($id);
|
||||
|
||||
// Get the price_breaks of the selected variation
|
||||
$price_breaks = [];
|
||||
foreach ($product['ProductVariations'] as $variation) {
|
||||
if ($variation['DigiKeyProductNumber'] == $id) {
|
||||
$price_breaks = $variation['StandardPricing'] ?? [];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $product['DigiKeyPartNumber'],
|
||||
name: $product['ManufacturerPartNumber'],
|
||||
description: $product['DetailedDescription'] ?? $product['ProductDescription'],
|
||||
provider_id: $id,
|
||||
name: $product['ManufacturerProductNumber'],
|
||||
description: $product['Description']['DetailedDescription'] ?? $product['Description']['ProductDescription'],
|
||||
category: $this->getCategoryString($product),
|
||||
manufacturer: $product['Manufacturer']['Value'] ?? null,
|
||||
mpn: $product['ManufacturerPartNumber'],
|
||||
preview_image_url: $product['PrimaryPhoto'] ?? null,
|
||||
manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']),
|
||||
manufacturer: $product['Manufacturer']['Name'] ?? null,
|
||||
mpn: $product['ManufacturerProductNumber'],
|
||||
preview_image_url: $product['PhotoUrl'] ?? null,
|
||||
manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']['Id']),
|
||||
provider_url: $product['ProductUrl'],
|
||||
footprint: $footprint,
|
||||
datasheets: $media['datasheets'],
|
||||
images: $media['images'],
|
||||
parameters: $parameters,
|
||||
vendor_infos: $this->pricingToDTOs($product['StandardPricing'] ?? [], $product['DigiKeyPartNumber'], $product['ProductUrl']),
|
||||
vendor_infos: $this->pricingToDTOs($price_breaks, $id, $product['ProductUrl']),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the product status from the Digikey API to the manufacturing status used in Part-DB
|
||||
* @param string|null $dk_status
|
||||
* @param int|null $dk_status
|
||||
* @return ManufacturingStatus|null
|
||||
*/
|
||||
private function productStatusToManufacturingStatus(?string $dk_status): ?ManufacturingStatus
|
||||
private function productStatusToManufacturingStatus(?int $dk_status): ?ManufacturingStatus
|
||||
{
|
||||
// The V4 can use strings to get the status, but if you have changed the PROVIDER_DIGIKEY_LANGUAGE it will not match.
|
||||
// Using the Id instead which should be fixed.
|
||||
//
|
||||
// The API is not well documented and the ID are not there yet, so were extracted using "trial and error".
|
||||
// The 'Preliminary' id was not found in several categories so I was unable to extract it. Disabled for now.
|
||||
return match ($dk_status) {
|
||||
null => null,
|
||||
'Active' => ManufacturingStatus::ACTIVE,
|
||||
'Obsolete' => ManufacturingStatus::DISCONTINUED,
|
||||
'Discontinued at Digi-Key', 'Last Time Buy' => ManufacturingStatus::EOL,
|
||||
'Not For New Designs' => ManufacturingStatus::NRFND,
|
||||
'Preliminary' => ManufacturingStatus::ANNOUNCED,
|
||||
0 => ManufacturingStatus::ACTIVE,
|
||||
1 => ManufacturingStatus::DISCONTINUED,
|
||||
2, 4 => ManufacturingStatus::EOL,
|
||||
7 => ManufacturingStatus::NRFND,
|
||||
//'Preliminary' => ManufacturingStatus::ANNOUNCED,
|
||||
default => ManufacturingStatus::NOT_SET,
|
||||
};
|
||||
}
|
||||
|
||||
private function getCategoryString(array $product): string
|
||||
{
|
||||
$category = $product['Category']['Value'];
|
||||
$sub_category = $product['Family']['Value'];
|
||||
$category = $product['Category']['Name'];
|
||||
$sub_category = current($product['Category']['ChildCategories']);
|
||||
|
||||
if ($sub_category) {
|
||||
//Replace the ' - ' category separator with ' -> '
|
||||
$sub_category = str_replace(' - ', ' -> ', $sub_category);
|
||||
$category = $category . ' -> ' . str_replace(' - ', ' -> ', $sub_category["Name"]);
|
||||
}
|
||||
|
||||
return $category . ' -> ' . $sub_category;
|
||||
return $category;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -215,18 +238,18 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
|
||||
foreach ($parameters as $parameter) {
|
||||
if ($parameter['ParameterId'] === 1291) { //Meaning "Manufacturer given footprint"
|
||||
$footprint_name = $parameter['Value'];
|
||||
$footprint_name = $parameter['ValueText'];
|
||||
}
|
||||
|
||||
if (in_array(trim((string) $parameter['Value']), ['', '-'], true)) {
|
||||
if (in_array(trim((string) $parameter['ValueText']), ['', '-'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
//If the parameter was marked as text only, then we do not try to parse it as a numerical value
|
||||
if (in_array($parameter['ParameterId'], self::TEXT_ONLY_PARAMETERS, true)) {
|
||||
$results[] = new ParameterDTO(name: $parameter['Parameter'], value_text: $parameter['Value']);
|
||||
$results[] = new ParameterDTO(name: $parameter['ParameterText'], value_text: $parameter['ValueText']);
|
||||
} else { //Otherwise try to parse it as a numerical value
|
||||
$results[] = ParameterDTO::parseValueIncludingUnit($parameter['Parameter'], $parameter['Value']);
|
||||
$results[] = ParameterDTO::parseValueIncludingUnit($parameter['ParameterText'], $parameter['ValueText']);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -254,16 +277,22 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
}
|
||||
|
||||
/**
|
||||
* @param array $media_links
|
||||
* @param string $id The Digikey product number, to get the media for
|
||||
* @return FileDTO[][]
|
||||
* @phpstan-return array<string, FileDTO[]>
|
||||
*/
|
||||
private function mediaToDTOs(array $media_links): array
|
||||
private function mediaToDTOs(string $id): array
|
||||
{
|
||||
$datasheets = [];
|
||||
$images = [];
|
||||
|
||||
foreach ($media_links as $media_link) {
|
||||
$response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/media', [
|
||||
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
|
||||
]);
|
||||
|
||||
$media_array = $response->toArray();
|
||||
|
||||
foreach ($media_array['MediaLinks'] as $media_link) {
|
||||
$file = new FileDTO(url: $media_link['Url'], name: $media_link['Title']);
|
||||
|
||||
switch ($media_link['MediaType']) {
|
||||
|
|
|
@ -29,14 +29,13 @@ use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Settings\InfoProviderSystem\Element14Settings;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class Element14Provider implements InfoProviderInterface
|
||||
{
|
||||
|
||||
private const ENDPOINT_URL = 'https://api.element14.com/catalog/products';
|
||||
private const API_VERSION_NUMBER = '1.2';
|
||||
private const API_VERSION_NUMBER = '1.4';
|
||||
private const NUMBER_OF_RESULTS = 20;
|
||||
|
||||
public const DISTRIBUTOR_NAME = 'Farnell';
|
||||
|
@ -44,9 +43,19 @@ class Element14Provider implements InfoProviderInterface
|
|||
private const COMPLIANCE_ATTRIBUTES = ['euEccn', 'hazardous', 'MSL', 'productTraceability', 'rohsCompliant',
|
||||
'rohsPhthalatesCompliant', 'SVHC', 'tariffCode', 'usEccn', 'hazardCode'];
|
||||
|
||||
private readonly HttpClientInterface $element14Client;
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $element14Client, private readonly Element14Settings $settings)
|
||||
{
|
||||
|
||||
/* We use the mozilla CA from the composer ca bundle directly, as some debian systems seems to have problems
|
||||
* with the SSL.COM CA, element14 uses. See https://github.com/Part-DB/Part-DB-server/issues/866
|
||||
*
|
||||
* This is a workaround until the issue is resolved in debian (or never).
|
||||
* As this only affects this provider, this should have no negative impact and the CA bundle is still secure.
|
||||
*/
|
||||
$this->element14Client = $element14Client->withOptions([
|
||||
'cafile' => CaBundle::getBundledCaBundlePath(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
|
@ -84,7 +93,7 @@ class Element14Provider implements InfoProviderInterface
|
|||
'resultsSettings.responseGroup' => 'large',
|
||||
'callInfo.apiKey' => $this->settings->apiKey,
|
||||
'callInfo.responseDataFormat' => 'json',
|
||||
'callInfo.version' => self::API_VERSION_NUMBER,
|
||||
'versionNumber' => self::API_VERSION_NUMBER,
|
||||
],
|
||||
]);
|
||||
|
||||
|
@ -108,10 +117,12 @@ class Element14Provider implements InfoProviderInterface
|
|||
mpn: $product['translatedManufacturerPartNumber'],
|
||||
preview_image_url: $this->toImageUrl($product['image'] ?? null),
|
||||
manufacturing_status: $this->releaseStatusCodeToManufacturingStatus($product['releaseStatusCode'] ?? null),
|
||||
provider_url: $this->generateProductURL($product['sku']),
|
||||
provider_url: $product['productURL'],
|
||||
notes: $product['productOverview']['description'] ?? null,
|
||||
datasheets: $this->parseDataSheets($product['datasheets'] ?? null),
|
||||
parameters: $this->attributesToParameters($product['attributes'] ?? null),
|
||||
vendor_infos: $this->pricesToVendorInfo($product['sku'], $product['prices'] ?? [])
|
||||
vendor_infos: $this->pricesToVendorInfo($product['sku'], $product['prices'] ?? [], $product['productURL']),
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -120,7 +131,7 @@ class Element14Provider implements InfoProviderInterface
|
|||
|
||||
private function generateProductURL($sku): string
|
||||
{
|
||||
return 'https://' . $this->settings->storeId . '/' . $sku;
|
||||
return 'https://' . $this->store_id . '/' . $sku;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -162,7 +173,7 @@ class Element14Provider implements InfoProviderInterface
|
|||
* @param array $prices
|
||||
* @return array
|
||||
*/
|
||||
private function pricesToVendorInfo(string $sku, array $prices): array
|
||||
private function pricesToVendorInfo(string $sku, array $prices, string $product_url): array
|
||||
{
|
||||
$price_dtos = [];
|
||||
|
||||
|
@ -180,7 +191,7 @@ class Element14Provider implements InfoProviderInterface
|
|||
distributor_name: self::DISTRIBUTOR_NAME,
|
||||
order_number: $sku,
|
||||
prices: $price_dtos,
|
||||
product_url: $this->generateProductURL($sku)
|
||||
product_url: $product_url
|
||||
)
|
||||
];
|
||||
}
|
||||
|
|
|
@ -92,6 +92,7 @@ class MouserProvider implements InfoProviderInterface
|
|||
From the startingRecord, the number of records specified will be returned up to the end of the recordset.
|
||||
This is useful for paging through the complete recordset of parts matching keyword.
|
||||
|
||||
|
||||
searchOptions string
|
||||
Optional.
|
||||
If not provided, the default is None.
|
||||
|
@ -174,11 +175,16 @@ class MouserProvider implements InfoProviderInterface
|
|||
throw new \RuntimeException('No part found with ID '.$id);
|
||||
}
|
||||
|
||||
//Manually filter out the part with the correct ID
|
||||
$tmp = array_filter($tmp, fn(PartDetailDTO $part) => $part->provider_id === $id);
|
||||
if (count($tmp) === 0) {
|
||||
throw new \RuntimeException('No part found with ID '.$id);
|
||||
}
|
||||
if (count($tmp) > 1) {
|
||||
throw new \RuntimeException('Multiple parts found with ID '.$id . ' ('.count($tmp).' found). This is basically a bug in Mousers API response. See issue #616.');
|
||||
throw new \RuntimeException('Multiple parts found with ID '.$id);
|
||||
}
|
||||
|
||||
return $tmp[0];
|
||||
return reset($tmp);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
|
|
|
@ -1218,7 +1218,7 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
* - 'value_min' => string|null The minimum value in a range, if applicable.
|
||||
* - 'value_max' => string|null The maximum value in a range, if applicable.
|
||||
*/
|
||||
private function customSplitIntoValueAndUnit(string $value1, string $value2 = null): array
|
||||
private function customSplitIntoValueAndUnit(string $value1, ?string $value2 = null): array
|
||||
{
|
||||
// Separate numbers and units (basic parsing handling)
|
||||
$unit = null;
|
||||
|
|
249
src/Services/InfoProviderSystem/Providers/PollinProvider.php
Normal file
249
src/Services/InfoProviderSystem/Providers/PollinProvider.php
Normal file
|
@ -0,0 +1,249 @@
|
|||
<?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\Services\InfoProviderSystem\Providers;
|
||||
|
||||
use App\Entity\Parts\ManufacturingStatus;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class PollinProvider implements InfoProviderInterface
|
||||
{
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $client,
|
||||
#[Autowire(env: 'bool:PROVIDER_POLLIN_ENABLED')]
|
||||
private readonly bool $enabled = true,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Pollin',
|
||||
'description' => 'Webscraping from pollin.de to get part information',
|
||||
'url' => 'https://www.pollin.de/',
|
||||
'disabled_help' => 'Set PROVIDER_POLLIN_ENABLED env to 1'
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'pollin';
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
{
|
||||
$response = $this->client->request('GET', 'https://www.pollin.de/search', [
|
||||
'query' => [
|
||||
'search' => $keyword
|
||||
]
|
||||
]);
|
||||
|
||||
$content = $response->getContent();
|
||||
|
||||
//If the response has us redirected to the product page, then just return the single item
|
||||
if ($response->getInfo('redirect_count') > 0) {
|
||||
return [$this->parseProductPage($content)];
|
||||
}
|
||||
|
||||
$dom = new Crawler($content);
|
||||
|
||||
$results = [];
|
||||
|
||||
//Iterate over each div.product-box
|
||||
$dom->filter('div.product-box')->each(function (Crawler $node) use (&$results) {
|
||||
$results[] = new SearchResultDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $node->filter('meta[itemprop="productID"]')->attr('content'),
|
||||
name: $node->filter('a.product-name')->text(),
|
||||
description: '',
|
||||
preview_image_url: $node->filter('img.product-image')->attr('src'),
|
||||
manufacturing_status: $this->mapAvailability($node->filter('link[itemprop="availability"]')->attr('href')),
|
||||
provider_url: $node->filter('a.product-name')->attr('href')
|
||||
);
|
||||
});
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function mapAvailability(string $availabilityURI): ManufacturingStatus
|
||||
{
|
||||
return match( $availabilityURI) {
|
||||
'http://schema.org/InStock' => ManufacturingStatus::ACTIVE,
|
||||
'http://schema.org/OutOfStock' => ManufacturingStatus::DISCONTINUED,
|
||||
default => ManufacturingStatus::NOT_SET
|
||||
};
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
{
|
||||
//Ensure that $id is numeric
|
||||
if (!is_numeric($id)) {
|
||||
throw new \InvalidArgumentException("The id must be numeric!");
|
||||
}
|
||||
|
||||
$response = $this->client->request('GET', 'https://www.pollin.de/search', [
|
||||
'query' => [
|
||||
'search' => $id
|
||||
]
|
||||
]);
|
||||
|
||||
//The response must have us redirected to the product page
|
||||
if ($response->getInfo('redirect_count') > 0) {
|
||||
throw new \RuntimeException("Could not resolve the product page for the given id!");
|
||||
}
|
||||
|
||||
$content = $response->getContent();
|
||||
|
||||
return $this->parseProductPage($content);
|
||||
}
|
||||
|
||||
private function parseProductPage(string $content): PartDetailDTO
|
||||
{
|
||||
$dom = new Crawler($content);
|
||||
|
||||
$productPageUrl = $dom->filter('meta[property="product:product_link"]')->attr('content');
|
||||
$orderId = trim($dom->filter('span[itemprop="sku"]')->text()); //Text is important here
|
||||
|
||||
//Calculate the mass
|
||||
$massStr = $dom->filter('meta[itemprop="weight"]')->attr('content');
|
||||
//Remove the unit
|
||||
$massStr = str_replace('kg', '', $massStr);
|
||||
//Convert to float and convert to grams
|
||||
$mass = (float) $massStr * 1000;
|
||||
|
||||
//Parse purchase info
|
||||
$purchaseInfo = new PurchaseInfoDTO('Pollin', $orderId, $this->parsePrices($dom), $productPageUrl);
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $orderId,
|
||||
name: trim($dom->filter('meta[property="og:title"]')->attr('content')),
|
||||
description: $dom->filter('meta[property="og:description"]')->attr('content'),
|
||||
category: $this->parseCategory($dom),
|
||||
manufacturer: $dom->filter('meta[property="product:brand"]')->count() > 0 ? $dom->filter('meta[property="product:brand"]')->attr('content') : null,
|
||||
preview_image_url: $dom->filter('meta[property="og:image"]')->attr('content'),
|
||||
manufacturing_status: $this->mapAvailability($dom->filter('link[itemprop="availability"]')->attr('href')),
|
||||
provider_url: $productPageUrl,
|
||||
notes: $this->parseNotes($dom),
|
||||
datasheets: $this->parseDatasheets($dom),
|
||||
parameters: $this->parseParameters($dom),
|
||||
vendor_infos: [$purchaseInfo],
|
||||
mass: $mass,
|
||||
);
|
||||
}
|
||||
|
||||
private function parseDatasheets(Crawler $dom): array
|
||||
{
|
||||
//Iterate over each a element withing div.pol-product-detail-download-files
|
||||
$datasheets = [];
|
||||
$dom->filter('div.pol-product-detail-download-files a')->each(function (Crawler $node) use (&$datasheets) {
|
||||
$datasheets[] = new FileDTO($node->attr('href'), $node->text());
|
||||
});
|
||||
|
||||
return $datasheets;
|
||||
}
|
||||
|
||||
private function parseParameters(Crawler $dom): array
|
||||
{
|
||||
$parameters = [];
|
||||
|
||||
//Iterate over each tr.properties-row inside table.product-detail-properties-table
|
||||
$dom->filter('table.product-detail-properties-table tr.properties-row')->each(function (Crawler $node) use (&$parameters) {
|
||||
$parameters[] = ParameterDTO::parseValueIncludingUnit(
|
||||
name: rtrim($node->filter('th.properties-label')->text(), ':'),
|
||||
value: trim($node->filter('td.properties-value')->text())
|
||||
);
|
||||
});
|
||||
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
private function parseCategory(Crawler $dom): string
|
||||
{
|
||||
$category = '';
|
||||
|
||||
//Iterate over each li.breadcrumb-item inside ol.breadcrumb
|
||||
$dom->filter('ol.breadcrumb li.breadcrumb-item')->each(function (Crawler $node) use (&$category) {
|
||||
//Skip if it has breadcrumb-item-home class
|
||||
if (str_contains($node->attr('class'), 'breadcrumb-item-home')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
$category .= $node->text() . ' -> ';
|
||||
});
|
||||
|
||||
//Remove the last ' -> '
|
||||
return substr($category, 0, -4);
|
||||
}
|
||||
|
||||
private function parseNotes(Crawler $dom): string
|
||||
{
|
||||
//Concat product highlights and product description
|
||||
return $dom->filter('div.product-detail-top-features')->html('') . '<br><br>' . $dom->filter('div.product-detail-description-text')->html('');
|
||||
}
|
||||
|
||||
private function parsePrices(Crawler $dom): array
|
||||
{
|
||||
//TODO: Properly handle multiple prices, for now we just look at the price for one piece
|
||||
|
||||
//We assume the currency is always the same
|
||||
$currency = $dom->filter('meta[property="product:price:currency"]')->attr('content');
|
||||
|
||||
//If there is meta[property=highPrice] then use this as the price
|
||||
if ($dom->filter('meta[itemprop="highPrice"]')->count() > 0) {
|
||||
$price = $dom->filter('meta[itemprop="highPrice"]')->attr('content');
|
||||
} else {
|
||||
$price = $dom->filter('meta[property="product:price:amount"]')->attr('content');
|
||||
}
|
||||
|
||||
return [
|
||||
new PriceDTO(1.0, $price, $currency)
|
||||
];
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::DATASHEET
|
||||
];
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue