mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2025-12-13 14:39:30 +00:00
Merge branch 'master' into settings-bundle
This commit is contained in:
commit
442457f11b
131 changed files with 12759 additions and 6750 deletions
|
|
@ -44,35 +44,31 @@ class AttachmentManager
|
|||
*
|
||||
* @param Attachment $attachment The attachment for which the file should be generated
|
||||
*
|
||||
* @return SplFileInfo|null The fileinfo for the attachment file. Null, if the attachment is external or has
|
||||
* @return SplFileInfo|null The fileinfo for the attachment file. Null, if the attachment is only external or has
|
||||
* invalid file.
|
||||
*/
|
||||
public function attachmentToFile(Attachment $attachment): ?SplFileInfo
|
||||
{
|
||||
if ($attachment->isExternal() || !$this->isFileExisting($attachment)) {
|
||||
if (!$this->isInternalFileExisting($attachment)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SplFileInfo($this->toAbsoluteFilePath($attachment));
|
||||
return new SplFileInfo($this->toAbsoluteInternalFilePath($attachment));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute filepath of the attachment. Null is returned, if the attachment is externally saved,
|
||||
* or is not existing.
|
||||
* Returns the absolute filepath to the internal copy of the attachment. Null is returned, if the attachment is
|
||||
* only externally saved, or is not existing.
|
||||
*
|
||||
* @param Attachment $attachment The attachment for which the filepath should be determined
|
||||
*/
|
||||
public function toAbsoluteFilePath(Attachment $attachment): ?string
|
||||
public function toAbsoluteInternalFilePath(Attachment $attachment): ?string
|
||||
{
|
||||
if ($attachment->getPath() === '') {
|
||||
if (!$attachment->hasInternal()){
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($attachment->isExternal()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = $this->pathResolver->placeholderToRealPath($attachment->getPath());
|
||||
$path = $this->pathResolver->placeholderToRealPath($attachment->getInternalPath());
|
||||
|
||||
//realpath does not work with null as argument
|
||||
if (null === $path) {
|
||||
|
|
@ -89,8 +85,8 @@ class AttachmentManager
|
|||
}
|
||||
|
||||
/**
|
||||
* Checks if the file in this attachement is existing. This works for files on the HDD, and for URLs
|
||||
* (it's not checked if the ressource behind the URL is really existing, so for every external attachment true is returned).
|
||||
* Checks if the file in this attachment is existing. This works for files on the HDD, and for URLs
|
||||
* (it's not checked if the resource behind the URL is really existing, so for every external attachment true is returned).
|
||||
*
|
||||
* @param Attachment $attachment The attachment for which the existence should be checked
|
||||
*
|
||||
|
|
@ -98,15 +94,23 @@ class AttachmentManager
|
|||
*/
|
||||
public function isFileExisting(Attachment $attachment): bool
|
||||
{
|
||||
if ($attachment->getPath() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($attachment->isExternal()) {
|
||||
if($attachment->hasExternal()){
|
||||
return true;
|
||||
}
|
||||
return $this->isInternalFileExisting($attachment);
|
||||
}
|
||||
|
||||
$absolute_path = $this->toAbsoluteFilePath($attachment);
|
||||
/**
|
||||
* Checks if the internal file in this attachment is existing. Returns false if the attachment doesn't have an
|
||||
* internal file.
|
||||
*
|
||||
* @param Attachment $attachment The attachment for which the existence should be checked
|
||||
*
|
||||
* @return bool true if the file is existing
|
||||
*/
|
||||
public function isInternalFileExisting(Attachment $attachment): bool
|
||||
{
|
||||
$absolute_path = $this->toAbsoluteInternalFilePath($attachment);
|
||||
|
||||
if (null === $absolute_path) {
|
||||
return false;
|
||||
|
|
@ -117,21 +121,17 @@ class AttachmentManager
|
|||
|
||||
/**
|
||||
* Returns the filesize of the attachments in bytes.
|
||||
* For external attachments or not existing attachments, null is returned.
|
||||
* For purely external attachments or inexistent attachments, null is returned.
|
||||
*
|
||||
* @param Attachment $attachment the filesize for which the filesize should be calculated
|
||||
*/
|
||||
public function getFileSize(Attachment $attachment): ?int
|
||||
{
|
||||
if ($attachment->isExternal()) {
|
||||
if (!$this->isInternalFileExisting($attachment)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->isFileExisting($attachment)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tmp = filesize($this->toAbsoluteFilePath($attachment));
|
||||
$tmp = filesize($this->toAbsoluteInternalFilePath($attachment));
|
||||
|
||||
return false !== $tmp ? $tmp : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,12 +115,16 @@ class AttachmentPathResolver
|
|||
* Converts an relative placeholder filepath (with %MEDIA% or older %BASE%) to an absolute filepath on disk.
|
||||
* The directory separator is always /. Relative pathes are not realy possible (.. is striped).
|
||||
*
|
||||
* @param string $placeholder_path the filepath with placeholder for which the real path should be determined
|
||||
* @param string|null $placeholder_path the filepath with placeholder for which the real path should be determined
|
||||
*
|
||||
* @return string|null The absolute real path of the file, or null if the placeholder path is invalid
|
||||
*/
|
||||
public function placeholderToRealPath(string $placeholder_path): ?string
|
||||
public function placeholderToRealPath(?string $placeholder_path): ?string
|
||||
{
|
||||
if (null === $placeholder_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
//The new attachments use %MEDIA% as placeholders, which is the directory set in media_directory
|
||||
//Older path entries are given via %BASE% which was the project root
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ class AttachmentReverseSearch
|
|||
$repo = $this->em->getRepository(Attachment::class);
|
||||
|
||||
return $repo->findBy([
|
||||
'path' => [$relative_path_new, $relative_path_old],
|
||||
'internal_path' => [$relative_path_new, $relative_path_old],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ class AttachmentSubmitHandler
|
|||
protected MimeTypesInterface $mimeTypes,
|
||||
protected FileTypeFilterTools $filterTools,
|
||||
protected AttachmentsSettings $settings,
|
||||
protected readonly SVGSanitizer $SVGSanitizer,
|
||||
)
|
||||
{
|
||||
//The mapping used to determine which folder will be used for an attachment type
|
||||
|
|
@ -209,13 +210,16 @@ class AttachmentSubmitHandler
|
|||
if ($file instanceof UploadedFile) {
|
||||
|
||||
$this->upload($attachment, $file, $secure_attachment);
|
||||
} elseif ($upload->downloadUrl && $attachment->isExternal()) {
|
||||
} elseif ($upload->downloadUrl && $attachment->hasExternal()) {
|
||||
$this->downloadURL($attachment, $secure_attachment);
|
||||
}
|
||||
|
||||
//Move the attachment files to secure location (and back) if needed
|
||||
$this->moveFile($attachment, $secure_attachment);
|
||||
|
||||
//Sanitize the SVG if needed
|
||||
$this->sanitizeSVGAttachment($attachment);
|
||||
|
||||
//Rename blacklisted (unsecure) files to a better extension
|
||||
$this->renameBlacklistedExtensions($attachment);
|
||||
|
||||
|
|
@ -246,12 +250,12 @@ class AttachmentSubmitHandler
|
|||
protected function renameBlacklistedExtensions(Attachment $attachment): Attachment
|
||||
{
|
||||
//We can not do anything on builtins or external ressources
|
||||
if ($attachment->isBuiltIn() || $attachment->isExternal()) {
|
||||
if ($attachment->isBuiltIn() || !$attachment->hasInternal()) {
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
//Determine the old filepath
|
||||
$old_path = $this->pathResolver->placeholderToRealPath($attachment->getPath());
|
||||
$old_path = $this->pathResolver->placeholderToRealPath($attachment->getInternalPath());
|
||||
if ($old_path === null || $old_path === '' || !file_exists($old_path)) {
|
||||
return $attachment;
|
||||
}
|
||||
|
|
@ -269,7 +273,7 @@ class AttachmentSubmitHandler
|
|||
$fs->rename($old_path, $new_path);
|
||||
|
||||
//Update the attachment
|
||||
$attachment->setPath($this->pathResolver->realPathToPlaceholder($new_path));
|
||||
$attachment->setInternalPath($this->pathResolver->realPathToPlaceholder($new_path));
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -277,17 +281,17 @@ class AttachmentSubmitHandler
|
|||
}
|
||||
|
||||
/**
|
||||
* Move the given attachment to secure location (or back to public folder) if needed.
|
||||
* Move the internal copy of the given attachment to a secure location (or back to public folder) if needed.
|
||||
*
|
||||
* @param Attachment $attachment the attachment for which the file should be moved
|
||||
* @param bool $secure_location this value determines, if the attachment is moved to the secure or public folder
|
||||
*
|
||||
* @return Attachment The attachment with the updated filepath
|
||||
* @return Attachment The attachment with the updated internal filepath
|
||||
*/
|
||||
protected function moveFile(Attachment $attachment, bool $secure_location): Attachment
|
||||
{
|
||||
//We can not do anything on builtins or external ressources
|
||||
if ($attachment->isBuiltIn() || $attachment->isExternal()) {
|
||||
if ($attachment->isBuiltIn() || !$attachment->hasInternal()) {
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
|
|
@ -297,7 +301,7 @@ class AttachmentSubmitHandler
|
|||
}
|
||||
|
||||
//Determine the old filepath
|
||||
$old_path = $this->pathResolver->placeholderToRealPath($attachment->getPath());
|
||||
$old_path = $this->pathResolver->placeholderToRealPath($attachment->getInternalPath());
|
||||
if (!file_exists($old_path)) {
|
||||
return $attachment;
|
||||
}
|
||||
|
|
@ -321,7 +325,7 @@ class AttachmentSubmitHandler
|
|||
|
||||
//Save info to attachment entity
|
||||
$new_path = $this->pathResolver->realPathToPlaceholder($new_path);
|
||||
$attachment->setPath($new_path);
|
||||
$attachment->setInternalPath($new_path);
|
||||
|
||||
return $attachment;
|
||||
}
|
||||
|
|
@ -331,7 +335,7 @@ class AttachmentSubmitHandler
|
|||
*
|
||||
* @param bool $secureAttachment True if the file should be moved to the secure attachment storage
|
||||
*
|
||||
* @return Attachment The attachment with the new filepath
|
||||
* @return Attachment The attachment with the downloaded copy
|
||||
*/
|
||||
protected function downloadURL(Attachment $attachment, bool $secureAttachment): Attachment
|
||||
{
|
||||
|
|
@ -340,16 +344,35 @@ class AttachmentSubmitHandler
|
|||
throw new RuntimeException('Download of attachments is not allowed!');
|
||||
}
|
||||
|
||||
$url = $attachment->getURL();
|
||||
$url = $attachment->getExternalPath();
|
||||
|
||||
$fs = new Filesystem();
|
||||
$attachment_folder = $this->generateAttachmentPath($attachment, $secureAttachment);
|
||||
$tmp_path = $attachment_folder.DIRECTORY_SEPARATOR.$this->generateAttachmentFilename($attachment, 'tmp');
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', $url, [
|
||||
$opts = [
|
||||
'buffer' => false,
|
||||
]);
|
||||
//Use user-agent and other headers to make the server think we are a browser
|
||||
'headers' => [
|
||||
"sec-ch-ua" => "\"Not(A:Brand\";v=\"99\", \"Google Chrome\";v=\"133\", \"Chromium\";v=\"133\"",
|
||||
"sec-ch-ua-mobile" => "?0",
|
||||
"sec-ch-ua-platform" => "\"Windows\"",
|
||||
"user-agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
|
||||
"sec-fetch-site" => "none",
|
||||
"sec-fetch-mode" => "navigate",
|
||||
],
|
||||
|
||||
];
|
||||
$response = $this->httpClient->request('GET', $url, $opts);
|
||||
//Digikey wants TLSv1.3, so try again with that if we get a 403
|
||||
if ($response->getStatusCode() === 403) {
|
||||
$opts['crypto_method'] = STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT;
|
||||
$response = $this->httpClient->request('GET', $url, $opts);
|
||||
}
|
||||
# if you have these changes and downloads still fail, check if it's due to an unknown certificate. Curl by
|
||||
# default uses the systems ca store and that doesn't contain all the intermediate certificates needed to
|
||||
# verify the leafs
|
||||
|
||||
if (200 !== $response->getStatusCode()) {
|
||||
throw new AttachmentDownloadException('Status code: '.$response->getStatusCode());
|
||||
|
|
@ -401,7 +424,7 @@ class AttachmentSubmitHandler
|
|||
//Make our file path relative to %BASE%
|
||||
$new_path = $this->pathResolver->realPathToPlaceholder($new_path);
|
||||
//Save the path to the attachment
|
||||
$attachment->setPath($new_path);
|
||||
$attachment->setInternalPath($new_path);
|
||||
} catch (TransportExceptionInterface) {
|
||||
throw new AttachmentDownloadException('Transport error!');
|
||||
}
|
||||
|
|
@ -429,7 +452,9 @@ class AttachmentSubmitHandler
|
|||
//Make our file path relative to %BASE%
|
||||
$file_path = $this->pathResolver->realPathToPlaceholder($file_path);
|
||||
//Save the path to the attachment
|
||||
$attachment->setPath($file_path);
|
||||
$attachment->setInternalPath($file_path);
|
||||
//reset any external paths the attachment might have had
|
||||
$attachment->setExternalPath(null);
|
||||
//And save original filename
|
||||
$attachment->setFilename($file->getClientOriginalName());
|
||||
|
||||
|
|
@ -479,4 +504,32 @@ class AttachmentSubmitHandler
|
|||
|
||||
return $this->max_upload_size_bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes the given SVG file, if the attachment is an internal SVG file.
|
||||
* @param Attachment $attachment
|
||||
* @return Attachment
|
||||
*/
|
||||
public function sanitizeSVGAttachment(Attachment $attachment): Attachment
|
||||
{
|
||||
//We can not do anything on builtins or external ressources
|
||||
if ($attachment->isBuiltIn() || !$attachment->hasInternal()) {
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
//Resolve the path to the file
|
||||
$path = $this->pathResolver->placeholderToRealPath($attachment->getInternalPath());
|
||||
|
||||
//Check if the file exists
|
||||
if (!file_exists($path)) {
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
//Check if the file is an SVG
|
||||
if ($attachment->getExtension() === "svg") {
|
||||
$this->SVGSanitizer->sanitizeFile($path);
|
||||
}
|
||||
|
||||
return $attachment;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,9 +92,9 @@ class AttachmentURLGenerator
|
|||
* Returns a URL under which the attachment file can be viewed.
|
||||
* @return string|null The URL or null if the attachment file is not existing
|
||||
*/
|
||||
public function getViewURL(Attachment $attachment): ?string
|
||||
public function getInternalViewURL(Attachment $attachment): ?string
|
||||
{
|
||||
$absolute_path = $this->attachmentHelper->toAbsoluteFilePath($attachment);
|
||||
$absolute_path = $this->attachmentHelper->toAbsoluteInternalFilePath($attachment);
|
||||
if (null === $absolute_path) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -111,6 +111,7 @@ class AttachmentURLGenerator
|
|||
|
||||
/**
|
||||
* Returns a URL to a thumbnail of the attachment file.
|
||||
* For external files the original URL is returned.
|
||||
* @return string|null The URL or null if the attachment file is not existing
|
||||
*/
|
||||
public function getThumbnailURL(Attachment $attachment, string $filter_name = 'thumbnail_sm'): ?string
|
||||
|
|
@ -119,11 +120,14 @@ class AttachmentURLGenerator
|
|||
throw new InvalidArgumentException('Thumbnail creation only works for picture attachments!');
|
||||
}
|
||||
|
||||
if ($attachment->isExternal() && ($attachment->getURL() !== null && $attachment->getURL() !== '')) {
|
||||
return $attachment->getURL();
|
||||
if (!$attachment->hasInternal()){
|
||||
if($attachment->hasExternal()) {
|
||||
return $attachment->getExternalPath();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$absolute_path = $this->attachmentHelper->toAbsoluteFilePath($attachment);
|
||||
$absolute_path = $this->attachmentHelper->toAbsoluteInternalFilePath($attachment);
|
||||
if (null === $absolute_path) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -137,7 +141,7 @@ class AttachmentURLGenerator
|
|||
//GD can not work with SVG, so serve it directly...
|
||||
//We can not use getExtension here, because it uses the original filename and not the real extension
|
||||
//Instead we use the logic, which is also used to determine if the attachment is a picture
|
||||
$extension = pathinfo(parse_url($attachment->getPath(), PHP_URL_PATH) ?? '', PATHINFO_EXTENSION);
|
||||
$extension = pathinfo(parse_url($attachment->getInternalPath(), PHP_URL_PATH) ?? '', PATHINFO_EXTENSION);
|
||||
if ('svg' === $extension) {
|
||||
return $this->assets->getUrl($asset_path);
|
||||
}
|
||||
|
|
@ -157,7 +161,7 @@ class AttachmentURLGenerator
|
|||
/**
|
||||
* Returns a download link to the file associated with the attachment.
|
||||
*/
|
||||
public function getDownloadURL(Attachment $attachment): string
|
||||
public function getInternalDownloadURL(Attachment $attachment): string
|
||||
{
|
||||
//Redirect always to download controller, which sets the correct headers for downloading:
|
||||
return $this->urlGenerator->generate('attachment_download', ['id' => $attachment->getID()]);
|
||||
|
|
|
|||
58
src/Services/Attachments/SVGSanitizer.php
Normal file
58
src/Services/Attachments/SVGSanitizer.php
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Services\Attachments;
|
||||
|
||||
use Rhukster\DomSanitizer\DOMSanitizer;
|
||||
|
||||
class SVGSanitizer
|
||||
{
|
||||
|
||||
/**
|
||||
* Sanitizes the given SVG string by removing any potentially harmful content (like inline scripts).
|
||||
* @param string $input
|
||||
* @return string
|
||||
*/
|
||||
public function sanitizeString(string $input): string
|
||||
{
|
||||
return (new DOMSanitizer(DOMSanitizer::SVG))->sanitize($input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes the given SVG file by removing any potentially harmful content (like inline scripts).
|
||||
* The sanitized content is written back to the file.
|
||||
* @param string $filepath
|
||||
*/
|
||||
public function sanitizeFile(string $filepath): void
|
||||
{
|
||||
//Open the file and read the content
|
||||
$content = file_get_contents($filepath);
|
||||
if ($content === false) {
|
||||
throw new \RuntimeException('Could not read file: ' . $filepath);
|
||||
}
|
||||
//Sanitize the content
|
||||
$sanitizedContent = $this->sanitizeString($content);
|
||||
//Write the sanitized content back to the file
|
||||
file_put_contents($filepath, $sanitizedContent);
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ use App\Entity\Parts\Category;
|
|||
use App\Entity\Parts\Footprint;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Services\Cache\ElementCacheTagGenerator;
|
||||
use App\Services\EntityURLGenerator;
|
||||
use App\Services\Trees\NodesListBuilder;
|
||||
use App\Settings\MiscSettings\KiCadEDASettings;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
|
@ -48,6 +49,7 @@ class KiCadHelper
|
|||
private readonly EntityManagerInterface $em,
|
||||
private readonly ElementCacheTagGenerator $tagGenerator,
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
private readonly EntityURLGenerator $entityURLGenerator,
|
||||
private readonly TranslatorInterface $translator,
|
||||
KiCadEDASettings $kiCadEDASettings,
|
||||
) {
|
||||
|
|
@ -68,6 +70,10 @@ class KiCadHelper
|
|||
$secure_class_name = $this->tagGenerator->getElementTypeCacheTag(Category::class);
|
||||
$item->tag($secure_class_name);
|
||||
|
||||
//Invalidate the cache on part changes (as the visibility depends on parts, and the parts can change)
|
||||
$secure_class_name = $this->tagGenerator->getElementTypeCacheTag(Part::class);
|
||||
$item->tag($secure_class_name);
|
||||
|
||||
//If the category depth is smaller than 0, create only one dummy category
|
||||
if ($this->category_depth < 0) {
|
||||
return [
|
||||
|
|
@ -112,6 +118,8 @@ class KiCadHelper
|
|||
$result[] = [
|
||||
'id' => (string)$category->getId(),
|
||||
'name' => $category->getFullPath('/'),
|
||||
//Show the category link as the category description, this also fixes an segfault in KiCad see issue #878
|
||||
'description' => $this->entityURLGenerator->listPartsURL($category),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -247,7 +247,8 @@ trait EntityMergerHelperTrait
|
|||
{
|
||||
return $this->mergeCollections($target, $other, 'attachments', fn(Attachment $t, Attachment $o): bool => $t->getName() === $o->getName()
|
||||
&& $t->getAttachmentType() === $o->getAttachmentType()
|
||||
&& $t->getPath() === $o->getPath());
|
||||
&& $t->getExternalPath() === $o->getExternalPath()
|
||||
&& $t->getInternalPath() === $o->getInternalPath());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -156,25 +156,34 @@ class EntityURLGenerator
|
|||
|
||||
public function viewURL(Attachment $entity): string
|
||||
{
|
||||
if ($entity->isExternal()) { //For external attachments, return the link to external path
|
||||
return $entity->getURL() ?? throw new \RuntimeException('External attachment has no URL!');
|
||||
//If the underlying file path is invalid, null gets returned, which is not allowed here.
|
||||
//We still have the chance to use an external path, if it is set.
|
||||
if ($entity->hasInternal() && ($url = $this->attachmentURLGenerator->getInternalViewURL($entity)) !== null) {
|
||||
return $url;
|
||||
}
|
||||
//return $this->urlGenerator->generate('attachment_view', ['id' => $entity->getID()]);
|
||||
return $this->attachmentURLGenerator->getViewURL($entity) ?? '';
|
||||
|
||||
if($entity->hasExternal()) {
|
||||
return $entity->getExternalPath();
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Attachment has no internal nor external path!');
|
||||
}
|
||||
|
||||
public function downloadURL($entity): string
|
||||
{
|
||||
if ($entity instanceof Attachment) {
|
||||
if ($entity->isExternal()) { //For external attachments, return the link to external path
|
||||
return $entity->getURL() ?? throw new \RuntimeException('External attachment has no URL!');
|
||||
}
|
||||
|
||||
return $this->attachmentURLGenerator->getDownloadURL($entity);
|
||||
if (!($entity instanceof Attachment)) {
|
||||
throw new EntityNotSupportedException(sprintf('The given entity is not supported yet! Passed class type: %s', $entity::class));
|
||||
}
|
||||
|
||||
//Otherwise throw an error
|
||||
throw new EntityNotSupportedException(sprintf('The given entity is not supported yet! Passed class type: %s', $entity::class));
|
||||
if ($entity->hasInternal()) {
|
||||
return $this->attachmentURLGenerator->getInternalDownloadURL($entity);
|
||||
}
|
||||
|
||||
if($entity->hasExternal()) {
|
||||
return $entity->getExternalPath();
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Attachment has not internal or external path!');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -357,7 +357,7 @@ class EntityImporter
|
|||
* @param iterable $entities the list of entities that should be fixed
|
||||
* @param AbstractStructuralDBElement|null $parent the parent, to which the entity should be set
|
||||
*/
|
||||
protected function correctParentEntites(iterable $entities, AbstractStructuralDBElement $parent = null): void
|
||||
protected function correctParentEntites(iterable $entities, ?AbstractStructuralDBElement $parent = null): void
|
||||
{
|
||||
foreach ($entities as $entity) {
|
||||
/** @var AbstractStructuralDBElement $entity */
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ trait PKImportHelperTrait
|
|||
//Next comes the filename plus extension
|
||||
$path .= '/'.$attachment_row['filename'].'.'.$attachment_row['extension'];
|
||||
|
||||
$attachment->setPath($path);
|
||||
$attachment->setInternalPath($path);
|
||||
|
||||
return $attachment;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,9 +72,9 @@ class ParameterDTO
|
|||
group: $group);
|
||||
}
|
||||
|
||||
//If the attribute contains "..." or a tilde we assume it is a range
|
||||
if (preg_match('/(\.{3}|~)/', $value) === 1) {
|
||||
$parts = preg_split('/\s*(\.{3}|~)\s*/', $value);
|
||||
//If the attribute contains ".." or "..." or a tilde we assume it is a range
|
||||
if (preg_match('/(\.{2,3}|~)/', $value) === 1) {
|
||||
$parts = preg_split('/\s*(\.{2,3}|~)\s*/', $value);
|
||||
if (count($parts) === 2) {
|
||||
//Try to extract number and unit from value (allow leading +)
|
||||
if ($unit === null || trim($unit) === '') {
|
||||
|
|
|
|||
|
|
@ -178,9 +178,21 @@ final class DTOtoEntityConverter
|
|||
//Set the provider reference on the part
|
||||
$entity->setProviderReference(InfoProviderReference::fromPartDTO($dto));
|
||||
|
||||
$param_groups = [];
|
||||
|
||||
//Add parameters
|
||||
foreach ($dto->parameters ?? [] as $parameter) {
|
||||
$entity->addParameter($this->convertParameter($parameter));
|
||||
$new_param = $this->convertParameter($parameter);
|
||||
|
||||
$key = $new_param->getName() . '##' . $new_param->getGroup();
|
||||
//If there is already an parameter with the same name and group, rename the new parameter, by suffixing a number
|
||||
if (count($param_groups[$key] ?? []) > 0) {
|
||||
$new_param->setName($new_param->getName() . ' (' . (count($param_groups[$key]) + 1) . ')');
|
||||
}
|
||||
|
||||
$param_groups[$key][] = $new_param;
|
||||
|
||||
$entity->addParameter($new_param);
|
||||
}
|
||||
|
||||
//Add preview image
|
||||
|
|
@ -196,6 +208,8 @@ final class DTOtoEntityConverter
|
|||
$entity->setMasterPictureAttachment($preview_image);
|
||||
}
|
||||
|
||||
$attachments_grouped = [];
|
||||
|
||||
//Add other images
|
||||
$images = $this->files_unique($dto->images ?? []);
|
||||
foreach ($images as $image) {
|
||||
|
|
@ -204,14 +218,29 @@ final class DTOtoEntityConverter
|
|||
continue;
|
||||
}
|
||||
|
||||
$entity->addAttachment($this->convertFile($image, $image_type));
|
||||
$attachment = $this->convertFile($image, $image_type);
|
||||
|
||||
$attachments_grouped[$attachment->getName()][] = $attachment;
|
||||
if (count($attachments_grouped[$attachment->getName()] ?? []) > 1) {
|
||||
$attachment->setName($attachment->getName() . ' (' . (count($attachments_grouped[$attachment->getName()]) + 1) . ')');
|
||||
}
|
||||
|
||||
|
||||
$entity->addAttachment($attachment);
|
||||
}
|
||||
|
||||
//Add datasheets
|
||||
$datasheet_type = $this->getDatasheetType();
|
||||
$datasheets = $this->files_unique($dto->datasheets ?? []);
|
||||
foreach ($datasheets as $datasheet) {
|
||||
$entity->addAttachment($this->convertFile($datasheet, $datasheet_type));
|
||||
$attachment = $this->convertFile($datasheet, $datasheet_type);
|
||||
|
||||
$attachments_grouped[$attachment->getName()][] = $attachment;
|
||||
if (count($attachments_grouped[$attachment->getName()] ?? []) > 1) {
|
||||
$attachment->setName($attachment->getName() . ' (' . (count($attachments_grouped[$attachment->getName()])) . ')');
|
||||
}
|
||||
|
||||
$entity->addAttachment($attachment);
|
||||
}
|
||||
|
||||
//Add orderdetails and prices
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ use App\Entity\Parts\Part;
|
|||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
||||
|
|
@ -34,10 +35,12 @@ final class PartInfoRetriever
|
|||
{
|
||||
|
||||
private const CACHE_DETAIL_EXPIRATION = 60 * 60 * 24 * 4; // 4 days
|
||||
private const CACHE_RESULT_EXPIRATION = 60 * 60 * 24 * 7; // 7 days
|
||||
private const CACHE_RESULT_EXPIRATION = 60 * 60 * 24 * 4; // 7 days
|
||||
|
||||
public function __construct(private readonly ProviderRegistry $provider_registry,
|
||||
private readonly DTOtoEntityConverter $dto_to_entity_converter, private readonly CacheInterface $partInfoCache)
|
||||
private readonly DTOtoEntityConverter $dto_to_entity_converter, private readonly CacheInterface $partInfoCache,
|
||||
#[Autowire(param: "kernel.debug")]
|
||||
private readonly bool $debugMode = false)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -56,6 +59,11 @@ final class PartInfoRetriever
|
|||
$provider = $this->provider_registry->getProviderByKey($provider);
|
||||
}
|
||||
|
||||
//Ensure that the provider is active
|
||||
if (!$provider->isActive()) {
|
||||
throw new \RuntimeException("The provider with key {$provider->getProviderKey()} is not active!");
|
||||
}
|
||||
|
||||
if (!$provider instanceof InfoProviderInterface) {
|
||||
throw new \InvalidArgumentException("The provider must be either a provider key or a provider instance!");
|
||||
}
|
||||
|
|
@ -77,7 +85,7 @@ final class PartInfoRetriever
|
|||
$escaped_keyword = urlencode($keyword);
|
||||
return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) {
|
||||
//Set the expiration time
|
||||
$item->expiresAfter(self::CACHE_RESULT_EXPIRATION);
|
||||
$item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 1);
|
||||
|
||||
return $provider->searchByKeyword($keyword);
|
||||
});
|
||||
|
|
@ -94,11 +102,16 @@ final class PartInfoRetriever
|
|||
{
|
||||
$provider = $this->provider_registry->getProviderByKey($provider_key);
|
||||
|
||||
//Ensure that the provider is active
|
||||
if (!$provider->isActive()) {
|
||||
throw new \RuntimeException("The provider with key $provider_key is not active!");
|
||||
}
|
||||
|
||||
//Generate key and escape reserved characters from the provider id
|
||||
$escaped_part_id = urlencode($part_id);
|
||||
return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) {
|
||||
//Set the expiration time
|
||||
$item->expiresAfter(self::CACHE_DETAIL_EXPIRATION);
|
||||
$item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 1);
|
||||
|
||||
return $provider->getDetails($part_id);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -108,12 +108,15 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
{
|
||||
$request = [
|
||||
'Keywords' => $keyword,
|
||||
'RecordCount' => 50,
|
||||
'RecordStartPosition' => 0,
|
||||
'ExcludeMarketPlaceProducts' => 'true',
|
||||
'Limit' => 50,
|
||||
'Offset' => 0,
|
||||
'FilterOptionsRequest' => [
|
||||
'MarketPlaceFilter' => 'ExcludeMarketPlace',
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->digikeyClient->request('POST', '/Search/v3/Products/Keyword', [
|
||||
//$response = $this->digikeyClient->request('POST', '/Search/v3/Products/Keyword', [
|
||||
$response = $this->digikeyClient->request('POST', '/products/v4/search/keyword', [
|
||||
'json' => $request,
|
||||
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
|
||||
]);
|
||||
|
|
@ -124,18 +127,21 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
$result = [];
|
||||
$products = $response_array['Products'];
|
||||
foreach ($products as $product) {
|
||||
$result[] = new SearchResultDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $product['DigiKeyPartNumber'],
|
||||
name: $product['ManufacturerPartNumber'],
|
||||
description: $product['DetailedDescription'] ?? $product['ProductDescription'],
|
||||
category: $this->getCategoryString($product),
|
||||
manufacturer: $product['Manufacturer']['Value'] ?? null,
|
||||
mpn: $product['ManufacturerPartNumber'],
|
||||
preview_image_url: $product['PrimaryPhoto'] ?? null,
|
||||
manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']),
|
||||
provider_url: $product['ProductUrl'],
|
||||
);
|
||||
foreach ($product['ProductVariations'] as $variation) {
|
||||
$result[] = new SearchResultDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $variation['DigiKeyProductNumber'],
|
||||
name: $product['ManufacturerProductNumber'],
|
||||
description: $product['Description']['DetailedDescription'] ?? $product['Description']['ProductDescription'],
|
||||
category: $this->getCategoryString($product),
|
||||
manufacturer: $product['Manufacturer']['Name'] ?? null,
|
||||
mpn: $product['ManufacturerProductNumber'],
|
||||
preview_image_url: $product['PhotoUrl'] ?? null,
|
||||
manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']['Id']),
|
||||
provider_url: $product['ProductUrl'],
|
||||
footprint: $variation['PackageType']['Name'], //Use the footprint field, to show the user the package type (Tape & Reel, etc., as digikey has many different package types)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
|
|
@ -143,62 +149,79 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
{
|
||||
$response = $this->digikeyClient->request('GET', '/Search/v3/Products/' . urlencode($id), [
|
||||
$response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/productdetails', [
|
||||
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
|
||||
]);
|
||||
|
||||
$product = $response->toArray();
|
||||
$response_array = $response->toArray();
|
||||
$product = $response_array['Product'];
|
||||
|
||||
$footprint = null;
|
||||
$parameters = $this->parametersToDTOs($product['Parameters'] ?? [], $footprint);
|
||||
$media = $this->mediaToDTOs($product['MediaLinks']);
|
||||
$media = $this->mediaToDTOs($id);
|
||||
|
||||
// Get the price_breaks of the selected variation
|
||||
$price_breaks = [];
|
||||
foreach ($product['ProductVariations'] as $variation) {
|
||||
if ($variation['DigiKeyProductNumber'] == $id) {
|
||||
$price_breaks = $variation['StandardPricing'] ?? [];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $product['DigiKeyPartNumber'],
|
||||
name: $product['ManufacturerPartNumber'],
|
||||
description: $product['DetailedDescription'] ?? $product['ProductDescription'],
|
||||
provider_id: $id,
|
||||
name: $product['ManufacturerProductNumber'],
|
||||
description: $product['Description']['DetailedDescription'] ?? $product['Description']['ProductDescription'],
|
||||
category: $this->getCategoryString($product),
|
||||
manufacturer: $product['Manufacturer']['Value'] ?? null,
|
||||
mpn: $product['ManufacturerPartNumber'],
|
||||
preview_image_url: $product['PrimaryPhoto'] ?? null,
|
||||
manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']),
|
||||
manufacturer: $product['Manufacturer']['Name'] ?? null,
|
||||
mpn: $product['ManufacturerProductNumber'],
|
||||
preview_image_url: $product['PhotoUrl'] ?? null,
|
||||
manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']['Id']),
|
||||
provider_url: $product['ProductUrl'],
|
||||
footprint: $footprint,
|
||||
datasheets: $media['datasheets'],
|
||||
images: $media['images'],
|
||||
parameters: $parameters,
|
||||
vendor_infos: $this->pricingToDTOs($product['StandardPricing'] ?? [], $product['DigiKeyPartNumber'], $product['ProductUrl']),
|
||||
vendor_infos: $this->pricingToDTOs($price_breaks, $id, $product['ProductUrl']),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the product status from the Digikey API to the manufacturing status used in Part-DB
|
||||
* @param string|null $dk_status
|
||||
* @param int|null $dk_status
|
||||
* @return ManufacturingStatus|null
|
||||
*/
|
||||
private function productStatusToManufacturingStatus(?string $dk_status): ?ManufacturingStatus
|
||||
private function productStatusToManufacturingStatus(?int $dk_status): ?ManufacturingStatus
|
||||
{
|
||||
// The V4 can use strings to get the status, but if you have changed the PROVIDER_DIGIKEY_LANGUAGE it will not match.
|
||||
// Using the Id instead which should be fixed.
|
||||
//
|
||||
// The API is not well documented and the ID are not there yet, so were extracted using "trial and error".
|
||||
// The 'Preliminary' id was not found in several categories so I was unable to extract it. Disabled for now.
|
||||
return match ($dk_status) {
|
||||
null => null,
|
||||
'Active' => ManufacturingStatus::ACTIVE,
|
||||
'Obsolete' => ManufacturingStatus::DISCONTINUED,
|
||||
'Discontinued at Digi-Key', 'Last Time Buy' => ManufacturingStatus::EOL,
|
||||
'Not For New Designs' => ManufacturingStatus::NRFND,
|
||||
'Preliminary' => ManufacturingStatus::ANNOUNCED,
|
||||
0 => ManufacturingStatus::ACTIVE,
|
||||
1 => ManufacturingStatus::DISCONTINUED,
|
||||
2, 4 => ManufacturingStatus::EOL,
|
||||
7 => ManufacturingStatus::NRFND,
|
||||
//'Preliminary' => ManufacturingStatus::ANNOUNCED,
|
||||
default => ManufacturingStatus::NOT_SET,
|
||||
};
|
||||
}
|
||||
|
||||
private function getCategoryString(array $product): string
|
||||
{
|
||||
$category = $product['Category']['Value'];
|
||||
$sub_category = $product['Family']['Value'];
|
||||
$category = $product['Category']['Name'];
|
||||
$sub_category = current($product['Category']['ChildCategories']);
|
||||
|
||||
//Replace the ' - ' category separator with ' -> '
|
||||
$sub_category = str_replace(' - ', ' -> ', $sub_category);
|
||||
if ($sub_category) {
|
||||
//Replace the ' - ' category separator with ' -> '
|
||||
$category = $category . ' -> ' . str_replace(' - ', ' -> ', $sub_category["Name"]);
|
||||
}
|
||||
|
||||
return $category . ' -> ' . $sub_category;
|
||||
return $category;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -215,18 +238,18 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
|
||||
foreach ($parameters as $parameter) {
|
||||
if ($parameter['ParameterId'] === 1291) { //Meaning "Manufacturer given footprint"
|
||||
$footprint_name = $parameter['Value'];
|
||||
$footprint_name = $parameter['ValueText'];
|
||||
}
|
||||
|
||||
if (in_array(trim((string) $parameter['Value']), ['', '-'], true)) {
|
||||
if (in_array(trim((string) $parameter['ValueText']), ['', '-'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
//If the parameter was marked as text only, then we do not try to parse it as a numerical value
|
||||
if (in_array($parameter['ParameterId'], self::TEXT_ONLY_PARAMETERS, true)) {
|
||||
$results[] = new ParameterDTO(name: $parameter['Parameter'], value_text: $parameter['Value']);
|
||||
$results[] = new ParameterDTO(name: $parameter['ParameterText'], value_text: $parameter['ValueText']);
|
||||
} else { //Otherwise try to parse it as a numerical value
|
||||
$results[] = ParameterDTO::parseValueIncludingUnit($parameter['Parameter'], $parameter['Value']);
|
||||
$results[] = ParameterDTO::parseValueIncludingUnit($parameter['ParameterText'], $parameter['ValueText']);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -254,16 +277,22 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
}
|
||||
|
||||
/**
|
||||
* @param array $media_links
|
||||
* @param string $id The Digikey product number, to get the media for
|
||||
* @return FileDTO[][]
|
||||
* @phpstan-return array<string, FileDTO[]>
|
||||
*/
|
||||
private function mediaToDTOs(array $media_links): array
|
||||
private function mediaToDTOs(string $id): array
|
||||
{
|
||||
$datasheets = [];
|
||||
$images = [];
|
||||
|
||||
foreach ($media_links as $media_link) {
|
||||
$response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/media', [
|
||||
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
|
||||
]);
|
||||
|
||||
$media_array = $response->toArray();
|
||||
|
||||
foreach ($media_array['MediaLinks'] as $media_link) {
|
||||
$file = new FileDTO(url: $media_link['Url'], name: $media_link['Title']);
|
||||
|
||||
switch ($media_link['MediaType']) {
|
||||
|
|
|
|||
|
|
@ -29,14 +29,13 @@ use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Settings\InfoProviderSystem\Element14Settings;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class Element14Provider implements InfoProviderInterface
|
||||
{
|
||||
|
||||
private const ENDPOINT_URL = 'https://api.element14.com/catalog/products';
|
||||
private const API_VERSION_NUMBER = '1.2';
|
||||
private const API_VERSION_NUMBER = '1.4';
|
||||
private const NUMBER_OF_RESULTS = 20;
|
||||
|
||||
public const DISTRIBUTOR_NAME = 'Farnell';
|
||||
|
|
@ -44,9 +43,19 @@ class Element14Provider implements InfoProviderInterface
|
|||
private const COMPLIANCE_ATTRIBUTES = ['euEccn', 'hazardous', 'MSL', 'productTraceability', 'rohsCompliant',
|
||||
'rohsPhthalatesCompliant', 'SVHC', 'tariffCode', 'usEccn', 'hazardCode'];
|
||||
|
||||
private readonly HttpClientInterface $element14Client;
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $element14Client, private readonly Element14Settings $settings)
|
||||
{
|
||||
|
||||
/* We use the mozilla CA from the composer ca bundle directly, as some debian systems seems to have problems
|
||||
* with the SSL.COM CA, element14 uses. See https://github.com/Part-DB/Part-DB-server/issues/866
|
||||
*
|
||||
* This is a workaround until the issue is resolved in debian (or never).
|
||||
* As this only affects this provider, this should have no negative impact and the CA bundle is still secure.
|
||||
*/
|
||||
$this->element14Client = $element14Client->withOptions([
|
||||
'cafile' => CaBundle::getBundledCaBundlePath(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
|
|
@ -84,7 +93,7 @@ class Element14Provider implements InfoProviderInterface
|
|||
'resultsSettings.responseGroup' => 'large',
|
||||
'callInfo.apiKey' => $this->settings->apiKey,
|
||||
'callInfo.responseDataFormat' => 'json',
|
||||
'callInfo.version' => self::API_VERSION_NUMBER,
|
||||
'versionNumber' => self::API_VERSION_NUMBER,
|
||||
],
|
||||
]);
|
||||
|
||||
|
|
@ -108,10 +117,12 @@ class Element14Provider implements InfoProviderInterface
|
|||
mpn: $product['translatedManufacturerPartNumber'],
|
||||
preview_image_url: $this->toImageUrl($product['image'] ?? null),
|
||||
manufacturing_status: $this->releaseStatusCodeToManufacturingStatus($product['releaseStatusCode'] ?? null),
|
||||
provider_url: $this->generateProductURL($product['sku']),
|
||||
provider_url: $product['productURL'],
|
||||
notes: $product['productOverview']['description'] ?? null,
|
||||
datasheets: $this->parseDataSheets($product['datasheets'] ?? null),
|
||||
parameters: $this->attributesToParameters($product['attributes'] ?? null),
|
||||
vendor_infos: $this->pricesToVendorInfo($product['sku'], $product['prices'] ?? [])
|
||||
vendor_infos: $this->pricesToVendorInfo($product['sku'], $product['prices'] ?? [], $product['productURL']),
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -120,7 +131,7 @@ class Element14Provider implements InfoProviderInterface
|
|||
|
||||
private function generateProductURL($sku): string
|
||||
{
|
||||
return 'https://' . $this->settings->storeId . '/' . $sku;
|
||||
return 'https://' . $this->store_id . '/' . $sku;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -162,7 +173,7 @@ class Element14Provider implements InfoProviderInterface
|
|||
* @param array $prices
|
||||
* @return array
|
||||
*/
|
||||
private function pricesToVendorInfo(string $sku, array $prices): array
|
||||
private function pricesToVendorInfo(string $sku, array $prices, string $product_url): array
|
||||
{
|
||||
$price_dtos = [];
|
||||
|
||||
|
|
@ -180,7 +191,7 @@ class Element14Provider implements InfoProviderInterface
|
|||
distributor_name: self::DISTRIBUTOR_NAME,
|
||||
order_number: $sku,
|
||||
prices: $price_dtos,
|
||||
product_url: $this->generateProductURL($sku)
|
||||
product_url: $product_url
|
||||
)
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ class MouserProvider implements InfoProviderInterface
|
|||
From the startingRecord, the number of records specified will be returned up to the end of the recordset.
|
||||
This is useful for paging through the complete recordset of parts matching keyword.
|
||||
|
||||
|
||||
searchOptions string
|
||||
Optional.
|
||||
If not provided, the default is None.
|
||||
|
|
@ -174,11 +175,16 @@ class MouserProvider implements InfoProviderInterface
|
|||
throw new \RuntimeException('No part found with ID '.$id);
|
||||
}
|
||||
|
||||
//Manually filter out the part with the correct ID
|
||||
$tmp = array_filter($tmp, fn(PartDetailDTO $part) => $part->provider_id === $id);
|
||||
if (count($tmp) === 0) {
|
||||
throw new \RuntimeException('No part found with ID '.$id);
|
||||
}
|
||||
if (count($tmp) > 1) {
|
||||
throw new \RuntimeException('Multiple parts found with ID '.$id . ' ('.count($tmp).' found). This is basically a bug in Mousers API response. See issue #616.');
|
||||
throw new \RuntimeException('Multiple parts found with ID '.$id);
|
||||
}
|
||||
|
||||
return $tmp[0];
|
||||
return reset($tmp);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
|
|
|
|||
|
|
@ -1218,7 +1218,7 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
* - 'value_min' => string|null The minimum value in a range, if applicable.
|
||||
* - 'value_max' => string|null The maximum value in a range, if applicable.
|
||||
*/
|
||||
private function customSplitIntoValueAndUnit(string $value1, string $value2 = null): array
|
||||
private function customSplitIntoValueAndUnit(string $value1, ?string $value2 = null): array
|
||||
{
|
||||
// Separate numbers and units (basic parsing handling)
|
||||
$unit = null;
|
||||
|
|
|
|||
249
src/Services/InfoProviderSystem/Providers/PollinProvider.php
Normal file
249
src/Services/InfoProviderSystem/Providers/PollinProvider.php
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Services\InfoProviderSystem\Providers;
|
||||
|
||||
use App\Entity\Parts\ManufacturingStatus;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class PollinProvider implements InfoProviderInterface
|
||||
{
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $client,
|
||||
#[Autowire(env: 'bool:PROVIDER_POLLIN_ENABLED')]
|
||||
private readonly bool $enabled = true,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Pollin',
|
||||
'description' => 'Webscraping from pollin.de to get part information',
|
||||
'url' => 'https://www.pollin.de/',
|
||||
'disabled_help' => 'Set PROVIDER_POLLIN_ENABLED env to 1'
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'pollin';
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
{
|
||||
$response = $this->client->request('GET', 'https://www.pollin.de/search', [
|
||||
'query' => [
|
||||
'search' => $keyword
|
||||
]
|
||||
]);
|
||||
|
||||
$content = $response->getContent();
|
||||
|
||||
//If the response has us redirected to the product page, then just return the single item
|
||||
if ($response->getInfo('redirect_count') > 0) {
|
||||
return [$this->parseProductPage($content)];
|
||||
}
|
||||
|
||||
$dom = new Crawler($content);
|
||||
|
||||
$results = [];
|
||||
|
||||
//Iterate over each div.product-box
|
||||
$dom->filter('div.product-box')->each(function (Crawler $node) use (&$results) {
|
||||
$results[] = new SearchResultDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $node->filter('meta[itemprop="productID"]')->attr('content'),
|
||||
name: $node->filter('a.product-name')->text(),
|
||||
description: '',
|
||||
preview_image_url: $node->filter('img.product-image')->attr('src'),
|
||||
manufacturing_status: $this->mapAvailability($node->filter('link[itemprop="availability"]')->attr('href')),
|
||||
provider_url: $node->filter('a.product-name')->attr('href')
|
||||
);
|
||||
});
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function mapAvailability(string $availabilityURI): ManufacturingStatus
|
||||
{
|
||||
return match( $availabilityURI) {
|
||||
'http://schema.org/InStock' => ManufacturingStatus::ACTIVE,
|
||||
'http://schema.org/OutOfStock' => ManufacturingStatus::DISCONTINUED,
|
||||
default => ManufacturingStatus::NOT_SET
|
||||
};
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
{
|
||||
//Ensure that $id is numeric
|
||||
if (!is_numeric($id)) {
|
||||
throw new \InvalidArgumentException("The id must be numeric!");
|
||||
}
|
||||
|
||||
$response = $this->client->request('GET', 'https://www.pollin.de/search', [
|
||||
'query' => [
|
||||
'search' => $id
|
||||
]
|
||||
]);
|
||||
|
||||
//The response must have us redirected to the product page
|
||||
if ($response->getInfo('redirect_count') > 0) {
|
||||
throw new \RuntimeException("Could not resolve the product page for the given id!");
|
||||
}
|
||||
|
||||
$content = $response->getContent();
|
||||
|
||||
return $this->parseProductPage($content);
|
||||
}
|
||||
|
||||
private function parseProductPage(string $content): PartDetailDTO
|
||||
{
|
||||
$dom = new Crawler($content);
|
||||
|
||||
$productPageUrl = $dom->filter('meta[property="product:product_link"]')->attr('content');
|
||||
$orderId = trim($dom->filter('span[itemprop="sku"]')->text()); //Text is important here
|
||||
|
||||
//Calculate the mass
|
||||
$massStr = $dom->filter('meta[itemprop="weight"]')->attr('content');
|
||||
//Remove the unit
|
||||
$massStr = str_replace('kg', '', $massStr);
|
||||
//Convert to float and convert to grams
|
||||
$mass = (float) $massStr * 1000;
|
||||
|
||||
//Parse purchase info
|
||||
$purchaseInfo = new PurchaseInfoDTO('Pollin', $orderId, $this->parsePrices($dom), $productPageUrl);
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $orderId,
|
||||
name: trim($dom->filter('meta[property="og:title"]')->attr('content')),
|
||||
description: $dom->filter('meta[property="og:description"]')->attr('content'),
|
||||
category: $this->parseCategory($dom),
|
||||
manufacturer: $dom->filter('meta[property="product:brand"]')->count() > 0 ? $dom->filter('meta[property="product:brand"]')->attr('content') : null,
|
||||
preview_image_url: $dom->filter('meta[property="og:image"]')->attr('content'),
|
||||
manufacturing_status: $this->mapAvailability($dom->filter('link[itemprop="availability"]')->attr('href')),
|
||||
provider_url: $productPageUrl,
|
||||
notes: $this->parseNotes($dom),
|
||||
datasheets: $this->parseDatasheets($dom),
|
||||
parameters: $this->parseParameters($dom),
|
||||
vendor_infos: [$purchaseInfo],
|
||||
mass: $mass,
|
||||
);
|
||||
}
|
||||
|
||||
private function parseDatasheets(Crawler $dom): array
|
||||
{
|
||||
//Iterate over each a element withing div.pol-product-detail-download-files
|
||||
$datasheets = [];
|
||||
$dom->filter('div.pol-product-detail-download-files a')->each(function (Crawler $node) use (&$datasheets) {
|
||||
$datasheets[] = new FileDTO($node->attr('href'), $node->text());
|
||||
});
|
||||
|
||||
return $datasheets;
|
||||
}
|
||||
|
||||
private function parseParameters(Crawler $dom): array
|
||||
{
|
||||
$parameters = [];
|
||||
|
||||
//Iterate over each tr.properties-row inside table.product-detail-properties-table
|
||||
$dom->filter('table.product-detail-properties-table tr.properties-row')->each(function (Crawler $node) use (&$parameters) {
|
||||
$parameters[] = ParameterDTO::parseValueIncludingUnit(
|
||||
name: rtrim($node->filter('th.properties-label')->text(), ':'),
|
||||
value: trim($node->filter('td.properties-value')->text())
|
||||
);
|
||||
});
|
||||
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
private function parseCategory(Crawler $dom): string
|
||||
{
|
||||
$category = '';
|
||||
|
||||
//Iterate over each li.breadcrumb-item inside ol.breadcrumb
|
||||
$dom->filter('ol.breadcrumb li.breadcrumb-item')->each(function (Crawler $node) use (&$category) {
|
||||
//Skip if it has breadcrumb-item-home class
|
||||
if (str_contains($node->attr('class'), 'breadcrumb-item-home')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
$category .= $node->text() . ' -> ';
|
||||
});
|
||||
|
||||
//Remove the last ' -> '
|
||||
return substr($category, 0, -4);
|
||||
}
|
||||
|
||||
private function parseNotes(Crawler $dom): string
|
||||
{
|
||||
//Concat product highlights and product description
|
||||
return $dom->filter('div.product-detail-top-features')->html('') . '<br><br>' . $dom->filter('div.product-detail-description-text')->html('');
|
||||
}
|
||||
|
||||
private function parsePrices(Crawler $dom): array
|
||||
{
|
||||
//TODO: Properly handle multiple prices, for now we just look at the price for one piece
|
||||
|
||||
//We assume the currency is always the same
|
||||
$currency = $dom->filter('meta[property="product:price:currency"]')->attr('content');
|
||||
|
||||
//If there is meta[property=highPrice] then use this as the price
|
||||
if ($dom->filter('meta[itemprop="highPrice"]')->count() > 0) {
|
||||
$price = $dom->filter('meta[itemprop="highPrice"]')->attr('content');
|
||||
} else {
|
||||
$price = $dom->filter('meta[property="product:price:amount"]')->attr('content');
|
||||
}
|
||||
|
||||
return [
|
||||
new PriceDTO(1.0, $price, $currency)
|
||||
];
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::DATASHEET
|
||||
];
|
||||
}
|
||||
}
|
||||
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' => 'Webscraping 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -51,6 +51,16 @@ class TMEClient
|
|||
return !($this->settings->apiToken === '' || $this->settings->apiSecret === '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the client is using a private (account related token) instead of a deprecated anonymous token
|
||||
* to authenticate with TME.
|
||||
* @return bool
|
||||
*/
|
||||
public function isUsingPrivateToken(): bool
|
||||
{
|
||||
//Private tokens are longer than anonymous ones (50 instead of 45 characters)
|
||||
return strlen($this->token) > 45;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the signature for the given action and parameters.
|
||||
|
|
|
|||
|
|
@ -37,9 +37,15 @@ class TMEProvider implements InfoProviderInterface
|
|||
|
||||
private const VENDOR_NAME = 'TME';
|
||||
|
||||
private readonly bool $get_gross_prices;
|
||||
public function __construct(private readonly TMEClient $tmeClient, private readonly TMESettings $settings)
|
||||
{
|
||||
|
||||
//If we have a private token, set get_gross_prices to false, as it is automatically determined by the account type then
|
||||
if ($this->tmeClient->isUsingPrivateToken()) {
|
||||
$this->get_gross_prices = false;
|
||||
} else {
|
||||
$this->get_gross_prices = $get_gross_prices;
|
||||
}
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
|
|
@ -185,7 +191,7 @@ class TMEProvider implements InfoProviderInterface
|
|||
'Country' => $this->settings->country,
|
||||
'Language' => $this->settings->language,
|
||||
'Currency' => $this->settings->currency,
|
||||
'GrossPrices' => $this->settings->grossPrices,
|
||||
'GrossPrices' => $this->get_gross_prices,
|
||||
'SymbolList' => [$id],
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -63,12 +63,24 @@ final class BarcodeProvider implements PlaceholderProviderInterface
|
|||
return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target);
|
||||
}
|
||||
|
||||
if ('[[BARCODE_DATAMATRIX]]' === $placeholder) {
|
||||
$label_options = new LabelOptions();
|
||||
$label_options->setBarcodeType(BarcodeType::DATAMATRIX);
|
||||
return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target);
|
||||
}
|
||||
|
||||
if ('[[BARCODE_C39]]' === $placeholder) {
|
||||
$label_options = new LabelOptions();
|
||||
$label_options->setBarcodeType(BarcodeType::CODE39);
|
||||
return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target);
|
||||
}
|
||||
|
||||
if ('[[BARCODE_C93]]' === $placeholder) {
|
||||
$label_options = new LabelOptions();
|
||||
$label_options->setBarcodeType(BarcodeType::CODE93);
|
||||
return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target);
|
||||
}
|
||||
|
||||
if ('[[BARCODE_C128]]' === $placeholder) {
|
||||
$label_options = new LabelOptions();
|
||||
$label_options->setBarcodeType(BarcodeType::CODE128);
|
||||
|
|
|
|||
|
|
@ -122,8 +122,8 @@ final class SandboxedTwigFactory
|
|||
'getFullPath', 'getPathArray', 'getSubelements', 'getChildren', 'isNotSelectable', ],
|
||||
AbstractCompany::class => ['getAddress', 'getPhoneNumber', 'getFaxNumber', 'getEmailAddress', 'getWebsite', 'getAutoProductUrl'],
|
||||
AttachmentContainingDBElement::class => ['getAttachments', 'getMasterPictureAttachment'],
|
||||
Attachment::class => ['isPicture', 'is3DModel', 'isExternal', 'isSecure', 'isBuiltIn', 'getExtension',
|
||||
'getElement', 'getURL', 'getHost', 'getFilename', 'getAttachmentType', 'getShowInTable', ],
|
||||
Attachment::class => ['isPicture', 'is3DModel', 'hasExternal', 'hasInternal', 'isSecure', 'isBuiltIn', 'getExtension',
|
||||
'getElement', 'getExternalPath', 'getHost', 'getFilename', 'getAttachmentType', 'getShowInTable'],
|
||||
AbstractParameter::class => ['getFormattedValue', 'getGroup', 'getSymbol', 'getValueMin', 'getValueMax',
|
||||
'getValueTypical', 'getUnit', 'getValueText', ],
|
||||
MeasurementUnit::class => ['getUnit', 'isInteger', 'useSIPrefix'],
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ declare(strict_types=1);
|
|||
*/
|
||||
namespace App\Services\Parts;
|
||||
|
||||
use App\Entity\Parts\StorageLocation;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\Parts\Category;
|
||||
use App\Entity\Parts\Footprint;
|
||||
|
|
@ -35,6 +36,9 @@ use InvalidArgumentException;
|
|||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Symfony\Contracts\Translation\TranslatableInterface;
|
||||
|
||||
use function Symfony\Component\Translation\t;
|
||||
|
||||
final class PartsTableActionHandler
|
||||
{
|
||||
|
|
@ -61,8 +65,9 @@ final class PartsTableActionHandler
|
|||
/**
|
||||
* @param Part[] $selected_parts
|
||||
* @return RedirectResponse|null Returns a redirect response if the user should be redirected to another page, otherwise null
|
||||
* //@param-out list<array{'part': Part, 'message': string|TranslatableInterface}>|array<void> $errors
|
||||
*/
|
||||
public function handleAction(string $action, array $selected_parts, ?int $target_id, ?string $redirect_url = null): ?RedirectResponse
|
||||
public function handleAction(string $action, array $selected_parts, ?int $target_id, ?string $redirect_url = null, array &$errors = []): ?RedirectResponse
|
||||
{
|
||||
if ($action === 'add_to_project') {
|
||||
return new RedirectResponse(
|
||||
|
|
@ -161,6 +166,29 @@ implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPart
|
|||
$this->denyAccessUnlessGranted('@measurement_units.read');
|
||||
$part->setPartUnit(null === $target_id ? null : $this->entityManager->find(MeasurementUnit::class, $target_id));
|
||||
break;
|
||||
case 'change_location':
|
||||
$this->denyAccessUnlessGranted('@storelocations.read');
|
||||
//Retrieve the first part lot and set the location for it
|
||||
$part_lots = $part->getPartLots();
|
||||
if ($part_lots->count() > 0) {
|
||||
if ($part_lots->count() > 1) {
|
||||
$errors[] = [
|
||||
'part' => $part,
|
||||
'message' => t('parts.table.action_handler.error.part_lots_multiple'),
|
||||
];
|
||||
break;
|
||||
}
|
||||
|
||||
$part_lot = $part_lots->first();
|
||||
$part_lot->setStorageLocation(null === $target_id ? null : $this->entityManager->find(StorageLocation::class, $target_id));
|
||||
} else { //Create a new part lot if there are none
|
||||
$part_lot = new PartLot();
|
||||
$part_lot->setPart($part);
|
||||
$part_lot->setInstockUnknown(true); //We do not know how many parts are in stock, so we set it to true
|
||||
$part_lot->setStorageLocation(null === $target_id ? null : $this->entityManager->find(StorageLocation::class, $target_id));
|
||||
$this->entityManager->persist($part_lot);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new InvalidArgumentException('The given action is unknown! ('.$action.')');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue