mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-03-01 21:09:35 +00:00
Merge branch 'master' into tweak-search
This commit is contained in:
commit
cc902a7a46
288 changed files with 35302 additions and 50165 deletions
141
src/Command/MaintenanceModeCommand.php
Normal file
141
src/Command/MaintenanceModeCommand.php
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Services\System\UpdateExecutor;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand('partdb:maintenance-mode', 'Enable/disable maintenance mode and set a message')]
|
||||
class MaintenanceModeCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UpdateExecutor $updateExecutor
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setDefinition([
|
||||
new InputOption('enable', null, InputOption::VALUE_NONE, 'Enable maintenance mode'),
|
||||
new InputOption('disable', null, InputOption::VALUE_NONE, 'Disable maintenance mode'),
|
||||
new InputOption('status', null, InputOption::VALUE_NONE, 'Show current maintenance mode status'),
|
||||
new InputOption('message', null, InputOption::VALUE_REQUIRED, 'Optional maintenance message (explicit option)'),
|
||||
new InputArgument('message_arg', InputArgument::OPTIONAL, 'Optional maintenance message as a positional argument (preferred when writing message directly)')
|
||||
]);
|
||||
}
|
||||
|
||||
public function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$enable = (bool)$input->getOption('enable');
|
||||
$disable = (bool)$input->getOption('disable');
|
||||
$status = (bool)$input->getOption('status');
|
||||
|
||||
// Accept message either via --message option or as positional argument
|
||||
$optionMessage = $input->getOption('message');
|
||||
$argumentMessage = $input->getArgument('message_arg');
|
||||
|
||||
// Prefer explicit --message option, otherwise use positional argument if provided
|
||||
$message = null;
|
||||
if (is_string($optionMessage) && $optionMessage !== '') {
|
||||
$message = $optionMessage;
|
||||
} elseif (is_string($argumentMessage) && $argumentMessage !== '') {
|
||||
$message = $argumentMessage;
|
||||
}
|
||||
|
||||
// If no action provided, show help
|
||||
if (!$enable && !$disable && !$status) {
|
||||
$io->text('Maintenance mode command. See usage below:');
|
||||
$this->printHelp($io);
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if ($enable && $disable) {
|
||||
$io->error('Conflicting options: specify either --enable or --disable, not both.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($status) {
|
||||
if ($this->updateExecutor->isMaintenanceMode()) {
|
||||
$info = $this->updateExecutor->getMaintenanceInfo();
|
||||
$reason = $info['reason'] ?? 'Unknown reason';
|
||||
$enabledAt = $info['enabled_at'] ?? 'Unknown time';
|
||||
|
||||
$io->success(sprintf('Maintenance mode is ENABLED (since %s).', $enabledAt));
|
||||
$io->text(sprintf('Reason: %s', $reason));
|
||||
} else {
|
||||
$io->success('Maintenance mode is DISABLED.');
|
||||
}
|
||||
|
||||
// If only status requested, exit
|
||||
if (!$enable && !$disable) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
if ($enable) {
|
||||
// Use provided message or fallback to a default English message
|
||||
$reason = is_string($message)
|
||||
? $message
|
||||
: 'The system is temporarily unavailable due to maintenance.';
|
||||
|
||||
$this->updateExecutor->enableMaintenanceMode($reason);
|
||||
|
||||
$io->success(sprintf('Maintenance mode enabled. Reason: %s', $reason));
|
||||
}
|
||||
|
||||
if ($disable) {
|
||||
$this->updateExecutor->disableMaintenanceMode();
|
||||
$io->success('Maintenance mode disabled.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
} catch (\Throwable $e) {
|
||||
$io->error(sprintf('Unexpected error: %s', $e->getMessage()));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function printHelp(SymfonyStyle $io): void
|
||||
{
|
||||
$io->writeln('');
|
||||
$io->writeln('Usage:');
|
||||
$io->writeln(' php bin/console partdb:maintenance_mode --enable [--message="Maintenance message"]');
|
||||
$io->writeln(' php bin/console partdb:maintenance_mode --enable "Maintenance message"');
|
||||
$io->writeln(' php bin/console partdb:maintenance_mode --disable');
|
||||
$io->writeln(' php bin/console partdb:maintenance_mode --status');
|
||||
$io->writeln('');
|
||||
}
|
||||
|
||||
}
|
||||
253
src/Command/Migrations/DBPlatformConvertCommand.php
Normal file
253
src/Command/Migrations/DBPlatformConvertCommand.php
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Command\Migrations;
|
||||
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Services\ImportExportSystem\PartKeeprImporter\PKImportHelper;
|
||||
use Doctrine\Bundle\DoctrineBundle\ConnectionFactory;
|
||||
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
|
||||
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
|
||||
use Doctrine\Migrations\Configuration\EntityManager\ExistingEntityManager;
|
||||
use Doctrine\Migrations\Configuration\Migration\ExistingConfiguration;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\Migrations\DependencyFactory;
|
||||
use Doctrine\ORM\Id\AssignedGenerator;
|
||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
#[AsCommand('partdb:migrations:convert-db-platform', 'Convert the database to a different platform')]
|
||||
class DBPlatformConvertCommand extends Command
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $targetEM,
|
||||
private readonly PKImportHelper $importHelper,
|
||||
private readonly DependencyFactory $dependencyFactory,
|
||||
#[Autowire('%kernel.project_dir%')]
|
||||
private readonly string $kernelProjectDir,
|
||||
)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function configure(): void
|
||||
{
|
||||
$this
|
||||
->setHelp('This command allows you to migrate the database from one database platform to another (e.g. from MySQL to PostgreSQL).')
|
||||
->addArgument('url', InputArgument::REQUIRED, 'The database connection URL of the source database to migrate from');
|
||||
}
|
||||
|
||||
public function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$sourceEM = $this->getSourceEm($input->getArgument('url'));
|
||||
|
||||
//Check that both databases are not using the same driver
|
||||
if ($sourceEM->getConnection()->getDatabasePlatform()::class === $this->targetEM->getConnection()->getDatabasePlatform()::class) {
|
||||
$io->warning('Source and target database are using the same database platform / driver. This command is only intended to migrate between different database platforms (e.g. from MySQL to PostgreSQL).');
|
||||
if (!$io->confirm('Do you want to continue anyway?', false)) {
|
||||
$io->info('Aborting migration process.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$this->ensureVersionUpToDate($sourceEM);
|
||||
|
||||
$io->note('This command is still in development. If you encounter any problems, please report them to the issue tracker on GitHub.');
|
||||
$io->warning(sprintf('This command will delete all existing data in the target database "%s". Make sure that you have no important data in the database before you continue!',
|
||||
$this->targetEM->getConnection()->getDatabase() ?? 'unknown'
|
||||
));
|
||||
|
||||
//$users = $sourceEM->getRepository(User::class)->findAll();
|
||||
//dump($users);
|
||||
|
||||
$io->ask('Please type "DELETE ALL DATA" to continue.', '', function ($answer) {
|
||||
if (strtoupper($answer) !== 'DELETE ALL DATA') {
|
||||
throw new \RuntimeException('You did not type "DELETE ALL DATA"!');
|
||||
}
|
||||
return $answer;
|
||||
});
|
||||
|
||||
|
||||
// Example migration logic (to be replaced with actual migration code)
|
||||
$io->info('Starting database migration...');
|
||||
|
||||
//Disable all event listeners on target EM to avoid unwanted side effects
|
||||
$eventManager = $this->targetEM->getEventManager();
|
||||
foreach ($eventManager->getAllListeners() as $event => $listeners) {
|
||||
foreach ($listeners as $listener) {
|
||||
$eventManager->removeEventListener($event, $listener);
|
||||
}
|
||||
}
|
||||
|
||||
$io->info('Clear target database...');
|
||||
$this->importHelper->purgeDatabaseForImport($this->targetEM, ['internal', 'migration_versions']);
|
||||
|
||||
$metadata = $this->targetEM->getMetadataFactory()->getAllMetadata();
|
||||
|
||||
$io->info('Modifying entity metadata for migration...');
|
||||
//First we modify each entity metadata to have an persist cascade on all relations
|
||||
foreach ($metadata as $metadatum) {
|
||||
$entityClass = $metadatum->getName();
|
||||
$io->writeln('Modifying cascade and ID settings for entity: ' . $entityClass, OutputInterface::VERBOSITY_VERBOSE);
|
||||
|
||||
foreach ($metadatum->getAssociationNames() as $fieldName) {
|
||||
$mapping = $metadatum->getAssociationMapping($fieldName);
|
||||
$mapping->cascade = array_unique(array_merge($mapping->cascade, ['persist']));
|
||||
$mapping->fetch = ClassMetadata::FETCH_EAGER; //Avoid lazy loading issues during migration
|
||||
|
||||
$metadatum->setIdGeneratorType(ClassMetadata::GENERATOR_TYPE_NONE);
|
||||
$metadatum->setIdGenerator(new AssignedGenerator());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$io->progressStart(count($metadata));
|
||||
|
||||
//First we migrate users to avoid foreign key constraint issues
|
||||
$io->info('Migrating users first to avoid foreign key constraint issues...');
|
||||
$this->fixUsers($sourceEM);
|
||||
|
||||
//Afterward we migrate all entities
|
||||
foreach ($metadata as $metadatum) {
|
||||
//skip all superclasses
|
||||
if ($metadatum->isMappedSuperclass) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entityClass = $metadatum->getName();
|
||||
|
||||
$io->note('Migrating entity: ' . $entityClass);
|
||||
|
||||
$repo = $sourceEM->getRepository($entityClass);
|
||||
$items = $repo->findAll();
|
||||
foreach ($items as $index => $item) {
|
||||
$this->targetEM->persist($item);
|
||||
}
|
||||
$this->targetEM->flush();
|
||||
}
|
||||
|
||||
$io->progressFinish();
|
||||
|
||||
|
||||
//Fix sequences / auto increment values on target database
|
||||
$io->info('Fixing sequences / auto increment values on target database...');
|
||||
$this->fixAutoIncrements($this->targetEM);
|
||||
|
||||
$io->success('Database migration completed successfully.');
|
||||
|
||||
if ($io->isVerbose()) {
|
||||
$io->info('Process took peak memory: ' . round(memory_get_peak_usage(true) / 1024 / 1024, 2) . ' MB');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a source EntityManager based on the given connection URL
|
||||
* @param string $url
|
||||
* @return EntityManagerInterface
|
||||
*/
|
||||
private function getSourceEm(string $url): EntityManagerInterface
|
||||
{
|
||||
//Replace any %kernel.project_dir% placeholders
|
||||
$url = str_replace('%kernel.project_dir%', $this->kernelProjectDir, $url);
|
||||
|
||||
$connectionFactory = new ConnectionFactory();
|
||||
$connection = $connectionFactory->createConnection(['url' => $url]);
|
||||
return new EntityManager($connection, $this->targetEM->getConfiguration());
|
||||
}
|
||||
|
||||
private function ensureVersionUpToDate(EntityManagerInterface $sourceEM): void
|
||||
{
|
||||
//Ensure that target database is up to date
|
||||
$migrationStatusCalculator = $this->dependencyFactory->getMigrationStatusCalculator();
|
||||
$newMigrations = $migrationStatusCalculator->getNewMigrations();
|
||||
if (count($newMigrations->getItems()) > 0) {
|
||||
throw new \RuntimeException("Target database is not up to date. Please run all migrations (with doctrine:migrations:migrate) before starting the migration process.");
|
||||
}
|
||||
|
||||
$sourceDependencyLoader = DependencyFactory::fromEntityManager(new ExistingConfiguration($this->dependencyFactory->getConfiguration()), new ExistingEntityManager($sourceEM));
|
||||
$sourceMigrationStatusCalculator = $sourceDependencyLoader->getMigrationStatusCalculator();
|
||||
$sourceNewMigrations = $sourceMigrationStatusCalculator->getNewMigrations();
|
||||
if (count($sourceNewMigrations->getItems()) > 0) {
|
||||
throw new \RuntimeException("Source database is not up to date. Please run all migrations (with doctrine:migrations:migrate) on the source database before starting the migration process.");
|
||||
}
|
||||
}
|
||||
|
||||
private function fixUsers(EntityManagerInterface $sourceEM): void
|
||||
{
|
||||
//To avoid a problem with (Column 'settings' cannot be null) in MySQL we need to migrate the user entities first
|
||||
//and fix the settings and backupCodes fields
|
||||
|
||||
$reflClass = new \ReflectionClass(User::class);
|
||||
foreach ($sourceEM->getRepository(User::class)->findAll() as $user) {
|
||||
foreach (['settings', 'backupCodes'] as $field) {
|
||||
$property = $reflClass->getProperty($field);
|
||||
if (!$property->isInitialized($user) || $property->getValue($user) === null) {
|
||||
$property->setValue($user, []);
|
||||
}
|
||||
}
|
||||
$this->targetEM->persist($user);
|
||||
}
|
||||
}
|
||||
|
||||
private function fixAutoIncrements(EntityManagerInterface $em): void
|
||||
{
|
||||
$connection = $em->getConnection();
|
||||
$platform = $connection->getDatabasePlatform();
|
||||
|
||||
if ($platform instanceof PostgreSQLPlatform) {
|
||||
$connection->executeStatement(
|
||||
//From: https://wiki.postgresql.org/wiki/Fixing_Sequences
|
||||
<<<SQL
|
||||
SELECT 'SELECT SETVAL(' ||
|
||||
quote_literal(quote_ident(PGT.schemaname) || '.' || quote_ident(S.relname)) ||
|
||||
', COALESCE(MAX(' ||quote_ident(C.attname)|| '), 1) ) FROM ' ||
|
||||
quote_ident(PGT.schemaname)|| '.'||quote_ident(T.relname)|| ';'
|
||||
FROM pg_class AS S,
|
||||
pg_depend AS D,
|
||||
pg_class AS T,
|
||||
pg_attribute AS C,
|
||||
pg_tables AS PGT
|
||||
WHERE S.relkind = 'S'
|
||||
AND S.oid = D.objid
|
||||
AND D.refobjid = T.oid
|
||||
AND D.refobjid = C.attrelid
|
||||
AND D.refobjsubid = C.attnum
|
||||
AND T.relname = PGT.tablename
|
||||
ORDER BY S.relname;
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
}
|
||||
445
src/Command/UpdateCommand.php
Normal file
445
src/Command/UpdateCommand.php
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Services\System\UpdateChecker;
|
||||
use App\Services\System\UpdateExecutor;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'partdb:update', description: 'Check for and install Part-DB updates', aliases: ['app:update'])]
|
||||
class UpdateCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly UpdateChecker $updateChecker,
|
||||
private readonly UpdateExecutor $updateExecutor)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setHelp(<<<'HELP'
|
||||
The <info>%command.name%</info> command checks for Part-DB updates and can install them.
|
||||
|
||||
<comment>Check for updates:</comment>
|
||||
<info>php %command.full_name% --check</info>
|
||||
|
||||
<comment>List available versions:</comment>
|
||||
<info>php %command.full_name% --list</info>
|
||||
|
||||
<comment>Update to the latest version:</comment>
|
||||
<info>php %command.full_name%</info>
|
||||
|
||||
<comment>Update to a specific version:</comment>
|
||||
<info>php %command.full_name% v2.6.0</info>
|
||||
|
||||
<comment>Update without creating a backup (faster but riskier):</comment>
|
||||
<info>php %command.full_name% --no-backup</info>
|
||||
|
||||
<comment>Non-interactive update for scripts:</comment>
|
||||
<info>php %command.full_name% --force</info>
|
||||
|
||||
<comment>View update logs:</comment>
|
||||
<info>php %command.full_name% --logs</info>
|
||||
HELP
|
||||
)
|
||||
->addArgument(
|
||||
'version',
|
||||
InputArgument::OPTIONAL,
|
||||
'Target version to update to (e.g., v2.6.0). If not specified, updates to the latest stable version.'
|
||||
)
|
||||
->addOption(
|
||||
'check',
|
||||
'c',
|
||||
InputOption::VALUE_NONE,
|
||||
'Only check for updates without installing'
|
||||
)
|
||||
->addOption(
|
||||
'list',
|
||||
'l',
|
||||
InputOption::VALUE_NONE,
|
||||
'List all available versions'
|
||||
)
|
||||
->addOption(
|
||||
'no-backup',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Skip creating a backup before updating (not recommended)'
|
||||
)
|
||||
->addOption(
|
||||
'force',
|
||||
'f',
|
||||
InputOption::VALUE_NONE,
|
||||
'Skip confirmation prompts'
|
||||
)
|
||||
->addOption(
|
||||
'include-prerelease',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Include pre-release versions'
|
||||
)
|
||||
->addOption(
|
||||
'logs',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Show recent update logs'
|
||||
)
|
||||
->addOption(
|
||||
'refresh',
|
||||
'r',
|
||||
InputOption::VALUE_NONE,
|
||||
'Force refresh of cached version information'
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
// Handle --logs option
|
||||
if ($input->getOption('logs')) {
|
||||
return $this->showLogs($io);
|
||||
}
|
||||
|
||||
// Handle --refresh option
|
||||
if ($input->getOption('refresh')) {
|
||||
$io->text('Refreshing version information...');
|
||||
$this->updateChecker->refreshVersionInfo();
|
||||
$io->success('Version cache cleared.');
|
||||
}
|
||||
|
||||
// Handle --list option
|
||||
if ($input->getOption('list')) {
|
||||
return $this->listVersions($io, $input->getOption('include-prerelease'));
|
||||
}
|
||||
|
||||
// Get update status
|
||||
$status = $this->updateChecker->getUpdateStatus();
|
||||
|
||||
// Display current status
|
||||
$io->title('Part-DB Update Manager');
|
||||
|
||||
$this->displayStatus($io, $status);
|
||||
|
||||
// Handle --check option
|
||||
if ($input->getOption('check')) {
|
||||
return $this->checkOnly($io, $status);
|
||||
}
|
||||
|
||||
// Validate we can update
|
||||
$validationResult = $this->validateUpdate($io, $status);
|
||||
if ($validationResult !== null) {
|
||||
return $validationResult;
|
||||
}
|
||||
|
||||
// Determine target version
|
||||
$targetVersion = $input->getArgument('version');
|
||||
$includePrerelease = $input->getOption('include-prerelease');
|
||||
|
||||
if (!$targetVersion) {
|
||||
$latest = $this->updateChecker->getLatestVersion($includePrerelease);
|
||||
if (!$latest) {
|
||||
$io->error('Could not determine the latest version. Please specify a version manually.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$targetVersion = $latest['tag'];
|
||||
}
|
||||
|
||||
// Validate target version
|
||||
if (!$this->updateChecker->isNewerVersionThanCurrent($targetVersion)) {
|
||||
$io->warning(sprintf(
|
||||
'Version %s is not newer than the current version %s.',
|
||||
$targetVersion,
|
||||
$status['current_version']
|
||||
));
|
||||
|
||||
if (!$input->getOption('force')) {
|
||||
if (!$io->confirm('Do you want to proceed anyway?', false)) {
|
||||
$io->info('Update cancelled.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm update
|
||||
if (!$input->getOption('force')) {
|
||||
$io->section('Update Plan');
|
||||
|
||||
$io->listing([
|
||||
sprintf('Target version: <info>%s</info>', $targetVersion),
|
||||
$input->getOption('no-backup')
|
||||
? '<fg=yellow>Backup will be SKIPPED</>'
|
||||
: 'A full backup will be created before updating',
|
||||
'Maintenance mode will be enabled during update',
|
||||
'Database migrations will be run automatically',
|
||||
'Cache will be cleared and rebuilt',
|
||||
]);
|
||||
|
||||
$io->warning('The update process may take several minutes. Do not interrupt it.');
|
||||
|
||||
if (!$io->confirm('Do you want to proceed with the update?', false)) {
|
||||
$io->info('Update cancelled.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute update
|
||||
return $this->executeUpdate($io, $targetVersion, !$input->getOption('no-backup'));
|
||||
}
|
||||
|
||||
private function displayStatus(SymfonyStyle $io, array $status): void
|
||||
{
|
||||
$io->definitionList(
|
||||
['Current Version' => sprintf('<info>%s</info>', $status['current_version'])],
|
||||
['Latest Version' => $status['latest_version']
|
||||
? sprintf('<info>%s</info>', $status['latest_version'])
|
||||
: '<fg=yellow>Unknown</>'],
|
||||
['Installation Type' => $status['installation']['type_name']],
|
||||
['Git Branch' => $status['git']['branch'] ?? '<fg=gray>N/A</>'],
|
||||
['Git Commit' => $status['git']['commit'] ?? '<fg=gray>N/A</>'],
|
||||
['Local Changes' => $status['git']['has_local_changes']
|
||||
? '<fg=yellow>Yes (update blocked)</>'
|
||||
: '<fg=green>No</>'],
|
||||
['Commits Behind' => $status['git']['commits_behind'] > 0
|
||||
? sprintf('<fg=yellow>%d</>', $status['git']['commits_behind'])
|
||||
: '<fg=green>0</>'],
|
||||
['Update Available' => $status['update_available']
|
||||
? '<fg=green>Yes</>'
|
||||
: 'No'],
|
||||
['Can Auto-Update' => $status['can_auto_update']
|
||||
? '<fg=green>Yes</>'
|
||||
: '<fg=yellow>No</>'],
|
||||
);
|
||||
|
||||
if (!empty($status['update_blockers'])) {
|
||||
$io->warning('Update blockers: ' . implode(', ', $status['update_blockers']));
|
||||
}
|
||||
}
|
||||
|
||||
private function checkOnly(SymfonyStyle $io, array $status): int
|
||||
{
|
||||
if (!$status['check_enabled']) {
|
||||
$io->warning('Update checking is disabled in privacy settings.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if ($status['update_available']) {
|
||||
$io->success(sprintf(
|
||||
'A new version is available: %s (current: %s)',
|
||||
$status['latest_version'],
|
||||
$status['current_version']
|
||||
));
|
||||
|
||||
if ($status['release_url']) {
|
||||
$io->text(sprintf('Release notes: <href=%s>%s</>', $status['release_url'], $status['release_url']));
|
||||
}
|
||||
|
||||
if ($status['can_auto_update']) {
|
||||
$io->text('');
|
||||
$io->text('Run <info>php bin/console partdb:update</info> to update.');
|
||||
} else {
|
||||
$io->text('');
|
||||
$io->text($status['installation']['update_instructions']);
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->success('You are running the latest version.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function validateUpdate(SymfonyStyle $io, array $status): ?int
|
||||
{
|
||||
// Check if update checking is enabled
|
||||
if (!$status['check_enabled']) {
|
||||
$io->error('Update checking is disabled in privacy settings. Enable it to use automatic updates.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Check installation type
|
||||
if (!$status['can_auto_update']) {
|
||||
$io->error('Automatic updates are not supported for this installation type.');
|
||||
$io->text($status['installation']['update_instructions']);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Validate preconditions
|
||||
$validation = $this->updateExecutor->validateUpdatePreconditions();
|
||||
if (!$validation['valid']) {
|
||||
$io->error('Cannot proceed with update:');
|
||||
$io->listing($validation['errors']);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function executeUpdate(SymfonyStyle $io, string $targetVersion, bool $createBackup): int
|
||||
{
|
||||
$io->section('Executing Update');
|
||||
$io->text(sprintf('Updating to version: <info>%s</info>', $targetVersion));
|
||||
$io->text('');
|
||||
|
||||
$progressCallback = function (array $step) use ($io): void {
|
||||
$icon = $step['success'] ? '<fg=green>✓</>' : '<fg=red>✗</>';
|
||||
$duration = $step['duration'] ? sprintf(' <fg=gray>(%.1fs)</>', $step['duration']) : '';
|
||||
$io->text(sprintf(' %s <info>%s</info>: %s%s', $icon, $step['step'], $step['message'], $duration));
|
||||
};
|
||||
|
||||
// Use executeUpdateWithProgress to update the progress file for web UI
|
||||
$result = $this->updateExecutor->executeUpdateWithProgress($targetVersion, $createBackup, $progressCallback);
|
||||
|
||||
$io->text('');
|
||||
|
||||
if ($result['success']) {
|
||||
$io->success(sprintf(
|
||||
'Successfully updated to %s in %.1f seconds!',
|
||||
$targetVersion,
|
||||
$result['duration']
|
||||
));
|
||||
|
||||
$io->text([
|
||||
sprintf('Rollback tag: <info>%s</info>', $result['rollback_tag']),
|
||||
sprintf('Log file: <info>%s</info>', $result['log_file']),
|
||||
]);
|
||||
|
||||
$io->note('If you encounter any issues, you can rollback using: git checkout ' . $result['rollback_tag']);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->error('Update failed: ' . $result['error']);
|
||||
|
||||
if ($result['rollback_tag']) {
|
||||
$io->warning(sprintf('System was rolled back to: %s', $result['rollback_tag']));
|
||||
}
|
||||
|
||||
if ($result['log_file']) {
|
||||
$io->text(sprintf('See log file for details: %s', $result['log_file']));
|
||||
}
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
private function listVersions(SymfonyStyle $io, bool $includePrerelease): int
|
||||
{
|
||||
$releases = $this->updateChecker->getAvailableReleases(15);
|
||||
$currentVersion = $this->updateChecker->getCurrentVersionString();
|
||||
|
||||
if (empty($releases)) {
|
||||
$io->warning('Could not fetch available versions. Check your internet connection.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->title('Available Part-DB Versions');
|
||||
|
||||
$table = new Table($io);
|
||||
$table->setHeaders(['Tag', 'Version', 'Released', 'Status']);
|
||||
|
||||
foreach ($releases as $release) {
|
||||
if (!$includePrerelease && $release['prerelease']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$version = $release['version'];
|
||||
$status = [];
|
||||
|
||||
if (version_compare($version, $currentVersion, '=')) {
|
||||
$status[] = '<fg=cyan>current</>';
|
||||
} elseif (version_compare($version, $currentVersion, '>')) {
|
||||
$status[] = '<fg=green>newer</>';
|
||||
}
|
||||
|
||||
if ($release['prerelease']) {
|
||||
$status[] = '<fg=yellow>pre-release</>';
|
||||
}
|
||||
|
||||
$table->addRow([
|
||||
$release['tag'],
|
||||
$version,
|
||||
(new \DateTime($release['published_at']))->format('Y-m-d'),
|
||||
implode(' ', $status) ?: '-',
|
||||
]);
|
||||
}
|
||||
|
||||
$table->render();
|
||||
|
||||
$io->text('');
|
||||
$io->text('Use <info>php bin/console partdb:update [tag]</info> to update to a specific version.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function showLogs(SymfonyStyle $io): int
|
||||
{
|
||||
$logs = $this->updateExecutor->getUpdateLogs();
|
||||
|
||||
if (empty($logs)) {
|
||||
$io->info('No update logs found.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->title('Recent Update Logs');
|
||||
|
||||
$table = new Table($io);
|
||||
$table->setHeaders(['Date', 'File', 'Size']);
|
||||
|
||||
foreach (array_slice($logs, 0, 10) as $log) {
|
||||
$table->addRow([
|
||||
date('Y-m-d H:i:s', $log['date']),
|
||||
$log['file'],
|
||||
$this->formatBytes($log['size']),
|
||||
]);
|
||||
}
|
||||
|
||||
$table->render();
|
||||
|
||||
$io->text('');
|
||||
$io->text('Log files are stored in: <info>var/log/updates/</info>');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$unitIndex = 0;
|
||||
|
||||
while ($bytes >= 1024 && $unitIndex < count($units) - 1) {
|
||||
$bytes /= 1024;
|
||||
$unitIndex++;
|
||||
}
|
||||
|
||||
return sprintf('%.1f %s', $bytes, $units[$unitIndex]);
|
||||
}
|
||||
}
|
||||
|
|
@ -22,9 +22,9 @@ declare(strict_types=1);
|
|||
*/
|
||||
namespace App\Command;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use App\Services\Misc\GitVersionInfo;
|
||||
use App\Services\System\GitVersionInfoProvider;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
|
@ -33,7 +33,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
|||
#[AsCommand('partdb:version|app:version', 'Shows the currently installed version of Part-DB.')]
|
||||
class VersionCommand extends Command
|
||||
{
|
||||
public function __construct(protected VersionManagerInterface $versionManager, protected GitVersionInfo $gitVersionInfo)
|
||||
public function __construct(protected VersionManagerInterface $versionManager, protected GitVersionInfoProvider $gitVersionInfo)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
|
@ -48,9 +48,9 @@ class VersionCommand extends Command
|
|||
|
||||
$message = 'Part-DB version: '. $this->versionManager->getVersion()->toString();
|
||||
|
||||
if ($this->gitVersionInfo->getGitBranchName() !== null) {
|
||||
$message .= ' Git branch: '. $this->gitVersionInfo->getGitBranchName();
|
||||
$message .= ', Git commit: '. $this->gitVersionInfo->getGitCommitHash();
|
||||
if ($this->gitVersionInfo->getBranchName() !== null) {
|
||||
$message .= ' Git branch: '. $this->gitVersionInfo->getBranchName();
|
||||
$message .= ', Git commit: '. $this->gitVersionInfo->getCommitHash();
|
||||
}
|
||||
|
||||
$io->success($message);
|
||||
|
|
|
|||
|
|
@ -366,6 +366,14 @@ abstract class BaseAdminController extends AbstractController
|
|||
}
|
||||
}
|
||||
|
||||
//Count how many actual new entities were created (id is null until persisted)
|
||||
$created_count = 0;
|
||||
foreach ($results as $result) {
|
||||
if (null === $result->getID()) {
|
||||
$created_count++;
|
||||
}
|
||||
}
|
||||
|
||||
//Persist valid entities to DB
|
||||
foreach ($results as $result) {
|
||||
$em->persist($result);
|
||||
|
|
@ -373,8 +381,14 @@ abstract class BaseAdminController extends AbstractController
|
|||
$em->flush();
|
||||
|
||||
if (count($results) > 0) {
|
||||
$this->addFlash('success', t('entity.mass_creation_flash', ['%COUNT%' => count($results)]));
|
||||
$this->addFlash('success', t('entity.mass_creation_flash', ['%COUNT%' => $created_count]));
|
||||
}
|
||||
|
||||
if (count($errors)) {
|
||||
//Recreate mass creation form, so we get the updated parent list and empty lines
|
||||
$mass_creation_form = $this->createForm(MassCreationForm::class, ['entity_class' => $this->entity_class]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return $this->render($this->twig_template, [
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@ namespace App\Controller;
|
|||
|
||||
use App\DataTables\LogDataTable;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Services\Misc\GitVersionInfo;
|
||||
use App\Services\System\BannerHelper;
|
||||
use App\Services\System\UpdateAvailableManager;
|
||||
use App\Services\System\GitVersionInfoProvider;
|
||||
use App\Services\System\UpdateAvailableFacade;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Omines\DataTablesBundle\DataTableFactory;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
|
|
@ -43,8 +43,8 @@ class HomepageController extends AbstractController
|
|||
|
||||
|
||||
#[Route(path: '/', name: 'homepage')]
|
||||
public function homepage(Request $request, GitVersionInfo $versionInfo, EntityManagerInterface $entityManager,
|
||||
UpdateAvailableManager $updateAvailableManager): Response
|
||||
public function homepage(Request $request, GitVersionInfoProvider $versionInfo, EntityManagerInterface $entityManager,
|
||||
UpdateAvailableFacade $updateAvailableManager): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('HAS_ACCESS_PERMISSIONS');
|
||||
|
||||
|
|
@ -77,8 +77,8 @@ class HomepageController extends AbstractController
|
|||
|
||||
return $this->render('homepage.html.twig', [
|
||||
'banner' => $this->bannerHelper->getBanner(),
|
||||
'git_branch' => $versionInfo->getGitBranchName(),
|
||||
'git_commit' => $versionInfo->getGitCommitHash(),
|
||||
'git_branch' => $versionInfo->getBranchName(),
|
||||
'git_commit' => $versionInfo->getCommitHash(),
|
||||
'show_first_steps' => $show_first_steps,
|
||||
'datatable' => $table,
|
||||
'new_version_available' => $updateAvailableManager->isUpdateAvailable(),
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ use App\Form\InfoProviderSystem\PartSearchType;
|
|||
use App\Services\InfoProviderSystem\ExistingPartFinder;
|
||||
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||
use App\Services\InfoProviderSystem\Providers\GenericWebProvider;
|
||||
use App\Settings\AppSettings;
|
||||
use App\Settings\InfoProviderSystem\InfoProviderGeneralSettings;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
|
@ -39,11 +40,15 @@ use Psr\Log\LoggerInterface;
|
|||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\UrlType;
|
||||
use Symfony\Component\HttpClient\Exception\ClientException;
|
||||
use Symfony\Component\HttpClient\Exception\TransportException;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
|
||||
|
||||
use function Symfony\Component\Translation\t;
|
||||
|
||||
#[Route('/tools/info_providers')]
|
||||
|
|
@ -178,6 +183,13 @@ class InfoProviderController extends AbstractController
|
|||
$exceptionLogger->error('Error during info provider search: ' . $e->getMessage(), ['exception' => $e]);
|
||||
} catch (OAuthReconnectRequiredException $e) {
|
||||
$this->addFlash('error', t('info_providers.search.error.oauth_reconnect', ['%provider%' => $e->getProviderName()]));
|
||||
} catch (TransportException $e) {
|
||||
$this->addFlash('error', t('info_providers.search.error.transport_exception'));
|
||||
$exceptionLogger->error('Transport error during info provider search: ' . $e->getMessage(), ['exception' => $e]);
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->addFlash('error', t('info_providers.search.error.general_exception', ['%type%' => (new \ReflectionClass($e))->getShortName()]));
|
||||
//Log the exception
|
||||
$exceptionLogger->error('Error during info provider search: ' . $e->getMessage(), ['exception' => $e]);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -198,4 +210,58 @@ class InfoProviderController extends AbstractController
|
|||
'update_target' => $update_target
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/from_url', name: 'info_providers_from_url')]
|
||||
public function fromURL(Request $request, GenericWebProvider $provider): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
if (!$provider->isActive()) {
|
||||
$this->addFlash('error', "Generic Web Provider is not active. Please enable it in the provider settings.");
|
||||
return $this->redirectToRoute('info_providers_list');
|
||||
}
|
||||
|
||||
$formBuilder = $this->createFormBuilder();
|
||||
$formBuilder->add('url', UrlType::class, [
|
||||
'label' => 'info_providers.from_url.url.label',
|
||||
'required' => true,
|
||||
]);
|
||||
$formBuilder->add('submit', SubmitType::class, [
|
||||
'label' => 'info_providers.search.submit',
|
||||
]);
|
||||
|
||||
$form = $formBuilder->getForm();
|
||||
$form->handleRequest($request);
|
||||
|
||||
$partDetail = null;
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
//Try to retrieve the part detail from the given URL
|
||||
$url = $form->get('url')->getData();
|
||||
try {
|
||||
$searchResult = $this->infoRetriever->searchByKeyword(
|
||||
keyword: $url,
|
||||
providers: [$provider]
|
||||
);
|
||||
|
||||
if (count($searchResult) === 0) {
|
||||
$this->addFlash('warning', t('info_providers.from_url.no_part_found'));
|
||||
} else {
|
||||
$searchResult = $searchResult[0];
|
||||
//Redirect to the part creation page with the found part detail
|
||||
return $this->redirectToRoute('info_providers_create_part', [
|
||||
'providerKey' => $searchResult->provider_key,
|
||||
'providerId' => $searchResult->provider_id,
|
||||
]);
|
||||
}
|
||||
} catch (ExceptionInterface $e) {
|
||||
$this->addFlash('error', t('info_providers.search.error.general_exception', ['%type%' => (new \ReflectionClass($e))->getShortName()]));
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('info_providers/from_url/from_url.html.twig', [
|
||||
'form' => $form,
|
||||
'partDetail' => $partDetail,
|
||||
]);
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ use App\Services\Parts\PartLotWithdrawAddHelper;
|
|||
use App\Services\Parts\PricedetailHelper;
|
||||
use App\Services\ProjectSystem\ProjectBuildPartHelper;
|
||||
use App\Settings\BehaviorSettings\PartInfoSettings;
|
||||
use App\Settings\MiscSettings\IpnSuggestSettings;
|
||||
use DateTime;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Exception;
|
||||
|
|
@ -74,6 +75,7 @@ final class PartController extends AbstractController
|
|||
private readonly EntityManagerInterface $em,
|
||||
private readonly EventCommentHelper $commentHelper,
|
||||
private readonly PartInfoSettings $partInfoSettings,
|
||||
private readonly IpnSuggestSettings $ipnSuggestSettings,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -133,6 +135,7 @@ final class PartController extends AbstractController
|
|||
'description_params' => $this->partInfoSettings->extractParamsFromDescription ? $parameterExtractor->extractParameters($part->getDescription()) : [],
|
||||
'comment_params' => $this->partInfoSettings->extractParamsFromNotes ? $parameterExtractor->extractParameters($part->getComment()) : [],
|
||||
'withdraw_add_helper' => $withdrawAddHelper,
|
||||
'highlightLotId' => $request->query->getInt('highlightLot', 0),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
@ -444,10 +447,13 @@ final class PartController extends AbstractController
|
|||
$template = 'parts/edit/update_from_ip.html.twig';
|
||||
}
|
||||
|
||||
$partRepository = $this->em->getRepository(Part::class);
|
||||
|
||||
return $this->render(
|
||||
$template,
|
||||
[
|
||||
'part' => $new_part,
|
||||
'ipnSuggestions' => $partRepository->autoCompleteIpn($data, $data->getDescription(), $this->ipnSuggestSettings->suggestPartDigits),
|
||||
'form' => $form,
|
||||
'merge_old_name' => $merge_infos['tname_before'] ?? null,
|
||||
'merge_other' => $merge_infos['other_part'] ?? null,
|
||||
|
|
@ -457,7 +463,6 @@ final class PartController extends AbstractController
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
#[Route(path: '/{id}/add_withdraw', name: 'part_add_withdraw', methods: ['POST'])]
|
||||
public function withdrawAddHandler(Part $part, Request $request, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper): Response
|
||||
{
|
||||
|
|
|
|||
|
|
@ -319,6 +319,7 @@ class PartListsController extends AbstractController
|
|||
|
||||
//As an unchecked checkbox is not set in the query, the default value for all bools have to be false (which is the default argument value)!
|
||||
$filter->setName($request->query->getBoolean('name'));
|
||||
$filter->setDbId($request->query->getBoolean('dbid'));
|
||||
$filter->setCategory($request->query->getBoolean('category'));
|
||||
$filter->setDescription($request->query->getBoolean('description'));
|
||||
$filter->setMpn($request->query->getBoolean('mpn'));
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ class SettingsController extends AbstractController
|
|||
$this->settingsManager->save($settings);
|
||||
|
||||
//It might be possible, that the tree settings have changed, so clear the cache
|
||||
$cache->invalidateTags(['tree_treeview', 'sidebar_tree_update']);
|
||||
$cache->invalidateTags(['tree_tools', 'tree_treeview', 'sidebar_tree_update', 'synonyms']);
|
||||
|
||||
$this->addFlash('success', t('settings.flash.saved'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ use App\Services\Attachments\AttachmentURLGenerator;
|
|||
use App\Services\Attachments\BuiltinAttachmentsFinder;
|
||||
use App\Services\Doctrine\DBInfoHelper;
|
||||
use App\Services\Doctrine\NatsortDebugHelper;
|
||||
use App\Services\Misc\GitVersionInfo;
|
||||
use App\Services\System\UpdateAvailableManager;
|
||||
use App\Services\System\GitVersionInfoProvider;
|
||||
use App\Services\System\UpdateAvailableFacade;
|
||||
use App\Settings\AppSettings;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
|
@ -47,16 +47,16 @@ class ToolsController extends AbstractController
|
|||
}
|
||||
|
||||
#[Route(path: '/server_infos', name: 'tools_server_infos')]
|
||||
public function systemInfos(GitVersionInfo $versionInfo, DBInfoHelper $DBInfoHelper, NatsortDebugHelper $natsortDebugHelper,
|
||||
AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableManager $updateAvailableManager,
|
||||
public function systemInfos(GitVersionInfoProvider $versionInfo, DBInfoHelper $DBInfoHelper, NatsortDebugHelper $natsortDebugHelper,
|
||||
AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableFacade $updateAvailableManager,
|
||||
AppSettings $settings): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.server_infos');
|
||||
|
||||
return $this->render('tools/server_infos/server_infos.html.twig', [
|
||||
//Part-DB section
|
||||
'git_branch' => $versionInfo->getGitBranchName(),
|
||||
'git_commit' => $versionInfo->getGitCommitHash(),
|
||||
'git_branch' => $versionInfo->getBranchName(),
|
||||
'git_commit' => $versionInfo->getCommitHash(),
|
||||
'default_locale' => $settings->system->localization->locale,
|
||||
'default_timezone' => $settings->system->localization->timezone,
|
||||
'default_currency' => $settings->system->localization->baseCurrency,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ declare(strict_types=1);
|
|||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Parameters\AbstractParameter;
|
||||
use App\Settings\MiscSettings\IpnSuggestSettings;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Parts\Category;
|
||||
|
|
@ -60,8 +61,11 @@ use Symfony\Component\Serializer\Serializer;
|
|||
#[Route(path: '/typeahead')]
|
||||
class TypeaheadController extends AbstractController
|
||||
{
|
||||
public function __construct(protected AttachmentURLGenerator $urlGenerator, protected Packages $assets)
|
||||
{
|
||||
public function __construct(
|
||||
protected AttachmentURLGenerator $urlGenerator,
|
||||
protected Packages $assets,
|
||||
protected IpnSuggestSettings $ipnSuggestSettings,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(path: '/builtInResources/search', name: 'typeahead_builtInRessources')]
|
||||
|
|
@ -183,4 +187,30 @@ class TypeaheadController extends AbstractController
|
|||
|
||||
return new JsonResponse($data, Response::HTTP_OK, [], true);
|
||||
}
|
||||
|
||||
#[Route(path: '/parts/ipn-suggestions', name: 'ipn_suggestions', methods: ['GET'])]
|
||||
public function ipnSuggestions(
|
||||
Request $request,
|
||||
EntityManagerInterface $entityManager
|
||||
): JsonResponse {
|
||||
$partId = $request->query->get('partId');
|
||||
if ($partId === '0' || $partId === 'undefined' || $partId === 'null') {
|
||||
$partId = null;
|
||||
}
|
||||
$categoryId = $request->query->getInt('categoryId');
|
||||
$description = base64_decode($request->query->getString('description'), true);
|
||||
|
||||
/** @var Part $part */
|
||||
$part = $partId !== null ? $entityManager->getRepository(Part::class)->find($partId) : new Part();
|
||||
/** @var Category|null $category */
|
||||
$category = $entityManager->getRepository(Category::class)->find($categoryId);
|
||||
|
||||
$clonedPart = clone $part;
|
||||
$clonedPart->setCategory($category);
|
||||
|
||||
$partRepository = $entityManager->getRepository(Part::class);
|
||||
$ipnSuggestions = $partRepository->autoCompleteIpn($clonedPart, $description, $this->ipnSuggestSettings->suggestPartDigits);
|
||||
|
||||
return new JsonResponse($ipnSuggestions);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
371
src/Controller/UpdateManagerController.php
Normal file
371
src/Controller/UpdateManagerController.php
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Services\System\BackupManager;
|
||||
use App\Services\System\UpdateChecker;
|
||||
use App\Services\System\UpdateExecutor;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Controller for the Update Manager web interface.
|
||||
*
|
||||
* This provides a read-only view of update status and instructions.
|
||||
* Actual updates should be performed via the CLI command for safety.
|
||||
*/
|
||||
#[Route('/system/update-manager')]
|
||||
class UpdateManagerController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UpdateChecker $updateChecker,
|
||||
private readonly UpdateExecutor $updateExecutor,
|
||||
private readonly VersionManagerInterface $versionManager,
|
||||
private readonly BackupManager $backupManager,
|
||||
#[Autowire(env: 'bool:DISABLE_WEB_UPDATES')]
|
||||
private readonly bool $webUpdatesDisabled = false,
|
||||
#[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')]
|
||||
private readonly bool $backupRestoreDisabled = false,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if web updates are disabled and throw exception if so.
|
||||
*/
|
||||
private function denyIfWebUpdatesDisabled(): void
|
||||
{
|
||||
if ($this->webUpdatesDisabled) {
|
||||
throw new AccessDeniedHttpException('Web-based updates are disabled by server configuration. Please use the CLI command instead.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if backup restore is disabled and throw exception if so.
|
||||
*/
|
||||
private function denyIfBackupRestoreDisabled(): void
|
||||
{
|
||||
if ($this->backupRestoreDisabled) {
|
||||
throw new AccessDeniedHttpException('Backup restore is disabled by server configuration.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main update manager page.
|
||||
*/
|
||||
#[Route('', name: 'admin_update_manager', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.show_updates');
|
||||
|
||||
$status = $this->updateChecker->getUpdateStatus();
|
||||
$availableUpdates = $this->updateChecker->getAvailableUpdates();
|
||||
$validation = $this->updateExecutor->validateUpdatePreconditions();
|
||||
|
||||
return $this->render('admin/update_manager/index.html.twig', [
|
||||
'status' => $status,
|
||||
'available_updates' => $availableUpdates,
|
||||
'all_releases' => $this->updateChecker->getAvailableReleases(10),
|
||||
'validation' => $validation,
|
||||
'is_locked' => $this->updateExecutor->isLocked(),
|
||||
'lock_info' => $this->updateExecutor->getLockInfo(),
|
||||
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
|
||||
'maintenance_info' => $this->updateExecutor->getMaintenanceInfo(),
|
||||
'update_logs' => $this->updateExecutor->getUpdateLogs(),
|
||||
'backups' => $this->backupManager->getBackups(),
|
||||
'web_updates_disabled' => $this->webUpdatesDisabled,
|
||||
'backup_restore_disabled' => $this->backupRestoreDisabled,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX endpoint to check update status.
|
||||
*/
|
||||
#[Route('/status', name: 'admin_update_manager_status', methods: ['GET'])]
|
||||
public function status(): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.show_updates');
|
||||
|
||||
return $this->json([
|
||||
'status' => $this->updateChecker->getUpdateStatus(),
|
||||
'is_locked' => $this->updateExecutor->isLocked(),
|
||||
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
|
||||
'lock_info' => $this->updateExecutor->getLockInfo(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX endpoint to refresh version information.
|
||||
*/
|
||||
#[Route('/refresh', name: 'admin_update_manager_refresh', methods: ['POST'])]
|
||||
public function refresh(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.show_updates');
|
||||
|
||||
// Validate CSRF token
|
||||
if (!$this->isCsrfTokenValid('update_manager_refresh', $request->request->get('_token'))) {
|
||||
return $this->json(['error' => 'Invalid CSRF token'], Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$this->updateChecker->refreshVersionInfo();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'status' => $this->updateChecker->getUpdateStatus(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* View release notes for a specific version.
|
||||
*/
|
||||
#[Route('/release/{tag}', name: 'admin_update_manager_release', methods: ['GET'])]
|
||||
public function releaseNotes(string $tag): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.show_updates');
|
||||
|
||||
$releases = $this->updateChecker->getAvailableReleases(20);
|
||||
$release = null;
|
||||
|
||||
foreach ($releases as $r) {
|
||||
if ($r['tag'] === $tag) {
|
||||
$release = $r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$release) {
|
||||
throw $this->createNotFoundException('Release not found');
|
||||
}
|
||||
|
||||
return $this->render('admin/update_manager/release_notes.html.twig', [
|
||||
'release' => $release,
|
||||
'current_version' => $this->updateChecker->getCurrentVersionString(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* View an update log file.
|
||||
*/
|
||||
#[Route('/log/{filename}', name: 'admin_update_manager_log', methods: ['GET'])]
|
||||
public function viewLog(string $filename): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
|
||||
// Security: Only allow viewing files from the update logs directory
|
||||
$logs = $this->updateExecutor->getUpdateLogs();
|
||||
$logPath = null;
|
||||
|
||||
foreach ($logs as $log) {
|
||||
if ($log['file'] === $filename) {
|
||||
$logPath = $log['path'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$logPath || !file_exists($logPath)) {
|
||||
throw $this->createNotFoundException('Log file not found');
|
||||
}
|
||||
|
||||
$content = file_get_contents($logPath);
|
||||
|
||||
return $this->render('admin/update_manager/log_viewer.html.twig', [
|
||||
'filename' => $filename,
|
||||
'content' => $content,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an update process.
|
||||
*/
|
||||
#[Route('/start', name: 'admin_update_manager_start', methods: ['POST'])]
|
||||
public function startUpdate(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
$this->denyIfWebUpdatesDisabled();
|
||||
|
||||
// Validate CSRF token
|
||||
if (!$this->isCsrfTokenValid('update_manager_start', $request->request->get('_token'))) {
|
||||
$this->addFlash('error', 'Invalid CSRF token');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Check if update is already running
|
||||
if ($this->updateExecutor->isLocked() || $this->updateExecutor->isUpdateRunning()) {
|
||||
$this->addFlash('error', 'An update is already in progress.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
$targetVersion = $request->request->get('version');
|
||||
$createBackup = $request->request->getBoolean('backup', true);
|
||||
|
||||
if (!$targetVersion) {
|
||||
// Get latest version if not specified
|
||||
$latest = $this->updateChecker->getLatestVersion();
|
||||
if (!$latest) {
|
||||
$this->addFlash('error', 'Could not determine target version.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
$targetVersion = $latest['tag'];
|
||||
}
|
||||
|
||||
// Validate preconditions
|
||||
$validation = $this->updateExecutor->validateUpdatePreconditions();
|
||||
if (!$validation['valid']) {
|
||||
$this->addFlash('error', implode(' ', $validation['errors']));
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Start the background update
|
||||
$pid = $this->updateExecutor->startBackgroundUpdate($targetVersion, $createBackup);
|
||||
|
||||
if (!$pid) {
|
||||
$this->addFlash('error', 'Failed to start update process.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Redirect to progress page
|
||||
return $this->redirectToRoute('admin_update_manager_progress');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update progress page.
|
||||
*/
|
||||
#[Route('/progress', name: 'admin_update_manager_progress', methods: ['GET'])]
|
||||
public function progress(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
|
||||
$progress = $this->updateExecutor->getProgress();
|
||||
$currentVersion = $this->versionManager->getVersion()->toString();
|
||||
|
||||
// Determine if this is a downgrade
|
||||
$isDowngrade = false;
|
||||
if ($progress && isset($progress['target_version'])) {
|
||||
$targetVersion = ltrim($progress['target_version'], 'v');
|
||||
$isDowngrade = version_compare($targetVersion, $currentVersion, '<');
|
||||
}
|
||||
|
||||
return $this->render('admin/update_manager/progress.html.twig', [
|
||||
'progress' => $progress,
|
||||
'is_locked' => $this->updateExecutor->isLocked(),
|
||||
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
|
||||
'is_downgrade' => $isDowngrade,
|
||||
'current_version' => $currentVersion,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX endpoint to get update progress.
|
||||
*/
|
||||
#[Route('/progress/status', name: 'admin_update_manager_progress_status', methods: ['GET'])]
|
||||
public function progressStatus(): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.show_updates');
|
||||
|
||||
$progress = $this->updateExecutor->getProgress();
|
||||
|
||||
return $this->json([
|
||||
'progress' => $progress,
|
||||
'is_locked' => $this->updateExecutor->isLocked(),
|
||||
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup details for restore confirmation.
|
||||
*/
|
||||
#[Route('/backup/{filename}', name: 'admin_update_manager_backup_details', methods: ['GET'])]
|
||||
public function backupDetails(string $filename): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
|
||||
$details = $this->backupManager->getBackupDetails($filename);
|
||||
|
||||
if (!$details) {
|
||||
return $this->json(['error' => 'Backup not found'], 404);
|
||||
}
|
||||
|
||||
return $this->json($details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore from a backup.
|
||||
*/
|
||||
#[Route('/restore', name: 'admin_update_manager_restore', methods: ['POST'])]
|
||||
public function restore(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
$this->denyIfBackupRestoreDisabled();
|
||||
|
||||
// Validate CSRF token
|
||||
if (!$this->isCsrfTokenValid('update_manager_restore', $request->request->get('_token'))) {
|
||||
$this->addFlash('error', 'Invalid CSRF token.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Check if already locked
|
||||
if ($this->updateExecutor->isLocked()) {
|
||||
$this->addFlash('error', 'An update or restore is already in progress.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
$filename = $request->request->get('filename');
|
||||
$restoreDatabase = $request->request->getBoolean('restore_database', true);
|
||||
$restoreConfig = $request->request->getBoolean('restore_config', false);
|
||||
$restoreAttachments = $request->request->getBoolean('restore_attachments', false);
|
||||
|
||||
if (!$filename) {
|
||||
$this->addFlash('error', 'No backup file specified.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Verify the backup exists
|
||||
$backupDetails = $this->backupManager->getBackupDetails($filename);
|
||||
if (!$backupDetails) {
|
||||
$this->addFlash('error', 'Backup file not found.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Execute restore (this is a synchronous operation for now - could be made async later)
|
||||
$result = $this->updateExecutor->restoreBackup(
|
||||
$filename,
|
||||
$restoreDatabase,
|
||||
$restoreConfig,
|
||||
$restoreAttachments
|
||||
);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->addFlash('success', 'Backup restored successfully.');
|
||||
} else {
|
||||
$this->addFlash('error', 'Restore failed: ' . ($result['error'] ?? 'Unknown error'));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ declare(strict_types=1);
|
|||
namespace App\DataTables\Filters;
|
||||
use App\DataTables\Filters\Constraints\AbstractConstraint;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
|
||||
class PartSearchFilter implements FilterInterface
|
||||
{
|
||||
|
|
@ -33,6 +34,9 @@ class PartSearchFilter implements FilterInterface
|
|||
/** @var bool Use name field for searching */
|
||||
protected bool $name = true;
|
||||
|
||||
/** @var bool Use id field for searching */
|
||||
protected bool $dbId = false;
|
||||
|
||||
/** @var bool Use category name for searching */
|
||||
protected bool $category = true;
|
||||
|
||||
|
|
@ -125,9 +129,13 @@ class PartSearchFilter implements FilterInterface
|
|||
public function apply(QueryBuilder $queryBuilder): void
|
||||
{
|
||||
$fields_to_search = $this->getFieldsToSearch();
|
||||
$is_numeric = preg_match('/^\d+$/', $this->keyword) === 1;
|
||||
|
||||
// Add exact ID match only when the keyword is numeric
|
||||
$search_dbId = $is_numeric && (bool)$this->dbId;
|
||||
|
||||
//If we have nothing to search for, do nothing
|
||||
if ($fields_to_search === [] || $this->keyword === '') {
|
||||
if (($fields_to_search === [] && !$search_dbId) || $this->keyword === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -214,6 +222,17 @@ class PartSearchFilter implements FilterInterface
|
|||
return $this;
|
||||
}
|
||||
|
||||
public function isDbId(): bool
|
||||
{
|
||||
return $this->dbId;
|
||||
}
|
||||
|
||||
public function setDbId(bool $dbId): PartSearchFilter
|
||||
{
|
||||
$this->dbId = $dbId;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isCategory(): bool
|
||||
{
|
||||
return $this->category;
|
||||
|
|
|
|||
|
|
@ -208,6 +208,7 @@ class LogDataTable implements DataTableTypeInterface
|
|||
|
||||
$dataTable->add('extra', LogEntryExtraColumn::class, [
|
||||
'label' => 'log.extra',
|
||||
'orderable' => false, //Sorting the JSON column makes no sense: MySQL/Sqlite does it via the string representation, PostgreSQL errors out
|
||||
]);
|
||||
|
||||
$dataTable->add('timeTravel', IconLinkColumn::class, [
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ use App\DataTables\Helpers\PartDataTableHelper;
|
|||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||
use App\Services\ElementTypeNameGenerator;
|
||||
use App\Services\EntityURLGenerator;
|
||||
use App\Services\Formatters\AmountFormatter;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
|
@ -41,7 +42,8 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
|||
|
||||
class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
||||
{
|
||||
public function __construct(protected TranslatorInterface $translator, protected PartDataTableHelper $partDataTableHelper, protected EntityURLGenerator $entityURLGenerator, protected AmountFormatter $amountFormatter)
|
||||
public function __construct(protected TranslatorInterface $translator, protected PartDataTableHelper $partDataTableHelper,
|
||||
protected EntityURLGenerator $entityURLGenerator, protected AmountFormatter $amountFormatter)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -79,7 +81,14 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
|||
return htmlspecialchars($this->amountFormatter->format($context->getQuantity(), $context->getPart()->getPartUnit()));
|
||||
},
|
||||
])
|
||||
|
||||
->add('partId', TextColumn::class, [
|
||||
'label' => $this->translator->trans('project.bom.part_id'),
|
||||
'visible' => true,
|
||||
'orderField' => 'part.id',
|
||||
'render' => function ($value, ProjectBOMEntry $context) {
|
||||
return $context->getPart() instanceof Part ? (string) $context->getPart()->getId() : '';
|
||||
},
|
||||
])
|
||||
->add('name', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.name'),
|
||||
'orderField' => 'NATSORT(part.name)',
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ final class FieldHelper
|
|||
{
|
||||
$db_platform = $qb->getEntityManager()->getConnection()->getDatabasePlatform();
|
||||
|
||||
$key = 'field2_' . md5($field_expr);
|
||||
$key = 'field2_' . hash('xxh3', $field_expr);
|
||||
|
||||
//If we are on MySQL, we can just use the FIELD function
|
||||
if ($db_platform instanceof AbstractMySQLPlatform) {
|
||||
|
|
@ -121,4 +121,4 @@ final class FieldHelper
|
|||
|
||||
return $qb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,9 +166,10 @@ abstract class Attachment extends AbstractNamedDBElement
|
|||
* @var string|null The path to the external source if the file is stored externally or was downloaded from an
|
||||
* external source. Null if there is no external source.
|
||||
*/
|
||||
#[ORM\Column(type: Types::STRING, nullable: true)]
|
||||
#[ORM\Column(type: Types::STRING, length: 2048, nullable: true)]
|
||||
#[Groups(['attachment:read'])]
|
||||
#[ApiProperty(example: 'http://example.com/image.jpg')]
|
||||
#[Assert\Length(max: 2048)]
|
||||
protected ?string $external_path = null;
|
||||
|
||||
/**
|
||||
|
|
@ -551,8 +552,8 @@ abstract class Attachment extends AbstractNamedDBElement
|
|||
*/
|
||||
#[Groups(['attachment:write'])]
|
||||
#[SerializedName('url')]
|
||||
#[ApiProperty(description: 'Set the path of the attachment here.
|
||||
Provide either an external URL, a path to a builtin file (like %FOOTPRINTS%/Active/ICs/IC_DFS.png) or an empty
|
||||
#[ApiProperty(description: 'Set the path of the attachment here.
|
||||
Provide either an external URL, a path to a builtin file (like %FOOTPRINTS%/Active/ICs/IC_DFS.png) or an empty
|
||||
string if the attachment has an internal file associated and you\'d like to reset the external source.
|
||||
If you set a new (nonempty) file path any associated internal file will be removed!')]
|
||||
public function setURL(?string $url): self
|
||||
|
|
|
|||
|
|
@ -83,8 +83,8 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement
|
|||
*/
|
||||
#[Assert\Url(requireTld: false)]
|
||||
#[Groups(['full', 'company:read', 'company:write', 'import', 'extended'])]
|
||||
#[ORM\Column(type: Types::STRING)]
|
||||
#[Assert\Length(max: 255)]
|
||||
#[ORM\Column(type: Types::STRING, length: 2048)]
|
||||
#[Assert\Length(max: 2048)]
|
||||
protected string $website = '';
|
||||
|
||||
#[Groups(['company:read', 'company:write', 'import', 'full', 'extended'])]
|
||||
|
|
@ -93,8 +93,8 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement
|
|||
/**
|
||||
* @var string The link to the website of an article. Use %PARTNUMBER% as placeholder for the part number.
|
||||
*/
|
||||
#[ORM\Column(type: Types::STRING)]
|
||||
#[Assert\Length(max: 255)]
|
||||
#[ORM\Column(type: Types::STRING, length: 2048)]
|
||||
#[Assert\Length(max: 2048)]
|
||||
#[Groups(['full', 'company:read', 'company:write', 'import', 'extended'])]
|
||||
protected string $auto_product_url = '';
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ class EDACategoryInfo
|
|||
/** @var bool|null If this is set to true, then this part will be excluded in the simulation */
|
||||
#[Column(type: Types::BOOLEAN, nullable: true)]
|
||||
#[Groups(['full', 'category:read', 'category:write', 'import'])]
|
||||
private ?bool $exclude_from_sim = true;
|
||||
private ?bool $exclude_from_sim = null;
|
||||
|
||||
/** @var string|null The KiCAD schematic symbol, which should be used (the path to the library) */
|
||||
#[Column(type: Types::STRING, nullable: true)]
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
|
|||
/**
|
||||
* @var float|null the guaranteed minimum value of this property
|
||||
*/
|
||||
#[Assert\Type(['float', null])]
|
||||
#[Assert\Type(['float', 'null'])]
|
||||
#[Assert\LessThanOrEqual(propertyPath: 'value_typical', message: 'parameters.validator.min_lesser_typical')]
|
||||
#[Assert\LessThan(propertyPath: 'value_max', message: 'parameters.validator.min_lesser_max')]
|
||||
#[Groups(['full', 'parameter:read', 'parameter:write', 'import'])]
|
||||
|
|
@ -134,7 +134,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
|
|||
/**
|
||||
* @var float|null the typical value of this property
|
||||
*/
|
||||
#[Assert\Type([null, 'float'])]
|
||||
#[Assert\Type(['null', 'float'])]
|
||||
#[Groups(['full', 'parameter:read', 'parameter:write', 'import'])]
|
||||
#[ORM\Column(type: Types::FLOAT, nullable: true)]
|
||||
protected ?float $value_typical = null;
|
||||
|
|
@ -142,7 +142,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
|
|||
/**
|
||||
* @var float|null the maximum value of this property
|
||||
*/
|
||||
#[Assert\Type(['float', null])]
|
||||
#[Assert\Type(['float', 'null'])]
|
||||
#[Assert\GreaterThanOrEqual(propertyPath: 'value_typical', message: 'parameters.validator.max_greater_typical')]
|
||||
#[Groups(['full', 'parameter:read', 'parameter:write', 'import'])]
|
||||
#[ORM\Column(type: Types::FLOAT, nullable: true)]
|
||||
|
|
@ -461,7 +461,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
|
|||
|
||||
return $str;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the class of the element that is allowed to be associated with this attachment.
|
||||
* @return string
|
||||
|
|
|
|||
|
|
@ -118,6 +118,13 @@ class Category extends AbstractPartsContainingDBElement
|
|||
#[ORM\Column(type: Types::TEXT)]
|
||||
protected string $partname_regex = '';
|
||||
|
||||
/**
|
||||
* @var string The prefix for ipn generation for created parts in this category.
|
||||
*/
|
||||
#[Groups(['full', 'import', 'category:read', 'category:write'])]
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: false, options: ['default' => ''])]
|
||||
protected string $part_ipn_prefix = '';
|
||||
|
||||
/**
|
||||
* @var bool Set to true, if the footprints should be disabled for parts this category (not implemented yet).
|
||||
*/
|
||||
|
|
@ -225,6 +232,16 @@ class Category extends AbstractPartsContainingDBElement
|
|||
return $this;
|
||||
}
|
||||
|
||||
public function getPartIpnPrefix(): string
|
||||
{
|
||||
return $this->part_ipn_prefix;
|
||||
}
|
||||
|
||||
public function setPartIpnPrefix(string $part_ipn_prefix): void
|
||||
{
|
||||
$this->part_ipn_prefix = $part_ipn_prefix;
|
||||
}
|
||||
|
||||
public function isDisableFootprints(): bool
|
||||
{
|
||||
return $this->disable_footprints;
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ class InfoProviderReference
|
|||
/**
|
||||
* @var string|null The url of this part inside the provider system or null if this info is not existing
|
||||
*/
|
||||
#[Column(type: Types::STRING, nullable: true)]
|
||||
#[Column(type: Types::STRING, length: 2048, nullable: true)]
|
||||
#[Groups(['provider_reference:read', 'full'])]
|
||||
private ?string $provider_url = null;
|
||||
|
||||
|
|
@ -157,4 +157,4 @@ class InfoProviderReference
|
|||
$ref->last_updated = new \DateTimeImmutable();
|
||||
return $ref;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@ use Doctrine\Common\Collections\ArrayCollection;
|
|||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\Common\Collections\Criteria;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
|
@ -75,7 +74,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||
* @extends AttachmentContainingDBElement<PartAttachment>
|
||||
* @template-use ParametersTrait<PartParameter>
|
||||
*/
|
||||
#[UniqueEntity(fields: ['ipn'], message: 'part.ipn.must_be_unique')]
|
||||
#[ORM\Entity(repositoryClass: PartRepository::class)]
|
||||
#[ORM\EntityListeners([TreeCacheInvalidationListener::class])]
|
||||
#[ORM\Table('`parts`')]
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ use Doctrine\ORM\Mapping as ORM;
|
|||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Constraints\Length;
|
||||
use App\Validator\Constraints\UniquePartIpnConstraint;
|
||||
|
||||
/**
|
||||
* Advanced properties of a part, not related to a more specific group.
|
||||
|
|
@ -65,6 +66,7 @@ trait AdvancedPropertyTrait
|
|||
#[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
|
||||
#[ORM\Column(type: Types::STRING, length: 100, unique: true, nullable: true)]
|
||||
#[Length(max: 100)]
|
||||
#[UniquePartIpnConstraint]
|
||||
protected ?string $ipn = null;
|
||||
|
||||
/**
|
||||
|
|
|
|||
49
src/EnvVarProcessors/AddSlashEnvVarProcessor.php
Normal file
49
src/EnvVarProcessors/AddSlashEnvVarProcessor.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?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\EnvVarProcessors;
|
||||
|
||||
use Symfony\Component\DependencyInjection\EnvVarProcessorInterface;
|
||||
|
||||
/**
|
||||
* Env var processor that adds a trailing slash to a string if not already present.
|
||||
*/
|
||||
final class AddSlashEnvVarProcessor implements EnvVarProcessorInterface
|
||||
{
|
||||
|
||||
public function getEnv(string $prefix, string $name, \Closure $getEnv): mixed
|
||||
{
|
||||
$env = $getEnv($name);
|
||||
if (!is_string($env)) {
|
||||
throw new \InvalidArgumentException(sprintf('The "addSlash" env var processor only works with strings, got %s.', gettype($env)));
|
||||
}
|
||||
return rtrim($env, '/') . '/';
|
||||
}
|
||||
|
||||
public static function getProvidedTypes(): array
|
||||
{
|
||||
return [
|
||||
'addSlash' => 'string',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
namespace App\EnvVarProcessors;
|
||||
|
||||
use Closure;
|
||||
use Symfony\Component\DependencyInjection\EnvVarProcessorInterface;
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
<?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\EventListener;
|
||||
|
||||
use App\Services\ElementTypeNameGenerator;
|
||||
use App\Services\ElementTypes;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\Translation\Translator;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
use Symfony\Contracts\Cache\TagAwareCacheInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
#[AsEventListener]
|
||||
readonly class RegisterSynonymsAsTranslationParametersListener
|
||||
{
|
||||
private Translator $translator;
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'translator.default')] TranslatorInterface $translator,
|
||||
private TagAwareCacheInterface $cache,
|
||||
private ElementTypeNameGenerator $typeNameGenerator)
|
||||
{
|
||||
if (!$translator instanceof Translator) {
|
||||
throw new \RuntimeException('Translator must be an instance of Symfony\Component\Translation\Translator or this listener cannot be used.');
|
||||
}
|
||||
$this->translator = $translator;
|
||||
}
|
||||
|
||||
public function getSynonymPlaceholders(string $locale): array
|
||||
{
|
||||
return $this->cache->get('partdb_synonym_placeholders' . '_' . $locale, function (ItemInterface $item) use ($locale) {
|
||||
$item->tag('synonyms');
|
||||
|
||||
|
||||
$placeholders = [];
|
||||
|
||||
//Generate a placeholder for each element type
|
||||
foreach (ElementTypes::cases() as $elementType) {
|
||||
//Versions with capitalized first letter
|
||||
$capitalized = ucfirst($elementType->value); //We have only ASCII element type values, so this is sufficient
|
||||
$placeholders['[' . $capitalized . ']'] = $this->typeNameGenerator->typeLabel($elementType, $locale);
|
||||
$placeholders['[[' . $capitalized . ']]'] = $this->typeNameGenerator->typeLabelPlural($elementType, $locale);
|
||||
|
||||
//And we have lowercase versions for both
|
||||
$placeholders['[' . $elementType->value . ']'] = mb_strtolower($this->typeNameGenerator->typeLabel($elementType, $locale));
|
||||
$placeholders['[[' . $elementType->value . ']]'] = mb_strtolower($this->typeNameGenerator->typeLabelPlural($elementType, $locale));
|
||||
}
|
||||
|
||||
return $placeholders;
|
||||
});
|
||||
}
|
||||
|
||||
public function __invoke(RequestEvent $event): void
|
||||
{
|
||||
//If we already added the parameters, skip adding them again
|
||||
if (isset($this->translator->getGlobalParameters()['@@partdb_synonyms_registered@@'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
//Register all placeholders for synonyms
|
||||
$placeholders = $this->getSynonymPlaceholders($event->getRequest()->getLocale());
|
||||
foreach ($placeholders as $key => $value) {
|
||||
$this->translator->addGlobalParameter($key, $value);
|
||||
}
|
||||
|
||||
//Register the marker parameter to avoid double registration
|
||||
$this->translator->addGlobalParameter('@@partdb_synonyms_registered@@', 'registered');
|
||||
}
|
||||
}
|
||||
230
src/EventSubscriber/MaintenanceModeSubscriber.php
Normal file
230
src/EventSubscriber/MaintenanceModeSubscriber.php
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Services\System\UpdateExecutor;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* Blocks all web requests when maintenance mode is enabled during updates.
|
||||
*/
|
||||
readonly class MaintenanceModeSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private UpdateExecutor $updateExecutor)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
// High priority to run before other listeners
|
||||
KernelEvents::REQUEST => ['onKernelRequest', 512], //High priority to run before other listeners
|
||||
];
|
||||
}
|
||||
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
// Only handle main requests
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if not in maintenance mode
|
||||
if (!$this->updateExecutor->isMaintenanceMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
//Allow to view the progress page
|
||||
if (preg_match('#^/\w{2}/system/update-manager/progress#', $event->getRequest()->getPathInfo())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow CLI requests
|
||||
if (PHP_SAPI === 'cli') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get maintenance info
|
||||
$maintenanceInfo = $this->updateExecutor->getMaintenanceInfo();
|
||||
|
||||
// Calculate how long the update has been running
|
||||
$duration = null;
|
||||
if ($maintenanceInfo && isset($maintenanceInfo['enabled_at'])) {
|
||||
try {
|
||||
$startedAt = new \DateTime($maintenanceInfo['enabled_at']);
|
||||
$now = new \DateTime();
|
||||
$duration = $now->getTimestamp() - $startedAt->getTimestamp();
|
||||
} catch (\Exception) {
|
||||
// Ignore date parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
$content = $this->getSimpleMaintenanceHtml($maintenanceInfo, $duration);
|
||||
|
||||
$response = new Response($content, Response::HTTP_SERVICE_UNAVAILABLE);
|
||||
$response->headers->set('Retry-After', '30');
|
||||
$response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
|
||||
$event->setResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a simple maintenance page HTML without Twig.
|
||||
*/
|
||||
private function getSimpleMaintenanceHtml(?array $maintenanceInfo, ?int $duration): string
|
||||
{
|
||||
$reason = htmlspecialchars($maintenanceInfo['reason'] ?? 'Update in progress');
|
||||
$durationText = $duration !== null ? sprintf('%d seconds', $duration) : 'a moment';
|
||||
|
||||
$startDateStr = $maintenanceInfo['enabled_at'] ?? 'unknown time';
|
||||
|
||||
return <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="refresh" content="15">
|
||||
<title>Part-DB - Maintenance</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #ffffff;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
max-width: 600px;
|
||||
}
|
||||
.icon {
|
||||
font-size: 80px;
|
||||
margin-bottom: 30px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.1); opacity: 0.8; }
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 20px;
|
||||
color: #00d4ff;
|
||||
}
|
||||
p {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 15px;
|
||||
color: #b8c5d6;
|
||||
}
|
||||
.reason {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 15px 25px;
|
||||
border-radius: 10px;
|
||||
margin: 20px 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin: 30px 0;
|
||||
}
|
||||
.progress-bar-inner {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #00d4ff, #00ff88);
|
||||
border-radius: 3px;
|
||||
animation: progress 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes progress {
|
||||
0% { width: 0%; margin-left: 0%; }
|
||||
50% { width: 50%; margin-left: 25%; }
|
||||
100% { width: 0%; margin-left: 100%; }
|
||||
}
|
||||
.info {
|
||||
font-size: 0.9rem;
|
||||
color: #8899aa;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.duration {
|
||||
font-family: monospace;
|
||||
background: rgba(0, 212, 255, 0.2);
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">
|
||||
<span class="spinner">⚙️</span>
|
||||
</div>
|
||||
<h1>Part-DB is under maintenance</h1>
|
||||
<p>We're making things better. This should only take a moment.</p>
|
||||
|
||||
<div class="reason">
|
||||
<strong>{$reason}</strong>
|
||||
</div>
|
||||
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-inner"></div>
|
||||
</div>
|
||||
|
||||
<p class="info">
|
||||
Maintenance mode active since <span class="duration">{$startDateStr}</span><br>
|
||||
<br>
|
||||
Started <span class="duration">{$durationText}</span> ago<br>
|
||||
<small>This page will automatically refresh every 15 seconds.</small>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
97
src/EventSubscriber/UserSystem/PartUniqueIpnSubscriber.php
Normal file
97
src/EventSubscriber/UserSystem/PartUniqueIpnSubscriber.php
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
namespace App\EventSubscriber\UserSystem;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Settings\MiscSettings\IpnSuggestSettings;
|
||||
use Doctrine\Common\EventSubscriber;
|
||||
use Doctrine\ORM\Events;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
|
||||
class PartUniqueIpnSubscriber implements EventSubscriber
|
||||
{
|
||||
public function __construct(
|
||||
private IpnSuggestSettings $ipnSuggestSettings
|
||||
) {
|
||||
}
|
||||
|
||||
public function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
Events::onFlush,
|
||||
];
|
||||
}
|
||||
|
||||
public function onFlush(OnFlushEventArgs $args): void
|
||||
{
|
||||
if (!$this->ipnSuggestSettings->autoAppendSuffix) {
|
||||
return;
|
||||
}
|
||||
|
||||
$em = $args->getObjectManager();
|
||||
$uow = $em->getUnitOfWork();
|
||||
$meta = $em->getClassMetadata(Part::class);
|
||||
|
||||
// Collect all IPNs already reserved in the current flush (so new entities do not collide with each other)
|
||||
$reservedIpns = [];
|
||||
|
||||
// Helper to assign a collision-free IPN for a Part entity
|
||||
$ensureUnique = function (Part $part) use ($em, $uow, $meta, &$reservedIpns) {
|
||||
$ipn = $part->getIpn();
|
||||
if ($ipn === null || $ipn === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check against IPNs already reserved in the current flush (except itself)
|
||||
$originalIpn = $ipn;
|
||||
$candidate = $originalIpn;
|
||||
$increment = 1;
|
||||
|
||||
$conflicts = function (string $candidate) use ($em, $part, $reservedIpns) {
|
||||
// Collision within the current flush session?
|
||||
if (isset($reservedIpns[$candidate]) && $reservedIpns[$candidate] !== $part) {
|
||||
return true;
|
||||
}
|
||||
// Collision with an existing DB row?
|
||||
$existing = $em->getRepository(Part::class)->findOneBy(['ipn' => $candidate]);
|
||||
return $existing !== null && $existing->getId() !== $part->getId();
|
||||
};
|
||||
|
||||
while ($conflicts($candidate)) {
|
||||
$candidate = $originalIpn . '_' . $increment;
|
||||
$increment++;
|
||||
}
|
||||
|
||||
if ($candidate !== $ipn) {
|
||||
$before = $part->getIpn();
|
||||
$part->setIpn($candidate);
|
||||
|
||||
// Recompute the change set so Doctrine writes the change
|
||||
$uow->recomputeSingleEntityChangeSet($meta, $part);
|
||||
$reservedIpns[$candidate] = $part;
|
||||
|
||||
// If the old IPN was reserved already, clean it up
|
||||
if ($before !== null && isset($reservedIpns[$before]) && $reservedIpns[$before] === $part) {
|
||||
unset($reservedIpns[$before]);
|
||||
}
|
||||
} else {
|
||||
// Candidate unchanged, but reserve it so subsequent entities see it
|
||||
$reservedIpns[$candidate] = $part;
|
||||
}
|
||||
};
|
||||
|
||||
// 1) Iterate over new entities
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
if ($entity instanceof Part) {
|
||||
$ensureUnique($entity);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Iterate over updates (if IPN changed, ensure uniqueness again)
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
if ($entity instanceof Part) {
|
||||
$ensureUnique($entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/Exceptions/ProviderIDNotSupportedException.php
Normal file
32
src/Exceptions/ProviderIDNotSupportedException.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 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\Exceptions;
|
||||
|
||||
class ProviderIDNotSupportedException extends \RuntimeException
|
||||
{
|
||||
public function fromProvider(string $providerKey, string $id): self
|
||||
{
|
||||
return new self(sprintf('The given ID %s is not supported by the provider %s.', $id, $providerKey,));
|
||||
}
|
||||
}
|
||||
|
|
@ -84,6 +84,17 @@ class CategoryAdminForm extends BaseEntityAdminForm
|
|||
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
|
||||
]);
|
||||
|
||||
$builder->add('part_ipn_prefix', TextType::class, [
|
||||
'required' => false,
|
||||
'empty_data' => '',
|
||||
'label' => 'category.edit.part_ipn_prefix',
|
||||
'help' => 'category.edit.part_ipn_prefix.help',
|
||||
'attr' => [
|
||||
'placeholder' => 'category.edit.part_ipn_prefix.placeholder',
|
||||
],
|
||||
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
|
||||
]);
|
||||
|
||||
$builder->add('default_description', RichTextEditorType::class, [
|
||||
'required' => false,
|
||||
'empty_data' => '',
|
||||
|
|
|
|||
|
|
@ -46,8 +46,8 @@ final class TogglePasswordTypeExtension extends AbstractTypeExtension
|
|||
{
|
||||
$resolver->setDefaults([
|
||||
'toggle' => false,
|
||||
'hidden_label' => 'Hide',
|
||||
'visible_label' => 'Show',
|
||||
'hidden_label' => new TranslatableMessage('password_toggle.hide'),
|
||||
'visible_label' => new TranslatableMessage('password_toggle.show'),
|
||||
'hidden_icon' => 'Default',
|
||||
'visible_icon' => 'Default',
|
||||
'button_classes' => ['toggle-password-button'],
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ use Symfony\Component\Form\ChoiceList\ChoiceList;
|
|||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\OptionsResolver\Options;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Translation\StaticMessage;
|
||||
|
||||
class ProviderSelectType extends AbstractType
|
||||
{
|
||||
|
|
@ -70,10 +71,10 @@ class ProviderSelectType extends AbstractType
|
|||
//The choice_label and choice_value only needs to be set if we want the objects
|
||||
$resolver->setDefault('choice_label', function (Options $options){
|
||||
if ('object' === $options['input']) {
|
||||
return ChoiceList::label($this, static fn (?InfoProviderInterface $choice) => $choice?->getProviderInfo()['name']);
|
||||
return ChoiceList::label($this, static fn (?InfoProviderInterface $choice) => new StaticMessage($choice?->getProviderInfo()['name']));
|
||||
}
|
||||
|
||||
return null;
|
||||
return static fn ($choice, $key, $value) => new StaticMessage($key);
|
||||
});
|
||||
$resolver->setDefault('choice_value', function (Options $options) {
|
||||
if ('object' === $options['input']) {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ use App\Form\Type\StructuralEntityType;
|
|||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\LogSystem\EventCommentNeededHelper;
|
||||
use App\Services\LogSystem\EventCommentType;
|
||||
use App\Settings\MiscSettings\IpnSuggestSettings;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
|
|
@ -57,8 +58,12 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
|||
|
||||
class PartBaseType extends AbstractType
|
||||
{
|
||||
public function __construct(protected Security $security, protected UrlGeneratorInterface $urlGenerator, protected EventCommentNeededHelper $event_comment_needed_helper)
|
||||
{
|
||||
public function __construct(
|
||||
protected Security $security,
|
||||
protected UrlGeneratorInterface $urlGenerator,
|
||||
protected EventCommentNeededHelper $event_comment_needed_helper,
|
||||
protected IpnSuggestSettings $ipnSuggestSettings,
|
||||
) {
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
|
|
@ -70,6 +75,39 @@ class PartBaseType extends AbstractType
|
|||
/** @var PartDetailDTO|null $dto */
|
||||
$dto = $options['info_provider_dto'];
|
||||
|
||||
$descriptionAttr = [
|
||||
'placeholder' => 'part.edit.description.placeholder',
|
||||
'rows' => 2,
|
||||
];
|
||||
|
||||
if ($this->ipnSuggestSettings->useDuplicateDescription) {
|
||||
// Only add attribute when duplicate description feature is enabled
|
||||
$descriptionAttr['data-ipn-suggestion'] = 'descriptionField';
|
||||
}
|
||||
|
||||
$ipnAttr = [
|
||||
'class' => 'ipn-suggestion-field',
|
||||
'data-elements--ipn-suggestion-target' => 'input',
|
||||
'autocomplete' => 'off',
|
||||
];
|
||||
|
||||
if ($this->ipnSuggestSettings->regex !== null && $this->ipnSuggestSettings->regex !== '') {
|
||||
$ipnAttr['pattern'] = $this->ipnSuggestSettings->regex;
|
||||
$ipnAttr['placeholder'] = $this->ipnSuggestSettings->regex;
|
||||
$ipnAttr['title'] = $this->ipnSuggestSettings->regexHelp;
|
||||
}
|
||||
|
||||
$ipnOptions = [
|
||||
'required' => false,
|
||||
'empty_data' => null,
|
||||
'label' => 'part.edit.ipn',
|
||||
'attr' => $ipnAttr,
|
||||
];
|
||||
|
||||
if (isset($ipnAttr['pattern']) && $this->ipnSuggestSettings->regexHelp !== null && $this->ipnSuggestSettings->regexHelp !== '') {
|
||||
$ipnOptions['help'] = $this->ipnSuggestSettings->regexHelp;
|
||||
}
|
||||
|
||||
//Common section
|
||||
$builder
|
||||
->add('name', TextType::class, [
|
||||
|
|
@ -84,10 +122,7 @@ class PartBaseType extends AbstractType
|
|||
'empty_data' => '',
|
||||
'label' => 'part.edit.description',
|
||||
'mode' => 'markdown-single_line',
|
||||
'attr' => [
|
||||
'placeholder' => 'part.edit.description.placeholder',
|
||||
'rows' => 2,
|
||||
],
|
||||
'attr' => $descriptionAttr,
|
||||
])
|
||||
->add('minAmount', SIUnitType::class, [
|
||||
'attr' => [
|
||||
|
|
@ -105,6 +140,9 @@ class PartBaseType extends AbstractType
|
|||
'disable_not_selectable' => true,
|
||||
//Do not require category for new parts, so that the user must select the category by hand and cannot forget it (the requirement is handled by the constraint in the entity)
|
||||
'required' => !$new_part,
|
||||
'attr' => [
|
||||
'data-ipn-suggestion' => 'categoryField',
|
||||
]
|
||||
])
|
||||
->add('footprint', StructuralEntityType::class, [
|
||||
'class' => Footprint::class,
|
||||
|
|
@ -178,11 +216,7 @@ class PartBaseType extends AbstractType
|
|||
'disable_not_selectable' => true,
|
||||
'label' => 'part.edit.partCustomState',
|
||||
])
|
||||
->add('ipn', TextType::class, [
|
||||
'required' => false,
|
||||
'empty_data' => null,
|
||||
'label' => 'part.edit.ipn',
|
||||
]);
|
||||
->add('ipn', TextType::class, $ipnOptions);
|
||||
|
||||
//Comment section
|
||||
$builder->add('comment', RichTextEditorType::class, [
|
||||
|
|
|
|||
|
|
@ -21,12 +21,11 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Form\Type;
|
||||
namespace App\Form\Settings;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\LanguageType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\LocaleType;
|
||||
use Symfony\Component\Intl\Languages;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
150
src/Form/Settings/TypeSynonymRowType.php
Normal file
150
src/Form/Settings/TypeSynonymRowType.php
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Settings;
|
||||
|
||||
use App\Services\ElementTypes;
|
||||
use App\Settings\SystemSettings\LocalizationSettings;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\LocaleType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Intl\Locales;
|
||||
use Symfony\Component\Translation\StaticMessage;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* A single translation row: data source + language + translations (singular/plural).
|
||||
*/
|
||||
class TypeSynonymRowType extends AbstractType
|
||||
{
|
||||
|
||||
private const PREFERRED_TYPES = [
|
||||
ElementTypes::CATEGORY,
|
||||
ElementTypes::STORAGE_LOCATION,
|
||||
ElementTypes::FOOTPRINT,
|
||||
ElementTypes::MANUFACTURER,
|
||||
ElementTypes::SUPPLIER,
|
||||
ElementTypes::PROJECT,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly LocalizationSettings $localizationSettings,
|
||||
private readonly TranslatorInterface $translator,
|
||||
#[Autowire(param: 'partdb.locale_menu')] private readonly array $preferredLanguagesParam,
|
||||
) {
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('dataSource', EnumType::class, [
|
||||
'class' => ElementTypes::class,
|
||||
'label' => false,
|
||||
'required' => true,
|
||||
'constraints' => [
|
||||
new Assert\NotBlank(),
|
||||
],
|
||||
'choice_label' => function (ElementTypes $choice) {
|
||||
return new StaticMessage(
|
||||
$this->translator->trans($choice->getDefaultLabelKey()) . ' (' . $this->translator->trans($choice->getDefaultPluralLabelKey()) . ')'
|
||||
);
|
||||
},
|
||||
'row_attr' => ['class' => 'mb-0'],
|
||||
'attr' => ['class' => 'form-select-sm'],
|
||||
'preferred_choices' => self::PREFERRED_TYPES
|
||||
])
|
||||
->add('locale', LocaleType::class, [
|
||||
'label' => false,
|
||||
'required' => true,
|
||||
// Restrict to languages configured in the language menu: disable ChoiceLoader and provide explicit choices
|
||||
'choice_loader' => null,
|
||||
'choices' => $this->buildLocaleChoices(true),
|
||||
'preferred_choices' => $this->getPreferredLocales(),
|
||||
'constraints' => [
|
||||
new Assert\NotBlank(),
|
||||
],
|
||||
'row_attr' => ['class' => 'mb-0'],
|
||||
'attr' => ['class' => 'form-select-sm']
|
||||
])
|
||||
->add('translation_singular', TextType::class, [
|
||||
'label' => false,
|
||||
'required' => true,
|
||||
'empty_data' => '',
|
||||
'constraints' => [
|
||||
new Assert\NotBlank(),
|
||||
],
|
||||
'row_attr' => ['class' => 'mb-0'],
|
||||
'attr' => ['class' => 'form-select-sm']
|
||||
])
|
||||
->add('translation_plural', TextType::class, [
|
||||
'label' => false,
|
||||
'required' => true,
|
||||
'empty_data' => '',
|
||||
'constraints' => [
|
||||
new Assert\NotBlank(),
|
||||
],
|
||||
'row_attr' => ['class' => 'mb-0'],
|
||||
'attr' => ['class' => 'form-select-sm']
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns only locales configured in the language menu (settings) or falls back to the parameter.
|
||||
* Format: ['German (DE)' => 'de', ...]
|
||||
*/
|
||||
private function buildLocaleChoices(bool $returnPossible = false): array
|
||||
{
|
||||
$locales = $this->getPreferredLocales();
|
||||
|
||||
if ($returnPossible) {
|
||||
$locales = $this->getPossibleLocales();
|
||||
}
|
||||
|
||||
$choices = [];
|
||||
foreach ($locales as $code) {
|
||||
$label = Locales::getName($code);
|
||||
$choices[$label . ' (' . strtoupper($code) . ')'] = $code;
|
||||
}
|
||||
return $choices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source of allowed locales:
|
||||
* 1) LocalizationSettings->languageMenuEntries (if set)
|
||||
* 2) Fallback: parameter partdb.locale_menu
|
||||
*/
|
||||
private function getPreferredLocales(): array
|
||||
{
|
||||
$fromSettings = $this->localizationSettings->languageMenuEntries ?? [];
|
||||
return !empty($fromSettings) ? array_values($fromSettings) : array_values($this->preferredLanguagesParam);
|
||||
}
|
||||
|
||||
private function getPossibleLocales(): array
|
||||
{
|
||||
return array_values($this->preferredLanguagesParam);
|
||||
}
|
||||
}
|
||||
223
src/Form/Settings/TypeSynonymsCollectionType.php
Normal file
223
src/Form/Settings/TypeSynonymsCollectionType.php
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
<?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\Form\Settings;
|
||||
|
||||
use App\Services\ElementTypes;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\CallbackTransformer;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
use Symfony\Component\Intl\Locales;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* Flat collection of translation rows.
|
||||
* View data: list [{dataSource, locale, translation_singular, translation_plural}, ...]
|
||||
* Model data: same structure (list). Optionally expands a nested map to a list.
|
||||
*/
|
||||
class TypeSynonymsCollectionType extends AbstractType
|
||||
{
|
||||
public function __construct(private readonly TranslatorInterface $translator)
|
||||
{
|
||||
}
|
||||
|
||||
private function flattenStructure(array $modelValue): array
|
||||
{
|
||||
//If the model is already flattened, return as is
|
||||
if (array_is_list($modelValue)) {
|
||||
return $modelValue;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($modelValue as $dataSource => $locales) {
|
||||
if (!is_array($locales)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($locales as $locale => $translations) {
|
||||
if (!is_array($translations)) {
|
||||
continue;
|
||||
}
|
||||
$out[] = [
|
||||
//Convert string to enum value
|
||||
'dataSource' => ElementTypes::from($dataSource),
|
||||
'locale' => $locale,
|
||||
'translation_singular' => $translations['singular'] ?? '',
|
||||
'translation_plural' => $translations['plural'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
|
||||
//Flatten the structure
|
||||
$data = $event->getData();
|
||||
$event->setData($this->flattenStructure($data));
|
||||
});
|
||||
|
||||
$builder->addModelTransformer(new CallbackTransformer(
|
||||
// Model -> View
|
||||
$this->flattenStructure(...),
|
||||
// View -> Model (keep list; let existing behavior unchanged)
|
||||
function (array $viewValue) {
|
||||
//Turn our flat list back into the structured array
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($viewValue as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$dataSource = $row['dataSource'] ?? null;
|
||||
$locale = $row['locale'] ?? null;
|
||||
$translation_singular = $row['translation_singular'] ?? null;
|
||||
$translation_plural = $row['translation_plural'] ?? null;
|
||||
|
||||
if ($dataSource === null ||
|
||||
!is_string($locale) || $locale === ''
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[$dataSource->value][$locale] = [
|
||||
'singular' => is_string($translation_singular) ? $translation_singular : '',
|
||||
'plural' => is_string($translation_plural) ? $translation_plural : '',
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
));
|
||||
|
||||
// Validation and normalization (duplicates + sorting) during SUBMIT
|
||||
$builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void {
|
||||
$form = $event->getForm();
|
||||
$rows = $event->getData();
|
||||
|
||||
if (!is_array($rows)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Duplicate check: (dataSource, locale) must be unique
|
||||
$seen = [];
|
||||
$hasDuplicate = false;
|
||||
|
||||
foreach ($rows as $idx => $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$ds = $row['dataSource'] ?? null;
|
||||
$loc = $row['locale'] ?? null;
|
||||
|
||||
if ($ds !== null && is_string($loc) && $loc !== '') {
|
||||
$key = $ds->value . '|' . $loc;
|
||||
if (isset($seen[$key])) {
|
||||
$hasDuplicate = true;
|
||||
|
||||
if ($form->has((string)$idx)) {
|
||||
$child = $form->get((string)$idx);
|
||||
|
||||
if ($child->has('dataSource')) {
|
||||
$child->get('dataSource')->addError(
|
||||
new FormError($this->translator->trans(
|
||||
'settings.synonyms.type_synonyms.collection_type.duplicate',
|
||||
[], 'validators'
|
||||
))
|
||||
);
|
||||
}
|
||||
if ($child->has('locale')) {
|
||||
$child->get('locale')->addError(
|
||||
new FormError($this->translator->trans(
|
||||
'settings.synonyms.type_synonyms.collection_type.duplicate',
|
||||
[], 'validators'
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$seen[$key] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasDuplicate) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Overall sort: first by dataSource key, then by localized language name
|
||||
$sortable = $rows;
|
||||
|
||||
usort($sortable, static function ($a, $b) {
|
||||
$aDs = $a['dataSource']->value ?? '';
|
||||
$bDs = $b['dataSource']->value ?? '';
|
||||
|
||||
$cmpDs = strcasecmp($aDs, $bDs);
|
||||
if ($cmpDs !== 0) {
|
||||
return $cmpDs;
|
||||
}
|
||||
|
||||
$aLoc = (string)($a['locale'] ?? '');
|
||||
$bLoc = (string)($b['locale'] ?? '');
|
||||
|
||||
$aName = Locales::getName($aLoc);
|
||||
$bName = Locales::getName($bLoc);
|
||||
|
||||
return strcasecmp($aName, $bName);
|
||||
});
|
||||
|
||||
$event->setData($sortable);
|
||||
});
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
|
||||
// Defaults for the collection and entry type
|
||||
$resolver->setDefaults([
|
||||
'entry_type' => TypeSynonymRowType::class,
|
||||
'allow_add' => true,
|
||||
'allow_delete' => true,
|
||||
'by_reference' => false,
|
||||
'required' => false,
|
||||
'prototype' => true,
|
||||
'empty_data' => [],
|
||||
'entry_options' => ['label' => false],
|
||||
]);
|
||||
}
|
||||
|
||||
public function getParent(): ?string
|
||||
{
|
||||
return CollectionType::class;
|
||||
}
|
||||
|
||||
public function getBlockPrefix(): string
|
||||
{
|
||||
return 'type_synonyms_collection';
|
||||
}
|
||||
}
|
||||
|
|
@ -30,7 +30,6 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
|||
|
||||
/**
|
||||
* A locale select field that uses the preferred languages from the configuration.
|
||||
|
||||
*/
|
||||
class LocaleSelectType extends AbstractType
|
||||
{
|
||||
|
|
|
|||
|
|
@ -110,8 +110,10 @@ class StructuralEntityType extends AbstractType
|
|||
//If no help text is explicitly set, we use the dto value as help text and show it as html
|
||||
$resolver->setDefault('help', fn(Options $options) => $this->dtoText($options['dto_value']));
|
||||
$resolver->setDefault('help_html', fn(Options $options) => $options['dto_value'] !== null);
|
||||
|
||||
|
||||
$resolver->setDefault('attr', function (Options $options) {
|
||||
//Normalize the attr to merge custom attributes
|
||||
$resolver->setNormalizer('attr', function (Options $options, $value) {
|
||||
$tmp = [
|
||||
'data-controller' => $options['controller'],
|
||||
'data-allow-add' => $options['allow_add'] ? 'true' : 'false',
|
||||
|
|
@ -121,7 +123,7 @@ class StructuralEntityType extends AbstractType
|
|||
$tmp['data-empty-message'] = $options['empty_message'];
|
||||
}
|
||||
|
||||
return $tmp;
|
||||
return array_merge($tmp, $value);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -109,6 +109,13 @@ class DBElementRepository extends EntityRepository
|
|||
return [];
|
||||
}
|
||||
|
||||
//Ensure that all IDs are integers and none is null
|
||||
foreach ($ids as $id) {
|
||||
if (!is_int($id)) {
|
||||
throw new \InvalidArgumentException('Non-integer ID given to findByIDInMatchingOrder: ' . var_export($id, true));
|
||||
}
|
||||
}
|
||||
|
||||
$cache_key = implode(',', $ids);
|
||||
|
||||
//Check if the result is already cached
|
||||
|
|
|
|||
|
|
@ -22,17 +22,35 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Parts\Category;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\PartLot;
|
||||
use App\Settings\MiscSettings\IpnSuggestSettings;
|
||||
use Doctrine\ORM\NonUniqueResultException;
|
||||
use Doctrine\ORM\NoResultException;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* @extends NamedDBElementRepository<Part>
|
||||
*/
|
||||
class PartRepository extends NamedDBElementRepository
|
||||
{
|
||||
private TranslatorInterface $translator;
|
||||
private IpnSuggestSettings $ipnSuggestSettings;
|
||||
|
||||
public function __construct(
|
||||
EntityManagerInterface $em,
|
||||
TranslatorInterface $translator,
|
||||
IpnSuggestSettings $ipnSuggestSettings,
|
||||
) {
|
||||
parent::__construct($em, $em->getClassMetadata(Part::class));
|
||||
|
||||
$this->translator = $translator;
|
||||
$this->ipnSuggestSettings = $ipnSuggestSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the summed up instock of all parts (only parts without a measurement unit).
|
||||
*
|
||||
|
|
@ -84,8 +102,7 @@ class PartRepository extends NamedDBElementRepository
|
|||
->where('ILIKE(part.name, :query) = TRUE')
|
||||
->orWhere('ILIKE(part.description, :query) = TRUE')
|
||||
->orWhere('ILIKE(category.name, :query) = TRUE')
|
||||
->orWhere('ILIKE(footprint.name, :query) = TRUE')
|
||||
;
|
||||
->orWhere('ILIKE(footprint.name, :query) = TRUE');
|
||||
|
||||
$qb->setParameter('query', '%'.$query.'%');
|
||||
|
||||
|
|
@ -94,4 +111,282 @@ class PartRepository extends NamedDBElementRepository
|
|||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides IPN (Internal Part Number) suggestions for a given part based on its category, description,
|
||||
* and configured autocomplete digit length.
|
||||
*
|
||||
* This function generates suggestions for common prefixes and incremented prefixes based on
|
||||
* the part's current category and its hierarchy. If the part is unsaved, a default "n.a." prefix is returned.
|
||||
*
|
||||
* @param Part $part The part for which autocomplete suggestions are generated.
|
||||
* @param string $description description to assist in generating suggestions.
|
||||
* @param int $suggestPartDigits The number of digits used in autocomplete increments.
|
||||
*
|
||||
* @return array An associative array containing the following keys:
|
||||
* - 'commonPrefixes': List of common prefixes found for the part.
|
||||
* - 'prefixesPartIncrement': Increments for the generated prefixes, including hierarchical prefixes.
|
||||
*/
|
||||
public function autoCompleteIpn(Part $part, string $description, int $suggestPartDigits): array
|
||||
{
|
||||
$category = $part->getCategory();
|
||||
$ipnSuggestions = ['commonPrefixes' => [], 'prefixesPartIncrement' => []];
|
||||
|
||||
//Show global prefix first if configured
|
||||
if ($this->ipnSuggestSettings->globalPrefix !== null && $this->ipnSuggestSettings->globalPrefix !== '') {
|
||||
$ipnSuggestions['commonPrefixes'][] = [
|
||||
'title' => $this->ipnSuggestSettings->globalPrefix,
|
||||
'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.global_prefix')
|
||||
];
|
||||
|
||||
$increment = $this->generateNextPossibleGlobalIncrement();
|
||||
$ipnSuggestions['prefixesPartIncrement'][] = [
|
||||
'title' => $this->ipnSuggestSettings->globalPrefix . $increment,
|
||||
'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.global_prefix')
|
||||
];
|
||||
}
|
||||
|
||||
if (strlen($description) > 150) {
|
||||
$description = substr($description, 0, 150);
|
||||
}
|
||||
|
||||
if ($description !== '' && $this->ipnSuggestSettings->useDuplicateDescription) {
|
||||
// Check if the description is already used in another part,
|
||||
|
||||
$suggestionByDescription = $this->getIpnSuggestByDescription($description);
|
||||
|
||||
if ($suggestionByDescription !== null && $suggestionByDescription !== $part->getIpn() && $part->getIpn() !== null && $part->getIpn() !== '') {
|
||||
$ipnSuggestions['prefixesPartIncrement'][] = [
|
||||
'title' => $part->getIpn(),
|
||||
'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.description.current-increment')
|
||||
];
|
||||
}
|
||||
|
||||
if ($suggestionByDescription !== null) {
|
||||
$ipnSuggestions['prefixesPartIncrement'][] = [
|
||||
'title' => $suggestionByDescription,
|
||||
'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.description.increment')
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the category and ensure it's an instance of Category
|
||||
if ($category instanceof Category) {
|
||||
$currentPath = $category->getPartIpnPrefix();
|
||||
$directIpnPrefixEmpty = $category->getPartIpnPrefix() === '';
|
||||
$currentPath = $currentPath === '' ? $this->ipnSuggestSettings->fallbackPrefix : $currentPath;
|
||||
|
||||
$increment = $this->generateNextPossiblePartIncrement($currentPath, $part, $suggestPartDigits);
|
||||
|
||||
$ipnSuggestions['commonPrefixes'][] = [
|
||||
'title' => $currentPath . $this->ipnSuggestSettings->numberSeparator,
|
||||
'description' => $directIpnPrefixEmpty ? $this->translator->trans('part.edit.tab.advanced.ipn.prefix_empty.direct_category', ['%name%' => $category->getName()]) : $this->translator->trans('part.edit.tab.advanced.ipn.prefix.direct_category')
|
||||
];
|
||||
|
||||
$ipnSuggestions['prefixesPartIncrement'][] = [
|
||||
'title' => $currentPath . $this->ipnSuggestSettings->numberSeparator . $increment,
|
||||
'description' => $directIpnPrefixEmpty ? $this->translator->trans('part.edit.tab.advanced.ipn.prefix_empty.direct_category', ['%name%' => $category->getName()]) : $this->translator->trans('part.edit.tab.advanced.ipn.prefix.direct_category.increment')
|
||||
];
|
||||
|
||||
// Process parent categories
|
||||
$parentCategory = $category->getParent();
|
||||
|
||||
while ($parentCategory instanceof Category) {
|
||||
// Prepend the parent category's prefix to the current path
|
||||
$effectiveIPNPrefix = $parentCategory->getPartIpnPrefix() === '' ? $this->ipnSuggestSettings->fallbackPrefix : $parentCategory->getPartIpnPrefix();
|
||||
|
||||
$currentPath = $effectiveIPNPrefix . $this->ipnSuggestSettings->categorySeparator . $currentPath;
|
||||
|
||||
$ipnSuggestions['commonPrefixes'][] = [
|
||||
'title' => $currentPath . $this->ipnSuggestSettings->numberSeparator,
|
||||
'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment')
|
||||
];
|
||||
|
||||
$increment = $this->generateNextPossiblePartIncrement($currentPath, $part, $suggestPartDigits);
|
||||
|
||||
$ipnSuggestions['prefixesPartIncrement'][] = [
|
||||
'title' => $currentPath . $this->ipnSuggestSettings->numberSeparator . $increment,
|
||||
'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.hierarchical.increment')
|
||||
];
|
||||
|
||||
// Move to the next parent category
|
||||
$parentCategory = $parentCategory->getParent();
|
||||
}
|
||||
} elseif ($part->getID() === null) {
|
||||
$ipnSuggestions['commonPrefixes'][] = [
|
||||
'title' => $this->ipnSuggestSettings->fallbackPrefix,
|
||||
'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.not_saved')
|
||||
];
|
||||
}
|
||||
|
||||
return $ipnSuggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggests the next IPN (Internal Part Number) based on the provided part description.
|
||||
*
|
||||
* Searches for parts with similar descriptions and retrieves their existing IPNs to calculate the next suggestion.
|
||||
* Returns null if the description is empty or no suggestion can be generated.
|
||||
*
|
||||
* @param string $description The part description to search for.
|
||||
*
|
||||
* @return string|null The suggested IPN, or null if no suggestion is possible.
|
||||
*
|
||||
* @throws NonUniqueResultException
|
||||
*/
|
||||
public function getIpnSuggestByDescription(string $description): ?string
|
||||
{
|
||||
if ($description === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$qb = $this->createQueryBuilder('part');
|
||||
|
||||
$qb->select('part')
|
||||
->where('part.description LIKE :descriptionPattern')
|
||||
->setParameter('descriptionPattern', $description.'%')
|
||||
->orderBy('part.id', 'ASC');
|
||||
|
||||
$partsBySameDescription = $qb->getQuery()->getResult();
|
||||
$givenIpnsWithSameDescription = [];
|
||||
|
||||
foreach ($partsBySameDescription as $part) {
|
||||
if ($part->getIpn() === null || $part->getIpn() === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$givenIpnsWithSameDescription[] = $part->getIpn();
|
||||
}
|
||||
|
||||
return $this->getNextIpnSuggestion($givenIpnsWithSameDescription);
|
||||
}
|
||||
|
||||
private function generateNextPossibleGlobalIncrement(): string
|
||||
{
|
||||
$qb = $this->createQueryBuilder('part');
|
||||
|
||||
|
||||
$qb->select('part.ipn')
|
||||
->where('REGEXP(part.ipn, :ipnPattern) = TRUE')
|
||||
->setParameter('ipnPattern', '^' . preg_quote($this->ipnSuggestSettings->globalPrefix, '/') . '\d+$')
|
||||
->orderBy('NATSORT(part.ipn)', 'DESC')
|
||||
->setMaxResults(1)
|
||||
;
|
||||
|
||||
$highestIPN = $qb->getQuery()->getOneOrNullResult();
|
||||
if ($highestIPN !== null) {
|
||||
//Remove the prefix and extract the increment part
|
||||
$incrementPart = substr($highestIPN['ipn'], strlen($this->ipnSuggestSettings->globalPrefix));
|
||||
//Extract a number using regex
|
||||
preg_match('/(\d+)$/', $incrementPart, $matches);
|
||||
$incrementInt = isset($matches[1]) ? (int) $matches[1] + 1 : 0;
|
||||
} else {
|
||||
$incrementInt = 1;
|
||||
}
|
||||
|
||||
|
||||
return str_pad((string) $incrementInt, $this->ipnSuggestSettings->suggestPartDigits, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the next possible increment for a part within a given category, while ensuring uniqueness.
|
||||
*
|
||||
* This method calculates the next available increment for a part's identifier (`ipn`) based on the current path
|
||||
* and the number of digits specified for the autocomplete feature. It ensures that the generated identifier
|
||||
* aligns with the expected length and does not conflict with already existing identifiers in the same category.
|
||||
*
|
||||
* @param string $currentPath The base path or prefix for the part's identifier.
|
||||
* @param Part $currentPart The part entity for which the increment is being generated.
|
||||
* @param int $suggestPartDigits The number of digits reserved for the increment.
|
||||
*
|
||||
* @return string The next possible increment as a zero-padded string.
|
||||
*
|
||||
* @throws NonUniqueResultException If the query returns non-unique results.
|
||||
* @throws NoResultException If the query fails to return a result.
|
||||
*/
|
||||
private function generateNextPossiblePartIncrement(string $currentPath, Part $currentPart, int $suggestPartDigits): string
|
||||
{
|
||||
$qb = $this->createQueryBuilder('part');
|
||||
|
||||
$expectedLength = strlen($currentPath) + strlen($this->ipnSuggestSettings->categorySeparator) + $suggestPartDigits; // Path + '-' + $suggestPartDigits digits
|
||||
|
||||
// Fetch all parts in the given category, sorted by their ID in ascending order
|
||||
$qb->select('part')
|
||||
->where('part.ipn LIKE :ipnPattern')
|
||||
->andWhere('LENGTH(part.ipn) = :expectedLength')
|
||||
->setParameter('ipnPattern', $currentPath . '%')
|
||||
->setParameter('expectedLength', $expectedLength)
|
||||
->orderBy('part.id', 'ASC');
|
||||
|
||||
$parts = $qb->getQuery()->getResult();
|
||||
|
||||
// Collect all used increments in the category
|
||||
$usedIncrements = [];
|
||||
foreach ($parts as $part) {
|
||||
if ($part->getIpn() === null || $part->getIpn() === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($part->getId() === $currentPart->getId() && $currentPart->getID() !== null) {
|
||||
// Extract and return the current part's increment directly
|
||||
$incrementPart = substr($part->getIpn(), -$suggestPartDigits);
|
||||
if (is_numeric($incrementPart)) {
|
||||
return str_pad((string) $incrementPart, $suggestPartDigits, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract last $autocompletePartDigits digits for possible available part increment
|
||||
$incrementPart = substr($part->getIpn(), -$suggestPartDigits);
|
||||
if (is_numeric($incrementPart)) {
|
||||
$usedIncrements[] = (int) $incrementPart;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Generate the next free $autocompletePartDigits-digit increment
|
||||
$nextIncrement = 1; // Start at the beginning
|
||||
|
||||
while (in_array($nextIncrement, $usedIncrements, true)) {
|
||||
$nextIncrement++;
|
||||
}
|
||||
|
||||
return str_pad((string) $nextIncrement, $suggestPartDigits, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the next IPN suggestion based on the maximum numeric suffix found in the given IPNs.
|
||||
*
|
||||
* The new IPN is constructed using the base format of the first provided IPN,
|
||||
* incremented by the next free numeric suffix. If no base IPNs are found,
|
||||
* returns null.
|
||||
*
|
||||
* @param array $givenIpns List of IPNs to analyze.
|
||||
*
|
||||
* @return string|null The next suggested IPN, or null if no base IPNs can be derived.
|
||||
*/
|
||||
private function getNextIpnSuggestion(array $givenIpns): ?string {
|
||||
$maxSuffix = 0;
|
||||
|
||||
foreach ($givenIpns as $ipn) {
|
||||
// Check whether the IPN contains a suffix "_ <number>"
|
||||
if (preg_match('/_(\d+)$/', $ipn, $matches)) {
|
||||
$suffix = (int)$matches[1];
|
||||
if ($suffix > $maxSuffix) {
|
||||
$maxSuffix = $suffix; // Höchste Nummer speichern
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the basic format (the IPN without suffix) from the first IPN
|
||||
$baseIpn = $givenIpns[0] ?? '';
|
||||
$baseIpn = preg_replace('/_\d+$/', '', $baseIpn); // Remove existing "_ <number>"
|
||||
|
||||
if ($baseIpn === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate next free possible IPN
|
||||
return $baseIpn . '_' . ($maxSuffix + 1);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -243,6 +243,14 @@ class StructuralDBElementRepository extends AttachmentContainingDBElementReposit
|
|||
return $result[0];
|
||||
}
|
||||
|
||||
//If the name contains category delimiters like ->, try to find the element by its full path
|
||||
if (str_contains($name, '->')) {
|
||||
$tmp = $this->getEntityByPath($name, '->');
|
||||
if (count($tmp) > 0) {
|
||||
return $tmp[count($tmp) - 1];
|
||||
}
|
||||
}
|
||||
|
||||
//If we find nothing, return null
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ class FileTypeFilterTools
|
|||
{
|
||||
$filter = trim($filter);
|
||||
|
||||
return $this->cache->get('filter_exts_'.md5($filter), function (ItemInterface $item) use ($filter) {
|
||||
return $this->cache->get('filter_exts_'.hash('xxh3', $filter), function (ItemInterface $item) use ($filter) {
|
||||
$elements = explode(',', $filter);
|
||||
$extensions = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ class UserCacheKeyGenerator
|
|||
//If the user is null, then treat it as anonymous user.
|
||||
//When the anonymous user is passed as user then use this path too.
|
||||
if (!($user instanceof User) || User::ID_ANONYMOUS === $user->getID()) {
|
||||
return 'user$_'.User::ID_ANONYMOUS;
|
||||
return 'user$_'.User::ID_ANONYMOUS . '_'.$locale;
|
||||
}
|
||||
|
||||
//Use the unique user id and the locale to generate the key
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ class KiCadHelper
|
|||
"symbolIdStr" => $part->getEdaInfo()->getKicadSymbol() ?? $part->getCategory()?->getEdaInfo()->getKicadSymbol() ?? "",
|
||||
"exclude_from_bom" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBom() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBom() ?? false),
|
||||
"exclude_from_board" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBoard() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBoard() ?? false),
|
||||
"exclude_from_sim" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromSim() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromSim() ?? true),
|
||||
"exclude_from_sim" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromSim() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromSim() ?? false),
|
||||
"fields" => []
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -24,68 +24,31 @@ namespace App\Services;
|
|||
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Attachments\AttachmentContainingDBElement;
|
||||
use App\Entity\Attachments\AttachmentType;
|
||||
use App\Entity\Base\AbstractDBElement;
|
||||
use App\Entity\Contracts\NamedElementInterface;
|
||||
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
|
||||
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
|
||||
use App\Entity\LabelSystem\LabelProfile;
|
||||
use App\Entity\Parameters\AbstractParameter;
|
||||
use App\Entity\Parts\Category;
|
||||
use App\Entity\Parts\Footprint;
|
||||
use App\Entity\Parts\Manufacturer;
|
||||
use App\Entity\Parts\MeasurementUnit;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\PartAssociation;
|
||||
use App\Entity\Parts\PartCustomState;
|
||||
use App\Entity\Parts\PartLot;
|
||||
use App\Entity\Parts\StorageLocation;
|
||||
use App\Entity\Parts\Supplier;
|
||||
use App\Entity\PriceInformations\Currency;
|
||||
use App\Entity\PriceInformations\Orderdetail;
|
||||
use App\Entity\PriceInformations\Pricedetail;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||
use App\Entity\UserSystem\Group;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Exceptions\EntityNotSupportedException;
|
||||
use App\Settings\SynonymSettings;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* @see \App\Tests\Services\ElementTypeNameGeneratorTest
|
||||
*/
|
||||
class ElementTypeNameGenerator
|
||||
final readonly class ElementTypeNameGenerator
|
||||
{
|
||||
protected array $mapping;
|
||||
|
||||
public function __construct(protected TranslatorInterface $translator, private readonly EntityURLGenerator $entityURLGenerator)
|
||||
public function __construct(
|
||||
private TranslatorInterface $translator,
|
||||
private EntityURLGenerator $entityURLGenerator,
|
||||
private SynonymSettings $synonymsSettings,
|
||||
)
|
||||
{
|
||||
//Child classes has to become before parent classes
|
||||
$this->mapping = [
|
||||
Attachment::class => $this->translator->trans('attachment.label'),
|
||||
Category::class => $this->translator->trans('category.label'),
|
||||
AttachmentType::class => $this->translator->trans('attachment_type.label'),
|
||||
Project::class => $this->translator->trans('project.label'),
|
||||
ProjectBOMEntry::class => $this->translator->trans('project_bom_entry.label'),
|
||||
Footprint::class => $this->translator->trans('footprint.label'),
|
||||
Manufacturer::class => $this->translator->trans('manufacturer.label'),
|
||||
MeasurementUnit::class => $this->translator->trans('measurement_unit.label'),
|
||||
Part::class => $this->translator->trans('part.label'),
|
||||
PartLot::class => $this->translator->trans('part_lot.label'),
|
||||
StorageLocation::class => $this->translator->trans('storelocation.label'),
|
||||
Supplier::class => $this->translator->trans('supplier.label'),
|
||||
Currency::class => $this->translator->trans('currency.label'),
|
||||
Orderdetail::class => $this->translator->trans('orderdetail.label'),
|
||||
Pricedetail::class => $this->translator->trans('pricedetail.label'),
|
||||
Group::class => $this->translator->trans('group.label'),
|
||||
User::class => $this->translator->trans('user.label'),
|
||||
AbstractParameter::class => $this->translator->trans('parameter.label'),
|
||||
LabelProfile::class => $this->translator->trans('label_profile.label'),
|
||||
PartAssociation::class => $this->translator->trans('part_association.label'),
|
||||
BulkInfoProviderImportJob::class => $this->translator->trans('bulk_info_provider_import_job.label'),
|
||||
BulkInfoProviderImportJobPart::class => $this->translator->trans('bulk_info_provider_import_job_part.label'),
|
||||
PartCustomState::class => $this->translator->trans('part_custom_state.label'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -99,27 +62,69 @@ class ElementTypeNameGenerator
|
|||
* @return string the localized label for the entity type
|
||||
*
|
||||
* @throws EntityNotSupportedException when the passed entity is not supported
|
||||
* @deprecated Use label() instead
|
||||
*/
|
||||
public function getLocalizedTypeLabel(object|string $entity): string
|
||||
{
|
||||
$class = is_string($entity) ? $entity : $entity::class;
|
||||
|
||||
//Check if we have a direct array entry for our entity class, then we can use it
|
||||
if (isset($this->mapping[$class])) {
|
||||
return $this->mapping[$class];
|
||||
}
|
||||
|
||||
//Otherwise iterate over array and check for inheritance (needed when the proxy element from doctrine are passed)
|
||||
foreach ($this->mapping as $class_to_check => $translation) {
|
||||
if (is_a($entity, $class_to_check, true)) {
|
||||
return $translation;
|
||||
}
|
||||
}
|
||||
|
||||
//When nothing was found throw an exception
|
||||
throw new EntityNotSupportedException(sprintf('No localized label for the element with type %s was found!', is_object($entity) ? $entity::class : (string) $entity));
|
||||
return $this->typeLabel($entity);
|
||||
}
|
||||
|
||||
private function resolveSynonymLabel(ElementTypes $type, ?string $locale, bool $plural): ?string
|
||||
{
|
||||
$locale ??= $this->translator->getLocale();
|
||||
|
||||
if ($this->synonymsSettings->isSynonymDefinedForType($type)) {
|
||||
if ($plural) {
|
||||
$syn = $this->synonymsSettings->getPluralSynonymForType($type, $locale);
|
||||
} else {
|
||||
$syn = $this->synonymsSettings->getSingularSynonymForType($type, $locale);
|
||||
}
|
||||
|
||||
if ($syn === null) {
|
||||
//Try to fall back to english
|
||||
if ($plural) {
|
||||
$syn = $this->synonymsSettings->getPluralSynonymForType($type, 'en');
|
||||
} else {
|
||||
$syn = $this->synonymsSettings->getSingularSynonymForType($type, 'en');
|
||||
}
|
||||
}
|
||||
|
||||
return $syn;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a localized label for the type of the entity. If user defined synonyms are defined,
|
||||
* these are used instead of the default labels.
|
||||
* @param object|string $entity
|
||||
* @param string|null $locale
|
||||
* @return string
|
||||
*/
|
||||
public function typeLabel(object|string $entity, ?string $locale = null): string
|
||||
{
|
||||
$type = ElementTypes::fromValue($entity);
|
||||
|
||||
return $this->resolveSynonymLabel($type, $locale, false)
|
||||
?? $this->translator->trans($type->getDefaultLabelKey(), locale: $locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to label(), but returns the plural version of the label.
|
||||
* @param object|string $entity
|
||||
* @param string|null $locale
|
||||
* @return string
|
||||
*/
|
||||
public function typeLabelPlural(object|string $entity, ?string $locale = null): string
|
||||
{
|
||||
$type = ElementTypes::fromValue($entity);
|
||||
|
||||
return $this->resolveSynonymLabel($type, $locale, true)
|
||||
?? $this->translator->trans($type->getDefaultPluralLabelKey(), locale: $locale);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns a string like in the format ElementType: ElementName.
|
||||
* For example this could be something like: "Part: BC547".
|
||||
|
|
@ -134,7 +139,7 @@ class ElementTypeNameGenerator
|
|||
*/
|
||||
public function getTypeNameCombination(NamedElementInterface $entity, bool $use_html = false): string
|
||||
{
|
||||
$type = $this->getLocalizedTypeLabel($entity);
|
||||
$type = $this->typeLabel($entity);
|
||||
if ($use_html) {
|
||||
return '<i>' . $type . ':</i> ' . htmlspecialchars($entity->getName());
|
||||
}
|
||||
|
|
@ -144,7 +149,7 @@ class ElementTypeNameGenerator
|
|||
|
||||
|
||||
/**
|
||||
* Returns a HTML formatted label for the given enitity in the format "Type: Name" (on elements with a name) and
|
||||
* Returns a HTML formatted label for the given entity in the format "Type: Name" (on elements with a name) and
|
||||
* "Type: ID" (on elements without a name). If possible the value is given as a link to the element.
|
||||
* @param AbstractDBElement $entity The entity for which the label should be generated
|
||||
* @param bool $include_associated If set to true, the associated entity (like the part belonging to a part lot) is included in the label to give further information
|
||||
|
|
@ -165,7 +170,7 @@ class ElementTypeNameGenerator
|
|||
} else { //Target does not have a name
|
||||
$tmp = sprintf(
|
||||
'<i>%s</i>: %s',
|
||||
$this->getLocalizedTypeLabel($entity),
|
||||
$this->typeLabel($entity),
|
||||
$entity->getID()
|
||||
);
|
||||
}
|
||||
|
|
@ -209,7 +214,7 @@ class ElementTypeNameGenerator
|
|||
{
|
||||
return sprintf(
|
||||
'<i>%s</i>: %s [%s]',
|
||||
$this->getLocalizedTypeLabel($class),
|
||||
$this->typeLabel($class),
|
||||
$id,
|
||||
$this->translator->trans('log.target_deleted')
|
||||
);
|
||||
|
|
|
|||
229
src/Services/ElementTypes.php
Normal file
229
src/Services/ElementTypes.php
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
<?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;
|
||||
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Attachments\AttachmentType;
|
||||
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
|
||||
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
|
||||
use App\Entity\LabelSystem\LabelProfile;
|
||||
use App\Entity\Parameters\AbstractParameter;
|
||||
use App\Entity\Parts\Category;
|
||||
use App\Entity\Parts\Footprint;
|
||||
use App\Entity\Parts\Manufacturer;
|
||||
use App\Entity\Parts\MeasurementUnit;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\PartAssociation;
|
||||
use App\Entity\Parts\PartCustomState;
|
||||
use App\Entity\Parts\PartLot;
|
||||
use App\Entity\Parts\StorageLocation;
|
||||
use App\Entity\Parts\Supplier;
|
||||
use App\Entity\PriceInformations\Currency;
|
||||
use App\Entity\PriceInformations\Orderdetail;
|
||||
use App\Entity\PriceInformations\Pricedetail;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||
use App\Entity\UserSystem\Group;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Exceptions\EntityNotSupportedException;
|
||||
use Symfony\Contracts\Translation\TranslatableInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
enum ElementTypes: string implements TranslatableInterface
|
||||
{
|
||||
case ATTACHMENT = "attachment";
|
||||
case CATEGORY = "category";
|
||||
case ATTACHMENT_TYPE = "attachment_type";
|
||||
case PROJECT = "project";
|
||||
case PROJECT_BOM_ENTRY = "project_bom_entry";
|
||||
case FOOTPRINT = "footprint";
|
||||
case MANUFACTURER = "manufacturer";
|
||||
case MEASUREMENT_UNIT = "measurement_unit";
|
||||
case PART = "part";
|
||||
case PART_LOT = "part_lot";
|
||||
case STORAGE_LOCATION = "storage_location";
|
||||
case SUPPLIER = "supplier";
|
||||
case CURRENCY = "currency";
|
||||
case ORDERDETAIL = "orderdetail";
|
||||
case PRICEDETAIL = "pricedetail";
|
||||
case GROUP = "group";
|
||||
case USER = "user";
|
||||
case PARAMETER = "parameter";
|
||||
case LABEL_PROFILE = "label_profile";
|
||||
case PART_ASSOCIATION = "part_association";
|
||||
case BULK_INFO_PROVIDER_IMPORT_JOB = "bulk_info_provider_import_job";
|
||||
case BULK_INFO_PROVIDER_IMPORT_JOB_PART = "bulk_info_provider_import_job_part";
|
||||
case PART_CUSTOM_STATE = "part_custom_state";
|
||||
|
||||
//Child classes has to become before parent classes
|
||||
private const CLASS_MAPPING = [
|
||||
Attachment::class => self::ATTACHMENT,
|
||||
Category::class => self::CATEGORY,
|
||||
AttachmentType::class => self::ATTACHMENT_TYPE,
|
||||
Project::class => self::PROJECT,
|
||||
ProjectBOMEntry::class => self::PROJECT_BOM_ENTRY,
|
||||
Footprint::class => self::FOOTPRINT,
|
||||
Manufacturer::class => self::MANUFACTURER,
|
||||
MeasurementUnit::class => self::MEASUREMENT_UNIT,
|
||||
Part::class => self::PART,
|
||||
PartLot::class => self::PART_LOT,
|
||||
StorageLocation::class => self::STORAGE_LOCATION,
|
||||
Supplier::class => self::SUPPLIER,
|
||||
Currency::class => self::CURRENCY,
|
||||
Orderdetail::class => self::ORDERDETAIL,
|
||||
Pricedetail::class => self::PRICEDETAIL,
|
||||
Group::class => self::GROUP,
|
||||
User::class => self::USER,
|
||||
AbstractParameter::class => self::PARAMETER,
|
||||
LabelProfile::class => self::LABEL_PROFILE,
|
||||
PartAssociation::class => self::PART_ASSOCIATION,
|
||||
BulkInfoProviderImportJob::class => self::BULK_INFO_PROVIDER_IMPORT_JOB,
|
||||
BulkInfoProviderImportJobPart::class => self::BULK_INFO_PROVIDER_IMPORT_JOB_PART,
|
||||
PartCustomState::class => self::PART_CUSTOM_STATE,
|
||||
];
|
||||
|
||||
/**
|
||||
* Gets the default translation key for the label of the element type (singular form).
|
||||
*/
|
||||
public function getDefaultLabelKey(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::ATTACHMENT => 'attachment.label',
|
||||
self::CATEGORY => 'category.label',
|
||||
self::ATTACHMENT_TYPE => 'attachment_type.label',
|
||||
self::PROJECT => 'project.label',
|
||||
self::PROJECT_BOM_ENTRY => 'project_bom_entry.label',
|
||||
self::FOOTPRINT => 'footprint.label',
|
||||
self::MANUFACTURER => 'manufacturer.label',
|
||||
self::MEASUREMENT_UNIT => 'measurement_unit.label',
|
||||
self::PART => 'part.label',
|
||||
self::PART_LOT => 'part_lot.label',
|
||||
self::STORAGE_LOCATION => 'storelocation.label',
|
||||
self::SUPPLIER => 'supplier.label',
|
||||
self::CURRENCY => 'currency.label',
|
||||
self::ORDERDETAIL => 'orderdetail.label',
|
||||
self::PRICEDETAIL => 'pricedetail.label',
|
||||
self::GROUP => 'group.label',
|
||||
self::USER => 'user.label',
|
||||
self::PARAMETER => 'parameter.label',
|
||||
self::LABEL_PROFILE => 'label_profile.label',
|
||||
self::PART_ASSOCIATION => 'part_association.label',
|
||||
self::BULK_INFO_PROVIDER_IMPORT_JOB => 'bulk_info_provider_import_job.label',
|
||||
self::BULK_INFO_PROVIDER_IMPORT_JOB_PART => 'bulk_info_provider_import_job_part.label',
|
||||
self::PART_CUSTOM_STATE => 'part_custom_state.label',
|
||||
};
|
||||
}
|
||||
|
||||
public function getDefaultPluralLabelKey(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::ATTACHMENT => 'attachment.labelp',
|
||||
self::CATEGORY => 'category.labelp',
|
||||
self::ATTACHMENT_TYPE => 'attachment_type.labelp',
|
||||
self::PROJECT => 'project.labelp',
|
||||
self::PROJECT_BOM_ENTRY => 'project_bom_entry.labelp',
|
||||
self::FOOTPRINT => 'footprint.labelp',
|
||||
self::MANUFACTURER => 'manufacturer.labelp',
|
||||
self::MEASUREMENT_UNIT => 'measurement_unit.labelp',
|
||||
self::PART => 'part.labelp',
|
||||
self::PART_LOT => 'part_lot.labelp',
|
||||
self::STORAGE_LOCATION => 'storelocation.labelp',
|
||||
self::SUPPLIER => 'supplier.labelp',
|
||||
self::CURRENCY => 'currency.labelp',
|
||||
self::ORDERDETAIL => 'orderdetail.labelp',
|
||||
self::PRICEDETAIL => 'pricedetail.labelp',
|
||||
self::GROUP => 'group.labelp',
|
||||
self::USER => 'user.labelp',
|
||||
self::PARAMETER => 'parameter.labelp',
|
||||
self::LABEL_PROFILE => 'label_profile.labelp',
|
||||
self::PART_ASSOCIATION => 'part_association.labelp',
|
||||
self::BULK_INFO_PROVIDER_IMPORT_JOB => 'bulk_info_provider_import_job.labelp',
|
||||
self::BULK_INFO_PROVIDER_IMPORT_JOB_PART => 'bulk_info_provider_import_job_part.labelp',
|
||||
self::PART_CUSTOM_STATE => 'part_custom_state.labelp',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to get a user-friendly representation of the object that can be translated.
|
||||
* For this the singular default label key is used.
|
||||
* @param TranslatorInterface $translator
|
||||
* @param string|null $locale
|
||||
* @return string
|
||||
*/
|
||||
public function trans(TranslatorInterface $translator, ?string $locale = null): string
|
||||
{
|
||||
return $translator->trans($this->getDefaultLabelKey(), locale: $locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the ElementType from a value, which can either be an enum value, an ElementTypes instance, a class name or an object instance.
|
||||
* @param string|object $value
|
||||
* @return self
|
||||
*/
|
||||
public static function fromValue(string|object $value): self
|
||||
{
|
||||
if ($value instanceof self) {
|
||||
return $value;
|
||||
}
|
||||
if (is_object($value)) {
|
||||
return self::fromClass($value);
|
||||
}
|
||||
|
||||
|
||||
//Otherwise try to parse it as enum value first
|
||||
$enumValue = self::tryFrom($value);
|
||||
|
||||
//Otherwise try to get it from class name
|
||||
return $enumValue ?? self::fromClass($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the ElementType from a class name or object instance.
|
||||
* @param string|object $class
|
||||
* @throws EntityNotSupportedException if the class is not supported
|
||||
* @return self
|
||||
*/
|
||||
public static function fromClass(string|object $class): self
|
||||
{
|
||||
if (is_object($class)) {
|
||||
$className = get_class($class);
|
||||
} else {
|
||||
$className = $class;
|
||||
}
|
||||
|
||||
if (array_key_exists($className, self::CLASS_MAPPING)) {
|
||||
return self::CLASS_MAPPING[$className];
|
||||
}
|
||||
|
||||
//Otherwise we need to check for inheritance
|
||||
foreach (self::CLASS_MAPPING as $entityClass => $elementType) {
|
||||
if (is_a($className, $entityClass, true)) {
|
||||
return $elementType;
|
||||
}
|
||||
}
|
||||
|
||||
throw new EntityNotSupportedException(sprintf('No localized label for the element with type %s was found!', $className));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -24,7 +24,7 @@ declare(strict_types=1);
|
|||
namespace App\Services\EntityMergers;
|
||||
|
||||
use App\Services\EntityMergers\Mergers\EntityMergerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
|
||||
|
||||
/**
|
||||
* This service is used to merge two entities together.
|
||||
|
|
@ -32,7 +32,7 @@ use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
|
|||
*/
|
||||
class EntityMerger
|
||||
{
|
||||
public function __construct(#[TaggedIterator('app.entity_merger')] protected iterable $mergers)
|
||||
public function __construct(#[AutowireIterator('app.entity_merger')] protected iterable $mergers)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -73,4 +73,4 @@ class EntityMerger
|
|||
}
|
||||
return $merger->merge($target, $other, $context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ class BOMImporter
|
|||
|
||||
private function parseKiCADPCB(string $data): array
|
||||
{
|
||||
$csv = Reader::createFromString($data);
|
||||
$csv = Reader::fromString($data);
|
||||
$csv->setDelimiter(';');
|
||||
$csv->setHeaderOffset(0);
|
||||
|
||||
|
|
@ -175,7 +175,7 @@ class BOMImporter
|
|||
*/
|
||||
private function validateKiCADPCB(string $data): array
|
||||
{
|
||||
$csv = Reader::createFromString($data);
|
||||
$csv = Reader::fromString($data);
|
||||
$csv->setDelimiter(';');
|
||||
$csv->setHeaderOffset(0);
|
||||
|
||||
|
|
@ -202,7 +202,7 @@ class BOMImporter
|
|||
// Handle potential BOM (Byte Order Mark) at the beginning
|
||||
$data = preg_replace('/^\xEF\xBB\xBF/', '', $data);
|
||||
|
||||
$csv = Reader::createFromString($data);
|
||||
$csv = Reader::fromString($data);
|
||||
$csv->setDelimiter($delimiter);
|
||||
$csv->setHeaderOffset(0);
|
||||
|
||||
|
|
@ -262,7 +262,7 @@ class BOMImporter
|
|||
// Handle potential BOM (Byte Order Mark) at the beginning
|
||||
$data = preg_replace('/^\xEF\xBB\xBF/', '', $data);
|
||||
|
||||
$csv = Reader::createFromString($data);
|
||||
$csv = Reader::fromString($data);
|
||||
$csv->setDelimiter($delimiter);
|
||||
$csv->setHeaderOffset(0);
|
||||
|
||||
|
|
@ -274,6 +274,16 @@ class BOMImporter
|
|||
$entries_by_key = []; // Track entries by name+part combination
|
||||
$mapped_entries = []; // Collect all mapped entries for validation
|
||||
|
||||
// Fetch suppliers once for efficiency
|
||||
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
|
||||
$supplierSPNKeys = [];
|
||||
$suppliersByName = []; // Map supplier names to supplier objects
|
||||
foreach ($suppliers as $supplier) {
|
||||
$supplierName = $supplier->getName();
|
||||
$supplierSPNKeys[] = $supplierName . ' SPN';
|
||||
$suppliersByName[$supplierName] = $supplier;
|
||||
}
|
||||
|
||||
foreach ($csv->getRecords() as $offset => $entry) {
|
||||
// Apply field mapping to translate column names
|
||||
$mapped_entry = $this->applyFieldMapping($entry, $field_mapping, $field_priorities);
|
||||
|
|
@ -349,6 +359,41 @@ class BOMImporter
|
|||
}
|
||||
}
|
||||
|
||||
// Try to link existing part based on supplier part number if no Part-DB ID is given
|
||||
if ($part === null) {
|
||||
// Check all available supplier SPN fields
|
||||
foreach ($suppliersByName as $supplierName => $supplier) {
|
||||
$supplier_spn = null;
|
||||
|
||||
if (isset($mapped_entry[$supplierName . ' SPN']) && !empty(trim($mapped_entry[$supplierName . ' SPN']))) {
|
||||
$supplier_spn = trim($mapped_entry[$supplierName . ' SPN']);
|
||||
}
|
||||
|
||||
if ($supplier_spn !== null) {
|
||||
// Query for orderdetails with matching supplier and SPN
|
||||
$orderdetail = $this->entityManager->getRepository(\App\Entity\PriceInformations\Orderdetail::class)
|
||||
->findOneBy([
|
||||
'supplier' => $supplier,
|
||||
'supplierpartnr' => $supplier_spn,
|
||||
]);
|
||||
|
||||
if ($orderdetail !== null && $orderdetail->getPart() !== null) {
|
||||
$part = $orderdetail->getPart();
|
||||
$name = $part->getName(); // Update name with actual part name
|
||||
|
||||
$this->logger->info('Linked BOM entry to existing part via supplier SPN', [
|
||||
'supplier' => $supplierName,
|
||||
'supplier_spn' => $supplier_spn,
|
||||
'part_id' => $part->getID(),
|
||||
'part_name' => $part->getName(),
|
||||
]);
|
||||
|
||||
break; // Stop searching once a match is found
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create unique key for this entry (name + part ID)
|
||||
$entry_key = $name . '|' . ($part ? $part->getID() : 'null');
|
||||
|
||||
|
|
@ -400,9 +445,14 @@ class BOMImporter
|
|||
if (isset($mapped_entry['Manufacturer'])) {
|
||||
$comment_parts[] = 'Manf: ' . $mapped_entry['Manufacturer'];
|
||||
}
|
||||
if (isset($mapped_entry['LCSC'])) {
|
||||
$comment_parts[] = 'LCSC: ' . $mapped_entry['LCSC'];
|
||||
|
||||
// Add supplier part numbers dynamically
|
||||
foreach ($supplierSPNKeys as $spnKey) {
|
||||
if (isset($mapped_entry[$spnKey]) && !empty($mapped_entry[$spnKey])) {
|
||||
$comment_parts[] = $spnKey . ': ' . $mapped_entry[$spnKey];
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($mapped_entry['Supplier and ref'])) {
|
||||
$comment_parts[] = $mapped_entry['Supplier and ref'];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ class EntityImporter
|
|||
}
|
||||
|
||||
//Only return objects once
|
||||
return array_values(array_unique($valid_entities));
|
||||
return array_values(array_unique($valid_entities, SORT_REGULAR));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ class PKDatastructureImporter
|
|||
public function importPartCustomStates(array $data): int
|
||||
{
|
||||
if (!isset($data['partcustomstate'])) {
|
||||
throw new \RuntimeException('$data must contain a "partcustomstate" key!');
|
||||
return 0; //Not all PartKeepr installations have custom states
|
||||
}
|
||||
|
||||
$partCustomStateData = $data['partcustomstate'];
|
||||
|
|
|
|||
|
|
@ -39,10 +39,10 @@ class PKImportHelper
|
|||
* Existing users and groups are not purged.
|
||||
* This is needed to avoid ID collisions.
|
||||
*/
|
||||
public function purgeDatabaseForImport(): void
|
||||
public function purgeDatabaseForImport(?EntityManagerInterface $entityManager = null, array $excluded_tables = ['users', 'groups', 'u2f_keys', 'internal', 'migration_versions']): void
|
||||
{
|
||||
//We use the ResetAutoIncrementORMPurger to reset the auto increment values of the tables. Also it normalizes table names before checking for exclusion.
|
||||
$purger = new ResetAutoIncrementORMPurger($this->em, ['users', 'groups', 'u2f_keys', 'internal', 'migration_versions']);
|
||||
$purger = new ResetAutoIncrementORMPurger($entityManager ?? $this->em, $excluded_tables);
|
||||
$purger->purge();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -150,6 +150,11 @@ trait PKImportHelperTrait
|
|||
|
||||
$target->addAttachment($attachment);
|
||||
$this->em->persist($attachment);
|
||||
|
||||
//If the attachment is an image, and the target has no master picture yet, set it
|
||||
if ($attachment->isPicture() && $target->getMasterPictureAttachment() === null) {
|
||||
$target->setMasterPictureAttachment($attachment);
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
|
|
|||
|
|
@ -91,7 +91,10 @@ class PKPartImporter
|
|||
$this->setAssociationField($entity, 'partUnit', MeasurementUnit::class, $part['partUnit_id']);
|
||||
}
|
||||
|
||||
$this->setAssociationField($entity, 'partCustomState', MeasurementUnit::class, $part['partCustomState_id']);
|
||||
if (isset($part['partCustomState_id'])) {
|
||||
$this->setAssociationField($entity, 'partCustomState', MeasurementUnit::class,
|
||||
$part['partCustomState_id']);
|
||||
}
|
||||
|
||||
//Create a part lot to store the stock level and location
|
||||
$lot = new PartLot();
|
||||
|
|
|
|||
|
|
@ -22,24 +22,41 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Services\InfoProviderSystem\DTOs;
|
||||
|
||||
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
||||
|
||||
/**
|
||||
* Represents a mapping between a part field and the info providers that should search in that field.
|
||||
*/
|
||||
readonly class BulkSearchFieldMappingDTO
|
||||
{
|
||||
/** @var string[] $providers Array of provider keys to search with (e.g., ['digikey', 'farnell']) */
|
||||
public array $providers;
|
||||
|
||||
/**
|
||||
* @param string $field The field to search in (e.g., 'mpn', 'name', or supplier-specific fields like 'digikey_spn')
|
||||
* @param string[] $providers Array of provider keys to search with (e.g., ['digikey', 'farnell'])
|
||||
* @param string[]|InfoProviderInterface[] $providers Array of provider keys to search with (e.g., ['digikey', 'farnell'])
|
||||
* @param int $priority Priority for this field mapping (1-10, lower numbers = higher priority)
|
||||
*/
|
||||
public function __construct(
|
||||
public string $field,
|
||||
public array $providers,
|
||||
array $providers = [],
|
||||
public int $priority = 1
|
||||
) {
|
||||
if ($priority < 1 || $priority > 10) {
|
||||
throw new \InvalidArgumentException('Priority must be between 1 and 10');
|
||||
}
|
||||
|
||||
//Ensure that providers are provided as keys
|
||||
foreach ($providers as &$provider) {
|
||||
if ($provider instanceof InfoProviderInterface) {
|
||||
$provider = $provider->getProviderKey();
|
||||
}
|
||||
if (!is_string($provider)) {
|
||||
throw new \InvalidArgumentException('Providers must be provided as strings or InfoProviderInterface instances');
|
||||
}
|
||||
}
|
||||
unset($provider);
|
||||
$this->providers = $providers;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ declare(strict_types=1);
|
|||
namespace App\Services\InfoProviderSystem;
|
||||
|
||||
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
||||
use App\Services\InfoProviderSystem\Providers\URLHandlerInfoProviderInterface;
|
||||
|
||||
/**
|
||||
* This class keeps track of all registered info providers and allows to find them by their key
|
||||
|
|
@ -47,6 +48,8 @@ final class ProviderRegistry
|
|||
*/
|
||||
private array $providers_disabled = [];
|
||||
|
||||
private array $providers_by_domain = [];
|
||||
|
||||
/**
|
||||
* @var bool Whether the registry has been initialized
|
||||
*/
|
||||
|
|
@ -78,6 +81,14 @@ final class ProviderRegistry
|
|||
$this->providers_by_name[$key] = $provider;
|
||||
if ($provider->isActive()) {
|
||||
$this->providers_active[$key] = $provider;
|
||||
if ($provider instanceof URLHandlerInfoProviderInterface) {
|
||||
foreach ($provider->getHandledDomains() as $domain) {
|
||||
if (isset($this->providers_by_domain[$domain])) {
|
||||
throw new \LogicException("Domain $domain is already handled by another provider");
|
||||
}
|
||||
$this->providers_by_domain[$domain] = $provider;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->providers_disabled[$key] = $provider;
|
||||
}
|
||||
|
|
@ -139,4 +150,29 @@ final class ProviderRegistry
|
|||
|
||||
return $this->providers_disabled;
|
||||
}
|
||||
}
|
||||
|
||||
public function getProviderHandlingDomain(string $domain): (InfoProviderInterface&URLHandlerInfoProviderInterface)|null
|
||||
{
|
||||
if (!$this->initialized) {
|
||||
$this->initStructures();
|
||||
}
|
||||
|
||||
//Check if the domain is directly existing:
|
||||
if (isset($this->providers_by_domain[$domain])) {
|
||||
return $this->providers_by_domain[$domain];
|
||||
}
|
||||
|
||||
//Otherwise check for subdomains:
|
||||
$parts = explode('.', $domain);
|
||||
while (count($parts) > 2) {
|
||||
array_shift($parts);
|
||||
$check_domain = implode('.', $parts);
|
||||
if (isset($this->providers_by_domain[$check_domain])) {
|
||||
return $this->providers_by_domain[$check_domain];
|
||||
}
|
||||
}
|
||||
|
||||
//If we found nothing, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
670
src/Services/InfoProviderSystem/Providers/BuerklinProvider.php
Normal file
670
src/Services/InfoProviderSystem/Providers/BuerklinProvider.php
Normal file
|
|
@ -0,0 +1,670 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
* Copyright (C) 2025 Marc Kreidler (https://github.com/mkne)
|
||||
*
|
||||
* 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 App\Settings\InfoProviderSystem\BuerklinSettings;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProviderInterface
|
||||
{
|
||||
|
||||
private const ENDPOINT_URL = 'https://www.buerklin.com/buerklinws/v2/buerklin';
|
||||
|
||||
public const DISTRIBUTOR_NAME = 'Buerklin';
|
||||
|
||||
private const CACHE_TTL = 600;
|
||||
/**
|
||||
* Local in-request cache to avoid hitting the PSR cache repeatedly for the same product.
|
||||
* @var array<string, array>
|
||||
*/
|
||||
private array $productCache = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $client,
|
||||
private readonly CacheItemPoolInterface $partInfoCache,
|
||||
private readonly BuerklinSettings $settings,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the latest OAuth token for the Buerklin API, or creates a new one if none is available
|
||||
* TODO: Rework this to use the OAuth token manager system in the database...
|
||||
* @return string
|
||||
*/
|
||||
private function getToken(): string
|
||||
{
|
||||
// Cache token to avoid hammering the auth server on every request
|
||||
$cacheKey = 'buerklin.oauth.token';
|
||||
$item = $this->partInfoCache->getItem($cacheKey);
|
||||
|
||||
if ($item->isHit()) {
|
||||
$token = $item->get();
|
||||
if (is_string($token) && $token !== '') {
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
|
||||
// Buerklin OAuth2 password grant (ROPC)
|
||||
$resp = $this->client->request('POST', 'https://www.buerklin.com/authorizationserver/oauth/token/', [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
],
|
||||
'body' => [
|
||||
'grant_type' => 'password',
|
||||
'client_id' => $this->settings->clientId,
|
||||
'client_secret' => $this->settings->secret,
|
||||
'username' => $this->settings->username,
|
||||
'password' => $this->settings->password,
|
||||
],
|
||||
]);
|
||||
|
||||
$data = $resp->toArray(false);
|
||||
|
||||
if (!isset($data['access_token'])) {
|
||||
throw new \RuntimeException(
|
||||
'Invalid token response from Buerklin: HTTP ' . $resp->getStatusCode() . ' body=' . $resp->getContent(false)
|
||||
);
|
||||
}
|
||||
|
||||
$token = (string) $data['access_token'];
|
||||
|
||||
// Cache for (expires_in - 30s) if available
|
||||
$ttl = 300;
|
||||
if (isset($data['expires_in']) && is_numeric($data['expires_in'])) {
|
||||
$ttl = max(60, (int) $data['expires_in'] - 30);
|
||||
}
|
||||
|
||||
$item->set($token);
|
||||
$item->expiresAfter($ttl);
|
||||
$this->partInfoCache->save($item);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function getDefaultQueryParams(): array
|
||||
{
|
||||
return [
|
||||
'curr' => $this->settings->currency ?: 'EUR',
|
||||
'language' => $this->settings->language ?: 'en',
|
||||
];
|
||||
}
|
||||
|
||||
private function getProduct(string $code): array
|
||||
{
|
||||
$code = strtoupper(trim($code));
|
||||
if ($code === '') {
|
||||
throw new \InvalidArgumentException('Product code must not be empty.');
|
||||
}
|
||||
|
||||
$cacheKey = sprintf(
|
||||
'buerklin.product.%s',
|
||||
md5($code . '|' . $this->settings->language . '|' . $this->settings->currency)
|
||||
);
|
||||
|
||||
if (isset($this->productCache[$cacheKey])) {
|
||||
return $this->productCache[$cacheKey];
|
||||
}
|
||||
|
||||
$item = $this->partInfoCache->getItem($cacheKey);
|
||||
if ($item->isHit() && is_array($cached = $item->get())) {
|
||||
return $this->productCache[$cacheKey] = $cached;
|
||||
}
|
||||
|
||||
$product = $this->makeAPICall('/products/' . rawurlencode($code) . '/');
|
||||
|
||||
$item->set($product);
|
||||
$item->expiresAfter(self::CACHE_TTL);
|
||||
$this->partInfoCache->save($item);
|
||||
|
||||
return $this->productCache[$cacheKey] = $product;
|
||||
}
|
||||
|
||||
private function makeAPICall(string $endpoint, array $queryParams = []): array
|
||||
{
|
||||
try {
|
||||
$response = $this->client->request('GET', self::ENDPOINT_URL . $endpoint, [
|
||||
'auth_bearer' => $this->getToken(),
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
'query' => array_merge($this->getDefaultQueryParams(), $queryParams),
|
||||
]);
|
||||
|
||||
return $response->toArray();
|
||||
} catch (\Exception $e) {
|
||||
throw new \RuntimeException("Buerklin API request failed: " .
|
||||
"Endpoint: " . $endpoint .
|
||||
"Token: [redacted] " .
|
||||
"QueryParams: " . json_encode($queryParams, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . " " .
|
||||
"Exception message: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function getProviderInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Buerklin',
|
||||
'description' => 'This provider uses the Buerklin API to search for parts.',
|
||||
'url' => 'https://www.buerklin.com/',
|
||||
'disabled_help' => 'Configure the API Client ID, Secret, Username and Password provided by Buerklin in the provider settings to enable.',
|
||||
'settings_class' => BuerklinSettings::class
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'buerklin';
|
||||
}
|
||||
|
||||
// This provider is considered active if settings are present
|
||||
public function isActive(): bool
|
||||
{
|
||||
// The client credentials and user credentials must be set
|
||||
return $this->settings->clientId !== null && $this->settings->clientId !== ''
|
||||
&& $this->settings->secret !== null && $this->settings->secret !== ''
|
||||
&& $this->settings->username !== null && $this->settings->username !== ''
|
||||
&& $this->settings->password !== null && $this->settings->password !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a field by removing any HTML tags and other unwanted characters
|
||||
* @param string|null $field
|
||||
* @return string|null
|
||||
*/
|
||||
private function sanitizeField(?string $field): ?string
|
||||
{
|
||||
if ($field === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return strip_tags($field);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a deserialized JSON object of the product and returns a PartDetailDTO
|
||||
* @param array $product
|
||||
* @return PartDetailDTO
|
||||
*/
|
||||
private function getPartDetail(array $product): PartDetailDTO
|
||||
{
|
||||
// If this is a search-result object, it may not contain prices/features/images -> reload full details.
|
||||
if ((!isset($product['price']) && !isset($product['volumePrices'])) && isset($product['code'])) {
|
||||
try {
|
||||
$product = $this->getProduct((string) $product['code']);
|
||||
} catch (\Throwable $e) {
|
||||
// If reload fails, keep the partial product data and continue.
|
||||
}
|
||||
}
|
||||
|
||||
// Extract images from API response
|
||||
$productImages = $this->getProductImages($product['images'] ?? null);
|
||||
|
||||
// Set preview image
|
||||
$preview = $productImages[0]->url ?? null;
|
||||
|
||||
// Extract features (parameters) from classifications[0].features of Buerklin JSON response
|
||||
$features = $product['classifications'][0]['features'] ?? [];
|
||||
|
||||
// Feature parameters (from classifications->features)
|
||||
$featureParams = $this->attributesToParameters($features, ''); // leave group empty for normal parameters
|
||||
|
||||
// Compliance parameters (from top-level fields like RoHS/SVHC/…)
|
||||
$complianceParams = $this->complianceToParameters($product, 'Compliance');
|
||||
|
||||
// Merge all parameters
|
||||
$allParams = array_merge($featureParams, $complianceParams);
|
||||
|
||||
// Assign footprint: "Design" (en) / "Bauform" (de) / "Enclosure" (en) / "Gehäuse" (de)
|
||||
$footprint = null;
|
||||
if (is_array($features)) {
|
||||
foreach ($features as $feature) {
|
||||
$name = $feature['name'] ?? null;
|
||||
if ($name === 'Design' || $name === 'Bauform' || $name === 'Enclosure' || $name === 'Gehäuse') {
|
||||
$footprint = $feature['featureValues'][0]['value'] ?? null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prices: prefer volumePrices, fallback to single price
|
||||
$code = (string) ($product['orderNumber'] ?? $product['code'] ?? '');
|
||||
$prices = $product['volumePrices'] ?? null;
|
||||
|
||||
if (!is_array($prices) || count($prices) === 0) {
|
||||
$pVal = $product['price']['value'] ?? null;
|
||||
$pCur = $product['price']['currencyIso'] ?? ($this->settings->currency ?: 'EUR');
|
||||
|
||||
if (is_numeric($pVal)) {
|
||||
$prices = [
|
||||
[
|
||||
'minQuantity' => 1,
|
||||
'value' => (float) $pVal,
|
||||
'currencyIso' => (string) $pCur,
|
||||
]
|
||||
];
|
||||
} else {
|
||||
$prices = [];
|
||||
}
|
||||
}
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: (string) ($product['code'] ?? $code),
|
||||
|
||||
name: (string) ($product['manufacturerProductId'] ?? $code),
|
||||
description: $this->sanitizeField($product['description'] ?? null),
|
||||
|
||||
category: $this->sanitizeField($product['classifications'][0]['name'] ?? ($product['categories'][0]['name'] ?? null)),
|
||||
manufacturer: $this->sanitizeField($product['manufacturer'] ?? null),
|
||||
mpn: $this->sanitizeField($product['manufacturerProductId'] ?? null),
|
||||
|
||||
preview_image_url: $preview,
|
||||
manufacturing_status: null,
|
||||
|
||||
provider_url: $this->getProductShortURL((string) ($product['code'] ?? $code)),
|
||||
footprint: $footprint,
|
||||
|
||||
datasheets: null, // not found in JSON response, the Buerklin website however has links to datasheets
|
||||
images: $productImages,
|
||||
|
||||
parameters: $allParams,
|
||||
|
||||
vendor_infos: $this->pricesToVendorInfo(
|
||||
sku: $code,
|
||||
url: $this->getProductShortURL($code),
|
||||
prices: $prices
|
||||
),
|
||||
|
||||
mass: $product['weight'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the price array to a VendorInfoDTO array to be used in the PartDetailDTO
|
||||
* @param string $sku
|
||||
* @param string $url
|
||||
* @param array $prices
|
||||
* @return array
|
||||
*/
|
||||
private function pricesToVendorInfo(string $sku, string $url, array $prices): array
|
||||
{
|
||||
$priceDTOs = array_map(function ($price) {
|
||||
$val = $price['value'] ?? null;
|
||||
$valStr = is_numeric($val)
|
||||
? number_format((float) $val, 6, '.', '') // 6 decimal places, trailing zeros are fine
|
||||
: (string) $val;
|
||||
|
||||
// Optional: softly trim unnecessary trailing zeros (e.g. 75.550000 -> 75.55)
|
||||
$valStr = rtrim(rtrim($valStr, '0'), '.');
|
||||
|
||||
return new PriceDTO(
|
||||
minimum_discount_amount: (float) ($price['minQuantity'] ?? 1),
|
||||
price: $valStr,
|
||||
currency_iso_code: (string) ($price['currencyIso'] ?? $this->settings->currency ?? 'EUR'),
|
||||
includes_tax: false
|
||||
);
|
||||
}, $prices);
|
||||
|
||||
return [
|
||||
new PurchaseInfoDTO(
|
||||
distributor_name: self::DISTRIBUTOR_NAME,
|
||||
order_number: $sku,
|
||||
prices: $priceDTOs,
|
||||
product_url: $url,
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns a valid Buerklin product short URL from product code
|
||||
* @param string $product_code
|
||||
* @return string
|
||||
*/
|
||||
private function getProductShortURL(string $product_code): string
|
||||
{
|
||||
return 'https://www.buerklin.com/' . $this->settings->language . '/p/' . $product_code . '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a deduplicated list of product images as FileDTOs.
|
||||
*
|
||||
* - takes only real image arrays (with 'url' field)
|
||||
* - makes relative URLs absolute
|
||||
* - deduplicates using URL
|
||||
* - prefers 'zoom' format, then 'product' format, then all others
|
||||
*
|
||||
* @param array|null $images
|
||||
* @return \App\Services\InfoProviderSystem\DTOs\FileDTO[]
|
||||
*/
|
||||
private function getProductImages(?array $images): array
|
||||
{
|
||||
if (!is_array($images)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 1) Only real image entries with URL
|
||||
$imgs = array_values(array_filter($images, fn($i) => is_array($i) && !empty($i['url'])));
|
||||
|
||||
// 2) Prefer zoom images
|
||||
$zoom = array_values(array_filter($imgs, fn($i) => ($i['format'] ?? null) === 'zoom'));
|
||||
$chosen = count($zoom) > 0
|
||||
? $zoom
|
||||
: array_values(array_filter($imgs, fn($i) => ($i['format'] ?? null) === 'product'));
|
||||
|
||||
// 3) If still none, take all
|
||||
if (count($chosen) === 0) {
|
||||
$chosen = $imgs;
|
||||
}
|
||||
|
||||
// 4) Deduplicate by URL (after making absolute)
|
||||
$byUrl = [];
|
||||
foreach ($chosen as $img) {
|
||||
$url = (string) $img['url'];
|
||||
|
||||
if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
|
||||
$url = 'https://www.buerklin.com' . $url;
|
||||
}
|
||||
if (!filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$byUrl[$url] = $url;
|
||||
}
|
||||
|
||||
return array_map(
|
||||
fn($url) => new FileDTO($url),
|
||||
array_values($byUrl)
|
||||
);
|
||||
}
|
||||
|
||||
private function attributesToParameters(array $features, ?string $group = null): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach ($features as $f) {
|
||||
if (!is_array($f)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $f['name'] ?? null;
|
||||
if (!is_string($name) || trim($name) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$vals = [];
|
||||
foreach (($f['featureValues'] ?? []) as $fv) {
|
||||
if (is_array($fv) && isset($fv['value']) && is_string($fv['value']) && trim($fv['value']) !== '') {
|
||||
$vals[] = trim($fv['value']);
|
||||
}
|
||||
}
|
||||
if (empty($vals)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Multiple values: join with comma
|
||||
$value = implode(', ', array_values(array_unique($vals)));
|
||||
|
||||
// Unit/symbol from Buerklin feature
|
||||
$unit = $f['featureUnit']['symbol'] ?? null;
|
||||
if (!is_string($unit) || trim($unit) === '') {
|
||||
$unit = null;
|
||||
}
|
||||
|
||||
// ParameterDTO parses value field (handles value + unit)
|
||||
$out[] = ParameterDTO::parseValueField(
|
||||
name: $name,
|
||||
value: $value,
|
||||
unit: $unit,
|
||||
symbol: null,
|
||||
group: $group
|
||||
);
|
||||
}
|
||||
|
||||
// Deduplicate by name
|
||||
$byName = [];
|
||||
foreach ($out as $p) {
|
||||
$byName[$p->name] ??= $p;
|
||||
}
|
||||
|
||||
return array_values($byName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PartDetailDTO[]
|
||||
*/
|
||||
public function searchByKeyword(string $keyword): array
|
||||
{
|
||||
$keyword = strtoupper(trim($keyword));
|
||||
if ($keyword === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$response = $this->makeAPICall('/products/search/', [
|
||||
'pageSize' => 50,
|
||||
'currentPage' => 0,
|
||||
'query' => $keyword,
|
||||
'sort' => 'relevance',
|
||||
]);
|
||||
|
||||
$products = $response['products'] ?? [];
|
||||
|
||||
// Normal case: products found in search results
|
||||
if (is_array($products) && !empty($products)) {
|
||||
return array_map(fn($p) => $this->getPartDetail($p), $products);
|
||||
}
|
||||
|
||||
// Fallback: try direct lookup by code
|
||||
try {
|
||||
$product = $this->getProduct($keyword);
|
||||
return [$this->getPartDetail($product)];
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
{
|
||||
// Detail endpoint is /products/{code}/
|
||||
$response = $this->getProduct($id);
|
||||
|
||||
return $this->getPartDetail($response);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
//ProviderCapabilities::DATASHEET, // currently not implemented
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
];
|
||||
}
|
||||
|
||||
private function complianceToParameters(array $product, ?string $group = 'Compliance'): array
|
||||
{
|
||||
$params = [];
|
||||
|
||||
$add = function (string $name, $value) use (&$params, $group) {
|
||||
if ($value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
$value = $value ? 'Yes' : 'No';
|
||||
} elseif (is_array($value) || is_object($value)) {
|
||||
// Avoid dumping large or complex structures
|
||||
return;
|
||||
} else {
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$params[] = ParameterDTO::parseValueField(
|
||||
name: $name,
|
||||
value: (string) $value,
|
||||
unit: null,
|
||||
symbol: null,
|
||||
group: $group
|
||||
);
|
||||
};
|
||||
|
||||
$add('RoHS conform', $product['labelRoHS'] ?? null); // "yes"/"no"
|
||||
|
||||
$rawRoHsDate = $product['dateRoHS'] ?? null;
|
||||
// Try to parse and reformat date to Y-m-d (do not use language-dependent formats)
|
||||
if (is_string($rawRoHsDate) && $rawRoHsDate !== '') {
|
||||
try {
|
||||
$dt = new \DateTimeImmutable($rawRoHsDate);
|
||||
$formatted = $dt->format('Y-m-d');
|
||||
} catch (\Exception $e) {
|
||||
$formatted = $rawRoHsDate;
|
||||
}
|
||||
// Always use the same parameter name (do not use language-dependent names)
|
||||
$add('RoHS date', $formatted);
|
||||
}
|
||||
$add('SVHC free', $product['SVHC'] ?? null); // bool
|
||||
$add('Hazardous good', $product['hazardousGood'] ?? null); // bool
|
||||
$add('Hazardous materials', $product['hazardousMaterials'] ?? null); // bool
|
||||
|
||||
$add('Country of origin', $product['countryOfOrigin'] ?? null);
|
||||
// Customs tariff code must always be stored as string, otherwise "85411000" may be stored as "8.5411e+7"
|
||||
if (isset($product['articleCustomsCode'])) {
|
||||
// Raw value as string
|
||||
$codeRaw = (string) $product['articleCustomsCode'];
|
||||
|
||||
// Optionally keep only digits (in case of spaces or other characters)
|
||||
$code = preg_replace('/\D/', '', $codeRaw) ?? $codeRaw;
|
||||
$code = trim($code);
|
||||
|
||||
if ($code !== '') {
|
||||
$params[] = new ParameterDTO(
|
||||
name: 'Customs code',
|
||||
value_text: $code,
|
||||
value_typ: null,
|
||||
value_min: null,
|
||||
value_max: null,
|
||||
unit: null,
|
||||
symbol: null,
|
||||
group: $group
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $keywords
|
||||
* @return array<string, SearchResultDTO[]>
|
||||
*/
|
||||
public function searchByKeywordsBatch(array $keywords): array
|
||||
{
|
||||
/** @var array<string, SearchResultDTO[]> $results */
|
||||
$results = [];
|
||||
|
||||
foreach ($keywords as $keyword) {
|
||||
$keyword = strtoupper(trim((string) $keyword));
|
||||
if ($keyword === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reuse existing single search -> returns PartDetailDTO[]
|
||||
/** @var PartDetailDTO[] $partDetails */
|
||||
$partDetails = $this->searchByKeyword($keyword);
|
||||
|
||||
// Convert to SearchResultDTO[]
|
||||
$results[$keyword] = array_map(
|
||||
fn(PartDetailDTO $detail) => $this->convertPartDetailToSearchResult($detail),
|
||||
$partDetails
|
||||
);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a PartDetailDTO into a SearchResultDTO for bulk search.
|
||||
*/
|
||||
private function convertPartDetailToSearchResult(PartDetailDTO $detail): SearchResultDTO
|
||||
{
|
||||
return new SearchResultDTO(
|
||||
provider_key: $detail->provider_key,
|
||||
provider_id: $detail->provider_id,
|
||||
name: $detail->name,
|
||||
description: $detail->description ?? '',
|
||||
category: $detail->category ?? null,
|
||||
manufacturer: $detail->manufacturer ?? null,
|
||||
mpn: $detail->mpn ?? null,
|
||||
preview_image_url: $detail->preview_image_url ?? null,
|
||||
manufacturing_status: $detail->manufacturing_status ?? null,
|
||||
provider_url: $detail->provider_url ?? null,
|
||||
footprint: $detail->footprint ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
public function getHandledDomains(): array
|
||||
{
|
||||
return ['buerklin.com'];
|
||||
}
|
||||
|
||||
public function getIDFromURL(string $url): ?string
|
||||
{
|
||||
//Inputs:
|
||||
//https://www.buerklin.com/de/p/bkl-electronic/niedervoltsteckverbinder/072341-l/40F1332/
|
||||
//https://www.buerklin.com/de/p/40F1332/
|
||||
//https://www.buerklin.com/en/p/bkl-electronic/dc-connectors/072341-l/40F1332/
|
||||
//https://www.buerklin.com/en/p/40F1332/
|
||||
//The ID is the last part after the manufacturer/category/mpn segment and before the final slash
|
||||
//https://www.buerklin.com/de/p/bkl-electronic/niedervoltsteckverbinder/072341-l/40F1332/#download should also work
|
||||
|
||||
$path = parse_url($url, PHP_URL_PATH);
|
||||
|
||||
if (!$path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure it's actually a product URL
|
||||
if (strpos($path, '/p/') === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$id = basename(rtrim($path, '/'));
|
||||
|
||||
return $id !== '' && $id !== 'p' ? $id : null;
|
||||
}
|
||||
|
||||
}
|
||||
343
src/Services/InfoProviderSystem/Providers/ConradProvider.php
Normal file
343
src/Services/InfoProviderSystem/Providers/ConradProvider.php
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 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 App\Settings\InfoProviderSystem\ConradSettings;
|
||||
use App\Settings\InfoProviderSystem\ConradShopIDs;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoProviderInterface
|
||||
{
|
||||
|
||||
private const SEARCH_ENDPOINT = '/search/1/v3/facetSearch';
|
||||
public const DISTRIBUTOR_NAME = 'Conrad';
|
||||
|
||||
private HttpClientInterface $httpClient;
|
||||
|
||||
public function __construct( HttpClientInterface $httpClient, private ConradSettings $settings)
|
||||
{
|
||||
//We want everything in JSON
|
||||
$this->httpClient = $httpClient->withOptions([
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Conrad',
|
||||
'description' => 'Retrieves part information from conrad.de',
|
||||
'url' => 'https://www.conrad.de/',
|
||||
'disabled_help' => 'Set API key in settings',
|
||||
'settings_class' => ConradSettings::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'conrad';
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return !empty($this->settings->apiKey);
|
||||
}
|
||||
|
||||
private function getProductUrl(string $productId): string
|
||||
{
|
||||
return 'https://' . $this->settings->shopID->getDomain() . '/' . $this->settings->shopID->getLanguage() . '/p/' . $productId;
|
||||
}
|
||||
|
||||
private function getFootprintFromTechnicalDetails(array $technicalDetails): ?string
|
||||
{
|
||||
foreach ($technicalDetails as $detail) {
|
||||
if ($detail['name'] === 'ATT_LOV_HOUSING_SEMICONDUCTORS') {
|
||||
return $detail['values'][0] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
{
|
||||
$url = $this->settings->shopID->getAPIRoot() . self::SEARCH_ENDPOINT . '/'
|
||||
. $this->settings->shopID->getDomainEnd() . '/' . $this->settings->shopID->getLanguage()
|
||||
. '/' . $this->settings->shopID->getCustomerType();
|
||||
|
||||
$response = $this->httpClient->request('POST', $url, [
|
||||
'query' => [
|
||||
'apikey' => $this->settings->apiKey,
|
||||
],
|
||||
'json' => [
|
||||
'query' => $keyword,
|
||||
'size' => 50,
|
||||
'sort' => [["field"=>"_score","order"=>"desc"]],
|
||||
],
|
||||
]);
|
||||
|
||||
$out = [];
|
||||
$results = $response->toArray();
|
||||
|
||||
foreach($results['hits'] as $result) {
|
||||
|
||||
$out[] = new SearchResultDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $result['productId'],
|
||||
name: $result['manufacturerId'] ?? $result['productId'],
|
||||
description: $result['title'] ?? '',
|
||||
manufacturer: $result['brand']['name'] ?? null,
|
||||
mpn: $result['manufacturerId'] ?? null,
|
||||
preview_image_url: $result['image'] ?? null,
|
||||
provider_url: $this->getProductUrl($result['productId']),
|
||||
footprint: $this->getFootprintFromTechnicalDetails($result['technicalDetails'] ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function getFootprintFromTechnicalAttributes(array $technicalDetails): ?string
|
||||
{
|
||||
foreach ($technicalDetails as $detail) {
|
||||
if ($detail['attributeID'] === 'ATT.LOV.HOUSING_SEMICONDUCTORS') {
|
||||
return $detail['values'][0]['value'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $technicalAttributes
|
||||
* @return array<ParameterDTO>
|
||||
*/
|
||||
private function technicalAttributesToParameters(array $technicalAttributes): array
|
||||
{
|
||||
return array_map(static function (array $p) {
|
||||
if (count($p['values']) === 1) { //Single value attribute
|
||||
if (array_key_exists('unit', $p['values'][0])) {
|
||||
return ParameterDTO::parseValueField( //With unit
|
||||
name: $p['attributeName'],
|
||||
value: $p['values'][0]['value'],
|
||||
unit: $p['values'][0]['unit']['name'],
|
||||
);
|
||||
}
|
||||
|
||||
return ParameterDTO::parseValueIncludingUnit(
|
||||
name: $p['attributeName'],
|
||||
value: $p['values'][0]['value'],
|
||||
);
|
||||
}
|
||||
|
||||
if (count($p['values']) === 2) { //Multi value attribute (e.g. min/max)
|
||||
$value = $p['values'][0]['value'] ?? null;
|
||||
$value2 = $p['values'][1]['value'] ?? null;
|
||||
$unit = $p['values'][0]['unit']['name'] ?? '';
|
||||
$unit2 = $p['values'][1]['unit']['name'] ?? '';
|
||||
if ($unit === $unit2 && is_numeric($value) && is_numeric($value2)) {
|
||||
if (array_key_exists('unit', $p['values'][0])) { //With unit
|
||||
return new ParameterDTO(
|
||||
name: $p['attributeName'],
|
||||
value_min: (float)$value,
|
||||
value_max: (float)$value2,
|
||||
unit: $unit,
|
||||
);
|
||||
}
|
||||
|
||||
return new ParameterDTO(
|
||||
name: $p['attributeName'],
|
||||
value_min: (float)$value,
|
||||
value_max: (float)$value2,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// fallback implementation
|
||||
$values = implode(", ", array_map(fn($q) =>
|
||||
array_key_exists('unit', $q) ? $q['value']." ". ($q['unit']['name'] ?? $q['unit']) : $q['value']
|
||||
, $p['values']));
|
||||
return ParameterDTO::parseValueIncludingUnit(
|
||||
name: $p['attributeName'],
|
||||
value: $values,
|
||||
);
|
||||
}, $technicalAttributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $productMedia
|
||||
* @return array<FileDTO>
|
||||
*/
|
||||
public function productMediaToDatasheets(array $productMedia): array
|
||||
{
|
||||
$files = [];
|
||||
foreach ($productMedia['manuals'] as $manual) {
|
||||
//Filter out unwanted languages
|
||||
if (!empty($this->settings->attachmentLanguageFilter) && !in_array($manual['language'], $this->settings->attachmentLanguageFilter, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files[] = new FileDTO($manual['fullUrl'], $manual['title'] . ' (' . $manual['language'] . ')');
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Queries prices for a given product ID. It makes a POST request to the Conrad API
|
||||
* @param string $productId
|
||||
* @return PurchaseInfoDTO
|
||||
*/
|
||||
private function queryPrices(string $productId): PurchaseInfoDTO
|
||||
{
|
||||
$priceQueryURL = $this->settings->shopID->getAPIRoot() . '/price-availability/4/'
|
||||
. $this->settings->shopID->getShopID() . '/facade';
|
||||
|
||||
$response = $this->httpClient->request('POST', $priceQueryURL, [
|
||||
'query' => [
|
||||
'apikey' => $this->settings->apiKey,
|
||||
'overrideCalculationSchema' => $this->settings->includeVAT ? 'GROSS' : 'NET'
|
||||
],
|
||||
'json' => [
|
||||
'ns:inputArticleItemList' => [
|
||||
"#namespaces" => [
|
||||
"ns" => "http://www.conrad.de/ccp/basit/service/article/priceandavailabilityservice/api"
|
||||
],
|
||||
'articles' => [
|
||||
[
|
||||
"articleID" => $productId,
|
||||
"calculatePrice" => true,
|
||||
"checkAvailability" => true,
|
||||
],
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$result = $response->toArray();
|
||||
|
||||
$priceInfo = $result['priceAndAvailabilityFacadeResponse']['priceAndAvailability']['price'] ?? [];
|
||||
$price = $priceInfo['price'] ?? "0.0";
|
||||
$currency = $priceInfo['currency'] ?? "EUR";
|
||||
$includesVat = !$priceInfo['isGrossAmount'] || $priceInfo['isGrossAmount'] === "true";
|
||||
$minOrderAmount = $result['priceAndAvailabilityFacadeResponse']['priceAndAvailability']['availabilityStatus']['minimumOrderQuantity'] ?? 1;
|
||||
|
||||
$prices = [];
|
||||
foreach ($priceInfo['priceScale'] ?? [] as $priceScale) {
|
||||
$prices[] = new PriceDTO(
|
||||
minimum_discount_amount: max($priceScale['scaleFrom'], $minOrderAmount),
|
||||
price: (string)$priceScale['pricePerUnit'],
|
||||
currency_iso_code: $currency,
|
||||
includes_tax: $includesVat
|
||||
);
|
||||
}
|
||||
if (empty($prices)) { //Fallback if no price scales are defined
|
||||
$prices[] = new PriceDTO(
|
||||
minimum_discount_amount: $minOrderAmount,
|
||||
price: (string)$price,
|
||||
currency_iso_code: $currency,
|
||||
includes_tax: $includesVat
|
||||
);
|
||||
}
|
||||
|
||||
return new PurchaseInfoDTO(
|
||||
distributor_name: self::DISTRIBUTOR_NAME,
|
||||
order_number: $productId,
|
||||
prices: $prices,
|
||||
product_url: $this->getProductUrl($productId)
|
||||
);
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
{
|
||||
$productInfoURL = $this->settings->shopID->getAPIRoot() . '/product/1/service/' . $this->settings->shopID->getShopID()
|
||||
. '/product/' . $id;
|
||||
|
||||
$response = $this->httpClient->request('GET', $productInfoURL, [
|
||||
'query' => [
|
||||
'apikey' => $this->settings->apiKey,
|
||||
]
|
||||
]);
|
||||
|
||||
$data = $response->toArray();
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $data['shortProductNumber'],
|
||||
name: $data['productFullInformation']['manufacturer']['name'] ?? $data['productFullInformation']['manufacturer']['id'] ?? $data['shortProductNumber'],
|
||||
description: $data['productShortInformation']['title'] ?? '',
|
||||
category: $data['productShortInformation']['articleGroupName'] ?? null,
|
||||
manufacturer: $data['brand']['displayName'] !== null ? preg_replace("/[\u{2122}\u{00ae}]/", "", $data['brand']['displayName']) : null, //Replace ™ and ® symbols
|
||||
mpn: $data['productFullInformation']['manufacturer']['id'] ?? null,
|
||||
preview_image_url: $data['productShortInformation']['mainImage']['imageUrl'] ?? null,
|
||||
provider_url: $this->getProductUrl($data['shortProductNumber']),
|
||||
footprint: $this->getFootprintFromTechnicalAttributes($data['productFullInformation']['technicalAttributes'] ?? []),
|
||||
notes: $data['productFullInformation']['description'] ?? null,
|
||||
datasheets: $this->productMediaToDatasheets($data['productMedia'] ?? []),
|
||||
parameters: $this->technicalAttributesToParameters($data['productFullInformation']['technicalAttributes'] ?? []),
|
||||
vendor_infos: [$this->queryPrices($data['shortProductNumber'])]
|
||||
);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
];
|
||||
}
|
||||
|
||||
public function getHandledDomains(): array
|
||||
{
|
||||
$domains = [];
|
||||
foreach (ConradShopIDs::cases() as $shopID) {
|
||||
$domains[] = $shopID->getDomain();
|
||||
}
|
||||
return array_unique($domains);
|
||||
}
|
||||
|
||||
public function getIDFromURL(string $url): ?string
|
||||
{
|
||||
//Input: https://www.conrad.de/de/p/apple-iphone-air-wolkenweiss-256-gb-eek-a-a-g-16-5-cm-6-5-zoll-3475299.html
|
||||
//The numbers before the optional .html are the product ID
|
||||
|
||||
$matches = [];
|
||||
if (preg_match('/-(\d+)(\.html)?$/', $url, $matches) === 1) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -311,6 +311,14 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
|
||||
]);
|
||||
|
||||
if ($response->getStatusCode() === 404) {
|
||||
//No media found
|
||||
return [
|
||||
'datasheets' => [],
|
||||
'images' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$media_array = $response->toArray();
|
||||
|
||||
foreach ($media_array['MediaLinks'] as $media_link) {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ use App\Settings\InfoProviderSystem\Element14Settings;
|
|||
use Composer\CaBundle\CaBundle;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class Element14Provider implements InfoProviderInterface
|
||||
class Element14Provider implements InfoProviderInterface, URLHandlerInfoProviderInterface
|
||||
{
|
||||
|
||||
private const ENDPOINT_URL = 'https://api.element14.com/catalog/products';
|
||||
|
|
@ -309,4 +309,21 @@ class Element14Provider implements InfoProviderInterface
|
|||
ProviderCapabilities::DATASHEET,
|
||||
];
|
||||
}
|
||||
|
||||
public function getHandledDomains(): array
|
||||
{
|
||||
return ['element14.com', 'farnell.com', 'newark.com'];
|
||||
}
|
||||
|
||||
public function getIDFromURL(string $url): ?string
|
||||
{
|
||||
//Input URL example: https://de.farnell.com/on-semiconductor/bc547b/transistor-npn-to-92/dp/1017673
|
||||
//The digits after the /dp/ are the part ID
|
||||
$matches = [];
|
||||
if (preg_match('#/dp/(\d+)#', $url, $matches) === 1) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
435
src/Services/InfoProviderSystem/Providers/GenericWebProvider.php
Normal file
435
src/Services/InfoProviderSystem/Providers/GenericWebProvider.php
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 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\Exceptions\ProviderIDNotSupportedException;
|
||||
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 App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||
use App\Settings\InfoProviderSystem\GenericWebProviderSettings;
|
||||
use Brick\Schema\Interfaces\BreadcrumbList;
|
||||
use Brick\Schema\Interfaces\ImageObject;
|
||||
use Brick\Schema\Interfaces\Product;
|
||||
use Brick\Schema\Interfaces\PropertyValue;
|
||||
use Brick\Schema\Interfaces\QuantitativeValue;
|
||||
use Brick\Schema\Interfaces\Thing;
|
||||
use Brick\Schema\SchemaReader;
|
||||
use Brick\Schema\SchemaTypeList;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class GenericWebProvider implements InfoProviderInterface
|
||||
{
|
||||
|
||||
public const DISTRIBUTOR_NAME = 'Website';
|
||||
|
||||
private readonly HttpClientInterface $httpClient;
|
||||
|
||||
public function __construct(HttpClientInterface $httpClient, private readonly GenericWebProviderSettings $settings,
|
||||
private readonly ProviderRegistry $providerRegistry, private readonly PartInfoRetriever $infoRetriever,
|
||||
)
|
||||
{
|
||||
$this->httpClient = $httpClient->withOptions(
|
||||
[
|
||||
'headers' => [
|
||||
'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
|
||||
],
|
||||
'timeout' => 15,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Generic Web URL',
|
||||
'description' => 'Tries to extract a part from a given product webpage URL using common metadata standards like JSON-LD and OpenGraph.',
|
||||
//'url' => 'https://example.com',
|
||||
'disabled_help' => 'Enable in settings to use this provider',
|
||||
'settings_class' => GenericWebProviderSettings::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'generic_web';
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->settings->enabled;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
{
|
||||
$url = $this->fixAndValidateURL($keyword);
|
||||
|
||||
//Before loading the page, try to delegate to another provider
|
||||
$delegatedPart = $this->delegateToOtherProvider($url);
|
||||
if ($delegatedPart !== null) {
|
||||
return [$delegatedPart];
|
||||
}
|
||||
|
||||
try {
|
||||
return [
|
||||
$this->getDetails($keyword, false) //We already tried delegation
|
||||
]; } catch (ProviderIDNotSupportedException $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function extractShopName(string $url): string
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
if ($host === false || $host === null) {
|
||||
return self::DISTRIBUTOR_NAME;
|
||||
}
|
||||
return $host;
|
||||
}
|
||||
|
||||
private function breadcrumbToCategory(?BreadcrumbList $breadcrumbList): ?string
|
||||
{
|
||||
if ($breadcrumbList === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$items = $breadcrumbList->itemListElement->getValues();
|
||||
if (count($items) < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
//Build our category from the breadcrumb items
|
||||
$categories = [];
|
||||
foreach ($items as $item) {
|
||||
if (isset($item->name)) {
|
||||
$categories[] = trim($item->name->toString());
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return implode(' -> ', $categories);
|
||||
}
|
||||
|
||||
private function productToPart(Product $product, string $url, Crawler $dom, ?BreadcrumbList $categoryBreadcrumb): PartDetailDTO
|
||||
{
|
||||
$notes = $product->description->toString() ?? "";
|
||||
if ($product->disambiguatingDescription !== null) {
|
||||
if (!empty($notes)) {
|
||||
$notes .= "\n\n";
|
||||
}
|
||||
$notes .= $product->disambiguatingDescription->toString();
|
||||
}
|
||||
|
||||
|
||||
//Extract vendor infos
|
||||
$vendor_infos = null;
|
||||
$offer = $product->offers->getFirstValue();
|
||||
if ($offer !== null) {
|
||||
$prices = [];
|
||||
if ($offer->price->toString() !== null) {
|
||||
$prices = [new PriceDTO(
|
||||
minimum_discount_amount: 1,
|
||||
price: $offer->price->toString(),
|
||||
currency_iso_code: $offer->priceCurrency?->toString()
|
||||
)];
|
||||
} else { //Check for nested offers (like IKEA does it)
|
||||
$offer2 = $offer->offers->getFirstValue();
|
||||
if ($offer2 !== null && $offer2->price->toString() !== null) {
|
||||
$prices = [
|
||||
new PriceDTO(
|
||||
minimum_discount_amount: 1,
|
||||
price: $offer2->price->toString(),
|
||||
currency_iso_code: $offer2->priceCurrency?->toString()
|
||||
)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$vendor_infos = [new PurchaseInfoDTO(
|
||||
distributor_name: $this->extractShopName($url),
|
||||
order_number: $product->sku?->toString() ?? $product->identifier?->toString() ?? 'Unknown',
|
||||
prices: $prices,
|
||||
product_url: $offer->url?->toString() ?? $url,
|
||||
)];
|
||||
}
|
||||
|
||||
//Extract image:
|
||||
$image = null;
|
||||
if ($product->image !== null) {
|
||||
$imageObj = $product->image->getFirstValue();
|
||||
if (is_string($imageObj)) {
|
||||
$image = $imageObj;
|
||||
} else if ($imageObj instanceof ImageObject) {
|
||||
$image = $imageObj->contentUrl?->toString() ?? $imageObj->url?->toString();
|
||||
}
|
||||
}
|
||||
|
||||
//Extract parameters from additionalProperty
|
||||
$parameters = [];
|
||||
foreach ($product->additionalProperty->getValues() as $property) {
|
||||
if ($property instanceof PropertyValue) { //TODO: Handle minValue and maxValue
|
||||
if ($property->unitText->toString() !== null) {
|
||||
$parameters[] = ParameterDTO::parseValueField(
|
||||
name: $property->name->toString() ?? 'Unknown',
|
||||
value: $property->value->toString() ?? '',
|
||||
unit: $property->unitText->toString()
|
||||
);
|
||||
} else {
|
||||
$parameters[] = ParameterDTO::parseValueIncludingUnit(
|
||||
name: $property->name->toString() ?? 'Unknown',
|
||||
value: $property->value->toString() ?? ''
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Try to extract weight
|
||||
$mass = null;
|
||||
if (($weight = $product->weight?->getFirstValue()) instanceof QuantitativeValue) {
|
||||
$mass = $weight->value->toString();
|
||||
}
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $url,
|
||||
name: $product->name?->toString() ?? $product->alternateName?->toString() ?? $product->mpn?->toString() ?? 'Unknown Name',
|
||||
description: $this->getMetaContent($dom, 'og:description') ?? $this->getMetaContent($dom, 'description') ?? '',
|
||||
category: $this->breadcrumbToCategory($categoryBreadcrumb) ?? $product->category?->toString(),
|
||||
manufacturer: self::propertyOrString($product->manufacturer) ?? self::propertyOrString($product->brand),
|
||||
mpn: $product->mpn?->toString(),
|
||||
preview_image_url: $image,
|
||||
provider_url: $url,
|
||||
notes: $notes,
|
||||
parameters: $parameters,
|
||||
vendor_infos: $vendor_infos,
|
||||
mass: $mass
|
||||
);
|
||||
}
|
||||
|
||||
private static function propertyOrString(SchemaTypeList|Thing|string|null $value, string $property = "name"): ?string
|
||||
{
|
||||
if ($value instanceof SchemaTypeList) {
|
||||
$value = $value->getFirstValue();
|
||||
}
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $value->$property?->toString();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets the content of a meta tag by its name or property attribute, or null if not found
|
||||
* @param Crawler $dom
|
||||
* @param string $name
|
||||
* @return string|null
|
||||
*/
|
||||
private function getMetaContent(Crawler $dom, string $name): ?string
|
||||
{
|
||||
$meta = $dom->filter('meta[property="'.$name.'"]');
|
||||
if ($meta->count() > 0) {
|
||||
return $meta->attr('content');
|
||||
}
|
||||
|
||||
//Try name attribute
|
||||
$meta = $dom->filter('meta[name="'.$name.'"]');
|
||||
if ($meta->count() > 0) {
|
||||
return $meta->attr('content');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates the URL to another provider if possible, otherwise return null
|
||||
* @param string $url
|
||||
* @return SearchResultDTO|null
|
||||
*/
|
||||
private function delegateToOtherProvider(string $url): ?SearchResultDTO
|
||||
{
|
||||
//Extract domain from url:
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
if ($host === false || $host === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$provider = $this->providerRegistry->getProviderHandlingDomain($host);
|
||||
|
||||
if ($provider !== null && $provider->isActive() && $provider->getProviderKey() !== $this->getProviderKey()) {
|
||||
try {
|
||||
$id = $provider->getIDFromURL($url);
|
||||
if ($id !== null) {
|
||||
$results = $this->infoRetriever->searchByKeyword($id, [$provider]);
|
||||
if (count($results) > 0) {
|
||||
return $results[0];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (ProviderIDNotSupportedException $e) {
|
||||
//Ignore and continue
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function fixAndValidateURL(string $url): string
|
||||
{
|
||||
$originalUrl = $url;
|
||||
|
||||
//Add scheme if missing
|
||||
if (!preg_match('/^https?:\/\//', $url)) {
|
||||
//Remove any leading slashes
|
||||
$url = ltrim($url, '/');
|
||||
|
||||
$url = 'https://'.$url;
|
||||
}
|
||||
|
||||
//If this is not a valid URL with host, domain and path, throw an exception
|
||||
if (filter_var($url, FILTER_VALIDATE_URL) === false ||
|
||||
parse_url($url, PHP_URL_HOST) === null ||
|
||||
parse_url($url, PHP_URL_PATH) === null) {
|
||||
throw new ProviderIDNotSupportedException("The given ID is not a valid URL: ".$originalUrl);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
public function getDetails(string $id, bool $check_for_delegation = true): PartDetailDTO
|
||||
{
|
||||
$url = $this->fixAndValidateURL($id);
|
||||
|
||||
if ($check_for_delegation) {
|
||||
//Before loading the page, try to delegate to another provider
|
||||
$delegatedPart = $this->delegateToOtherProvider($url);
|
||||
if ($delegatedPart !== null) {
|
||||
return $this->infoRetriever->getDetailsForSearchResult($delegatedPart);
|
||||
}
|
||||
}
|
||||
|
||||
//Try to get the webpage content
|
||||
$response = $this->httpClient->request('GET', $url);
|
||||
$content = $response->getContent();
|
||||
|
||||
$dom = new Crawler($content);
|
||||
|
||||
//Try to determine a canonical URL
|
||||
$canonicalURL = $url;
|
||||
if ($dom->filter('link[rel="canonical"]')->count() > 0) {
|
||||
$canonicalURL = $dom->filter('link[rel="canonical"]')->attr('href');
|
||||
} else if ($dom->filter('meta[property="og:url"]')->count() > 0) {
|
||||
$canonicalURL = $dom->filter('meta[property="og:url"]')->attr('content');
|
||||
}
|
||||
|
||||
//If the canonical URL is relative, make it absolute
|
||||
if (parse_url($canonicalURL, PHP_URL_SCHEME) === null) {
|
||||
$parsedUrl = parse_url($url);
|
||||
$scheme = $parsedUrl['scheme'] ?? 'https';
|
||||
$host = $parsedUrl['host'] ?? '';
|
||||
$canonicalURL = $scheme.'://'.$host.$canonicalURL;
|
||||
}
|
||||
|
||||
|
||||
$schemaReader = SchemaReader::forAllFormats();
|
||||
$things = $schemaReader->readHtml($content, $canonicalURL);
|
||||
|
||||
//Try to find a breadcrumb schema to extract the category
|
||||
$categoryBreadCrumbs = null;
|
||||
foreach ($things as $thing) {
|
||||
if ($thing instanceof BreadcrumbList) {
|
||||
$categoryBreadCrumbs = $thing;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
//Try to find a Product schema
|
||||
foreach ($things as $thing) {
|
||||
if ($thing instanceof Product) {
|
||||
return $this->productToPart($thing, $canonicalURL, $dom, $categoryBreadCrumbs);
|
||||
}
|
||||
}
|
||||
|
||||
//If no JSON-LD data is found, try to extract basic data from meta tags
|
||||
$pageTitle = $dom->filter('title')->count() > 0 ? $dom->filter('title')->text() : 'Unknown';
|
||||
|
||||
$prices = [];
|
||||
if ($price = $this->getMetaContent($dom, 'product:price:amount')) {
|
||||
$prices[] = new PriceDTO(
|
||||
minimum_discount_amount: 1,
|
||||
price: $price,
|
||||
currency_iso_code: $this->getMetaContent($dom, 'product:price:currency'),
|
||||
);
|
||||
} else {
|
||||
//Amazon fallback
|
||||
$amazonAmount = $dom->filter('input[type="hidden"][name*="amount"]');
|
||||
if ($amazonAmount->count() > 0) {
|
||||
$prices[] = new PriceDTO(
|
||||
minimum_discount_amount: 1,
|
||||
price: $amazonAmount->first()->attr('value'),
|
||||
currency_iso_code: $dom->filter('input[type="hidden"][name*="currencyCode"]')->first()->attr('value'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$vendor_infos = [new PurchaseInfoDTO(
|
||||
distributor_name: $this->extractShopName($canonicalURL),
|
||||
order_number: 'Unknown',
|
||||
prices: $prices,
|
||||
product_url: $canonicalURL,
|
||||
)];
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $canonicalURL,
|
||||
name: $this->getMetaContent($dom, 'og:title') ?? $pageTitle,
|
||||
description: $this->getMetaContent($dom, 'og:description') ?? $this->getMetaContent($dom, 'description') ?? '',
|
||||
manufacturer: $this->getMetaContent($dom, 'product:brand'),
|
||||
preview_image_url: $this->getMetaContent($dom, 'og:image'),
|
||||
provider_url: $canonicalURL,
|
||||
vendor_infos: $vendor_infos,
|
||||
);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::PRICE
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ use App\Settings\InfoProviderSystem\LCSCSettings;
|
|||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class LCSCProvider implements BatchInfoProviderInterface
|
||||
class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProviderInterface
|
||||
{
|
||||
|
||||
private const ENDPOINT_URL = 'https://wmsc.lcsc.com/ftps/wm';
|
||||
|
|
@ -452,4 +452,21 @@ class LCSCProvider implements BatchInfoProviderInterface
|
|||
ProviderCapabilities::FOOTPRINT,
|
||||
];
|
||||
}
|
||||
|
||||
public function getHandledDomains(): array
|
||||
{
|
||||
return ['lcsc.com'];
|
||||
}
|
||||
|
||||
public function getIDFromURL(string $url): ?string
|
||||
{
|
||||
//Input example: https://www.lcsc.com/product-detail/C258144.html?s_z=n_BC547
|
||||
//The part between the "C" and the ".html" is the unique ID
|
||||
|
||||
$matches = [];
|
||||
if (preg_match("#/product-detail/(\w+)\.html#", $url, $matches) > 0) {
|
||||
return $matches[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -397,13 +397,13 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
* Generates a cache key for storing part details based on the provided provider ID.
|
||||
*
|
||||
* This method creates a unique cache key by prefixing the provider ID with 'part_details_'
|
||||
* and hashing the provider ID using MD5 to ensure a consistent and compact key format.
|
||||
* and hashing the provider ID using XXH3 to ensure a consistent and compact key format.
|
||||
*
|
||||
* @param string $provider_id The unique identifier of the provider or part.
|
||||
* @return string The generated cache key.
|
||||
*/
|
||||
private function getCacheKey(string $provider_id): string {
|
||||
return 'oemsecrets_part_' . md5($provider_id);
|
||||
return 'oemsecrets_part_' . hash('xxh3', $provider_id);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -680,7 +680,7 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
if (is_array($prices)) {
|
||||
// Step 1: Check if prices exist in the preferred currency
|
||||
if (isset($prices[$this->settings->currency]) && is_array($prices[$this->settings->currency])) {
|
||||
$priceDetails = $prices[$this->$this->settings->currency];
|
||||
$priceDetails = $prices[$this->settings->currency];
|
||||
foreach ($priceDetails as $priceDetail) {
|
||||
if (
|
||||
is_array($priceDetail) &&
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|||
use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class PollinProvider implements InfoProviderInterface
|
||||
class PollinProvider implements InfoProviderInterface, URLHandlerInfoProviderInterface
|
||||
{
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $client,
|
||||
|
|
@ -141,11 +141,16 @@ class PollinProvider implements InfoProviderInterface
|
|||
$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;
|
||||
$massDom = $dom->filter('meta[itemprop="weight"]');
|
||||
if ($massDom->count() > 0) {
|
||||
$massStr = $massDom->attr('content');
|
||||
$massStr = str_replace('kg', '', $massStr);
|
||||
//Convert to float and convert to grams
|
||||
$mass = (float) $massStr * 1000;
|
||||
} else {
|
||||
$mass = null;
|
||||
}
|
||||
|
||||
|
||||
//Parse purchase info
|
||||
$purchaseInfo = new PurchaseInfoDTO('Pollin', $orderId, $this->parsePrices($dom), $productPageUrl);
|
||||
|
|
@ -248,4 +253,22 @@ class PollinProvider implements InfoProviderInterface
|
|||
ProviderCapabilities::DATASHEET
|
||||
];
|
||||
}
|
||||
|
||||
public function getHandledDomains(): array
|
||||
{
|
||||
return ['pollin.de'];
|
||||
}
|
||||
|
||||
public function getIDFromURL(string $url): ?string
|
||||
{
|
||||
//URL like: https://www.pollin.de/p/shelly-bluetooth-schalter-und-dimmer-blu-zb-button-plug-play-mocha-592325
|
||||
|
||||
//Extract the 6-digit number at the end of the URL
|
||||
$matches = [];
|
||||
if (preg_match('/-(\d{6})(?:\/|$)/', $url, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,9 +31,6 @@ enum ProviderCapabilities
|
|||
/** Basic information about a part, like the name, description, part number, manufacturer etc */
|
||||
case BASIC;
|
||||
|
||||
/** Information about the footprint of a part */
|
||||
case FOOTPRINT;
|
||||
|
||||
/** Provider can provide a picture for a part */
|
||||
case PICTURE;
|
||||
|
||||
|
|
@ -43,6 +40,24 @@ enum ProviderCapabilities
|
|||
/** Provider can provide prices for a part */
|
||||
case PRICE;
|
||||
|
||||
/** Information about the footprint of a part */
|
||||
case FOOTPRINT;
|
||||
|
||||
/**
|
||||
* Get the order index for displaying capabilities in a stable order.
|
||||
* @return int
|
||||
*/
|
||||
public function getOrderIndex(): int
|
||||
{
|
||||
return match($this) {
|
||||
self::BASIC => 1,
|
||||
self::PICTURE => 2,
|
||||
self::DATASHEET => 3,
|
||||
self::PRICE => 4,
|
||||
self::FOOTPRINT => 5,
|
||||
};
|
||||
}
|
||||
|
||||
public function getTranslationKey(): string
|
||||
{
|
||||
return 'info_providers.capabilities.' . match($this) {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Settings\InfoProviderSystem\TMESettings;
|
||||
|
||||
class TMEProvider implements InfoProviderInterface
|
||||
class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterface
|
||||
{
|
||||
|
||||
private const VENDOR_NAME = 'TME';
|
||||
|
|
@ -296,4 +296,22 @@ class TMEProvider implements InfoProviderInterface
|
|||
ProviderCapabilities::PRICE,
|
||||
];
|
||||
}
|
||||
|
||||
public function getHandledDomains(): array
|
||||
{
|
||||
return ['tme.eu'];
|
||||
}
|
||||
|
||||
public function getIDFromURL(string $url): ?string
|
||||
{
|
||||
//Input: https://www.tme.eu/de/details/fi321_se/kuhler/alutronic/
|
||||
//The ID is the part after the details segment and before the next slash
|
||||
|
||||
$matches = [];
|
||||
if (preg_match('#/details/([^/]+)/#', $url, $matches) === 1) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 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;
|
||||
|
||||
/**
|
||||
* If an interface
|
||||
*/
|
||||
interface URLHandlerInfoProviderInterface
|
||||
{
|
||||
/**
|
||||
* Returns a list of supported domains (e.g. ["digikey.com"])
|
||||
* @return array An array of supported domains
|
||||
*/
|
||||
public function getHandledDomains(): array;
|
||||
|
||||
/**
|
||||
* Extracts the unique ID of a part from a given URL. It is okay if this is not a canonical ID, as long as it can be used to uniquely identify the part within this provider.
|
||||
* @param string $url The URL to extract the ID from
|
||||
* @return string|null The extracted ID, or null if the URL is not valid for this provider
|
||||
*/
|
||||
public function getIDFromURL(string $url): ?string;
|
||||
}
|
||||
|
|
@ -92,7 +92,7 @@ final class BarcodeRedirector
|
|||
throw new EntityNotFoundException();
|
||||
}
|
||||
|
||||
return $this->urlGenerator->generate('app_part_show', ['id' => $lot->getPart()->getID()]);
|
||||
return $this->urlGenerator->generate('app_part_show', ['id' => $lot->getPart()->getID(), 'highlightLot' => $lot->getID()]);
|
||||
|
||||
case LabelSupportedElement::STORELOCATION:
|
||||
return $this->urlGenerator->generate('part_list_store_location', ['id' => $barcodeScan->target_id]);
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2022 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\Misc;
|
||||
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
|
||||
class GitVersionInfo
|
||||
{
|
||||
protected string $project_dir;
|
||||
|
||||
public function __construct(KernelInterface $kernel)
|
||||
{
|
||||
$this->project_dir = $kernel->getProjectDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Git branch name of the installed system.
|
||||
*
|
||||
* @return string|null The current git branch name. Null, if this is no Git installation
|
||||
*/
|
||||
public function getGitBranchName(): ?string
|
||||
{
|
||||
if (is_file($this->project_dir.'/.git/HEAD')) {
|
||||
$git = file($this->project_dir.'/.git/HEAD');
|
||||
$head = explode('/', $git[0], 3);
|
||||
|
||||
if (!isset($head[2])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim($head[2]);
|
||||
}
|
||||
|
||||
return null; // this is not a Git installation
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hash of the last git commit (on remote "origin"!).
|
||||
*
|
||||
* If this method does not work, try to make a "git pull" first!
|
||||
*
|
||||
* @param int $length if this is smaller than 40, only the first $length characters will be returned
|
||||
*
|
||||
* @return string|null The hash of the last commit, null If this is no Git installation
|
||||
*/
|
||||
public function getGitCommitHash(int $length = 7): ?string
|
||||
{
|
||||
$filename = $this->project_dir.'/.git/refs/remotes/origin/'.$this->getGitBranchName();
|
||||
if (is_file($filename)) {
|
||||
$head = file($filename);
|
||||
|
||||
if (!isset($head[0])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hash = $head[0];
|
||||
|
||||
return substr($hash, 0, $length);
|
||||
}
|
||||
|
||||
return null; // this is not a Git installation
|
||||
}
|
||||
}
|
||||
453
src/Services/System/BackupManager.php
Normal file
453
src/Services/System/BackupManager.php
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\System;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Manages Part-DB backups: creation, restoration, and listing.
|
||||
*
|
||||
* This service handles all backup-related operations and can be used
|
||||
* by the Update Manager, CLI commands, or other services.
|
||||
*/
|
||||
readonly class BackupManager
|
||||
{
|
||||
private const BACKUP_DIR = 'var/backups';
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(param: 'kernel.project_dir')]
|
||||
private string $projectDir,
|
||||
private LoggerInterface $logger,
|
||||
private Filesystem $filesystem,
|
||||
private VersionManagerInterface $versionManager,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private CommandRunHelper $commandRunHelper,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the backup directory path.
|
||||
*/
|
||||
public function getBackupDir(): string
|
||||
{
|
||||
return $this->projectDir . '/' . self::BACKUP_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current version string for use in filenames.
|
||||
*/
|
||||
private function getCurrentVersionString(): string
|
||||
{
|
||||
return $this->versionManager->getVersion()->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a backup before updating.
|
||||
*
|
||||
* @param string|null $targetVersion Optional target version for naming
|
||||
* @param string|null $prefix Optional prefix for the backup filename
|
||||
* @return string The path to the created backup file
|
||||
*/
|
||||
public function createBackup(?string $targetVersion = null, ?string $prefix = 'backup'): string
|
||||
{
|
||||
$backupDir = $this->getBackupDir();
|
||||
|
||||
if (!is_dir($backupDir)) {
|
||||
$this->filesystem->mkdir($backupDir, 0755);
|
||||
}
|
||||
|
||||
$currentVersion = $this->getCurrentVersionString();
|
||||
|
||||
// Build filename
|
||||
if ($targetVersion) {
|
||||
$targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion);
|
||||
$backupFile = $backupDir . '/pre-update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His') . '.zip';
|
||||
} else {
|
||||
$backupFile = $backupDir . '/' . $prefix . '-v' . $currentVersion . '-' . date('Y-m-d-His') . '.zip';
|
||||
}
|
||||
|
||||
$this->commandRunHelper->runCommand([
|
||||
'php', 'bin/console', 'partdb:backup',
|
||||
'--full',
|
||||
'--overwrite',
|
||||
$backupFile,
|
||||
], 'Create backup', 600);
|
||||
|
||||
$this->logger->info('Created backup', ['file' => $backupFile]);
|
||||
|
||||
return $backupFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of backups, that are available, sorted by date descending.
|
||||
*
|
||||
* @return array<array{file: string, path: string, date: int, size: int}>
|
||||
*/
|
||||
public function getBackups(): array
|
||||
{
|
||||
$backupDir = $this->getBackupDir();
|
||||
|
||||
if (!is_dir($backupDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$backups = [];
|
||||
foreach (glob($backupDir . '/*.zip') as $backupFile) {
|
||||
$backups[] = [
|
||||
'file' => basename($backupFile),
|
||||
'path' => $backupFile,
|
||||
'date' => filemtime($backupFile),
|
||||
'size' => filesize($backupFile),
|
||||
];
|
||||
}
|
||||
|
||||
// Sort by date descending
|
||||
usort($backups, static fn($a, $b) => $b['date'] <=> $a['date']);
|
||||
|
||||
return $backups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get details about a specific backup file.
|
||||
*
|
||||
* @param string $filename The backup filename
|
||||
* @return null|array{file: string, path: string, date: int, size: int, from_version: ?string, to_version: ?string, contains_database?: bool, contains_config?: bool, contains_attachments?: bool} Backup details or null if not found
|
||||
*/
|
||||
public function getBackupDetails(string $filename): ?array
|
||||
{
|
||||
$backupDir = $this->getBackupDir();
|
||||
$backupPath = $backupDir . '/' . basename($filename);
|
||||
|
||||
if (!file_exists($backupPath) || !str_ends_with($backupPath, '.zip')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse version info from filename: pre-update-v2.5.1-to-v2.5.0-2024-01-30-185400.zip
|
||||
$info = [
|
||||
'file' => basename($backupPath),
|
||||
'path' => $backupPath,
|
||||
'date' => filemtime($backupPath),
|
||||
'size' => filesize($backupPath),
|
||||
'from_version' => null,
|
||||
'to_version' => null,
|
||||
];
|
||||
|
||||
if (preg_match('/pre-update-v([\d.]+)-to-v?([\d.]+)-/', $filename, $matches)) {
|
||||
$info['from_version'] = $matches[1];
|
||||
$info['to_version'] = $matches[2];
|
||||
}
|
||||
|
||||
// Check what the backup contains by reading the ZIP
|
||||
try {
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($backupPath) === true) {
|
||||
$info['contains_database'] = $zip->locateName('database.sql') !== false || $zip->locateName('var/app.db') !== false;
|
||||
$info['contains_config'] = $zip->locateName('.env.local') !== false || $zip->locateName('config/parameters.yaml') !== false;
|
||||
$info['contains_attachments'] = $zip->locateName('public/media/') !== false || $zip->locateName('uploads/') !== false;
|
||||
$zip->close();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Could not read backup ZIP contents', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a backup file.
|
||||
*
|
||||
* @param string $filename The backup filename to delete
|
||||
* @return bool True if deleted successfully
|
||||
*/
|
||||
public function deleteBackup(string $filename): bool
|
||||
{
|
||||
$backupDir = $this->getBackupDir();
|
||||
$backupPath = $backupDir . '/' . basename($filename);
|
||||
|
||||
if (!file_exists($backupPath) || !str_ends_with($backupPath, '.zip')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->filesystem->remove($backupPath);
|
||||
$this->logger->info('Deleted backup', ['file' => $filename]);
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to delete backup', ['file' => $filename, 'error' => $e->getMessage()]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore from a backup file.
|
||||
*
|
||||
* @param string $filename The backup filename to restore
|
||||
* @param bool $restoreDatabase Whether to restore the database
|
||||
* @param bool $restoreConfig Whether to restore config files
|
||||
* @param bool $restoreAttachments Whether to restore attachments
|
||||
* @param callable|null $onProgress Callback for progress updates
|
||||
* @return array{success: bool, steps: array, error: ?string}
|
||||
*/
|
||||
public function restoreBackup(
|
||||
string $filename,
|
||||
bool $restoreDatabase = true,
|
||||
bool $restoreConfig = false,
|
||||
bool $restoreAttachments = false,
|
||||
?callable $onProgress = null
|
||||
): array {
|
||||
$steps = [];
|
||||
$startTime = microtime(true);
|
||||
|
||||
$log = function (string $step, string $message, bool $success, ?float $duration = null) use (&$steps, $onProgress): void {
|
||||
$entry = [
|
||||
'step' => $step,
|
||||
'message' => $message,
|
||||
'success' => $success,
|
||||
'timestamp' => (new \DateTime())->format('c'),
|
||||
'duration' => $duration,
|
||||
];
|
||||
$steps[] = $entry;
|
||||
$this->logger->info('[Restore] ' . $step . ': ' . $message, ['success' => $success]);
|
||||
|
||||
if ($onProgress) {
|
||||
$onProgress($entry);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Validate backup file
|
||||
$backupDir = $this->getBackupDir();
|
||||
$backupPath = $backupDir . '/' . basename($filename);
|
||||
|
||||
if (!file_exists($backupPath)) {
|
||||
throw new \RuntimeException('Backup file not found: ' . $filename);
|
||||
}
|
||||
|
||||
$stepStart = microtime(true);
|
||||
|
||||
// Step 1: Extract backup to temp directory
|
||||
$tempDir = sys_get_temp_dir() . '/partdb_restore_' . uniqid();
|
||||
$this->filesystem->mkdir($tempDir);
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($backupPath) !== true) {
|
||||
throw new \RuntimeException('Could not open backup ZIP file');
|
||||
}
|
||||
$zip->extractTo($tempDir);
|
||||
$zip->close();
|
||||
$log('extract', 'Extracted backup to temporary directory', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 2: Restore database if requested and present
|
||||
if ($restoreDatabase) {
|
||||
$stepStart = microtime(true);
|
||||
$this->restoreDatabaseFromBackup($tempDir);
|
||||
$log('database', 'Restored database', true, microtime(true) - $stepStart);
|
||||
}
|
||||
|
||||
// Step 3: Restore config files if requested and present
|
||||
if ($restoreConfig) {
|
||||
$stepStart = microtime(true);
|
||||
$this->restoreConfigFromBackup($tempDir);
|
||||
$log('config', 'Restored configuration files', true, microtime(true) - $stepStart);
|
||||
}
|
||||
|
||||
// Step 4: Restore attachments if requested and present
|
||||
if ($restoreAttachments) {
|
||||
$stepStart = microtime(true);
|
||||
$this->restoreAttachmentsFromBackup($tempDir);
|
||||
$log('attachments', 'Restored attachments', true, microtime(true) - $stepStart);
|
||||
}
|
||||
|
||||
// Step 5: Clean up temp directory
|
||||
$stepStart = microtime(true);
|
||||
$this->filesystem->remove($tempDir);
|
||||
$log('cleanup', 'Cleaned up temporary files', true, microtime(true) - $stepStart);
|
||||
|
||||
$totalDuration = microtime(true) - $startTime;
|
||||
$log('complete', sprintf('Restore completed successfully in %.1f seconds', $totalDuration), true);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'steps' => $steps,
|
||||
'error' => null,
|
||||
];
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Restore failed: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'file' => $filename,
|
||||
]);
|
||||
|
||||
// Try to clean up
|
||||
try {
|
||||
if (isset($tempDir) && is_dir($tempDir)) {
|
||||
$this->filesystem->remove($tempDir);
|
||||
}
|
||||
} catch (\Throwable $cleanupError) {
|
||||
$this->logger->error('Cleanup after failed restore also failed', ['error' => $cleanupError->getMessage()]);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'steps' => $steps,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore database from backup.
|
||||
*/
|
||||
private function restoreDatabaseFromBackup(string $tempDir): void
|
||||
{
|
||||
// Check for SQL dump (MySQL/PostgreSQL)
|
||||
$sqlFile = $tempDir . '/database.sql';
|
||||
if (file_exists($sqlFile)) {
|
||||
// Import SQL using mysql/psql command directly
|
||||
// First, get database connection params from Doctrine
|
||||
$connection = $this->entityManager->getConnection();
|
||||
$params = $connection->getParams();
|
||||
$platform = $connection->getDatabasePlatform();
|
||||
|
||||
if ($platform instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) {
|
||||
// Use mysql command to import - need to use shell to handle input redirection
|
||||
$mysqlCmd = 'mysql';
|
||||
if (isset($params['host'])) {
|
||||
$mysqlCmd .= ' -h ' . escapeshellarg($params['host']);
|
||||
}
|
||||
if (isset($params['port'])) {
|
||||
$mysqlCmd .= ' -P ' . escapeshellarg((string)$params['port']);
|
||||
}
|
||||
if (isset($params['user'])) {
|
||||
$mysqlCmd .= ' -u ' . escapeshellarg($params['user']);
|
||||
}
|
||||
if (isset($params['password']) && $params['password']) {
|
||||
$mysqlCmd .= ' -p' . escapeshellarg($params['password']);
|
||||
}
|
||||
if (isset($params['dbname'])) {
|
||||
$mysqlCmd .= ' ' . escapeshellarg($params['dbname']);
|
||||
}
|
||||
$mysqlCmd .= ' < ' . escapeshellarg($sqlFile);
|
||||
|
||||
// Execute using shell
|
||||
$process = Process::fromShellCommandline($mysqlCmd, $this->projectDir, null, null, 300);
|
||||
$process->run();
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
throw new \RuntimeException('MySQL import failed: ' . $process->getErrorOutput());
|
||||
}
|
||||
} elseif ($platform instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
|
||||
// Use psql command to import
|
||||
$psqlCmd = 'psql';
|
||||
if (isset($params['host'])) {
|
||||
$psqlCmd .= ' -h ' . escapeshellarg($params['host']);
|
||||
}
|
||||
if (isset($params['port'])) {
|
||||
$psqlCmd .= ' -p ' . escapeshellarg((string)$params['port']);
|
||||
}
|
||||
if (isset($params['user'])) {
|
||||
$psqlCmd .= ' -U ' . escapeshellarg($params['user']);
|
||||
}
|
||||
if (isset($params['dbname'])) {
|
||||
$psqlCmd .= ' -d ' . escapeshellarg($params['dbname']);
|
||||
}
|
||||
$psqlCmd .= ' -f ' . escapeshellarg($sqlFile);
|
||||
|
||||
// Set PGPASSWORD environment variable if password is provided
|
||||
$env = null;
|
||||
if (isset($params['password']) && $params['password']) {
|
||||
$env = ['PGPASSWORD' => $params['password']];
|
||||
}
|
||||
|
||||
// Execute using shell
|
||||
$process = Process::fromShellCommandline($psqlCmd, $this->projectDir, $env, null, 300);
|
||||
$process->run();
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
throw new \RuntimeException('PostgreSQL import failed: ' . $process->getErrorOutput());
|
||||
}
|
||||
} else {
|
||||
throw new \RuntimeException('Unsupported database platform for restore');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for SQLite database file
|
||||
$sqliteFile = $tempDir . '/var/app.db';
|
||||
if (file_exists($sqliteFile)) {
|
||||
$targetDb = $this->projectDir . '/var/app.db';
|
||||
$this->filesystem->copy($sqliteFile, $targetDb, true);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->warning('No database found in backup');
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore config files from backup.
|
||||
*/
|
||||
private function restoreConfigFromBackup(string $tempDir): void
|
||||
{
|
||||
// Restore .env.local
|
||||
$envLocal = $tempDir . '/.env.local';
|
||||
if (file_exists($envLocal)) {
|
||||
$this->filesystem->copy($envLocal, $this->projectDir . '/.env.local', true);
|
||||
}
|
||||
|
||||
// Restore config/parameters.yaml
|
||||
$parametersYaml = $tempDir . '/config/parameters.yaml';
|
||||
if (file_exists($parametersYaml)) {
|
||||
$this->filesystem->copy($parametersYaml, $this->projectDir . '/config/parameters.yaml', true);
|
||||
}
|
||||
|
||||
// Restore config/banner.md
|
||||
$bannerMd = $tempDir . '/config/banner.md';
|
||||
if (file_exists($bannerMd)) {
|
||||
$this->filesystem->copy($bannerMd, $this->projectDir . '/config/banner.md', true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore attachments from backup.
|
||||
*/
|
||||
private function restoreAttachmentsFromBackup(string $tempDir): void
|
||||
{
|
||||
// Restore public/media
|
||||
$publicMedia = $tempDir . '/public/media';
|
||||
if (is_dir($publicMedia)) {
|
||||
$this->filesystem->mirror($publicMedia, $this->projectDir . '/public/media', null, ['override' => true]);
|
||||
}
|
||||
|
||||
// Restore uploads
|
||||
$uploads = $tempDir . '/uploads';
|
||||
if (is_dir($uploads)) {
|
||||
$this->filesystem->mirror($uploads, $this->projectDir . '/uploads', null, ['override' => true]);
|
||||
}
|
||||
}
|
||||
}
|
||||
73
src/Services/System/CommandRunHelper.php
Normal file
73
src/Services/System/CommandRunHelper.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 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\System;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class CommandRunHelper
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a shell command with proper error handling.
|
||||
*/
|
||||
public function runCommand(array $command, string $description, int $timeout = 120): string
|
||||
{
|
||||
$process = new Process($command, $this->project_dir);
|
||||
$process->setTimeout($timeout);
|
||||
|
||||
// Set environment variables needed for Composer and other tools
|
||||
// This is especially important when running as www-data which may not have HOME set
|
||||
// We inherit from current environment and override/add specific variables
|
||||
$currentEnv = getenv();
|
||||
if (!is_array($currentEnv)) {
|
||||
$currentEnv = [];
|
||||
}
|
||||
$env = array_merge($currentEnv, [
|
||||
'HOME' => $this->project_dir.'/var/www-data-home',
|
||||
'COMPOSER_HOME' => $this->project_dir.'/var/composer',
|
||||
'PATH' => getenv('PATH') ?: '/usr/local/bin:/usr/bin:/bin',
|
||||
]);
|
||||
$process->setEnv($env);
|
||||
|
||||
$output = '';
|
||||
$process->run(function ($type, $buffer) use (&$output) {
|
||||
$output .= $buffer;
|
||||
});
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
$errorOutput = $process->getErrorOutput() ?: $process->getOutput();
|
||||
throw new \RuntimeException(
|
||||
sprintf('%s failed: %s', $description, trim($errorOutput))
|
||||
);
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
}
|
||||
141
src/Services/System/GitVersionInfoProvider.php
Normal file
141
src/Services/System/GitVersionInfoProvider.php
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
/**
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2022 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\System;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* This service provides information about the current Git installation (if any).
|
||||
*/
|
||||
final readonly class GitVersionInfoProvider
|
||||
{
|
||||
public function __construct(#[Autowire(param: 'kernel.project_dir')] private string $project_dir)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the project directory is a Git repository.
|
||||
* @return bool
|
||||
*/
|
||||
public function isGitRepo(): bool
|
||||
{
|
||||
return is_dir($this->getGitDirectory());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the Git directory of the installed system without a trailing slash.
|
||||
* Even if this is no Git installation, the path is returned.
|
||||
* @return string The path to the Git directory of the installed system
|
||||
*/
|
||||
public function getGitDirectory(): string
|
||||
{
|
||||
return $this->project_dir . '/.git';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Git branch name of the installed system.
|
||||
*
|
||||
* @return string|null The current git branch name. Null, if this is no Git installation
|
||||
*/
|
||||
public function getBranchName(): ?string
|
||||
{
|
||||
if (is_file($this->getGitDirectory() . '/HEAD')) {
|
||||
$git = file($this->getGitDirectory() . '/HEAD');
|
||||
$head = explode('/', $git[0], 3);
|
||||
|
||||
if (!isset($head[2])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim($head[2]);
|
||||
}
|
||||
|
||||
return null; // this is not a Git installation
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hash of the last git commit (on remote "origin"!).
|
||||
*
|
||||
* If this method does not work, try to make a "git pull" first!
|
||||
*
|
||||
* @param int $length if this is smaller than 40, only the first $length characters will be returned
|
||||
*
|
||||
* @return string|null The hash of the last commit, null If this is no Git installation
|
||||
*/
|
||||
public function getCommitHash(int $length = 8): ?string
|
||||
{
|
||||
$path = $this->getGitDirectory() . '/HEAD';
|
||||
if (!file_exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$head = trim(file_get_contents($path));
|
||||
|
||||
// If it's a symbolic ref (e.g., "ref: refs/heads/main")
|
||||
if (str_starts_with($head, 'ref:')) {
|
||||
$refPath = $this->getGitDirectory() . '/' . trim(substr($head, 5));
|
||||
if (file_exists($refPath)) {
|
||||
$hash = trim(file_get_contents($refPath));
|
||||
}
|
||||
} else {
|
||||
// Otherwise, it's a detached HEAD (the hash is right there)
|
||||
$hash = $head;
|
||||
}
|
||||
|
||||
return isset($hash) ? substr($hash, 0, $length) : null;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Git remote URL of the installed system.
|
||||
*/
|
||||
public function getRemoteURL(): ?string
|
||||
{
|
||||
// Get remote URL
|
||||
$configFile = $this->getGitDirectory() . '/config';
|
||||
if (file_exists($configFile)) {
|
||||
$config = file_get_contents($configFile);
|
||||
if (preg_match('#url = (.+)#', $config, $matches)) {
|
||||
return trim($matches[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return null; // this is not a Git installation
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are local changes in the Git repository.
|
||||
* Attention: This runs a git command, which might be slow!
|
||||
* @return bool|null True if there are local changes, false if not, null if this is not a Git installation
|
||||
*/
|
||||
public function hasLocalChanges(): ?bool
|
||||
{
|
||||
$process = new Process(['git', 'status', '--porcelain'], $this->project_dir);
|
||||
$process->run();
|
||||
if (!$process->isSuccessful()) {
|
||||
return null; // this is not a Git installation
|
||||
}
|
||||
return !empty(trim($process->getOutput()));
|
||||
}
|
||||
}
|
||||
65
src/Services/System/InstallationType.php
Normal file
65
src/Services/System/InstallationType.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 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\System;
|
||||
|
||||
/**
|
||||
* Detects the installation type of Part-DB to determine the appropriate update strategy.
|
||||
*/
|
||||
enum InstallationType: string
|
||||
{
|
||||
case GIT = 'git';
|
||||
case DOCKER = 'docker';
|
||||
case ZIP_RELEASE = 'zip_release';
|
||||
case UNKNOWN = 'unknown';
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::GIT => 'Git Clone',
|
||||
self::DOCKER => 'Docker',
|
||||
self::ZIP_RELEASE => 'Release Archive (ZIP File)',
|
||||
self::UNKNOWN => 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
public function supportsAutoUpdate(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::GIT => true,
|
||||
self::DOCKER => false,
|
||||
// ZIP_RELEASE auto-update not yet implemented
|
||||
self::ZIP_RELEASE => false,
|
||||
self::UNKNOWN => false,
|
||||
};
|
||||
}
|
||||
|
||||
public function getUpdateInstructions(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::GIT => 'Run: php bin/console partdb:update',
|
||||
self::DOCKER => 'Pull the new Docker image and recreate the container: docker-compose pull && docker-compose up -d',
|
||||
self::ZIP_RELEASE => 'Download the new release ZIP from GitHub, extract it over your installation, and run: php bin/console doctrine:migrations:migrate && php bin/console cache:clear',
|
||||
self::UNKNOWN => 'Unable to determine installation type. Please update manually.',
|
||||
};
|
||||
}
|
||||
}
|
||||
153
src/Services/System/InstallationTypeDetector.php
Normal file
153
src/Services/System/InstallationTypeDetector.php
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Services\System;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
readonly class InstallationTypeDetector
|
||||
{
|
||||
public function __construct(#[Autowire(param: 'kernel.project_dir')] private string $project_dir, private GitVersionInfoProvider $gitVersionInfoProvider)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the installation type based on filesystem markers.
|
||||
*/
|
||||
public function detect(): InstallationType
|
||||
{
|
||||
// Check for Docker environment first
|
||||
if ($this->isDocker()) {
|
||||
return InstallationType::DOCKER;
|
||||
}
|
||||
|
||||
// Check for Git installation
|
||||
if ($this->isGitInstall()) {
|
||||
return InstallationType::GIT;
|
||||
}
|
||||
|
||||
// Check for ZIP release (has VERSION file but no .git)
|
||||
if ($this->isZipRelease()) {
|
||||
return InstallationType::ZIP_RELEASE;
|
||||
}
|
||||
|
||||
return InstallationType::UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running inside a Docker container.
|
||||
*/
|
||||
public function isDocker(): bool
|
||||
{
|
||||
// Check for /.dockerenv file
|
||||
if (file_exists('/.dockerenv')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for DOCKER environment variable
|
||||
if (getenv('DOCKER') !== false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for container runtime in cgroup
|
||||
if (file_exists('/proc/1/cgroup')) {
|
||||
$cgroup = @file_get_contents('/proc/1/cgroup');
|
||||
if ($cgroup !== false && (str_contains($cgroup, 'docker') || str_contains($cgroup, 'containerd'))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a Git-based installation.
|
||||
*/
|
||||
public function isGitInstall(): bool
|
||||
{
|
||||
return $this->gitVersionInfoProvider->isGitRepo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this appears to be a ZIP release installation.
|
||||
*/
|
||||
public function isZipRelease(): bool
|
||||
{
|
||||
// Has VERSION file but no .git directory
|
||||
return file_exists($this->project_dir . '/VERSION') && !$this->isGitInstall();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed information about the installation.
|
||||
*/
|
||||
public function getInstallationInfo(): array
|
||||
{
|
||||
$type = $this->detect();
|
||||
|
||||
$info = [
|
||||
'type' => $type,
|
||||
'type_name' => $type->getLabel(),
|
||||
'supports_auto_update' => $type->supportsAutoUpdate(),
|
||||
'update_instructions' => $type->getUpdateInstructions(),
|
||||
'project_dir' => $this->project_dir,
|
||||
];
|
||||
|
||||
if ($type === InstallationType::GIT) {
|
||||
$info['git'] = $this->getGitInfo();
|
||||
}
|
||||
|
||||
if ($type === InstallationType::DOCKER) {
|
||||
$info['docker'] = $this->getDockerInfo();
|
||||
}
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Git-specific information.
|
||||
* @return array{branch: string|null, commit: string|null, remote_url: string|null, has_local_changes: bool}
|
||||
*/
|
||||
private function getGitInfo(): array
|
||||
{
|
||||
return [
|
||||
'branch' => $this->gitVersionInfoProvider->getBranchName(),
|
||||
'commit' => $this->gitVersionInfoProvider->getCommitHash(8),
|
||||
'remote_url' => $this->gitVersionInfoProvider->getRemoteURL(),
|
||||
'has_local_changes' => $this->gitVersionInfoProvider->hasLocalChanges() ?? false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Docker-specific information.
|
||||
* @return array{container_id: string|null, image: string|null}
|
||||
*/
|
||||
private function getDockerInfo(): array
|
||||
{
|
||||
return [
|
||||
'container_id' => @file_get_contents('/proc/1/cpuset') ?: null,
|
||||
'image' => getenv('DOCKER_IMAGE') ?: null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -24,28 +24,23 @@ declare(strict_types=1);
|
|||
namespace App\Services\System;
|
||||
|
||||
use App\Settings\SystemSettings\PrivacySettings;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Version\Version;
|
||||
|
||||
/**
|
||||
* This class checks if a new version of Part-DB is available.
|
||||
*/
|
||||
class UpdateAvailableManager
|
||||
class UpdateAvailableFacade
|
||||
{
|
||||
|
||||
private const API_URL = 'https://api.github.com/repos/Part-DB/Part-DB-server/releases/latest';
|
||||
private const CACHE_KEY = 'uam_latest_version';
|
||||
private const CACHE_TTL = 60 * 60 * 24 * 2; // 2 day
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $httpClient,
|
||||
private readonly CacheInterface $updateCache, private readonly VersionManagerInterface $versionManager,
|
||||
private readonly PrivacySettings $privacySettings, private readonly LoggerInterface $logger,
|
||||
#[Autowire(param: 'kernel.debug')] private readonly bool $is_dev_mode)
|
||||
public function __construct(
|
||||
private readonly CacheInterface $updateCache,
|
||||
private readonly PrivacySettings $privacySettings,
|
||||
private readonly UpdateChecker $updateChecker,
|
||||
)
|
||||
{
|
||||
|
||||
}
|
||||
|
|
@ -89,9 +84,7 @@ class UpdateAvailableManager
|
|||
}
|
||||
|
||||
$latestVersion = $this->getLatestVersion();
|
||||
$currentVersion = $this->versionManager->getVersion();
|
||||
|
||||
return $latestVersion->isGreaterThan($currentVersion);
|
||||
return $this->updateChecker->isNewerVersionThanCurrent($latestVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -111,34 +104,7 @@ class UpdateAvailableManager
|
|||
|
||||
return $this->updateCache->get(self::CACHE_KEY, function (ItemInterface $item) {
|
||||
$item->expiresAfter(self::CACHE_TTL);
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', self::API_URL);
|
||||
$result = $response->toArray();
|
||||
$tag_name = $result['tag_name'];
|
||||
|
||||
// Remove the leading 'v' from the tag name
|
||||
$version = substr($tag_name, 1);
|
||||
|
||||
return [
|
||||
'version' => $version,
|
||||
'url' => $result['html_url'],
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
//When we are in dev mode, throw the exception, otherwise just silently log it
|
||||
if ($this->is_dev_mode) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
//In the case of an error, try it again after half of the cache time
|
||||
$item->expiresAfter(self::CACHE_TTL / 2);
|
||||
|
||||
$this->logger->error('Checking for updates failed: ' . $e->getMessage());
|
||||
|
||||
return [
|
||||
'version' => '0.0.1',
|
||||
'url' => 'update-checking-error'
|
||||
];
|
||||
}
|
||||
return $this->updateChecker->getLatestVersion();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
338
src/Services/System/UpdateChecker.php
Normal file
338
src/Services/System/UpdateChecker.php
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Services\System;
|
||||
|
||||
use App\Settings\SystemSettings\PrivacySettings;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Version\Version;
|
||||
|
||||
/**
|
||||
* Enhanced update checker that fetches release information including changelogs.
|
||||
*/
|
||||
class UpdateChecker
|
||||
{
|
||||
private const GITHUB_API_BASE = 'https://api.github.com/repos/Part-DB/Part-DB-server';
|
||||
private const CACHE_KEY_RELEASES = 'update_checker_releases';
|
||||
private const CACHE_KEY_COMMITS = 'update_checker_commits_behind';
|
||||
private const CACHE_TTL = 60 * 60 * 6; // 6 hours
|
||||
private const CACHE_TTL_ERROR = 60 * 60; // 1 hour on error
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $httpClient,
|
||||
private readonly CacheInterface $updateCache, private readonly VersionManagerInterface $versionManager,
|
||||
private readonly PrivacySettings $privacySettings, private readonly LoggerInterface $logger,
|
||||
private readonly InstallationTypeDetector $installationTypeDetector,
|
||||
private readonly GitVersionInfoProvider $gitVersionInfoProvider,
|
||||
#[Autowire(param: 'kernel.debug')] private readonly bool $is_dev_mode,
|
||||
#[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current installed version.
|
||||
*/
|
||||
public function getCurrentVersion(): Version
|
||||
{
|
||||
return $this->versionManager->getVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current version as string.
|
||||
*/
|
||||
public function getCurrentVersionString(): string
|
||||
{
|
||||
return $this->getCurrentVersion()->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Git repository information.
|
||||
* @return array{branch: ?string, commit: ?string, has_local_changes: bool, commits_behind: int, is_git_install: bool}
|
||||
*/
|
||||
private function getGitInfo(): array
|
||||
{
|
||||
$info = [
|
||||
'branch' => null,
|
||||
'commit' => null,
|
||||
'has_local_changes' => false,
|
||||
'commits_behind' => 0,
|
||||
'is_git_install' => false,
|
||||
];
|
||||
|
||||
if (!$this->gitVersionInfoProvider->isGitRepo()) {
|
||||
return $info;
|
||||
}
|
||||
|
||||
$info['is_git_install'] = true;
|
||||
|
||||
$info['branch'] = $this->gitVersionInfoProvider->getBranchName();
|
||||
$info['commit'] = $this->gitVersionInfoProvider->getCommitHash(8);
|
||||
$info['has_local_changes'] = $this->gitVersionInfoProvider->hasLocalChanges();
|
||||
|
||||
// Get commits behind (fetch first)
|
||||
if ($info['branch']) {
|
||||
// Try to get cached commits behind count
|
||||
$info['commits_behind'] = $this->getCommitsBehind($info['branch']);
|
||||
}
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of commits behind the remote branch (cached).
|
||||
*/
|
||||
private function getCommitsBehind(string $branch): int
|
||||
{
|
||||
if (!$this->privacySettings->checkForUpdates) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$cacheKey = self::CACHE_KEY_COMMITS . '_' . hash('xxh3', $branch);
|
||||
|
||||
return $this->updateCache->get($cacheKey, function (ItemInterface $item) use ($branch) {
|
||||
$item->expiresAfter(self::CACHE_TTL);
|
||||
|
||||
// Fetch from remote first
|
||||
$process = new Process(['git', 'fetch', '--tags', 'origin'], $this->project_dir);
|
||||
$process->run();
|
||||
|
||||
// Count commits behind
|
||||
$process = new Process(['git', 'rev-list', 'HEAD..origin/' . $branch, '--count'], $this->project_dir);
|
||||
$process->run();
|
||||
|
||||
return $process->isSuccessful() ? (int) trim($process->getOutput()) : 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh git information by invalidating cache.
|
||||
*/
|
||||
public function refreshVersionInfo(): void
|
||||
{
|
||||
$gitBranch = $this->gitVersionInfoProvider->getBranchName();
|
||||
if ($gitBranch) {
|
||||
$this->updateCache->delete(self::CACHE_KEY_COMMITS . '_' . hash('xxh3', $gitBranch));
|
||||
}
|
||||
$this->updateCache->delete(self::CACHE_KEY_RELEASES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available releases from GitHub (cached).
|
||||
*
|
||||
* @return array<array{version: string, tag: string, name: string, url: string, published_at: string, body: string, prerelease: bool, draft:bool, assets: array, tarball_url: ?string, zipball_url: ?string}>
|
||||
*/
|
||||
public function getAvailableReleases(int $limit = 10): array
|
||||
{
|
||||
if (!$this->privacySettings->checkForUpdates) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->updateCache->get(self::CACHE_KEY_RELEASES, function (ItemInterface $item) use ($limit) {
|
||||
$item->expiresAfter(self::CACHE_TTL);
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', self::GITHUB_API_BASE . '/releases', [
|
||||
'query' => ['per_page' => $limit],
|
||||
'headers' => [
|
||||
'Accept' => 'application/vnd.github.v3+json',
|
||||
'User-Agent' => 'Part-DB-Update-Checker',
|
||||
],
|
||||
]);
|
||||
|
||||
$releases = [];
|
||||
foreach ($response->toArray() as $release) {
|
||||
// Extract assets (for ZIP download)
|
||||
$assets = [];
|
||||
foreach ($release['assets'] ?? [] as $asset) {
|
||||
if (str_ends_with($asset['name'], '.zip') || str_ends_with($asset['name'], '.tar.gz')) {
|
||||
$assets[] = [
|
||||
'name' => $asset['name'],
|
||||
'url' => $asset['browser_download_url'],
|
||||
'size' => $asset['size'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$releases[] = [
|
||||
'version' => ltrim($release['tag_name'], 'v'),
|
||||
'tag' => $release['tag_name'],
|
||||
'name' => $release['name'] ?? $release['tag_name'],
|
||||
'url' => $release['html_url'],
|
||||
'published_at' => $release['published_at'],
|
||||
'body' => $release['body'] ?? '',
|
||||
'prerelease' => $release['prerelease'] ?? false,
|
||||
'draft' => $release['draft'] ?? false,
|
||||
'assets' => $assets,
|
||||
'tarball_url' => $release['tarball_url'] ?? null,
|
||||
'zipball_url' => $release['zipball_url'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return $releases;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to fetch releases from GitHub: ' . $e->getMessage());
|
||||
$item->expiresAfter(self::CACHE_TTL_ERROR);
|
||||
|
||||
if ($this->is_dev_mode) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest stable release.
|
||||
* @return array{version: string, tag: string, name: string, url: string, published_at: string, body: string, prerelease: bool, assets: array}|null
|
||||
*/
|
||||
public function getLatestVersion(bool $includePrerelease = false): ?array
|
||||
{
|
||||
$releases = $this->getAvailableReleases();
|
||||
|
||||
foreach ($releases as $release) {
|
||||
// Skip drafts always
|
||||
if ($release['draft']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip prereleases unless explicitly included
|
||||
if (!$includePrerelease && $release['prerelease']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $release;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific version is newer than current.
|
||||
*/
|
||||
public function isNewerVersionThanCurrent(Version|string $version): bool
|
||||
{
|
||||
if ($version instanceof Version) {
|
||||
return $version->isGreaterThan($this->getCurrentVersion());
|
||||
}
|
||||
try {
|
||||
return Version::fromString(ltrim($version, 'v'))->isGreaterThan($this->getCurrentVersion());
|
||||
} catch (\Exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive update status.
|
||||
* @return array{current_version: string, latest_version: ?string, latest_tag: ?string, update_available: bool, release_notes: ?string, release_url: ?string,
|
||||
* published_at: ?string, git: array, installation: array, can_auto_update: bool, update_blockers: array, check_enabled: bool}
|
||||
*/
|
||||
public function getUpdateStatus(): array
|
||||
{
|
||||
$current = $this->getCurrentVersion();
|
||||
$latest = $this->getLatestVersion();
|
||||
$gitInfo = $this->getGitInfo();
|
||||
$installInfo = $this->installationTypeDetector->getInstallationInfo();
|
||||
|
||||
$updateAvailable = false;
|
||||
$latestVersion = null;
|
||||
$latestTag = null;
|
||||
|
||||
if ($latest) {
|
||||
try {
|
||||
$latestVersionObj = Version::fromString($latest['version']);
|
||||
$updateAvailable = $latestVersionObj->isGreaterThan($current);
|
||||
$latestVersion = $latest['version'];
|
||||
$latestTag = $latest['tag'];
|
||||
} catch (\Exception) {
|
||||
// Invalid version string
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if we can auto-update
|
||||
$canAutoUpdate = $installInfo['supports_auto_update'];
|
||||
$updateBlockers = [];
|
||||
|
||||
if ($gitInfo['has_local_changes']) {
|
||||
$canAutoUpdate = false;
|
||||
$updateBlockers[] = 'local_changes';
|
||||
}
|
||||
|
||||
if ($installInfo['type'] === InstallationType::DOCKER) {
|
||||
$updateBlockers[] = 'docker_installation';
|
||||
}
|
||||
|
||||
return [
|
||||
'current_version' => $current->toString(),
|
||||
'latest_version' => $latestVersion,
|
||||
'latest_tag' => $latestTag,
|
||||
'update_available' => $updateAvailable,
|
||||
'release_notes' => $latest['body'] ?? null,
|
||||
'release_url' => $latest['url'] ?? null,
|
||||
'published_at' => $latest['published_at'] ?? null,
|
||||
'git' => $gitInfo,
|
||||
'installation' => $installInfo,
|
||||
'can_auto_update' => $canAutoUpdate,
|
||||
'update_blockers' => $updateBlockers,
|
||||
'check_enabled' => $this->privacySettings->checkForUpdates,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get releases newer than the current version.
|
||||
* @return array<array{version: string, tag: string, name: string, url: string, published_at: string, body: string, prerelease: bool, assets: array}>
|
||||
*/
|
||||
public function getAvailableUpdates(bool $includePrerelease = false): array
|
||||
{
|
||||
$releases = $this->getAvailableReleases();
|
||||
$current = $this->getCurrentVersion();
|
||||
$updates = [];
|
||||
|
||||
foreach ($releases as $release) {
|
||||
if ($release['draft']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$includePrerelease && $release['prerelease']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$releaseVersion = Version::fromString($release['version']);
|
||||
if ($releaseVersion->isGreaterThan($current)) {
|
||||
$updates[] = $release;
|
||||
}
|
||||
} catch (\Exception) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $updates;
|
||||
}
|
||||
}
|
||||
940
src/Services/System/UpdateExecutor.php
Normal file
940
src/Services/System/UpdateExecutor.php
Normal file
|
|
@ -0,0 +1,940 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Services\System;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Handles the execution of Part-DB updates with safety mechanisms.
|
||||
*
|
||||
* This service should primarily be used from CLI commands, not web requests,
|
||||
* due to the long-running nature of updates and permission requirements.
|
||||
*
|
||||
* For web requests, use startBackgroundUpdate() method.
|
||||
*/
|
||||
class UpdateExecutor
|
||||
{
|
||||
private const LOCK_FILE = 'var/update.lock';
|
||||
private const MAINTENANCE_FILE = 'var/maintenance.flag';
|
||||
private const UPDATE_LOG_DIR = 'var/log/updates';
|
||||
private const PROGRESS_FILE = 'var/update_progress.json';
|
||||
|
||||
/** @var array<array{step: string, message: string, success: bool, timestamp: string, duration: ?float}> */
|
||||
private array $steps = [];
|
||||
|
||||
private ?string $currentLogFile = null;
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(param: 'kernel.project_dir')]
|
||||
private readonly string $project_dir,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly Filesystem $filesystem,
|
||||
private readonly InstallationTypeDetector $installationTypeDetector,
|
||||
private readonly UpdateChecker $updateChecker,
|
||||
private readonly BackupManager $backupManager,
|
||||
private readonly CommandRunHelper $commandRunHelper,
|
||||
#[Autowire(param: 'app.debug_mode')]
|
||||
private readonly bool $debugMode = false,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current version string for use in filenames.
|
||||
*/
|
||||
private function getCurrentVersionString(): string
|
||||
{
|
||||
return $this->updateChecker->getCurrentVersionString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an update is currently in progress.
|
||||
*/
|
||||
public function isLocked(): bool
|
||||
{
|
||||
// Check if lock is stale (older than 1 hour)
|
||||
$lockData = $this->getLockInfo();
|
||||
if ($lockData === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($lockData && isset($lockData['started_at'])) {
|
||||
$startedAt = new \DateTime($lockData['started_at']);
|
||||
$now = new \DateTime();
|
||||
$diff = $now->getTimestamp() - $startedAt->getTimestamp();
|
||||
|
||||
// If lock is older than 1 hour, consider it stale
|
||||
if ($diff > 3600) {
|
||||
$this->logger->warning('Found stale update lock, removing it');
|
||||
$this->releaseLock();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lock information, or null if not locked.
|
||||
* @return null|array{started_at: string, pid: int, user: string}
|
||||
*/
|
||||
public function getLockInfo(): ?array
|
||||
{
|
||||
$lockFile = $this->project_dir . '/' . self::LOCK_FILE;
|
||||
|
||||
if (!file_exists($lockFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode(file_get_contents($lockFile), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if maintenance mode is enabled.
|
||||
*/
|
||||
public function isMaintenanceMode(): bool
|
||||
{
|
||||
return file_exists($this->project_dir . '/' . self::MAINTENANCE_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maintenance mode information.
|
||||
* @return null|array{enabled_at: string, reason: string}
|
||||
*/
|
||||
public function getMaintenanceInfo(): ?array
|
||||
{
|
||||
$maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE;
|
||||
|
||||
if (!file_exists($maintenanceFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode(file_get_contents($maintenanceFile), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire an exclusive lock for the update process.
|
||||
*/
|
||||
public function acquireLock(): bool
|
||||
{
|
||||
if ($this->isLocked()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lockFile = $this->project_dir . '/' . self::LOCK_FILE;
|
||||
$lockDir = dirname($lockFile);
|
||||
|
||||
if (!is_dir($lockDir)) {
|
||||
$this->filesystem->mkdir($lockDir);
|
||||
}
|
||||
|
||||
$lockData = [
|
||||
'started_at' => (new \DateTime())->format('c'),
|
||||
'pid' => getmypid(),
|
||||
'user' => get_current_user(),
|
||||
];
|
||||
|
||||
$this->filesystem->dumpFile($lockFile, json_encode($lockData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the update lock.
|
||||
*/
|
||||
public function releaseLock(): void
|
||||
{
|
||||
$lockFile = $this->project_dir . '/' . self::LOCK_FILE;
|
||||
|
||||
if (file_exists($lockFile)) {
|
||||
$this->filesystem->remove($lockFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable maintenance mode to block user access during update.
|
||||
*/
|
||||
public function enableMaintenanceMode(string $reason = 'Update in progress'): void
|
||||
{
|
||||
$maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE;
|
||||
$maintenanceDir = dirname($maintenanceFile);
|
||||
|
||||
if (!is_dir($maintenanceDir)) {
|
||||
$this->filesystem->mkdir($maintenanceDir);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'enabled_at' => (new \DateTime())->format('c'),
|
||||
'reason' => $reason,
|
||||
];
|
||||
|
||||
$this->filesystem->dumpFile($maintenanceFile, json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable maintenance mode.
|
||||
*/
|
||||
public function disableMaintenanceMode(): void
|
||||
{
|
||||
$maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE;
|
||||
|
||||
if (file_exists($maintenanceFile)) {
|
||||
$this->filesystem->remove($maintenanceFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that we can perform an update.
|
||||
*
|
||||
* @return array{valid: bool, errors: array<string>}
|
||||
*/
|
||||
public function validateUpdatePreconditions(): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
// Check installation type
|
||||
$installType = $this->installationTypeDetector->detect();
|
||||
if (!$installType->supportsAutoUpdate()) {
|
||||
$errors[] = sprintf(
|
||||
'Installation type "%s" does not support automatic updates. %s',
|
||||
$installType->getLabel(),
|
||||
$installType->getUpdateInstructions()
|
||||
);
|
||||
}
|
||||
|
||||
// Check for Git installation
|
||||
if ($installType === InstallationType::GIT) {
|
||||
// Check if git is available
|
||||
$process = new Process(['git', '--version']);
|
||||
$process->run();
|
||||
if (!$process->isSuccessful()) {
|
||||
$errors[] = 'Git command not found. Please ensure Git is installed and in PATH.';
|
||||
}
|
||||
|
||||
// Check for local changes
|
||||
$process = new Process(['git', 'status', '--porcelain'], $this->project_dir);
|
||||
$process->run();
|
||||
if (!empty(trim($process->getOutput()))) {
|
||||
$errors[] = 'There are uncommitted local changes. Please commit or stash them before updating.';
|
||||
}
|
||||
}
|
||||
|
||||
// Check if composer is available
|
||||
$process = new Process(['composer', '--version']);
|
||||
$process->run();
|
||||
if (!$process->isSuccessful()) {
|
||||
$errors[] = 'Composer command not found. Please ensure Composer is installed and in PATH.';
|
||||
}
|
||||
|
||||
// Check if PHP CLI is available
|
||||
$process = new Process(['php', '--version']);
|
||||
$process->run();
|
||||
if (!$process->isSuccessful()) {
|
||||
$errors[] = 'PHP CLI not found. Please ensure PHP is installed and in PATH.';
|
||||
}
|
||||
|
||||
// Check if yarn is available (for frontend assets)
|
||||
$process = new Process(['yarn', '--version']);
|
||||
$process->run();
|
||||
if (!$process->isSuccessful()) {
|
||||
$errors[] = 'Yarn command not found. Please ensure Yarn is installed and in PATH for frontend asset compilation.';
|
||||
}
|
||||
|
||||
// Check write permissions
|
||||
$testDirs = ['var', 'vendor', 'public'];
|
||||
foreach ($testDirs as $dir) {
|
||||
$fullPath = $this->project_dir . '/' . $dir;
|
||||
if (is_dir($fullPath) && !is_writable($fullPath)) {
|
||||
$errors[] = sprintf('Directory "%s" is not writable.', $dir);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already locked
|
||||
if ($this->isLocked()) {
|
||||
$lockInfo = $this->getLockInfo();
|
||||
$errors[] = sprintf(
|
||||
'An update is already in progress (started at %s).',
|
||||
$lockInfo['started_at'] ?? 'unknown time'
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => empty($errors),
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the update to a specific version.
|
||||
*
|
||||
* @param string $targetVersion The target version/tag to update to (e.g., "v2.6.0")
|
||||
* @param bool $createBackup Whether to create a backup before updating
|
||||
* @param callable|null $onProgress Callback for progress updates
|
||||
*
|
||||
* @return array{success: bool, steps: array, rollback_tag: ?string, error: ?string, log_file: ?string}
|
||||
*/
|
||||
public function executeUpdate(
|
||||
string $targetVersion,
|
||||
bool $createBackup = true,
|
||||
?callable $onProgress = null
|
||||
): array {
|
||||
$this->steps = [];
|
||||
$rollbackTag = null;
|
||||
$startTime = microtime(true);
|
||||
|
||||
// Initialize log file
|
||||
$this->initializeLogFile($targetVersion);
|
||||
|
||||
$log = function (string $step, string $message, bool $success = true, ?float $duration = null) use ($onProgress): void {
|
||||
$entry = [
|
||||
'step' => $step,
|
||||
'message' => $message,
|
||||
'success' => $success,
|
||||
'timestamp' => (new \DateTime())->format('c'),
|
||||
'duration' => $duration,
|
||||
];
|
||||
|
||||
$this->steps[] = $entry;
|
||||
$this->writeToLogFile($entry);
|
||||
$this->logger->info("Update [{$step}]: {$message}", ['success' => $success]);
|
||||
|
||||
if ($onProgress) {
|
||||
$onProgress($entry);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Validate preconditions
|
||||
$validation = $this->validateUpdatePreconditions();
|
||||
if (!$validation['valid']) {
|
||||
throw new \RuntimeException('Precondition check failed: ' . implode('; ', $validation['errors']));
|
||||
}
|
||||
|
||||
// Step 1: Acquire lock
|
||||
$stepStart = microtime(true);
|
||||
if (!$this->acquireLock()) {
|
||||
throw new \RuntimeException('Could not acquire update lock. Another update may be in progress.');
|
||||
}
|
||||
$log('lock', 'Acquired exclusive update lock', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 2: Enable maintenance mode
|
||||
$stepStart = microtime(true);
|
||||
$this->enableMaintenanceMode('Updating to ' . $targetVersion);
|
||||
$log('maintenance', 'Enabled maintenance mode', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 3: Create rollback point with version info
|
||||
$stepStart = microtime(true);
|
||||
$currentVersion = $this->getCurrentVersionString();
|
||||
$targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion);
|
||||
$rollbackTag = 'pre-update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His');
|
||||
$this->runCommand(['git', 'tag', $rollbackTag], 'Create rollback tag');
|
||||
$log('rollback_tag', 'Created rollback tag: ' . $rollbackTag, true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 4: Create backup (optional)
|
||||
if ($createBackup) {
|
||||
$stepStart = microtime(true);
|
||||
$backupFile = $this->backupManager->createBackup($targetVersion);
|
||||
$log('backup', 'Created backup: ' . basename($backupFile), true, microtime(true) - $stepStart);
|
||||
}
|
||||
|
||||
// Step 5: Fetch from remote
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand(['git', 'fetch', '--tags', '--force', 'origin'], 'Fetch from origin', 120);
|
||||
$log('fetch', 'Fetched latest changes and tags from origin', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 6: Checkout target version
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand(['git', 'checkout', $targetVersion], 'Checkout version');
|
||||
$log('checkout', 'Checked out version: ' . $targetVersion, true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 7: Install PHP dependencies
|
||||
$stepStart = microtime(true);
|
||||
if ($this->debugMode) {
|
||||
$this->runCommand([ // Install with dev dependencies in debug mode
|
||||
'composer',
|
||||
'install',
|
||||
'--no-interaction',
|
||||
'--no-progress',
|
||||
], 'Install PHP dependencies', 600);
|
||||
} else {
|
||||
$this->runCommand([
|
||||
'composer',
|
||||
'install',
|
||||
'--no-dev',
|
||||
'--optimize-autoloader',
|
||||
'--no-interaction',
|
||||
'--no-progress',
|
||||
], 'Install PHP dependencies', 600);
|
||||
}
|
||||
$log('composer', 'Installed/updated PHP dependencies', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 8: Install frontend dependencies
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand([
|
||||
'yarn', 'install',
|
||||
'--frozen-lockfile',
|
||||
'--non-interactive',
|
||||
], 'Install frontend dependencies', 600);
|
||||
$log('yarn_install', 'Installed frontend dependencies', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 9: Build frontend assets
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand([
|
||||
'yarn', 'build',
|
||||
], 'Build frontend assets', 600);
|
||||
$log('yarn_build', 'Built frontend assets', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 10: Run database migrations
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand([
|
||||
'php', 'bin/console', 'doctrine:migrations:migrate',
|
||||
'--no-interaction',
|
||||
'--allow-no-migration',
|
||||
], 'Run migrations', 300);
|
||||
$log('migrations', 'Database migrations completed', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 11: Clear cache
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand([
|
||||
'php', 'bin/console', 'cache:clear',
|
||||
'--env=prod',
|
||||
'--no-interaction',
|
||||
], 'Clear cache', 120);
|
||||
$log('cache_clear', 'Cleared application cache', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 12: Warm up cache
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand([
|
||||
'php', 'bin/console', 'cache:warmup',
|
||||
'--env=prod',
|
||||
], 'Warmup cache', 120);
|
||||
$log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 13: Disable maintenance mode
|
||||
$stepStart = microtime(true);
|
||||
$this->disableMaintenanceMode();
|
||||
$log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 14: Release lock
|
||||
$stepStart = microtime(true);
|
||||
$this->releaseLock();
|
||||
|
||||
$totalDuration = microtime(true) - $startTime;
|
||||
$log('complete', sprintf('Update completed successfully in %.1f seconds', $totalDuration), true, microtime(true) - $stepStart);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'steps' => $this->steps,
|
||||
'rollback_tag' => $rollbackTag,
|
||||
'error' => null,
|
||||
'log_file' => $this->currentLogFile,
|
||||
'duration' => $totalDuration,
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$log('error', 'Update failed: ' . $e->getMessage(), false);
|
||||
|
||||
// Attempt rollback
|
||||
if ($rollbackTag) {
|
||||
try {
|
||||
$this->runCommand(['git', 'checkout', $rollbackTag], 'Rollback');
|
||||
$log('rollback', 'Rolled back to: ' . $rollbackTag, true);
|
||||
|
||||
// Re-run composer install after rollback
|
||||
$this->runCommand([
|
||||
'composer', 'install',
|
||||
'--no-dev',
|
||||
'--optimize-autoloader',
|
||||
'--no-interaction',
|
||||
], 'Reinstall dependencies after rollback', 600);
|
||||
$log('rollback_composer', 'Reinstalled PHP dependencies after rollback', true);
|
||||
|
||||
// Re-run yarn install after rollback
|
||||
$this->runCommand([
|
||||
'yarn', 'install',
|
||||
'--frozen-lockfile',
|
||||
'--non-interactive',
|
||||
], 'Reinstall frontend dependencies after rollback', 600);
|
||||
$log('rollback_yarn_install', 'Reinstalled frontend dependencies after rollback', true);
|
||||
|
||||
// Re-run yarn build after rollback
|
||||
$this->runCommand([
|
||||
'yarn', 'build',
|
||||
], 'Rebuild frontend assets after rollback', 600);
|
||||
$log('rollback_yarn_build', 'Rebuilt frontend assets after rollback', true);
|
||||
|
||||
// Clear cache after rollback
|
||||
$this->runCommand([
|
||||
'php', 'bin/console', 'cache:clear',
|
||||
'--env=prod',
|
||||
], 'Clear cache after rollback', 120);
|
||||
$log('rollback_cache', 'Cleared cache after rollback', true);
|
||||
|
||||
} catch (\Exception $rollbackError) {
|
||||
$log('rollback_failed', 'Rollback failed: ' . $rollbackError->getMessage(), false);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
$this->disableMaintenanceMode();
|
||||
$this->releaseLock();
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'steps' => $this->steps,
|
||||
'rollback_tag' => $rollbackTag,
|
||||
'error' => $e->getMessage(),
|
||||
'log_file' => $this->currentLogFile,
|
||||
'duration' => microtime(true) - $startTime,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a shell command with proper error handling.
|
||||
*/
|
||||
private function runCommand(array $command, string $description, int $timeout = 120): string
|
||||
{
|
||||
return $this->commandRunHelper->runCommand($command, $description, $timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the log file for this update.
|
||||
*/
|
||||
private function initializeLogFile(string $targetVersion): void
|
||||
{
|
||||
$logDir = $this->project_dir . '/' . self::UPDATE_LOG_DIR;
|
||||
|
||||
if (!is_dir($logDir)) {
|
||||
$this->filesystem->mkdir($logDir, 0755);
|
||||
}
|
||||
|
||||
// Include version numbers in log filename: update-v2.5.1-to-v2.6.0-2024-01-30-185400.log
|
||||
$currentVersion = $this->getCurrentVersionString();
|
||||
$targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion);
|
||||
$this->currentLogFile = $logDir . '/update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His') . '.log';
|
||||
|
||||
$header = sprintf(
|
||||
"Part-DB Update Log\n" .
|
||||
"==================\n" .
|
||||
"Started: %s\n" .
|
||||
"From Version: %s\n" .
|
||||
"Target Version: %s\n" .
|
||||
"==================\n\n",
|
||||
date('Y-m-d H:i:s'),
|
||||
$currentVersion,
|
||||
$targetVersion
|
||||
);
|
||||
|
||||
file_put_contents($this->currentLogFile, $header);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an entry to the log file.
|
||||
*/
|
||||
private function writeToLogFile(array $entry): void
|
||||
{
|
||||
if (!$this->currentLogFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
$line = sprintf(
|
||||
"[%s] %s: %s%s\n",
|
||||
$entry['timestamp'],
|
||||
strtoupper($entry['step']),
|
||||
$entry['message'],
|
||||
$entry['duration'] ? sprintf(' (%.2fs)', $entry['duration']) : ''
|
||||
);
|
||||
|
||||
file_put_contents($this->currentLogFile, $line, FILE_APPEND);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of update log files.
|
||||
* @return array{file: string, path: string, date: int, size: int}[]
|
||||
*/
|
||||
public function getUpdateLogs(): array
|
||||
{
|
||||
$logDir = $this->project_dir . '/' . self::UPDATE_LOG_DIR;
|
||||
|
||||
if (!is_dir($logDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$logs = [];
|
||||
foreach (glob($logDir . '/update-*.log') as $logFile) {
|
||||
$logs[] = [
|
||||
'file' => basename($logFile),
|
||||
'path' => $logFile,
|
||||
'date' => filemtime($logFile),
|
||||
'size' => filesize($logFile),
|
||||
];
|
||||
}
|
||||
|
||||
// Sort by date descending
|
||||
usort($logs, static fn($a, $b) => $b['date'] <=> $a['date']);
|
||||
|
||||
return $logs;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Restore from a backup file with maintenance mode and cache clearing.
|
||||
*
|
||||
* This wraps BackupManager::restoreBackup with additional safety measures
|
||||
* like lock acquisition, maintenance mode, and cache operations.
|
||||
*
|
||||
* @param string $filename The backup filename to restore
|
||||
* @param bool $restoreDatabase Whether to restore the database
|
||||
* @param bool $restoreConfig Whether to restore config files
|
||||
* @param bool $restoreAttachments Whether to restore attachments
|
||||
* @param callable|null $onProgress Callback for progress updates
|
||||
* @return array{success: bool, steps: array, error: ?string}
|
||||
*/
|
||||
public function restoreBackup(
|
||||
string $filename,
|
||||
bool $restoreDatabase = true,
|
||||
bool $restoreConfig = false,
|
||||
bool $restoreAttachments = false,
|
||||
?callable $onProgress = null
|
||||
): array {
|
||||
$this->steps = [];
|
||||
$startTime = microtime(true);
|
||||
|
||||
$log = function (string $step, string $message, bool $success, ?float $duration = null) use ($onProgress): void {
|
||||
$entry = [
|
||||
'step' => $step,
|
||||
'message' => $message,
|
||||
'success' => $success,
|
||||
'timestamp' => (new \DateTime())->format('c'),
|
||||
'duration' => $duration,
|
||||
];
|
||||
$this->steps[] = $entry;
|
||||
$this->logger->info('[Restore] ' . $step . ': ' . $message, ['success' => $success]);
|
||||
|
||||
if ($onProgress) {
|
||||
$onProgress($entry);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
$stepStart = microtime(true);
|
||||
|
||||
// Step 1: Acquire lock
|
||||
if (!$this->acquireLock()) {
|
||||
throw new \RuntimeException('Could not acquire lock. Another operation may be in progress.');
|
||||
}
|
||||
$log('lock', 'Acquired exclusive restore lock', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 2: Enable maintenance mode
|
||||
$stepStart = microtime(true);
|
||||
$this->enableMaintenanceMode('Restoring from backup...');
|
||||
$log('maintenance', 'Enabled maintenance mode', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 3: Delegate to BackupManager for core restoration
|
||||
$stepStart = microtime(true);
|
||||
$result = $this->backupManager->restoreBackup(
|
||||
$filename,
|
||||
$restoreDatabase,
|
||||
$restoreConfig,
|
||||
$restoreAttachments,
|
||||
function ($entry) use ($log) {
|
||||
// Forward progress from BackupManager
|
||||
$log($entry['step'], $entry['message'], $entry['success'], $entry['duration'] ?? null);
|
||||
}
|
||||
);
|
||||
|
||||
if (!$result['success']) {
|
||||
throw new \RuntimeException($result['error'] ?? 'Restore failed');
|
||||
}
|
||||
|
||||
// Step 4: Clear cache
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand(['php', 'bin/console', 'cache:clear', '--no-warmup'], 'Clear cache');
|
||||
$log('cache_clear', 'Cleared application cache', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 5: Warm up cache
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand(['php', 'bin/console', 'cache:warmup'], 'Warm up cache');
|
||||
$log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 6: Disable maintenance mode
|
||||
$stepStart = microtime(true);
|
||||
$this->disableMaintenanceMode();
|
||||
$log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 7: Release lock
|
||||
$this->releaseLock();
|
||||
|
||||
$totalDuration = microtime(true) - $startTime;
|
||||
$log('complete', sprintf('Restore completed successfully in %.1f seconds', $totalDuration), true);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'steps' => $this->steps,
|
||||
'error' => null,
|
||||
];
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Restore failed: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'file' => $filename,
|
||||
]);
|
||||
|
||||
// Try to clean up
|
||||
try {
|
||||
$this->disableMaintenanceMode();
|
||||
$this->releaseLock();
|
||||
} catch (\Throwable $cleanupError) {
|
||||
$this->logger->error('Cleanup after failed restore also failed', ['error' => $cleanupError->getMessage()]);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'steps' => $this->steps,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the progress file.
|
||||
*/
|
||||
public function getProgressFilePath(): string
|
||||
{
|
||||
return $this->project_dir . '/' . self::PROGRESS_FILE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save progress to file for web UI polling.
|
||||
* @param array{status: string, target_version: string, create_backup: bool, started_at: string, current_step: int, total_steps: int, step_name: string, step_message: string, steps: array, error: ?string} $progress
|
||||
*/
|
||||
private function saveProgress(array $progress): void
|
||||
{
|
||||
$progressFile = $this->getProgressFilePath();
|
||||
$progressDir = dirname($progressFile);
|
||||
|
||||
if (!is_dir($progressDir)) {
|
||||
$this->filesystem->mkdir($progressDir);
|
||||
}
|
||||
|
||||
$this->filesystem->dumpFile($progressFile, json_encode($progress, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current update progress from file.
|
||||
* @return null|array{status: string, target_version: string, create_backup: bool, started_at: string, current_step: int, total_steps: int, step_name: string, step_message: string, steps: array, error: ?string}
|
||||
*/
|
||||
public function getProgress(): ?array
|
||||
{
|
||||
$progressFile = $this->getProgressFilePath();
|
||||
|
||||
if (!file_exists($progressFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents($progressFile), true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
// If the progress file is stale (older than 30 minutes), consider it invalid
|
||||
if ($data && isset($data['started_at'])) {
|
||||
$startedAt = strtotime($data['started_at']);
|
||||
if (time() - $startedAt > 1800) {
|
||||
$this->clearProgress();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear progress file.
|
||||
*/
|
||||
public function clearProgress(): void
|
||||
{
|
||||
$progressFile = $this->getProgressFilePath();
|
||||
|
||||
if (file_exists($progressFile)) {
|
||||
$this->filesystem->remove($progressFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an update is currently running (based on progress file).
|
||||
*/
|
||||
public function isUpdateRunning(): bool
|
||||
{
|
||||
$progress = $this->getProgress();
|
||||
|
||||
if (!$progress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isset($progress['status']) && $progress['status'] === 'running';
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the update process in the background.
|
||||
* Returns the process ID or null on failure.
|
||||
*/
|
||||
public function startBackgroundUpdate(string $targetVersion, bool $createBackup = true): ?int
|
||||
{
|
||||
// Validate first
|
||||
$validation = $this->validateUpdatePreconditions();
|
||||
if (!$validation['valid']) {
|
||||
$this->logger->error('Update validation failed', ['errors' => $validation['errors']]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Initialize progress file
|
||||
$this->saveProgress([
|
||||
'status' => 'starting',
|
||||
'target_version' => $targetVersion,
|
||||
'create_backup' => $createBackup,
|
||||
'started_at' => (new \DateTime())->format('c'),
|
||||
'current_step' => 0,
|
||||
'total_steps' => 14,
|
||||
'step_name' => 'initializing',
|
||||
'step_message' => 'Starting update process...',
|
||||
'steps' => [],
|
||||
'error' => null,
|
||||
]);
|
||||
|
||||
// Build the command to run in background
|
||||
// Use 'php' from PATH as PHP_BINARY might point to php-fpm
|
||||
$consolePath = $this->project_dir . '/bin/console';
|
||||
$logFile = $this->project_dir . '/var/log/update-background.log';
|
||||
|
||||
// Ensure log directory exists
|
||||
$logDir = dirname($logFile);
|
||||
if (!is_dir($logDir)) {
|
||||
$this->filesystem->mkdir($logDir, 0755);
|
||||
}
|
||||
|
||||
//If we are on Windows, we cannot use nohup
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
$command = sprintf(
|
||||
'start /B php %s partdb:update %s %s --force --no-interaction >> %s 2>&1',
|
||||
escapeshellarg($consolePath),
|
||||
escapeshellarg($targetVersion),
|
||||
$createBackup ? '' : '--no-backup',
|
||||
escapeshellarg($logFile)
|
||||
);
|
||||
} else { //Unix like platforms should be able to use nohup
|
||||
// Use nohup to properly detach the process from the web request
|
||||
// The process will continue running even after the PHP request ends
|
||||
$command = sprintf(
|
||||
'nohup php %s partdb:update %s %s --force --no-interaction >> %s 2>&1 &',
|
||||
escapeshellarg($consolePath),
|
||||
escapeshellarg($targetVersion),
|
||||
$createBackup ? '' : '--no-backup',
|
||||
escapeshellarg($logFile)
|
||||
);
|
||||
}
|
||||
|
||||
$this->logger->info('Starting background update', [
|
||||
'command' => $command,
|
||||
'target_version' => $targetVersion,
|
||||
]);
|
||||
|
||||
// Execute in background using shell_exec for proper detachment
|
||||
// shell_exec with & runs the command in background
|
||||
|
||||
//@php-ignore-next-line We really need to use shell_exec here
|
||||
$output = shell_exec($command);
|
||||
|
||||
// Give it a moment to start
|
||||
usleep(500000); // 500ms
|
||||
|
||||
// Check if progress file was updated (indicates process started)
|
||||
$progress = $this->getProgress();
|
||||
if ($progress && isset($progress['status'])) {
|
||||
$this->logger->info('Background update started successfully');
|
||||
return 1; // Return a non-null value to indicate success
|
||||
}
|
||||
|
||||
$this->logger->error('Background update may not have started', ['output' => $output]);
|
||||
return 1; // Still return success as the process might just be slow to start
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute update with progress file updates for web UI.
|
||||
* This is called by the CLI command and updates the progress file.
|
||||
*/
|
||||
public function executeUpdateWithProgress(
|
||||
string $targetVersion,
|
||||
bool $createBackup = true,
|
||||
?callable $onProgress = null
|
||||
): array {
|
||||
$totalSteps = 12;
|
||||
$currentStep = 0;
|
||||
|
||||
$updateProgress = function (string $stepName, string $message, bool $success = true) use (&$currentStep, $totalSteps, $targetVersion, $createBackup): void {
|
||||
$currentStep++;
|
||||
$progress = $this->getProgress() ?? [
|
||||
'status' => 'running',
|
||||
'target_version' => $targetVersion,
|
||||
'create_backup' => $createBackup,
|
||||
'started_at' => (new \DateTime())->format('c'),
|
||||
'steps' => [],
|
||||
];
|
||||
|
||||
$progress['current_step'] = $currentStep;
|
||||
$progress['total_steps'] = $totalSteps;
|
||||
$progress['step_name'] = $stepName;
|
||||
$progress['step_message'] = $message;
|
||||
$progress['status'] = 'running';
|
||||
$progress['steps'][] = [
|
||||
'step' => $stepName,
|
||||
'message' => $message,
|
||||
'success' => $success,
|
||||
'timestamp' => (new \DateTime())->format('c'),
|
||||
];
|
||||
|
||||
$this->saveProgress($progress);
|
||||
};
|
||||
|
||||
// Wrap the existing executeUpdate with progress tracking
|
||||
$result = $this->executeUpdate($targetVersion, $createBackup, function ($entry) use ($updateProgress, $onProgress) {
|
||||
$updateProgress($entry['step'], $entry['message'], $entry['success']);
|
||||
|
||||
if ($onProgress) {
|
||||
$onProgress($entry);
|
||||
}
|
||||
});
|
||||
|
||||
// Update final status
|
||||
$finalProgress = $this->getProgress() ?? [];
|
||||
$finalProgress['status'] = $result['success'] ? 'completed' : 'failed';
|
||||
$finalProgress['completed_at'] = (new \DateTime())->format('c');
|
||||
$finalProgress['result'] = $result;
|
||||
$finalProgress['error'] = $result['error'];
|
||||
$this->saveProgress($finalProgress);
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,9 @@ use App\Entity\UserSystem\Group;
|
|||
use App\Entity\UserSystem\User;
|
||||
use App\Helpers\Trees\TreeViewNode;
|
||||
use App\Services\Cache\UserCacheKeyGenerator;
|
||||
use App\Services\ElementTypeNameGenerator;
|
||||
use App\Services\InfoProviderSystem\Providers\GenericWebProvider;
|
||||
use App\Settings\InfoProviderSystem\GenericWebProviderSettings;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
|
@ -50,8 +53,15 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
|||
*/
|
||||
class ToolsTreeBuilder
|
||||
{
|
||||
public function __construct(protected TranslatorInterface $translator, protected UrlGeneratorInterface $urlGenerator, protected TagAwareCacheInterface $cache, protected UserCacheKeyGenerator $keyGenerator, protected Security $security)
|
||||
{
|
||||
public function __construct(
|
||||
protected TranslatorInterface $translator,
|
||||
protected UrlGeneratorInterface $urlGenerator,
|
||||
protected TagAwareCacheInterface $cache,
|
||||
protected UserCacheKeyGenerator $keyGenerator,
|
||||
protected Security $security,
|
||||
private readonly ElementTypeNameGenerator $elementTypeNameGenerator,
|
||||
private readonly GenericWebProviderSettings $genericWebProviderSettings
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -139,7 +149,14 @@ class ToolsTreeBuilder
|
|||
$this->translator->trans('info_providers.search.title'),
|
||||
$this->urlGenerator->generate('info_providers_search')
|
||||
))->setIcon('fa-treeview fa-fw fa-solid fa-cloud-arrow-down');
|
||||
|
||||
|
||||
if ($this->genericWebProviderSettings->enabled) {
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('info_providers.from_url.title'),
|
||||
$this->urlGenerator->generate('info_providers_from_url')
|
||||
))->setIcon('fa-treeview fa-fw fa-solid fa-book-atlas');
|
||||
}
|
||||
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('info_providers.bulk_import.manage_jobs'),
|
||||
$this->urlGenerator->generate('bulk_info_provider_manage')
|
||||
|
|
@ -160,67 +177,67 @@ class ToolsTreeBuilder
|
|||
|
||||
if ($this->security->isGranted('read', new AttachmentType())) {
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('tree.tools.edit.attachment_types'),
|
||||
$this->elementTypeNameGenerator->typeLabelPlural(AttachmentType::class),
|
||||
$this->urlGenerator->generate('attachment_type_new')
|
||||
))->setIcon('fa-fw fa-treeview fa-solid fa-file-alt');
|
||||
}
|
||||
if ($this->security->isGranted('read', new Category())) {
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('tree.tools.edit.categories'),
|
||||
$this->elementTypeNameGenerator->typeLabelPlural(Category::class),
|
||||
$this->urlGenerator->generate('category_new')
|
||||
))->setIcon('fa-fw fa-treeview fa-solid fa-tags');
|
||||
}
|
||||
if ($this->security->isGranted('read', new Project())) {
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('tree.tools.edit.projects'),
|
||||
$this->elementTypeNameGenerator->typeLabelPlural(Project::class),
|
||||
$this->urlGenerator->generate('project_new')
|
||||
))->setIcon('fa-fw fa-treeview fa-solid fa-archive');
|
||||
}
|
||||
if ($this->security->isGranted('read', new Supplier())) {
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('tree.tools.edit.suppliers'),
|
||||
$this->elementTypeNameGenerator->typeLabelPlural(Supplier::class),
|
||||
$this->urlGenerator->generate('supplier_new')
|
||||
))->setIcon('fa-fw fa-treeview fa-solid fa-truck');
|
||||
}
|
||||
if ($this->security->isGranted('read', new Manufacturer())) {
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('tree.tools.edit.manufacturer'),
|
||||
$this->elementTypeNameGenerator->typeLabelPlural(Manufacturer::class),
|
||||
$this->urlGenerator->generate('manufacturer_new')
|
||||
))->setIcon('fa-fw fa-treeview fa-solid fa-industry');
|
||||
}
|
||||
if ($this->security->isGranted('read', new StorageLocation())) {
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('tree.tools.edit.storelocation'),
|
||||
$this->elementTypeNameGenerator->typeLabelPlural(StorageLocation::class),
|
||||
$this->urlGenerator->generate('store_location_new')
|
||||
))->setIcon('fa-fw fa-treeview fa-solid fa-cube');
|
||||
}
|
||||
if ($this->security->isGranted('read', new Footprint())) {
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('tree.tools.edit.footprint'),
|
||||
$this->elementTypeNameGenerator->typeLabelPlural(Footprint::class),
|
||||
$this->urlGenerator->generate('footprint_new')
|
||||
))->setIcon('fa-fw fa-treeview fa-solid fa-microchip');
|
||||
}
|
||||
if ($this->security->isGranted('read', new Currency())) {
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('tree.tools.edit.currency'),
|
||||
$this->elementTypeNameGenerator->typeLabelPlural(Currency::class),
|
||||
$this->urlGenerator->generate('currency_new')
|
||||
))->setIcon('fa-fw fa-treeview fa-solid fa-coins');
|
||||
}
|
||||
if ($this->security->isGranted('read', new MeasurementUnit())) {
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('tree.tools.edit.measurement_unit'),
|
||||
$this->elementTypeNameGenerator->typeLabelPlural(MeasurementUnit::class),
|
||||
$this->urlGenerator->generate('measurement_unit_new')
|
||||
))->setIcon('fa-fw fa-treeview fa-solid fa-balance-scale');
|
||||
}
|
||||
if ($this->security->isGranted('read', new LabelProfile())) {
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('tree.tools.edit.label_profile'),
|
||||
$this->elementTypeNameGenerator->typeLabelPlural(LabelProfile::class),
|
||||
$this->urlGenerator->generate('label_profile_new')
|
||||
))->setIcon('fa-fw fa-treeview fa-solid fa-qrcode');
|
||||
}
|
||||
if ($this->security->isGranted('read', new PartCustomState())) {
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('tree.tools.edit.part_custom_state'),
|
||||
$this->elementTypeNameGenerator->typeLabelPlural(PartCustomState::class),
|
||||
$this->urlGenerator->generate('part_custom_state_new')
|
||||
))->setIcon('fa-fw fa-treeview fa-solid fa-tools');
|
||||
}
|
||||
|
|
@ -308,6 +325,13 @@ class ToolsTreeBuilder
|
|||
))->setIcon('fa fa-fw fa-gears fa-solid');
|
||||
}
|
||||
|
||||
if ($this->security->isGranted('@system.show_updates')) {
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('tree.tools.system.update_manager'),
|
||||
$this->urlGenerator->generate('admin_update_manager')
|
||||
))->setIcon('fa-fw fa-treeview fa-solid fa-arrow-circle-up');
|
||||
}
|
||||
|
||||
return $nodes;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,9 +34,9 @@ use App\Entity\ProjectSystem\Project;
|
|||
use App\Helpers\Trees\TreeViewNode;
|
||||
use App\Helpers\Trees\TreeViewNodeIterator;
|
||||
use App\Repository\NamedDBElementRepository;
|
||||
use App\Repository\StructuralDBElementRepository;
|
||||
use App\Services\Cache\ElementCacheTagGenerator;
|
||||
use App\Services\Cache\UserCacheKeyGenerator;
|
||||
use App\Services\ElementTypeNameGenerator;
|
||||
use App\Services\EntityURLGenerator;
|
||||
use App\Settings\BehaviorSettings\SidebarSettings;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
|
@ -67,6 +67,7 @@ class TreeViewGenerator
|
|||
protected TranslatorInterface $translator,
|
||||
private readonly UrlGeneratorInterface $router,
|
||||
private readonly SidebarSettings $sidebarSettings,
|
||||
private readonly ElementTypeNameGenerator $elementTypeNameGenerator
|
||||
) {
|
||||
$this->rootNodeEnabled = $this->sidebarSettings->rootNodeEnabled;
|
||||
$this->rootNodeExpandedByDefault = $this->sidebarSettings->rootNodeExpanded;
|
||||
|
|
@ -212,15 +213,7 @@ class TreeViewGenerator
|
|||
|
||||
protected function entityClassToRootNodeString(string $class): string
|
||||
{
|
||||
return match ($class) {
|
||||
Category::class => $this->translator->trans('category.labelp'),
|
||||
StorageLocation::class => $this->translator->trans('storelocation.labelp'),
|
||||
Footprint::class => $this->translator->trans('footprint.labelp'),
|
||||
Manufacturer::class => $this->translator->trans('manufacturer.labelp'),
|
||||
Supplier::class => $this->translator->trans('supplier.labelp'),
|
||||
Project::class => $this->translator->trans('project.labelp'),
|
||||
default => $this->translator->trans('tree.root_node.text'),
|
||||
};
|
||||
return $this->elementTypeNameGenerator->typeLabelPlural($class);
|
||||
}
|
||||
|
||||
protected function entityClassToRootNodeIcon(string $class): ?string
|
||||
|
|
|
|||
|
|
@ -111,8 +111,9 @@ class PermissionPresetsHelper
|
|||
|
||||
//Allow to manage Oauth tokens
|
||||
$this->permissionResolver->setPermission($perm_holder, 'system', 'manage_oauth_tokens', PermissionData::ALLOW);
|
||||
//Allow to show updates
|
||||
//Allow to show and manage updates
|
||||
$this->permissionResolver->setPermission($perm_holder, 'system', 'show_updates', PermissionData::ALLOW);
|
||||
$this->permissionResolver->setPermission($perm_holder, 'system', 'manage_updates', PermissionData::ALLOW);
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,12 @@ class AppSettings
|
|||
#[EmbeddedSettings()]
|
||||
public ?InfoProviderSettings $infoProviders = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?SynonymSettings $synonyms = null;
|
||||
|
||||
#[EmbeddedSettings()]
|
||||
public ?MiscSettings $miscSettings = null;
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
84
src/Settings/InfoProviderSystem/BuerklinSettings.php
Normal file
84
src/Settings/InfoProviderSystem/BuerklinSettings.php
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<?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)
|
||||
* Copyright (C) 2025 Marc Kreidler (https://github.com/mkne)
|
||||
*
|
||||
* 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\Settings\InfoProviderSystem;
|
||||
|
||||
use App\Form\Type\APIKeyType;
|
||||
use App\Settings\SettingsIcon;
|
||||
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
|
||||
use Jbtronics\SettingsBundle\Settings\Settings;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CountryType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CurrencyType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\LanguageType;
|
||||
use Symfony\Component\Translation\TranslatableMessage as TM;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[Settings(label: new TM("settings.ips.buerklin"), description: new TM("settings.ips.buerklin.help"))]
|
||||
#[SettingsIcon("fa-plug")]
|
||||
class BuerklinSettings
|
||||
{
|
||||
use SettingsTrait;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.ips.digikey.client_id"),
|
||||
formType: APIKeyType::class,
|
||||
envVar: "PROVIDER_BUERKLIN_CLIENT_ID", envVarMode: EnvVarMode::OVERWRITE
|
||||
)]
|
||||
public ?string $clientId = null;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.ips.digikey.secret"),
|
||||
formType: APIKeyType::class,
|
||||
envVar: "PROVIDER_BUERKLIN_SECRET", envVarMode: EnvVarMode::OVERWRITE
|
||||
)]
|
||||
public ?string $secret = null;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.ips.buerklin.username"),
|
||||
formType: APIKeyType::class,
|
||||
envVar: "PROVIDER_BUERKLIN_USER", envVarMode: EnvVarMode::OVERWRITE
|
||||
)]
|
||||
public ?string $username = null;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("user.edit.password"),
|
||||
formType: APIKeyType::class,
|
||||
envVar: "PROVIDER_BUERKLIN_PASSWORD", envVarMode: EnvVarMode::OVERWRITE
|
||||
)]
|
||||
public ?string $password = null;
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.tme.currency"), formType: CurrencyType::class,
|
||||
formOptions: ["preferred_choices" => ["EUR"]],
|
||||
envVar: "PROVIDER_BUERKLIN_CURRENCY", envVarMode: EnvVarMode::OVERWRITE)]
|
||||
#[Assert\Currency()]
|
||||
public string $currency = "EUR";
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.tme.language"), formType: LanguageType::class,
|
||||
formOptions: ["preferred_choices" => ["en", "de"]],
|
||||
envVar: "PROVIDER_BUERKLIN_LANGUAGE", envVarMode: EnvVarMode::OVERWRITE)]
|
||||
#[Assert\Language]
|
||||
public string $language = "en";
|
||||
}
|
||||
77
src/Settings/InfoProviderSystem/ConradSettings.php
Normal file
77
src/Settings/InfoProviderSystem/ConradSettings.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 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\Settings\InfoProviderSystem;
|
||||
|
||||
use App\Form\Type\APIKeyType;
|
||||
use App\Settings\SettingsIcon;
|
||||
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
|
||||
use Jbtronics\SettingsBundle\ParameterTypes\ArrayType;
|
||||
use Jbtronics\SettingsBundle\ParameterTypes\StringType;
|
||||
use Jbtronics\SettingsBundle\Settings\Settings;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CountryType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\LanguageType;
|
||||
use Symfony\Component\Translation\TranslatableMessage as TM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[Settings(label: new TM("settings.ips.conrad"))]
|
||||
#[SettingsIcon("fa-plug")]
|
||||
class ConradSettings
|
||||
{
|
||||
use SettingsTrait;
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.element14.apiKey"),
|
||||
formType: APIKeyType::class,
|
||||
formOptions: ["help_html" => true], envVar: "PROVIDER_CONRAD_API_KEY", envVarMode: EnvVarMode::OVERWRITE)]
|
||||
public ?string $apiKey = null;
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.conrad.shopID"),
|
||||
description: new TM("settings.ips.conrad.shopID.description"),
|
||||
formType: EnumType::class,
|
||||
formOptions: ['class' => ConradShopIDs::class],
|
||||
)]
|
||||
public ConradShopIDs $shopID = ConradShopIDs::COM_B2B;
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.reichelt.include_vat"))]
|
||||
public bool $includeVAT = true;
|
||||
|
||||
/**
|
||||
* @var array|string[] Only attachments in these languages will be downloaded (ISO 639-1 codes)
|
||||
*/
|
||||
#[Assert\Unique()]
|
||||
#[Assert\All([new Assert\Language()])]
|
||||
#[SettingsParameter(type: ArrayType::class,
|
||||
label: new TM("settings.ips.conrad.attachment_language_filter"), description: new TM("settings.ips.conrad.attachment_language_filter.description"),
|
||||
options: ['type' => StringType::class],
|
||||
formType: LanguageType::class,
|
||||
formOptions: [
|
||||
'multiple' => true,
|
||||
'preferred_choices' => ['en', 'de', 'fr', 'it', 'cs', 'da', 'nl', 'hu', 'hr', 'sk', 'pl']
|
||||
],
|
||||
)]
|
||||
public array $attachmentLanguageFilter = ['en'];
|
||||
}
|
||||
167
src/Settings/InfoProviderSystem/ConradShopIDs.php
Normal file
167
src/Settings/InfoProviderSystem/ConradShopIDs.php
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 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\Settings\InfoProviderSystem;
|
||||
|
||||
use Symfony\Contracts\Translation\TranslatableInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
enum ConradShopIDs: string implements TranslatableInterface
|
||||
{
|
||||
case COM_B2B = 'HP_COM_B2B';
|
||||
case DE_B2B = 'CQ_DE_B2B';
|
||||
case DE_B2C = 'CQ_DE_B2C';
|
||||
case AT_B2C = 'CQ_AT_B2C';
|
||||
case CH_B2C_DE = 'CQ_CH_B2C_DE';
|
||||
case CH_B2C_FR = 'CQ_CH_B2C_FR';
|
||||
case SE_B2B = 'HP_SE_B2B';
|
||||
case HU_B2C = 'CQ_HU_B2C';
|
||||
case CZ_B2B = 'HP_CZ_B2B';
|
||||
case SI_B2B = 'HP_SI_B2B';
|
||||
case SK_B2B = 'HP_SK_B2B';
|
||||
case BE_B2B = 'HP_BE_B2B';
|
||||
case PL_B2B = 'HP_PL_B2B';
|
||||
case NL_B2B = 'CQ_NL_B2B';
|
||||
case NL_B2C = 'CQ_NL_B2C';
|
||||
case DK_B2B = 'HP_DK_B2B';
|
||||
case IT_B2B = 'HP_IT_B2B';
|
||||
|
||||
case FR_B2B = 'HP_FR_B2B';
|
||||
case AT_B2B = 'CQ_AT_B2B';
|
||||
case HR_B2B = 'HP_HR_B2B';
|
||||
|
||||
|
||||
public function trans(TranslatorInterface $translator, ?string $locale = null): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DE_B2B => "conrad.de (B2B)",
|
||||
self::AT_B2C => "conrad.at (B2C)",
|
||||
self::CH_B2C_DE => "conrad.ch DE (B2C)",
|
||||
self::CH_B2C_FR => "conrad.ch FR (B2C)",
|
||||
self::SE_B2B => "conrad.se (B2B)",
|
||||
self::HU_B2C => "conrad.hu (B2C)",
|
||||
self::CZ_B2B => "conrad.cz (B2B)",
|
||||
self::SI_B2B => "conrad.si (B2B)",
|
||||
self::SK_B2B => "conrad.sk (B2B)",
|
||||
self::BE_B2B => "conrad.be (B2B)",
|
||||
self::DE_B2C => "conrad.de (B2C)",
|
||||
self::PL_B2B => "conrad.pl (B2B)",
|
||||
self::NL_B2B => "conrad.nl (B2B)",
|
||||
self::DK_B2B => "conradelektronik.dk (B2B)",
|
||||
self::IT_B2B => "conrad.it (B2B)",
|
||||
self::NL_B2C => "conrad.nl (B2C)",
|
||||
self::FR_B2B => "conrad.fr (B2B)",
|
||||
self::COM_B2B => "conrad.com (B2B)",
|
||||
self::AT_B2B => "conrad.at (B2B)",
|
||||
self::HR_B2B => "conrad.hr (B2B)",
|
||||
};
|
||||
}
|
||||
|
||||
public function getDomain(): string
|
||||
{
|
||||
if ($this === self::DK_B2B) {
|
||||
return 'conradelektronik.dk';
|
||||
}
|
||||
|
||||
return 'conrad.' . $this->getDomainEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the API root URL for this shop ID. e.g. https://api.conrad.de
|
||||
* @return string
|
||||
*/
|
||||
public function getAPIRoot(): string
|
||||
{
|
||||
return 'https://api.' . $this->getDomain();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the shop ID value used in the API requests. e.g. 'CQ_DE_B2B'
|
||||
* @return string
|
||||
*/
|
||||
public function getShopID(): string
|
||||
{
|
||||
if ($this === self::CH_B2C_FR || $this === self::CH_B2C_DE) {
|
||||
return 'CQ_CH_B2C';
|
||||
}
|
||||
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function getDomainEnd(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DE_B2B, self::DE_B2C => 'de',
|
||||
self::AT_B2B, self::AT_B2C => 'at',
|
||||
self::CH_B2C_DE => 'ch', self::CH_B2C_FR => 'ch',
|
||||
self::SE_B2B => 'se',
|
||||
self::HU_B2C => 'hu',
|
||||
self::CZ_B2B => 'cz',
|
||||
self::SI_B2B => 'si',
|
||||
self::SK_B2B => 'sk',
|
||||
self::BE_B2B => 'be',
|
||||
self::PL_B2B => 'pl',
|
||||
self::NL_B2B, self::NL_B2C => 'nl',
|
||||
self::DK_B2B => 'dk',
|
||||
self::IT_B2B => 'it',
|
||||
self::FR_B2B => 'fr',
|
||||
self::COM_B2B => 'com',
|
||||
self::HR_B2B => 'hr',
|
||||
};
|
||||
}
|
||||
|
||||
public function getLanguage(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DE_B2B, self::DE_B2C, self::AT_B2B, self::AT_B2C => 'de',
|
||||
self::CH_B2C_DE => 'de', self::CH_B2C_FR => 'fr',
|
||||
self::SE_B2B => 'sv',
|
||||
self::HU_B2C => 'hu',
|
||||
self::CZ_B2B => 'cs',
|
||||
self::SI_B2B => 'sl',
|
||||
self::SK_B2B => 'sk',
|
||||
self::BE_B2B => 'nl',
|
||||
self::PL_B2B => 'pl',
|
||||
self::NL_B2B, self::NL_B2C => 'nl',
|
||||
self::DK_B2B => 'da',
|
||||
self::IT_B2B => 'it',
|
||||
self::FR_B2B => 'fr',
|
||||
self::COM_B2B => 'en',
|
||||
self::HR_B2B => 'hr',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the customer type for this shop ID. e.g. 'b2b' or 'b2c'
|
||||
* @return string 'b2b' or 'b2c'
|
||||
*/
|
||||
public function getCustomerType(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DE_B2B, self::AT_B2B, self::SE_B2B, self::CZ_B2B, self::SI_B2B,
|
||||
self::SK_B2B, self::BE_B2B, self::PL_B2B, self::NL_B2B, self::DK_B2B,
|
||||
self::IT_B2B, self::FR_B2B, self::COM_B2B, self::HR_B2B => 'b2b',
|
||||
self::DE_B2C, self::AT_B2C, self::CH_B2C_DE, self::CH_B2C_FR, self::HU_B2C, self::NL_B2C => 'b2c',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 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\Settings\InfoProviderSystem;
|
||||
|
||||
use App\Settings\SettingsIcon;
|
||||
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
|
||||
use Jbtronics\SettingsBundle\Settings\Settings;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
|
||||
use Symfony\Component\Translation\TranslatableMessage as TM;
|
||||
|
||||
#[Settings(name: "generic_web_provider", label: new TM("settings.ips.generic_web_provider"), description: new TM("settings.ips.generic_web_provider.description"))]
|
||||
#[SettingsIcon("fa-plug")]
|
||||
class GenericWebProviderSettings
|
||||
{
|
||||
use SettingsTrait;
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.lcsc.enabled"), description: new TM("settings.ips.generic_web_provider.enabled.help"),
|
||||
envVar: "bool:PROVIDER_GENERIC_WEB_ENABLED", envVarMode: EnvVarMode::OVERWRITE
|
||||
)]
|
||||
public bool $enabled = false;
|
||||
}
|
||||
|
|
@ -37,6 +37,9 @@ class InfoProviderSettings
|
|||
#[EmbeddedSettings]
|
||||
public ?InfoProviderGeneralSettings $general = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?GenericWebProviderSettings $genericWebProvider = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?DigikeySettings $digikey = null;
|
||||
|
||||
|
|
@ -63,4 +66,10 @@ class InfoProviderSettings
|
|||
|
||||
#[EmbeddedSettings]
|
||||
public ?PollinSettings $pollin = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?BuerklinSettings $buerklin = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?ConradSettings $conrad = null;
|
||||
}
|
||||
|
|
|
|||
109
src/Settings/MiscSettings/IpnSuggestSettings.php
Normal file
109
src/Settings/MiscSettings/IpnSuggestSettings.php
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Settings\MiscSettings;
|
||||
|
||||
use App\Settings\SettingsIcon;
|
||||
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
|
||||
use Jbtronics\SettingsBundle\ParameterTypes\StringType;
|
||||
use Jbtronics\SettingsBundle\Settings\Settings;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
|
||||
use Symfony\Component\Translation\StaticMessage;
|
||||
use Symfony\Component\Translation\TranslatableMessage as TM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[Settings(label: new TM("settings.misc.ipn_suggest"))]
|
||||
#[SettingsIcon("fa-arrow-up-1-9")]
|
||||
class IpnSuggestSettings
|
||||
{
|
||||
use SettingsTrait;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.misc.ipn_suggest.regex"),
|
||||
description: new TM("settings.misc.ipn_suggest.regex.help"),
|
||||
options: ['type' => StringType::class],
|
||||
formOptions: ['attr' => ['placeholder' => new StaticMessage( '^[A-Za-z0-9]{3,4}(?:-[A-Za-z0-9]{3,4})*-\d{4}$')]],
|
||||
envVar: "IPN_SUGGEST_REGEX", envVarMode: EnvVarMode::OVERWRITE,
|
||||
)]
|
||||
public ?string $regex = null;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.misc.ipn_suggest.regex_help"),
|
||||
description: new TM("settings.misc.ipn_suggest.regex_help_description"),
|
||||
options: ['type' => StringType::class],
|
||||
formOptions: ['attr' => ['placeholder' => new TM('settings.misc.ipn_suggest.regex.help.placeholder')]],
|
||||
envVar: "IPN_SUGGEST_REGEX_HELP", envVarMode: EnvVarMode::OVERWRITE,
|
||||
)]
|
||||
public ?string $regexHelp = null;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.misc.ipn_suggest.autoAppendSuffix"),
|
||||
envVar: "bool:IPN_AUTO_APPEND_SUFFIX", envVarMode: EnvVarMode::OVERWRITE,
|
||||
)]
|
||||
public bool $autoAppendSuffix = false;
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.misc.ipn_suggest.suggestPartDigits"),
|
||||
description: new TM("settings.misc.ipn_suggest.suggestPartDigits.help"),
|
||||
formOptions: ['attr' => ['min' => 1, 'max' => 8]],
|
||||
envVar: "int:IPN_SUGGEST_PART_DIGITS", envVarMode: EnvVarMode::OVERWRITE
|
||||
)]
|
||||
#[Assert\Range(min: 1, max: 8)]
|
||||
public int $suggestPartDigits = 4;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.misc.ipn_suggest.useDuplicateDescription"),
|
||||
description: new TM("settings.misc.ipn_suggest.useDuplicateDescription.help"),
|
||||
envVar: "bool:IPN_USE_DUPLICATE_DESCRIPTION", envVarMode: EnvVarMode::OVERWRITE,
|
||||
)]
|
||||
public bool $useDuplicateDescription = false;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.misc.ipn_suggest.fallbackPrefix"),
|
||||
description: new TM("settings.misc.ipn_suggest.fallbackPrefix.help"),
|
||||
options: ['type' => StringType::class],
|
||||
)]
|
||||
public string $fallbackPrefix = 'N.A.';
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.misc.ipn_suggest.numberSeparator"),
|
||||
description: new TM("settings.misc.ipn_suggest.numberSeparator.help"),
|
||||
options: ['type' => StringType::class],
|
||||
)]
|
||||
public string $numberSeparator = '-';
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.misc.ipn_suggest.categorySeparator"),
|
||||
description: new TM("settings.misc.ipn_suggest.categorySeparator.help"),
|
||||
options: ['type' => StringType::class],
|
||||
)]
|
||||
public string $categorySeparator = '-';
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.misc.ipn_suggest.globalPrefix"),
|
||||
description: new TM("settings.misc.ipn_suggest.globalPrefix.help"),
|
||||
options: ['type' => StringType::class],
|
||||
)]
|
||||
public ?string $globalPrefix = null;
|
||||
}
|
||||
|
|
@ -35,4 +35,7 @@ class MiscSettings
|
|||
|
||||
#[EmbeddedSettings]
|
||||
public ?ExchangeRateSettings $exchangeRate = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?IpnSuggestSettings $ipnSuggestSettings = null;
|
||||
}
|
||||
|
|
|
|||
116
src/Settings/SynonymSettings.php
Normal file
116
src/Settings/SynonymSettings.php
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<?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\Settings;
|
||||
|
||||
use App\Form\Settings\TypeSynonymsCollectionType;
|
||||
use App\Services\ElementTypes;
|
||||
use Jbtronics\SettingsBundle\ParameterTypes\ArrayType;
|
||||
use Jbtronics\SettingsBundle\ParameterTypes\SerializeType;
|
||||
use Jbtronics\SettingsBundle\Settings\Settings;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
|
||||
use Symfony\Component\Translation\TranslatableMessage as TM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[Settings(label: new TM("settings.synonyms"), description: "settings.synonyms.help")]
|
||||
#[SettingsIcon("fa-language")]
|
||||
class SynonymSettings
|
||||
{
|
||||
use SettingsTrait;
|
||||
|
||||
#[SettingsParameter(
|
||||
ArrayType::class,
|
||||
label: new TM("settings.synonyms.type_synonyms"),
|
||||
description: new TM("settings.synonyms.type_synonyms.help"),
|
||||
options: ['type' => SerializeType::class],
|
||||
formType: TypeSynonymsCollectionType::class,
|
||||
formOptions: [
|
||||
'required' => false,
|
||||
],
|
||||
)]
|
||||
#[Assert\Type('array')]
|
||||
#[Assert\All([new Assert\Type('array')])]
|
||||
/**
|
||||
* @var array<string, array<string, array{singular: string, plural: string}>> $typeSynonyms
|
||||
* An array of the form: [
|
||||
* 'category' => [
|
||||
* 'en' => ['singular' => 'Category', 'plural' => 'Categories'],
|
||||
* 'de' => ['singular' => 'Kategorie', 'plural' => 'Kategorien'],
|
||||
* ],
|
||||
* 'manufacturer' => [
|
||||
* 'en' => ['singular' => 'Manufacturer', 'plural' =>'Manufacturers'],
|
||||
* ],
|
||||
* ]
|
||||
*/
|
||||
public array $typeSynonyms = [];
|
||||
|
||||
/**
|
||||
* Checks if there is any synonym defined for the given type (no matter which language).
|
||||
* @param ElementTypes $type
|
||||
* @return bool
|
||||
*/
|
||||
public function isSynonymDefinedForType(ElementTypes $type): bool
|
||||
{
|
||||
return isset($this->typeSynonyms[$type->value]) && count($this->typeSynonyms[$type->value]) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the singular synonym for the given type and locale, or null if none is defined.
|
||||
* @param ElementTypes $type
|
||||
* @param string $locale
|
||||
* @return string|null
|
||||
*/
|
||||
public function getSingularSynonymForType(ElementTypes $type, string $locale): ?string
|
||||
{
|
||||
return $this->typeSynonyms[$type->value][$locale]['singular'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the plural synonym for the given type and locale, or null if none is defined.
|
||||
* @param ElementTypes $type
|
||||
* @param string|null $locale
|
||||
* @return string|null
|
||||
*/
|
||||
public function getPluralSynonymForType(ElementTypes $type, ?string $locale): ?string
|
||||
{
|
||||
return $this->typeSynonyms[$type->value][$locale]['plural']
|
||||
?? $this->typeSynonyms[$type->value][$locale]['singular']
|
||||
?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a synonym for the given type and locale.
|
||||
* @param ElementTypes $type
|
||||
* @param string $locale
|
||||
* @param string $singular
|
||||
* @param string $plural
|
||||
* @return void
|
||||
*/
|
||||
public function setSynonymForType(ElementTypes $type, string $locale, string $singular, string $plural): void
|
||||
{
|
||||
$this->typeSynonyms[$type->value][$locale] = [
|
||||
'singular' => $singular,
|
||||
'plural' => $plural,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Settings\SystemSettings;
|
||||
|
||||
use App\Form\Type\LanguageMenuEntriesType;
|
||||
use App\Form\Settings\LanguageMenuEntriesType;
|
||||
use App\Form\Type\LocaleSelectType;
|
||||
use App\Settings\SettingsIcon;
|
||||
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ class SystemSettings
|
|||
#[EmbeddedSettings()]
|
||||
public ?LocalizationSettings $localization = null;
|
||||
|
||||
|
||||
|
||||
#[EmbeddedSettings()]
|
||||
public ?CustomizationSettings $customization = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ namespace App\State;
|
|||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\PartDBInfo;
|
||||
use App\Services\Misc\GitVersionInfo;
|
||||
use App\Services\System\BannerHelper;
|
||||
use App\Services\System\GitVersionInfoProvider;
|
||||
use App\Settings\SystemSettings\CustomizationSettings;
|
||||
use App\Settings\SystemSettings\LocalizationSettings;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
|
|
@ -17,7 +17,7 @@ class PartDBInfoProvider implements ProviderInterface
|
|||
{
|
||||
|
||||
public function __construct(private readonly VersionManagerInterface $versionManager,
|
||||
private readonly GitVersionInfo $gitVersionInfo,
|
||||
private readonly GitVersionInfoProvider $gitVersionInfo,
|
||||
private readonly BannerHelper $bannerHelper,
|
||||
private readonly string $default_uri,
|
||||
private readonly LocalizationSettings $localizationSettings,
|
||||
|
|
@ -31,8 +31,8 @@ class PartDBInfoProvider implements ProviderInterface
|
|||
{
|
||||
return new PartDBInfo(
|
||||
version: $this->versionManager->getVersion()->toString(),
|
||||
git_branch: $this->gitVersionInfo->getGitBranchName(),
|
||||
git_commit: $this->gitVersionInfo->getGitCommitHash(),
|
||||
git_branch: $this->gitVersionInfo->getBranchName(),
|
||||
git_commit: $this->gitVersionInfo->getCommitHash(),
|
||||
title: $this->customizationSettings->instanceName,
|
||||
banner: $this->bannerHelper->getBanner(),
|
||||
default_uri: $this->default_uri,
|
||||
|
|
|
|||
|
|
@ -1,242 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Translation\Fixes;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||
use Symfony\Component\Translation\Dumper\FileDumper;
|
||||
use Symfony\Component\Translation\MessageCatalogue;
|
||||
use Symfony\Component\Translation\Exception\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Backport of the XliffFile dumper from Symfony 7.2, which supports segment attributes and notes, this keeps the
|
||||
* metadata when editing the translations from inside Symfony.
|
||||
*/
|
||||
#[AsDecorator("translation.dumper.xliff")]
|
||||
class SegmentAwareXliffFileDumper extends FileDumper
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
private string $extension = 'xlf',
|
||||
) {
|
||||
}
|
||||
|
||||
public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string
|
||||
{
|
||||
$xliffVersion = '1.2';
|
||||
if (\array_key_exists('xliff_version', $options)) {
|
||||
$xliffVersion = $options['xliff_version'];
|
||||
}
|
||||
|
||||
if (\array_key_exists('default_locale', $options)) {
|
||||
$defaultLocale = $options['default_locale'];
|
||||
} else {
|
||||
$defaultLocale = \Locale::getDefault();
|
||||
}
|
||||
|
||||
if ('1.2' === $xliffVersion) {
|
||||
return $this->dumpXliff1($defaultLocale, $messages, $domain, $options);
|
||||
}
|
||||
if ('2.0' === $xliffVersion) {
|
||||
return $this->dumpXliff2($defaultLocale, $messages, $domain);
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException(\sprintf('No support implemented for dumping XLIFF version "%s".', $xliffVersion));
|
||||
}
|
||||
|
||||
protected function getExtension(): string
|
||||
{
|
||||
return $this->extension;
|
||||
}
|
||||
|
||||
private function dumpXliff1(string $defaultLocale, MessageCatalogue $messages, ?string $domain, array $options = []): string
|
||||
{
|
||||
$toolInfo = ['tool-id' => 'symfony', 'tool-name' => 'Symfony'];
|
||||
if (\array_key_exists('tool_info', $options)) {
|
||||
$toolInfo = array_merge($toolInfo, $options['tool_info']);
|
||||
}
|
||||
|
||||
$dom = new \DOMDocument('1.0', 'utf-8');
|
||||
$dom->formatOutput = true;
|
||||
|
||||
$xliff = $dom->appendChild($dom->createElement('xliff'));
|
||||
$xliff->setAttribute('version', '1.2');
|
||||
$xliff->setAttribute('xmlns', 'urn:oasis:names:tc:xliff:document:1.2');
|
||||
|
||||
$xliffFile = $xliff->appendChild($dom->createElement('file'));
|
||||
$xliffFile->setAttribute('source-language', str_replace('_', '-', $defaultLocale));
|
||||
$xliffFile->setAttribute('target-language', str_replace('_', '-', $messages->getLocale()));
|
||||
$xliffFile->setAttribute('datatype', 'plaintext');
|
||||
$xliffFile->setAttribute('original', 'file.ext');
|
||||
|
||||
$xliffHead = $xliffFile->appendChild($dom->createElement('header'));
|
||||
$xliffTool = $xliffHead->appendChild($dom->createElement('tool'));
|
||||
foreach ($toolInfo as $id => $value) {
|
||||
$xliffTool->setAttribute($id, $value);
|
||||
}
|
||||
|
||||
if ($catalogueMetadata = $messages->getCatalogueMetadata('', $domain) ?? []) {
|
||||
$xliffPropGroup = $xliffHead->appendChild($dom->createElement('prop-group'));
|
||||
foreach ($catalogueMetadata as $key => $value) {
|
||||
$xliffProp = $xliffPropGroup->appendChild($dom->createElement('prop'));
|
||||
$xliffProp->setAttribute('prop-type', $key);
|
||||
$xliffProp->appendChild($dom->createTextNode($value));
|
||||
}
|
||||
}
|
||||
|
||||
$xliffBody = $xliffFile->appendChild($dom->createElement('body'));
|
||||
foreach ($messages->all($domain) as $source => $target) {
|
||||
$translation = $dom->createElement('trans-unit');
|
||||
|
||||
$translation->setAttribute('id', strtr(substr(base64_encode(hash('xxh128', $source, true)), 0, 7), '/+', '._'));
|
||||
$translation->setAttribute('resname', $source);
|
||||
|
||||
$s = $translation->appendChild($dom->createElement('source'));
|
||||
$s->appendChild($dom->createTextNode($source));
|
||||
|
||||
// Does the target contain characters requiring a CDATA section?
|
||||
$text = 1 === preg_match('/[&<>]/', $target) ? $dom->createCDATASection($target) : $dom->createTextNode($target);
|
||||
|
||||
$targetElement = $dom->createElement('target');
|
||||
$metadata = $messages->getMetadata($source, $domain);
|
||||
if ($this->hasMetadataArrayInfo('target-attributes', $metadata)) {
|
||||
foreach ($metadata['target-attributes'] as $name => $value) {
|
||||
$targetElement->setAttribute($name, $value);
|
||||
}
|
||||
}
|
||||
$t = $translation->appendChild($targetElement);
|
||||
$t->appendChild($text);
|
||||
|
||||
if ($this->hasMetadataArrayInfo('notes', $metadata)) {
|
||||
foreach ($metadata['notes'] as $note) {
|
||||
if (!isset($note['content'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$n = $translation->appendChild($dom->createElement('note'));
|
||||
$n->appendChild($dom->createTextNode($note['content']));
|
||||
|
||||
if (isset($note['priority'])) {
|
||||
$n->setAttribute('priority', $note['priority']);
|
||||
}
|
||||
|
||||
if (isset($note['from'])) {
|
||||
$n->setAttribute('from', $note['from']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$xliffBody->appendChild($translation);
|
||||
}
|
||||
|
||||
return $dom->saveXML();
|
||||
}
|
||||
|
||||
private function dumpXliff2(string $defaultLocale, MessageCatalogue $messages, ?string $domain): string
|
||||
{
|
||||
$dom = new \DOMDocument('1.0', 'utf-8');
|
||||
$dom->formatOutput = true;
|
||||
|
||||
$xliff = $dom->appendChild($dom->createElement('xliff'));
|
||||
$xliff->setAttribute('xmlns', 'urn:oasis:names:tc:xliff:document:2.0');
|
||||
$xliff->setAttribute('version', '2.0');
|
||||
$xliff->setAttribute('srcLang', str_replace('_', '-', $defaultLocale));
|
||||
$xliff->setAttribute('trgLang', str_replace('_', '-', $messages->getLocale()));
|
||||
|
||||
$xliffFile = $xliff->appendChild($dom->createElement('file'));
|
||||
if (str_ends_with($domain, MessageCatalogue::INTL_DOMAIN_SUFFIX)) {
|
||||
$xliffFile->setAttribute('id', substr($domain, 0, -\strlen(MessageCatalogue::INTL_DOMAIN_SUFFIX)).'.'.$messages->getLocale());
|
||||
} else {
|
||||
$xliffFile->setAttribute('id', $domain.'.'.$messages->getLocale());
|
||||
}
|
||||
|
||||
if ($catalogueMetadata = $messages->getCatalogueMetadata('', $domain) ?? []) {
|
||||
$xliff->setAttribute('xmlns:m', 'urn:oasis:names:tc:xliff:metadata:2.0');
|
||||
$xliffMetadata = $xliffFile->appendChild($dom->createElement('m:metadata'));
|
||||
foreach ($catalogueMetadata as $key => $value) {
|
||||
$xliffMeta = $xliffMetadata->appendChild($dom->createElement('prop'));
|
||||
$xliffMeta->setAttribute('type', $key);
|
||||
$xliffMeta->appendChild($dom->createTextNode($value));
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($messages->all($domain) as $source => $target) {
|
||||
$translation = $dom->createElement('unit');
|
||||
$translation->setAttribute('id', strtr(substr(base64_encode(hash('xxh128', $source, true)), 0, 7), '/+', '._'));
|
||||
|
||||
if (\strlen($source) <= 80) {
|
||||
$translation->setAttribute('name', $source);
|
||||
}
|
||||
|
||||
$metadata = $messages->getMetadata($source, $domain);
|
||||
|
||||
// Add notes section
|
||||
if ($this->hasMetadataArrayInfo('notes', $metadata)) {
|
||||
$notesElement = $dom->createElement('notes');
|
||||
foreach ($metadata['notes'] as $note) {
|
||||
$n = $dom->createElement('note');
|
||||
$n->appendChild($dom->createTextNode($note['content'] ?? ''));
|
||||
unset($note['content']);
|
||||
|
||||
foreach ($note as $name => $value) {
|
||||
$n->setAttribute($name, $value);
|
||||
}
|
||||
$notesElement->appendChild($n);
|
||||
}
|
||||
$translation->appendChild($notesElement);
|
||||
}
|
||||
|
||||
$segment = $translation->appendChild($dom->createElement('segment'));
|
||||
|
||||
if ($this->hasMetadataArrayInfo('segment-attributes', $metadata)) {
|
||||
foreach ($metadata['segment-attributes'] as $name => $value) {
|
||||
$segment->setAttribute($name, $value);
|
||||
}
|
||||
}
|
||||
|
||||
$s = $segment->appendChild($dom->createElement('source'));
|
||||
$s->appendChild($dom->createTextNode($source));
|
||||
|
||||
// Does the target contain characters requiring a CDATA section?
|
||||
$text = 1 === preg_match('/[&<>]/', $target) ? $dom->createCDATASection($target) : $dom->createTextNode($target);
|
||||
|
||||
$targetElement = $dom->createElement('target');
|
||||
if ($this->hasMetadataArrayInfo('target-attributes', $metadata)) {
|
||||
foreach ($metadata['target-attributes'] as $name => $value) {
|
||||
$targetElement->setAttribute($name, $value);
|
||||
}
|
||||
}
|
||||
$t = $segment->appendChild($targetElement);
|
||||
$t->appendChild($text);
|
||||
|
||||
$xliffFile->appendChild($translation);
|
||||
}
|
||||
|
||||
return $dom->saveXML();
|
||||
}
|
||||
|
||||
private function hasMetadataArrayInfo(string $key, ?array $metadata = null): bool
|
||||
{
|
||||
return is_iterable($metadata[$key] ?? null);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,262 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Translation\Fixes;
|
||||
|
||||
use Symfony\Component\Config\Resource\FileResource;
|
||||
use Symfony\Component\Config\Util\Exception\InvalidXmlException;
|
||||
use Symfony\Component\Config\Util\Exception\XmlParsingException;
|
||||
use Symfony\Component\Config\Util\XmlUtils;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||
use Symfony\Component\Translation\Exception\InvalidResourceException;
|
||||
use Symfony\Component\Translation\Exception\NotFoundResourceException;
|
||||
use Symfony\Component\Translation\Exception\RuntimeException;
|
||||
use Symfony\Component\Translation\Loader\LoaderInterface;
|
||||
use Symfony\Component\Translation\MessageCatalogue;
|
||||
use Symfony\Component\Translation\Util\XliffUtils;
|
||||
|
||||
/**
|
||||
* Backport of the XliffFile dumper from Symfony 7.2, which supports segment attributes and notes, this keeps the
|
||||
* metadata when editing the translations from inside Symfony.
|
||||
*/
|
||||
#[AsDecorator("translation.loader.xliff")]
|
||||
class SegmentAwareXliffFileLoader implements LoaderInterface
|
||||
{
|
||||
public function load(mixed $resource, string $locale, string $domain = 'messages'): MessageCatalogue
|
||||
{
|
||||
if (!class_exists(XmlUtils::class)) {
|
||||
throw new RuntimeException('Loading translations from the Xliff format requires the Symfony Config component.');
|
||||
}
|
||||
|
||||
if (!$this->isXmlString($resource)) {
|
||||
if (!stream_is_local($resource)) {
|
||||
throw new InvalidResourceException(\sprintf('This is not a local file "%s".', $resource));
|
||||
}
|
||||
|
||||
if (!file_exists($resource)) {
|
||||
throw new NotFoundResourceException(\sprintf('File "%s" not found.', $resource));
|
||||
}
|
||||
|
||||
if (!is_file($resource)) {
|
||||
throw new InvalidResourceException(\sprintf('This is neither a file nor an XLIFF string "%s".', $resource));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->isXmlString($resource)) {
|
||||
$dom = XmlUtils::parse($resource);
|
||||
} else {
|
||||
$dom = XmlUtils::loadFile($resource);
|
||||
}
|
||||
} catch (\InvalidArgumentException|XmlParsingException|InvalidXmlException $e) {
|
||||
throw new InvalidResourceException(\sprintf('Unable to load "%s": ', $resource).$e->getMessage(), $e->getCode(), $e);
|
||||
}
|
||||
|
||||
if ($errors = XliffUtils::validateSchema($dom)) {
|
||||
throw new InvalidResourceException(\sprintf('Invalid resource provided: "%s"; Errors: ', $resource).XliffUtils::getErrorsAsString($errors));
|
||||
}
|
||||
|
||||
$catalogue = new MessageCatalogue($locale);
|
||||
$this->extract($dom, $catalogue, $domain);
|
||||
|
||||
if (is_file($resource) && class_exists(FileResource::class)) {
|
||||
$catalogue->addResource(new FileResource($resource));
|
||||
}
|
||||
|
||||
return $catalogue;
|
||||
}
|
||||
|
||||
private function extract(\DOMDocument $dom, MessageCatalogue $catalogue, string $domain): void
|
||||
{
|
||||
$xliffVersion = XliffUtils::getVersionNumber($dom);
|
||||
|
||||
if ('1.2' === $xliffVersion) {
|
||||
$this->extractXliff1($dom, $catalogue, $domain);
|
||||
}
|
||||
|
||||
if ('2.0' === $xliffVersion) {
|
||||
$this->extractXliff2($dom, $catalogue, $domain);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract messages and metadata from DOMDocument into a MessageCatalogue.
|
||||
*/
|
||||
private function extractXliff1(\DOMDocument $dom, MessageCatalogue $catalogue, string $domain): void
|
||||
{
|
||||
$xml = simplexml_import_dom($dom);
|
||||
$encoding = $dom->encoding ? strtoupper($dom->encoding) : null;
|
||||
|
||||
$namespace = 'urn:oasis:names:tc:xliff:document:1.2';
|
||||
$xml->registerXPathNamespace('xliff', $namespace);
|
||||
|
||||
foreach ($xml->xpath('//xliff:file') as $file) {
|
||||
$fileAttributes = $file->attributes();
|
||||
|
||||
$file->registerXPathNamespace('xliff', $namespace);
|
||||
|
||||
foreach ($file->xpath('.//xliff:prop') as $prop) {
|
||||
$catalogue->setCatalogueMetadata($prop->attributes()['prop-type'], (string) $prop, $domain);
|
||||
}
|
||||
|
||||
foreach ($file->xpath('.//xliff:trans-unit') as $translation) {
|
||||
$attributes = $translation->attributes();
|
||||
|
||||
if (!(isset($attributes['resname']) || isset($translation->source))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$source = (string) (isset($attributes['resname']) && $attributes['resname'] ? $attributes['resname'] : $translation->source);
|
||||
|
||||
if (isset($translation->target)
|
||||
&& 'needs-translation' === (string) $translation->target->attributes()['state']
|
||||
&& \in_array((string) $translation->target, [$source, (string) $translation->source], true)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the xlf file has another encoding specified, try to convert it because
|
||||
// simple_xml will always return utf-8 encoded values
|
||||
$target = $this->utf8ToCharset((string) ($translation->target ?? $translation->source), $encoding);
|
||||
|
||||
$catalogue->set($source, $target, $domain);
|
||||
|
||||
$metadata = [
|
||||
'source' => (string) $translation->source,
|
||||
'file' => [
|
||||
'original' => (string) $fileAttributes['original'],
|
||||
],
|
||||
];
|
||||
if ($notes = $this->parseNotesMetadata($translation->note, $encoding)) {
|
||||
$metadata['notes'] = $notes;
|
||||
}
|
||||
|
||||
if (isset($translation->target) && $translation->target->attributes()) {
|
||||
$metadata['target-attributes'] = [];
|
||||
foreach ($translation->target->attributes() as $key => $value) {
|
||||
$metadata['target-attributes'][$key] = (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($attributes['id'])) {
|
||||
$metadata['id'] = (string) $attributes['id'];
|
||||
}
|
||||
|
||||
$catalogue->setMetadata($source, $metadata, $domain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function extractXliff2(\DOMDocument $dom, MessageCatalogue $catalogue, string $domain): void
|
||||
{
|
||||
$xml = simplexml_import_dom($dom);
|
||||
$encoding = $dom->encoding ? strtoupper($dom->encoding) : null;
|
||||
|
||||
$xml->registerXPathNamespace('xliff', 'urn:oasis:names:tc:xliff:document:2.0');
|
||||
|
||||
foreach ($xml->xpath('//xliff:unit') as $unit) {
|
||||
foreach ($unit->segment as $segment) {
|
||||
$attributes = $unit->attributes();
|
||||
$source = $attributes['name'] ?? $segment->source;
|
||||
|
||||
// If the xlf file has another encoding specified, try to convert it because
|
||||
// simple_xml will always return utf-8 encoded values
|
||||
$target = $this->utf8ToCharset((string) ($segment->target ?? $segment->source), $encoding);
|
||||
|
||||
$catalogue->set((string) $source, $target, $domain);
|
||||
|
||||
$metadata = [];
|
||||
if ($segment->attributes()) {
|
||||
$metadata['segment-attributes'] = [];
|
||||
foreach ($segment->attributes() as $key => $value) {
|
||||
$metadata['segment-attributes'][$key] = (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($segment->target) && $segment->target->attributes()) {
|
||||
$metadata['target-attributes'] = [];
|
||||
foreach ($segment->target->attributes() as $key => $value) {
|
||||
$metadata['target-attributes'][$key] = (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($unit->notes)) {
|
||||
$metadata['notes'] = [];
|
||||
foreach ($unit->notes->note as $noteNode) {
|
||||
$note = [];
|
||||
foreach ($noteNode->attributes() as $key => $value) {
|
||||
$note[$key] = (string) $value;
|
||||
}
|
||||
$note['content'] = (string) $noteNode;
|
||||
$metadata['notes'][] = $note;
|
||||
}
|
||||
}
|
||||
|
||||
$catalogue->setMetadata((string) $source, $metadata, $domain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a UTF8 string to the specified encoding.
|
||||
*/
|
||||
private function utf8ToCharset(string $content, ?string $encoding = null): string
|
||||
{
|
||||
if ('UTF-8' !== $encoding && $encoding) {
|
||||
return mb_convert_encoding($content, $encoding, 'UTF-8');
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function parseNotesMetadata(?\SimpleXMLElement $noteElement = null, ?string $encoding = null): array
|
||||
{
|
||||
$notes = [];
|
||||
|
||||
if (null === $noteElement) {
|
||||
return $notes;
|
||||
}
|
||||
|
||||
/** @var \SimpleXMLElement $xmlNote */
|
||||
foreach ($noteElement as $xmlNote) {
|
||||
$noteAttributes = $xmlNote->attributes();
|
||||
$note = ['content' => $this->utf8ToCharset((string) $xmlNote, $encoding)];
|
||||
if (isset($noteAttributes['priority'])) {
|
||||
$note['priority'] = (int) $noteAttributes['priority'];
|
||||
}
|
||||
|
||||
if (isset($noteAttributes['from'])) {
|
||||
$note['from'] = (string) $noteAttributes['from'];
|
||||
}
|
||||
|
||||
$notes[] = $note;
|
||||
}
|
||||
|
||||
return $notes;
|
||||
}
|
||||
|
||||
private function isXmlString(string $resource): bool
|
||||
{
|
||||
return str_starts_with($resource, '<?xml');
|
||||
}
|
||||
}
|
||||
88
src/Translation/NoCDATAXliffFileDumper.php
Normal file
88
src/Translation/NoCDATAXliffFileDumper.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 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\Translation;
|
||||
|
||||
use DOMDocument;
|
||||
use DOMXPath;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||
use Symfony\Component\Translation\Dumper\FileDumper;
|
||||
use Symfony\Component\Translation\MessageCatalogue;
|
||||
|
||||
/**
|
||||
* The goal of this class, is to ensure that the XLIFF dumper does not output CDATA, but instead outputs the text
|
||||
* using the normal XML escaping. Crowdin outputs the translations without CDATA, we want to be consistent with that, to
|
||||
* prevent unnecessary diffs in the translation files when we update them with translations from Crowdin.
|
||||
*/
|
||||
#[AsDecorator("translation.dumper.xliff")]
|
||||
class NoCDATAXliffFileDumper extends FileDumper
|
||||
{
|
||||
|
||||
public function __construct(private readonly FileDumper $decorated)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
private function convertCDataToEscapedText(string $xmlContent): string
|
||||
{
|
||||
$dom = new DOMDocument();
|
||||
// Preserve whitespace to keep Symfony's formatting intact
|
||||
$dom->preserveWhiteSpace = true;
|
||||
$dom->formatOutput = true;
|
||||
|
||||
// Load the XML (handle internal errors if necessary)
|
||||
$dom->loadXML($xmlContent);
|
||||
|
||||
$xpath = new DOMXPath($dom);
|
||||
// Find all CDATA sections
|
||||
$cdataNodes = $xpath->query('//node()/comment()|//node()/text()|//node()') ;
|
||||
|
||||
// We specifically want CDATA sections. XPath 1.0 doesn't have a direct
|
||||
// "cdata-section()" selector easily, so we iterate through all nodes
|
||||
// and check their type.
|
||||
|
||||
$nodesToRemove = [];
|
||||
foreach ($xpath->query('//text() | //*') as $node) {
|
||||
foreach ($node->childNodes as $child) {
|
||||
if ($child->nodeType === XML_CDATA_SECTION_NODE) {
|
||||
// Create a new text node with the content of the CDATA
|
||||
// DOMDocument will automatically escape special chars on save
|
||||
$newTextNode = $dom->createTextNode($child->textContent);
|
||||
$node->replaceChild($newTextNode, $child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $dom->saveXML();
|
||||
}
|
||||
|
||||
public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string
|
||||
{
|
||||
return $this->convertCDataToEscapedText($this->decorated->formatCatalogue($messages, $domain, $options));
|
||||
}
|
||||
|
||||
protected function getExtension(): string
|
||||
{
|
||||
return $this->decorated->getExtension();
|
||||
}
|
||||
}
|
||||
|
|
@ -76,6 +76,8 @@ final class EntityExtension extends AbstractExtension
|
|||
|
||||
/* Gets a human readable label for the type of the given entity */
|
||||
new TwigFunction('entity_type_label', fn(object|string $entity): string => $this->nameGenerator->getLocalizedTypeLabel($entity)),
|
||||
new TwigFunction('type_label', fn(object|string $entity): string => $this->nameGenerator->typeLabel($entity)),
|
||||
new TwigFunction('type_label_p', fn(object|string $entity): string => $this->nameGenerator->typeLabelPlural($entity)),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
79
src/Twig/UpdateExtension.php
Normal file
79
src/Twig/UpdateExtension.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Twig;
|
||||
|
||||
use App\Services\System\UpdateAvailableFacade;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
/**
|
||||
* Twig extension for update-related functions.
|
||||
*/
|
||||
final class UpdateExtension extends AbstractExtension
|
||||
{
|
||||
public function __construct(private readonly UpdateAvailableFacade $updateAvailableManager,
|
||||
private readonly Security $security)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
new TwigFunction('is_update_available', $this->isUpdateAvailable(...)),
|
||||
new TwigFunction('get_latest_version', $this->getLatestVersion(...)),
|
||||
new TwigFunction('get_latest_version_url', $this->getLatestVersionUrl(...)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an update is available and the user has permission to see it.
|
||||
*/
|
||||
public function isUpdateAvailable(): bool
|
||||
{
|
||||
// Only show to users with the show_updates permission
|
||||
if (!$this->security->isGranted('@system.show_updates')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->updateAvailableManager->isUpdateAvailable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest available version string.
|
||||
*/
|
||||
public function getLatestVersion(): string
|
||||
{
|
||||
return $this->updateAvailableManager->getLatestVersionString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL to the latest version release page.
|
||||
*/
|
||||
public function getLatestVersionUrl(): string
|
||||
{
|
||||
return $this->updateAvailableManager->getLatestVersionUrl();
|
||||
}
|
||||
}
|
||||
22
src/Validator/Constraints/UniquePartIpnConstraint.php
Normal file
22
src/Validator/Constraints/UniquePartIpnConstraint.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Validator\Constraints;
|
||||
|
||||
use Attribute;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY)]
|
||||
class UniquePartIpnConstraint extends Constraint
|
||||
{
|
||||
public string $message = 'part.ipn.must_be_unique';
|
||||
|
||||
public function getTargets(): string|array
|
||||
{
|
||||
return [self::CLASS_CONSTRAINT, self::PROPERTY_CONSTRAINT];
|
||||
}
|
||||
|
||||
public function validatedBy(): string
|
||||
{
|
||||
return UniquePartIpnValidator::class;
|
||||
}
|
||||
}
|
||||
55
src/Validator/Constraints/UniquePartIpnValidator.php
Normal file
55
src/Validator/Constraints/UniquePartIpnValidator.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace App\Validator\Constraints;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Settings\MiscSettings\IpnSuggestSettings;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
class UniquePartIpnValidator extends ConstraintValidator
|
||||
{
|
||||
private EntityManagerInterface $entityManager;
|
||||
private IpnSuggestSettings $ipnSuggestSettings;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager, IpnSuggestSettings $ipnSuggestSettings)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
$this->ipnSuggestSettings = $ipnSuggestSettings;
|
||||
}
|
||||
|
||||
public function validate($value, Constraint $constraint): void
|
||||
{
|
||||
if (null === $value || '' === $value) {
|
||||
return;
|
||||
}
|
||||
|
||||
//If the autoAppendSuffix option is enabled, the IPN becomes unique automatically later
|
||||
if ($this->ipnSuggestSettings->autoAppendSuffix) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$constraint instanceof UniquePartIpnConstraint) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var Part $currentPart */
|
||||
$currentPart = $this->context->getObject();
|
||||
|
||||
if (!$currentPart instanceof Part) {
|
||||
return;
|
||||
}
|
||||
|
||||
$repository = $this->entityManager->getRepository(Part::class);
|
||||
$existingParts = $repository->findBy(['ipn' => $value]);
|
||||
|
||||
foreach ($existingParts as $existingPart) {
|
||||
if ($currentPart->getId() !== $existingPart->getId()) {
|
||||
$this->context->buildViolation($constraint->message)
|
||||
->setParameter('{{ value }}', $value)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue