diff --git a/.gitignore b/.gitignore index 91bcb0db3..d54711344 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,11 @@ -vendor -ibexa -chromedriver -.php_cs.cache -bin/do.bash -.phpunit.result.cache -documentation/export -.idea -node_modules -drivers -.php-cs-fixer.cache +./vendor +./ibexa +./chromedriver +./.php_cs.cache +./bin/do.bash +./.phpunit.result.cache +./documentation/export +./.idea +./node_modules +./drivers +./.php-cs-fixer.cache diff --git a/components/ImportExportBundle/LICENSE b/components/ImportExportBundle/LICENSE new file mode 100644 index 000000000..0d69285cd --- /dev/null +++ b/components/ImportExportBundle/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020 Novactive, https://github.com/Novactive/AlmaviaCXIbexaImportExportBundle + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/components/ImportExportBundle/README.md b/components/ImportExportBundle/README.md new file mode 100644 index 000000000..86b3114c3 --- /dev/null +++ b/components/ImportExportBundle/README.md @@ -0,0 +1,29 @@ +# AlmaviaCX Ibexa Import/Export Bundle + +Import / Export workflow : + + +A `job` trigger a `workflow` + +A `workflow` call a `reader` to get a list of items, then use a list of `step` to filter/modify the items and finally pass them to a list of `writer` + + +## Step + +A step service must implement `AlmaviaCX\Bundle\IbexaImportExport\Step\StepInterface` and have the tag `almaviacx.import_export.component` + +The bundle provide the `AlmaviaCX\Bundle\IbexaImportExport\Step\AbstractStep` to simplify the creation of a service + +### Provided steps + +#### AlmaviaCX\Bundle\IbexaImportExport\Step\IbexaContentToArrayStep + +Transform a content into an associative array. Take a map as argument to extract properties from a content to generate the associative array + +More explaination on the transformation process [here](./doc/ibexa_content_to_array_step.md) + +Options are : +- map (array representing the resulting associative array. each entry value correspond to a property of the content. ex : `["title" => "content.fields[title].value"]`) + +## Writer + diff --git a/components/ImportExportBundle/bin/mdb-to-sqlite.bash b/components/ImportExportBundle/bin/mdb-to-sqlite.bash new file mode 100755 index 000000000..2c17a7577 --- /dev/null +++ b/components/ImportExportBundle/bin/mdb-to-sqlite.bash @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +mdb_path=$1 +sqlite_path=$2 +id=$BASHPID +tmp_dir="/tmp/mdb-convert/${id}" + +test -f $sqlite_path && rm $sqlite_path + +echo "Starting conversion in ${tmp_dir}" +mkdir -p $tmp_dir +mdb-schema $mdb_path sqlite > ${tmp_dir}/schema.sql +mkdir -p ${tmp_dir}/sql +for i in $( mdb-tables $mdb_path ); do mdb-export --quote="'" -D "%Y-%m-%d %H:%M:%S" -H -I sqlite $mdb_path $i > ${tmp_dir}/sql/$i.sql; done +cat ${tmp_dir}/schema.sql | sqlite3 $sqlite_path +for f in ${tmp_dir}/sql/* ; do (echo 'BEGIN;'; cat $f; echo 'COMMIT;') | sqlite3 $sqlite_path; done +rm -rf $tmp_dir diff --git a/components/ImportExportBundle/ci-config.yaml b/components/ImportExportBundle/ci-config.yaml new file mode 100644 index 000000000..baf7adaa7 --- /dev/null +++ b/components/ImportExportBundle/ci-config.yaml @@ -0,0 +1,3 @@ +install: true +test: false +repo: Novactive/AlmaviaCXIbexaImportExportBundle diff --git a/components/ImportExportBundle/composer.json b/components/ImportExportBundle/composer.json new file mode 100644 index 000000000..983b67860 --- /dev/null +++ b/components/ImportExportBundle/composer.json @@ -0,0 +1,41 @@ +{ + "name": "almaviacx/ibexaimportexportbundle", + "description": "Bundle to handle import/export", + "keywords": [ + "ibexa", + "bundle" + ], + "homepage": "https://github.com/Novactive/AlmaviaCXIbexaImportExportBundle", + "type": "ibexa-bundle", + "authors": [ + { + "name": "AlmaviaCX", + "homepage": "https://almaviacx.com/expertises/web-mobile/", + "email": "dirtech.web@almaviacx.com" + } + ], + "license": [ + "MIT" + ], + "require": { + "php": "^7.3 || ^8.0", + "craue/formflow-bundle": "^3.0", + "symfony/uid": "^5.4" + }, + "autoload": { + "psr-4": { + "AlmaviaCX\\Bundle\\IbexaImportExportBundle\\": "src/bundle", + "AlmaviaCX\\Bundle\\IbexaImportExport\\": "src/lib" + } + }, + "suggest": { + "matthiasnoback/symfony-console-form": "Used to execute workflow from CLI", + "phpoffice/phpspreadsheet": "Used for the XlsReader", + "symfony/messenger": "Used to run job asynchronously" + }, + "extra": { + }, + "bin": [ + "bin/mdb-to-sqlite.bash" + ] +} diff --git a/components/ImportExportBundle/doc/reader.md b/components/ImportExportBundle/doc/reader.md new file mode 100644 index 000000000..4b1511d9f --- /dev/null +++ b/components/ImportExportBundle/doc/reader.md @@ -0,0 +1,45 @@ +# Reader + +The job of a reader is to fetch the datas from somewhere and transmit them to the workflow processors. + +A reader service must implement `AlmaviaCX\Bundle\IbexaImportExport\Reader\ReaderInterface` and have the tag `almaviacx.import_export.component` and provide an alias + +```yaml +tags: + - { name: almaviacx.import_export.component, alias: reader.csv} +``` + +The bundle provide the `AlmaviaCX\Bundle\IbexaImportExport\Reader\AbstractReader` to simplify the creation of a service + +Using this abstraction, you just need to implement the `getName` and `__invoke` methods. + +```injectablephp +public static function getName() +{ + return new TranslatableMessage('reader.csv.name', [], 'import_export'); +} + +public function __invoke(): Iterator +{ + // TODO: Implement __invoke() method. +} +``` + +You can also override the following functions : +- `getOptionsFormType` to provide the form type used to manage the reader options +- `getOptionsType` to provide a different options class + + +## Provided readers + +### AlmaviaCX\Bundle\IbexaImportExport\Reader\Csv\CsvReader + +Fetch rows from a CSV file. + +Related options : `AlmaviaCX\Bundle\IbexaImportExport\Reader\Csv\CsvReader\CsvReaderOptions` + +### AlmaviaCX\Bundle\IbexaImportExport\Reader\Ibexa\ContentList\IbexaContentListReader + +Fetch a list of Ibexa contents + +Related options : `AlmaviaCX\Bundle\IbexaImportExport\Reader\Ibexa\ContentList\IbexaContentListReaderOptions` diff --git a/components/ImportExportBundle/doc/step.md b/components/ImportExportBundle/doc/step.md new file mode 100644 index 000000000..7ba7a442c --- /dev/null +++ b/components/ImportExportBundle/doc/step.md @@ -0,0 +1,5 @@ +# Step + +A step service must implement `AlmaviaCX\Bundle\IbexaImportExport\Step\StepInterface` and have the tag `almaviacx.import_export.component` + +The bundle provide the `AlmaviaCX\Bundle\IbexaImportExport\Step\AbstractStep` to simplify the creation of a service diff --git a/components/ImportExportBundle/doc/workflow.md b/components/ImportExportBundle/doc/workflow.md new file mode 100644 index 000000000..bac1b59e7 --- /dev/null +++ b/components/ImportExportBundle/doc/workflow.md @@ -0,0 +1,54 @@ +# Workflow + +Workflow can be created throught the admin UI or as a Symfony service. + +## Workflow service + +The workflow service must implement `AlmaviaCX\Bundle\IbexaImportExport\Workflow\WorkflowInterface` and have the tag `almaviacx.import_export.workflow` +```yaml +App\ImportExport\Workflow\ImportContentWorkflow: + tags: + - { name: almaviacx.import_export.workflow } +``` + +The bundle provide the `AlmaviaCX\Bundle\IbexaImportExport\Workflow\AbstractWorkflow` to simplify the creation of a service. + +Using this abstraction, you just need to implement the `getDefaultConfig` method in order to provide the configuration. + +Exemple : +```injectablephp +public static function getDefaultConfig(): WorkflowConfiguration +{ + $configuration = new WorkflowConfiguration( + 'app.import_export.workflow.import_content', + 'Import content', + ); + $readerOptions = new CsvReaderOptions(); + $readerOptions->headerRowNumber = 0; + $configuration->setReader( + CsvReader::class, + $readerOptions + ); + + $writerOptions = new IbexaContentWriterOptions(); + $writerOptions->map = new ItemTransformationMap( + [ + 'contentRemoteId' => [ + 'transformer' => SlugTransformer::class, + 'source' => new PropertyPath( '[name]' ) + ], + 'mainLanguageCode' => 'eng-GB', + 'contentTypeIdentifier' => 'article', + 'fields[eng-GB][title]' => new PropertyPath( '[name]' ), + 'fields[eng-GB][intro]' => new PropertyPath( '[intro]' ), + ] + ); + $configuration->addProcessor( + IbexaContentWriter::class, + $writerOptions + ); + return $configuration; +} +``` + +Every options that are not specified this way will be asked when creating the job triggering this workflow diff --git a/components/ImportExportBundle/doc/writer.md b/components/ImportExportBundle/doc/writer.md new file mode 100644 index 000000000..373f10ac2 --- /dev/null +++ b/components/ImportExportBundle/doc/writer.md @@ -0,0 +1,5 @@ +# Writer + +A writer service must implement `AlmaviaCX\Bundle\IbexaImportExport\Writer\WriterInterface` and have the tag `almaviacx.import_export.component` + +The bundle provide the `AlmaviaCX\Bundle\IbexaImportExport\Writer\AbstractWriter` to simplify the creation of a service diff --git a/components/ImportExportBundle/src/bundle/AlmaviaCXIbexaImportExportBundle.php b/components/ImportExportBundle/src/bundle/AlmaviaCXIbexaImportExportBundle.php new file mode 100644 index 000000000..d2499a592 --- /dev/null +++ b/components/ImportExportBundle/src/bundle/AlmaviaCXIbexaImportExportBundle.php @@ -0,0 +1,32 @@ +getExtension('ibexa'); + $ibexaExtension->addPolicyProvider(new PolicyProvider()); + + $container->addCompilerPass(new ComponentPass()); + $container->addCompilerPass(new WorkflowPass()); + $container->addCompilerPass(new ItemValueTransformerPass()); + } +} diff --git a/components/ImportExportBundle/src/bundle/Command/CreateJobCommand.php b/components/ImportExportBundle/src/bundle/Command/CreateJobCommand.php new file mode 100644 index 000000000..ebeb64099 --- /dev/null +++ b/components/ImportExportBundle/src/bundle/Command/CreateJobCommand.php @@ -0,0 +1,64 @@ +jobService = $jobService; + $this->eventDispatcher = $eventDispatcher; + } + + protected function configure() + { + parent::configure(); + $this->addArgument('identifier', InputArgument::REQUIRED, 'Workflow identifier'); + $this->addArgument('label', InputArgument::REQUIRED, 'Job label'); + $this->addArgument('creator', InputArgument::REQUIRED, 'Creator'); + $this->addOption('debug', null, InputOption::VALUE_NONE, 'Enable debug mode'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $job = new Job(); + $job->setLabel($input->getArgument('label')); + $job->setCreatorId((int) $input->getArgument('creator')); + $job->setWorkflowIdentifier($input->getArgument('identifier')); + + /** @var \Matthias\SymfonyConsoleForm\Console\Helper\FormHelper $formHelper */ + $formHelper = $this->getHelper('form'); + + $job = $formHelper->interactUsingForm( + JobProcessConfigurationFormType::class, + $input, + $output, + [], + $job + ); + + $this->eventDispatcher->dispatch(new PostJobCreateFormSubmitEvent($job)); + $this->jobService->createJob($job, false); + + return Command::SUCCESS; + } +} diff --git a/components/ImportExportBundle/src/bundle/Command/ExecuteWorkflowCommand.php b/components/ImportExportBundle/src/bundle/Command/ExecuteWorkflowCommand.php new file mode 100644 index 000000000..8f0121af0 --- /dev/null +++ b/components/ImportExportBundle/src/bundle/Command/ExecuteWorkflowCommand.php @@ -0,0 +1,76 @@ +workflowExecutor = $workflowExecutor; + $this->workflowRegistry = $workflowRegistry; + parent::__construct(); + } + + protected function configure() + { + parent::configure(); + $this->addArgument('identifier', InputArgument::REQUIRED, 'Workflow identifier'); + $this->addOption('debug', null, InputOption::VALUE_NONE, 'Enable debug mode'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $workflowIdentifier = $input->getArgument('identifier'); + $workflow = $this->workflowRegistry->getWorkflow($workflowIdentifier); + $baseConfiguration = $workflow->getDefaultConfig(); + if (!$baseConfiguration->isAvailable(WorkflowConfiguration::AVAILABILITY_CLI)) { + throw new InvalidArgumentException(sprintf('Workflow %s is not available', $workflowIdentifier)); + } + + /** @var \Matthias\SymfonyConsoleForm\Console\Helper\FormHelper $formHelper */ + $formHelper = $this->getHelper('form'); + $runtimeProcessConfiguration = $formHelper->interactUsingForm( + WorkflowProcessConfigurationFormType::class, + $input, + $output, + ['default_configuration' => $baseConfiguration->getProcessConfiguration()] + ); + + $progressBar = new ProgressBar($output); + + $workflow->addEventListener(WorkflowEvent::START, function (WorkflowEvent $event) use ($progressBar) { + $progressBar->start($event->getWorkflow()->getTotalItemsCount()); + }); + $workflow->addEventListener(WorkflowEvent::PROGRESS, function () use ($progressBar) { + $progressBar->advance(); + }); + $logger = new WorkflowConsoleLogger($output); + $workflow->setLogger($logger); + $workflow->setDebug($input->getOption('debug')); + ($this->workflowExecutor)($workflow, $runtimeProcessConfiguration); + $workflow->clean(); + + return Command::SUCCESS; + } +} diff --git a/components/ImportExportBundle/src/bundle/Controller/Admin/JobController.php b/components/ImportExportBundle/src/bundle/Controller/Admin/JobController.php new file mode 100644 index 000000000..0d85b6e87 --- /dev/null +++ b/components/ImportExportBundle/src/bundle/Controller/Admin/JobController.php @@ -0,0 +1,200 @@ +formFactory = $formFactory; + $this->notificationHandler = $notificationHandler; + $this->jobService = $jobService; + $this->jobCreateFlow = $jobCreateFlow; + $this->permissionResolver = $permissionResolver; + $this->eventDispatcher = $eventDispatcher; + } + + public function list(Request $request): Response + { + $page = $request->query->get('page') ?? 1; + + $pagerfanta = new Pagerfanta( + new CallbackAdapter( + function (): int { + return $this->jobService->countJobs(); + }, + function (int $offset, int $length): array { + return $this->jobService->loadJobs($length, $offset); + } + ) + ); + + $pagerfanta->setMaxPerPage(10); + $pagerfanta->setCurrentPage(min($page, $pagerfanta->getNbPages())); + + return $this->render('@ibexadesign/import_export/job/list.html.twig', [ + 'pager' => $pagerfanta, + 'can_create' => $this->isGranted(new Attribute('import_export', 'job.create')), + ]); + } + + public function create(Request $request): Response + { + $job = Instantiator::instantiate(Job::class); + $this->jobCreateFlow->bind($job); + + $form = $this->jobCreateFlow->createForm(); + if ($this->jobCreateFlow->isValid($form)) { + $this->jobCreateFlow->saveCurrentStepData($form); + if ($this->jobCreateFlow->nextStep()) { + // form for the next step + $form = $this->jobCreateFlow->createForm(); + } else { + $this->jobCreateFlow->reset(); + try { + $this->eventDispatcher->dispatch(new PostJobCreateFormSubmitEvent($job)); + + $job->setCreatorId($this->permissionResolver->getCurrentUserReference()->getUserId()); + $this->jobService->createJob($job); + $this->notificationHandler->success( + 'job.create.success', + ['%label%' => $job->getLabel()], + 'import_export' + ); + + return new RedirectResponse($this->generateUrl('import_export.job.view', [ + 'id' => $job->getId(), + ])); + } catch (Exception $exception) { + $this->notificationHandler->error( + /* @Ignore */ + $exception->getMessage() + ); + } + } + } + + return $this->render('@ibexadesign/import_export/job/create.html.twig', [ + 'form_job_create' => $form->createView(), + 'form_job_create_flow' => $this->jobCreateFlow, + ]); + } + + public function view(Job $job): Response + { + return $this->render('@ibexadesign/import_export/job/view.html.twig', [ + 'job' => $job, + ]); + } + + public function displayLogs(Job $job, RequestStack $requestStack): Response + { + $request = $requestStack->getMainRequest(); + + $countsByLevel = $this->jobService->getJobLogsCountByLevel($job); + $formBuilder = $this->formFactory->createNamedBuilder('logs', FormType::class, null, ['method' => 'GET']); + $formBuilder->add('level', ChoiceType::class, [ + 'label' => 'job.logs.level', + 'choices' => array_flip([null => array_sum($countsByLevel)] + $countsByLevel), + 'choice_label' => function ($choice, int $count, $level) { + return sprintf( + '%s (%d)', + $level ? Logger::getLevelName((int) $level) : 'ALL', + $count + ); + }, + 'attr' => [ + 'class' => 'ibexa-form-autosubmit', + ], + ]); + $form = $formBuilder->getForm(); + $form->handleRequest($request); + + $logsQuery = $request->get('logs', []) + ['page' => 1, 'level' => null]; + + $logs = $this->jobService->getJobLogs($job, $logsQuery['level'] ? (int) $logsQuery['level'] : null); + $pager = new Pagerfanta(new CollectionAdapter($logs)); + $pager->setMaxPerPage(50); + $pager->setCurrentPage($logsQuery['page']); + + return $this->render('@ibexadesign/import_export/job/logs.html.twig', [ + 'job' => $job, + 'logs' => $pager, + 'form' => $form->createView(), + 'request_query' => $request->query->all(), + ]); + } + + public function run(Job $job, int $batchLimit = null, bool $reset = false): Response + { + $this->jobService->runJob($job, $batchLimit, $reset); + + return new RedirectResponse($this->generateUrl('import_export.job.view', [ + 'id' => $job->getId(), + ])); + } + + public function debug(Job $job, int $index) + { + $this->jobService->debug($job, $index); + + return new RedirectResponse($this->generateUrl('import_export.job.view', [ + 'id' => $job->getId(), + ])); + } + + public function delete(Job $job): Response + { + $this->jobService->delete($job); + + return new RedirectResponse($this->generateUrl('import_export.job.list')); + } + + public static function getTranslationMessages(): array + { + return [ + (new Message('job.create.success', 'import_export'))->setDesc("Job '%label%' created."), + ]; + } +} diff --git a/components/ImportExportBundle/src/bundle/Controller/Admin/WriterController.php b/components/ImportExportBundle/src/bundle/Controller/Admin/WriterController.php new file mode 100644 index 000000000..efcbae8d0 --- /dev/null +++ b/components/ImportExportBundle/src/bundle/Controller/Admin/WriterController.php @@ -0,0 +1,87 @@ +twig = $twig; + $this->componentRegistry = $componentRegistry; + $this->fileHandler = $fileHandler; + } + + public function displayResults(Job $job): Response + { + $results = []; + foreach ($job->getWriterResults() as $index => $writerResults) { + try { + /** @var \AlmaviaCX\Bundle\IbexaImportExport\Writer\WriterInterface $writer */ + $writer = $this->componentRegistry->getComponent($writerResults->getWriterType()); + $template = $writer::getResultTemplate(); + if (!$template) { + continue; + } + $this->twig->load($template); + + $results[] = [ + 'template' => $template, + 'parameters' => [ + 'results' => $writerResults->getResults(), + 'writerIndex' => $index, + 'writer' => $writer, + 'job' => $job, + ], + ]; + } catch (LoaderError|NotFoundException $e) { + throw $e; + } + } + + return $this->render('@ibexadesign/import_export/job/results.html.twig', [ + 'job' => $job, + 'results' => $results, + ]); + } + + /** + * @param int|string $writerIndex + * + * @return \Symfony\Component\HttpFoundation\Response + */ + public function downloadFile(Job $job, $writerIndex): DownloadFileResponse + { + $writerResults = $job->getWriterResults()[$writerIndex]; + $writer = $this->componentRegistry->getComponent($writerResults->getWriterType()); + if (!$writer instanceof AbstractStreamWriter) { + throw new NotFoundHttpException(); + } + + $response = new DownloadFileResponse($writerResults->getResults()['filepath'], $this->fileHandler); + $response->setContentDisposition( + ResponseHeaderBag::DISPOSITION_ATTACHMENT, + $job->getLabel(), + ); + + return $response; + } +} diff --git a/components/ImportExportBundle/src/bundle/DependencyInjection/AlmaviaCXIbexaImportExportExtension.php b/components/ImportExportBundle/src/bundle/DependencyInjection/AlmaviaCXIbexaImportExportExtension.php new file mode 100644 index 000000000..a4382d957 --- /dev/null +++ b/components/ImportExportBundle/src/bundle/DependencyInjection/AlmaviaCXIbexaImportExportExtension.php @@ -0,0 +1,74 @@ +load('default_settings.yaml'); + $loader->load('controller.yaml'); + $loader->load('forms.yaml'); + $loader->load('event_subscriber.yaml'); + $loader->load('menu.yaml'); + $loader->load('services.yaml'); + $loader->load('accessor/ibexa/content_field_value_transformer.yaml'); + $loader->load('accessor/ibexa/object_accessor.yaml'); + $loader->load('item_value_transformer/transformers.yaml'); + $loader->load('workflow/component.yaml'); + $loader->load('workflow/job.yaml'); + $loader->load('workflow/workflow.yaml'); + + $activatedBundles = array_keys($container->getParameter('kernel.bundles')); + if (interface_exists('Symfony\Component\Messenger\MessageBusInterface')) { + $loader->load('messenger.yaml'); + } + if (in_array('IbexaTaxonomyBundle', $activatedBundles, true)) { + $loader->load('item_value_transformer/taxonomy.yaml'); + } +// if (in_array('IbexaFormBuilderBundle', $activatedBundles, true)) { +// } +// if (in_array('IbexaFieldTypePageBundle', $activatedBundles, true)) { +// } + } + + public function prepend(ContainerBuilder $container): void + { + $ibexaOrmConfig = [ + 'orm' => [ + 'entity_mappings' => [ + 'AlmaviaCXIbexaImportExport' => [ + 'type' => 'annotation', + 'dir' => __DIR__.'/../../lib', + 'prefix' => 'AlmaviaCX\Bundle\IbexaImportExport', + 'is_bundle' => false, + ], + ], + ], + ]; + $container->prependExtensionConfig('ibexa', $ibexaOrmConfig); + + $configs = [ + 'ibexa.yaml' => 'ibexa', + ]; + + foreach ($configs as $fileName => $extensionName) { + $configFile = __DIR__.'/../Resources/config/prepend/'.$fileName; + $config = Yaml::parse(file_get_contents($configFile)); + $container->prependExtensionConfig($extensionName, $config); + $container->addResource(new FileResource($configFile)); + } + } +} diff --git a/components/ImportExportBundle/src/bundle/DependencyInjection/CompilerPass/ComponentPass.php b/components/ImportExportBundle/src/bundle/DependencyInjection/CompilerPass/ComponentPass.php new file mode 100644 index 000000000..3b4ff6df1 --- /dev/null +++ b/components/ImportExportBundle/src/bundle/DependencyInjection/CompilerPass/ComponentPass.php @@ -0,0 +1,45 @@ +findTaggedServiceIds($tagName) as $serviceId => $tags) { + $serviceDefinition = $container->getDefinition($serviceId); + $serviceDefinition->setShared(false); + $servicesMap[$serviceDefinition->getClass()] = new Reference($serviceId); + + foreach ($tags as $attributes) { + if (!isset($attributes['alias'])) { + throw new LogicException( + sprintf( + 'Service "%s" tagged with "%s" service tag needs an "alias" attribute + to identify the Component Type.', + $serviceId, + $tagName + ) + ); + } + $servicesMap[$attributes['alias']] = new Reference($serviceId); + } + } + + $serviceLocator = ServiceLocatorTagPass::register($container, $servicesMap); + + $registryDefinition = $container->getDefinition(ComponentRegistry::class); + $registryDefinition->replaceArgument('$typeContainer', $serviceLocator); + } +} diff --git a/components/ImportExportBundle/src/bundle/DependencyInjection/CompilerPass/ItemValueTransformerPass.php b/components/ImportExportBundle/src/bundle/DependencyInjection/CompilerPass/ItemValueTransformerPass.php new file mode 100644 index 000000000..ea6234f5d --- /dev/null +++ b/components/ImportExportBundle/src/bundle/DependencyInjection/CompilerPass/ItemValueTransformerPass.php @@ -0,0 +1,45 @@ +findTaggedServiceIds($tagName) as $serviceId => $tags) { + $serviceDefinition = $container->getDefinition($serviceId); + $serviceDefinition->setShared(false); + + $servicesMap[$serviceDefinition->getClass()] = new Reference($serviceId); + foreach ($tags as $attributes) { + if (!isset($attributes['alias'])) { + throw new LogicException( + sprintf( + 'Service "%s" tagged with "%s" service tag needs an "alias" attribute + to identify the Component Type.', + $serviceId, + $tagName + ) + ); + } + $servicesMap[$attributes['alias']] = new Reference($serviceId); + } + } + + $serviceLocator = ServiceLocatorTagPass::register($container, $servicesMap); + + $registryDefinition = $container->getDefinition(ItemValueTransformerRegistry::class); + $registryDefinition->replaceArgument('$typeContainer', $serviceLocator); + } +} diff --git a/components/ImportExportBundle/src/bundle/DependencyInjection/CompilerPass/WorkflowPass.php b/components/ImportExportBundle/src/bundle/DependencyInjection/CompilerPass/WorkflowPass.php new file mode 100644 index 000000000..5f1636af8 --- /dev/null +++ b/components/ImportExportBundle/src/bundle/DependencyInjection/CompilerPass/WorkflowPass.php @@ -0,0 +1,38 @@ +findTaggedServiceIds('almaviacx.import_export.workflow')) as $serviceId) { + $workflowServiceDefinition = $container->getDefinition($serviceId); + $workflowServiceDefinition->setShared(false); + $workflowServiceClassName = $workflowServiceDefinition->getClass(); + $workflowServiceDefaultConfiguration = WorkflowRegistry::getWorkflowDefaultConfiguration( + $workflowServiceClassName + ); + + $workflowIdentifier = $workflowServiceDefaultConfiguration->getIdentifier(); + $workflowServicesMap[$workflowIdentifier] = new Reference($serviceId); + $availableWorkflowServices[$workflowIdentifier] = $workflowServiceClassName; + } + + $serviceLocator = ServiceLocatorTagPass::register($container, $workflowServicesMap); + + $registryDefinition = $container->getDefinition(WorkflowRegistry::class); + $registryDefinition->replaceArgument('$typeContainer', $serviceLocator); + $registryDefinition->replaceArgument('$availableWorkflowServices', $availableWorkflowServices); + } +} diff --git a/components/ImportExportBundle/src/bundle/DependencyInjection/Security/Provider/PolicyProvider.php b/components/ImportExportBundle/src/bundle/DependencyInjection/Security/Provider/PolicyProvider.php new file mode 100644 index 000000000..288faacd6 --- /dev/null +++ b/components/ImportExportBundle/src/bundle/DependencyInjection/Security/Provider/PolicyProvider.php @@ -0,0 +1,20 @@ + 0 ? processedCount / totalCount * 100 : 0 %} +{% set bg_color = progress == 100 ? 'bg-success' : 'bg-info' %} +
+
{{ processedCount }} / {{ totalCount }}
+
+
+
{{ progress|round }}% +
+
+
+
diff --git a/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/form/fields.html.twig b/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/form/fields.html.twig new file mode 100644 index 000000000..b48b3587a --- /dev/null +++ b/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/form/fields.html.twig @@ -0,0 +1,9 @@ +{% extends '@ibexadesign/ui/form_fields.html.twig' %} + +{% block _job_process_configuration_form_options_row %} + {% if form.children is empty %} + {{ 'job.options.empty'|trans()|desc('There is no options to configure') }} + {% else %} + {{ block('form_row') }} + {% endif %} +{% endblock %} diff --git a/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/form_flow_buttons.html.twig b/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/form_flow_buttons.html.twig new file mode 100644 index 000000000..03db5c8dc --- /dev/null +++ b/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/form_flow_buttons.html.twig @@ -0,0 +1,57 @@ +{% set renderBackButton = flow.getFirstStepNumber() < flow.getLastStepNumber() and flow.getCurrentStepNumber() in (flow.getFirstStepNumber() + 1) .. flow.getLastStepNumber() %} +{% set renderResetButton = craue_formflow_button_render_reset is defined ? craue_formflow_button_render_reset : true %} +{% set buttonCount = 1 + (renderBackButton ? 1 : 0) + (renderResetButton ? 1 : 0) %} + + diff --git a/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/job/create.html.twig b/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/job/create.html.twig new file mode 100644 index 000000000..d2f9a182a --- /dev/null +++ b/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/job/create.html.twig @@ -0,0 +1,37 @@ +{% extends '@ibexadesign/ui/edit_base.html.twig' %} + +{% form_theme form_job_create '@ibexadesign/import_export/form/fields.html.twig' %} + +{% trans_default_domain 'import_export' %} + +{% set anchor_params = { + close_href: path('import_export.job.list'), +} %} + +{% block header %} + {% set job_create_sidebar_right = knp_menu_get('almaviacx.import_export.menu.job_create.sidebar_right', [], {'flow': form_job_create_flow}) %} + + {% include '@ibexadesign/ui/edit_header.html.twig' with { + action_name: 'job.creating'|trans|desc('Creating'), + title: 'job.new.title'|trans|desc('Creating a new Job') ~ ' - ' ~ form_job_create_flow.getCurrentStepLabel(), + context_actions: knp_menu_render(job_create_sidebar_right, {'template': '@ibexadesign/ui/menu/context_menu.html.twig'}) + } %} +{% endblock %} + +{% block content %} +
+ {{ form_start(form_job_create) }} +
+
+ {{ form_rest(form_job_create) }} +
+
+ {% include '@ibexadesign/import_export/form_flow_buttons.html.twig' with { flow: form_job_create_flow } only %} + {{ form_end(form_job_create) }} +
+{% endblock %} + +{% block stylesheets %} + {{ parent() }} + +{% endblock %} diff --git a/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/job/list.html.twig b/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/job/list.html.twig new file mode 100644 index 000000000..cad643b89 --- /dev/null +++ b/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/job/list.html.twig @@ -0,0 +1,117 @@ +{% extends "@ibexadesign/ui/layout.html.twig" %} + +{% from '@ibexadesign/ui/component/macros.html.twig' import results_headline %} + +{% trans_default_domain 'import_export' %} + +{% block body_class %}import_export-job-list-view{% endblock %} + +{% block breadcrumbs %} + {% include '@ibexadesign/ui/breadcrumbs.html.twig' with { items: [ + { value: 'breadcrumb.admin'|trans(domain='messages')|desc('Admin') }, + { value: 'job.breadcrumb.list'|trans|desc('Jobs') } + ]} %} +{% endblock %} + +{% block header %} + {% include '@ibexadesign/ui/page_title.html.twig' with { + title: 'job.list.title'|trans|desc('Jobs'), + } %} +{% endblock %} + +{% block context_menu %} + {% set menu_items %} + {% if can_create %} +
  • + + + + + + {{ 'job.list.action.create'|trans|desc('Create') }} + + +
  • + {% endif %} + {% endset %} + + {{ include('@ibexadesign/ui/component/context_menu/context_menu.html.twig', { + menu_items: menu_items, + }) }} +{% endblock %} + +{% block content %} +
    + {% set body_rows = [] %} + {% set status = [ + 'job.status.pending'|trans()|desc('Pending'), + 'job.status.running'|trans()|desc('Running'), + 'job.status.completed'|trans()|desc('Completed'), + 'job.status.queued'|trans()|desc('Queued'), + 'job.status.paused'|trans()|desc('Paused') + + ] %} + {% set show_table_notice = false %} + + {% for job in pager.currentPageResults %} + {% set body_row_cols = [] %} + + {% set body_row_cols = body_row_cols|merge([ + { content: job.id }, + ]) %} + + {% set col_raw %} + {% set view_url = path('import_export.job.view', { + id: job.id + }) %} + + {{ job.label }} + {% endset %} + {% set body_row_cols = body_row_cols|merge([{ + content: col_raw, + raw: true, + }]) %} + + {% set progress_bar %} + {% include '@ibexadesign/import_export/components/progress_bar.html.twig' with {processedCount: job.processedItemsCount, totalCount: job.totalItemsCount} only %} + {% endset %} + {% set body_row_cols = body_row_cols|merge([{ + content: progress_bar, + raw: true, + }]) %} + + {% set body_row_cols = body_row_cols|merge([ + { content: status[job.status] }, + { content: job.requestedDate|ibexa_full_datetime }, + ]) %} + + {% set body_rows = body_rows|merge([{ cols: body_row_cols }]) %} + {% endfor %} + + {% embed '@ibexadesign/ui/component/table/table.html.twig' with { + headline: custom_results_headline ?? results_headline(pager.getNbResults()), + head_cols: [ + { content: 'job.property.id'|trans|desc('Id') }, + { content: 'job.property.label'|trans|desc('Label') }, + { content: 'job.property.progress'|trans()|desc('Progress') }, + { content: 'job.property.status'|trans|desc('Status') }, + { content: 'job.property.requested_date'|trans|desc('Requested date') }, + ], + body_rows, + show_notice: show_table_notice, + } %} + {% endembed %} + + {% if pager.haveToPaginate %} + {% include '@ibexadesign/ui/pagination.html.twig' with { + 'pager': pager + } %} + {% endif %} +
    +{% endblock %} + +{% block javascripts %} +{% endblock %} diff --git a/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/job/logs.html.twig b/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/job/logs.html.twig new file mode 100644 index 000000000..be0122cb0 --- /dev/null +++ b/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/job/logs.html.twig @@ -0,0 +1,60 @@ +{% trans_default_domain 'import_export' %} + +{% form_theme form with '@ibexadesign/ui/form_fields.html.twig' %} + +
    + {% set levelColors = { + 'DEBUG' : 'bg-info', + 'INFO' : 'bg-success text-white', + 'NOTICE' : 'bg-light text-white', + 'WARNING' : 'bg-warning', + 'ERROR' : 'bg-danger text-white', + 'CRITICAL' : 'bg-danger text-white', + 'ALERT' : 'bg-danger text-white', + 'EMERGENCY' : 'bg-dark text-white', + } %} + {% set body_rows = [] %} + + {% for log in logs %} + {% set log_level %} + {{ log.record.level_name }} + {% endset %} + {% set message %} + {% if log.record.context.exception is defined %} + +
    +
    {{ log.record.context.exception }}
    +
    + {% else %} + {{ log.record.message }} + {% endif %} + {% endset %} + {% set body_row_cols = [ + { content: log.record.context.item_index|default() }, + { content: log_level, raw: true }, + { content: message, raw: true } + ] %} + + {% set body_rows = body_rows|merge([{ cols: body_row_cols }]) %} + {% endfor %} + + {% set headline = 'job.view.logs.title'|trans()|desc('Logs') %} + {% set actions %} + {{ form_start(form) }} + {{ form_widget(form.level) }} + {{ form_end(form) }} + {% endset %} + + {% embed '@ibexadesign/ui/component/table/table.html.twig' with { + headline: headline, + actions: actions, + head_cols: [ + { content: 'job.view.log.item_index'|trans|desc('Item') }, + { content: 'job.view.log.type'|trans|desc('Type') }, + { content: 'job.view.log.message'|trans|desc('Message') }, + ], + body_rows, + } %} + {% endembed %} + {{ pagerfanta(logs, 'ibexa', {'routeName': 'import_export.job.view', 'routeParams': {'id': job.id}|merge(request_query), 'pageParameter': '[logs][page]'}) }} +
    diff --git a/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/job/results.html.twig b/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/job/results.html.twig new file mode 100644 index 000000000..1ddd9ae88 --- /dev/null +++ b/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/job/results.html.twig @@ -0,0 +1,7 @@ +{% trans_default_domain 'import_export' %} + +
    + {% for result in results %} + {% include result.template with result.parameters only %} + {% endfor %} +
    diff --git a/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/job/view.html.twig b/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/job/view.html.twig new file mode 100644 index 000000000..cf43d9527 --- /dev/null +++ b/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/job/view.html.twig @@ -0,0 +1,98 @@ +{# @var job \AlmaviaCX\Bundle\IbexaImportExport\Job\Job #} +{% extends '@ibexadesign/ui/layout.html.twig' %} + +{% trans_default_domain 'import_export' %} + +{% block body_class %}import_export-job-view{% endblock %} + +{% block breadcrumbs %} + {% include '@ibexadesign/ui/breadcrumbs.html.twig' with { items: [ + { value: 'breadcrumb.admin'|trans(domain='messages')|desc('Admin') }, + { url: path('import_export.job.list'), value: 'job.breadcrumb.list'|trans|desc('Jobs') }, + { value: 'job.view.title'|trans({ '%label%': job.label })|desc('Job: %label%') } + ]} %} +{% endblock %} + +{% block header %} + {% embed '@ibexadesign/ui/page_title.html.twig' with { + title: job.label, + } %} + {% block bottom %} + + {{ 'job.list.title'|trans(domain='import_export')|desc('Jobs') }} + + {% endblock %} + {% endembed %} +{% endblock %} + +{% block content %} + {% set status = [ + 'job.status.pending'|trans()|desc('Pending'), + 'job.status.running'|trans()|desc('Running'), + 'job.status.completed'|trans()|desc('Completed'), + 'job.status.queued'|trans()|desc('Queued'), + 'job.status.paused'|trans()|desc('Paused') + + ] %} + {% set progress_bar %} + {% include '@ibexadesign/import_export/components/progress_bar.html.twig' with {processedCount: job.processedItemsCount, totalCount: job.totalItemsCount} only %} + {% endset %} + {% set information_items = [ + { + label: 'job.property.id'|trans|desc('Id'), + content: job.id, + }, + { + label: 'job.property.label'|trans|desc('Label'), + content: job.label, + }, + { + label: 'job.property.requested_date'|trans|desc('Requested date'), + content: job.requestedDate|ibexa_full_datetime, + }, + { + label: 'job.property.status'|trans|desc('Status'), + content: status[job.status], + }, + { + label: 'job.property.startTime'|trans|desc('Start time'), + content: job.startTime ? job.startTime|ibexa_full_datetime : '', + }, + { + label: 'job.property.endTime'|trans|desc('End time'), + content: job.endTime ? job.endTime|ibexa_full_datetime : '', + }, + { + label: 'job.property.progress'|trans()|desc('Progress'), + content_raw: progress_bar, + }, + ] %} + + {% set information_headline_items %} + + + + + + {{ 'job.run'|trans|desc('Run') }} + + + {% endset %} + +
    + {% include '@ibexadesign/ui/component/details/details.html.twig' with { + headline: 'tab.details.technical_details'|trans()|desc('Technical details'), + headline_items: information_headline_items, + items: information_items, + } only %} +
    + + {{ render(controller('AlmaviaCX\\Bundle\\IbexaImportExportBundle\\Controller\\Admin\\WriterController::displayResults', {job: job})) }} + {{ render(controller('AlmaviaCX\\Bundle\\IbexaImportExportBundle\\Controller\\Admin\\JobController::displayLogs', {job: job})) }} +{% endblock %} + +{% block javascripts %} +{% endblock %} diff --git a/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/notification/default.html.twig b/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/notification/default.html.twig new file mode 100644 index 000000000..b9c059233 --- /dev/null +++ b/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/notification/default.html.twig @@ -0,0 +1,25 @@ +{% extends '@ibexadesign/account/notifications/list_item.html.twig' %} + +{% trans_default_domain 'import_export' %} + +{% block icon %} + + + + + +{% endblock %} + +{% block notification_type %} + + {{ 'Notice'|trans|desc('Notice') }} + +{% endblock %} + +{% block message %} + {% embed '@ibexadesign/ui/component/table/table_body_cell.html.twig' with { class: 'ibexa-notifications-modal__description' } %} + {% block content %} +

    {{ notification.data.message|trans(notification.data.message_parameters|default([])) }}

    + {% endblock %} + {% endembed %} +{% endblock %} diff --git a/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/writer/results/writer_csv.html.twig b/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/writer/results/writer_csv.html.twig new file mode 100644 index 000000000..63a9453ff --- /dev/null +++ b/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/writer/results/writer_csv.html.twig @@ -0,0 +1,21 @@ +{% trans_default_domain 'import_export' %} +{% embed '@ibexadesign/ui/component/details/details.html.twig' %} + {% block details_header %} +
    + {% include '@ibexadesign/ui/component/table/table_header.html.twig' with { + headline: writer.name|trans(), + actions: headline_items|default([]) + } %} +
    + {% endblock %} + {% block details_items %} +
    +
    +
    {{ 'writer.csv.generated_file.title'|trans([], 'import_export')|desc('Generated file') }}
    + +
    +
    + {% endblock %} +{% endembed %} diff --git a/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/writer/results/writer_ibexa_content.html.twig b/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/writer/results/writer_ibexa_content.html.twig new file mode 100644 index 000000000..b000b808b --- /dev/null +++ b/components/ImportExportBundle/src/bundle/Resources/views/themes/admin/import_export/writer/results/writer_ibexa_content.html.twig @@ -0,0 +1,15 @@ +{% trans_default_domain 'import_export' %} +{% embed '@ibexadesign/ui/component/details/details.html.twig' %} + {% block details_header %} +
    + {% include '@ibexadesign/ui/component/table/table_header.html.twig' with { + headline: writer.name|trans(), + actions: headline_items|default([]) + } %} +
    + {% endblock %} + {% block details_items %} +
    +
    + {% endblock %} +{% endembed %} diff --git a/components/ImportExportBundle/src/lib/Accessor/AbstractItemAccessor.php b/components/ImportExportBundle/src/lib/Accessor/AbstractItemAccessor.php new file mode 100644 index 000000000..67733f199 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Accessor/AbstractItemAccessor.php @@ -0,0 +1,18 @@ +getPropertyAccessor(); + } +} diff --git a/components/ImportExportBundle/src/lib/Accessor/ArrayAccessor.php b/components/ImportExportBundle/src/lib/Accessor/ArrayAccessor.php new file mode 100644 index 000000000..83f0dc0ee --- /dev/null +++ b/components/ImportExportBundle/src/lib/Accessor/ArrayAccessor.php @@ -0,0 +1,68 @@ + */ + protected array $array; + + /** + * @param array $array + */ + public function __construct(array $array) + { + $this->array = $array; + } + + /** + * @param int|string $offset + */ + public function offsetExists($offset): bool + { + return isset($this->array[$offset]); + } + + /** + * @param $offset + */ + public function offsetGet($offset) + { + if (!$this->offsetExists($offset)) { + throw new Exception( + sprintf( + 'Undefined offset: %s. +Available offsets are %s', + $offset, + implode(' / ', array_map(function ($value) { + return "'$value'"; + }, array_keys($this->array))) + ) + ); + } + + return $this->array[$offset]; + } + + /** + * @param int|string $offset + */ + public function offsetSet($offset, $value): void + { + $this->array[$offset] = $value; + } + + /** + * @param int|string $offset + */ + public function offsetUnset($offset): void + { + unset($this->array[$offset]); + } +} diff --git a/components/ImportExportBundle/src/lib/Accessor/DatetimeAccessor.php b/components/ImportExportBundle/src/lib/Accessor/DatetimeAccessor.php new file mode 100644 index 000000000..858a55a4a --- /dev/null +++ b/components/ImportExportBundle/src/lib/Accessor/DatetimeAccessor.php @@ -0,0 +1,21 @@ +timestamp = $dateTime->getTimestamp(); + $this->ISO8601 = $dateTime->format('c'); + $this->YMD = $dateTime->format('Y-m-d'); + } +} diff --git a/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/ContentAccessor.php b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/ContentAccessor.php new file mode 100644 index 000000000..cdcb7b0e6 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/ContentAccessor.php @@ -0,0 +1,30 @@ + */ + public array $names; + public DatetimeAccessor $creationDate; + public int $mainLocationId; + public int $id; + + protected Content $content; + + public function getContent(): Content + { + return $this->content; + } +} diff --git a/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/ContentAccessorBuilder.php b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/ContentAccessorBuilder.php new file mode 100644 index 000000000..bde4652f5 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/ContentAccessorBuilder.php @@ -0,0 +1,72 @@ +contentService = $contentService; + $this->contentFieldAccessorBuilder = $contentFieldAccessorBuilder; + } + + public function buildFromContent(Content $content): ContentAccessor + { + return $this->create(function () use ($content) { + return $content; + }); + } + + public function create(callable $contentInitializer): ContentAccessor + { + $initializers = [ + "\0*\0content" => $contentInitializer, + 'id' => function (ContentAccessor $instance) { + return $instance->getContent()->id; + }, + 'mainLocationId' => function (ContentAccessor $instance) { + return $instance->getContent()->contentInfo->mainLocationId; + }, + 'fields' => function (ContentAccessor $instance) { + $content = $instance->getContent(); + $fields = []; + foreach ($content->getFields() as $field) { + $fieldDefinition = $content->getContentType()->getFieldDefinition($field->fieldDefIdentifier); + $fields[$field->fieldDefIdentifier] = $this->contentFieldAccessorBuilder->build( + $field, + $fieldDefinition + ); + } + + return $fields; + }, + 'names' => function (ContentAccessor $instance) { + return $instance->getContent()->getVersionInfo()->getNames(); + }, + 'creationDate' => function (ContentAccessor $instance) { + return new DatetimeAccessor($instance->getContent()->versionInfo->creationDate); + }, + ]; + + return ContentAccessor::createLazyGhost($initializers); + } + + public function buildFromContentId(int $contentId): ContentAccessor + { + return $this->create(function () use ($contentId) { + return $this->contentService->loadContent($contentId); + }); + } +} diff --git a/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ContentFieldAccessor.php b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ContentFieldAccessor.php new file mode 100644 index 000000000..dd75e480f --- /dev/null +++ b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ContentFieldAccessor.php @@ -0,0 +1,14 @@ + $transformers + */ + public function __construct( + iterable $transformers + ) { + foreach ($transformers as $type => $transformer) { + $this->contentFieldValueTransformers[$type] = $transformer; + } + } + + public function build(Field $field, FieldDefinition $fieldDefinition): ContentFieldAccessor + { + $initializers = [ + 'value' => function (ContentFieldAccessor $instance) use ($field, $fieldDefinition) { + return $this->getValue($field, $fieldDefinition); + }, + ]; + + return ContentFieldAccessor::createLazyGhost($initializers); + } + + protected function getValue(Field $field, FieldDefinition $fieldDefinition) + { + $transformer = $this->contentFieldValueTransformers[$field->fieldTypeIdentifier] ?? null; + if ($transformer) { + return $transformer($field, $fieldDefinition); + } + + return $field->value; + } +} diff --git a/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/DateFieldValueTransformer.php b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/DateFieldValueTransformer.php new file mode 100644 index 000000000..683007bbf --- /dev/null +++ b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/DateFieldValueTransformer.php @@ -0,0 +1,17 @@ +getValue()->date); + } +} diff --git a/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/DateTimeFieldValueTransformer.php b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/DateTimeFieldValueTransformer.php new file mode 100644 index 000000000..982cf9660 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/DateTimeFieldValueTransformer.php @@ -0,0 +1,17 @@ +getValue()->value); + } +} diff --git a/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/FieldValueTransformerInterface.php b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/FieldValueTransformerInterface.php new file mode 100644 index 000000000..c21a45150 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/FieldValueTransformerInterface.php @@ -0,0 +1,13 @@ +propertyName = $propertyName; + } + + public function __invoke(Field $field, FieldDefinition $fieldDefinition) + { + $accessor = PropertyAccess::createPropertyAccessor(); + + return $accessor->getValue($field, $this->propertyName); + } +} diff --git a/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/RelationFieldValueTransformer.php b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/RelationFieldValueTransformer.php new file mode 100644 index 000000000..67784d841 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/RelationFieldValueTransformer.php @@ -0,0 +1,33 @@ +contentAccessorBuilder = $contentAccessorBuilder; + } + + public function __invoke(Field $field, FieldDefinition $fieldDefinition): ?ContentAccessor + { + /** @var RelationValue $fieldValue */ + $fieldValue = $field->getValue(); + if (null === $fieldValue->destinationContentId) { + return null; + } + + return $this->contentAccessorBuilder->buildFromContentId($fieldValue->destinationContentId); + } +} diff --git a/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/RelationListFieldValueTransformer.php b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/RelationListFieldValueTransformer.php new file mode 100644 index 000000000..1dbd3cf1d --- /dev/null +++ b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/RelationListFieldValueTransformer.php @@ -0,0 +1,38 @@ +contentAccessorBuilder = $contentAccessorBuilder; + } + + /** + * @return ContentAccessor[] + */ + public function __invoke(Field $field, FieldDefinition $fieldDefinition): array + { + /** @var RelationListValue $fieldValue */ + $fieldValue = $field->getValue(); + if (empty($fieldValue->destinationContentIds)) { + return []; + } + + return array_map(function (int $contentId) { + return $this->contentAccessorBuilder->buildFromContentId($contentId); + }, $fieldValue->destinationContentIds); + } +} diff --git a/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/RichtextFieldValueTransformer.php b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/RichtextFieldValueTransformer.php new file mode 100644 index 000000000..a3e872e17 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/RichtextFieldValueTransformer.php @@ -0,0 +1,31 @@ +richTextOutputConverter = $richTextOutputConverter; + } + + public function __invoke(Field $field, FieldDefinition $fieldDefinition): object + { + /** @var \Ibexa\FieldTypeRichText\FieldType\RichText\Value $fieldValue */ + $fieldValue = $field->value; + + return (object) [ + 'xml' => $fieldValue->xml->saveXML(), + 'html' => $this->richTextOutputConverter->convert($fieldValue->xml)->saveHTML(), + ]; + } +} diff --git a/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/SelectionFieldValueTransformer.php b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/SelectionFieldValueTransformer.php new file mode 100644 index 000000000..c582d5e39 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/SelectionFieldValueTransformer.php @@ -0,0 +1,21 @@ +getValue(); + + return array_intersect_key( + $fieldDefinition->fieldSettings['options'], + array_flip($fieldValue->selection) + ); + } +} diff --git a/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/TaxonomyFieldValueTransformer.php b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/TaxonomyFieldValueTransformer.php new file mode 100644 index 000000000..73b976fe5 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Content/Field/ValueTransformer/TaxonomyFieldValueTransformer.php @@ -0,0 +1,34 @@ +taxonomyAccessorBuilder = $taxonomyAccessorBuilder; + } + + /** + * @return array<\Ibexa\Contracts\Taxonomy\Value\TaxonomyEntry> + */ + public function __invoke(Field $field, FieldDefinition $fieldDefinition): array + { + /** @var TaxonomyEntryAssignmentValue $fieldValue */ + $fieldValue = $field->value; + + return array_map(function (TaxonomyEntry $taxonomy) { + return $this->taxonomyAccessorBuilder->buildFromTaxonomyEntry($taxonomy); + }, $fieldValue->getTaxonomyEntries()); + } +} diff --git a/components/ImportExportBundle/src/lib/Accessor/Ibexa/ObjectAccessor.php b/components/ImportExportBundle/src/lib/Accessor/Ibexa/ObjectAccessor.php new file mode 100644 index 000000000..4a8e199ce --- /dev/null +++ b/components/ImportExportBundle/src/lib/Accessor/Ibexa/ObjectAccessor.php @@ -0,0 +1,21 @@ +locationService = $locationService; + $this->contentService = $contentService; + $this->contentAccessorBuilder = $contentAccessorBuilder; + } + + public function buildFromContent(Content $content): ObjectAccessor + { + $initializers = [ + 'content' => function (ObjectAccessor $instance) use ($content) { + return $this->contentAccessorBuilder->buildFromContent($content); + }, + 'mainLocation' => function (ObjectAccessor $instance) use ($content) { + return $content->contentInfo->getMainLocation(); + }, + 'contentType' => function (ObjectAccessor $instance) use ($content) { + return $content->contentInfo->getContentType(); + }, + 'locations' => function (ObjectAccessor $instance) use ($content) { + return $this->locationService->loadLocations($content->contentInfo); + }, + ]; + + return $this->createLazyGhost($initializers); + } + + protected function createLazyGhost(array $initializers): ObjectAccessor + { + return ObjectAccessor::createLazyGhost($initializers); + } +} diff --git a/components/ImportExportBundle/src/lib/Accessor/Ibexa/Taxonomy/TaxonomyAccessor.php b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Taxonomy/TaxonomyAccessor.php new file mode 100644 index 000000000..457442e45 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Taxonomy/TaxonomyAccessor.php @@ -0,0 +1,29 @@ + */ + public array $names; + public ?TaxonomyAccessor $parent; + public string $taxonomy; + + protected TaxonomyEntry $taxonomyEntry; + + public function getTaxonomyEntry(): TaxonomyEntry + { + return $this->taxonomyEntry; + } +} diff --git a/components/ImportExportBundle/src/lib/Accessor/Ibexa/Taxonomy/TaxonomyAccessorBuilder.php b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Taxonomy/TaxonomyAccessorBuilder.php new file mode 100644 index 000000000..4f902ab74 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Accessor/Ibexa/Taxonomy/TaxonomyAccessorBuilder.php @@ -0,0 +1,46 @@ +create(function () use ($taxonomyEntry) { + return $taxonomyEntry; + }); + } + + public function create(callable $taxonomyEntryInitializer): TaxonomyAccessor + { + $initializers = [ + "\0*\0taxonomyEntry" => $taxonomyEntryInitializer, + 'id' => function (TaxonomyAccessor $instance) { + return $instance->getTaxonomyEntry()->getId(); + }, + 'identifier' => function (TaxonomyAccessor $instance) { + return $instance->getTaxonomyEntry()->getIdentifier(); + }, + 'name' => function (TaxonomyAccessor $instance) { + return $instance->getTaxonomyEntry()->getName(); + }, + 'names' => function (TaxonomyAccessor $instance) { + return $instance->getTaxonomyEntry()->getNames(); + }, + 'parent' => function (TaxonomyAccessor $instance) { + $parent = $instance->getTaxonomyEntry()->getParent(); + + return $parent ? $this->buildFromTaxonomyEntry($parent) : null; + }, + 'taxonomy' => function (TaxonomyAccessor $instance) { + return $instance->getTaxonomyEntry()->getTaxonomy(); + }, + ]; + + return TaxonomyAccessor::createLazyGhost($initializers); + } +} diff --git a/components/ImportExportBundle/src/lib/Accessor/XpathPropertyAccessor.php b/components/ImportExportBundle/src/lib/Accessor/XpathPropertyAccessor.php new file mode 100644 index 000000000..8f4bf6895 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Accessor/XpathPropertyAccessor.php @@ -0,0 +1,38 @@ +ownerDocument); + + return $xpath->evaluate((string) $propertyPath, $objectOrArray); + } + + public function isWritable($objectOrArray, $propertyPath) + { + return false; + } + + public function isReadable($objectOrArray, $propertyPath) + { + return true; + } +} diff --git a/components/ImportExportBundle/src/lib/AdminUi/Menu/Event/ConfigureMenuEvent.php b/components/ImportExportBundle/src/lib/AdminUi/Menu/Event/ConfigureMenuEvent.php new file mode 100644 index 000000000..841a8916f --- /dev/null +++ b/components/ImportExportBundle/src/lib/AdminUi/Menu/Event/ConfigureMenuEvent.php @@ -0,0 +1,11 @@ + ['onMenuConfigure', -1000], + ]; + } + + public function onMenuConfigure(ConfigureMenuEvent $event): void + { + $menu = $event->getMenu(); + + $contentMenu = $menu->getChild(MainMenuBuilder::ITEM_CONTENT); + + $importExportGroup = $contentMenu->addChild( + 'export_import', + [ + 'extras' => [ + 'orderNumber' => 1000, + ], + ] + ); + $importExportGroup->addChild( + 'export_import_job_list', + [ + 'route' => 'import_export.job.list', + ] + ); + } + + /** + * {@inheritdoc} + */ + public static function getTranslationMessages(): array + { + return [ + ( new Message('export_import', 'ibexa_menu') )->setDesc('Import / Export'), + ( new Message('export_import_job_list', 'ibexa_menu') )->setDesc('Jobs'), + ]; + } +} diff --git a/components/ImportExportBundle/src/lib/AdminUi/Menu/JobCreateRightSidebarBuilder.php b/components/ImportExportBundle/src/lib/AdminUi/Menu/JobCreateRightSidebarBuilder.php new file mode 100644 index 000000000..e77787e69 --- /dev/null +++ b/components/ImportExportBundle/src/lib/AdminUi/Menu/JobCreateRightSidebarBuilder.php @@ -0,0 +1,124 @@ +factory->createItem('root'); + + $childrens = []; + /** @var FormFlow $flow */ + $flow = $options['flow']; + $renderBackButton = $flow->getFirstStepNumber() < $flow->getLastStepNumber() && + in_array( + $flow->getCurrentStepNumber(), + range(($flow->getFirstStepNumber() + 1), $flow->getLastStepNumber()) + ); + $renderResetButton = $options['render_reset'] ?? true; + $isLastStep = $flow->getCurrentStepNumber() == $flow->getLastStepNumber(); + + $buttons = [ + [ + 'id' => self::ITEM__FINISH, + 'label' => self::ITEM__FINISH, + 'render' => $isLastStep, + 'attributes' => [ + 'class' => 'ibexa-btn--trigger', + 'data-click' => '#form_flow_next', + ], + ], + [ + 'id' => self::ITEM__NEXT, + 'label' => self::ITEM__NEXT, + 'render' => !$isLastStep, + 'attributes' => [ + 'class' => 'ibexa-btn--trigger', + 'data-click' => '#form_flow_next', + ], + ], + [ + 'id' => self::ITEM__BACK, + 'label' => self::ITEM__BACK, + 'render' => $renderBackButton, + 'attributes' => [ + 'class' => 'ibexa-btn--trigger', + 'data-click' => '#form_flow_back', + ], + ], + [ + 'id' => self::ITEM__RESET, + 'label' => self::ITEM__RESET, + 'render' => $renderResetButton, + 'attributes' => [ + 'class' => 'ibexa-btn--trigger', + 'data-click' => '#form_flow_reset', + ], + ], + ]; + + foreach ($buttons as $button) { + if ($button['render']) { + $childrens[$button['id']] = $this->createMenuItem( + $button['id'], + [ + 'attributes' => $button['attributes'], + ] + ); + } + } + + $childrens[self::ITEM__CANCEL] = $this->createMenuItem( + self::ITEM__CANCEL, + [ + 'route' => 'import_export.job.list', + ] + ); + $menu->setChildren($childrens); + + return $menu; + } + + /** + * @return \JMS\TranslationBundle\Model\Message[] + */ + public static function getTranslationMessages(): array + { + return [ + ( new Message(self::ITEM__FINISH, 'ibexa_menu') )->setDesc('Create'), + ( new Message(self::ITEM__NEXT, 'ibexa_menu') )->setDesc('Next'), + ( new Message(self::ITEM__BACK, 'ibexa_menu') )->setDesc('Back'), + ( new Message(self::ITEM__RESET, 'ibexa_menu') )->setDesc('Reset'), + ( new Message(self::ITEM__CANCEL, 'ibexa_menu') )->setDesc('Discard changes'), + ]; + } +} diff --git a/components/ImportExportBundle/src/lib/Component/AbstractComponent.php b/components/ImportExportBundle/src/lib/Component/AbstractComponent.php new file mode 100644 index 000000000..10b972b91 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Component/AbstractComponent.php @@ -0,0 +1,64 @@ +options = $options; + } + + public function getOptions(): ComponentOptions + { + return $this->options; + } + + public function getOption(string $name, $default = null) + { + return $this->options->{$name} ?? $default; + } + + public function clean(): void + { + } + + public function prepare(): void + { + } + + public function finish(): void + { + } + + public function setLogger(WorkflowLoggerInterface $logger): void + { + $this->logger = $logger; + } +} diff --git a/components/ImportExportBundle/src/lib/Component/ComponentBuilder.php b/components/ImportExportBundle/src/lib/Component/ComponentBuilder.php new file mode 100644 index 000000000..f04053660 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Component/ComponentBuilder.php @@ -0,0 +1,38 @@ +componentRegistry = $componentRegistry; + } + + public function __invoke( + ComponentReference $componentReference, + ?ComponentOptions $runtimeProcessConfiguration = null + ): ComponentInterface { + $component = $this->componentRegistry->getComponent($componentReference->getType()); + + $options = $componentReference->getOptions(); + if ($options) { + if ($runtimeProcessConfiguration) { + $options->merge($runtimeProcessConfiguration); + } + $options->replaceComponentReferences($this); + $component->setOptions( + $options + ); + } + + return $component; + } +} diff --git a/components/ImportExportBundle/src/lib/Component/ComponentInterface.php b/components/ImportExportBundle/src/lib/Component/ComponentInterface.php new file mode 100644 index 000000000..f0e8e5642 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Component/ComponentInterface.php @@ -0,0 +1,34 @@ +getAvailableOptions(); + foreach ($availableOptions as $option) { + $this->initializationState[$option] = false; + } + } + + public function getInitializationState(): array + { + return $this->initializationState; + } + + public function getInitializedOptions(): array + { + return array_keys(array_filter($this->initializationState, static function ($option) { + return true === $option; + })); + } + + public function getNonInitializedOptions(): array + { + return array_keys(array_filter($this->initializationState, static function ($option) { + return false === $option; + })); + } + + public function isOptionInitialised(string $name): bool + { + return $this->initializationState[$name] ?? false; + } + + public function getAvailableOptions(): iterable + { + $properties = (new ReflectionClass(static::class))->getProperties(); + foreach ($properties as $property) { + if ('initializedOptions' === $property->getName()) { + continue; + } + yield $property->getName(); + } + } + + public function __set($name, $value) + { + if (property_exists($this, $name)) { + $this->initializationState[$name] = true; + $this->{$name} = $value; + + return; + } + $className = static::class; + throw new Exception("Option '{$name}' not found on '{$className}'"); + } + + public function __get($name) + { + return $this->{$name} ?? null; + } + + public function merge(ComponentOptions $overrideOptions): ComponentOptions + { + $availableOptions = $this->getNonInitializedOptions(); + foreach ($availableOptions as $availableOption) { + if (!isset($overrideOptions->{$availableOption})) { + continue; + } + $this->{$availableOption} = $overrideOptions->{$availableOption}; + } + + return $this; + } + + /** + * @param callable(ComponentReference $componentReference): ComponentInterface $buildComponentCallback + */ + public function replaceComponentReferences($buildComponentCallback): void + { + } +} diff --git a/components/ImportExportBundle/src/lib/Component/ComponentOptionsFormType.php b/components/ImportExportBundle/src/lib/Component/ComponentOptionsFormType.php new file mode 100644 index 000000000..5d4f66d20 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Component/ComponentOptionsFormType.php @@ -0,0 +1,51 @@ +addEventListener( + FormEvents::PRE_SET_DATA, + function (FormEvent $event) use ($defaultConfiguration) { + $form = $event->getForm(); + + $initializedOptions = $defaultConfiguration->getInitializedOptions(); + foreach ($initializedOptions as $initializedOption) { + if ($form->has($initializedOption)) { + $form->remove($initializedOption); + } + } + } + ); + } + } + + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + $resolver->define('default_configuration')->required()->allowedTypes(AbstractComponent::getOptionsType()); + $resolver->setDefaults([ + 'default_configuration' => null, + 'show_initialized' => false, + 'data_class' => AbstractComponent::getOptionsType(), + 'translation_domain' => 'forms', + ]); + } +} diff --git a/components/ImportExportBundle/src/lib/Component/ComponentReference.php b/components/ImportExportBundle/src/lib/Component/ComponentReference.php new file mode 100644 index 000000000..34cba6689 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Component/ComponentReference.php @@ -0,0 +1,27 @@ +type = $type; + $this->options = $options; + } + + public function getType(): string + { + return $this->type; + } + + public function getOptions(): ?ComponentOptions + { + return $this->options; + } +} diff --git a/components/ImportExportBundle/src/lib/Component/ComponentRegistry.php b/components/ImportExportBundle/src/lib/Component/ComponentRegistry.php new file mode 100644 index 000000000..8d502b259 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Component/ComponentRegistry.php @@ -0,0 +1,50 @@ +typeContainer = $typeContainer; + } + + public function getComponent(string $type): ComponentInterface + { + return $this->typeContainer->get($type); + } + + public static function getComponentOptionsFormType(string $componentClassName): ?string + { + try { + /** @var ReflectionClass<\AlmaviaCX\Bundle\IbexaImportExport\Component\ComponentInterface> $componentClass */ + $componentClass = new ReflectionClass($componentClassName); + + return $componentClass->getMethod('getOptionsFormType')->invoke(null); + } catch (\ReflectionException $e) { + return null; + } + } + + /** + * @return string|\Symfony\Component\Translation\TranslatableMessage|null + */ + public static function getComponentName(string $componentClassName) + { + try { + /** @var ReflectionClass<\AlmaviaCX\Bundle\IbexaImportExport\Component\ComponentInterface> $componentClass */ + $componentClass = new ReflectionClass($componentClassName); + + return $componentClass->getMethod('getName')->invoke(null); + } catch (\ReflectionException $e) { + return null; + } + } +} diff --git a/components/ImportExportBundle/src/lib/Event/BasicEventDispatcherTrait.php b/components/ImportExportBundle/src/lib/Event/BasicEventDispatcherTrait.php new file mode 100644 index 000000000..bf21049ca --- /dev/null +++ b/components/ImportExportBundle/src/lib/Event/BasicEventDispatcherTrait.php @@ -0,0 +1,68 @@ +listeners[$eventName][$priority][] = $listener; + unset($this->optimizedListeners[$eventName]); + } + + protected function dispatchEvent(object $event, string $eventName = null): void + { + $listeners = $this->optimizedListeners[$eventName] ?? + (empty($this->listeners[$eventName]) ? [] : + $this->optimizeListeners($eventName)); + $stoppable = $event instanceof StoppableEventInterface; + foreach ($listeners as $listener) { + if ($stoppable && $event->isPropagationStopped()) { + break; + } + $listener($event, $eventName, $this); + } + } + + /** + * @SuppressWarnings(PHPMD.UnusedLocalVariable) + */ + private function optimizeListeners(string $eventName): array + { + krsort($this->listeners[$eventName]); + $this->optimizedListeners[$eventName] = []; + + foreach ($this->listeners[$eventName] as &$listeners) { + foreach ($listeners as &$listener) { + $closure = &$this->optimizedListeners[$eventName][]; + if ( + \is_array($listener) && + isset($listener[0]) && $listener[0] instanceof Closure && 2 >= \count($listener) + ) { + $closure = static function (...$args) use (&$listener, &$closure) { + if ($listener[0] instanceof Closure) { + $listener[0] = $listener[0](); + $listener[1] = $listener[1] ?? '__invoke'; + } + ($closure = Closure::fromCallable($listener))(...$args); + }; + } else { + $closure = $listener instanceof Closure || $listener instanceof WrappedListener ? + $listener : + Closure::fromCallable($listener); + } + } + } + + return $this->optimizedListeners[$eventName]; + } +} diff --git a/components/ImportExportBundle/src/lib/Event/PostJobCreateFormSubmitEvent.php b/components/ImportExportBundle/src/lib/Event/PostJobCreateFormSubmitEvent.php new file mode 100644 index 000000000..c5ccb1932 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Event/PostJobCreateFormSubmitEvent.php @@ -0,0 +1,22 @@ +job = $job; + } + + public function getJob(): Job + { + return $this->job; + } +} diff --git a/components/ImportExportBundle/src/lib/Event/PostJobRunEvent.php b/components/ImportExportBundle/src/lib/Event/PostJobRunEvent.php new file mode 100644 index 000000000..6f1dfb390 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Event/PostJobRunEvent.php @@ -0,0 +1,30 @@ +job = $job; + $this->workflow = $workflow; + } + + public function getJob(): Job + { + return $this->job; + } + + public function getWorkflow(): WorkflowInterface + { + return $this->workflow; + } +} diff --git a/components/ImportExportBundle/src/lib/Event/PreJobRunEvent.php b/components/ImportExportBundle/src/lib/Event/PreJobRunEvent.php new file mode 100644 index 000000000..5ae7b6e4f --- /dev/null +++ b/components/ImportExportBundle/src/lib/Event/PreJobRunEvent.php @@ -0,0 +1,31 @@ +job = $job; + $this->workflow = $workflow; + } + + public function getJob(): Job + { + return $this->job; + } + + public function getWorkflow(): WorkflowInterface + { + return $this->workflow; + } +} diff --git a/components/ImportExportBundle/src/lib/Event/ResetJobRunEvent.php b/components/ImportExportBundle/src/lib/Event/ResetJobRunEvent.php new file mode 100644 index 000000000..ba13db27a --- /dev/null +++ b/components/ImportExportBundle/src/lib/Event/ResetJobRunEvent.php @@ -0,0 +1,22 @@ +job = $job; + } + + public function getJob(): Job + { + return $this->job; + } +} diff --git a/components/ImportExportBundle/src/lib/Event/Subscriber/PostJobCreateFormSubmitEventSubscriber.php b/components/ImportExportBundle/src/lib/Event/Subscriber/PostJobCreateFormSubmitEventSubscriber.php new file mode 100644 index 000000000..1ef3eb642 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Event/Subscriber/PostJobCreateFormSubmitEventSubscriber.php @@ -0,0 +1,52 @@ +fileHandler = $fileHandler; + } + + public static function getSubscribedEvents(): array + { + return [ + PostJobCreateFormSubmitEvent::class => ['onPostJobCreateFormSubmit', 0], + ]; + } + + public function onPostJobCreateFormSubmit(PostJobCreateFormSubmitEvent $event): void + { + $job = $event->getJob(); + + $options = $job->getOptions()['reader'] ?? null; + if ($options instanceof FileReaderOptions && $options->file instanceof File) { + $file = $options->file; + $fileHandler = fopen($file->getPathname(), 'rb'); + $newFilename = sprintf( + 'job/file_reader_%s.%s', + Uuid::v4(), + $file instanceof UploadedFile ? + $file->getClientOriginalExtension() : + pathinfo($file->getFilename(), PATHINFO_EXTENSION) + ); + $this->fileHandler->writeStream($newFilename, $fileHandler, new Config()); + $options->file = $newFilename; + } + } +} diff --git a/components/ImportExportBundle/src/lib/Event/Subscriber/RemoveWrittenFilesEventSubscriber.php b/components/ImportExportBundle/src/lib/Event/Subscriber/RemoveWrittenFilesEventSubscriber.php new file mode 100644 index 000000000..d64cf32d6 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Event/Subscriber/RemoveWrittenFilesEventSubscriber.php @@ -0,0 +1,39 @@ +fileHandler = $fileHandler; + } + + public static function getSubscribedEvents(): array + { + return [ + ResetJobRunEvent::class => ['onResetJob', 0], + ]; + } + + public function onResetJob(ResetJobRunEvent $event) + { + $job = $event->getJob(); + $results = $job->getWriterResults(); + + foreach ($results as $result) { + if (CsvWriter::class === $result->getWriterType() && isset($result->getResults()['filepath'])) { + $this->fileHandler->delete($result->getResults()['filepath']); + } + } + } +} diff --git a/components/ImportExportBundle/src/lib/Exception/BaseException.php b/components/ImportExportBundle/src/lib/Exception/BaseException.php new file mode 100644 index 000000000..14e4b661a --- /dev/null +++ b/components/ImportExportBundle/src/lib/Exception/BaseException.php @@ -0,0 +1,11 @@ +source = $source; + parent::__construct(sprintf('[%s] %s', $source, $previous->getMessage()), $previous->getCode(), $previous); + } +} diff --git a/components/ImportExportBundle/src/lib/File/DownloadFileResponse.php b/components/ImportExportBundle/src/lib/File/DownloadFileResponse.php new file mode 100644 index 000000000..1642cd6bc --- /dev/null +++ b/components/ImportExportBundle/src/lib/File/DownloadFileResponse.php @@ -0,0 +1,181 @@ +fileHandler = $fileHandler; + + parent::__construct(null, $status, $headers); + $this->setFilepath($filepath, $contentDisposition, $autoLastModified); + + if ($public) { + $this->setPublic(); + } + } + + public function setFilepath( + string $filepath, + ?string $contentDisposition = null, + bool $autoLastModified = true + ): DownloadFileResponse { + $this->filepath = $filepath; + + if ($autoLastModified) { + $this->setAutoLastModified(); + } + + if ($contentDisposition) { + $this->setContentDisposition($contentDisposition); + } + + return $this; + } + + public function getFilepath(): string + { + return $this->filepath; + } + + public function setAutoLastModified(): DownloadFileResponse + { + $date = new DateTime(); + $date->setTimestamp($this->fileHandler->lastModified($this->filepath)->lastModified()); + $this->setLastModified($date); + + return $this; + } + + public function setContentDisposition($disposition, $filename = '', $filenameFallback = ''): DownloadFileResponse + { + if (empty($filename)) { + $filename = pathinfo($this->filepath, PATHINFO_FILENAME); + } + + if (empty($filenameFallback)) { + $filenameFallback = mb_convert_encoding($filename, 'ASCII'); + } + $dispositionHeader = $this->headers->makeDisposition($disposition, $filename, $filenameFallback); + $this->headers->set('Content-Disposition', $dispositionHeader); + + return $this; + } + + public function prepare(Request $request): DownloadFileResponse + { + $fileSize = $this->fileHandler->fileSize($this->filepath)->fileSize(); + $this->headers->set('Content-Length', $fileSize); + $this->headers->set('Accept-Ranges', 'bytes'); + $this->headers->set('Content-Transfer-Encoding', 'binary'); + + if (!$this->headers->has('Content-Type')) { + $mimeType = $this->fileHandler->mimeType($this->filepath)->mimeType(); + $this->headers->set( + 'Content-Type', + $mimeType ?: 'application/octet-stream' + ); + } + + if ('HTTP/1.0' != $request->server->get('SERVER_PROTOCOL')) { + $this->setProtocolVersion('1.1'); + } + + $this->ensureIEOverSSLCompatibility($request); + + $this->offset = 0; + $this->maxlen = -1; + + if ($request->headers->has('Range')) { + // Process the range headers. + if (!$request->headers->has('If-Range') || $this->getEtag() == $request->headers->get('If-Range')) { + $range = $request->headers->get('Range'); + + list($start, $end) = explode('-', substr($range, 6), 2) + [0]; + + $end = ('' === $end) ? $fileSize - 1 : (int) $end; + + if ('' === $start) { + $start = $fileSize - $end; + $end = $fileSize - 1; + } else { + $start = (int) $start; + } + + if ($start <= $end) { + if ($start < 0 || $end > $fileSize - 1) { + $this->setStatusCode(416); // HTTP_REQUESTED_RANGE_NOT_SATISFIABLE + } elseif (0 !== $start || $end !== $fileSize - 1) { + $this->maxlen = $end < $fileSize ? $end - $start + 1 : -1; + $this->offset = $start; + + $this->setStatusCode(206); // HTTP_PARTIAL_CONTENT + $this->headers->set('Content-Range', sprintf('bytes %s-%s/%s', $start, $end, $fileSize)); + $this->headers->set('Content-Length', $end - $start + 1); + } + } + } + } + + return $this; + } + + public function sendContent() + { + if (!$this->isSuccessful()) { + parent::sendContent(); + + return; + } + + if (0 === $this->maxlen) { + return; + } + + $destinationStream = fopen('php://output', 'wb'); + $sourceStream = $this->fileHandler->readStream($this->filepath); + stream_copy_to_stream($sourceStream, $destinationStream, $this->maxlen, $this->offset); + + fclose($destinationStream); + } + + /** + * {@inheritdoc} + * + * @throws \LogicException when the content is not null + */ + public function setContent($content) + { + if (null !== $content) { + throw new LogicException('The content cannot be set on a BinaryStreamResponse instance.'); + } + } + + public function getContent() + { + return null; + } +} diff --git a/components/ImportExportBundle/src/lib/File/FileHandler.php b/components/ImportExportBundle/src/lib/File/FileHandler.php new file mode 100644 index 000000000..e34095f11 --- /dev/null +++ b/components/ImportExportBundle/src/lib/File/FileHandler.php @@ -0,0 +1,158 @@ +innerAdapter = $innerAdapter; + $this->prefixer = $prefixer; + $this->filepathResolver = $filepathResolver; + } + + public function fileExists(string $path): bool + { + $path = $this->prefixer->prefixPath($path); + + return $this->innerAdapter->fileExists($path); + } + + public function write(string $path, string $contents, Config $config): void + { + $path = $this->prefixer->prefixPath($path); + + $this->innerAdapter->write($path, $contents, $config); + } + + public function writeStream(string $path, $contents, Config $config): void + { + $path = $this->prefixer->prefixPath($path); + + $this->innerAdapter->writeStream($path, $contents, $config); + } + + public function read(string $path): string + { + $path = $this->prefixer->prefixPath($path); + + return $this->innerAdapter->read($path); + } + + public function readStream(string $path) + { + $path = $this->prefixer->prefixPath($path); + + return $this->innerAdapter->readStream($path); + } + + public function delete(string $path): void + { + $path = $this->prefixer->prefixPath($path); + + $this->innerAdapter->delete($path); + } + + public function deleteDirectory(string $path): void + { + $path = $this->prefixer->prefixPath($path); + + $this->innerAdapter->deleteDirectory($path); + } + + public function createDirectory(string $path, Config $config): void + { + $path = $this->prefixer->prefixPath($path); + + $this->innerAdapter->createDirectory($path, $config); + } + + public function setVisibility(string $path, string $visibility): void + { + $path = $this->prefixer->prefixPath($path); + + $this->innerAdapter->setVisibility($path, $visibility); + } + + public function visibility(string $path): FileAttributes + { + $path = $this->prefixer->prefixPath($path); + + return $this->innerAdapter->visibility($path); + } + + public function mimeType(string $path): FileAttributes + { + $path = $this->prefixer->prefixPath($path); + + return $this->innerAdapter->mimeType($path); + } + + public function lastModified(string $path): FileAttributes + { + $path = $this->prefixer->prefixPath($path); + + return $this->innerAdapter->lastModified($path); + } + + public function fileSize(string $path): FileAttributes + { + $path = $this->prefixer->prefixPath($path); + + return $this->innerAdapter->fileSize($path); + } + + public function listContents(string $path, bool $deep): iterable + { + $path = $this->prefixer->prefixPath($path); + + foreach ($this->innerAdapter->listContents($path, $deep) as $storageAttributes) { + $itemPath = $this->prefixer->stripPrefix($storageAttributes->path()); + + yield $storageAttributes->withPath($itemPath); + } + + yield from []; + } + + public function move(string $source, string $destination, Config $config): void + { + $source = $this->prefixer->prefixPath($source); + $destination = $this->prefixer->prefixPath($destination); + + $this->innerAdapter->move($source, $destination, $config); + } + + public function copy(string $source, string $destination, Config $config): void + { + $sourcePath = $source; + $destinationPath = $this->prefixer->prefixPath($destination); + + $this->innerAdapter->copy($sourcePath, $destinationPath, $config); + } + + public function resolvePath(string $filepath): string + { + return ($this->filepathResolver)($filepath); + } +} diff --git a/components/ImportExportBundle/src/lib/File/FileReadIterator.php b/components/ImportExportBundle/src/lib/File/FileReadIterator.php new file mode 100644 index 000000000..8dfd10022 --- /dev/null +++ b/components/ImportExportBundle/src/lib/File/FileReadIterator.php @@ -0,0 +1,97 @@ +stream = $stream; + $this->firstLineNumber = $firstLineNumber; + $this->lineNumber = $firstLineNumber; + } + + /** + * @return string|false + */ + protected function getLine() + { + return fgets($this->stream); + } + + public function seek($offset) + { + fseek($this->stream, 0); + if ($offset > 0) { + for ($i = 0; $i < $offset; ++$i) { + fgets($this->stream); + } + } + $this->lineNumber = $offset; + $this->line = $this->getLine(); + } + + public function rewind() + { + $this->seek($this->firstLineNumber); + } + + public function valid(): bool + { + return false !== $this->line; + } + + /** + * @return false|string + */ + public function current() + { + return $this->line; + } + + public function key(): int + { + return $this->lineNumber; + } + + public function next() + { + if (false !== $this->line) { + $this->line = $this->getLine(); + ++$this->lineNumber; + } + } + + public function __destruct() + { + fclose($this->stream); + } + + public function count(): int + { + $lines = 0; + fseek($this->stream, 0); + while (!feof($this->stream)) { + $lines += substr_count(fread($this->stream, 8192), "\n"); + } + + fseek($this->stream, $this->lineNumber); + + return $lines; + } +} diff --git a/components/ImportExportBundle/src/lib/File/PathPrefixer.php b/components/ImportExportBundle/src/lib/File/PathPrefixer.php new file mode 100644 index 000000000..0d73692d0 --- /dev/null +++ b/components/ImportExportBundle/src/lib/File/PathPrefixer.php @@ -0,0 +1,57 @@ +prefix = $prefix; + $this->separator = $separator; + } + + protected function getPrefix(): string + { + return $this->prefix; + } + + public function prefixPath(string $path): string + { + $prefix = rtrim($this->prefix, '\\/'); + if ('' !== $prefix || $this->prefix === $this->separator) { + $prefix .= $this->separator; + } + + return $prefix.ltrim($path, '\\/'); + } + + public function stripPrefix(string $path): string + { + return substr($path, strlen($this->prefix)); + } + + public function stripDirectoryPrefix(string $path): string + { + return rtrim($this->stripPrefix($path), '\\/'); + } + + public function prefixDirectoryPath(string $path): string + { + $prefixedPath = $this->prefixPath(rtrim($path, '\\/')); + + if ('' === $prefixedPath || str_ends_with($prefixedPath, $this->separator)) { + return $prefixedPath; + } + + return $prefixedPath.$this->separator; + } +} diff --git a/components/ImportExportBundle/src/lib/Item/ItemAccessorInterface.php b/components/ImportExportBundle/src/lib/Item/ItemAccessorInterface.php new file mode 100644 index 000000000..5f9c6f618 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Item/ItemAccessorInterface.php @@ -0,0 +1,12 @@ +callback = $callback; + } + + /** + * {@inheritDoc} + */ + public function __invoke($item) + { + return call_user_func($this->callback, $item); + } +} diff --git a/components/ImportExportBundle/src/lib/Item/Iterator/DoctrineSeekableItemIterator.php b/components/ImportExportBundle/src/lib/Item/Iterator/DoctrineSeekableItemIterator.php new file mode 100644 index 000000000..793a40a0e --- /dev/null +++ b/components/ImportExportBundle/src/lib/Item/Iterator/DoctrineSeekableItemIterator.php @@ -0,0 +1,103 @@ +batchSize = $batchSize; + $this->countQueryString = $countQueryString; + $this->queryString = $queryString; + $this->connection = $connection; + } + + private function fetch(): Iterator + { + $queryString = sprintf('%s LIMIT %d OFFSET %d', $this->queryString, $this->batchSize, $this->position); + + return new ArrayIterator($this->connection->executeQuery($queryString)->fetchAllAssociative()); + } + + private function initialize(): void + { + $this->position = 0; + $this->innerIterator = $this->fetch(); + } + + private function isInitialized(): bool + { + return isset($this->innerIterator); + } + + public function current() + { + if (!$this->isInitialized()) { + $this->initialize(); + } + + return $this->innerIterator->current(); + } + + public function next(): void + { + if (!$this->isInitialized()) { + $this->initialize(); + } + ++$this->position; + $this->innerIterator->next(); + if (!$this->innerIterator->valid() && ($this->position % $this->batchSize) === 0) { + $this->innerIterator = $this->fetch(); + } + } + + public function key(): mixed + { + return $this->position; + } + + public function valid(): bool + { + if (!$this->isInitialized()) { + $this->initialize(); + } + + return $this->innerIterator->valid(); + } + + public function rewind(): void + { + $this->initialize(); + } + + public function count(): int + { + return $this->connection->executeQuery($this->countQueryString)->fetchOne(); + } + + public function seek($offset): void + { + $this->position = $offset; + $this->innerIterator = $this->fetch(); + } +} diff --git a/components/ImportExportBundle/src/lib/Item/Iterator/ItemIterator.php b/components/ImportExportBundle/src/lib/Item/Iterator/ItemIterator.php new file mode 100644 index 000000000..04f61dfac --- /dev/null +++ b/components/ImportExportBundle/src/lib/Item/Iterator/ItemIterator.php @@ -0,0 +1,54 @@ +itemTransformer = $itemTransformer; + $this->innerIterator = $innerIterator; + parent::__construct($totalCount); + } + + public function current() + { + $item = $this->innerIterator->current(); + if ($this->itemTransformer instanceof IteratorItemTransformerInterface) { + return ($this->itemTransformer)($item); + } + + return $item; + } + + public function next(): void + { + $this->innerIterator->next(); + } + + public function key(): int + { + return $this->innerIterator->key(); + } + + public function valid(): bool + { + return $this->innerIterator->valid(); + } + + public function rewind(): void + { + $this->innerIterator->rewind(); + } +} diff --git a/components/ImportExportBundle/src/lib/Item/Iterator/IteratorItemTransformerInterface.php b/components/ImportExportBundle/src/lib/Item/Iterator/IteratorItemTransformerInterface.php new file mode 100644 index 000000000..f2a6f8319 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Item/Iterator/IteratorItemTransformerInterface.php @@ -0,0 +1,13 @@ +innerIterator->seek($offset); + } +} diff --git a/components/ImportExportBundle/src/lib/Item/Transformer/ItemTransformer.php b/components/ImportExportBundle/src/lib/Item/Transformer/ItemTransformer.php new file mode 100644 index 000000000..b9f5a7f3f --- /dev/null +++ b/components/ImportExportBundle/src/lib/Item/Transformer/ItemTransformer.php @@ -0,0 +1,113 @@ +sourceResolver = $sourceResolver; + } + + /** + * @param object|array $objectOrArray + * @param \AlmaviaCX\Bundle\IbexaImportExport\Item\Transformer\TransformationMap $map + * @param array|object $destinationObjectOrArray + * + * @return array|object + */ + public function __invoke( + $objectOrArray, + TransformationMap $map, + $destinationObjectOrArray = [] + ) { + $elements = $map->getElements(); + foreach ($elements as $destination => $source) { + $value = ($this->sourceResolver)($source, $objectOrArray); + $this->sourceResolver->getDefaultPropertyAccessor()->setValue( + $destinationObjectOrArray, + $destination, + $value + ); + } + + return $destinationObjectOrArray; + } + + /** + * @param object|string $objectOrClass + */ + public function getAvailableProperties($objectOrClass): array + { + return $this->getPropertiesPaths($objectOrClass); + } + + /** + * @param object|string $objectOrClass + * + * @throws \ReflectionException + */ + protected function getPropertiesPaths($objectOrClass, string $prefix = ''): array + { + $reflectionClass = new ReflectionClass($objectOrClass); + $properties = []; + foreach ($reflectionClass->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + try { + $propertyName = $property->getName(); + + $properties = array_merge( + $properties, + $this->getPropertyPaths( + $objectOrClass->{$propertyName}, + sprintf('%s%s', $prefix, $propertyName) + ) + ); + } catch (PropertyNotFoundException $exception) { + continue; + } + } + + return $properties; + } + + protected function getPropertyPaths($value, string $propertyName): array + { + $paths = []; + if (is_array($value)) { + foreach ($value as $index => $item) { + $itemPropertyPaths = $this->getPropertyPaths( + $item, + sprintf('%s[%s]', $propertyName, $index) + ); + if (!empty($itemPropertyPaths)) { + $paths = array_merge($paths, $itemPropertyPaths); + } + } + + return $paths; + } + + if (is_object($value)) { + return array_merge( + $paths, + $this->getPropertiesPaths( + $value, + sprintf('%s.', $propertyName) + ) + ); + } + + $paths[] = $propertyName; + + return $paths; + } +} diff --git a/components/ImportExportBundle/src/lib/Item/Transformer/Source.php b/components/ImportExportBundle/src/lib/Item/Transformer/Source.php new file mode 100644 index 000000000..4fbf2912b --- /dev/null +++ b/components/ImportExportBundle/src/lib/Item/Transformer/Source.php @@ -0,0 +1,52 @@ +|array $transformers + */ + public function __construct($path, array $transformers = []) + { + if (is_array($path)) { + $this->path = array_map(function (string $path) { + return new PropertyPath($path); + }, $path); + } else { + $this->path = new PropertyPath($path); + } + + $this->transformers = $transformers; + } + + /** + * @return PropertyPath[]|PropertyPath + */ + public function getPath() + { + return $this->path; + } + + public function getTransformers(): array + { + return $this->transformers; + } + + public function __toString(): string + { + return (string) $this->path; + } +} diff --git a/components/ImportExportBundle/src/lib/Item/Transformer/SourceResolver.php b/components/ImportExportBundle/src/lib/Item/Transformer/SourceResolver.php new file mode 100644 index 000000000..3b2bb41b0 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Item/Transformer/SourceResolver.php @@ -0,0 +1,157 @@ +referenceBag = $referenceBag; + $this->itemValueTransformerRegistry = $itemValueTransformerRegistry; + $this->defaultPropertyAccessor = PropertyAccess::createPropertyAccessorBuilder() + ->getPropertyAccessor(); + } + + public function getDefaultPropertyAccessor(): PropertyAccessorInterface + { + return $this->defaultPropertyAccessor; + } + + /** + * @param object|array $objectOrArray + * + * @return array + */ + public function getPropertyMultipleValue($objectOrArray, PropertyPath $source) + { + $wildcardPosition = strpos((string) $source, '[*]'); + $pathBeforeWildCard = substr((string) $source, 0, $wildcardPosition); + $pathAfterWildCard = substr((string) $source, $wildcardPosition + 3); + + $value = []; + $array = $this->getPropertyAccessor($objectOrArray)->getValue($objectOrArray, $pathBeforeWildCard); + foreach ($array as $element) { + if (empty($pathAfterWildCard)) { + $value[] = $element; + } else { + $value[] = $this->getPropertyValue( + $element, + new PropertyPath(ltrim($pathAfterWildCard, '.')) + ); + } + } + + return $value; + } + + /** + * @param object|array $objectOrArray + */ + public function getPropertyValue($objectOrArray, $source) + { + try { + if ($source instanceof PropertyPath) { + if (false !== strpos((string) $source, '[*]')) { + return $this->getPropertyMultipleValue($objectOrArray, $source); + } + + $value = $this->getPropertyAccessor($objectOrArray)->getValue($objectOrArray, $source); + + return is_string($value) ? trim($value) : $value; + } + } catch (NoSuchIndexException|NoSuchPropertyException $exception) { + return null; + } + + return $source; + } + + /** + * @param object|array $objectOrArray + */ + private function getPropertyAccessor($objectOrArray): PropertyAccessorInterface + { + return $objectOrArray instanceof DOMNode ? + new XpathPropertyAccessor() : + $this->defaultPropertyAccessor; + } + + /** + * @param $source + * @param object|array $objectOrArray + */ + protected function getSourceValue($source, $objectOrArray) + { + try { + if ($source instanceof Reference) { + return $this->referenceBag->getReference($source->getName(), null, $source->getScope()); + } + + if ($source instanceof Source) { + $sourcePath = $source->getPath(); + if (is_array($sourcePath)) { + $value = array_map(function (PropertyPath $path) use ($objectOrArray) { + return $this->getPropertyValue($objectOrArray, $path); + }, $sourcePath); + } else { + $value = $this->getPropertyValue($objectOrArray, $sourcePath); + } + + foreach ($source->getTransformers() as $transformerInfos) { + if (is_array($transformerInfos)) { + [ $transformerType, $transformerOptions ] = $transformerInfos; + } else { + $transformerType = $transformerInfos; + $transformerOptions = []; + } + $transformer = $this->itemValueTransformerRegistry->get($transformerType); + $value = $transformer($value, $transformerOptions); + } + + return $value; + } + + return $this->getPropertyValue($objectOrArray, $source); + } catch (Throwable $exception) { + throw new SourceResolutionException($source, $exception); + } + } + + /** + * @param object|array $objectOrArray + */ + public function __invoke($source, $objectOrArray) + { + if (is_array($source)) { + $value = []; + foreach ($source as $sourceKey => $sourceItem) { + $sourceItemKey = $this->getSourceValue($sourceKey, $objectOrArray); + $sourceItemValue = $this->getSourceValue($sourceItem, $objectOrArray); + $value[$sourceItemKey] = $sourceItemValue; + } + } else { + $value = $this->getSourceValue($source, $objectOrArray); + } + + return $value; + } +} diff --git a/components/ImportExportBundle/src/lib/Item/Transformer/TransformationMap.php b/components/ImportExportBundle/src/lib/Item/Transformer/TransformationMap.php new file mode 100644 index 000000000..b398b6e5b --- /dev/null +++ b/components/ImportExportBundle/src/lib/Item/Transformer/TransformationMap.php @@ -0,0 +1,38 @@ + */ + protected array $elements; + + /** + * @param array $elements + */ + public function __construct(array $elements) + { + $this->setElements($elements); + } + + /** + * @return array + */ + public function getElements(): array + { + return $this->elements; + } + + /** + * @param array $elements + */ + public function setElements(array $elements): void + { + $this->elements = $elements; + } +} diff --git a/components/ImportExportBundle/src/lib/Item/ValueTransformer/AbstractItemValueTransformer.php b/components/ImportExportBundle/src/lib/Item/ValueTransformer/AbstractItemValueTransformer.php new file mode 100644 index 000000000..2dfc03cc6 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Item/ValueTransformer/AbstractItemValueTransformer.php @@ -0,0 +1,31 @@ +resolveOptions($options); + + return $this->transform($value, $options); + } + + abstract protected function transform($value, array $options = []); + + protected function resolveOptions(array $options): array + { + $optionsResolver = new OptionsResolver(); + $this->configureOptions($optionsResolver); + + return $optionsResolver->resolve($options); + } + + protected function configureOptions(OptionsResolver $optionsResolver) + { + } +} diff --git a/components/ImportExportBundle/src/lib/Item/ValueTransformer/Ibexa/HtmlToRichtextTransformer.php b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Ibexa/HtmlToRichtextTransformer.php new file mode 100644 index 000000000..0e140bb32 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Ibexa/HtmlToRichtextTransformer.php @@ -0,0 +1,37 @@ +richtextInputHandler = $richtextInputHandler; + } + + public function transform($value, array $options = []) + { + if (null === $value) { + return new Value(); + } + + $convertedDoc = $this->richtextInputHandler->fromString( + sprintf( + ' +
    %s
    ', + $value + ) + ); + + return new Value($convertedDoc); + } +} diff --git a/components/ImportExportBundle/src/lib/Item/ValueTransformer/Ibexa/HtmlToTextBlockTransformer.php b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Ibexa/HtmlToTextBlockTransformer.php new file mode 100644 index 000000000..01eeec6dd --- /dev/null +++ b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Ibexa/HtmlToTextBlockTransformer.php @@ -0,0 +1,40 @@ +/', "\n", $value); + } + + // Replace
    tags with newlines + $value = preg_replace('//i', "\n", $value); + + // Strip HTML tags + $text = strip_tags($value); + + // Decode HTML entities + $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + // Trim leading and trailing whitespace/newlines + $text = trim($text); + + return new Value($text); + } +} diff --git a/components/ImportExportBundle/src/lib/Item/ValueTransformer/Ibexa/TaxonomyEntryAssignementTransformer.php b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Ibexa/TaxonomyEntryAssignementTransformer.php new file mode 100644 index 000000000..2abe58868 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Ibexa/TaxonomyEntryAssignementTransformer.php @@ -0,0 +1,35 @@ +define('taxonomy') + ->required() + ->allowedTypes('string'); + } +} diff --git a/components/ImportExportBundle/src/lib/Item/ValueTransformer/Ibexa/TaxonomyEntryTransformer.php b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Ibexa/TaxonomyEntryTransformer.php new file mode 100644 index 000000000..3c76ac34c --- /dev/null +++ b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Ibexa/TaxonomyEntryTransformer.php @@ -0,0 +1,73 @@ +taxonomyService = $taxonomyService; + } + + /** + * @param int|string|array $value + * + * @return TaxonomyEntry|TaxonomyEntry[]|null + */ + public function transform($value, array $options = []) + { + if (empty($value)) { + return null; + } + $taxonomy = $options['taxonomy'] ?? null; + + if (is_scalar($value)) { + return $this->loadTaxonomyEntry($value, $taxonomy); + } + + $entries = []; + foreach ($value as $id) { + if (empty($id)) { + continue; + } + $entries[] = $this->loadTaxonomyEntry($id, $taxonomy); + } + + return array_filter($entries); + } + + /** + * @param int|string $id + */ + protected function loadTaxonomyEntry($id, ?string $taxonomyName = null): ?TaxonomyEntry + { + try { + if (is_string($id)) { + return $this->taxonomyService->loadEntryByIdentifier($id, $taxonomyName); + } + + return $this->taxonomyService->loadEntryById($id, $taxonomyName); + } catch (TaxonomyEntryNotFoundException $exception) { + throw new Exception(sprintf('No taxonomy entry found for id/identifier "%s" in "%s"', $id, $taxonomyName)); + } + } + + protected function configureOptions(OptionsResolver $optionsResolver) + { + parent::configureOptions($optionsResolver); + $optionsResolver->define('taxonomy') + ->required() + ->allowedTypes('string'); + } +} diff --git a/components/ImportExportBundle/src/lib/Item/ValueTransformer/Ibexa/TextToRichtextTransformer.php b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Ibexa/TextToRichtextTransformer.php new file mode 100644 index 000000000..68287dc35 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Ibexa/TextToRichtextTransformer.php @@ -0,0 +1,41 @@ +htmlToRichtextTransformer = $htmlToRichtextTransformer; + } + + public function transform($value, array $options = []) + { + $rawText = null; + if ($value) { + if (is_scalar($value)) { + $value = [$value]; + } + + $rawText = []; + foreach ($value as $text) { + $rawText[] = sprintf( + '

    %s

    ', + htmlentities(trim((string) $text)) + ); + } + + $rawText = str_replace([' '], [' '], implode(PHP_EOL, $rawText)); + $rawText = preg_replace(['/\\n/'], [''], $rawText); + } + + return ($this->htmlToRichtextTransformer)($rawText); + } +} diff --git a/components/ImportExportBundle/src/lib/Item/ValueTransformer/ItemValueTransformerInterface.php b/components/ImportExportBundle/src/lib/Item/ValueTransformer/ItemValueTransformerInterface.php new file mode 100644 index 000000000..c2e8fd2fb --- /dev/null +++ b/components/ImportExportBundle/src/lib/Item/ValueTransformer/ItemValueTransformerInterface.php @@ -0,0 +1,13 @@ +typeContainer = $typeContainer; + } + + public function get(?string $type): callable + { + if (null === $type || !$this->typeContainer->has($type)) { + return static function ($value) { + return $value; + }; + } + + return $this->typeContainer->get($type); + } +} diff --git a/components/ImportExportBundle/src/lib/Item/ValueTransformer/Utils/CallbackTransformer.php b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Utils/CallbackTransformer.php new file mode 100644 index 000000000..6ebe00ca9 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Utils/CallbackTransformer.php @@ -0,0 +1,24 @@ +define('callback') + ->required() + ->allowedTypes('callable', 'array'); + } +} diff --git a/components/ImportExportBundle/src/lib/Item/ValueTransformer/Utils/DownloadToTmpTransformer.php b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Utils/DownloadToTmpTransformer.php new file mode 100644 index 000000000..b0b5d0226 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Utils/DownloadToTmpTransformer.php @@ -0,0 +1,51 @@ + [ + 'verify_peer' => false, + 'verify_peer_name' => false, + ], + ] + ) + ) + ); + + return $tmpFilePath; + } +} diff --git a/components/ImportExportBundle/src/lib/Item/ValueTransformer/Utils/JoinTransformer.php b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Utils/JoinTransformer.php new file mode 100644 index 000000000..0c674b173 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Utils/JoinTransformer.php @@ -0,0 +1,33 @@ +define('separator') + ->default(',') + ->allowedTypes('string'); + } +} diff --git a/components/ImportExportBundle/src/lib/Item/ValueTransformer/Utils/Md5Transformer.php b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Utils/Md5Transformer.php new file mode 100644 index 000000000..9a72fdee2 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Utils/Md5Transformer.php @@ -0,0 +1,15 @@ +slugConverter = $slugConverter; + } + + /** + * @param string $value + * + * @return string + */ + public function transform($value, array $options = []) + { + return $this->slugConverter->convert($value); + } +} diff --git a/components/ImportExportBundle/src/lib/Item/ValueTransformer/Utils/SprintfTransformer.php b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Utils/SprintfTransformer.php new file mode 100644 index 000000000..143f7f926 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Utils/SprintfTransformer.php @@ -0,0 +1,33 @@ +define('format') + ->required() + ->allowedTypes('string'); + } +} diff --git a/components/ImportExportBundle/src/lib/Item/ValueTransformer/Utils/ToDateTimeTransformer.php b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Utils/ToDateTimeTransformer.php new file mode 100644 index 000000000..5e9349f1a --- /dev/null +++ b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Utils/ToDateTimeTransformer.php @@ -0,0 +1,29 @@ +define('input_format') + ->default('Y-m-d') + ->allowedTypes('string'); + } +} diff --git a/components/ImportExportBundle/src/lib/Item/ValueTransformer/Utils/ToIntegerTransformer.php b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Utils/ToIntegerTransformer.php new file mode 100644 index 000000000..f066ddeaa --- /dev/null +++ b/components/ImportExportBundle/src/lib/Item/ValueTransformer/Utils/ToIntegerTransformer.php @@ -0,0 +1,15 @@ +eventDispatcher = $eventDispatcher; + } + + public function __invoke(Job $job, int $batchLimit = -1, bool $reset = false): int + { + if ($reset || Job::STATUS_COMPLETED === $job->getStatus()) { + $this->eventDispatcher->dispatch(new ResetJobRunEvent($job)); + $job->reset(); + } + + if (Job::STATUS_PAUSED !== $job->getStatus() || Job::STATUS_PENDING !== $job->getStatus()) { + return $this->run($job, $batchLimit); + } + + return $job->getStatus(); + } + + abstract protected function run(Job $job, int $batchLimit = -1): int; +} diff --git a/components/ImportExportBundle/src/lib/Job/AsyncJobRunner.php b/components/ImportExportBundle/src/lib/Job/AsyncJobRunner.php new file mode 100644 index 000000000..ee158c92b --- /dev/null +++ b/components/ImportExportBundle/src/lib/Job/AsyncJobRunner.php @@ -0,0 +1,34 @@ +jobRepository = $jobRepository; + $this->jobRunMessageHandler = $jobRunMessageHandler; + parent::__construct($eventDispatcher); + } + + protected function run(Job $job, int $batchLimit = -1): int + { + $job->setStatus(Job::STATUS_QUEUED); + $this->jobRepository->save($job); + + $this->jobRunMessageHandler->triggerStart($job, $batchLimit); + + return $job->getStatus(); + } +} diff --git a/components/ImportExportBundle/src/lib/Job/Form/JobCreateFlow.php b/components/ImportExportBundle/src/lib/Job/Form/JobCreateFlow.php new file mode 100644 index 000000000..3a4cfcf68 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Job/Form/JobCreateFlow.php @@ -0,0 +1,31 @@ + 'Job configuration', + 'form_type' => JobFormType::class, + ], + [ + 'label' => 'Components configuration', + 'form_type' => JobProcessConfigurationFormType::class, + 'form_options' => [ + 'show_initialized' => false, + ], + ], + ]; + + return $steps; + } +} diff --git a/components/ImportExportBundle/src/lib/Job/Form/Type/JobFormType.php b/components/ImportExportBundle/src/lib/Job/Form/Type/JobFormType.php new file mode 100644 index 000000000..fcaaf534e --- /dev/null +++ b/components/ImportExportBundle/src/lib/Job/Form/Type/JobFormType.php @@ -0,0 +1,43 @@ +workflowRegistry = $workflowRegistry; + } + + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder->add('label', TextType::class, [ + 'label' => /* @Desc("Label") */ 'job.label', + ]); + + $availableWorkflows = $this->workflowRegistry->getAvailableWorkflowServices(); + $builder->add('workflowIdentifier', ChoiceType::class, [ + 'label' => /* @Desc("Workflow") */ 'job.workflowIdentifier', + 'choices' => array_flip($availableWorkflows), + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'translation_domain' => 'forms', + ]); + } +} diff --git a/components/ImportExportBundle/src/lib/Job/Form/Type/JobProcessConfigurationFormType.php b/components/ImportExportBundle/src/lib/Job/Form/Type/JobProcessConfigurationFormType.php new file mode 100644 index 000000000..61fcc7415 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Job/Form/Type/JobProcessConfigurationFormType.php @@ -0,0 +1,100 @@ +workflowRegistry = $workflowRegistry; + $this->componentRegistry = $componentRegistry; + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options) { + $form = $event->getForm(); + /* @var \AlmaviaCX\Bundle\IbexaImportExport\Job\Job $job */ + $job = $event->getData(); + if (!$job) { + return; + } + $workflowIdentifier = $job->getWorkflowIdentifier(); + $workflowDefaultConfiguration = $this->workflowRegistry::getWorkflowDefaultConfiguration( + $this->workflowRegistry->getWorkflowClassName($workflowIdentifier) + ); + + $optionsForm = $form->add('options', WorkflowProcessConfigurationFormType::class, [ + 'label' => /* @Desc("Workflow options") */ 'workflow.options', + 'required' => false, + 'show_initialized' => $options['show_initialized'], + 'default_configuration' => $workflowDefaultConfiguration->getProcessConfiguration(), + ]); + }); + + $builder->addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event) { + if ($event->getForm()->has('options')) { + $optionsForm = $event->getForm()->get('options'); + $this->removeEmptyChildren($optionsForm); + } + }); + } + + protected function removeEmptyChildren(FormInterface $form) + { + foreach ($form->all() as $child) { + if ($child->getConfig()->getType()->getInnerType() instanceof FormType) { + $this->removeEmptyChildren($child); + } + if (empty($child->all())) { + $form->remove($child->getName()); + } + } + } + + /** + * @param string|callable $componentClassName + */ + protected function getComponentOptionsFormType($componentClassName): ?string + { + return $this->componentRegistry::getComponentOptionsFormType($componentClassName); + } + + /** + * @param string|callable $componentClassName + * + * @return string|\Symfony\Component\Translation\TranslatableMessage|null + */ + protected function getComponentName($componentClassName) + { + return $this->componentRegistry::getComponentName($componentClassName); + } + + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + $resolver->setDefaults([ + 'data_class' => Job::class, + 'show_initialized' => false, + 'translation_domain' => 'forms', + 'workflow_configuration' => null, + ]); + } +} diff --git a/components/ImportExportBundle/src/lib/Job/Job.php b/components/ImportExportBundle/src/lib/Job/Job.php new file mode 100644 index 000000000..0569b1241 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Job/Job.php @@ -0,0 +1,303 @@ +} + */ + protected array $options = []; + + /** + * @ORM\OneToMany( + * targetEntity=JobRecord::class, + * mappedBy="job", + * cascade={"persist", "remove"}, + * orphanRemoval=true, + * fetch="EXTRA_LAZY", + * indexBy="identifier" + * ) + * + * @var Collection + */ + protected ?Collection $records = null; + + /** + * @ORM\Column(type="text", nullable=true) + */ + protected ?string $writerResults = null; + + public function __construct() + { + $this->records = new ArrayCollection(); + } + + public function getId(): int + { + return $this->id; + } + + public function setId(int $id): void + { + $this->id = $id; + } + + public function getLabel(): string + { + return $this->label; + } + + public function setLabel(string $label): void + { + $this->label = $label; + } + + public function getWorkflowIdentifier(): ?string + { + return $this->workflowIdentifier; + } + + public function setWorkflowIdentifier(?string $workflowIdentifier): void + { + $this->workflowIdentifier = $workflowIdentifier; + } + + public function getStatus(): int + { + return $this->status; + } + + public function setStatus(int $status): void + { + $this->status = $status; + } + + public function getRequestedDate(): DateTimeImmutable + { + return $this->requestedDate; + } + + public function setRequestedDate(DateTimeImmutable $requestedDate): void + { + $this->requestedDate = $requestedDate; + } + + public function getStartTime(): ?DateTimeImmutable + { + return $this->startTime; + } + + public function setStartTime(?DateTimeImmutable $startTime): void + { + $this->startTime = $startTime; + } + + public function getEndTime(): ?DateTimeImmutable + { + return $this->endTime; + } + + public function setEndTime(?DateTimeImmutable $endTime): void + { + $this->endTime = $endTime; + } + + public function getCreatorId(): int + { + return $this->creatorId; + } + + public function setCreatorId(int $creatorId): void + { + $this->creatorId = $creatorId; + } + + /** + * @return array{reader?: ReaderOptions, processors?: array} + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * @param array{reader?: ReaderOptions, processors?: array} $options + */ + public function setOptions(array $options): void + { + $this->options = $options; + } + + /** + * @return \Doctrine\Common\Collections\Collection + */ + public function getRecords(): Collection + { + return $this->records; + } + + public function getRecordsForLevel(int $level): Collection + { + $criteria = new Criteria(); + $criteria->where(new Comparison('level', '=', $level)); + + return $this->records->matching($criteria); + } + + /** + * @param array $records + */ + public function setRecords(array $records): void + { + $this->records->clear(); + foreach ($records as $record) { + $this->addRecord($record); + } + } + + /** + * @param array $records + */ + public function addRecords(array $records): void + { + foreach ($records as $record) { + $this->addRecord($record); + } + } + + public function addRecord(JobRecord $record): void + { + if (!$this->records) { + $this->records = new ArrayCollection(); + } + if (!$this->records->containsKey($record->getIdentifier())) { + $record->setJob($this); + $this->records->set($record->getIdentifier(), $record); + } + } + + /** + * @return \AlmaviaCX\Bundle\IbexaImportExport\Writer\WriterResults[] + */ + public function getWriterResults(): array + { + return $this->writerResults ? unserialize($this->writerResults) : []; + } + + public function setWriterResults(array $writerResults): void + { + $this->writerResults = serialize($writerResults); + } + + public function getProcessedItemsCount(): int + { + return $this->processedItemsCount; + } + + public function setProcessedItemsCount(int $processedItemsCount): void + { + $this->processedItemsCount = $processedItemsCount; + } + + public function getTotalItemsCount(): int + { + return $this->totalItemsCount; + } + + public function setTotalItemsCount(int $totalItemsCount): void + { + $this->totalItemsCount = $totalItemsCount; + } + + public function getProgress(): float + { + return $this->totalItemsCount > 0 ? $this->processedItemsCount / $this->totalItemsCount : 0; + } + + public function reset(): void + { + $this->startTime = null; + $this->endTime = null; + $this->records = new ArrayCollection(); + $this->writerResults = null; + $this->status = self::STATUS_PENDING; + $this->totalItemsCount = 0; + $this->processedItemsCount = 0; + } +} diff --git a/components/ImportExportBundle/src/lib/Job/JobDebugger.php b/components/ImportExportBundle/src/lib/Job/JobDebugger.php new file mode 100644 index 000000000..930f49457 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Job/JobDebugger.php @@ -0,0 +1,36 @@ +workflowRegistry = $workflowRegistry; + $this->workflowExecutor = $workflowExecutor; + } + + public function __invoke(Job $job, int $index): void + { + $workflow = $this->workflowRegistry->getWorkflow($job->getWorkflowIdentifier()); + $workflow->setLogger(new WorkflowLogger()); + $workflow->setDebug(true); + $workflow->setOffset($index - 1); + ($this->workflowExecutor)( + $workflow, + $job->getOptions(), + 1 + ); + } +} diff --git a/components/ImportExportBundle/src/lib/Job/JobRecord.php b/components/ImportExportBundle/src/lib/Job/JobRecord.php new file mode 100644 index 000000000..768e4ff82 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Job/JobRecord.php @@ -0,0 +1,109 @@ +id = $id; + $this->identifier = $id->toRfc4122(); + $this->record = $record; + $this->level = $record['level']; + } + + public function getId(): Ulid + { + return $this->id; + } + + public function setId(Ulid $id): void + { + $this->id = $id; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + public function setIdentifier(string $identifier): void + { + $this->identifier = $identifier; + } + + /** + * @return Record + */ + public function getRecord(): array + { + return $this->record; + } + + /** + * @param Record $record + */ + public function setRecord(array $record): void + { + $this->record = $record; + } + + public function getJob(): Job + { + return $this->job; + } + + public function setJob(Job $job): void + { + $this->job = $job; + } + + public function getLevel(): int + { + return $this->level; + } + + public function setLevel(int $level): void + { + $this->level = $level; + } +} diff --git a/components/ImportExportBundle/src/lib/Job/JobRepository.php b/components/ImportExportBundle/src/lib/Job/JobRepository.php new file mode 100644 index 000000000..ef918bc38 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Job/JobRepository.php @@ -0,0 +1,54 @@ +getClassMetadata(Job::class)); + } + + public function findById(int $id): ?Job + { + return $this->findOneBy(['id' => $id]); + } + + public function save(Job $job): void + { + $this->_em->persist($job); + $this->_em->flush(); + } + + public function delete(Job $job): void + { + $this->_em->remove($job); + $this->_em->flush(); + } + + /** + * @return array + */ + public function getJobLogsCountByLevel(int $jobId): array + { + $qb = $this->_em->getConnection()->createQueryBuilder(); + $qb->select('count(id) as count, level'); + $qb->from('import_export_job_record'); + $qb->where($qb->expr()->eq('job_id', ':jobId')); + $qb->groupBy('level'); + $qb->setParameter('jobId', $jobId); + + $rows = $qb->execute()->fetchAllAssociative(); + + return array_combine( + array_column($rows, 'level'), + array_column($rows, 'count'), + ); + } +} diff --git a/components/ImportExportBundle/src/lib/Job/JobRunner.php b/components/ImportExportBundle/src/lib/Job/JobRunner.php new file mode 100644 index 000000000..5a73814d1 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Job/JobRunner.php @@ -0,0 +1,99 @@ +workflowExecutor = $workflowExecutor; + $this->workflowRegistry = $workflowRegistry; + $this->jobRepository = $jobRepository; + parent::__construct($eventDispatcher); + } + + protected function run(Job $job, int $batchLimit = -1): int + { + $logger = new WorkflowLogger(); + + $workflow = $this->workflowRegistry->getWorkflow($job->getWorkflowIdentifier()); + $workflow->setLogger($logger); + + $proccessed = 0; + $onWorkflowProgress = function (WorkflowEvent $event) use (&$proccessed, $logger, $job) { + $workflow = $event->getWorkflow(); + // Ibexa content creation trigger an entity manager clear, which mean we need to reload the entity + $job = $this->jobRepository->findById($job->getId()); + $job->addRecords($logger->getRecords()); + $job->setProcessedItemsCount($workflow->getOffset()); + $this->jobRepository->save($job); + ++$proccessed; + }; + $workflow->addEventListener(WorkflowEvent::PROGRESS, $onWorkflowProgress); + + $this->eventDispatcher->dispatch(new PreJobRunEvent($job, $workflow)); + + if (Job::STATUS_PAUSED === $job->getStatus()) { + $workflow->setOffset($job->getProcessedItemsCount()); + $workflow->setWriterResults($job->getWriterResults()); + $workflow->setTotalItemsCount($job->getTotalItemsCount()); + } else { + $job->setStartTime(new DateTimeImmutable()); + $onWorkflowStart = function (WorkflowEvent $event) use ($job) { + $workflow = $event->getWorkflow(); + // Ibexa content creation trigger an entity manager clear, which mean we need to reload the entity + $job = $this->jobRepository->findById($job->getId()); + $job->setTotalItemsCount($workflow->getTotalItemsCount()); + $this->jobRepository->save($job); + }; + $workflow->addEventListener(WorkflowEvent::START, $onWorkflowStart); + } + $job->setStatus(Job::STATUS_RUNNING); + $this->jobRepository->save($job); + + ($this->workflowExecutor)( + $workflow, + $job->getOptions(), + $batchLimit + ); + + // Ibexa content creation trigger an entity manager clear, which mean we need to reload the entity + $job = $this->jobRepository->findById($job->getId()); + $job->addRecords($logger->getRecords()); + $job->setWriterResults($workflow->getWriterResults()); + if (1 == $job->getProgress() || 0 === $job->getTotalItemsCount() || 0 === $proccessed) { + $job->setStatus(Job::STATUS_COMPLETED); + $job->setEndTime($workflow->getEndTime()); + } else { + $job->setStatus(Job::STATUS_PAUSED); + } + + $this->eventDispatcher->dispatch(new PostJobRunEvent($job, $workflow)); + + $this->jobRepository->save($job); + + return $job->getStatus(); + } +} diff --git a/components/ImportExportBundle/src/lib/Job/JobRunnerInterface.php b/components/ImportExportBundle/src/lib/Job/JobRunnerInterface.php new file mode 100644 index 000000000..a8eb03fa6 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Job/JobRunnerInterface.php @@ -0,0 +1,10 @@ +jobDebugger = $jobDebugger; + $this->configResolver = $configResolver; + $this->jobRepository = $jobRepository; + $this->jobRunner = $jobRunner; + } + + public function createJob(Job $job, bool $autoStart = true) + { + $job->setRequestedDate(new DateTimeImmutable()); + $job->setStatus(Job::STATUS_PENDING); + + $this->jobRepository->save($job); + + if ($autoStart) { + $this->runJob($job); + } + } + + public function runJob(Job $job, int $batchLimit = null, bool $reset = false): void + { + if (!$batchLimit) { + $batchLimit = $this->configResolver->getParameter('default_batch_limit', 'import_export'); + } + ($this->jobRunner)($job, $batchLimit, $reset); + } + + public function debug(Job $job, int $index) + { + ($this->jobDebugger)($job, $index); + } + + public function loadJobById(int $id): ?Job + { + return $this->jobRepository->findById($id); + } + + public function countJobs(): int + { + return $this->jobRepository->count([]); + } + + public function loadJobs($limit = 10, $offset = 0): array + { + return $this->jobRepository->findBy( + [], + ['requestedDate' => 'DESC', 'id' => 'DESC'], + $limit, + $offset + ); + } + + public function delete(Job $job): void + { + $this->jobRepository->delete($job); + } + + public function getJobLogs(Job $job, ?int $level = null): Collection + { + if (!$level) { + return $job->getRecords(); + } + + return $job->getRecordsForLevel($level); + } + + /** + * @return array + */ + public function getJobLogsCountByLevel(Job $job): array + { + return $this->jobRepository->getJobLogsCountByLevel($job->getId()); + } +} diff --git a/components/ImportExportBundle/src/lib/Message/JobResumeMessage.php b/components/ImportExportBundle/src/lib/Message/JobResumeMessage.php new file mode 100644 index 000000000..90519e289 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Message/JobResumeMessage.php @@ -0,0 +1,9 @@ +jobId = $jobId; + $this->batchLimit = $batchLimit; + } + + public function getJobId(): int + { + return $this->jobId; + } + + public function getBatchLimit(): int + { + return $this->batchLimit; + } +} diff --git a/components/ImportExportBundle/src/lib/Message/JobStartMessage.php b/components/ImportExportBundle/src/lib/Message/JobStartMessage.php new file mode 100644 index 000000000..52ac5e516 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Message/JobStartMessage.php @@ -0,0 +1,9 @@ +messageBus = $messageBus; + $this->notificationSender = $notificationSender; + $this->jobRepository = $jobRepository; + $this->jobRunner = $jobRunner; + } + + /** + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException + */ + public function __invoke(JobRunMessage $message): void + { + $job = $this->jobRepository->findById($message->getJobId()); + $status = ($this->jobRunner)($job, $message->getBatchLimit()); + + if (Job::STATUS_COMPLETED === $status) { + ($this->notificationSender)( + $job->getCreatorId(), + NotificationSender::JOB_DONE_TYPE, + [ + 'job_id' => $job->getId(), + 'job_label' => $job->getLabel(), + 'message' => NotificationSender::MESSAGES[NotificationSender::JOB_DONE_TYPE], + 'message_parameters' => [ + '%job_id%' => $job->getId(), + '%job_label%' => $job->getLabel(), + ], + ] + ); + } + if (Job::STATUS_PAUSED === $status) { + $this->triggerResume($job, $message->getBatchLimit()); + } + } + + public function triggerStart(Job $job, int $batchLimit = -1): void + { + $this->messageBus->dispatch(new JobStartMessage($job->getId(), $batchLimit)); + } + + public function triggerResume(Job $job, int $batchLimit = -1): void + { + $this->messageBus->dispatch(new JobResumeMessage($job->getId(), $batchLimit)); + } +} diff --git a/components/ImportExportBundle/src/lib/Monolog/Formater/Formater.php b/components/ImportExportBundle/src/lib/Monolog/Formater/Formater.php new file mode 100644 index 000000000..e8e04c062 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Monolog/Formater/Formater.php @@ -0,0 +1,76 @@ + 'bg-info', + Logger::INFO => 'bg-success text-white', + Logger::NOTICE => 'bg-light text-white', + Logger::WARNING => 'bg-warning', + Logger::ERROR => 'bg-danger text-white', + Logger::CRITICAL => 'bg-danger text-white', + Logger::ALERT => 'bg-danger text-white', + Logger::EMERGENCY => 'bg-dark text-white', + ]; + + public function format(array $record): string + { + $title = sprintf( + '[%s] %s', + $record['level_name'], + (string) $record['message'], + ); + if (isset($record['context']['item_index'])) { + $title = sprintf( + '[Item %s]%s', + $record['context']['item_index'], + $title, + ); + unset($record['context']['item_index']); + } + $output = $this->addTitle( + $title, + $record['level'] + ); + $output .= ''; + + if ($record['context'] && !empty($record['context'])) { + $embeddedTable = '
    '; + foreach ($record['context'] as $key => $value) { + $embeddedTable .= $this->addRow((string) $key, $this->convertToString($value)); + } + $embeddedTable .= '
    '; + $output .= $this->addRow('Context', $embeddedTable, false); + } + if ($record['extra']) { + $embeddedTable = ''; + foreach ($record['extra'] as $key => $value) { + $embeddedTable .= $this->addRow((string) $key, $this->convertToString($value)); + } + $embeddedTable .= '
    '; + $output .= $this->addRow('Extra', $embeddedTable, false); + } + + return $output.''; + } + + /** + * Create a HTML h1 tag. + * + * @param string $title Text to be in the h1 + * @param int $level Error level + */ + protected function addTitle(string $title, int $level): string + { + $title = htmlspecialchars($title, ENT_NOQUOTES, 'UTF-8'); + + return '
    '.$title.'
    '; + } +} diff --git a/components/ImportExportBundle/src/lib/Monolog/Handler/WorkflowHandler.php b/components/ImportExportBundle/src/lib/Monolog/Handler/WorkflowHandler.php new file mode 100644 index 000000000..fe7b2d0c4 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Monolog/Handler/WorkflowHandler.php @@ -0,0 +1,185 @@ + */ + protected array $records = []; + /** @var array */ + protected array $recordsByLevel = []; + + protected bool $skipReset = false; + + public function __construct($level = Logger::DEBUG, bool $bubble = true) + { + parent::__construct($level, $bubble); + } + + /** + * @return array + */ + public function getRecords() + { + return $this->records; + } + + /** + * @return void + */ + public function clear() + { + $this->records = []; + $this->recordsByLevel = []; + } + + /** + * @return void + */ + public function reset() + { + if (!$this->skipReset) { + $this->clear(); + } + } + + /** + * @return void + */ + public function setSkipReset(bool $skipReset) + { + $this->skipReset = $skipReset; + } + + /** + * @param string|int $level Logging level value or name + * + * @phpstan-param Level|LevelName|LogLevel::* $level + */ + public function hasRecords($level): bool + { + return isset($this->recordsByLevel[Logger::toMonologLevel($level)]); + } + + /** + * @param string|array $record Either a message string or an array containing message + * and optionally context keys that will be checked against all records + * @param string|int $level Logging level value or name + * + * @phpstan-param array{message: string, context?: mixed[]}|string $record + * @phpstan-param Level|LevelName|LogLevel::* $level + */ + public function hasRecord($record, $level): bool + { + if (is_string($record)) { + $record = ['message' => $record]; + } + + return $this->hasRecordThatPasses(function ($rec) use ($record) { + if ($rec['message'] !== $record['message']) { + return false; + } + if (isset($record['context']) && $rec['context'] !== $record['context']) { + return false; + } + + return true; + }, $level); + } + + /** + * @param string|int $level Logging level value or name + * + * @phpstan-param Level|LevelName|LogLevel::* $level + */ + public function hasRecordThatContains(string $message, $level): bool + { + return $this->hasRecordThatPasses(function ($rec) use ($message) { + return false !== strpos($rec['message'], $message); + }, $level); + } + + /** + * @param string|int $level Logging level value or name + * + * @phpstan-param Level|LevelName|LogLevel::* $level + */ + public function hasRecordThatMatches(string $regex, $level): bool + { + return $this->hasRecordThatPasses(function (array $rec) use ($regex): bool { + return preg_match($regex, $rec['message']) > 0; + }, $level); + } + + /** + * @param string|int $level Logging level value or name + * + * @return bool + * + * @psalm-param callable(Record, int): mixed $predicate + * @phpstan-param Level|LevelName|LogLevel::* $level + */ + public function hasRecordThatPasses(callable $predicate, $level) + { + $level = Logger::toMonologLevel($level); + + if (!isset($this->recordsByLevel[$level])) { + return false; + } + + foreach ($this->recordsByLevel[$level] as $i => $rec) { + if ($predicate($rec->getRecord(), $i)) { + return true; + } + } + + return false; + } + + /** + * {@inheritDoc} + */ + protected function write(array $record): void + { + $jobRecord = new JobRecord( + new Ulid(), + $record + ); + $this->recordsByLevel[$record['level']][] = $jobRecord; + $this->records[] = $jobRecord; + } + + /** + * @param mixed[] $args + * + * @return bool + */ + public function __call(string $method, array $args) + { + if (preg_match('/(.*)(Debug|Info|Notice|Warning|Error|Critical|Alert|Emergency)(.*)/', $method, $matches) > 0) { + $genericMethod = $matches[1].('Records' !== $matches[3] ? 'Record' : '').$matches[3]; + $level = constant('Monolog\Logger::'.strtoupper($matches[2])); + $callback = [$this, $genericMethod]; + if (is_callable($callback)) { + $args[] = $level; + + return call_user_func_array($callback, $args); + } + } + + throw new BadMethodCallException('Call to undefined method '.get_class($this).'::'.$method.'()'); + } +} diff --git a/components/ImportExportBundle/src/lib/Monolog/WorkflowConsoleLogger.php b/components/ImportExportBundle/src/lib/Monolog/WorkflowConsoleLogger.php new file mode 100644 index 000000000..e3b6e0a12 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Monolog/WorkflowConsoleLogger.php @@ -0,0 +1,26 @@ +itemIndex) { + $context['item_index'] = $this->itemIndex; + $message = "[{item_index}] {$message}"; + } + parent::log($level, $message, $context); // TODO: Change the autogenerated stub + } + + public function getRecords(): array + { + return []; + } +} diff --git a/components/ImportExportBundle/src/lib/Monolog/WorkflowLogger.php b/components/ImportExportBundle/src/lib/Monolog/WorkflowLogger.php new file mode 100644 index 000000000..9f494d11f --- /dev/null +++ b/components/ImportExportBundle/src/lib/Monolog/WorkflowLogger.php @@ -0,0 +1,50 @@ +logHandler = new WorkflowHandler(); + parent::__construct('importexport.workflow', [$this->logHandler]); + } + + public function addRecord( + int $level, + string $message, + array $context = [], + DateTimeImmutable $datetime = null + ): bool { + if ($this->itemIndex) { + $context['item_index'] = $this->itemIndex; + } + + return parent::addRecord($level, $message, $context, $datetime); + } + + public function logException(Throwable $e): void + { + $this->error($e->getMessage(), ['exception' => $e->getTraceAsString()]); + } + + /** + * @return array + */ + public function getRecords(): array + { + return $this->logHandler->getRecords(); + } +} diff --git a/components/ImportExportBundle/src/lib/Monolog/WorkflowLoggerInterface.php b/components/ImportExportBundle/src/lib/Monolog/WorkflowLoggerInterface.php new file mode 100644 index 000000000..8cf0e604f --- /dev/null +++ b/components/ImportExportBundle/src/lib/Monolog/WorkflowLoggerInterface.php @@ -0,0 +1,17 @@ +itemIndex = $itemIndex; + } + + public function logException(Throwable $e): void + { + $this->error($e->getMessage(), ['exception' => $e->getTraceAsString()]); + } +} diff --git a/components/ImportExportBundle/src/lib/Notification/NotificationRenderer.php b/components/ImportExportBundle/src/lib/Notification/NotificationRenderer.php new file mode 100644 index 000000000..e78c5a68a --- /dev/null +++ b/components/ImportExportBundle/src/lib/Notification/NotificationRenderer.php @@ -0,0 +1,42 @@ +twig = $twig; + $this->router = $router; + } + + public function render(Notification $notification): string + { + return $this->twig->render( + '@ibexadesign/import_export/notification/default.html.twig', + ['notification' => $notification] + ); + } + + public function generateUrl(Notification $notification): ?string + { + if (array_key_exists('job_id', $notification->data)) { + return $this->router->generate( + 'import_export.job.view', + ['id' => $notification->data['job_id']] + ); + } + + return null; + } +} diff --git a/components/ImportExportBundle/src/lib/Notification/NotificationSender.php b/components/ImportExportBundle/src/lib/Notification/NotificationSender.php new file mode 100644 index 000000000..ad9c37a82 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Notification/NotificationSender.php @@ -0,0 +1,47 @@ + 'notification.job.done', + ]; + + protected NotificationService $notificationService; + + public function __construct(NotificationService $notificationService) + { + $this->notificationService = $notificationService; + } + + /** + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException + */ + public function __invoke(int $receiverId, string $type, array $data = []): void + { + $notification = new CreateStruct(); + $notification->ownerId = $receiverId; + $notification->type = 'import_export:notification:default'; + $notification->data = $data; + + $this->notificationService->createNotification($notification); + } + + public static function getTranslationMessages(): array + { + return [ + ( new Message(self::MESSAGES[self::JOB_DONE_TYPE], 'import_export') ) + ->setDesc('Job %job_label% (%job_id%) is done running.'), + ]; + } +} diff --git a/components/ImportExportBundle/src/lib/Processor/AbstractProcessor.php b/components/ImportExportBundle/src/lib/Processor/AbstractProcessor.php new file mode 100644 index 000000000..9ab9edd9f --- /dev/null +++ b/components/ImportExportBundle/src/lib/Processor/AbstractProcessor.php @@ -0,0 +1,25 @@ +processItem($item); + + return null !== $processResult ? $processResult : $item; + } + + /** + * @param object|array $item + */ + abstract public function processItem($item); +} diff --git a/components/ImportExportBundle/src/lib/Processor/Aggregator/ProcessorAggregator.php b/components/ImportExportBundle/src/lib/Processor/Aggregator/ProcessorAggregator.php new file mode 100644 index 000000000..5f1b337d6 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Processor/Aggregator/ProcessorAggregator.php @@ -0,0 +1,70 @@ +getProcessors(); + try { + foreach ($processors as $processor) { + $item = ($processor)($item); + if (false === $item) { + return; + } + } + } catch (Throwable $e) { + if ($this->getOption('errorBubbling', true)) { + throw $e; + } + $this->logger->logException($e); + + return null; + } + } + + /** + * @return array + */ + public function getProcessors(): array + { + return $this->getOption('processors', []); + } + + public function setLogger(WorkflowLoggerInterface $logger): void + { + parent::setLogger($logger); + $processors = $this->getProcessors(); + foreach ($processors as $processor) { + $processor->setLogger($logger); + } + } + + public function getIdentifier(): string + { + return 'processor.aggregator'; + } + + public static function getName(): TranslatableMessage + { + return new TranslatableMessage(/* @Desc("Aggregator") */ 'processor.aggregator.name'); + } + + public static function getOptionsType(): ?string + { + return ProcessorAggregatorOptions::class; + } +} diff --git a/components/ImportExportBundle/src/lib/Processor/Aggregator/ProcessorAggregatorOptions.php b/components/ImportExportBundle/src/lib/Processor/Aggregator/ProcessorAggregatorOptions.php new file mode 100644 index 000000000..5f4a9a539 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Processor/Aggregator/ProcessorAggregatorOptions.php @@ -0,0 +1,39 @@ + $processors + * @property bool $errorBubbling + */ +class ProcessorAggregatorOptions extends ProcessorOptions +{ + use ProcessorReferenceAggregationTrait; + + protected bool $errorBubbling = true; + + public function merge(ComponentOptions $overrideOptions): ComponentOptions + { + dd($overrideOptions); + } + + /** + * {@inheritDoc} + */ + public function replaceComponentReferences($buildComponentCallback): void + { + foreach ($this->processors as $key => $processor) { + $this->processors[$key] = call_user_func( + $buildComponentCallback, + $processor + ); + } + } +} diff --git a/components/ImportExportBundle/src/lib/Processor/ProcessorInterface.php b/components/ImportExportBundle/src/lib/Processor/ProcessorInterface.php new file mode 100644 index 000000000..44a286ea8 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Processor/ProcessorInterface.php @@ -0,0 +1,15 @@ + + */ + protected array $processors = []; + + public function addProcessor(ComponentReference $processor): void + { + $this->processors[] = $processor; + } + + /** + * @return array + */ + public function getProcessors(): array + { + return $this->processors ?? []; + } +} diff --git a/components/ImportExportBundle/src/lib/Reader/AbstractReader.php b/components/ImportExportBundle/src/lib/Reader/AbstractReader.php new file mode 100644 index 000000000..173e096be --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reader/AbstractReader.php @@ -0,0 +1,15 @@ +totalCount = $totalCount; + } + + public function count(): int + { + return $this->totalCount; + } +} diff --git a/components/ImportExportBundle/src/lib/Reader/Csv/CsvFileReadIterator.php b/components/ImportExportBundle/src/lib/Reader/Csv/CsvFileReadIterator.php new file mode 100644 index 000000000..012ee9e3c --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reader/Csv/CsvFileReadIterator.php @@ -0,0 +1,41 @@ +escape = $escape; + $this->enclosure = $enclosure; + $this->delimiter = $delimiter; + parent::__construct($stream, $firstLineNumber); + } + + /** + * @return array|false|null + */ + protected function getLine() + { + return fgetcsv( + $this->stream, + null, + $this->delimiter, + $this->enclosure, + $this->escape + ); + } +} diff --git a/components/ImportExportBundle/src/lib/Reader/Csv/CsvReader.php b/components/ImportExportBundle/src/lib/Reader/Csv/CsvReader.php new file mode 100644 index 000000000..f115462cd --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reader/Csv/CsvReader.php @@ -0,0 +1,105 @@ +slugConverter = $slugConverter; + parent::__construct($fileHandler); + } + + /** + * @throws \League\Flysystem\FilesystemException + * + * @return \Iterator<\AlmaviaCX\Bundle\IbexaImportExport\Item\ItemAccessorInterface> + */ + public function __invoke(): ReaderIteratorInterface + { + /** @var \AlmaviaCX\Bundle\IbexaImportExport\Reader\Csv\CsvReaderOptions $options */ + $options = $this->getOptions(); + + $headerRowNumber = $options->headerRowNumber; + $stream = $this->getFileStream(); + + $firstRow = 0; + $headers = []; + if (null !== $headerRowNumber) { + $firstRow = $headerRowNumber + 1; + } + + $iterator = new CsvFileReadIterator( + $stream, + $firstRow, + $options->delimiter, + $options->enclosure, + $options->escape, + ); + + $totalLines = $iterator->count(); + if (null !== $headerRowNumber) { + $iterator->seek($headerRowNumber); + $headers = array_map(function ($header) { + return $this->cleanHeader($header); + }, $iterator->current()); + $iterator->rewind(); + $totalLines -= $headerRowNumber; + } + + return new SeekableItemIterator( + $totalLines, + $iterator, + new CallbackIteratorItemTransformer(function ($item) use ($headers) { + return $this->transformItem( + new ArrayAccessor(!empty($headers) ? array_combine($headers, $item) : $item) + ); + }) + ); + } + + protected function transformItem($item) + { + return $item; + } + + protected function cleanHeader(string $value): string + { + return $this->slugConverter->convert(trim($value)); + } + + public static function getName(): TranslatableMessage + { + return new TranslatableMessage('reader.csv.name', [], 'import_export'); + } + + public static function getTranslationMessages(): array + { + return [( new Message('reader.csv.name', 'import_export') )->setDesc('CSV Reader')]; + } + + public static function getOptionsFormType(): ?string + { + return CsvReaderOptionsFormType::class; + } + + public static function getOptionsType(): ?string + { + return CsvReaderOptions::class; + } +} diff --git a/components/ImportExportBundle/src/lib/Reader/Csv/CsvReaderOptions.php b/components/ImportExportBundle/src/lib/Reader/Csv/CsvReaderOptions.php new file mode 100644 index 000000000..506d3ef3c --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reader/Csv/CsvReaderOptions.php @@ -0,0 +1,27 @@ +add('headerRowNumber', NumberType::class, [ + 'label' => /* @Desc("Header row number") */ 'csv_reader.form.options.headerRowNumber.label', + ]); + $builder->add('delimiter', ChoiceType::class, [ + 'label' => /* @Desc("Delimiter") */ 'csv_reader.form.options.delimiter.label', + 'choices' => array_combine(CsvReaderOptions::DELIMITERS, CsvReaderOptions::DELIMITERS), + ]); + $builder->add('enclosure', ChoiceType::class, [ + 'label' => /* @Desc("Enclosure") */ 'csv_reader.form.options.enclosure.label', + 'choices' => array_combine(CsvReaderOptions::ENCLOSURE, CsvReaderOptions::ENCLOSURE), + ]); + $builder->add('escape', ChoiceType::class, [ + 'label' => /* @Desc("Escape") */ 'csv_reader.form.options.escape.label', + 'choices' => array_combine(CsvReaderOptions::ESCAPE, CsvReaderOptions::ESCAPE), + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + $resolver->setDefaults([ + 'data_class' => CsvReader::getOptionsType(), + 'translation_domain' => 'forms', + ]); + } +} diff --git a/components/ImportExportBundle/src/lib/Reader/File/AbstractFileReader.php b/components/ImportExportBundle/src/lib/Reader/File/AbstractFileReader.php new file mode 100644 index 000000000..74ab8656b --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reader/File/AbstractFileReader.php @@ -0,0 +1,71 @@ +fileHandler = $fileHandler; + } + + /** + * @throws \League\Flysystem\FilesystemException + */ + protected function getFileStream() + { + /** @var FileReaderOptions $options */ + $options = $this->getOptions(); + if ($options->file instanceof File) { + return fopen($options->file->getRealPath(), 'rb'); + } + + return $this->fileHandler->readStream($options->file); + } + + /** + * @throws \League\Flysystem\FilesystemException + */ + protected function getFileTmpCopy(): string + { + if (null === $this->tmpFile) { + $this->tmpFile = tmpfile(); + $originalFile = $this->getFileStream(); + stream_copy_to_stream($originalFile, $this->tmpFile); + } + + $tmpFileMetadata = stream_get_meta_data($this->tmpFile); + + return $tmpFileMetadata['uri']; + } + + public static function getOptionsFormType(): ?string + { + return FileReaderOptionsFormType::class; + } + + public static function getOptionsType(): ?string + { + return FileReaderOptions::class; + } + + public function clean(): void + { + /** @var \AlmaviaCX\Bundle\IbexaImportExport\Reader\File\FileReaderOptions $options */ + $options = $this->getOptions(); + + if (is_string($options->file)) { + $this->fileHandler->delete($options->file); + } + } +} diff --git a/components/ImportExportBundle/src/lib/Reader/File/FileReaderOptions.php b/components/ImportExportBundle/src/lib/Reader/File/FileReaderOptions.php new file mode 100644 index 000000000..02bd244ba --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reader/File/FileReaderOptions.php @@ -0,0 +1,17 @@ +add('file', FileType::class, [ + 'label' => /* @Desc("File") */ 'file_reader.form.options.file.label', + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + $resolver->setDefaults([ + 'data_class' => AbstractFileReader::getOptionsType(), + 'translation_domain' => 'forms', + ]); + } +} diff --git a/components/ImportExportBundle/src/lib/Reader/Ibexa/ContentList/IbexaContentListReader.php b/components/ImportExportBundle/src/lib/Reader/Ibexa/ContentList/IbexaContentListReader.php new file mode 100644 index 000000000..43ef155e1 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reader/Ibexa/ContentList/IbexaContentListReader.php @@ -0,0 +1,100 @@ +objectAccessorBuilder = $objectAccessorBuilder; + $this->searchService = $searchService; + } + + public function __invoke(): ReaderIteratorInterface + { + /** @var IbexaContentListReaderOptions $options */ + $options = $this->getOptions(); + + $criterions = []; + if ($options->parentLocationId) { + $criterions[] = new Query\Criterion\ParentLocationId( + $options->parentLocationId + ); + } + if ($options->contentTypes) { + $ids = []; + $identifiers = []; + foreach ($options->contentTypes as $contentType) { + if (is_string($contentType)) { + $identifiers[] = $contentType; + } else { + $ids[] = $contentType; + } + } + + if (!empty($ids)) { + $criterions[] = new Query\Criterion\ContentTypeId($ids); + } + + if (!empty($identifiers)) { + $criterions[] = new Query\Criterion\ContentTypeIdentifier($identifiers); + } + } + $query = new Query(); + if ($criterions) { + $query->filter = new Query\Criterion\LogicalAnd($criterions); + } + + $countQuery = clone $query; + $countQuery->limit = 0; + $searchResults = $this->searchService->findContent($countQuery); + + return new ItemIterator( + $searchResults->totalCount, + new BatchIterator( + new ContentSearchAdapter($this->searchService, $query) + ), + new ContentSearchHitTransformerIterator( + $this->objectAccessorBuilder + ) + ); + } + + public static function getName(): TranslatableMessage + { + return new TranslatableMessage('reader.ibexa.content_list.name', [], 'import_export'); + } + + public static function getTranslationMessages(): array + { + return [( new Message('reader.ibexa.content_list.name', 'import_export') )->setDesc('Content list')]; + } + + public static function getOptionsFormType(): ?string + { + return IbexaContentListReaderOptionsFormType::class; + } + + public static function getOptionsType(): ?string + { + return IbexaContentListReaderOptions::class; + } +} diff --git a/components/ImportExportBundle/src/lib/Reader/Ibexa/ContentList/IbexaContentListReaderOptions.php b/components/ImportExportBundle/src/lib/Reader/Ibexa/ContentList/IbexaContentListReaderOptions.php new file mode 100644 index 000000000..8e1fc3abb --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reader/Ibexa/ContentList/IbexaContentListReaderOptions.php @@ -0,0 +1,17 @@ + $contentTypes + */ +class IbexaContentListReaderOptions extends ReaderOptions +{ + protected ?int $parentLocationId = null; + protected array $contentTypes = []; +} diff --git a/components/ImportExportBundle/src/lib/Reader/Ibexa/ContentList/IbexaContentListReaderOptionsFormType.php b/components/ImportExportBundle/src/lib/Reader/Ibexa/ContentList/IbexaContentListReaderOptionsFormType.php new file mode 100644 index 000000000..25ba75e7a --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reader/Ibexa/ContentList/IbexaContentListReaderOptionsFormType.php @@ -0,0 +1,31 @@ +add('parentLocationId', IntegerType::class, [ + 'label' => /* @Desc("Parent location id") */ 'reader.ibexa.content_list.options.parentLocationId.label', + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + $resolver->setDefaults([ + 'data_class' => IbexaContentListReader::getOptionsType(), + 'translation_domain' => 'forms', + ]); + } +} diff --git a/components/ImportExportBundle/src/lib/Reader/Ibexa/IteratorItemTransformer/ContentSearchHitTransformerIterator.php b/components/ImportExportBundle/src/lib/Reader/Ibexa/IteratorItemTransformer/ContentSearchHitTransformerIterator.php new file mode 100644 index 000000000..132589fad --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reader/Ibexa/IteratorItemTransformer/ContentSearchHitTransformerIterator.php @@ -0,0 +1,33 @@ +valueAccessorBuilder = $valueAccessorBuilder; + } + + /** + * {@inheritDoc} + */ + public function __invoke($item) + { + if ($item instanceof SearchHit && $item->valueObject instanceof Content) { + return $this->valueAccessorBuilder->buildFromContent($item->valueObject); + } + + return null; + } +} diff --git a/components/ImportExportBundle/src/lib/Reader/Mdb/MdbReader.php b/components/ImportExportBundle/src/lib/Reader/Mdb/MdbReader.php new file mode 100644 index 000000000..d739db781 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reader/Mdb/MdbReader.php @@ -0,0 +1,105 @@ +converterPath = $converterPath; + $this->converterTimeout = $converterTimeout; + parent::__construct($fileHandler); + } + + public function prepare(): void + { + parent::prepare(); + + $filePath = $this->getFileTmpCopy(); + + $this->dbFile = tmpfile(); + $tmpFileMetadata = stream_get_meta_data($this->dbFile); + + $process = new Process( + [ + $this->converterPath, + $filePath, + $tmpFileMetadata['uri'], + ] + ); + $process->setTimeout($this->converterTimeout); + $process->run(); + if (!$process->isSuccessful()) { + throw new RuntimeException( + sprintf('An error occurred while converting the mdb file: %s', $process->getErrorOutput()) + ); + } + + $this->connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => $tmpFileMetadata['uri']]); + } + + public function __invoke(): ReaderIteratorInterface + { + /** @var MdbReaderOptions $options */ + $options = $this->getOptions(); + + return new DoctrineSeekableItemIterator( + $this->connection, + $options->queryString, + $options->countQueryString + ); + } + + public function finish(): void + { + parent::finish(); + fclose($this->dbFile); + } + + public static function getName(): TranslatableMessage + { + return new TranslatableMessage('reader.mdb.name', [], 'import_export'); + } + + public static function getTranslationMessages(): array + { + return [( new Message('reader.mdb.name', 'import_export') )->setDesc('Microsoft Access Database reader')]; + } + + public static function getOptionsType(): ?string + { + return MdbReaderOptions::class; + } + + public static function getOptionsFormType(): ?string + { + return MdbReaderOptionsFormType::class; + } + + public function getConnection(): Connection + { + return $this->connection; + } +} diff --git a/components/ImportExportBundle/src/lib/Reader/Mdb/MdbReaderOptions.php b/components/ImportExportBundle/src/lib/Reader/Mdb/MdbReaderOptions.php new file mode 100644 index 000000000..c9bb5d149 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reader/Mdb/MdbReaderOptions.php @@ -0,0 +1,17 @@ +add('queryString', TextType::class, [ + 'label' => /* @Desc("Query string") */ 'mdb_reader.form.options.query_string.label', + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + $resolver->setDefaults([ + 'data_class' => MdbReader::getOptionsType(), + 'translation_domain' => 'forms', + ]); + } +} diff --git a/components/ImportExportBundle/src/lib/Reader/ReaderInterface.php b/components/ImportExportBundle/src/lib/Reader/ReaderInterface.php new file mode 100644 index 000000000..a0a139e26 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reader/ReaderInterface.php @@ -0,0 +1,15 @@ + + */ + public function __invoke(): ReaderIteratorInterface; +} diff --git a/components/ImportExportBundle/src/lib/Reader/ReaderIteratorInterface.php b/components/ImportExportBundle/src/lib/Reader/ReaderIteratorInterface.php new file mode 100644 index 000000000..a435fe378 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reader/ReaderIteratorInterface.php @@ -0,0 +1,12 @@ +setDefaults([ + 'data_class' => AbstractReader::getOptionsType(), + 'translation_domain' => 'forms', + ]); + } +} diff --git a/components/ImportExportBundle/src/lib/Reader/Xls/XlsReader.php b/components/ImportExportBundle/src/lib/Reader/Xls/XlsReader.php new file mode 100644 index 000000000..61bc2a4ff --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reader/Xls/XlsReader.php @@ -0,0 +1,117 @@ +getOptions(); + + $tmpFileName = $this->getFileTmpCopy(); + $reader = IOFactory::createReaderForFile($tmpFileName); + $reader->setReadDataOnly(true); + if ($options->tabName) { + $reader->setLoadSheetsOnly([$options->tabName]); + } + + $this->spreadsheet = $reader->load($tmpFileName); + $worksheet = $this->spreadsheet->getActiveSheet(); + + return $this->getIterator( + $worksheet, + $options->headerRowNumber ? $options->headerRowNumber + 1 : 1, + $options->colsRange + ); + } + + public function getSpreadsheet(): Spreadsheet + { + return $this->spreadsheet; + } + + /** + * @param array{'start': string, 'end': string}|null $colsRange + */ + public function getIterator(Worksheet $worksheet, int $startRow = 1, ?array $colsRange = null): ItemIterator + { + $maxDataRow = 0; + $endRow = $startRow - 1; + $rowIterator = $worksheet->getRowIterator($startRow); + foreach ($rowIterator as $row) { + if ( + $row->isEmpty( + CellIterator::TREAT_EMPTY_STRING_AS_EMPTY_CELL | CellIterator::TREAT_NULL_VALUE_AS_EMPTY_CELL + ) + ) { + break; + } + ++$maxDataRow; + ++$endRow; + } + + return new ItemIterator( + $maxDataRow, + $worksheet->getRowIterator($startRow, $endRow), + new CallbackIteratorItemTransformer(function (Row $row) use ($colsRange) { + $cellsIterator = $row->getCellIterator( + $colsRange ? $colsRange['start'] : 'A', + $colsRange ? $colsRange['end'] : null + ); + + return new ArrayAccessor( + array_map( + function (Cell $cell) { + return $cell->getValue(); + }, + iterator_to_array($cellsIterator) + ) + ); + }) + ); + } + + public static function getName(): TranslatableMessage + { + return new TranslatableMessage('reader.xls.name', [], 'import_export'); + } + + public static function getTranslationMessages(): array + { + return [( new Message('reader.xls.name', 'import_export') )->setDesc('Excel reader')]; + } + + public static function getOptionsType(): ?string + { + return XlsReaderOptions::class; + } + + public static function getOptionsFormType(): ?string + { + return XlsReaderOptionsFormType::class; + } +} diff --git a/components/ImportExportBundle/src/lib/Reader/Xls/XlsReaderOptions.php b/components/ImportExportBundle/src/lib/Reader/Xls/XlsReaderOptions.php new file mode 100644 index 000000000..14e867c2a --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reader/Xls/XlsReaderOptions.php @@ -0,0 +1,20 @@ + 'A', 'end' => null]; +} diff --git a/components/ImportExportBundle/src/lib/Reader/Xls/XlsReaderOptionsFormType.php b/components/ImportExportBundle/src/lib/Reader/Xls/XlsReaderOptionsFormType.php new file mode 100644 index 000000000..753f9b887 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reader/Xls/XlsReaderOptionsFormType.php @@ -0,0 +1,47 @@ +add('tabName', TextType::class, [ + 'label' => /* @Desc("Tab name") */ 'xls_reader.form.options.tabName.label', + ]); + $builder->add('headerRowNumber', NumberType::class, [ + 'label' => /* @Desc("Header row number") */ 'xls_reader.form.options.headerRowNumber.label', + ]); + $rangeForm = $builder->create('colsRange', FormType::class, [ + 'label' => /* @Desc("Columns range") */ 'xls_reader.form.options.colsRange.label', + ]); + $builder->add($rangeForm); + $rangeForm->add('from', TextType::class, [ + 'label' => /* @Desc("Start") */ 'xls_reader.form.options.colsRange.start.label', + ]); + $rangeForm->add('to', TextType::class, [ + 'label' => /* @Desc("End") */ 'xls_reader.form.options.colsRange.end.label', + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + $resolver->setDefaults([ + 'data_class' => XlsReader::getOptionsType(), + 'translation_domain' => 'forms', + ]); + } +} diff --git a/components/ImportExportBundle/src/lib/Reader/Xml/XmlParser.php b/components/ImportExportBundle/src/lib/Reader/Xml/XmlParser.php new file mode 100644 index 000000000..19f18947d --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reader/Xml/XmlParser.php @@ -0,0 +1,135 @@ +debug = $debug; + } + + /** + * @param resource $stream + */ + public function __construct($stream, string $searchedElementName) + { + $this->searchedElementName = $searchedElementName; + $this->stream = $stream; + $this->createNativeParser(); + } + + protected function resetNativeParser(): void + { + if ($this->nativeXmlParser) { + xml_parser_free($this->nativeXmlParser); + $this->nativeXmlParser = null; + } + } + + protected function createNativeParser(): void + { + $this->resetNativeParser(); + $this->nativeXmlParser = xml_parser_create('UTF-8'); + xml_set_object($this->nativeXmlParser, $this); + xml_set_element_handler($this->nativeXmlParser, 'startElement', 'endElement'); + } + + public function startElement($parser, $name, $attribs): void + { + if (self::STATE_SEARCHING_ELEMENT !== $this->state) { + return; + } + if (strtolower($name) == $this->searchedElementName) { + if (0 === $this->depth) { + $this->foundElementStartColumn = xml_get_current_column_number($parser) - strlen( + $this->searchedElementName + ) - 1; + $this->state = self::STATE_PARSING_ELEMENT; + } + ++$this->depth; + } + } + + public function endElement($parser, $name): void + { + if (self::STATE_PARSING_ELEMENT !== $this->state) { + return; + } + if (strtolower($name) == $this->searchedElementName) { + --$this->depth; + if (0 === $this->depth) { + $this->state = self::STATE_SEARCHING_ELEMENT; + $columnNumber = xml_get_current_column_number($parser); + $this->foundElementStack[] = $this->tmpElement.mb_substr( + $this->currentLineXml, + $this->foundElementStartColumn - 1, + $columnNumber - $this->foundElementStartColumn + ) + ; + $this->foundElementStartColumn = $columnNumber; + $this->tmpElement = ''; + } + } + } + + public function rewind(): void + { + rewind($this->stream); + $this->state = self::STATE_SEARCHING_ELEMENT; + $this->tmpElement = ''; + $this->foundElementStack = []; + $this->foundElementStartColumn = 1; + $this->depth = 0; + $this->createNativeParser(); + } + + public function parse(): ?string + { + while (!feof($this->stream) || !empty($this->foundElementStack)) { + if (!empty($this->foundElementStack)) { + return array_shift($this->foundElementStack); + } + + if (!feof($this->stream)) { + $xml = fgets($this->stream); + $this->currentLineXml = $xml ? $xml : ''; + xml_parse($this->nativeXmlParser, $this->currentLineXml, feof($this->stream)); + + if (self::STATE_PARSING_ELEMENT === $this->state) { + $this->tmpElement .= mb_substr($this->currentLineXml, $this->foundElementStartColumn - 1); + $this->foundElementStartColumn = 1; + } + } + } + + $this->resetNativeParser(); + + return null; + } + + public function __destruct() + { + $this->resetNativeParser(); + } +} diff --git a/components/ImportExportBundle/src/lib/Reader/Xml/XmlReader.php b/components/ImportExportBundle/src/lib/Reader/Xml/XmlReader.php new file mode 100644 index 000000000..47bca5606 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reader/Xml/XmlReader.php @@ -0,0 +1,55 @@ +getOptions(); + + $iterator = new XmlReaderIterator($this->getFileStream(), $options->nodeNameSelector); + + return new SeekableItemIterator( + $iterator->count(), + $iterator, + new CallbackIteratorItemTransformer([$this, 'transformItem']) + ); + } + + public function transformItem($item) + { + return $item; + } + + public static function getOptionsFormType(): ?string + { + return XmlReaderOptionsFormType::class; + } + + public static function getOptionsType(): ?string + { + return XmlReaderOptions::class; + } + + public static function getName(): TranslatableMessage + { + return new TranslatableMessage('reader.xml.name', [], 'import_export'); + } + + public static function getTranslationMessages(): array + { + return [( new Message('reader.xml.name', 'import_export') )->setDesc('XML Reader')]; + } +} diff --git a/components/ImportExportBundle/src/lib/Reader/Xml/XmlReaderIterator.php b/components/ImportExportBundle/src/lib/Reader/Xml/XmlReaderIterator.php new file mode 100644 index 000000000..386febca0 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reader/Xml/XmlReaderIterator.php @@ -0,0 +1,97 @@ +xmlParser = new XmlParser($stream, $nodeNameSelector); + } + + public function current() + { + return $this->current; + } + + public function next(): void + { + if (false !== $this->current) { + $this->current = $this->findNextNode(); + ++$this->currentIndex; + } + } + + public function key() + { + return $this->currentIndex; + } + + public function valid(): bool + { + return false !== $this->current; + } + + public function rewind(): void + { + $this->xmlParser->rewind(); + $this->current = $this->findNextNode(); + $this->currentIndex = 0; + } + + /** + * @return \DOMNode|bool + */ + protected function findNextNode() + { + $document = new DOMDocument(); + + $xml = $this->xmlParser->parse(); + if (null === $xml) { + return false; + } + $fragment = $document->createDocumentFragment(); + $fragment->appendXML(trim($xml)); + $document->append($fragment); + + return $document->firstChild; + } + + public function seek($offset): void + { + if ($offset > 1) { + for ($i = 1; $i < $offset; ++$i) { + $this->xmlParser->parse(); + } + } + $this->currentIndex = $offset; + $this->current = $this->findNextNode(); + } + + public function count(): int + { + $totalCount = 0; + $this->xmlParser->setDebug(true); + while (null !== $this->xmlParser->parse()) { + ++$totalCount; + } + $this->xmlParser->setDebug(false); + $this->rewind(); + + return $totalCount; + } +} diff --git a/components/ImportExportBundle/src/lib/Reader/Xml/XmlReaderOptions.php b/components/ImportExportBundle/src/lib/Reader/Xml/XmlReaderOptions.php new file mode 100644 index 000000000..aafa24488 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reader/Xml/XmlReaderOptions.php @@ -0,0 +1,15 @@ +name = $name; + $this->scope = $scope; + } + + public function getName(): string + { + return $this->name; + } + + public function getScope(): int + { + return $this->scope; + } +} diff --git a/components/ImportExportBundle/src/lib/Reference/ReferenceBag.php b/components/ImportExportBundle/src/lib/Reference/ReferenceBag.php new file mode 100644 index 000000000..3b932e670 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reference/ReferenceBag.php @@ -0,0 +1,49 @@ +references[$scope])) { + $this->references[$scope] = []; + } + $this->references[$scope][$name] = $value; + } + + public function hasReference(string $name, int $scope = Reference::SCOPE_ITEM): bool + { + return isset($this->references[$scope][$name]); + } + + public function getReference(string $name, $default = null, int $scope = Reference::SCOPE_ITEM) + { + return $this->references[$scope][$name] ?? $default; + } + + public function resetScope(int $scope): void + { + unset($this->references[$scope]); + } + + public function __set(string $name, $value): void + { + $this->addReference($name, $value); + } + + public function __get(string $name) + { + return $this->getReference($name); + } + + public function __isset(string $name): bool + { + return $this->hasReference($name); + } +} diff --git a/components/ImportExportBundle/src/lib/Reference/ReferenceMap.php b/components/ImportExportBundle/src/lib/Reference/ReferenceMap.php new file mode 100644 index 000000000..6739c512e --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reference/ReferenceMap.php @@ -0,0 +1,14 @@ + + */ +class ReferenceMap extends TransformationMap +{ +} diff --git a/components/ImportExportBundle/src/lib/Reference/ReferenceSource.php b/components/ImportExportBundle/src/lib/Reference/ReferenceSource.php new file mode 100644 index 000000000..60084157a --- /dev/null +++ b/components/ImportExportBundle/src/lib/Reference/ReferenceSource.php @@ -0,0 +1,23 @@ +scope = $scope; + parent::__construct($path, $transformers); + } + + public function getScope(): int + { + return $this->scope; + } +} diff --git a/components/ImportExportBundle/src/lib/Resolver/FilepathResolver.php b/components/ImportExportBundle/src/lib/Resolver/FilepathResolver.php new file mode 100644 index 000000000..bb4f44acc --- /dev/null +++ b/components/ImportExportBundle/src/lib/Resolver/FilepathResolver.php @@ -0,0 +1,39 @@ +parameterBag = $parameterBag; + } + + public function __invoke(string $filepath): string + { + $tokens = $this->buildTokens(); + + return str_replace( + array_keys($tokens), + array_values($tokens), + $this->parameterBag->resolveString($filepath) + ); + } + + public function buildTokens(): array + { + $datetime = new DateTimeImmutable(); + + return [ + '{date}' => $datetime->format('Y-m-d'), + '{datetime}' => $datetime->format('Y-m-d-H-i-s'), + ]; + } +} diff --git a/components/ImportExportBundle/src/lib/Result/Result.php b/components/ImportExportBundle/src/lib/Result/Result.php new file mode 100644 index 000000000..8749fd519 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Result/Result.php @@ -0,0 +1,47 @@ +startTime = $startTime; + $this->endTime = $endTime; + $this->elapsed = $startTime->diff($endTime); + $this->writerResults = $writerResults; + } + + public function getStartTime(): DateTimeImmutable + { + return $this->startTime; + } + + public function getEndTime(): DateTimeImmutable + { + return $this->endTime; + } + + public function getElapsed(): DateInterval + { + return $this->elapsed; + } + + public function getWriterResults(): array + { + return $this->writerResults; + } +} diff --git a/components/ImportExportBundle/src/lib/Step/AbstractStep.php b/components/ImportExportBundle/src/lib/Step/AbstractStep.php new file mode 100644 index 000000000..321eb2a7a --- /dev/null +++ b/components/ImportExportBundle/src/lib/Step/AbstractStep.php @@ -0,0 +1,15 @@ +getOptions(); + + return call_user_func($options->callback, $item); + } + + public static function getName(): TranslatableMessage + { + return new TranslatableMessage('step.callback.name', [], 'import_export'); + } + + public static function getTranslationMessages(): array + { + return [( new Message('step.callback.name', 'import_export') )->setDesc('Callback')]; + } +} diff --git a/components/ImportExportBundle/src/lib/Step/Callback/CallbackStepOptions.php b/components/ImportExportBundle/src/lib/Step/Callback/CallbackStepOptions.php new file mode 100644 index 000000000..b30b9f826 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Step/Callback/CallbackStepOptions.php @@ -0,0 +1,17 @@ +sourceResolver = $sourceResolver; + } + + public function processItem($item) + { + $value = $this->sourceResolver->getPropertyValue( + $item, + new PropertyPath($this->getOption('propertyPath')) + ); + if (empty($value)) { + return false; + } + + return $item; + } + + public static function getName(): TranslatableMessage + { + return new TranslatableMessage('step.filter.not_empty.name', [], 'import_export'); + } + + public static function getTranslationMessages(): array + { + return [( new Message('step.filter.not_empty.name', 'import_export') )->setDesc('Not empty filter')]; + } + + public static function getOptionsType(): ?string + { + return NotEmptyFilterStepOptions::class; + } +} diff --git a/components/ImportExportBundle/src/lib/Step/Filter/NotEmpty/NotEmptyFilterStepOptions.php b/components/ImportExportBundle/src/lib/Step/Filter/NotEmpty/NotEmptyFilterStepOptions.php new file mode 100644 index 000000000..2d19e9cde --- /dev/null +++ b/components/ImportExportBundle/src/lib/Step/Filter/NotEmpty/NotEmptyFilterStepOptions.php @@ -0,0 +1,15 @@ +sourceResolver = $sourceResolver; + } + + public function processItem($item) + { + $value = $this->sourceResolver->getPropertyValue( + $item, + new PropertyPath($this->getOption('propertyPath')) + ); + if (in_array($value, $this->values, true)) { + return false; + } + $this->values[] = $value; + + return $item; + } + + public static function getName(): TranslatableMessage + { + return new TranslatableMessage('step.filter.unique.name', [], 'import_export'); + } + + public static function getTranslationMessages(): array + { + return [( new Message('step.filter.unique.name', 'import_export') )->setDesc('Unique filter')]; + } + + public static function getOptionsType(): ?string + { + return UniqueFilterStepOptions::class; + } +} diff --git a/components/ImportExportBundle/src/lib/Step/Filter/Unique/UniqueFilterStepOptions.php b/components/ImportExportBundle/src/lib/Step/Filter/Unique/UniqueFilterStepOptions.php new file mode 100644 index 000000000..5fa3665de --- /dev/null +++ b/components/ImportExportBundle/src/lib/Step/Filter/Unique/UniqueFilterStepOptions.php @@ -0,0 +1,15 @@ +setDefaults([ + 'data_class' => AbstractStep::getOptionsType(), + 'translation_domain' => 'forms', + ]); + } +} diff --git a/components/ImportExportBundle/src/lib/Workflow/AbstractWorkflow.php b/components/ImportExportBundle/src/lib/Workflow/AbstractWorkflow.php new file mode 100644 index 000000000..7d18d89d1 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Workflow/AbstractWorkflow.php @@ -0,0 +1,191 @@ +referenceBag = $references; + } + + /** + * @param \AlmaviaCX\Bundle\IbexaImportExport\Workflow\WorkflowExecutionConfiguration $configuration + */ + public function setConfiguration(WorkflowExecutionConfiguration $configuration): void + { + $this->configuration = $configuration; + } + + protected function prepare(): void + { + $this->startTime = new DateTimeImmutable(); + $this->referenceBag->resetScope(Reference::SCOPE_WORKFLOW); + foreach ($this->configuration->getProcessors() as $processor) { + $processor->setLogger($this->logger); + $processor->prepare(); + } + foreach ($this->configuration->getWriters() as $index => $writer) { + if (isset($this->writerResults[$index])) { + $writer->setResults($this->writerResults[$index]); + } + } + $reader = $this->configuration->getReader(); + $reader->prepare(); + $this->itemsIterator = ($reader)(); + if (!$this->totalItemsCount) { + $this->totalItemsCount = $this->itemsIterator->count(); + } + + $this->dispatchEvent(new WorkflowEvent($this), WorkflowEvent::PREPARE); + } + + protected function finish(): void + { + $this->endTime = new DateTimeImmutable(); + foreach ($this->configuration->getProcessors() as $processor) { + $processor->finish(); + } + foreach ($this->configuration->getWriters() as $index => $writer) { + $this->writerResults[$index] = $writer->getResults(); + } + $this->configuration->getReader()->finish(); + + $this->dispatchEvent(new WorkflowEvent($this), WorkflowEvent::FINISH); + } + + public function __invoke(int $batchLimit = -1): void + { + try { + $this->prepare(); + $this->dispatchEvent(new WorkflowEvent($this), WorkflowEvent::START); + + $limitIterator = new LimitIterator($this->itemsIterator, $this->offset, $batchLimit); + foreach ($limitIterator as $index => $item) { + $this->logger->setItemIndex($index + 1); + $this->referenceBag->resetScope(Reference::SCOPE_ITEM); + $this->processItem($item); + ++$this->offset; + $this->dispatchEvent(new WorkflowEvent($this), WorkflowEvent::PROGRESS); + } + } catch (Throwable $e) { + if ($this->debug) { + throw $e; + } + $this->logger->setItemIndex(null); + $this->logger->logException($e); + } + $this->finish(); + } + + public function clean(): void + { + $this->configuration->getReader()->clean(); + foreach ($this->configuration->getProcessors() as $processor) { + $processor->clean(); + } + } + + public function setLogger(WorkflowLoggerInterface $logger): void + { + $this->logger = $logger; + } + + abstract public function getDefaultConfig(): WorkflowConfiguration; + + public function getStartTime(): DateTimeImmutable + { + return $this->startTime; + } + + public function getEndTime(): DateTimeImmutable + { + return $this->endTime; + } + + public function getWriterResults(): array + { + return $this->writerResults; + } + + public function setWriterResults(array $writerResults): void + { + $this->writerResults = $writerResults; + } + + public function getOffset(): int + { + return $this->offset; + } + + public function setOffset(int $offset): void + { + $this->offset = $offset; + } + + public function getTotalItemsCount(): int + { + return $this->totalItemsCount; + } + + public function setTotalItemsCount(?int $totalItemsCount): void + { + $this->totalItemsCount = $totalItemsCount; + } + + public function setDebug(bool $debug): void + { + $this->debug = $debug; + } + + /** + * @throws \Throwable + */ + protected function processItem($item): void + { + try { + foreach ($this->configuration->getProcessors() as $processor) { + $processResult = ($processor)($item); + if (false === $processResult) { + return; + } + if (null !== $processResult) { + $item = $processResult; + } + } + } catch (Throwable $procesItemException) { + if ($this->debug) { + throw $procesItemException; + } + $this->logger->logException($procesItemException); + } + } +} diff --git a/components/ImportExportBundle/src/lib/Workflow/ConfigurableWorkflow.php b/components/ImportExportBundle/src/lib/Workflow/ConfigurableWorkflow.php new file mode 100644 index 000000000..a6b3f47a1 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Workflow/ConfigurableWorkflow.php @@ -0,0 +1,60 @@ +workflowConfiguration = $workflowConfiguration; + $this->reader = $reader; + $this->writers = $writers; + $this->steps = $steps; + } + + public function getIdentifier(): string + { + return 'configurable'; + } + + protected function getReader(): ReaderInterface + { + return $this->reader; + } + + protected function getWriters(): array + { + return $this->writers; + } + + protected function getSteps(): array + { + return $this->steps; + } + + public function getDefaultConfig(): WorkflowConfiguration + { + return new WorkflowConfiguration( + 'configurable', + 'Configurable workflow', + ); + } +} diff --git a/components/ImportExportBundle/src/lib/Workflow/ConfigurableWorkflowFactory.php b/components/ImportExportBundle/src/lib/Workflow/ConfigurableWorkflowFactory.php new file mode 100644 index 000000000..79952baef --- /dev/null +++ b/components/ImportExportBundle/src/lib/Workflow/ConfigurableWorkflowFactory.php @@ -0,0 +1,30 @@ +getProcessConfiguration(); + + return new ConfigurableWorkflow( + $workflowConfiguration, + $this->getReader($configuration['reader']['identifier']), + $this->getWriters( + array_map(function (array $writerConfiguration) { + return $writerConfiguration['identifier']; + }, $configuration['writers']) + ), + $this->getSteps(array_map(function (array $writerConfiguration) { + return $writerConfiguration['identifier']; + }, $configuration['steps'])), + ); + } +} diff --git a/components/ImportExportBundle/src/lib/Workflow/Form/Type/WorkflowProcessConfigurationFormType.php b/components/ImportExportBundle/src/lib/Workflow/Form/Type/WorkflowProcessConfigurationFormType.php new file mode 100644 index 000000000..feab4d7c0 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Workflow/Form/Type/WorkflowProcessConfigurationFormType.php @@ -0,0 +1,81 @@ +workflowRegistry = $workflowRegistry; + $this->componentRegistry = $componentRegistry; + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + /** @var WorkflowProcessConfiguration $defaultConfiguration */ + $defaultConfiguration = $options['default_configuration']; + $showInitialized = $options['show_initialized']; + + $readerFormType = $this->getComponentOptionsFormType($defaultConfiguration->getReader()->getType()); + if ($readerFormType) { + $readerForm = $builder->create('reader', $readerFormType, [ + 'label' => $this->getComponentName($defaultConfiguration->getReader()->getType()), + 'show_initialized' => $showInitialized, + 'default_configuration' => $defaultConfiguration->getReader()->getOptions(), + ]); + $builder->add($readerForm); + } + + $processorsForm = $builder->create('processors', FormType::class, [ + 'label' => false, + ]); + foreach ($defaultConfiguration->getProcessors() as $index => $processorConfig) { + $processorFormType = $this->getComponentOptionsFormType($processorConfig->getType()); + if ($processorFormType) { + $processorForm = $processorsForm->create($index, $processorFormType, [ + 'label' => $this->getComponentName($processorConfig->getType()), + 'show_initialized' => $showInitialized, + 'default_configuration' => $processorConfig->getOptions(), + ]); + $processorsForm->add($processorForm); + } + } + $builder->add($processorsForm); + } + + protected function getComponentOptionsFormType(string $componentClassName): ?string + { + return $this->componentRegistry::getComponentOptionsFormType($componentClassName); + } + + /** + * @return string|\Symfony\Component\Translation\TranslatableMessage|null + */ + protected function getComponentName(string $componentClassName) + { + return $this->componentRegistry::getComponentName($componentClassName); + } + + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + $resolver->setDefaults([ + 'default_configuration' => null, + 'show_initialized' => false, + 'translation_domain' => 'forms', + ]); + } +} diff --git a/components/ImportExportBundle/src/lib/Workflow/WorkflowConfiguration.php b/components/ImportExportBundle/src/lib/Workflow/WorkflowConfiguration.php new file mode 100644 index 000000000..27862bb28 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Workflow/WorkflowConfiguration.php @@ -0,0 +1,138 @@ +availability = $availability; + $this->identifier = $identifier; + $this->name = $name; + $this->processConfiguration = new WorkflowProcessConfiguration(); + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + public function setIdentifier(string $identifier): void + { + $this->identifier = $identifier; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getProcessConfiguration(): WorkflowProcessConfiguration + { + return $this->processConfiguration; + } + + public function setProcessConfiguration(WorkflowProcessConfiguration $processConfiguration): void + { + $this->processConfiguration = $processConfiguration; + } + + public function setReader(string $class, ReaderOptions $options = null): void + { + $requiredOptionsType = call_user_func([$class, 'getOptionsType']); + if (!$options) { + $options = new $requiredOptionsType(); + } + if (!$options instanceof $requiredOptionsType) { + throw new InvalidArgumentException('Options must be an instance of '.$requiredOptionsType); + } + $this->processConfiguration->setReader(new ComponentReference($class, $options)); + } + + /** + * @param callable(ItemAccessorInterface $item): ?ItemAccessorInterface $callback + */ + public function addCallbackProcessor(callable $callback): void + { + $option = new CallbackStepOptions(); + $option->callback = $callback; + $this->addProcessor(CallbackStep::class, $option); + } + + public function addProcessor(string $class, ProcessorOptions $options = null): void + { + $requiredOptionsType = call_user_func([$class, 'getOptionsType']); + if (!$options) { + $options = new $requiredOptionsType(); + } + if (!$options instanceof $requiredOptionsType) { + throw new InvalidArgumentException('Options must be an instance of '.$requiredOptionsType); + } + $this->processConfiguration->addProcessor(new ComponentReference($class, $options)); + } + + public function getReader(): ComponentReference + { + return $this->processConfiguration->getReader(); + } + + /** + * @return array + */ + public function getProcessors(): array + { + return $this->processConfiguration->getProcessors(); + } + + /** + * @return int + */ + public function isAvailable(int $requiredAvailability): bool + { + return $requiredAvailability === ($requiredAvailability & $this->availability); + } +} diff --git a/components/ImportExportBundle/src/lib/Workflow/WorkflowConfigurationRepository.php b/components/ImportExportBundle/src/lib/Workflow/WorkflowConfigurationRepository.php new file mode 100644 index 000000000..891ff24e8 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Workflow/WorkflowConfigurationRepository.php @@ -0,0 +1,47 @@ +entityManager = $entityManager; + } + + public function find(string $identifier): ?WorkflowConfiguration + { + return $this->entityManager->getRepository(WorkflowConfiguration::class)->findOneBy( + ['identifier' => $identifier] + ); + } + + public function save(WorkflowConfiguration $configuration): void + { + $this->entityManager->persist($configuration); + $this->entityManager->flush(); + } + + /** + * @return array + */ + public function getAll(): array + { + $qb = $this->entityManager->createQueryBuilder(); + $qb->select('wc.identifier, wc.name') + ->from(WorkflowConfiguration::class, 'wc'); + + $workflows = []; + foreach ($qb->getQuery()->getArrayResult() as $item) { + dd($item); + } + + return $workflows; + } +} diff --git a/components/ImportExportBundle/src/lib/Workflow/WorkflowEvent.php b/components/ImportExportBundle/src/lib/Workflow/WorkflowEvent.php new file mode 100644 index 000000000..d66c8f333 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Workflow/WorkflowEvent.php @@ -0,0 +1,31 @@ +workflow = $workflow; + } + + /** + * @return \AlmaviaCX\Bundle\IbexaImportExport\Workflow\WorkflowInterface + */ + public function getWorkflow(): WorkflowInterface + { + return $this->workflow; + } +} diff --git a/components/ImportExportBundle/src/lib/Workflow/WorkflowExecutionConfiguration.php b/components/ImportExportBundle/src/lib/Workflow/WorkflowExecutionConfiguration.php new file mode 100644 index 000000000..1a36fccbe --- /dev/null +++ b/components/ImportExportBundle/src/lib/Workflow/WorkflowExecutionConfiguration.php @@ -0,0 +1,64 @@ +reader = $reader; + } + + public function getReader(): ReaderInterface + { + return $this->reader; + } + + public function addProcessor(ProcessorInterface $processor): void + { + $this->processors[] = $processor; + } + + /** + * @return \AlmaviaCX\Bundle\IbexaImportExport\Writer\WriterInterface[] + */ + public function getWriters(): array + { + return iterator_to_array($this->findWriters($this->processors)); + } + + /** + * @param \AlmaviaCX\Bundle\IbexaImportExport\Processor\ProcessorInterface[] $processors + */ + protected function findWriters(array $processors): Generator + { + foreach ($processors as $processor) { + if ($processor instanceof WriterInterface) { + yield $processor; + } + if ($processor instanceof ProcessorAggregator) { + $this->findWriters($processor->getProcessors()); + } + } + } + + /** + * @return \AlmaviaCX\Bundle\IbexaImportExport\Processor\ProcessorInterface[] + */ + public function getProcessors(): array + { + return $this->processors; + } +} diff --git a/components/ImportExportBundle/src/lib/Workflow/WorkflowExecutor.php b/components/ImportExportBundle/src/lib/Workflow/WorkflowExecutor.php new file mode 100644 index 000000000..a9d429a34 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Workflow/WorkflowExecutor.php @@ -0,0 +1,79 @@ +componentBuilder = $componentBuilder; + } + + /** + * @param \AlmaviaCX\Bundle\IbexaImportExport\Workflow\WorkflowInterface $workflow + * @param array{reader?: ReaderOptions, processors?: array} $runtimeProcessConfiguration + */ + public function __invoke( + WorkflowInterface $workflow, + array $runtimeProcessConfiguration, + int $batchLimit = -1 + ): void { + $workflow->setConfiguration( + $this->buildRunConfiguration( + $workflow, + $runtimeProcessConfiguration + ) + ); + + ($workflow)($batchLimit); + } + + /** + * @param \AlmaviaCX\Bundle\IbexaImportExport\Workflow\WorkflowInterface $workflow + * @param array{reader?: ReaderOptions, processors?: array} $runtimeProcessConfiguration + * + * @throws \Exception + * + * @return \AlmaviaCX\Bundle\IbexaImportExport\Workflow\WorkflowExecutionConfiguration + */ + protected function buildRunConfiguration( + WorkflowInterface $workflow, + array $runtimeProcessConfiguration + ): WorkflowExecutionConfiguration { + $baseConfiguration = $workflow->getDefaultConfig(); + + $processConfiguration = $baseConfiguration->getProcessConfiguration(); + $reader = ($this->componentBuilder)( + $processConfiguration->getReader(), + $runtimeProcessConfiguration['reader'] ?? null + ); + if (!$reader instanceof ReaderInterface) { + throw new Exception('Reader not instance of '.ReaderInterface::class); + } + $executionConfiguration = new WorkflowExecutionConfiguration($reader); + + $processorsConfiguration = $processConfiguration->getProcessors(); + foreach ($processorsConfiguration as $index => $processorConfiguration) { + $processor = ($this->componentBuilder)( + $processorConfiguration, + $runtimeProcessConfiguration['processors'][$index] ?? null + ); + if ($processor instanceof ProcessorInterface) { + $executionConfiguration->addProcessor($processor); + } + } + + return $executionConfiguration; + } +} diff --git a/components/ImportExportBundle/src/lib/Workflow/WorkflowInterface.php b/components/ImportExportBundle/src/lib/Workflow/WorkflowInterface.php new file mode 100644 index 000000000..108472f19 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Workflow/WorkflowInterface.php @@ -0,0 +1,41 @@ +reader = $reader; + } + + /** + * @param array $processors + */ + public function setProcessors(array $processors): void + { + $this->processors = $processors; + } + + public function getReader(): ?ComponentReference + { + return $this->reader; + } +} diff --git a/components/ImportExportBundle/src/lib/Workflow/WorkflowRegistry.php b/components/ImportExportBundle/src/lib/Workflow/WorkflowRegistry.php new file mode 100644 index 000000000..39f90f71a --- /dev/null +++ b/components/ImportExportBundle/src/lib/Workflow/WorkflowRegistry.php @@ -0,0 +1,77 @@ + */ + protected array $availableWorkflowServices; + + /** + * @param array $availableWorkflowServices + */ + public function __construct( + ContainerInterface $typeContainer, + array $availableWorkflowServices + ) { + $this->availableWorkflowServices = $availableWorkflowServices; + $this->typeContainer = $typeContainer; + } + + public function getWorkflow(string $identifier): WorkflowInterface + { + return $this->typeContainer->get($identifier); + } + + public static function getWorkflowDefaultConfiguration(string $workflowServiceClassName): ?WorkflowConfiguration + { + try { + $workflowService = static::getWorkflowService($workflowServiceClassName); + + $instance = Instantiator::instantiate($workflowServiceClassName); + + return $workflowService->getMethod('getDefaultConfig')->invoke($instance); + } catch (\ReflectionException $e) { + return null; + } + } + + public function getWorkflowClassName(string $identifier): string + { + return $this->availableWorkflowServices[$identifier]; + } + + /** + * @throws \ReflectionException + * + * @return ReflectionClass<\AlmaviaCX\Bundle\IbexaImportExport\Workflow\WorkflowInterface> + */ + protected static function getWorkflowService(string $workflowServiceClassName): ReflectionClass + { + return new ReflectionClass($workflowServiceClassName); + } + + /** + * @return array + */ + public function getAvailableWorkflowServices( + int $requiredAvailability = WorkflowConfiguration::AVAILABILITY_ADMIN_UI + ): array { + $worflows = []; + foreach ($this->availableWorkflowServices as $identifier => $workflowServiceClassName) { + $baseConfig = static::getWorkflowDefaultConfiguration($workflowServiceClassName); + if ($baseConfig && $baseConfig->isAvailable($requiredAvailability)) { + $worflows[$identifier] = $baseConfig->getName(); + } + } + + return $worflows; + } +} diff --git a/components/ImportExportBundle/src/lib/Writer/AbstractWriter.php b/components/ImportExportBundle/src/lib/Writer/AbstractWriter.php new file mode 100644 index 000000000..2fc8b0c52 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Writer/AbstractWriter.php @@ -0,0 +1,98 @@ +sourceResolver = $sourceResolver; + $this->referenceBag = $references; + $this->itemTransformer = $itemTransformer; + $this->results = new WriterResults(static::class, []); + } + + public static function getOptionsType(): ?string + { + return WriterOptions::class; + } + + public function setResults(WriterResults $results): void + { + $this->results = $results; + } + + /** + * @return \AlmaviaCX\Bundle\IbexaImportExport\Writer\WriterResults + */ + public function getResults(): WriterResults + { + return $this->results; + } + + /** + * @param object|array $item + * + * @return \AlmaviaCX\Bundle\IbexaImportExport\Item\ItemAccessorInterface|false|null + */ + public function processItem($item) + { + $writenItem = $this->writeItem($item, $this->mapItem($item)); + $this->setReferences($writenItem); + } + + protected function setReferences($objectOrArray): void + { + /** @var \AlmaviaCX\Bundle\IbexaImportExport\Writer\WriterOptions $options */ + $options = $this->getOptions(); + if (null === $options->referencesMap) { + return; + } + foreach ($options->referencesMap->getElements() as $referenceName => $referenceSource) { + $value = ($this->sourceResolver)($referenceSource, $objectOrArray); + $this->referenceBag->addReference($referenceName, $value, $referenceSource->getScope()); + } + } + + /** + * @param object|array $item + * @param array|object $mappedItem + * + * @return false|ItemAccessorInterface|null + */ + abstract protected function writeItem($item, $mappedItem); + + /** + * @param object|array $item + * + * @return array|object + */ + protected function mapItem($item) + { + /** @var \AlmaviaCX\Bundle\IbexaImportExport\Writer\WriterOptions $options */ + $options = $this->getOptions(); + + return ($this->itemTransformer)($item, $options->map, $this->getMappedItemInstance()); + } + + protected function getMappedItemInstance() + { + return []; + } +} diff --git a/components/ImportExportBundle/src/lib/Writer/Csv/CsvWriter.php b/components/ImportExportBundle/src/lib/Writer/Csv/CsvWriter.php new file mode 100644 index 000000000..75527b19e --- /dev/null +++ b/components/ImportExportBundle/src/lib/Writer/Csv/CsvWriter.php @@ -0,0 +1,78 @@ +getOptions(); + if (self::MODE_NEW_FILE === $this->mode && $options->prependHeaderRow && 0 == $this->row++) { + $headers = array_keys($mappedItem); + fputcsv($this->stream, $headers, $options->delimiter, $options->enclosure); + } + + if (!is_array($mappedItem)) { + throw new InvalidArgumentException('[CsvWriter] provided item must be an array.'); + } + foreach ($mappedItem as $valueIdentifier => $value) { + if (!is_scalar($value) && !is_null($value)) { + throw new InvalidArgumentException( + sprintf( + '[CsvWriter] provided value for "%s" must be scalar instead of %s.', + $valueIdentifier, + gettype($value) + ) + ); + } + } + + fputcsv( + $this->stream, + $mappedItem, + $options->delimiter, + $options->enclosure + ); + + return $mappedItem; + } + + public static function getName(): TranslatableMessage + { + return new TranslatableMessage('writer.csv.name', [], 'import_export'); + } + + public static function getTranslationMessages(): array + { + return [(new Message('writer.csv.name', 'import_export'))->setDesc('CSV Writer')]; + } + + public static function getOptionsType(): ?string + { + return CsvWriterOptions::class; + } + + public static function getResultTemplate(): ?string + { + return '@ibexadesign/import_export/writer/results/writer_csv.html.twig'; + } +} diff --git a/components/ImportExportBundle/src/lib/Writer/Csv/CsvWriterOptions.php b/components/ImportExportBundle/src/lib/Writer/Csv/CsvWriterOptions.php new file mode 100644 index 000000000..a1fe3b1a5 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Writer/Csv/CsvWriterOptions.php @@ -0,0 +1,21 @@ +repository = $repository; + } + + /** + * @param array $fieldsByLanguages + */ + protected function setContentFields( + ContentType $contentType, + ContentStruct $contentStruct, + array $fieldsByLanguages + ): void { + foreach ($fieldsByLanguages as $languageCode => $fields) { + foreach ($fields as $fieldID => $field) { + $fieldDefinition = $contentType->getFieldDefinition($fieldID); + if ($fieldDefinition instanceof FieldDefinition) { + $contentStruct->setField($fieldID, $field, $languageCode); + } + } + } + } +} diff --git a/components/ImportExportBundle/src/lib/Writer/Ibexa/Content/IbexaContentCreator.php b/components/ImportExportBundle/src/lib/Writer/Ibexa/Content/IbexaContentCreator.php new file mode 100644 index 000000000..f0ef1673c --- /dev/null +++ b/components/ImportExportBundle/src/lib/Writer/Ibexa/Content/IbexaContentCreator.php @@ -0,0 +1,95 @@ + $parentLocationIdList + * @param array $fieldsByLanguages + * + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\BadStateException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\ContentFieldValidationException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\ContentValidationException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\UnauthorizedException + */ + public function __invoke( + string $contentTypeIdentifier, + array $parentLocationIdList, + array $fieldsByLanguages, + string $remoteId, + int $ownerId = null, + string $languageCode = 'eng-GB', + int $sectionId = null, + $modificationDate = null, + bool $hidden = false + ): Content { + $contentType = $this->repository->getContentTypeService()->loadContentTypeByIdentifier( + $contentTypeIdentifier + ); + + /* Creating new content create structure */ + $contentCreateStruct = $this->repository->getContentService()->newContentCreateStruct( + $contentType, + $languageCode + ); + $contentCreateStruct->remoteId = $remoteId; + $contentCreateStruct->ownerId = $ownerId; + if (null !== $modificationDate) { + $contentCreateStruct->modificationDate = $modificationDate instanceof DateTime ? + $modificationDate : + DateTime::createFromFormat('U', (string) $modificationDate); + } + + if ($sectionId) { + $contentCreateStruct->sectionId = $sectionId; + } + + /* Update content structure fields */ + $this->setContentFields($contentType, $contentCreateStruct, $fieldsByLanguages); + + /* Assigning the content locations */ + $locationCreateStructs = []; + foreach ($parentLocationIdList as $locationRemoteId => $parentLocationId) { + if (empty($parentLocationId)) { + throw new Exception('Parent location id cannot be empty'); + } + if ($parentLocationId instanceof Location) { + $parentLocationId = $parentLocationId->id; + } + if (is_string($parentLocationId)) { + $parentLocationId = $this->repository->getLocationService()->loadLocationByRemoteId( + $parentLocationId + )->id; + } + $locationCreateStruct = $this->repository->getLocationService()->newLocationCreateStruct( + $parentLocationId + ); + if (is_string($locationRemoteId)) { + $locationCreateStruct->remoteId = $locationRemoteId; + } + if ($hidden) { + $locationCreateStruct->hidden = true; + } + $locationCreateStructs[] = $locationCreateStruct; + } + + /* Creating new draft */ + $draft = $this->repository->getContentService()->createContent( + $contentCreateStruct, + $locationCreateStructs + ); + + /* Publish the new content draft */ + return $this->repository->getContentService()->publishVersion($draft->versionInfo); + } +} diff --git a/components/ImportExportBundle/src/lib/Writer/Ibexa/Content/IbexaContentData.php b/components/ImportExportBundle/src/lib/Writer/Ibexa/Content/IbexaContentData.php new file mode 100644 index 000000000..adb89ac6e --- /dev/null +++ b/components/ImportExportBundle/src/lib/Writer/Ibexa/Content/IbexaContentData.php @@ -0,0 +1,114 @@ + */ + protected array $fields = []; + protected string $mainLanguageCode = 'eng-GB'; + protected ?int $ownerId = null; + protected ?string $contentTypeIdentifier = null; + /** @var array */ + protected array $parentLocationIdList = [2]; + protected ?int $sectionId = null; + protected int|null|DateTime $modificationDate = null; + + public function getContentRemoteId(): string + { + return $this->contentRemoteId; + } + + public function setContentRemoteId(string $contentRemoteId): void + { + $this->contentRemoteId = $contentRemoteId; + } + + /** + * @return array + */ + public function getFields(): array + { + return $this->fields; + } + + /** + * @param array $fields + */ + public function setFields(array $fields): void + { + $this->fields = $fields; + } + + public function getMainLanguageCode(): string + { + return $this->mainLanguageCode; + } + + public function setMainLanguageCode(string $mainLanguageCode): void + { + $this->mainLanguageCode = $mainLanguageCode; + } + + public function getOwnerId(): ?int + { + return $this->ownerId; + } + + public function setOwnerId(?int $ownerId): void + { + $this->ownerId = $ownerId; + } + + public function getContentTypeIdentifier(): ?string + { + return $this->contentTypeIdentifier; + } + + public function setContentTypeIdentifier(?string $contentTypeIdentifier): void + { + $this->contentTypeIdentifier = $contentTypeIdentifier; + } + + /** + * @return array + */ + public function getParentLocationIdList(): array + { + return $this->parentLocationIdList; + } + + /** + * @param array $parentLocationIdList + */ + public function setParentLocationIdList(array $parentLocationIdList): void + { + $this->parentLocationIdList = $parentLocationIdList; + } + + public function getSectionId(): ?int + { + return $this->sectionId; + } + + public function setSectionId(?int $sectionId): void + { + $this->sectionId = $sectionId; + } + + public function getModificationDate(): DateTime|int|null + { + return $this->modificationDate; + } + + public function setModificationDate(DateTime|int|null $modificationDate): void + { + $this->modificationDate = $modificationDate; + } +} diff --git a/components/ImportExportBundle/src/lib/Writer/Ibexa/Content/IbexaContentImporter.php b/components/ImportExportBundle/src/lib/Writer/Ibexa/Content/IbexaContentImporter.php new file mode 100644 index 000000000..bd3319cb5 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Writer/Ibexa/Content/IbexaContentImporter.php @@ -0,0 +1,82 @@ +contentCreator = $contentCreator; + $this->contentUpdater = $contentUpdater; + $this->repository = $repository; + } + + /** + * @param \AlmaviaCX\Bundle\IbexaImportExport\Writer\Ibexa\Content\IbexaContentData $contentData + * + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\BadStateException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\ContentFieldValidationException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\ContentValidationException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\UnauthorizedException + * + * @return \Ibexa\Contracts\Core\Repository\Values\Content\Content + */ + public function __invoke(IbexaContentData $contentData, bool $allowUpdate = true) + { + $remoteId = $contentData->getContentRemoteId(); + $ownerId = $contentData->getOwnerId(); + if (null === $ownerId) { + $ownerId = $this->repository + ->getPermissionResolver() + ->getCurrentUserReference() + ->getUserId(); + } + + try { + try { + $content = $this->repository->getContentService()->loadContentByRemoteId( + $contentData->getContentRemoteId() + ); + if (!$allowUpdate) { + return $content; + } + + return ($this->contentUpdater)( + $content, + $contentData->getFields(), + $contentData->getParentLocationIdList(), + $ownerId, + $contentData->getMainLanguageCode() + ); + } catch (NotFoundException $exception) { + return ($this->contentCreator)( + $contentData->getContentTypeIdentifier(), + $contentData->getParentLocationIdList(), + $contentData->getFields(), + $remoteId, + $ownerId, + $contentData->getMainLanguageCode(), + $contentData->getSectionId(), + $contentData->getModificationDate() + ); + } + } catch (\Throwable $exception) { + dump($exception); + throw $exception; + } + } +} diff --git a/components/ImportExportBundle/src/lib/Writer/Ibexa/Content/IbexaContentUpdater.php b/components/ImportExportBundle/src/lib/Writer/Ibexa/Content/IbexaContentUpdater.php new file mode 100644 index 000000000..12eabdaef --- /dev/null +++ b/components/ImportExportBundle/src/lib/Writer/Ibexa/Content/IbexaContentUpdater.php @@ -0,0 +1,125 @@ + $fieldsByLanguages + * + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\BadStateException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\ContentFieldValidationException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\ContentValidationException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\UnauthorizedException + */ + public function __invoke( + Content $content, + array $fieldsByLanguages, + array $parentLocationIdList, + int $ownerId = null, + string $mainLanguageCode = 'eng-GB', + bool $hidden = false + ): Content { + $contentType = $this->repository->getContentTypeService()->loadContentType( + $content->contentInfo->contentTypeId + ); + + $contentInfo = $content->contentInfo; + $contentDraft = $this->repository->getContentService()->createContentDraft($contentInfo); + + /* Creating new content update structure */ + $contentUpdateStruct = $this->repository + ->getContentService() + ->newContentUpdateStruct(); + $contentUpdateStruct->initialLanguageCode = $mainLanguageCode; // set language for new version + $contentUpdateStruct->creatorId = $ownerId; + + $this->setContentFields( + $contentType, + $contentUpdateStruct, + $fieldsByLanguages, + ); + + $contentDraft = $this->repository->getContentService()->updateContent( + $contentDraft->versionInfo, + $contentUpdateStruct + ); + + /* Publish the new content draft */ + $publishedContent = $this->repository->getContentService()->publishVersion($contentDraft->versionInfo); + + $this->handleLocations($content, $parentLocationIdList, $hidden); + + return $publishedContent; + } + + protected function handleLocations(Content $content, array $parentLocationIdList, bool $hidden): void + { + $existingLocations = $this->repository->getLocationService()->loadLocations($content->contentInfo); + $locationsToKeep = []; + foreach ($parentLocationIdList as $locationRemoteId => $parentLocationId) { + if (empty($parentLocationId)) { + throw new Exception('Parent location id cannot be empty'); + } + $locationsToKeep[] = $this->handleLocation( + $content, + $parentLocationId, + $locationRemoteId, + $existingLocations, + $hidden + ); + } + + foreach ($existingLocations as $existingLocation) { + if (!in_array($existingLocation, $locationsToKeep)) { + $this->repository->getLocationService()->deleteLocation($existingLocation); + } + } + } + + protected function handleLocation( + Content $content, + $parentLocationId, + $locationRemoteId, + array $existingLocations, + bool $hidden + ): Location { + if ($parentLocationId instanceof Location) { + $parentLocationId = $parentLocationId->id; + } + if (is_string($parentLocationId)) { + $parentLocationId = $this->repository->getLocationService()->loadLocationByRemoteId( + $parentLocationId + )->id; + } + + foreach ($existingLocations as $existingLocation) { + if ($existingLocation->parentLocationId === $parentLocationId) { + return $existingLocation; + } + } + + $locationCreateStruct = $this->repository->getLocationService()->newLocationCreateStruct( + $parentLocationId + ); + if (is_string($locationRemoteId)) { + $locationCreateStruct->remoteId = $locationRemoteId; + } + if ($hidden) { + $locationCreateStruct->hidden = true; + } + + return $this->repository->getLocationService()->createLocation( + $content->contentInfo, + $locationCreateStruct + ); + } +} diff --git a/components/ImportExportBundle/src/lib/Writer/Ibexa/Content/IbexaContentWriter.php b/components/ImportExportBundle/src/lib/Writer/Ibexa/Content/IbexaContentWriter.php new file mode 100644 index 000000000..d078b6c07 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Writer/Ibexa/Content/IbexaContentWriter.php @@ -0,0 +1,95 @@ +repository = $repository; + $this->contentImporter = $contentImporter; + $this->objectAccessorBuilder = $objectAccessorBuilder; + parent::__construct($sourceResolver, $itemTransformer, $references); + } + + protected function getMappedItemInstance() + { + return new IbexaContentData(); + } + + /** + * {@inheritDoc} + * + * @param \AlmaviaCX\Bundle\IbexaImportExport\Writer\Ibexa\Content\IbexaContentData $mappedItem + */ + protected function writeItem($item, $mappedItem) + { + /** @var \AlmaviaCX\Bundle\IbexaImportExport\Writer\Ibexa\Content\IbexaContentWriterOptions $options */ + $options = $this->getOptions(); + + $content = $this->repository->sudo(function (Repository $repository) use ($options, $mappedItem) { + try { + return ($this->contentImporter)($mappedItem, $options->allowUpdate); + } catch (ContentFieldValidationException $exception) { + $newException = \Ibexa\Core\Base\Exceptions\ContentFieldValidationException::createNewWithMultiline( + $exception->getFieldErrors(), + $mappedItem->getContentRemoteId() + ); + $this->logger->notice('----> '.get_class($newException)); + $this->logger->notice($newException->getMessage()); + $this->logger->notice(print_r($newException->getFieldErrors(), true)); + $this->logger->notice(print_r($newException->getTraceAsString(), true)); + + throw $exception; + } + }); + + $this->logger->info( + 'Imported content "'.$content->contentInfo->name.'" ('.$content->contentInfo->remoteId.')' + ); + + $imported_content_ids = $this->results->getResult('imported_content_ids'); + $imported_content_ids[] = $content->id; + $this->results->setResult('imported_content_ids', $imported_content_ids); + + return $this->objectAccessorBuilder->buildFromContent($content); + } + + public static function getName(): TranslatableMessage + { + return new TranslatableMessage('writer.ibexa.content.name', [], 'import_export'); + } + + public static function getTranslationMessages(): array + { + return [( new Message('writer.ibexa.content.name', 'import_export') )->setDesc('Ibexa content writer')]; + } + + public static function getResultTemplate(): ?string + { + return '@ibexadesign/import_export/writer/results/writer_ibexa_content.html.twig'; + } +} diff --git a/components/ImportExportBundle/src/lib/Writer/Ibexa/Content/IbexaContentWriterOptions.php b/components/ImportExportBundle/src/lib/Writer/Ibexa/Content/IbexaContentWriterOptions.php new file mode 100644 index 000000000..6b84617d6 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Writer/Ibexa/Content/IbexaContentWriterOptions.php @@ -0,0 +1,15 @@ +fileHandler = $fileHandler; + parent::__construct($sourceResolver, $itemTransformer, $references); + } + + public function prepare(): void + { + $this->stream = fopen('php://temp', 'w+'); + $filepath = $this->results->getResult('filepath'); + if (!$filepath) { + /** @var \AlmaviaCX\Bundle\IbexaImportExport\Writer\Stream\StreamWriterOptions $options */ + $options = $this->getOptions(); + $filepath = ($this->fileHandler)->resolvePath($options->filepath); + $this->results->setResult('filepath', $filepath); + } else { + try { + $existingStream = $this->fileHandler->readStream($filepath); + stream_copy_to_stream($existingStream, $this->stream); + $this->mode = self::MODE_APPEND_FILE; + } catch (FilesystemException $e) { + $this->logger->logException($e); + } + } + } + + public function finish(): void + { + parent::finish(); + + rewind($this->stream); + $filepath = $this->results->getResult('filepath'); + $this->fileHandler->writeStream($filepath, $this->stream, new Config()); + + if (is_resource($this->stream)) { + fclose($this->stream); + } + } + + public static function getOptionsFormType(): ?string + { + return StreamWriterOptionsFormType::class; + } + + public static function getOptionsType(): ?string + { + return StreamWriterOptions::class; + } +} diff --git a/components/ImportExportBundle/src/lib/Writer/Stream/StreamWriterOptions.php b/components/ImportExportBundle/src/lib/Writer/Stream/StreamWriterOptions.php new file mode 100644 index 000000000..22ccdebc4 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Writer/Stream/StreamWriterOptions.php @@ -0,0 +1,15 @@ +filepathResolver = $filepathResolver; + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + parent::buildForm($builder, $options); + $tokens = implode( + ' / ', + array_keys($this->filepathResolver->buildTokens()) + ); + $builder->add('filepath', TextType::class, [ + 'label' => /* @Desc("File path") */ 'writer.stream.options.filepath.label', + 'help' => new TranslatableMessage('writer.stream.options.filepath.tokens', ['%tokens%' => $tokens]), + ]); + } + + public static function getTranslationMessages(): array + { + return [ + ( new Message('writer.stream.options.filepath.tokens', 'forms') ) + ->setDesc('Tokens: %tokens%'), + ]; + } + + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + $resolver->setDefaults([ + 'data_class' => AbstractStreamWriter::getOptionsType(), + 'translation_domain' => 'forms', + ]); + } +} diff --git a/components/ImportExportBundle/src/lib/Writer/Utils/DumpWriter.php b/components/ImportExportBundle/src/lib/Writer/Utils/DumpWriter.php new file mode 100644 index 000000000..514863355 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Writer/Utils/DumpWriter.php @@ -0,0 +1,33 @@ +setDesc('Dump Writer')]; + } + + public static function getResultTemplate(): ?string + { + return null; + } +} diff --git a/components/ImportExportBundle/src/lib/Writer/WriterInterface.php b/components/ImportExportBundle/src/lib/Writer/WriterInterface.php new file mode 100644 index 000000000..f01e31e3a --- /dev/null +++ b/components/ImportExportBundle/src/lib/Writer/WriterInterface.php @@ -0,0 +1,16 @@ +setDefaults([ + 'data_class' => AbstractWriter::getOptionsType(), + 'translation_domain' => 'forms', + ]); + } +} diff --git a/components/ImportExportBundle/src/lib/Writer/WriterResults.php b/components/ImportExportBundle/src/lib/Writer/WriterResults.php new file mode 100644 index 000000000..9569a0e24 --- /dev/null +++ b/components/ImportExportBundle/src/lib/Writer/WriterResults.php @@ -0,0 +1,44 @@ +writerType = $writerType; + $this->results = $results; + } + + public function getWriterType(): string + { + return $this->writerType; + } + + /** + * @return mixed[] + */ + public function getResults(): array + { + return $this->results; + } + + public function setResult(string $key, $value): void + { + $this->results[$key] = $value; + } + + public function getResult(string $key, $default = null) + { + return $this->results[$key] ?? $default; + } +}