mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-01-13 13:49:33 +00:00
Merge branch 'Part-DB:master' into master
This commit is contained in:
commit
c2e346ca1a
23 changed files with 5766 additions and 1753 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
|
|
@ -235,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
|
|
@ -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,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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