diff --git a/composer.json b/composer.json index 98f51871..f3b6066d 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,8 @@ "ibexa/core": "~4.3.0@dev", "ibexa/content-forms": "~4.3.0@dev", "ibexa/rest": "~4.3.0@dev", - "ibexa/http-cache": "~4.3.0@dev" + "ibexa/http-cache": "~4.3.0@dev", + "symfony/process": "^5.0" }, "require-dev": { "ibexa/ci-scripts": "^0.2@dev", @@ -56,6 +57,11 @@ "Ibexa\\Tests\\Integration\\FieldTypeRichText\\": "tests/integration/" } }, + "config": { + "allow-plugins": { + "*": false + } + }, "scripts": { "fix-cs": "php-cs-fixer fix --config=.php-cs-fixer.php -v --show-progress=dots", "check-cs": "@fix-cs --dry-run", diff --git a/src/bundle/Command/AbstractMultiProcessComand.php b/src/bundle/Command/AbstractMultiProcessComand.php new file mode 100644 index 00000000..aa62fb94 --- /dev/null +++ b/src/bundle/Command/AbstractMultiProcessComand.php @@ -0,0 +1,366 @@ +permissionResolver = $permissionResolver; + $this->userService = $userService; + $this->dryRun = false; + $this->processes = []; + + parent::__construct($name); + } + + public function configure(): void + { + $this + ->addOption( + 'user', + 'u', + InputOption::VALUE_REQUIRED, + 'Ibexa DXP username', + 'admin' + ) + ->addOption( + 'dry-run', + null, + InputOption::VALUE_NONE, + 'Run the converter without writing anything to the database' + ) + ->addOption( + 'no-progress', + null, + InputOption::VALUE_NONE, + 'Disable the progress bar.' + )->addOption( + 'processes', + null, + InputOption::VALUE_OPTIONAL, + 'Number of child processes to run in parallel for iterations, if set to "auto" it will set to number of CPU cores -1, set to "1" or "0" to disable [default: "auto"]', + 1 + )->addOption( + 'iteration-count', + null, + InputOption::VALUE_OPTIONAL, + 'Number of objects to process in a single iteration. Set to avoid using too much memory [default: 10000]', + 10000 + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->user = (string) $input->getOption('user'); + $this->permissionResolver->setCurrentUserReference( + $this->userService->loadUserByLogin($this->user) + ); + + $this->environment = (string) $input->getOption('env'); + + if ($input->getOption('dry-run')) { + $this->dryRun = true; + } + if ($this->isDryRun() && !$this->isChildProcess()) { + $output->writeln('Running in dry-run mode. No changes will actually be written to database'); + } + + $this->maxProcesses = (int) $input->getOption('processes'); + if ($this->maxProcesses < 1) { + throw new RuntimeException('Invalid value for "--processes" given'); + } + + $this->iterationCount = (int) $input->getOption('iteration-count'); + if ($this->iterationCount < 1) { + throw new RuntimeException('Invalid value for "--processes" given'); + } + + $this->hasProgressBar = !$this->isChildProcess() && !$input->getOption('no-progress'); + + $this->output = $output; + + if ($this->isChildProcess()) { + $cursor = $this->constructCursorFromInputOptions(); + $this->processData($cursor); + } else { + $this->output->writeln('Processing ' . $this->getObjectCount() . ' items.'); + $this->output->writeln('Using ' . $this->getMaxProcesses() . ' concurrent processes and processing ' . $this->getIterationCount() . ' items per iteration'); + + $this->startProgressBar(); + + $this->iterate(); + $this->waitForAllChildren(); + $this->completed(); + } + + return self::SUCCESS; + } + + /** + * This method should return the total number of items to process. + * + * @return int + */ + abstract protected function getObjectCount(): int; + + /** + * This method should process the subset of data, specified by the cursor. + * + * @param mixed $cursor + * + * @return mixed + */ + abstract protected function processData(mixed $cursor); + + /** + * This method is called once in every child process. It should return a cursor based on the input parameters + * to the subprocess command. + * + * @return mixed + */ + abstract protected function constructCursorFromInputOptions(): mixed; + + /** + * This method should return the command arguments that should be added when launching a new child process. It will + * typically be the arguments needed in order to construct the Cursor for the child process. + * + * @param mixed $cursor + * + * @return array + */ + abstract protected function addChildProcessArguments(mixed $cursor): array; + + /** + * The method should return true if the current process is a child process. This is typically detected using the + * custom command arguments used when launching a child proccess. + * + * @return bool + */ + abstract protected function isChildProcess(): bool; + + /** + * This is the method that is responsible for iterating over the dataset that is being processed and split it into + * chunks that can be processed by a child processes. In order to do that it will maintain a cursor and call + * createChildProcess() for each chunk. + */ + abstract protected function iterate(): void; + + /** + * This method is called when all data has been completed successfully. + */ + abstract protected function completed(): void; + + /** + * Anything written to standard output from the subprocess might be processed here + * Typically, you may print that on the console. + * + * @param string $output + */ + abstract protected function processIncrementalOutput(string $output): void; + + /** + * Anything written to standard error from the subprocess might be processed here + * Typically, you may print that on the console. + * + * @param string $output + */ + abstract protected function processIncrementalErrorOutput(string $output): void; + + public function isDryRun(): bool + { + return $this->dryRun; + } + + public function getMaxProcesses(): int + { + return $this->maxProcesses; + } + + public function getIterationCount(): int + { + return $this->iterationCount; + } + + protected function doFork(): bool + { + return $this->maxProcesses > 1; + } + + protected function waitForAvailableProcessSlot() + { + if (!$this->processSlotAvailable()) { + $this->waitForChild(); + } + } + + protected function processSlotAvailable(): bool + { + return \count($this->processes) < $this->getMaxProcesses(); + } + + private function waitForChild(): void + { + $childEnded = false; + while (!$childEnded) { + foreach ($this->processes as $pid => $p) { + $process = $p['process']; + $itemCount = $p['itemCount']; + + if (!$process->isRunning()) { + $this->processIncrementalOutput($process->getIncrementalOutput()); + $this->processIncrementalErrorOutput($process->getIncrementalErrorOutput()); + $childEnded = true; + $exitStatus = $process->getExitCode(); + if ($exitStatus !== 0) { + throw new RuntimeException(sprintf('Child process ended with status code %d. Terminating', $exitStatus)); + } + unset($this->processes[$pid]); + $this->advanceProgressBar($itemCount); + break; + } + $this->processIncrementalOutput($process->getIncrementalOutput()); + $this->processIncrementalErrorOutput($process->getIncrementalErrorOutput()); + } + if (!$childEnded) { + sleep(1); + } + } + } + + protected function waitForAllChildren(): void + { + while (count($this->processes) > 0) { + $this->waitForChild(); + } + $this->finishProgressBar(); + } + + protected function createChildProcess(mixed $cursor, int $itemCount) + { + if ($this->doFork()) { + $this->waitForAvailableProcessSlot(); + + $phpBinaryFinder = new PhpExecutableFinder(); + $phpBinaryPath = $phpBinaryFinder->find(); + + $arguments = [ + $phpBinaryPath, + 'bin/console', + $this->getName(), + "--user=$this->user", + ]; + + $arguments[] = '--env=' . $this->environment; + + if ($this->isDryRun()) { + $arguments[] = '--dry-run'; + } + if ($this->output->isVerbose()) { + $arguments[] = '-v'; + } elseif ($this->output->isVeryVerbose()) { + $arguments[] = '-vv'; + } elseif ($this->output->isDebug()) { + $arguments[] = '-vvv'; + } + + $arguments = array_merge($arguments, $this->addChildProcessArguments($cursor)); + + $process = new Process($arguments); + $process->start(); + $this->processes[$process->getPid()] = [ + 'process' => $process, + 'itemCount' => $itemCount, + ]; + } else { + $this->processData($cursor); + $this->advanceProgressBar($itemCount); + } + } + + private function startProgressBar() + { + if ($this->hasProgressBar) { + $this->progressBar = new ProgressBar($this->output, $this->getObjectCount()); + $this->progressBar->setFormat('very_verbose'); + $this->progressBar->start(); + } + } + + protected function advanceProgressBar($step) + { + if ($this->hasProgressBar) { + $this->progressBar->advance($step); + } + } + + protected function finishProgressBar() + { + if ($this->hasProgressBar) { + $this->progressBar->finish(); + } + } +} diff --git a/src/bundle/Command/MigrateNamespacesCommand.php b/src/bundle/Command/MigrateNamespacesCommand.php new file mode 100644 index 00000000..efb1a064 --- /dev/null +++ b/src/bundle/Command/MigrateNamespacesCommand.php @@ -0,0 +1,206 @@ +objectCount = null; + $this->convertDone = 0; + $this->convertSkipped = 0; + parent::__construct(null, $permissionResolver, $userService); + $this->gateway = $gateway; + } + + public function configure(): void + { + parent::configure(); + + $this + ->setName(self::$defaultName) + ->addOption( + 'cursor-start', + null, + InputOption::VALUE_REQUIRED, + 'Internal option - only used for subprocesses', + ) + ->addOption( + 'cursor-stop', + null, + InputOption::VALUE_REQUIRED, + 'Internal option - only used for subprocesses', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->cursorStart = $input->getOption('cursor-start') !== null ? (int) $input->getOption('cursor-start') : null; + $this->cursorStop = $input->getOption('cursor-stop') !== null ? (int) $input->getOption('cursor-stop') : null; + + // Check that both --cursor-start and cursor-start are set, or neither + if (($this->cursorStart === null) xor ($this->cursorStop === null)) { + throw new RuntimeException('The options --cursor-start and -cursor-stop are only for internal use !'); + } + + parent::execute($input, $output); + + return self::SUCCESS; + } + + protected function getObjectCount(): int + { + if ($this->objectCount === null) { + $this->output->writeln('Fetching number of objects to process. This might take several minutes if you have many records in ezcontentobject_attribute table.'); + $this->objectCount = $this->gateway->countRichtextAttributes(); + } + + return $this->objectCount; + } + + protected function iterate(): void + { + $limit = $this->getIterationCount(); + $cursor = [ + 'start' => -1, + 'stop' => null, + ]; + + $contentAttributeIDs = $this->gateway->getContentObjectAttributeIds($cursor['start'], $limit); + $cursor['stop'] = $this->getNextCursor($contentAttributeIDs); + while ($cursor['stop'] !== null) { + $this->createChildProcess($cursor, count($contentAttributeIDs)); + + $cursor['start'] = $cursor['stop']; + $contentAttributeIDs = $this->gateway->getContentObjectAttributeIds($cursor['start'], $limit); + $cursor['stop'] = $this->getNextCursor($contentAttributeIDs); + } + } + + protected function completed(): void + { + $this->output->writeln(PHP_EOL . 'Completed'); + $this->output->writeln("Converted $this->convertDone field attributes(s)"); + $this->output->writeln("Skipped $this->convertSkipped field attributes(s) which already had correct namespaces"); + } + + protected function getNextCursor(array $contentAttributeIDs): ?int + { + $lastId = count($contentAttributeIDs) > 0 ? end($contentAttributeIDs)['id'] : null; + + return $lastId; + } + + protected function processData($cursor) + { + $this->updateNamespacesInColumns($cursor['start'], $cursor['stop']); + if ($this->isChildProcess()) { + $this->output->writeln("Converted:$this->convertDone"); + $this->output->writeln("Skipped:$this->convertSkipped"); + } + } + + protected function constructCursorFromInputOptions(): mixed + { + return [ + 'start' => $this->cursorStart, + 'stop' => $this->cursorStop, + ]; + } + + protected function addChildProcessArguments($cursor): array + { + return [ + '--cursor-start=' . $cursor['start'], + '--cursor-stop=' . $cursor['stop'], + ]; + } + + protected function isChildProcess(): bool + { + return $this->cursorStart !== null || $this->cursorStop !== null; + } + + protected function processIncrementalOutput(string $output): void + { + if ($output !== '') { + $lines = explode(PHP_EOL, $output); + foreach ($lines as $line) { + if (strpos($line, 'Converted:') === 0) { + $this->convertDone += (int) substr($line, strpos($line, ':') + 1); + } elseif (strpos($line, 'Skipped:') === 0) { + $this->convertSkipped += (int) substr($line, strpos($line, ':') + 1); + } elseif ($line !== '') { + $this->output->writeln($line); + } + } + } + } + + protected function processIncrementalErrorOutput(string $output): void + { + $this->output->write($output); + } + + public static function migrateNamespaces(string $xmlText) + { + $xmlText = str_replace('xmlns:ezxhtml="http://ez.no/xmlns/ezpublish/docbook/xhtml"', 'xmlns:ezxhtml="http://ibexa.co/xmlns/dxp/docbook/xhtml"', $xmlText); + $xmlText = str_replace('xmlns:ezcustom="http://ez.no/xmlns/ezpublish/docbook/custom"', 'xmlns:ezcustom="http://ibexa.co/xmlns/dxp/docbook/custom"', $xmlText); + $xmlText = str_replace('ezxhtml:class="ez-embed-type-image"', 'ezxhtml:class="ibexa-embed-type-image"', $xmlText); + $xmlText = str_replace('xmlns:ez="http://ez.no/xmlns/ezpublish/docbook"', 'xmlns:ez="http://ibexa.co/xmlns/ezpublish/docbook"', $xmlText); + $xmlText = str_replace('xmlns:a="http://ez.no/xmlns/annotation"', 'xmlns:a="http://ibexa.co/xmlns/annotation"', $xmlText); + $xmlText = str_replace('xmlns:m="http://ez.no/xmlns/module"', 'xmlns:m="http://ibexa.co/xmlns/module"', $xmlText); + + return $xmlText; + } + + protected function updateNamespacesInColumns(int $contentAttributeIdStart, int $contentAttributeIdStop): void + { + $contentAttributes = $this->gateway->getContentObjectAttributes($contentAttributeIdStart, $contentAttributeIdStop); + + foreach ($contentAttributes as $contentAttribute) { + //$orgString = $contentAttribute['data_text']; + $newXml = self::migrateNamespaces($contentAttribute['data_text']); + + if ($newXml !== $contentAttribute['data_text']) { + ++$this->convertDone; + if (!$this->isDryRun()) { + $this->gateway->updateContentObjectAttribute($newXml, $contentAttribute['contentobject_id'], $contentAttribute['id'], $contentAttribute['version'], $contentAttribute['language_code']); + } + } else { + ++$this->convertSkipped; + } + } + } +} diff --git a/src/bundle/DependencyInjection/Compiler/RichTextHtml5ConverterPass.php b/src/bundle/DependencyInjection/Compiler/RichTextHtml5ConverterPass.php index 756310b4..f62d74be 100644 --- a/src/bundle/DependencyInjection/Compiler/RichTextHtml5ConverterPass.php +++ b/src/bundle/DependencyInjection/Compiler/RichTextHtml5ConverterPass.php @@ -37,6 +37,14 @@ public function process(ContainerBuilder $container) ); $this->setConverterDefinitions($taggedInputServiceIds, $html5InputConverterDefinition); } + + if ($container->hasDefinition('ibexa.richtext.converter.edit.xhtml5')) { + $html5EditConverterDefinition = $container->getDefinition('ibexa.richtext.converter.edit.xhtml5'); + $taggedInputServiceIds = $container->findTaggedServiceIds( + 'ibexa.field_type.richtext.converter.edit.xhtml5' + ); + $this->setConverterDefinitions($taggedInputServiceIds, $html5EditConverterDefinition); + } } /** diff --git a/src/bundle/Resources/config/fieldtype_services.yaml b/src/bundle/Resources/config/fieldtype_services.yaml index 39ec7f2f..20f8a76f 100644 --- a/src/bundle/Resources/config/fieldtype_services.yaml +++ b/src/bundle/Resources/config/fieldtype_services.yaml @@ -139,9 +139,31 @@ services: tags: - {name: ibexa.field_type.richtext.converter.input.xhtml5, priority: 10} + # Aggregate converter for XHTML5 edit that other converters register to + # through 'ibexa.field_type.richtext.converter.edit.xhtml5' service tag. + ibexa.richtext.converter.edit.xhtml5: + class: Ibexa\FieldTypeRichText\RichText\Converter\Aggregate + lazy: true + Ibexa\FieldTypeRichText\RichText\Converter\Html5Edit: class: Ibexa\FieldTypeRichText\RichText\Converter\Html5Edit arguments: - '%ibexa.field_type.richtext.converter.edit.xhtml5.resources%' - '@ibexa.config.resolver' + tags: + - {name: ibexa.field_type.richtext.converter.edit.xhtml5, priority: 100} + # Note: should run before xsl transformation + Ibexa\FieldTypeRichText\RichText\Converter\EzNoNamespace: + class: Ibexa\FieldTypeRichText\RichText\Converter\EzNoNamespace + tags: + - {name: ibexa.field_type.richtext.converter.output.xhtml5, priority: -15} + - {name: ibexa.field_type.richtext.converter.edit.xhtml5, priority: -15} + + Ibexa\Bundle\FieldTypeRichText\Command\MigrateNamespacesCommand: + autowire: true + autoconfigure: true + + Ibexa\FieldTypeRichText\Persistence\Legacy\ContentModelGateway: + arguments: + $connection: '@ibexa.persistence.connection' diff --git a/src/bundle/Resources/config/form.yaml b/src/bundle/Resources/config/form.yaml index ba37f313..0857678c 100644 --- a/src/bundle/Resources/config/form.yaml +++ b/src/bundle/Resources/config/form.yaml @@ -6,12 +6,12 @@ services: Ibexa\FieldTypeRichText\Form\Type\RichTextFieldType: arguments: - $docbookToXhtml5EditConverter: '@Ibexa\FieldTypeRichText\RichText\Converter\Html5Edit' + $docbookToXhtml5EditConverter: '@ibexa.richtext.converter.edit.xhtml5' $fieldTypeService: '@ibexa.api.service.field_type' Ibexa\FieldTypeRichText\Form\Type\RichTextType: arguments: - $docbookToXhtml5EditConverter: '@Ibexa\FieldTypeRichText\RichText\Converter\Html5Edit' + $docbookToXhtml5EditConverter: '@ibexa.richtext.converter.edit.xhtml5' Ibexa\FieldTypeRichText\Validator\Constraints\RichTextValidator: tags: diff --git a/src/bundle/Resources/config/rest.yaml b/src/bundle/Resources/config/rest.yaml index 7aae8148..573bbad0 100644 --- a/src/bundle/Resources/config/rest.yaml +++ b/src/bundle/Resources/config/rest.yaml @@ -6,6 +6,6 @@ services: Ibexa\FieldTypeRichText\REST\FieldTypeProcessor\RichTextProcessor: arguments: - - '@Ibexa\FieldTypeRichText\RichText\Converter\Html5Edit' + - '@ibexa.richtext.converter.edit.xhtml5' tags: - { name: ibexa.rest.field_type.processor, alias: ezrichtext } diff --git a/src/bundle/Resources/config/templating.yaml b/src/bundle/Resources/config/templating.yaml index 42d26faf..67d1f93b 100644 --- a/src/bundle/Resources/config/templating.yaml +++ b/src/bundle/Resources/config/templating.yaml @@ -7,7 +7,7 @@ services: Ibexa\Bundle\FieldTypeRichText\Templating\Twig\Extension\RichTextConverterExtension: arguments: $richTextOutputConverter: '@ibexa.richtext.converter.output.xhtml5' - $richTextEditConverter: '@Ibexa\FieldTypeRichText\RichText\Converter\Html5Edit' + $richTextEditConverter: '@ibexa.richtext.converter.edit.xhtml5' Ibexa\Bundle\FieldTypeRichText\Templating\Twig\Extension\YoutubeIdExtractorExtension: ~ diff --git a/src/lib/Persistence/Legacy/ContentModelGateway.php b/src/lib/Persistence/Legacy/ContentModelGateway.php new file mode 100644 index 00000000..aaab6739 --- /dev/null +++ b/src/lib/Persistence/Legacy/ContentModelGateway.php @@ -0,0 +1,147 @@ +connection = $connection; + } + + public function countRichtextAttributes(): int + { + $query = $this->connection->createQueryBuilder(); + $query->select('count(distinct a.id)') + ->from(self::DB_TABLE_CONTENTOBJECT_ATTRIBUTE, 'a') + ->where( + $query->expr()->eq( + 'a.data_type_string', + ':data_type' + ) + ) + ->setParameter(':data_type', self::FIELD_TYPE_IDENTIFIER); + + $statement = $query->execute(); + + return (int) $statement->fetchOne(); + } + + public function getContentObjectAttributeIds(int $startId, int $limit): array + { + $query = $this->connection->createQueryBuilder(); + $query->select('a.id') + ->from(self::DB_TABLE_CONTENTOBJECT_ATTRIBUTE, 'a') + ->where( + $query->expr()->eq( + 'a.data_type_string', + ':data_type' + ) + )->andWhere( + $query->expr()->gt( + 'a.id', + ':start_id' + ) + ) + ->groupBy('a.id') + ->orderBy('a.id') + ->setMaxResults($limit) + ->setParameter(':data_type', self::FIELD_TYPE_IDENTIFIER) + ->setParameter(':start_id', $startId); + + $statement = $query->execute(); + + return $statement->fetchAllAssociative(); + } + + public function getContentObjectAttributes(int $contentAttributeIdStart, int $contentAttributeIdStop): array + { + $query = $this->connection->createQueryBuilder(); + $query->select('a.id, a.version, a.contentobject_id, a.language_code, a.data_text') + ->from(self::DB_TABLE_CONTENTOBJECT_ATTRIBUTE, 'a') + ->where( + $query->expr()->eq( + 'a.data_type_string', + ':data_type' + ) + )->andWhere( + $query->expr()->gt( + 'a.id', + ':content_attribute_id_start' + ) + )->andWhere( + $query->expr()->lte( + 'a.id', + ':content_attribute_id_stop' + ) + ) + ->orderBy('a.id') + ->setParameter(':data_type', self::FIELD_TYPE_IDENTIFIER) + ->setParameter(':content_attribute_id_start', $contentAttributeIdStart) + ->setParameter(':content_attribute_id_stop', $contentAttributeIdStop); + + $statement = $query->execute(); + + return $statement->fetchAllAssociative(); + } + + public function updateContentObjectAttribute($xml, $contentId, $attributeId, $version, $languageCode) + { + $updateQuery = $this->connection->createQueryBuilder(); + $updateQuery->update('ezcontentobject_attribute') + ->set('data_text', ':newxml') + ->where( + $updateQuery->expr()->eq( + 'data_type_string', + ':datatypestring' + ) + ) + ->andWhere( + $updateQuery->expr()->eq( + 'contentobject_id', + ':contentId' + ) + ) + ->andWhere( + $updateQuery->expr()->eq( + 'id', + ':attributeid' + ) + ) + ->andWhere( + $updateQuery->expr()->eq( + 'version', + ':version' + ) + ) + ->andWhere( + $updateQuery->expr()->eq( + 'language_code', + ':languagecode' + ) + ) + ->setParameter(':newxml', $xml) + ->setParameter(':datatypestring', self::FIELD_TYPE_IDENTIFIER) + ->setParameter(':contentId', $contentId) + ->setParameter(':attributeid', $attributeId) + ->setParameter(':version', $version) + ->setParameter(':languagecode', $languageCode); + $updateQuery->execute(); + } +} diff --git a/src/lib/RichText/Converter/EzNoNamespace.php b/src/lib/RichText/Converter/EzNoNamespace.php new file mode 100644 index 00000000..0df928bc --- /dev/null +++ b/src/lib/RichText/Converter/EzNoNamespace.php @@ -0,0 +1,25 @@ +saveXML(); + $xml = MigrateNamespacesCommand::migrateNamespaces($xml); + $xmlDoc->loadXML($xml); + + return $xmlDoc; + } +} diff --git a/tests/bundle/Command/MigrateNamespacesCommandTest.php b/tests/bundle/Command/MigrateNamespacesCommandTest.php new file mode 100644 index 00000000..f009219c --- /dev/null +++ b/tests/bundle/Command/MigrateNamespacesCommandTest.php @@ -0,0 +1,124 @@ + +
+ + This is some text in custom_class with + something in bold + and the end. + +
+', + ' +
+ + This is some text in custom_class with + something in bold + and the end. + +
+', + ], + [ + ' +
+ + This is some text + + + + medium + + +
+', + ' +
+ + This is some text + + + + medium + + +
+', + ], + [ + ' +
+ + This is some text + +
+', + ' +
+ + This is some text + +
+', + ], + [ + ' +
+ + This is some text + +
+', + ' +
+ + This is some text + +
+', + ], + [ + ' +
+ + This is some text + +
+', + ' +
+ + This is some text + +
+', + ], + ]; + } + + /** + * @dataProvider providerMigrateNamespaces + */ + public function testMigrateNamespaces(string $xmlTextSource, string $xmlTestExpected): void + { + $migratedXml = MigrateNamespacesCommand::migrateNamespaces($xmlTextSource); + self::assertEquals($xmlTestExpected, $migratedXml, 'Docbook with ez.no namespaces was not converted correctly'); + } +} diff --git a/tests/lib/RichText/Converter/AggregateTest.php b/tests/lib/RichText/Converter/AggregateTest.php index 42e8a780..ee5683c4 100644 --- a/tests/lib/RichText/Converter/AggregateTest.php +++ b/tests/lib/RichText/Converter/AggregateTest.php @@ -14,8 +14,10 @@ use Ibexa\Contracts\FieldTypeRichText\RichText\RendererInterface; use Ibexa\Core\MVC\Symfony\Routing\UrlAliasRouter; use Ibexa\FieldTypeRichText\RichText\Converter\Aggregate; +use Ibexa\FieldTypeRichText\RichText\Converter\EzNoNamespace; use Ibexa\FieldTypeRichText\RichText\Converter\Link; use Ibexa\FieldTypeRichText\RichText\Converter\Render\Template; +use Ibexa\FieldTypeRichText\RichText\Converter\Xslt; use PHPUnit\Framework\TestCase; class AggregateTest extends TestCase @@ -97,6 +99,140 @@ public function providerConvertWithLinkInCustomTag(): array ], ]; } + + public function providerConvertDocbookToHtmlEditWithEznoNamespace(): array + { + return [ + [ + ' +
+ + This is some text in custom_class with + something in bold + and the end. + +
+', + ' +
+

+ This is some text in custom_class with + something in bold + and the end. +

+
+', + ], + [ + ' +
+ + This is some text + + + + medium + + +
+', + ' +
+

+ This is some text +

+
+ + medium + +
+
+', + ], + ]; + } + + /** + * @dataProvider providerConvertDocbookToHtmlEditWithEznoNamespace + */ + public function testConvertDocbookToHtmlEditWithEznoNamespace(string $input, string $expectedOutput): void + { + $xmlDocument = new DOMDocument(); + $xmlDocument->loadXML($input); + + $eznoNamespaceConverter = new EzNoNamespace(); + $xsltConverter = new Xslt( + __DIR__ . '/../../../../src/bundle/Resources/richtext/stylesheets/docbook/xhtml5/edit/xhtml5.xsl', + [ + [ + 'path' => __DIR__ . '/../../../../src/bundle/Resources/richtext/stylesheets/docbook/xhtml5/edit/core.xsl', + 'priority' => 100, + ], + ] + ); + + $aggregate = new Aggregate([$eznoNamespaceConverter, $xsltConverter]); + + $output = $aggregate->convert($xmlDocument); + + $expectedOutputDocument = new DOMDocument(); + $expectedOutputDocument->loadXML($expectedOutput); + $this->assertEquals($expectedOutputDocument, $output, 'Xml is not converted as expected'); + } + + public function providerConvertDocbookToHtmlOutputWithEznoNamespace(): array + { + return [ + [ + ' +
+ + This is some text in custom_class with + something in bold + and the end. + +
+', + ' +
+

+ This is some text in custom_class with + something in bold + and the end. +

+
+', + ], + ]; + } + + /** + * @dataProvider providerConvertDocbookToHtmlOutputWithEznoNamespace + */ + public function testConvertDocbookToHtmlOutputWithEznoNamespace(string $input, string $expectedOutput): void + { + $xmlDocument = new DOMDocument(); + $xmlDocument->loadXML($input); + + $eznoNamespaceConverter = new EzNoNamespace(); + $xsltConverter = new Xslt( + __DIR__ . '/../../../../src/bundle/Resources/richtext/stylesheets/docbook/xhtml5/output/xhtml5.xsl', + [ + [ + 'path' => __DIR__ . '/../../../../src/bundle/Resources/richtext/stylesheets/docbook/xhtml5/output/core.xsl', + 'priority' => 100, + ], + ] + ); + + $aggregate = new Aggregate([$eznoNamespaceConverter, $xsltConverter]); + + $output = $aggregate->convert($xmlDocument); + + $expectedOutputDocument = new DOMDocument(); + $expectedOutputDocument->loadXML($expectedOutput); + $this->assertEquals($expectedOutputDocument, $output, 'Xml is not converted as expected'); + } } class_alias(AggregateTest::class, 'EzSystems\Tests\EzPlatformRichText\RichText\Converter\AggregateTest');