diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-84718-AddCLIExportCommandToImpExpExtension.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-84718-AddCLIExportCommandToImpExpExtension.rst new file mode 100644 index 0000000000000000000000000000000000000000..a6e368bda13703b4425a0e14935b478a90a4034d --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/master/Feature-84718-AddCLIExportCommandToImpExpExtension.rst @@ -0,0 +1,62 @@ +.. include:: ../../Includes.txt + +====================================================== +Feature: #84718 - Add CLI export command to EXT:impexp +====================================================== + +See :issue:`84718` + +Description +=========== + +The new CLI command + +- :shell:`impexp:export` + +was added as the missing twin of the existing CLI command :shell:`impexp:import`. + +The export command can be executed via + +.. code-block:: bash + + typo3/sysext/core/bin/typo3 impexp:export [options] [--] [<filename>] + +and exports the entire TYPO3 page tree - or parts of it - to a data file of +format XML or T3D, which can be used for import into any TYPO3 instance or +as initial page tree of a :ref:`distribution <t3coreapi:distribution>`. + +The export can be fine-tuned through the complete set of options already +available in the export view of the TYPO3 backend: + +.. code-block:: bash + + Arguments: + filename The filename to export to (without file extension) + + Options: + --type[=TYPE] The file type (xml, t3d, t3d_compressed). [default: "xml"] + --pid[=PID] The root page of the exported page tree. [default: -1] + --levels[=LEVELS] The depth of the exported page tree. "-2": "Records on this page", "-1": "Expanded tree", "0": "This page", "1": "1 level down", .. "999": "Infinite levels". [default: 0] + --table[=TABLE] Include all records of this table. Examples: "_ALL", "tt_content", "sys_file_reference", etc. (multiple values allowed) + --record[=RECORD] Include this specific record. Pattern is "{table}:{record}". Examples: "tt_content:12", etc. (multiple values allowed) + --list[=LIST] Include the records of this table and this page. Pattern is "{table}:{pid}". Examples: "sys_language:0", etc. (multiple values allowed) + --includeRelated[=INCLUDERELATED] Include record relations to this table, including the related record. Examples: "_ALL", "sys_category", etc. (multiple values allowed) + --includeStatic[=INCLUDESTATIC] Include record relations to this table, excluding the related record. Examples: "_ALL", "sys_language", etc. (multiple values allowed) + --exclude[=EXCLUDE] Exclude this specific record. Pattern is "{table}:{record}". Examples: "fe_users:3", etc. (multiple values allowed) + --excludeDisabledRecords Exclude records which are handled as disabled by their TCA configuration, e.g. by fields "disabled", "starttime" or "endtime". + --excludeHtmlCss Exclude referenced HTML and CSS files. + --title[=TITLE] The meta title of the export. + --description[=DESCRIPTION] The meta description of the export. + --notes[=NOTES] The meta notes of the export. + --dependency[=DEPENDENCY] This TYPO3 extension is required for the exported records. Examples: "news", "powermail", etc. (multiple values allowed) + --saveFilesOutsideExportFile Save files into separate folder instead of including them into the common export file. Folder name pattern is "{filename}.files". + +Impact +====== + +Exporting a TYPO3 page tree without time limit is now possible via CLI. + +Repeated exports with the same configuration become easily documentable and +applicable - for example during distribution development. + +.. index:: CLI, ext:impexp diff --git a/typo3/sysext/impexp/Classes/Command/ExportCommand.php b/typo3/sysext/impexp/Classes/Command/ExportCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..e1504c64ee6b0d3be0ecbcb904dc5bf9cb928e3f --- /dev/null +++ b/typo3/sysext/impexp/Classes/Command/ExportCommand.php @@ -0,0 +1,212 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Impexp\Command; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use TYPO3\CMS\Core\Core\Bootstrap; +use TYPO3\CMS\Core\Utility\PathUtility; +use TYPO3\CMS\Impexp\Export; + +/** + * Command for exporting T3D/XML data files + */ +class ExportCommand extends Command +{ + protected Export $export; + + public function __construct(Export $export) + { + $this->export = $export; + parent::__construct(); + } + + /** + * Configure the command by defining the name, options and arguments + */ + protected function configure(): void + { + $this + ->addArgument( + 'filename', + InputArgument::OPTIONAL, + 'The filename to export to (without file extension)' + ) + ->addOption( + 'type', + null, + InputOption::VALUE_OPTIONAL, + 'The file type (xml, t3d, t3d_compressed).', + $this->export->getExportFileType() + ) + ->addOption( + 'pid', + null, + InputOption::VALUE_OPTIONAL, + 'The root page of the exported page tree.', + $this->export->getPid() + ) + ->addOption( + 'levels', + null, + InputOption::VALUE_OPTIONAL, + sprintf( + 'The depth of the exported page tree. ' . + '"%d": "Records on this page", ' . + '"%d": "Expanded tree", ' . + '"0": "This page", ' . + '"1": "1 level down", ' . + '.. ' . + '"%d": "Infinite levels".', + Export::LEVELS_RECORDS_ON_THIS_PAGE, + Export::LEVELS_EXPANDED_TREE, + Export::LEVELS_INFINITE + ), + $this->export->getLevels() + ) + ->addOption( + 'table', + null, + InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, + 'Include all records of this table. Examples: "_ALL", "tt_content", "sys_file_reference", etc.' + ) + ->addOption( + 'record', + null, + InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, + 'Include this specific record. Pattern is "{table}:{record}". Examples: "tt_content:12", etc.' + ) + ->addOption( + 'list', + null, + InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, + 'Include the records of this table and this page. Pattern is "{table}:{pid}". Examples: "sys_language:0", etc.' + ) + ->addOption( + 'includeRelated', + null, + InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, + 'Include record relations to this table, including the related record. Examples: "_ALL", "sys_category", etc.' + ) + ->addOption( + 'includeStatic', + null, + InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, + 'Include record relations to this table, excluding the related record. Examples: "_ALL", "sys_language", etc.' + ) + ->addOption( + 'exclude', + null, + InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, + 'Exclude this specific record. Pattern is "{table}:{record}". Examples: "fe_users:3", etc.' + ) + ->addOption( + 'excludeDisabledRecords', + null, + InputOption::VALUE_NONE, + 'Exclude records which are handled as disabled by their TCA configuration, e.g. by fields "disabled", "starttime" or "endtime".' + ) + ->addOption( + 'excludeHtmlCss', + null, + InputOption::VALUE_NONE, + 'Exclude referenced HTML and CSS files.' + ) + ->addOption( + 'title', + null, + InputOption::VALUE_OPTIONAL, + 'The meta title of the export.' + ) + ->addOption( + 'description', + null, + InputOption::VALUE_OPTIONAL, + 'The meta description of the export.' + ) + ->addOption( + 'notes', + null, + InputOption::VALUE_OPTIONAL, + 'The meta notes of the export.' + ) + ->addOption( + 'dependency', + null, + InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, + 'This TYPO3 extension is required for the exported records. Examples: "news", "powermail", etc.' + ) + ->addOption( + 'saveFilesOutsideExportFile', + null, + InputOption::VALUE_NONE, + 'Save files into separate folder instead of including them into the common export file. Folder name pattern is "{filename}.files".' + ) + ; + } + + /** + * Executes the command for exporting a t3d/xml file from the TYPO3 system + * + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + // Ensure the _cli_ user is authenticated + Bootstrap::initializeBackendAuthentication(); + + $io = new SymfonyStyle($input, $output); + + try { + $this->export->setExportFileName(PathUtility::basename((string)$input->getArgument('filename'))); + $this->export->setExportFileType((string)$input->getOption('type')); + $this->export->setPid((int)$input->getOption('pid')); + $this->export->setLevels((int)$input->getOption('levels')); + $this->export->setTables($input->getOption('table')); + $this->export->setRecord($input->getOption('record')); + $this->export->setList($input->getOption('list')); + $this->export->setRelOnlyTables($input->getOption('includeRelated')); + $this->export->setRelStaticTables($input->getOption('includeStatic')); + $this->export->setExcludeMap($input->getOption('exclude')); + $this->export->setExcludeDisabledRecords($input->getOption('excludeDisabledRecords')); + $this->export->setIncludeExtFileResources(!$input->getOption('excludeHtmlCss')); + $this->export->setTitle((string)$input->getOption('title')); + $this->export->setDescription((string)$input->getOption('description')); + $this->export->setNotes((string)$input->getOption('notes')); + $this->export->setExtensionDependencies($input->getOption('dependency')); + $this->export->setSaveFilesOutsideExportFile($input->getOption('saveFilesOutsideExportFile')); + $this->export->process(); + $saveFile = $this->export->saveToFile(); + $io->success('Exporting to ' . $saveFile->getPublicUrl() . ' succeeded.'); + return 0; + } catch (\Exception $e) { + $saveFolder = $this->export->getOrCreateDefaultImportExportFolder(); + $io->error('Exporting to ' . $saveFolder->getPublicUrl() . ' failed.'); + if ($io->isVerbose()) { + $io->writeln($e->getMessage()); + } + return 1; + } + } +} diff --git a/typo3/sysext/impexp/Classes/Command/ImportCommand.php b/typo3/sysext/impexp/Classes/Command/ImportCommand.php index 451a4f161c71d03ee82a3ae4bbf5ddb01c0e1a05..3f7a072660919850e0b46fb4c4490e8f4cf9ee7a 100644 --- a/typo3/sysext/impexp/Classes/Command/ImportCommand.php +++ b/typo3/sysext/impexp/Classes/Command/ImportCommand.php @@ -77,7 +77,7 @@ class ImportCommand extends Command ) ->addOption( 'importMode', - 'm', + null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, sprintf( 'Set the import mode of this specific record. ' . PHP_EOL . diff --git a/typo3/sysext/impexp/Configuration/Services.yaml b/typo3/sysext/impexp/Configuration/Services.yaml index b1355c96c3f755da1b42525608a8143026761b3e..ac086cfd3912f13e7897100e8d13b997bfee76dd 100644 --- a/typo3/sysext/impexp/Configuration/Services.yaml +++ b/typo3/sysext/impexp/Configuration/Services.yaml @@ -21,3 +21,9 @@ services: - name: 'console.command' command: 'impexp:import' description: 'Imports a T3D / XML file with content into a page tree' + + TYPO3\CMS\Impexp\Command\ExportCommand: + tags: + - name: 'console.command' + command: 'impexp:export' + description: 'Exports a T3D / XML file with content of a page tree' diff --git a/typo3/sysext/impexp/Tests/Functional/Command/ExportCommandTest.php b/typo3/sysext/impexp/Tests/Functional/Command/ExportCommandTest.php new file mode 100644 index 0000000000000000000000000000000000000000..788f51f8af6691e56dca9430a01dafcc4c73f7db --- /dev/null +++ b/typo3/sysext/impexp/Tests/Functional/Command/ExportCommandTest.php @@ -0,0 +1,114 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Impexp\Tests\Functional\Command; + +use Symfony\Component\Console\Tester\CommandTester; +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Impexp\Command\ExportCommand; +use TYPO3\CMS\Impexp\Export; +use TYPO3\CMS\Impexp\Tests\Functional\AbstractImportExportTestCase; + +/** + * Test case + */ +class ExportCommandTest extends AbstractImportExportTestCase +{ + /** + * @test + */ + public function exportCommandRequiresNoArguments(): void + { + $exportMock = $this->getAccessibleMock(Export::class, ['setMetaData']); + $tester = new CommandTester(new ExportCommand($exportMock)); + $tester->execute([], []); + + self::assertEquals(0, $tester->getStatusCode()); + } + + /** + * @test + */ + public function exportCommandSavesExportWithGivenFileName(): void + { + $fileName = 'empty_export'; + + $exportMock = $this->getAccessibleMock(Export::class, ['setMetaData']); + $tester = new CommandTester(new ExportCommand($exportMock)); + $tester->execute(['filename' => $fileName], []); + + preg_match('/([^\s]*importexport[^\s]*)/', $tester->getDisplay(), $display); + $filePath = Environment::getPublicPath() . '/' . $display[1]; + + self::assertEquals(0, $tester->getStatusCode()); + self::assertStringEndsWith('empty_export.xml', $filePath); + self::assertXmlFileEqualsXmlFile(__DIR__ . '/../Fixtures/XmlExports/empty.xml', $filePath); + } + + /** + * @test + */ + public function exportCommandPassesArgumentsToExportObject(): void + { + $input = [ + 'filename' => 'empty_export', + '--type' => Export::FILETYPE_T3D, + '--pid' => 123, + '--levels' => Export::LEVELS_RECORDS_ON_THIS_PAGE, + '--table' => ['tt_content'], + '--record' => ['sys_category:6'], + '--list' => ['sys_category:123'], + '--includeRelated' => ['be_users'], + '--includeStatic' => ['sys_language'], + '--exclude' => ['be_users:3'], + '--excludeDisabledRecords' => true, + '--excludeHtmlCss' => true, + '--title' => 'Export Command', + '--description' => 'The export which considers all arguments passed on the command line.', + '--notes' => 'This export is not for production use.', + '--dependency' => ['bootstrap_package'], + '--saveFilesOutsideExportFile' => true + ]; + + $exportMock = $this->getAccessibleMock(Export::class, [ + 'setExportFileType', 'setExportFileName', 'setPid', 'setLevels', 'setTables', 'setRecord', 'setList', + 'setRelOnlyTables', 'setRelStaticTables', 'setExcludeMap', 'setExcludeDisabledRecords', + 'setIncludeExtFileResources', 'setTitle', 'setDescription', 'setNotes', 'setExtensionDependencies', + 'setSaveFilesOutsideExportFile' + ]); + $exportMock->expects(self::once())->method('setExportFileName')->with(self::equalTo($input['filename'])); + $exportMock->expects(self::once())->method('setExportFileType')->with(self::equalTo($input['--type'])); + $exportMock->expects(self::once())->method('setPid')->with(self::equalTo($input['--pid'])); + $exportMock->expects(self::once())->method('setLevels')->with(self::equalTo($input['--levels'])); + $exportMock->expects(self::once())->method('setTables')->with(self::equalTo($input['--table'])); + $exportMock->expects(self::once())->method('setRecord')->with(self::equalTo($input['--record'])); + $exportMock->expects(self::once())->method('setList')->with(self::equalTo($input['--list'])); + $exportMock->expects(self::once())->method('setRelOnlyTables')->with(self::equalTo($input['--includeRelated'])); + $exportMock->expects(self::once())->method('setRelStaticTables')->with(self::equalTo($input['--includeStatic'])); + $exportMock->expects(self::once())->method('setExcludeMap')->with(self::equalTo($input['--exclude'])); + $exportMock->expects(self::once())->method('setExcludeDisabledRecords')->with(self::equalTo($input['--excludeDisabledRecords'])); + $exportMock->expects(self::once())->method('setIncludeExtFileResources')->with(self::equalTo(!$input['--excludeHtmlCss'])); + $exportMock->expects(self::once())->method('setTitle')->with(self::equalTo($input['--title'])); + $exportMock->expects(self::once())->method('setDescription')->with(self::equalTo($input['--description'])); + $exportMock->expects(self::once())->method('setNotes')->with(self::equalTo($input['--notes'])); + $exportMock->expects(self::once())->method('setExtensionDependencies')->with(self::equalTo($input['--dependency'])); + $exportMock->expects(self::once())->method('setSaveFilesOutsideExportFile')->with(self::equalTo($input['--saveFilesOutsideExportFile'])); + + $tester = new CommandTester(new ExportCommand($exportMock)); + $tester->execute($input); + } +}