Merge branch 'master' into tweak-search

This commit is contained in:
d-buchmann 2026-02-12 09:34:58 +01:00 committed by GitHub
commit cc902a7a46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
288 changed files with 35302 additions and 50165 deletions

View 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('');
}
}

View 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);
}
}
}

View 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]);
}
}

View file

@ -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);

View file

@ -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, [

View file

@ -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(),

View file

@ -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,
]);
}
}

View file

@ -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
{

View file

@ -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'));

View file

@ -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'));
}

View file

@ -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,

View file

@ -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);
}
}

View 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');
}
}

View file

@ -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;

View file

@ -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, [

View file

@ -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)',

View file

@ -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;
}
}
}

View file

@ -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

View file

@ -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 = '';

View file

@ -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)]

View file

@ -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

View file

@ -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;

View file

@ -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;
}
}
}

View file

@ -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`')]

View file

@ -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;
/**

View 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',
];
}
}

View file

@ -20,7 +20,7 @@
declare(strict_types=1);
namespace App\Services;
namespace App\EnvVarProcessors;
use Closure;
use Symfony\Component\DependencyInjection\EnvVarProcessorInterface;

View file

@ -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');
}
}

View 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;
}
}

View 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);
}
}
}
}

View 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,));
}
}

View file

@ -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' => '',

View file

@ -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'],

View file

@ -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']) {

View file

@ -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, [

View file

@ -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;

View 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);
}
}

View 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';
}
}

View file

@ -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
{

View file

@ -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);
});
}

View file

@ -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

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -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 = [];

View file

@ -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

View file

@ -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" => []
];

View file

@ -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')
);

View 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));
}
}

View file

@ -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);
}
}
}

View file

@ -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'];
}

View file

@ -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));
}
/**

View file

@ -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'];

View file

@ -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();
}

View file

@ -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();

View file

@ -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();

View file

@ -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;
}
/**

View file

@ -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;
}
}

View 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;
}
}

View 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;
}
}

View file

@ -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) {

View file

@ -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;
}
}

View 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
];
}
}

View file

@ -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;
}
}

View file

@ -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) &&

View file

@ -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;
}
}

View file

@ -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) {

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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]);

View file

@ -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
}
}

View 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]);
}
}
}

View 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;
}
}

View 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()));
}
}

View 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.',
};
}
}

View 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,
];
}
}

View file

@ -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();
});
}
}
}

View 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;
}
}

View 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;
}
}

View file

@ -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;
}
}

View file

@ -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

View file

@ -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);
}

View file

@ -47,6 +47,12 @@ class AppSettings
#[EmbeddedSettings()]
public ?InfoProviderSettings $infoProviders = null;
#[EmbeddedSettings]
public ?SynonymSettings $synonyms = null;
#[EmbeddedSettings()]
public ?MiscSettings $miscSettings = null;
}

View 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";
}

View 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'];
}

View 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',
};
}
}

View file

@ -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;
}

View file

@ -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;
}

View 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;
}

View file

@ -35,4 +35,7 @@ class MiscSettings
#[EmbeddedSettings]
public ?ExchangeRateSettings $exchangeRate = null;
#[EmbeddedSettings]
public ?IpnSuggestSettings $ipnSuggestSettings = null;
}

View 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,
];
}
}

View file

@ -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;

View file

@ -33,6 +33,8 @@ class SystemSettings
#[EmbeddedSettings()]
public ?LocalizationSettings $localization = null;
#[EmbeddedSettings()]
public ?CustomizationSettings $customization = null;

View file

@ -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,

View file

@ -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);
}
}

View file

@ -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');
}
}

View 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();
}
}

View file

@ -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)),
];
}

View 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();
}
}

View 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;
}
}

View 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();
}
}
}
}