mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-01-20 09:09:33 +00:00
Merge branch 'master' into SplitAttachmentPaths
This commit is contained in:
commit
524900c34a
55 changed files with 5821 additions and 1805 deletions
|
|
@ -44,6 +44,8 @@
|
||||||
PassEnv PROVIDER_MOUSER_KEY PROVIDER_MOUSER_SEARCH_OPTION PROVIDER_MOUSER_SEARCH_LIMIT PROVIDER_MOUSER_SEARCH_WITH_SIGNUP_LANGUAGE
|
PassEnv PROVIDER_MOUSER_KEY PROVIDER_MOUSER_SEARCH_OPTION PROVIDER_MOUSER_SEARCH_LIMIT PROVIDER_MOUSER_SEARCH_WITH_SIGNUP_LANGUAGE
|
||||||
PassEnv PROVIDER_LCSC_ENABLED PROVIDER_LCSC_CURRENCY
|
PassEnv PROVIDER_LCSC_ENABLED PROVIDER_LCSC_CURRENCY
|
||||||
PassEnv PROVIDER_OEMSECRETS_KEY PROVIDER_OEMSECRETS_COUNTRY_CODE PROVIDER_OEMSECRETS_CURRENCY PROVIDER_OEMSECRETS_ZERO_PRICE PROVIDER_OEMSECRETS_SET_PARAM PROVIDER_OEMSECRETS_SORT_CRITERIA
|
PassEnv PROVIDER_OEMSECRETS_KEY PROVIDER_OEMSECRETS_COUNTRY_CODE PROVIDER_OEMSECRETS_CURRENCY PROVIDER_OEMSECRETS_ZERO_PRICE PROVIDER_OEMSECRETS_SET_PARAM PROVIDER_OEMSECRETS_SORT_CRITERIA
|
||||||
|
PassEnv PROVIDER_REICHELT_ENABLED PROVIDER_REICHELT_CURRENCY PROVIDER_REICHELT_COUNTRY PROVIDER_REICHELT_LANGUAGE PROVIDER_REICHELT_INCLUDE_VAT
|
||||||
|
PassEnv PROVIDER_POLLIN_ENABLED
|
||||||
PassEnv EDA_KICAD_CATEGORY_DEPTH
|
PassEnv EDA_KICAD_CATEGORY_DEPTH
|
||||||
|
|
||||||
# For most configuration files from conf-available/, which are
|
# For most configuration files from conf-available/, which are
|
||||||
|
|
|
||||||
21
.env
21
.env
|
|
@ -216,6 +216,27 @@ PROVIDER_OEMSECRETS_SET_PARAM=1
|
||||||
#If unset or set to any other value, no sorting is performed.
|
#If unset or set to any other value, no sorting is performed.
|
||||||
PROVIDER_OEMSECRETS_SORT_CRITERIA=C
|
PROVIDER_OEMSECRETS_SORT_CRITERIA=C
|
||||||
|
|
||||||
|
|
||||||
|
# Reichelt provider:
|
||||||
|
# Reichelt.com offers no official API, so this info provider webscrapes the website to extract info
|
||||||
|
# It could break at any time, use it at your own risk
|
||||||
|
# We dont require an API key for Reichelt, just set this to 1 to enable Reichelt support
|
||||||
|
PROVIDER_REICHELT_ENABLED=0
|
||||||
|
# The country to get prices for
|
||||||
|
PROVIDER_REICHELT_COUNTRY=DE
|
||||||
|
# The language to get results in (en, de, fr, nl, pl, it, es)
|
||||||
|
PROVIDER_REICHELT_LANGUAGE=en
|
||||||
|
# Include VAT in prices (set to 1 to include VAT, 0 to exclude VAT)
|
||||||
|
PROVIDER_REICHELT_INCLUDE_VAT=1
|
||||||
|
# The currency to get prices in (only for countries with countries other than EUR)
|
||||||
|
PROVIDER_REICHELT_CURRENCY=EUR
|
||||||
|
|
||||||
|
# Pollin provider:
|
||||||
|
# Pollin.de offers no official API, so this info provider webscrapes the website to extract info
|
||||||
|
# It could break at any time, use it at your own risk
|
||||||
|
# We dont require an API key for Pollin, just set this to 1 to enable Pollin support
|
||||||
|
PROVIDER_POLLIN_ENABLED=0
|
||||||
|
|
||||||
##################################################################################
|
##################################################################################
|
||||||
# EDA integration related settings
|
# EDA integration related settings
|
||||||
##################################################################################
|
##################################################################################
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,12 @@ import "tom-select/dist/css/tom-select.bootstrap5.css";
|
||||||
import '../../css/components/tom-select_extensions.css';
|
import '../../css/components/tom-select_extensions.css';
|
||||||
import TomSelect from "tom-select";
|
import TomSelect from "tom-select";
|
||||||
|
|
||||||
|
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
|
||||||
|
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
|
||||||
|
|
||||||
|
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
|
||||||
|
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
_tomSelect;
|
_tomSelect;
|
||||||
|
|
||||||
|
|
@ -46,6 +52,12 @@ export default class extends Controller {
|
||||||
}
|
}
|
||||||
return '<div>' + escape(data.label) + '</div>';
|
return '<div>' + escape(data.label) + '</div>';
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'autoselect_typed': {},
|
||||||
|
'click_to_edit': {},
|
||||||
|
'clear_button': {},
|
||||||
|
"restore_on_backspace": {}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,12 @@ import "tom-select/dist/css/tom-select.bootstrap5.css";
|
||||||
import '../../css/components/tom-select_extensions.css';
|
import '../../css/components/tom-select_extensions.css';
|
||||||
import TomSelect from "tom-select";
|
import TomSelect from "tom-select";
|
||||||
|
|
||||||
|
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
|
||||||
|
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
|
||||||
|
|
||||||
|
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
|
||||||
|
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the frontend controller for StaticFileAutocompleteType form element.
|
* This is the frontend controller for StaticFileAutocompleteType form element.
|
||||||
* Basically it loads a text file from the given url (via data-url) and uses it as a source for the autocomplete.
|
* Basically it loads a text file from the given url (via data-url) and uses it as a source for the autocomplete.
|
||||||
|
|
@ -46,7 +52,13 @@ export default class extends Controller {
|
||||||
orderField: 'text',
|
orderField: 'text',
|
||||||
|
|
||||||
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
|
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
|
||||||
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING'
|
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
|
||||||
|
plugins: {
|
||||||
|
'autoselect_typed': {},
|
||||||
|
'click_to_edit': {},
|
||||||
|
'clear_button': {},
|
||||||
|
'restore_on_backspace': {}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.element.dataset.url) {
|
if (this.element.dataset.url) {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ import {Controller} from "@hotwired/stimulus";
|
||||||
|
|
||||||
import {trans, ENTITY_SELECT_GROUP_NEW_NOT_ADDED_TO_DB} from '../../translator.js'
|
import {trans, ENTITY_SELECT_GROUP_NEW_NOT_ADDED_TO_DB} from '../../translator.js'
|
||||||
|
|
||||||
|
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
|
||||||
|
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
_tomSelect;
|
_tomSelect;
|
||||||
|
|
||||||
|
|
@ -37,11 +40,15 @@ export default class extends Controller {
|
||||||
const allowAdd = this.element.getAttribute("data-allow-add") === "true";
|
const allowAdd = this.element.getAttribute("data-allow-add") === "true";
|
||||||
const addHint = this.element.getAttribute("data-add-hint") ?? "";
|
const addHint = this.element.getAttribute("data-add-hint") ?? "";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
let settings = {
|
let settings = {
|
||||||
allowEmptyOption: true,
|
allowEmptyOption: true,
|
||||||
selectOnTab: true,
|
selectOnTab: true,
|
||||||
maxOptions: null,
|
maxOptions: null,
|
||||||
create: allowAdd ? this.createItem.bind(this) : false,
|
create: allowAdd ? this.createItem.bind(this) : false,
|
||||||
|
createFilter: this.createFilter.bind(this),
|
||||||
|
|
||||||
// This three options allow us to paste element names with commas: (see issue #538)
|
// This three options allow us to paste element names with commas: (see issue #538)
|
||||||
maxItems: 1,
|
maxItems: 1,
|
||||||
|
|
@ -81,8 +88,17 @@ export default class extends Controller {
|
||||||
//Add callbacks to update validity
|
//Add callbacks to update validity
|
||||||
onInitialize: this.updateValidity.bind(this),
|
onInitialize: this.updateValidity.bind(this),
|
||||||
onChange: this.updateValidity.bind(this),
|
onChange: this.updateValidity.bind(this),
|
||||||
|
|
||||||
|
plugins: {
|
||||||
|
"autoselect_typed": {},
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
//Add clear button plugin, if an empty option is present
|
||||||
|
if (this.element.querySelector("option[value='']") !== null) {
|
||||||
|
settings.plugins["clear_button"] = {};
|
||||||
|
}
|
||||||
|
|
||||||
this._tomSelect = new TomSelect(this.element, settings);
|
this._tomSelect = new TomSelect(this.element, settings);
|
||||||
//Do not do a sync here as this breaks the initial rendering of the empty option
|
//Do not do a sync here as this breaks the initial rendering of the empty option
|
||||||
//this._tomSelect.sync();
|
//this._tomSelect.sync();
|
||||||
|
|
@ -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() {
|
updateValidity() {
|
||||||
//Mark this input as invalid, if the selected option is disabled
|
//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 '../../css/components/tom-select_extensions.css';
|
||||||
import TomSelect from "tom-select";
|
import TomSelect from "tom-select";
|
||||||
|
|
||||||
|
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
|
||||||
|
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
|
||||||
|
|
||||||
|
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
|
||||||
|
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
_tomSelect;
|
_tomSelect;
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
let settings = {
|
let settings = {
|
||||||
plugins: {
|
plugins: {
|
||||||
remove_button:{
|
remove_button:{},
|
||||||
}
|
'autoselect_typed': {},
|
||||||
|
'click_to_edit': {},
|
||||||
},
|
},
|
||||||
persistent: false,
|
persistent: false,
|
||||||
selectOnTab: true,
|
selectOnTab: true,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,13 @@ import TomSelect from "tom-select";
|
||||||
import katex from "katex";
|
import katex from "katex";
|
||||||
import "katex/dist/katex.css";
|
import "katex/dist/katex.css";
|
||||||
|
|
||||||
|
|
||||||
|
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
|
||||||
|
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
|
||||||
|
|
||||||
|
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
|
||||||
|
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
||||||
|
|
||||||
/* stimulusFetch: 'lazy' */
|
/* stimulusFetch: 'lazy' */
|
||||||
export default class extends Controller
|
export default class extends Controller
|
||||||
{
|
{
|
||||||
|
|
@ -53,7 +60,10 @@ export default class extends Controller
|
||||||
connect() {
|
connect() {
|
||||||
const settings = {
|
const settings = {
|
||||||
plugins: {
|
plugins: {
|
||||||
clear_button:{}
|
'autoselect_typed': {},
|
||||||
|
'click_to_edit': {},
|
||||||
|
'clear_button': {},
|
||||||
|
'restore_on_backspace': {}
|
||||||
},
|
},
|
||||||
persistent: false,
|
persistent: false,
|
||||||
maxItems: 1,
|
maxItems: 1,
|
||||||
|
|
|
||||||
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 };
|
||||||
|
|
@ -54,6 +54,8 @@
|
||||||
"symfony/apache-pack": "^1.0",
|
"symfony/apache-pack": "^1.0",
|
||||||
"symfony/asset": "6.4.*",
|
"symfony/asset": "6.4.*",
|
||||||
"symfony/console": "6.4.*",
|
"symfony/console": "6.4.*",
|
||||||
|
"symfony/css-selector": "6.4.*",
|
||||||
|
"symfony/dom-crawler": "6.4.*",
|
||||||
"symfony/dotenv": "6.4.*",
|
"symfony/dotenv": "6.4.*",
|
||||||
"symfony/expression-language": "6.4.*",
|
"symfony/expression-language": "6.4.*",
|
||||||
"symfony/flex": "^v2.3.1",
|
"symfony/flex": "^v2.3.1",
|
||||||
|
|
@ -104,7 +106,6 @@
|
||||||
"rector/rector": "^2.0.4",
|
"rector/rector": "^2.0.4",
|
||||||
"roave/security-advisories": "dev-latest",
|
"roave/security-advisories": "dev-latest",
|
||||||
"symfony/browser-kit": "6.4.*",
|
"symfony/browser-kit": "6.4.*",
|
||||||
"symfony/css-selector": "6.4.*",
|
|
||||||
"symfony/debug-bundle": "6.4.*",
|
"symfony/debug-bundle": "6.4.*",
|
||||||
"symfony/maker-bundle": "^1.13",
|
"symfony/maker-bundle": "^1.13",
|
||||||
"symfony/phpunit-bridge": "6.4.*",
|
"symfony/phpunit-bridge": "6.4.*",
|
||||||
|
|
|
||||||
522
composer.lock
generated
522
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -150,9 +150,9 @@ In the `serverVersion` parameter you can specify the version of the PostgreSQL s
|
||||||
|
|
||||||
The `charset` parameter specify the character set of the database. It should be set to `utf8` to ensure that all characters are stored correctly.
|
The `charset` parameter specify the character set of the database. It should be set to `utf8` to ensure that all characters are stored correctly.
|
||||||
|
|
||||||
If you want to use a unix socket for the connection instead of a TCP connnection, you can specify the socket path in the `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
|
```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"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,9 @@ You must create an organization there and create a "Production app". Most settin
|
||||||
grant access to the "Product Information" API.
|
grant access to the "Product Information" API.
|
||||||
You will get a Client ID and a Client Secret, which you have to put in the Part-DB env configuration (see below).
|
You will get a Client ID and a Client Secret, which you have to put in the Part-DB env configuration (see below).
|
||||||
|
|
||||||
|
**Attention**: Currently only the "Product Information V3 (Deprecated)" is supported by Part-DB.
|
||||||
|
Using "Product Information V4" will not work.
|
||||||
|
|
||||||
The following env configuration options are available:
|
The following env configuration options are available:
|
||||||
|
|
||||||
* `PROVIDER_DIGIKEY_CLIENT_ID`: The client ID you got from Digi-Key (mandatory)
|
* `PROVIDER_DIGIKEY_CLIENT_ID`: The client ID you got from Digi-Key (mandatory)
|
||||||
|
|
@ -232,6 +235,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.
|
completeness (prioritizing items with the most detailed information). If set to 'M', it further sorts by manufacturer name.
|
||||||
If set to any other value, no sorting is performed.
|
If set to any other value, no sorting is performed.
|
||||||
|
|
||||||
|
### Reichelt
|
||||||
|
|
||||||
|
The reichelt provider uses webscraping from [reichelt.com](https://reichelt.com/) to get part information.
|
||||||
|
This is not an official API and could break at any time. So use it at your own risk.
|
||||||
|
|
||||||
|
The following env configuration options are available:
|
||||||
|
* `PROVIDER_REICHELT_ENABLED`: Set this to `1` to enable the Reichelt provider
|
||||||
|
* `PROVIDER_REICHELT_CURRENCY`: The currency you want to get prices in. Only possible for countries which use Non-EUR (optional, default: `EUR`)
|
||||||
|
* `PROVIDER_REICHELT_COUNTRY`: The country you want to get the prices for (optional, default: `DE`)
|
||||||
|
* `PROVIDER_REICHELT_LANGUAGE`: The language you want to get the descriptions in (optional, default: `en`)
|
||||||
|
* `PROVIDER_REICHELT_INCLUDE_VAT`: If set to `1`, the prices will be gross prices (including tax), otherwise net prices (optional, default: `1`)
|
||||||
|
|
||||||
|
### Pollin
|
||||||
|
|
||||||
|
The pollin provider uses webscraping from [pollin.de](https://www.pollin.de/) to get part information.
|
||||||
|
This is not an official API and could break at any time. So use it at your own risk.
|
||||||
|
|
||||||
|
The following env configuration options are available:
|
||||||
|
* `PROVIDER_POLLIN_ENABLED`: Set this to `1` to enable the Pollin provider
|
||||||
|
|
||||||
### Custom provider
|
### Custom provider
|
||||||
|
|
||||||
To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long
|
To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -43,9 +43,9 @@ class AddDocumentedAPIPropertiesJSONSchemaFactory implements SchemaFactoryInterf
|
||||||
string $className,
|
string $className,
|
||||||
string $format = 'json',
|
string $format = 'json',
|
||||||
string $type = Schema::TYPE_OUTPUT,
|
string $type = Schema::TYPE_OUTPUT,
|
||||||
Operation $operation = null,
|
?Operation $operation = null,
|
||||||
Schema $schema = null,
|
?Schema $schema = null,
|
||||||
array $serializerContext = null,
|
?array $serializerContext = null,
|
||||||
bool $forceCollection = false
|
bool $forceCollection = false
|
||||||
): Schema {
|
): Schema {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class EntityFilter extends AbstractFilter
|
||||||
public function __construct(
|
public function __construct(
|
||||||
ManagerRegistry $managerRegistry,
|
ManagerRegistry $managerRegistry,
|
||||||
private readonly EntityFilterHelper $filter_helper,
|
private readonly EntityFilterHelper $filter_helper,
|
||||||
LoggerInterface $logger = null,
|
?LoggerInterface $logger = null,
|
||||||
?array $properties = null,
|
?array $properties = null,
|
||||||
?NameConverterInterface $nameConverter = null
|
?NameConverterInterface $nameConverter = null
|
||||||
) {
|
) {
|
||||||
|
|
@ -50,7 +50,7 @@ class EntityFilter extends AbstractFilter
|
||||||
QueryBuilder $queryBuilder,
|
QueryBuilder $queryBuilder,
|
||||||
QueryNameGeneratorInterface $queryNameGenerator,
|
QueryNameGeneratorInterface $queryNameGenerator,
|
||||||
string $resourceClass,
|
string $resourceClass,
|
||||||
Operation $operation = null,
|
?Operation $operation = null,
|
||||||
array $context = []
|
array $context = []
|
||||||
): void {
|
): void {
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ final class LikeFilter extends AbstractFilter
|
||||||
QueryBuilder $queryBuilder,
|
QueryBuilder $queryBuilder,
|
||||||
QueryNameGeneratorInterface $queryNameGenerator,
|
QueryNameGeneratorInterface $queryNameGenerator,
|
||||||
string $resourceClass,
|
string $resourceClass,
|
||||||
Operation $operation = null,
|
?Operation $operation = null,
|
||||||
array $context = []
|
array $context = []
|
||||||
): void {
|
): void {
|
||||||
// Otherwise filter is applied to order and page as well
|
// Otherwise filter is applied to order and page as well
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ class PartStoragelocationFilter extends AbstractFilter
|
||||||
public function __construct(
|
public function __construct(
|
||||||
ManagerRegistry $managerRegistry,
|
ManagerRegistry $managerRegistry,
|
||||||
private readonly EntityFilterHelper $filter_helper,
|
private readonly EntityFilterHelper $filter_helper,
|
||||||
LoggerInterface $logger = null,
|
?LoggerInterface $logger = null,
|
||||||
?array $properties = null,
|
?array $properties = null,
|
||||||
?NameConverterInterface $nameConverter = null
|
?NameConverterInterface $nameConverter = null
|
||||||
) {
|
) {
|
||||||
|
|
@ -51,7 +51,7 @@ class PartStoragelocationFilter extends AbstractFilter
|
||||||
QueryBuilder $queryBuilder,
|
QueryBuilder $queryBuilder,
|
||||||
QueryNameGeneratorInterface $queryNameGenerator,
|
QueryNameGeneratorInterface $queryNameGenerator,
|
||||||
string $resourceClass,
|
string $resourceClass,
|
||||||
Operation $operation = null,
|
?Operation $operation = null,
|
||||||
array $context = []
|
array $context = []
|
||||||
): void {
|
): void {
|
||||||
//Do not check for mapping here, as we are using a virtual property
|
//Do not check for mapping here, as we are using a virtual property
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
#[AsCommand('partdb:users:enable|partdb:user:enable', 'Enables/Disable the login of one or more users')]
|
#[AsCommand('partdb:users:enable|partdb:user:enable', 'Enables/Disable the login of one or more users')]
|
||||||
class UserEnableCommand extends Command
|
class UserEnableCommand extends Command
|
||||||
{
|
{
|
||||||
public function __construct(protected EntityManagerInterface $entityManager, string $name = null)
|
public function __construct(protected EntityManagerInterface $entityManager, ?string $name = null)
|
||||||
{
|
{
|
||||||
parent::__construct($name);
|
parent::__construct($name);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ class TwoStepORMAdapter extends ORMAdapter
|
||||||
|
|
||||||
private \Closure|null $query_modifier = null;
|
private \Closure|null $query_modifier = null;
|
||||||
|
|
||||||
public function __construct(ManagerRegistry $registry = null)
|
public function __construct(?ManagerRegistry $registry = null)
|
||||||
{
|
{
|
||||||
parent::__construct($registry);
|
parent::__construct($registry);
|
||||||
$this->detailQueryCallable = static function (QueryBuilder $qb, array $ids): never {
|
$this->detailQueryCallable = static function (QueryBuilder $qb, array $ids): never {
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ abstract class AbstractConstraint implements FilterInterface
|
||||||
* @var string The property where this BooleanConstraint should apply to
|
* @var string The property where this BooleanConstraint should apply to
|
||||||
*/
|
*/
|
||||||
protected string $property,
|
protected string $property,
|
||||||
string $identifier = null)
|
?string $identifier = null)
|
||||||
{
|
{
|
||||||
$this->identifier = $identifier ?? $this->generateParameterIdentifier($property);
|
$this->identifier = $identifier ?? $this->generateParameterIdentifier($property);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ class BooleanConstraint extends AbstractConstraint
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
string $property,
|
string $property,
|
||||||
string $identifier = null,
|
?string $identifier = null,
|
||||||
/** @var bool|null The value of our constraint */
|
/** @var bool|null The value of our constraint */
|
||||||
protected ?bool $value = null
|
protected ?bool $value = null
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class DateTimeConstraint extends AbstractConstraint
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
string $property,
|
string $property,
|
||||||
string $identifier = null,
|
?string $identifier = null,
|
||||||
/**
|
/**
|
||||||
* The value1 used for comparison (this is the main one used for all mono-value comparisons)
|
* The value1 used for comparison (this is the main one used for all mono-value comparisons)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ class EntityConstraint extends AbstractConstraint
|
||||||
public function __construct(protected ?NodesListBuilder $nodesListBuilder,
|
public function __construct(protected ?NodesListBuilder $nodesListBuilder,
|
||||||
protected string $class,
|
protected string $class,
|
||||||
string $property,
|
string $property,
|
||||||
string $identifier = null,
|
?string $identifier = null,
|
||||||
protected ?AbstractDBElement $value = null,
|
protected ?AbstractDBElement $value = null,
|
||||||
protected ?string $operator = null)
|
protected ?string $operator = null)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ class NumberConstraint extends AbstractConstraint
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
string $property,
|
string $property,
|
||||||
string $identifier = null,
|
?string $identifier = null,
|
||||||
/**
|
/**
|
||||||
* The value1 used for comparison (this is the main one used for all mono-value comparisons)
|
* The value1 used for comparison (this is the main one used for all mono-value comparisons)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ use Doctrine\ORM\QueryBuilder;
|
||||||
|
|
||||||
class LessThanDesiredConstraint extends BooleanConstraint
|
class LessThanDesiredConstraint extends BooleanConstraint
|
||||||
{
|
{
|
||||||
public function __construct(string $property = null, string $identifier = null, ?bool $default_value = null)
|
public function __construct(?string $property = null, ?string $identifier = null, ?bool $default_value = null)
|
||||||
{
|
{
|
||||||
parent::__construct($property ?? '(
|
parent::__construct($property ?? '(
|
||||||
SELECT COALESCE(SUM(ld_partLot.amount), 0.0)
|
SELECT COALESCE(SUM(ld_partLot.amount), 0.0)
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ class TagsConstraint extends AbstractConstraint
|
||||||
{
|
{
|
||||||
final public const ALLOWED_OPERATOR_VALUES = ['ANY', 'ALL', 'NONE'];
|
final public const ALLOWED_OPERATOR_VALUES = ['ANY', 'ALL', 'NONE'];
|
||||||
|
|
||||||
public function __construct(string $property, string $identifier = null,
|
public function __construct(string $property, ?string $identifier = null,
|
||||||
protected ?string $value = null,
|
protected ?string $value = null,
|
||||||
protected ?string $operator = '')
|
protected ?string $operator = '')
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ class TextConstraint extends AbstractConstraint
|
||||||
/**
|
/**
|
||||||
* @param string $value
|
* @param string $value
|
||||||
*/
|
*/
|
||||||
public function __construct(string $property, string $identifier = null, /**
|
public function __construct(string $property, ?string $identifier = null, /**
|
||||||
* @var string|null The value to compare to
|
* @var string|null The value to compare to
|
||||||
*/
|
*/
|
||||||
protected ?string $value = null, /**
|
protected ?string $value = null, /**
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,7 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement
|
||||||
*
|
*
|
||||||
* @return string the link to the article
|
* @return string the link to the article
|
||||||
*/
|
*/
|
||||||
public function getAutoProductUrl(string $partnr = null): string
|
public function getAutoProductUrl(?string $partnr = null): string
|
||||||
{
|
{
|
||||||
if (is_string($partnr)) {
|
if (is_string($partnr)) {
|
||||||
return str_replace('%PARTNUMBER%', $partnr, $this->auto_product_url);
|
return str_replace('%PARTNUMBER%', $partnr, $this->auto_product_url);
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ class OAuthToken extends AbstractNamedDBElement implements AccessTokenInterface
|
||||||
*/
|
*/
|
||||||
private const DEFAULT_EXPIRATION_TIME = 3600;
|
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 is given, you also have to give the expires_at date
|
||||||
if ($token !== null && $expires_at === null) {
|
if ($token !== null && $expires_at === null) {
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ trait WithPermPresetsTrait
|
||||||
return json_encode($user->getPermissions());
|
return json_encode($user->getPermissions());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setContainer(ContainerInterface $container = null): void
|
public function setContainer(?ContainerInterface $container = null): void
|
||||||
{
|
{
|
||||||
if ($container !== null) {
|
if ($container !== null) {
|
||||||
$this->container = $container;
|
$this->container = $container;
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ class LogEntryRepository extends DBElementRepository
|
||||||
* @param int|null $limit
|
* @param int|null $limit
|
||||||
* @param int|null $offset
|
* @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);
|
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
|
* @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 = [
|
$data = [
|
||||||
'realm' => $this->realm,
|
'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
|
//Check if the request is an API request
|
||||||
if ($this->isJSONRequest($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.
|
* 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).
|
* 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 $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.
|
* @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;
|
$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) {
|
if (!$object instanceof Attachment) {
|
||||||
throw new \InvalidArgumentException('This normalizer only supports Attachment objects!');
|
throw new \InvalidArgumentException('This normalizer only supports Attachment objects!');
|
||||||
|
|
@ -64,7 +64,7 @@ class AttachmentNormalizer implements NormalizerInterface, NormalizerAwareInterf
|
||||||
return $data;
|
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
|
// avoid recursion: only call once per object
|
||||||
if (isset($context[self::ALREADY_CALLED])) {
|
if (isset($context[self::ALREADY_CALLED])) {
|
||||||
|
|
|
||||||
|
|
@ -33,12 +33,12 @@ use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||||
class BigNumberNormalizer implements NormalizerInterface, DenormalizerInterface
|
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;
|
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) {
|
if (!$object instanceof BigNumber) {
|
||||||
throw new \InvalidArgumentException('This normalizer only supports BigNumber objects!');
|
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)) {
|
if (!is_a($type, BigNumber::class, true)) {
|
||||||
throw new \InvalidArgumentException('This normalizer only supports BigNumber objects!');
|
throw new \InvalidArgumentException('This normalizer only supports BigNumber objects!');
|
||||||
|
|
@ -67,7 +67,7 @@ class BigNumberNormalizer implements NormalizerInterface, DenormalizerInterface
|
||||||
return $type::of($data);
|
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
|
//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));
|
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
|
//We only remove the type field for CSV export
|
||||||
return !isset($context[self::ALREADY_CALLED]) && $format === 'csv' && $data instanceof Part ;
|
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) {
|
if (!$object instanceof Part) {
|
||||||
throw new \InvalidArgumentException('This normalizer only supports Part objects!');
|
throw new \InvalidArgumentException('This normalizer only supports Part objects!');
|
||||||
|
|
@ -117,7 +117,7 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm
|
||||||
return $data;
|
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);
|
$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
|
//Only denormalize if we are doing a file import operation
|
||||||
if (!($context['partdb_import'] ?? false)) {
|
if (!($context['partdb_import'] ?? false)) {
|
||||||
|
|
@ -78,7 +78,7 @@ class StructuralElementDenormalizer implements DenormalizerInterface, Denormaliz
|
||||||
* @return AbstractStructuralDBElement|null
|
* @return AbstractStructuralDBElement|null
|
||||||
* @phpstan-return T|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
|
//Do not use API Platform's denormalizer
|
||||||
$context[SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER] = true;
|
$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
|
//Only denormalize if we are doing a file import operation
|
||||||
if (!($context['partdb_import'] ?? false)) {
|
if (!($context['partdb_import'] ?? false)) {
|
||||||
|
|
@ -51,7 +51,7 @@ class StructuralElementFromNameDenormalizer implements DenormalizerInterface
|
||||||
* @phpstan-param class-string<T> $type
|
* @phpstan-param class-string<T> $type
|
||||||
* @phpstan-return T|null
|
* @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
|
//Retrieve the repository for the given type
|
||||||
/** @var StructuralDBElementRepository<T> $repo */
|
/** @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
|
//Only normalize if we are doing a file export operation
|
||||||
if (!($context['partdb_export'] ?? false)) {
|
if (!($context['partdb_export'] ?? false)) {
|
||||||
|
|
@ -48,7 +48,7 @@ class StructuralElementNormalizer implements NormalizerInterface
|
||||||
return $data instanceof AbstractStructuralDBElement;
|
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) {
|
if (!$object instanceof AbstractStructuralDBElement) {
|
||||||
throw new \InvalidArgumentException('This normalizer only supports AbstractStructural objects!');
|
throw new \InvalidArgumentException('This normalizer only supports AbstractStructural objects!');
|
||||||
|
|
|
||||||
|
|
@ -357,7 +357,7 @@ class EntityImporter
|
||||||
* @param iterable $entities the list of entities that should be fixed
|
* @param iterable $entities the list of entities that should be fixed
|
||||||
* @param AbstractStructuralDBElement|null $parent the parent, to which the entity should be set
|
* @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) {
|
foreach ($entities as $entity) {
|
||||||
/** @var AbstractStructuralDBElement $entity */
|
/** @var AbstractStructuralDBElement $entity */
|
||||||
|
|
|
||||||
|
|
@ -72,9 +72,9 @@ class ParameterDTO
|
||||||
group: $group);
|
group: $group);
|
||||||
}
|
}
|
||||||
|
|
||||||
//If the attribute contains "..." or a tilde we assume it is a range
|
//If the attribute contains ".." or "..." or a tilde we assume it is a range
|
||||||
if (preg_match('/(\.{3}|~)/', $value) === 1) {
|
if (preg_match('/(\.{2,3}|~)/', $value) === 1) {
|
||||||
$parts = preg_split('/\s*(\.{3}|~)\s*/', $value);
|
$parts = preg_split('/\s*(\.{2,3}|~)\s*/', $value);
|
||||||
if (count($parts) === 2) {
|
if (count($parts) === 2) {
|
||||||
//Try to extract number and unit from value (allow leading +)
|
//Try to extract number and unit from value (allow leading +)
|
||||||
if ($unit === null || trim($unit) === '') {
|
if ($unit === null || trim($unit) === '') {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ use App\Entity\Parts\Part;
|
||||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||||
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
use Symfony\Contracts\Cache\CacheInterface;
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
use Symfony\Contracts\Cache\ItemInterface;
|
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_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,
|
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);
|
$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) {
|
if (!$provider instanceof InfoProviderInterface) {
|
||||||
throw new \InvalidArgumentException("The provider must be either a provider key or a provider instance!");
|
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);
|
$escaped_keyword = urlencode($keyword);
|
||||||
return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) {
|
return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) {
|
||||||
//Set the expiration time
|
//Set the expiration time
|
||||||
$item->expiresAfter(self::CACHE_RESULT_EXPIRATION);
|
$item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 1);
|
||||||
|
|
||||||
return $provider->searchByKeyword($keyword);
|
return $provider->searchByKeyword($keyword);
|
||||||
});
|
});
|
||||||
|
|
@ -94,11 +102,16 @@ final class PartInfoRetriever
|
||||||
{
|
{
|
||||||
$provider = $this->provider_registry->getProviderByKey($provider_key);
|
$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
|
//Generate key and escape reserved characters from the provider id
|
||||||
$escaped_part_id = urlencode($part_id);
|
$escaped_part_id = urlencode($part_id);
|
||||||
return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) {
|
return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) {
|
||||||
//Set the expiration time
|
//Set the expiration time
|
||||||
$item->expiresAfter(self::CACHE_DETAIL_EXPIRATION);
|
$item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 1);
|
||||||
|
|
||||||
return $provider->getDetails($part_id);
|
return $provider->getDetails($part_id);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ class LCSCProvider implements InfoProviderInterface
|
||||||
'Cookie' => new Cookie('currencyCode', $this->currency)
|
'Cookie' => new Cookie('currencyCode', $this->currency)
|
||||||
],
|
],
|
||||||
'query' => [
|
'query' => [
|
||||||
'productCode' => $id,
|
'prductCode' => $id,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1221,7 +1221,7 @@ class OEMSecretsProvider implements InfoProviderInterface
|
||||||
* - 'value_min' => string|null The minimum value in a range, if applicable.
|
* - 'value_min' => string|null The minimum value in a range, if applicable.
|
||||||
* - 'value_max' => string|null The maximum 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)
|
// Separate numbers and units (basic parsing handling)
|
||||||
$unit = null;
|
$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' => 'Webscrapping from pollin.de to get part information',
|
||||||
|
'url' => 'https://www.reichelt.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
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
285
src/Services/InfoProviderSystem/Providers/ReicheltProvider.php
Normal file
285
src/Services/InfoProviderSystem/Providers/ReicheltProvider.php
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
<?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\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 ReicheltProvider implements InfoProviderInterface
|
||||||
|
{
|
||||||
|
|
||||||
|
public const DISTRIBUTOR_NAME = "Reichelt";
|
||||||
|
|
||||||
|
public function __construct(private readonly HttpClientInterface $client,
|
||||||
|
#[Autowire(env: "bool:PROVIDER_REICHELT_ENABLED")]
|
||||||
|
private readonly bool $enabled = true,
|
||||||
|
#[Autowire(env: "PROVIDER_REICHELT_LANGUAGE")]
|
||||||
|
private readonly string $language = "en",
|
||||||
|
#[Autowire(env: "PROVIDER_REICHELT_COUNTRY")]
|
||||||
|
private readonly string $country = "DE",
|
||||||
|
#[Autowire(env: "PROVIDER_REICHELT_INCLUDE_VAT")]
|
||||||
|
private readonly bool $includeVAT = false,
|
||||||
|
#[Autowire(env: "PROVIDER_REICHELT_CURRENCY")]
|
||||||
|
private readonly string $currency = "EUR",
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProviderInfo(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => 'Reichelt',
|
||||||
|
'description' => 'Webscrapping from reichelt.com to get part information',
|
||||||
|
'url' => 'https://www.reichelt.com/',
|
||||||
|
'disabled_help' => 'Set PROVIDER_REICHELT_ENABLED env to 1'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProviderKey(): string
|
||||||
|
{
|
||||||
|
return 'reichelt';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isActive(): bool
|
||||||
|
{
|
||||||
|
return $this->enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function searchByKeyword(string $keyword): array
|
||||||
|
{
|
||||||
|
$response = $this->client->request('GET', sprintf($this->getBaseURL() . '/shop/search/%s', $keyword));
|
||||||
|
$html = $response->getContent();
|
||||||
|
|
||||||
|
//Parse the HTML and return the results
|
||||||
|
$dom = new Crawler($html);
|
||||||
|
//Iterate over all div.al_gallery_article elements
|
||||||
|
$results = [];
|
||||||
|
$dom->filter('div.al_gallery_article')->each(function (Crawler $element) use (&$results) {
|
||||||
|
|
||||||
|
//Extract product id from data-product attribute
|
||||||
|
$artId = json_decode($element->attr('data-product'), true, 2, JSON_THROW_ON_ERROR)['artid'];
|
||||||
|
|
||||||
|
$productID = $element->filter('meta[itemprop="productID"]')->attr('content');
|
||||||
|
$name = $element->filter('meta[itemprop="name"]')->attr('content');
|
||||||
|
$sku = $element->filter('meta[itemprop="sku"]')->attr('content');
|
||||||
|
|
||||||
|
//Try to extract a picture URL:
|
||||||
|
$pictureURL = $element->filter("div.al_artlogo img")->attr('src');
|
||||||
|
|
||||||
|
$results[] = new SearchResultDTO(
|
||||||
|
provider_key: $this->getProviderKey(),
|
||||||
|
provider_id: $artId,
|
||||||
|
name: $productID,
|
||||||
|
description: $name,
|
||||||
|
category: null,
|
||||||
|
manufacturer: $sku,
|
||||||
|
preview_image_url: $pictureURL,
|
||||||
|
provider_url: $element->filter('a.al_artinfo_link')->attr('href')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDetails(string $id): PartDetailDTO
|
||||||
|
{
|
||||||
|
//Check that the ID is a number
|
||||||
|
if (!is_numeric($id)) {
|
||||||
|
throw new \InvalidArgumentException("Invalid ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
//Use this endpoint to resolve the artID to a product page
|
||||||
|
$response = $this->client->request('GET',
|
||||||
|
sprintf(
|
||||||
|
'https://www.reichelt.com/?ACTION=514&id=74&article=%s&LANGUAGE=%s&CCOUNTRY=%s',
|
||||||
|
$id,
|
||||||
|
strtoupper($this->language),
|
||||||
|
strtoupper($this->country)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$json = $response->toArray();
|
||||||
|
|
||||||
|
//Retrieve the product page from the response
|
||||||
|
$productPage = $this->getBaseURL() . '/shop/product' . $json[0]['article_path'];
|
||||||
|
|
||||||
|
|
||||||
|
$response = $this->client->request('GET', $productPage, [
|
||||||
|
'query' => [
|
||||||
|
'CCTYPE' => $this->includeVAT ? 'private' : 'business',
|
||||||
|
'currency' => $this->currency,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$html = $response->getContent();
|
||||||
|
$dom = new Crawler($html);
|
||||||
|
|
||||||
|
//Extract the product notes
|
||||||
|
$notes = $dom->filter('p[itemprop="description"]')->html();
|
||||||
|
|
||||||
|
//Extract datasheets
|
||||||
|
$datasheets = [];
|
||||||
|
$dom->filter('div.articleDatasheet a')->each(function (Crawler $element) use (&$datasheets) {
|
||||||
|
$datasheets[] = new FileDTO($element->attr('href'), $element->filter('span')->text());
|
||||||
|
});
|
||||||
|
|
||||||
|
//Determine price for one unit
|
||||||
|
$priceString = $dom->filter('meta[itemprop="price"]')->attr('content');
|
||||||
|
$currency = $dom->filter('meta[itemprop="priceCurrency"]')->attr('content', 'EUR');
|
||||||
|
|
||||||
|
//Create purchase info
|
||||||
|
$purchaseInfo = new PurchaseInfoDTO(
|
||||||
|
distributor_name: self::DISTRIBUTOR_NAME,
|
||||||
|
order_number: $json[0]['article_artnr'],
|
||||||
|
prices: array_merge(
|
||||||
|
[new PriceDTO(1.0, $priceString, $currency, $this->includeVAT)]
|
||||||
|
, $this->parseBatchPrices($dom, $currency)),
|
||||||
|
product_url: $productPage
|
||||||
|
);
|
||||||
|
|
||||||
|
//Create part object
|
||||||
|
return new PartDetailDTO(
|
||||||
|
provider_key: $this->getProviderKey(),
|
||||||
|
provider_id: $id,
|
||||||
|
name: $json[0]['article_artnr'],
|
||||||
|
description: $json[0]['article_besch'],
|
||||||
|
category: $this->parseCategory($dom),
|
||||||
|
manufacturer: $json[0]['manufacturer_name'],
|
||||||
|
mpn: $this->parseMPN($dom),
|
||||||
|
preview_image_url: $json[0]['article_picture'],
|
||||||
|
provider_url: $productPage,
|
||||||
|
notes: $notes,
|
||||||
|
datasheets: $datasheets,
|
||||||
|
parameters: $this->parseParameters($dom),
|
||||||
|
vendor_infos: [$purchaseInfo]
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseMPN(Crawler $dom): string
|
||||||
|
{
|
||||||
|
//Find the small element directly after meta[itemprop="url"] element
|
||||||
|
$element = $dom->filter('meta[itemprop="url"] + small');
|
||||||
|
//If the text contains GTIN text, take the small element afterwards
|
||||||
|
if (str_contains($element->text(), 'GTIN')) {
|
||||||
|
$element = $dom->filter('meta[itemprop="url"] + small + small');
|
||||||
|
}
|
||||||
|
|
||||||
|
//The MPN is contained in the span inside the element
|
||||||
|
return $element->filter('span')->text();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseBatchPrices(Crawler $dom, string $currency): array
|
||||||
|
{
|
||||||
|
//Iterate over each a.inline-block element in div.discountValue
|
||||||
|
$prices = [];
|
||||||
|
$dom->filter('div.discountValue a.inline-block')->each(function (Crawler $element) use (&$prices, $currency) {
|
||||||
|
//The minimum amount is the number in the span.block element
|
||||||
|
$minAmountText = $element->filter('span.block')->text();
|
||||||
|
|
||||||
|
//Extract a integer from the text
|
||||||
|
$matches = [];
|
||||||
|
if (!preg_match('/\d+/', $minAmountText, $matches)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$minAmount = (int) $matches[0];
|
||||||
|
|
||||||
|
//The price is the text of the p.productPrice element
|
||||||
|
$priceString = $element->filter('p.productPrice')->text();
|
||||||
|
//Replace comma with dot
|
||||||
|
$priceString = str_replace(',', '.', $priceString);
|
||||||
|
//Strip any non-numeric characters
|
||||||
|
$priceString = preg_replace('/[^0-9.]/', '', $priceString);
|
||||||
|
|
||||||
|
$prices[] = new PriceDTO($minAmount, $priceString, $currency, $this->includeVAT);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $prices;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private function parseCategory(Crawler $dom): string
|
||||||
|
{
|
||||||
|
// Look for ol.breadcrumb and iterate over the li elements
|
||||||
|
$category = '';
|
||||||
|
$dom->filter('ol.breadcrumb li.triangle-left')->each(function (Crawler $element) use (&$category) {
|
||||||
|
//Do not include the .breadcrumb-showmore element
|
||||||
|
if ($element->attr('id') === 'breadcrumb-showmore') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$category .= $element->text() . ' -> ';
|
||||||
|
});
|
||||||
|
//Remove the trailing ' -> '
|
||||||
|
$category = substr($category, 0, -4);
|
||||||
|
|
||||||
|
return $category;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Crawler $dom
|
||||||
|
* @return ParameterDTO[]
|
||||||
|
*/
|
||||||
|
private function parseParameters(Crawler $dom): array
|
||||||
|
{
|
||||||
|
$parameters = [];
|
||||||
|
//Iterate over each ul.articleTechnicalData which contains the specifications of each group
|
||||||
|
$dom->filter('ul.articleTechnicalData')->each(function (Crawler $groupElement) use (&$parameters) {
|
||||||
|
$groupName = $groupElement->filter('li.articleTechnicalHeadline')->text();
|
||||||
|
|
||||||
|
//Iterate over each second li in ul.articleAttribute, which contains the specifications
|
||||||
|
$groupElement->filter('ul.articleAttribute li:nth-child(2n)')->each(function (Crawler $specElement) use (&$parameters, $groupName) {
|
||||||
|
$parameters[] = ParameterDTO::parseValueIncludingUnit(
|
||||||
|
name: $specElement->previousAll()->text(),
|
||||||
|
value: $specElement->text(),
|
||||||
|
group: $groupName
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return $parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getBaseURL(): string
|
||||||
|
{
|
||||||
|
//Without the trailing slash
|
||||||
|
return 'https://www.reichelt.com/' . strtolower($this->country) . '/' . strtolower($this->language);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCapabilities(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
ProviderCapabilities::BASIC,
|
||||||
|
ProviderCapabilities::PICTURE,
|
||||||
|
ProviderCapabilities::DATASHEET,
|
||||||
|
ProviderCapabilities::PRICE,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -43,12 +43,12 @@ class UniqueObjectCollection extends Constraint
|
||||||
* @param array|string $fields the combination of fields that must contain unique values or a set of options
|
* @param array|string $fields the combination of fields that must contain unique values or a set of options
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
array $options = null,
|
?array $options = null,
|
||||||
string $message = null,
|
?string $message = null,
|
||||||
callable $normalizer = null,
|
?callable $normalizer = null,
|
||||||
array $groups = null,
|
?array $groups = null,
|
||||||
mixed $payload = null,
|
mixed $payload = null,
|
||||||
array|string $fields = null,
|
array|string|null $fields = null,
|
||||||
public bool $allowNull = true,
|
public bool $allowNull = true,
|
||||||
) {
|
) {
|
||||||
parent::__construct($options, $groups, $payload);
|
parent::__construct($options, $groups, $payload);
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ class ValidGoogleAuthCode extends Constraint
|
||||||
* @param TwoFactorInterface|null $user The user to use for the validation process, if null, the current user is used
|
* @param TwoFactorInterface|null $user The user to use for the validation process, if null, the current user is used
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
array $options = null,
|
?array $options = null,
|
||||||
array $groups = null,
|
?array $groups = null,
|
||||||
mixed $payload = null,
|
mixed $payload = null,
|
||||||
public ?TwoFactorInterface $user = null)
|
public ?TwoFactorInterface $user = null)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,16 @@ class ParameterDTOTest extends TestCase
|
||||||
'test'
|
'test'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
//Test ranges
|
||||||
|
yield [
|
||||||
|
new ParameterDTO('test', value_min: 1.0, value_max: 2.0, unit: 'kg', symbol: 'm', group: 'test'),
|
||||||
|
'test',
|
||||||
|
'1.0..2.0',
|
||||||
|
'kg',
|
||||||
|
'm',
|
||||||
|
'test'
|
||||||
|
];
|
||||||
|
|
||||||
//Test ranges with tilde
|
//Test ranges with tilde
|
||||||
yield [
|
yield [
|
||||||
new ParameterDTO('test', value_min: -1.0, value_max: 2.0, unit: 'kg', symbol: 'm', group: 'test'),
|
new ParameterDTO('test', value_min: -1.0, value_max: 2.0, unit: 'kg', symbol: 'm', group: 'test'),
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,23 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="nl">
|
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="nl">
|
||||||
<file id="security.en">
|
<file id="security.en">
|
||||||
<unit id="aazoCks" name="user.login_error.user_disabled">
|
<unit id="GrLNa9P" name="user.login_error.user_disabled">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>user.login_error.user_disabled</source>
|
<source>user.login_error.user_disabled</source>
|
||||||
<target>Uw account is gedeactiveerd! Neem contact op met een beheerder indien dit incorrect is.</target>
|
<target>Uw account is gedeactiveerd! Neem contact op met een beheerder indien dit incorrect is.</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="Dpb9AmY" name="saml.error.cannot_login_local_user_per_saml">
|
<unit id="IFQ5XrG" name="saml.error.cannot_login_local_user_per_saml">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>saml.error.cannot_login_local_user_per_saml</source>
|
<source>saml.error.cannot_login_local_user_per_saml</source>
|
||||||
<target>U kunt niet inloggen als lokale gebruiker met SSO! Gebruik uw lokale wachtwoord.</target>
|
<target>U kunt niet inloggen als lokale gebruiker met SSO! Gebruik uw lokale wachtwoord.</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="wOYPZmb" name="saml.error.cannot_login_saml_user_locally">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>saml.error.cannot_login_saml_user_locally</source>
|
||||||
|
<target>U kunt geen lokale authenticatie gebruiken om in te loggen als SAML-gebruiker! Gebruik in plaats daarvan SSO-aanmelding.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
</file>
|
</file>
|
||||||
</xliff>
|
</xliff>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue