From 3a4ca58ca19a2e291319b1e9148916c1987827b3 Mon Sep 17 00:00:00 2001 From: Sven Wappler Date: Mon, 10 Aug 2026 22:31:28 +0200 Subject: [PATCH] TYPO3 v15 dev-main snapshot () --- .gitignore | 1 + Classes/Command/ExportCommand.php | 205 ++ Classes/Command/ImportCommand.php | 158 ++ Classes/ContextMenu/ItemProvider.php | 123 ++ Classes/Controller/ExportController.php | 386 ++++ Classes/Controller/ImportController.php | 291 +++ .../Domain/Repository/PresetRepository.php | 183 ++ Classes/Event/BeforeImportEvent.php | 44 + Classes/Exception.php | 26 + Classes/Exception/ImportFailedException.php | 27 + .../InsufficientUserPermissionsException.php | 27 + .../Exception/LoadingFileFailedException.php | 27 + .../Exception/MalformedPresetException.php | 27 + .../PrerequisitesNotMetException.php | 27 + Classes/Exception/PresetNotFoundException.php | 27 + Classes/Export.php | 1337 +++++++++++++ Classes/Import.php | 1677 +++++++++++++++++ Classes/ImportExport.php | 1335 +++++++++++++ .../ImportContentOnPackageInitialization.php | 84 + ...eConfigurationsOnPackageInitialization.php | 115 ++ Classes/Utility/ImportExportUtility.php | 99 + Classes/View/ExportPageTreeView.php | 216 +++ Configuration/Backend/Routes.php | 16 + Configuration/Icons.php | 12 + Configuration/JavaScriptModules.php | 10 + Configuration/Services.yaml | 8 + Configuration/TCA/Overrides/pages.php | 13 + Configuration/TCA/Overrides/sys_template.php | 13 + Configuration/TCA/Overrides/tt_content.php | 13 + Configuration/TCA/tx_impexp_presets.php | 53 + .../CheckAndPerformImport.png | Bin 0 -> 165734 bytes .../CheckAndPerformImport.rst.txt | 7 + .../AutomaticScreenshots/CheckExport.png | Bin 0 -> 177190 bytes .../AutomaticScreenshots/CheckExport.rst.txt | 7 + .../AutomaticScreenshots/ConfigureExport.png | Bin 0 -> 133146 bytes .../ConfigureExport.rst.txt | 7 + .../AutomaticScreenshots/ConfigureImport.png | Bin 0 -> 78014 bytes .../ConfigureImport.rst.txt | 7 + .../ContextMenuExport.png | Bin 0 -> 117864 bytes .../ContextMenuExport.rst.txt | 7 + .../ContextMenuImport.png | Bin 0 -> 108578 bytes .../ContextMenuImport.rst.txt | 7 + .../AutomaticScreenshots/DownloadExport.png | Bin 0 -> 58855 bytes .../DownloadExport.rst.txt | 7 + .../Images/AutomaticScreenshots/ImpExp.png | Bin 0 -> 72742 bytes .../AutomaticScreenshots/ImpExp.rst.txt | 7 + .../Images/AutomaticScreenshots/Presets.png | Bin 0 -> 34015 bytes .../AutomaticScreenshots/Presets.rst.txt | 7 + .../SelectAdvancedExportOptions.png | Bin 0 -> 57955 bytes .../SelectAdvancedExportOptions.rst.txt | 7 + .../AutomaticScreenshots/UpdateContent.png | Bin 0 -> 77554 bytes .../UpdateContent.rst.txt | 7 + .../AutomaticScreenshots/UploadImport.png | Bin 0 -> 27078 bytes .../AutomaticScreenshots/UploadImport.rst.txt | 7 + .../Images/ManualScreenshots/ImpExpV3.8.png | Bin 0 -> 10216 bytes .../ManualScreenshots/ImpExpV3.8.rst.txt | 5 + .../ManualScreenshots/InstallActivate.png | Bin 0 -> 27669 bytes Documentation/Includes.rst.txt | 1 + Documentation/Index.rst | 53 + Documentation/Installation/Index.rst | 57 + Documentation/Introduction/Index.rst | 70 + Documentation/Security/Index.rst | 67 + Documentation/Sitemap.rst | 10 + Documentation/Usage/CommandLine.rst | 131 ++ Documentation/Usage/Export.rst | 128 ++ Documentation/Usage/Import.rst | 84 + Documentation/Usage/Index.rst | 33 + Documentation/Usage/Presets.rst | 69 + Documentation/Usage/Update.rst | 25 + Documentation/guides.xml | 21 + LICENSE.txt | 339 ++++ README.rst | 11 + Resources/Private/Language/db.xlf | 26 + Resources/Private/Language/locallang.xlf | 434 +++++ .../Export/AdvancedOptions.fluid.html | 85 + .../Partials/Export/Configuration.fluid.html | 227 +++ .../Private/Partials/Export/Save.fluid.html | 205 ++ .../Private/Partials/Import/Import.fluid.html | 213 +++ .../Partials/Import/MetaData.fluid.html | 132 ++ .../Private/Partials/Import/Upload.fluid.html | 65 + Resources/Private/Partials/Preview.fluid.html | 171 ++ Resources/Private/Templates/Export.fluid.html | 101 + Resources/Private/Templates/Import.fluid.html | 110 ++ Resources/Public/Icons/Extension.png | Bin 0 -> 1144 bytes .../Public/Icons/status-reference-hard.png | Bin 0 -> 788 bytes .../Public/Icons/status-reference-soft.png | Bin 0 -> 681 bytes .../Public/JavaScript/context-menu-actions.js | 13 + Resources/Public/JavaScript/import-export.js | 13 + composer.json | 56 + ext_tables.sql | 27 + 90 files changed, 9646 insertions(+) create mode 100644 .gitignore create mode 100644 Classes/Command/ExportCommand.php create mode 100644 Classes/Command/ImportCommand.php create mode 100644 Classes/ContextMenu/ItemProvider.php create mode 100644 Classes/Controller/ExportController.php create mode 100644 Classes/Controller/ImportController.php create mode 100644 Classes/Domain/Repository/PresetRepository.php create mode 100644 Classes/Event/BeforeImportEvent.php create mode 100644 Classes/Exception.php create mode 100644 Classes/Exception/ImportFailedException.php create mode 100644 Classes/Exception/InsufficientUserPermissionsException.php create mode 100644 Classes/Exception/LoadingFileFailedException.php create mode 100644 Classes/Exception/MalformedPresetException.php create mode 100644 Classes/Exception/PrerequisitesNotMetException.php create mode 100644 Classes/Exception/PresetNotFoundException.php create mode 100644 Classes/Export.php create mode 100644 Classes/Import.php create mode 100644 Classes/ImportExport.php create mode 100644 Classes/Initialization/ImportContentOnPackageInitialization.php create mode 100644 Classes/Initialization/ImportSiteConfigurationsOnPackageInitialization.php create mode 100644 Classes/Utility/ImportExportUtility.php create mode 100644 Classes/View/ExportPageTreeView.php create mode 100644 Configuration/Backend/Routes.php create mode 100644 Configuration/Icons.php create mode 100644 Configuration/JavaScriptModules.php create mode 100644 Configuration/Services.yaml create mode 100644 Configuration/TCA/Overrides/pages.php create mode 100644 Configuration/TCA/Overrides/sys_template.php create mode 100644 Configuration/TCA/Overrides/tt_content.php create mode 100644 Configuration/TCA/tx_impexp_presets.php create mode 100644 Documentation/Images/AutomaticScreenshots/CheckAndPerformImport.png create mode 100644 Documentation/Images/AutomaticScreenshots/CheckAndPerformImport.rst.txt create mode 100644 Documentation/Images/AutomaticScreenshots/CheckExport.png create mode 100644 Documentation/Images/AutomaticScreenshots/CheckExport.rst.txt create mode 100644 Documentation/Images/AutomaticScreenshots/ConfigureExport.png create mode 100644 Documentation/Images/AutomaticScreenshots/ConfigureExport.rst.txt create mode 100644 Documentation/Images/AutomaticScreenshots/ConfigureImport.png create mode 100644 Documentation/Images/AutomaticScreenshots/ConfigureImport.rst.txt create mode 100644 Documentation/Images/AutomaticScreenshots/ContextMenuExport.png create mode 100644 Documentation/Images/AutomaticScreenshots/ContextMenuExport.rst.txt create mode 100644 Documentation/Images/AutomaticScreenshots/ContextMenuImport.png create mode 100644 Documentation/Images/AutomaticScreenshots/ContextMenuImport.rst.txt create mode 100644 Documentation/Images/AutomaticScreenshots/DownloadExport.png create mode 100644 Documentation/Images/AutomaticScreenshots/DownloadExport.rst.txt create mode 100644 Documentation/Images/AutomaticScreenshots/ImpExp.png create mode 100644 Documentation/Images/AutomaticScreenshots/ImpExp.rst.txt create mode 100644 Documentation/Images/AutomaticScreenshots/Presets.png create mode 100644 Documentation/Images/AutomaticScreenshots/Presets.rst.txt create mode 100644 Documentation/Images/AutomaticScreenshots/SelectAdvancedExportOptions.png create mode 100644 Documentation/Images/AutomaticScreenshots/SelectAdvancedExportOptions.rst.txt create mode 100644 Documentation/Images/AutomaticScreenshots/UpdateContent.png create mode 100644 Documentation/Images/AutomaticScreenshots/UpdateContent.rst.txt create mode 100644 Documentation/Images/AutomaticScreenshots/UploadImport.png create mode 100644 Documentation/Images/AutomaticScreenshots/UploadImport.rst.txt create mode 100644 Documentation/Images/ManualScreenshots/ImpExpV3.8.png create mode 100644 Documentation/Images/ManualScreenshots/ImpExpV3.8.rst.txt create mode 100644 Documentation/Images/ManualScreenshots/InstallActivate.png create mode 100644 Documentation/Includes.rst.txt create mode 100644 Documentation/Index.rst create mode 100644 Documentation/Installation/Index.rst create mode 100644 Documentation/Introduction/Index.rst create mode 100644 Documentation/Security/Index.rst create mode 100644 Documentation/Sitemap.rst create mode 100644 Documentation/Usage/CommandLine.rst create mode 100644 Documentation/Usage/Export.rst create mode 100644 Documentation/Usage/Import.rst create mode 100644 Documentation/Usage/Index.rst create mode 100644 Documentation/Usage/Presets.rst create mode 100644 Documentation/Usage/Update.rst create mode 100644 Documentation/guides.xml create mode 100644 LICENSE.txt create mode 100644 README.rst create mode 100644 Resources/Private/Language/db.xlf create mode 100644 Resources/Private/Language/locallang.xlf create mode 100644 Resources/Private/Partials/Export/AdvancedOptions.fluid.html create mode 100644 Resources/Private/Partials/Export/Configuration.fluid.html create mode 100644 Resources/Private/Partials/Export/Save.fluid.html create mode 100644 Resources/Private/Partials/Import/Import.fluid.html create mode 100644 Resources/Private/Partials/Import/MetaData.fluid.html create mode 100644 Resources/Private/Partials/Import/Upload.fluid.html create mode 100644 Resources/Private/Partials/Preview.fluid.html create mode 100644 Resources/Private/Templates/Export.fluid.html create mode 100644 Resources/Private/Templates/Import.fluid.html create mode 100644 Resources/Public/Icons/Extension.png create mode 100644 Resources/Public/Icons/status-reference-hard.png create mode 100644 Resources/Public/Icons/status-reference-soft.png create mode 100644 Resources/Public/JavaScript/context-menu-actions.js create mode 100644 Resources/Public/JavaScript/import-export.js create mode 100644 composer.json create mode 100644 ext_tables.sql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57872d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/Classes/Command/ExportCommand.php b/Classes/Command/ExportCommand.php new file mode 100644 index 0000000..03bd833 --- /dev/null +++ b/Classes/Command/ExportCommand.php @@ -0,0 +1,205 @@ +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", ' + . '"0": "This page", ' + . '"1": "1 level down", ' + . '.. ' + . '"%d": "Infinite levels".', + Export::LEVELS_RECORDS_ON_THIS_PAGE, + 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: "be_users:0", etc.' + ) + ->addOption( + 'include-related', + null, + InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, + 'Include record relations to this table, including the related record. Examples: "_ALL", "sys_category", etc.' + ) + ->addOption( + 'include-static', + null, + InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, + 'Include record relations to this table, excluding the related record. Examples: "_ALL", "be_users", 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( + 'exclude-disabled-records', + null, + InputOption::VALUE_NONE, + 'Exclude records which are handled as disabled by their TCA configuration, e.g. by fields "disabled", "starttime" or "endtime".' + ) + ->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( + 'save-files-outside-export-file', + null, + InputOption::VALUE_NONE, + 'Save files into separate folder instead of including them into the common export file. Folder name pattern is "{filename}.files".' + ) + ->addOption( + 'include-site-configurations', + null, + InputOption::VALUE_NONE, + 'Include site configurations for exported root pages.' + ) + ; + } + + /** + * Executes the command for exporting a t3d/xml file from the TYPO3 system + */ + 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('include-related')); + $this->export->setRelStaticTables($input->getOption('include-static')); + $this->export->setExcludeMap(array_fill_keys($input->getOption('exclude'), 1)); + $this->export->setExcludeDisabledRecords($input->getOption('exclude-disabled-records')); + $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('save-files-outside-export-file')); + $this->export->setIncludeSiteConfigurations($input->getOption('include-site-configurations')); + $this->export->process(); + $saveFile = $this->export->saveToFile(); + $io->success('Exporting to ' . $saveFile->getPublicUrl() . ' succeeded.'); + return Command::SUCCESS; + } catch (\Exception $e) { + $saveFolder = $this->export->getOrCreateDefaultImportExportFolder(); + $io->error('Exporting to ' . $saveFolder->getPublicUrl() . ' failed.'); + if ($io->isVerbose()) { + $io->writeln($e->getMessage()); + } + return Command::FAILURE; + } + } +} diff --git a/Classes/Command/ImportCommand.php b/Classes/Command/ImportCommand.php new file mode 100644 index 0000000..a3619cd --- /dev/null +++ b/Classes/Command/ImportCommand.php @@ -0,0 +1,158 @@ +addArgument( + 'file', + InputArgument::REQUIRED, + 'The file path to import from (.t3d or .xml).' + ) + ->addArgument( + 'pid', + InputArgument::OPTIONAL, + 'The page to import to.', + 0 + ) + ->addOption( + 'update-records', + null, + InputOption::VALUE_NONE, + 'If set, existing records with the same UID will be updated instead of inserted.' + ) + ->addOption( + 'ignore-pid', + null, + InputOption::VALUE_NONE, + 'If set, page IDs of updated records are not corrected (only works in conjunction with --update-records).' + ) + ->addOption( + 'force-uid', + null, + InputOption::VALUE_NONE, + 'If set, UIDs from file will be forced.' + ) + ->addOption( + 'import-mode', + null, + InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, + sprintf( + 'Set the import mode of this specific record. ' . PHP_EOL + . 'Pattern is "{table}:{record}={mode}". ' . PHP_EOL + . 'Available modes for new records are "%1$s" and "%3$s" ' + . 'and for existing records "%2$s", "%4$s", "%5$s" and "%3$s".' . PHP_EOL + . 'Examples are "pages:987=%1$s", "tt_content:1=%2$s", etc.', + Import::IMPORT_MODE_FORCE_UID, + Import::IMPORT_MODE_AS_NEW, + Import::IMPORT_MODE_EXCLUDE, + Import::IMPORT_MODE_IGNORE_PID, + Import::IMPORT_MODE_RESPECT_PID + ) + ) + ->addOption( + 'enable-log', + null, + InputOption::VALUE_NONE, + 'If set, all database actions are logged.' + ) + ; + } + + /** + * Executes the command for importing a t3d/xml file into the TYPO3 system + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + // Ensure the _cli_ user is authenticated + Bootstrap::initializeBackendAuthentication(); + + $io = new SymfonyStyle($input, $output); + + try { + $this->import->setPid((int)$input->getArgument('pid')); + $this->import->setUpdate($input->getOption('update-records')); + $this->import->setGlobalIgnorePid($input->getOption('ignore-pid')); + $this->import->setForceAllUids($input->getOption('force-uid')); + $this->import->setEnableLogging($input->getOption('enable-log')); + $this->import->setImportMode($this->parseAssociativeArray($input, 'import-mode', '=')); + $this->import->loadFile((string)$input->getArgument('file')); + $this->import->checkImportPrerequisites(); + $this->import->importData(); + $io->success('Importing ' . $input->getArgument('file') . ' to page ' . $input->getArgument('pid') . ' succeeded.'); + return Command::SUCCESS; + } catch (\Exception $e) { + // Since impexp triggers core and DataHandler with potential hooks, and exception could come from "everywhere". + $io->error('Importing ' . $input->getArgument('file') . ' to page ' . $input->getArgument('pid') . ' failed.'); + if ($io->isVerbose()) { + $io->writeln($e->getMessage()); + $io->writeln($this->import->getErrorLog()); + } + return Command::FAILURE; + } + } + + /** + * Parse a basic commandline option array into an associative array by splitting each entry into a key part and + * a value part using a specific separator. + */ + protected function parseAssociativeArray(InputInterface &$input, string $optionName, string $separator): array + { + $array = []; + + foreach ($input->getOption($optionName) as &$value) { + $parts = GeneralUtility::trimExplode($separator, $value, true, 2); + if (count($parts) === 2) { + $array[$parts[0]] = $parts[1]; + } else { + throw new \InvalidArgumentException( + sprintf('Command line option "%s" has invalid entry "%s".', $optionName, $value), + 1610464090 + ); + } + } + + return $array; + } +} diff --git a/Classes/ContextMenu/ItemProvider.php b/Classes/ContextMenu/ItemProvider.php new file mode 100644 index 0000000..e54f067 --- /dev/null +++ b/Classes/ContextMenu/ItemProvider.php @@ -0,0 +1,123 @@ + [ + 'type' => 'item', + 'label' => 'LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:export', + 'iconIdentifier' => 'actions-document-export-t3d', + 'callbackAction' => 'exportT3d', + ], + 'importT3d' => [ + 'type' => 'item', + 'label' => 'LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:import', + 'iconIdentifier' => 'actions-document-import-t3d', + 'callbackAction' => 'importT3d', + ], + ]; + + public function __construct( + private readonly UriBuilder $uriBuilder, + ) { + parent::__construct(); + } + + /** + * Export item is added for all database records except files + */ + public function canHandle(): bool + { + return $this->table !== 'sys_file'; + } + + /** + * This needs to be lower than priority of the RecordProvider + */ + public function getPriority(): int + { + return 50; + } + + /** + * Adds import/export items to the "submenu" if available + */ + public function addItems(array $items): array + { + $this->initDisabledItems(); + $localItems = $this->prepareItems($this->itemsConfiguration); + if (isset($items['more']['childItems'])) { + $items['more']['childItems'] = $items['more']['childItems'] + $localItems; + } else { + $items += $localItems; + } + return $items; + } + + protected function canRender(string $itemName, string $type): bool + { + if (in_array($itemName, $this->disabledItems, true)) { + return false; + } + $canRender = false; + switch ($itemName) { + case 'exportT3d': + $canRender = $this->backendUser->isExportEnabled(); + break; + case 'importT3d': + $canRender = $this->table === 'pages' && $this->backendUser->isImportEnabled(); + break; + } + return $canRender; + } + + /** + * Registers custom JS module with item onclick behaviour + */ + protected function getAdditionalAttributes(string $itemName): array + { + $attributes = [ + 'data-callback-module' => '@typo3/impexp/context-menu-actions', + ]; + + // Add action url for items + switch ($itemName) { + case 'exportT3d': + $attributes['data-action-url'] = (string)$this->uriBuilder->buildUriFromRoute('tx_impexp_export'); + break; + case 'importT3d': + $attributes['data-action-url'] = (string)$this->uriBuilder->buildUriFromRoute('tx_impexp_import'); + break; + } + + return $attributes; + } +} diff --git a/Classes/Controller/ExportController.php b/Classes/Controller/ExportController.php new file mode 100644 index 0000000..eaf5504 --- /dev/null +++ b/Classes/Controller/ExportController.php @@ -0,0 +1,386 @@ + 1, + 'preset' => [], + 'external_static' => [ + 'tables' => [], + ], + 'external_ref' => [ + 'tables' => [], + ], + 'pagetree' => [ + 'tables' => [], + ], + 'extension_dep' => [], + 'meta' => [ + 'title' => '', + 'description' => '', + 'notes' => '', + ], + 'record' => [], + 'list' => [], + 'includeSiteConfigurations' => 0, + ]; + + public function __construct( + protected readonly IconFactory $iconFactory, + protected readonly ModuleTemplateFactory $moduleTemplateFactory, + protected readonly ResponseFactoryInterface $responseFactory, + protected readonly PresetRepository $presetRepository, + protected readonly TcaSchemaFactory $tcaSchemaFactory, + ) {} + + public function handleRequest(ServerRequestInterface $request): ResponseInterface + { + if ($this->getBackendUser()->isExportEnabled() === false) { + throw new \RuntimeException( + 'Export module is disabled for non admin users and ' + . 'user TSconfig options.impexp.enableExportForNonAdminUser is not enabled.', + 1636901978 + ); + } + + $backendUser = $this->getBackendUser(); + $queryParams = $request->getQueryParams(); + $parsedBody = $request->getParsedBody(); + + $id = (int)($parsedBody['id'] ?? $queryParams['id'] ?? 0); + $permsClause = $backendUser->getPagePermsClause(Permission::PAGE_SHOW); + $pageInfo = BackendUtility::readPageAccess($id, $permsClause) ?: []; + if ($pageInfo === []) { + throw new \RuntimeException("You don't have access to this page.", 1604308206); + } + + // @todo: Only small parts of tx_impexp can be hand over as GET, e.g. ['list'] and 'id', drop GET of everything else. + // Also, there's a clash with id: it can be ['list']'table:id', it can be 'id', it can be tx_impexp['id']. This + // should be de-messed somehow. + $inputDataFromGetPost = $parsedBody['tx_impexp'] ?? $queryParams['tx_impexp'] ?? []; + $inputData = $this->defaultInputData; + ArrayUtility::mergeRecursiveWithOverrule($inputData, $inputDataFromGetPost); + if ($inputData['resetExclude'] ?? false) { + $inputData['exclude'] = []; + } + $inputData['preset']['public'] = (int)($inputData['preset']['public'] ?? 0); + + $view = $this->moduleTemplateFactory->create($request); + + $presetAction = $parsedBody['preset'] ?? []; + $inputData = $this->processPresets($view, $presetAction, $inputData); + + $export = $this->configureExportFromFormData($inputData); + $export->process(); + + if ($inputData['download_export'] ?? false) { + return $this->getDownload($export); + } + $saveFolder = $export->getOrCreateDefaultImportExportFolder(); + if (($inputData['save_export'] ?? false) && $saveFolder !== null) { + $this->saveExportToFile($view, $export, $saveFolder); + } + $inputData['filename'] = $export->getExportFileName(); + + $view->assignMultiple([ + 'id' => $id, + 'errors' => $export->getErrorLog(), + 'preview' => $export->renderPreview(), + 'tableSelectOptions' => $this->getTableSelectOptions(['pages']), + 'treeHTML' => $export->getTreeHTML(), + 'levelSelectOptions' => $this->getPageLevelSelectOptions($inputData), + 'records' => $this->getRecordSelectOptions($inputData), + 'tableList' => $this->getSelectableTableList($inputData), + 'externalReferenceTableSelectOptions' => $this->getTableSelectOptions(), + 'externalStaticTableSelectOptions' => $this->getTableSelectOptions(), + 'presetSelectOptions' => $this->presetRepository->getPresets($id), + 'fileName' => '', + 'filetypeSelectOptions' => $this->getFileSelectOptions($export), + 'saveFolder' => $saveFolder?->getPublicUrl() ?? '', + 'hasSaveFolder' => true, + 'extensions' => $this->getExtensionList(), + 'inData' => $inputData, + 'isAdmin' => $backendUser->isAdmin(), + ]); + $view->setModuleName(''); + $view->getDocHeaderComponent()->setPageBreadcrumb($pageInfo); + return $view->renderResponse('Export'); + } + + protected function processPresets(ModuleTemplate $view, array $presetAction, array $inputData): array + { + if (empty($presetAction)) { + return $inputData; + } + $presetUid = (int)$presetAction['select']; + try { + if (isset($presetAction['save'])) { + if ($presetUid > 0) { + // Update existing + $this->presetRepository->updatePreset($presetUid, $inputData); + $view->addFlashMessage('Preset #' . $presetUid . ' saved!', 'Presets', ContextualFeedbackSeverity::INFO); + } else { + // Insert new + $this->presetRepository->createPreset($inputData); + $view->addFlashMessage('New preset "' . $inputData['preset']['title'] . '" is created', 'Presets', ContextualFeedbackSeverity::INFO); + } + } + if (isset($presetAction['delete'])) { + if ($presetUid > 0) { + $this->presetRepository->deletePreset($presetUid); + $view->addFlashMessage('Preset #' . $presetUid . ' deleted!', 'Presets', ContextualFeedbackSeverity::INFO); + } else { + $view->addFlashMessage('ERROR: No preset selected for deletion.', 'Presets', ContextualFeedbackSeverity::ERROR); + } + } + if (isset($presetAction['load']) || isset($presetAction['merge'])) { + if ($presetUid > 0) { + $presetData = $this->presetRepository->loadPreset($presetUid); + if (isset($presetAction['merge'])) { + // Merge records + if (is_array($presetData['record'] ?? null)) { + $inputData['record'] = array_merge((array)$inputData['record'], $presetData['record']); + } + // Merge lists + if (is_array($presetData['list'] ?? null)) { + $inputData['list'] = array_merge((array)$inputData['list'], $presetData['list']); + } + $view->addFlashMessage('Preset #' . $presetUid . ' merged!', 'Presets', ContextualFeedbackSeverity::INFO); + } else { + $inputData = $presetData; + $view->addFlashMessage('Preset #' . $presetUid . ' loaded!', 'Presets', ContextualFeedbackSeverity::INFO); + } + } else { + $view->addFlashMessage('ERROR: No preset selected for loading.', 'Presets', ContextualFeedbackSeverity::ERROR); + } + } + } catch (PresetNotFoundException|InsufficientUserPermissionsException|MalformedPresetException $e) { + $view->addFlashMessage($e->getMessage(), 'Presets', ContextualFeedbackSeverity::ERROR); + } + return $inputData; + } + + protected function configureExportFromFormData(array $inputData): Export + { + $export = GeneralUtility::makeInstance(Export::class); + $export->setExcludeMap((array)($inputData['exclude'] ?? [])); + $export->setSoftrefCfg((array)($inputData['softrefCfg'] ?? [])); + $export->setExtensionDependencies((($inputData['extension_dep'] ?? '') === '') ? [] : (array)$inputData['extension_dep']); + $export->setShowStaticRelations((bool)($inputData['showStaticRelations'] ?? false)); + $export->setExcludeDisabledRecords((bool)($inputData['excludeDisabled'] ?? false)); + if (!empty($inputData['filetype'])) { + $export->setExportFileType((string)$inputData['filetype']); + } + $export->setExportFileName((string)($inputData['filename'] ?? '')); + $export->setRelStaticTables((($inputData['external_static']['tables'] ?? '') === '') ? [] : (array)$inputData['external_static']['tables']); + $export->setRelOnlyTables((($inputData['external_ref']['tables'] ?? '') === '') ? [] : (array)$inputData['external_ref']['tables']); + if (isset($inputData['save_export'], $inputData['saveFilesOutsideExportFile']) && $inputData['saveFilesOutsideExportFile'] === '1') { + $export->setSaveFilesOutsideExportFile(true); + } + if ($this->getBackendUser()->isAdmin()) { + $export->setIncludeSiteConfigurations((bool)($inputData['includeSiteConfigurations'] ?? false)); + } + $export->setTitle((string)($inputData['meta']['title'] ?? '')); + $export->setDescription((string)($inputData['meta']['description'] ?? '')); + $export->setNotes((string)($inputData['meta']['notes'] ?? '')); + $export->setRecord((($inputData['record'] ?? '') === '') ? [] : (array)$inputData['record']); + $export->setList((($inputData['list'] ?? '') === '') ? [] : (array)$inputData['list']); + if (MathUtility::canBeInterpretedAsInteger($inputData['pagetree']['id'] ?? null)) { + $export->setPid((int)$inputData['pagetree']['id']); + } + if (MathUtility::canBeInterpretedAsInteger($inputData['pagetree']['levels'] ?? null)) { + $export->setLevels((int)$inputData['pagetree']['levels']); + } + $export->setTables((($inputData['pagetree']['tables'] ?? '') === '') ? [] : (array)$inputData['pagetree']['tables']); + return $export; + } + + protected function getDownload(Export $export): ResponseInterface + { + $fileName = $export->getOrGenerateExportFileNameWithFileExtension(); + $fileContent = $export->render(); + $response = $this->responseFactory->createResponse() + ->withHeader('Content-Type', 'application/octet-stream') + ->withHeader('Content-Length', (string)strlen($fileContent)) + ->withHeader('Content-Disposition', 'attachment; filename=' . PathUtility::basename($fileName)); + $response->getBody()->write($export->render()); + return $response; + } + + protected function saveExportToFile(ModuleTemplate $view, Export $export, Folder $saveFolder): void + { + $languageService = $this->getLanguageService(); + try { + $saveFile = $export->saveToFile(); + $saveFileSize = $saveFile->getProperty('size'); + $view->addFlashMessage( + sprintf($languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:exportdata_savedInSBytes'), $saveFile->getPublicUrl(), GeneralUtility::formatSize($saveFileSize)), + $languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:exportdata_savedFile') + ); + } catch (CoreException $e) { + $view->addFlashMessage( + sprintf($languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:exportdata_badPathS'), $saveFolder->getPublicUrl()), + $languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:exportdata_problemsSavingFile'), + ContextualFeedbackSeverity::ERROR + ); + } + } + + protected function getPageLevelSelectOptions(array $inputData): array + { + $languageService = $this->getLanguageService(); + $options = []; + if (MathUtility::canBeInterpretedAsInteger($inputData['pagetree']['id'] ?? '')) { + $options = [ + Export::LEVELS_RECORDS_ON_THIS_PAGE => $languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:makeconfig_tablesOnThisPage'), + 0 => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.depth_0'), + 1 => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.depth_1'), + 2 => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.depth_2'), + 3 => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.depth_3'), + 4 => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.depth_4'), + Export::LEVELS_INFINITE => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.depth_infi'), + ]; + } + return $options; + } + + protected function getRecordSelectOptions(array $inputData): array + { + $records = []; + foreach ($inputData['record'] ?? [] as $tableNameColonUid) { + [$tableName, $recordUid] = explode(':', $tableNameColonUid); + if ($record = BackendUtility::getRecordWSOL((string)$tableName, (int)$recordUid)) { + $records[] = [ + 'icon' => $this->iconFactory->getIconForRecord($tableName, $record, IconSize::SMALL)->render(), + 'title' => BackendUtility::getRecordTitle($tableName, $record, true), + 'tableName' => $tableName, + 'recordUid' => $recordUid, + ]; + } + } + return $records; + } + + protected function getSelectableTableList(array $inputData): array + { + $backendUser = $this->getBackendUser(); + $languageService = $this->getLanguageService(); + $tableList = []; + foreach ($inputData['list'] ?? [] as $reference) { + $referenceParts = explode(':', $reference); + $tableName = $referenceParts[0]; + if ($backendUser->check('tables_select', $tableName)) { + // If the page is actually the root, handle it differently. + // NOTE: we don't compare integers, because the number comes from the split string above + if ($referenceParts[1] === '0') { + $iconAndTitle = $this->iconFactory->getIcon('apps-pagetree-root', IconSize::SMALL)->render() . $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename']; + } else { + $record = BackendUtility::getRecordWSOL('pages', (int)$referenceParts[1]); + $iconAndTitle = $this->iconFactory->getIconForRecord('pages', $record, IconSize::SMALL)->render() + . BackendUtility::getRecordTitle('pages', $record, true); + } + $tableList[] = [ + 'iconAndTitle' => sprintf($languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:makeconfig_tableListEntry'), $tableName, $iconAndTitle), + 'reference' => $reference, + ]; + } + } + return $tableList; + } + + protected function getExtensionList(): array + { + $loadedExtensions = ExtensionManagementUtility::getLoadedExtensionListArray(); + return array_combine($loadedExtensions, $loadedExtensions); + } + + protected function getFileSelectOptions(Export $export): array + { + $languageService = $this->getLanguageService(); + $fileTypeOptions = []; + foreach ($export->getSupportedFileTypes() as $supportedFileType) { + $fileTypeOptions[$supportedFileType] = $languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:makesavefo_' . $supportedFileType); + } + return $fileTypeOptions; + } + + /** + * Get a list of all exportable tables - basically all TCA tables. Blacklist some if wanted. + * Returned array keys are table names, values are "translations". + */ + protected function getTableSelectOptions(array $excludeList = []): array + { + $languageService = $this->getLanguageService(); + $backendUser = $this->getBackendUser(); + $options = [ + '_ALL' => $languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:ALL_tables'), + ]; + foreach ($this->tcaSchemaFactory->all()->getNames() as $table) { + if (!in_array($table, $excludeList, true) && $backendUser->check('tables_select', $table)) { + $options[$table] = $table; + } + } + natsort($options); + return $options; + } + + protected function getBackendUser(): BackendUserAuthentication + { + return $GLOBALS['BE_USER']; + } + + protected function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/Classes/Controller/ImportController.php b/Classes/Controller/ImportController.php new file mode 100644 index 0000000..483a08e --- /dev/null +++ b/Classes/Controller/ImportController.php @@ -0,0 +1,291 @@ +getBackendUser()->isImportEnabled()) { + throw new \RuntimeException( + 'Import module is disabled for non admin users and user TSconfig options.impexp.enableImportForNonAdminUser is not enabled.', + 1464435459 + ); + } + + $backendUser = $this->getBackendUser(); + $languageService = $this->getLanguageService(); + $queryParams = $request->getQueryParams(); + $parsedBody = $request->getParsedBody(); + + $id = (int)($parsedBody['id'] ?? $queryParams['id'] ?? 0); + $permsClause = $backendUser->getPagePermsClause(Permission::PAGE_SHOW); + $pageInfo = BackendUtility::readPageAccess($id, $permsClause) ?: []; + if ($pageInfo === []) { + throw new \RuntimeException("You don't have access to this page.", 1604308205); + } + + $inputData = $request->getParsedBody()['tx_impexp'] ?? $request->getQueryParams()['tx_impexp'] ?? []; + if ($inputData['new_import'] ?? false) { + unset($inputData['import_mode']); + } + + $view = $this->moduleTemplateFactory->create($request); + + $import = GeneralUtility::makeInstance(Import::class); + $import->setPid($id); + // Resolve the upload destination server-side. The import upload target is pinned to this + // folder and must never be taken from the (client-controlled) request body. + $importFolder = $import->getOrCreateDefaultImportExportFolder(); + + $uploadStatus = self::NO_UPLOAD; + $uploadedFileName = ''; + if ($request->getMethod() === 'POST' && empty($parsedBody)) { + // This happens if the post request was larger than allowed on the server. + $view->addFlashMessage( + $languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:importdata_upload_nodata'), + $languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:importdata_upload_error'), + ContextualFeedbackSeverity::ERROR + ); + } + if ($request->getMethod() === 'POST' && isset($parsedBody['_upload'])) { + $uploadStatus = self::UPLOAD_FAILED; + $file = $this->handleFileUpload($request, $importFolder, $view); + if ($file !== null) { + $inputData['file'] = $file->getCombinedIdentifier(); + $uploadStatus = self::UPLOAD_DONE; + $uploadedFileName = $file->getName(); + } + } + + $this->configureImportFromFormDataAndImportIfRequested($view, $import, $inputData); + + if (!$this->getBackendUser()->isAdmin() + && $import->getSiteConfigurations() !== [] + ) { + $view->addFlashMessage( + $languageService->translate('importdata_siteConfigurationsAdminOnly', 'impexp.messages'), + $languageService->translate('importdata_siteConfigurations', 'impexp.messages'), + ContextualFeedbackSeverity::WARNING + ); + } + + $view->assignMultiple([ + 'importFolder' => $importFolder?->getCombinedIdentifier() ?? '', + 'import' => $import, + 'errors' => $import->getErrorLog(), + 'preview' => $import->renderPreview(), + 'id' => $id, + 'fileSelectOptions' => $this->getSelectableFileList($import), + 'inData' => $inputData, + 'isAdmin' => $this->getBackendUser()->isAdmin(), + 'uploadedFile' => $uploadedFileName, + 'uploadStatus' => $uploadStatus, + 'allowedUploadExtensionList' => self::ALLOWED_UPLOAD_EXTENSION_LIST, + ]); + $view->setModuleName(''); + $view->getDocHeaderComponent()->setPageBreadcrumb($pageInfo); + if ((int)($pageInfo['uid'] ?? 0) > 0) { + $view->addButtonToButtonBar($this->componentFactory->createViewButton(PreviewUriBuilder::create($pageInfo) + ->withRootLine(BackendUtility::BEgetRootLine($pageInfo['uid'])) + ->buildDispatcherDataAttributes() ?? [])); + } + return $view->renderResponse('Import'); + } + + protected function handleFileUpload(ServerRequestInterface $request, ?Folder $importFolder, ModuleTemplate $view): ?File + { + if ($importFolder === null) { + return null; + } + // Reject any file that is not an import file before it is written to storage. The import + // upload must never be used to place arbitrary file types in a (potentially public) storage. + $uploadedFile = $request->getUploadedFiles()['upload_1'] ?? null; + if (!$uploadedFile instanceof UploadedFileInterface) { + return null; + } + $uploadExtension = strtolower(pathinfo((string)$uploadedFile->getClientFilename(), PATHINFO_EXTENSION)); + if (!in_array($uploadExtension, self::ALLOWED_UPLOAD_EXTENSIONS, true)) { + $view->addFlashMessage( + $this->getLanguageService()->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:importdata_upload_invalidExtension'), + $this->getLanguageService()->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:importdata_upload_error'), + ContextualFeedbackSeverity::ERROR + ); + return null; + } + $parsedBody = $request->getParsedBody() ?? []; + $conflictMode = empty($parsedBody['overwriteExistingFiles']) ? DuplicationBehavior::CANCEL : DuplicationBehavior::REPLACE; + // The upload target is pinned to the import/export folder resolved server-side and must + // not be taken from the (client-controlled) request body, otherwise an uploaded file + // could be redirected to an arbitrary, potentially publicly accessible, storage location. + $fileCommands = [ + 'upload' => [ + 1 => [ + 'target' => $importFolder->getCombinedIdentifier(), + 'data' => '1', + ], + ], + ]; + $this->fileProcessor->setActionPermissions(); + $this->fileProcessor->setExistingFilesConflictMode($conflictMode); + $this->fileProcessor->start($fileCommands, $request->getUploadedFiles()); + $result = $this->fileProcessor->processData(); + // If upload went well, set the new file as the import file. + return $result['upload'][0][0] ?? null; + } + + /** + * @throws \BadFunctionCallException + * @throws \InvalidArgumentException + * @throws \RuntimeException + */ + protected function configureImportFromFormDataAndImportIfRequested(ModuleTemplate $view, Import $import, array $inputData): void + { + $import->setUpdate((bool)($inputData['do_update'] ?? false)); + $import->setImportMode((array)($inputData['import_mode'] ?? null)); + $import->setEnableLogging((bool)($inputData['enableLogging'] ?? false)); + $import->setGlobalIgnorePid((bool)($inputData['global_ignore_pid'] ?? false)); + $import->setForceAllUids((bool)($inputData['force_all_UIDS'] ?? false)); + $import->setShowDiff(!(bool)($inputData['notShowDiff'] ?? false)); + $import->setSoftrefInputValues((array)($inputData['softrefInputValues'] ?? null)); + if (!empty($inputData['file'])) { + if (PathUtility::isExtensionPath($inputData['file'])) { + $filePath = $inputData['file']; + } else { + $filePath = $this->getFilePathWithinFileMountBoundaries((string)$inputData['file']); + } + try { + $import->loadFile($filePath); + $import->checkImportPrerequisites(); + if ($inputData['import_file'] ?? false) { + $import->importData(); + BackendUtility::setUpdateSignal('updatePageTree'); + } + } catch (\Exception $e) { + $view->addFlashMessage($e->getMessage(), '', ContextualFeedbackSeverity::ERROR); + } + } + } + + protected function getFilePathWithinFileMountBoundaries(string $filePath): string + { + try { + $file = $this->resourceFactory->getFileObjectFromCombinedIdentifier($filePath); + return $file->getForLocalProcessing(false); + } catch (\Exception $exception) { + return ''; + } + } + + protected function getSelectableFileList(Import $import): array + { + $exportFiles = []; + + // Fileadmin + $folder = $import->getOrCreateDefaultImportExportFolder(); + if ($folder !== null) { + $filter = GeneralUtility::makeInstance(FileExtensionFilter::class); + $filter->setAllowedFileExtensions(['t3d', 'xml']); + $folder->getStorage()->addFileAndFolderNameFilter([$filter, 'filterFileList']); + $exportFiles = $folder->getFiles(); + } + $selectableFiles = ['']; + foreach ($exportFiles as $file) { + $selectableFiles[$file->getCombinedIdentifier()] = $file->getPublicUrl(); + } + + // Extension Distribution + if ($this->getBackendUser()->isAdmin()) { + $possibleImportFiles = [ + 'Initialisation/data.t3d', + 'Initialisation/data.xml', + ]; + $activePackages = GeneralUtility::makeInstance(PackageManager::class)->getActivePackages(); + foreach ($activePackages as $package) { + foreach ($possibleImportFiles as $possibleImportFile) { + if (!file_exists($package->getPackagePath() . $possibleImportFile)) { + continue; + } + $selectableFiles['EXT:' . $package->getPackageKey() . '/' . $possibleImportFile] = 'EXT:' . $package->getPackageKey() . '/' . $possibleImportFile; + } + } + } + + return $selectableFiles; + } + + protected function getBackendUser(): BackendUserAuthentication + { + return $GLOBALS['BE_USER']; + } + + protected function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/Classes/Domain/Repository/PresetRepository.php b/Classes/Domain/Repository/PresetRepository.php new file mode 100644 index 0000000..087aa6f --- /dev/null +++ b/Classes/Domain/Repository/PresetRepository.php @@ -0,0 +1,183 @@ +getBackendUser(); + $queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::PRESET_TABLE); + $queryBuilder->select('*') + ->from(self::PRESET_TABLE) + ->where( + $queryBuilder->expr()->or( + $queryBuilder->expr()->gt('public', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)), + $queryBuilder->expr()->eq('user_uid', $queryBuilder->createNamedParameter($backendUser->user['uid'], Connection::PARAM_INT)) + ) + ); + if ($pageId) { + $queryBuilder->andWhere( + $queryBuilder->expr()->or( + $queryBuilder->expr()->eq('item_uid', $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT)), + $queryBuilder->expr()->eq('item_uid', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)) + ) + ); + } + $presets = $queryBuilder->executeQuery(); + // @todo: View should handle default option and data details parsing, not repository. + $options = ['']; + while ($presetCfg = $presets->fetchAssociative()) { + $options[$presetCfg['uid']] = $presetCfg['title'] . ' [' . $presetCfg['uid'] . ']' + . ($presetCfg['public'] ? ' [Public]' : '') + . ((int)$presetCfg['user_uid'] === (int)$backendUser->user['uid'] ? ' [Own]' : ''); + } + return $options; + } + + public function createPreset(array $data): void + { + $timestamp = $this->context->getPropertyFromAspect('date', 'timestamp'); + $this->connectionPool->getConnectionForTable(self::PRESET_TABLE)->insert( + self::PRESET_TABLE, + [ + 'user_uid' => $this->getBackendUser()->user['uid'], + 'public' => $data['preset']['public'], + 'title' => $data['preset']['title'], + 'item_uid' => (int)$data['pagetree']['id'], + 'preset_data' => serialize($data), + 'tstamp' => $timestamp, + 'crdate' => $timestamp, + ], + ['preset_data' => Connection::PARAM_LOB] + ); + } + + /** + * @throws InsufficientUserPermissionsException + * @throws PresetNotFoundException + */ + public function updatePreset(int $uid, array $data): void + { + $backendUser = $this->getBackendUser(); + $preset = $this->getPreset($uid); + if (!($backendUser->isAdmin() || (int)$preset['user_uid'] === (int)$backendUser->user['uid'])) { + throw new InsufficientUserPermissionsException( + 'ERROR: You were not the owner of the preset so you could not delete it.', + 1604584766 + ); + } + $timestamp = $this->context->getPropertyFromAspect('date', 'timestamp'); + $this->connectionPool->getConnectionForTable(self::PRESET_TABLE)->update( + self::PRESET_TABLE, + [ + 'public' => $data['preset']['public'], + 'title' => $data['preset']['title'], + 'item_uid' => $data['pagetree']['id'], + 'preset_data' => serialize($data), + 'tstamp' => $timestamp, + ], + ['uid' => $uid], + ['preset_data' => Connection::PARAM_LOB] + ); + } + + /** + * @throws MalformedPresetException + */ + public function loadPreset(int $uid): array + { + $preset = $this->getPreset($uid); + // @todo: Move this to a json array instead. + $presetData = unserialize($preset['preset_data'], ['allowed_classes' => false]); + if (!is_array($presetData)) { + throw new MalformedPresetException( + 'ERROR: No configuration data found in preset record!', + 1604608922 + ); + } + return $presetData; + } + + /** + * @throws InsufficientUserPermissionsException + * @throws PresetNotFoundException + */ + public function deletePreset(int $uid): void + { + $backendUser = $this->getBackendUser(); + $preset = $this->getPreset($uid); + if (!($backendUser->isAdmin() || (int)$preset['user_uid'] === (int)$backendUser->user['uid'])) { + throw new InsufficientUserPermissionsException( + 'ERROR: You were not the owner of the preset so you could not delete it.', + 1604564346 + ); + } + $this->connectionPool->getConnectionForTable(self::PRESET_TABLE)->delete( + self::PRESET_TABLE, + ['uid' => $uid] + ); + } + + /** + * Get raw preset from database. Does not unserialize preset_data as opposed to public loadPreset(). + * + * @throws PresetNotFoundException + */ + private function getPreset(int $uid): array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::PRESET_TABLE); + $preset = $queryBuilder->select('*') + ->from(self::PRESET_TABLE) + ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT))) + ->executeQuery() + ->fetchAssociative(); + if (!is_array($preset)) { + throw new PresetNotFoundException( + 'ERROR: No valid preset #' . $uid . ' found.', + 1604608843 + ); + } + return $preset; + } + + private function getBackendUser(): BackendUserAuthentication + { + return $GLOBALS['BE_USER']; + } +} diff --git a/Classes/Event/BeforeImportEvent.php b/Classes/Event/BeforeImportEvent.php new file mode 100644 index 0000000..2c06026 --- /dev/null +++ b/Classes/Event/BeforeImportEvent.php @@ -0,0 +1,44 @@ +import; + } + + /** + * The file being about to be imported + */ + public function getFile(): string + { + return $this->file; + } +} diff --git a/Classes/Exception.php b/Classes/Exception.php new file mode 100644 index 0000000..25f8ce3 --- /dev/null +++ b/Classes/Exception.php @@ -0,0 +1,26 @@ +, timestamps: list, columnDefaults: array}> + */ + private array $filterRecordFieldsSetupCache = []; + + protected bool $saveFilesOutsideExportFile = false; + protected bool $includeSiteConfigurations = false; + protected string $exportFileName = ''; + protected string $exportFileType = self::FILETYPE_XML; + protected array $supportedFileTypes = []; + + /** + * Cache for checks if page is in user web mounts. + */ + protected array $pageInWebMountCache = []; + + public function __construct( + protected readonly ConnectionPool $connectionPool, + protected readonly Locales $locales, + protected readonly Typo3Version $typo3Version, + protected readonly ReferenceIndex $referenceIndex, + protected readonly SiteConfiguration $siteConfiguration, + protected readonly Context $context, + ) {} + + /** + * Process configuration + */ + public function process(): void + { + $this->initializeExport(); + $this->setHeader(); + + // Configure which records to export + foreach ($this->record as $ref) { + $rParts = explode(':', $ref); + $table = $rParts[0]; + $record = BackendUtility::getRecord($rParts[0], (int)$rParts[1]); + if (is_array($record)) { + $this->exportAddRecord($table, $record); + } + } + + // Configure which tables to export + foreach ($this->list as $ref) { + $rParts = explode(':', $ref); + $table = $rParts[0]; + $pid = (int)$rParts[1]; + if ($this->getBackendUser()->check('tables_select', $table)) { + $statement = $this->execListQueryPid($pid, $table); + while ($record = $statement->fetchAssociative()) { + $this->exportAddRecord($table, $record); + } + } + } + + // Configure which page tree to export + if ($this->pid !== -1) { + $pageTree = null; + if ($this->levels === self::LEVELS_RECORDS_ON_THIS_PAGE) { + $this->addRecordsForPid($this->pid, $this->tables); + } else { + /** @var ExportPageTreeView $pageTreeView */ + $pageTreeView = GeneralUtility::makeInstance(ExportPageTreeView::class); + $initClause = $this->getExcludePagesClause(); + if ($this->excludeDisabledRecords) { + $initClause .= BackendUtility::BEenableFields('pages'); + } + $pageTreeView->init($initClause); + $pageTreeView->buildTreeByLevels($this->pid, $this->levels); + $this->treeHTML = $pageTreeView->printTree(); + $pageTree = $pageTreeView->buffer_idH; + } + // In most cases, we should have a multi-level array, $pageTree, with the page tree + // structure here (and the HTML code loaded into memory for a nice display...) + if (is_array($pageTree)) { + $pageList = []; + $this->removeExcludedPagesFromPageTree($pageTree); + $this->setPageTree($pageTree); + $this->flatInversePageTree($pageTree, $pageList); + $pagesSchema = $this->tcaSchemaFactory->get('pages'); + $transOrigPointerFieldName = null; + $languageFieldName = null; + $languageCapability = null; + if ($pagesSchema->isLanguageAware()) { + $languageCapability = $pagesSchema->getCapability(TcaSchemaCapability::Language); + $transOrigPointerFieldName = $languageCapability->getTranslationOriginPointerField()->getName(); + $languageFieldName = $languageCapability->getLanguageField()->getName(); + } + foreach ($pageList as $pageUid => $_) { + $record = BackendUtility::getRecord('pages', $pageUid); + if (is_array($record)) { + $this->exportAddRecord('pages', $record); + foreach ($this->getTranslationForPage($languageCapability, (int)$record['uid'], $this->excludeDisabledRecords) as $pageTranslation) { + // Export l10n translations + // All exported records need to be considered within "insidePageTree", not "outsidePageTree", + // because they actually ARE part of the page tree. To achieve this, their UID index is + // added into $this->dat['header']['pagetree']. + $this->exportAddRecord('pages', $pageTranslation); + // Be sure to not overwrite existing parts of the pagetree + // Integrate the extra record into the internal pagetree array + $this->dat['header']['pagetree'][(int)$pageTranslation['uid']]['uid'] = (int)$pageTranslation['uid']; + } + + // Translated pages can also be directly exported; in that case the pageList may + // point to the UID of a translated page, and not the root page. Since tt_content + // records are bound to the default page UID, those records would be missing. + // So we use the page ID of the default language, and then attach all records + // for that page ID, which also match the selected page's language. + if (($record[$transOrigPointerFieldName] ?? 0) > 0) { + $this->addRecordsForPid( + (int)$record[$transOrigPointerFieldName], + $this->tables, + [$record[$languageFieldName]] + ); + } + } + $this->addRecordsForPid((int)$pageUid, $this->tables); + } + } + } + + // After adding ALL records we add records from database relations + for ($l = 0; $l < 10; $l++) { + if ($this->exportAddRecordsFromRelations($l) === 0) { + break; + } + } + + // Files must be added after the database relations are added, + // so that files from ALL added records are included! + $this->exportAddFilesFromSysFilesRecords(); + if ($this->includeSiteConfigurations) { + $this->exportAddSiteConfigurations(); + } + } + + /** + * Add page translations to list of pages + */ + protected function getTranslationForPage( + ?LanguageAwareSchemaCapability $languageCapability, + int $defaultLanguagePageUid, + bool $considerHiddenPages, + array $limitToLanguageIds = [] + ): array { + if ($languageCapability === null) { + return []; + } + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages'); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class)) + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + if (!$considerHiddenPages) { + $queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(HiddenRestriction::class)); + } + $constraints = [ + $queryBuilder->expr()->eq( + $languageCapability->getTranslationOriginPointerField()->getName(), + $queryBuilder->createNamedParameter($defaultLanguagePageUid, Connection::PARAM_INT) + ), + ]; + if (!empty($limitToLanguageIds)) { + $constraints[] = $queryBuilder->expr()->in( + $languageCapability->getLanguageField()->getName(), + $queryBuilder->createNamedParameter($limitToLanguageIds, ArrayParameterType::INTEGER) + ); + } else { + // Ensure consistency by only fetching pages where not only l10n_parent matches, but also a + // sys_language_uid > 0 exists. + $constraints[] = $queryBuilder->expr()->gt($languageCapability->getLanguageField()->getName(), 0); + } + return $queryBuilder + ->select('*') + ->from('pages') + ->where(...$constraints) + ->orderBy('uid', 'ASC') + ->executeQuery() + ->fetchAllAssociative(); + } + + /** + * Initialize all settings for the export + */ + protected function initializeExport(): void + { + $this->dat = [ + 'header' => [], + 'records' => [], + ]; + } + + protected function setHeader(): void + { + // Initializing: + foreach ($this->softrefCfg as $key => $value) { + if (!($value['mode'] ?? false)) { + unset($this->softrefCfg[$key]); + } + } + // Version of file format + $this->dat['header']['XMLversion'] = '1.0'; + $this->dat['header']['charset'] = 'utf-8'; + // Meta data (overridable for testing) + $this->setMetaData(); + // Add list of tables to consider static + if ($this->relStaticTables !== []) { + $this->dat['header']['relStaticTables'] = $this->relStaticTables; + } + // The list of excluded records + if ($this->excludeMap !== []) { + $this->dat['header']['excludeMap'] = $this->excludeMap; + } + // Soft reference mode for elements + if ($this->softrefCfg !== []) { + $this->dat['header']['softrefCfg'] = $this->softrefCfg; + } + // List of extensions the import depends on. + if ($this->extensionDependencies !== []) { + $this->dat['header']['extensionDependencies'] = $this->extensionDependencies; + } + } + + protected function setMetaData(): void + { + $user = $this->getBackendUser(); + if ($user->user['lang'] ?? false) { + $locale = $this->locales->createLocale($user->user['lang']); + } else { + $locale = new Locale(); + } + /** @var DateTimeAspect $dateAspect */ + $dateAspect = $this->context->getAspect('date'); + $meta = array_filter([ + 'title' => $this->title, + 'description' => $this->description, + 'notes' => $this->notes, + 'packager_username' => $this->getBackendUser()->user['username'], + 'packager_name' => $this->getBackendUser()->user['realName'], + 'packager_email' => $this->getBackendUser()->user['email'], + 'TYPO3_version' => (string)$this->typo3Version, + 'created' => (new DateFormatter())->format($dateAspect->getDateTime(), 'EEE d. MMMM y', $locale), + ], static fn(string $value): bool => $value !== ''); + if ($meta !== []) { + $this->dat['header']['meta'] = $meta; + } + } + + /** + * Sets the page-tree array in the export header + * + * @param array $pageTree Hierarchy of ids, the page tree: array([uid] => array("uid" => [uid], "subrow" => array(.....)), [uid] => ....) + */ + public function setPageTree(array $pageTree): void + { + $this->dat['header']['pagetree'] = $pageTree; + } + + /** + * Removes entries in the page tree which are found in ->excludeMap[] + * + * @param array $pageTree Hierarchy of ids, the page tree + */ + protected function removeExcludedPagesFromPageTree(array &$pageTree): void + { + foreach ($pageTree as $pid => $value) { + if ($this->isRecordExcluded('pages', (int)($pageTree[$pid]['uid'] ?? 0))) { + unset($pageTree[$pid]); + } elseif (is_array($pageTree[$pid]['subrow'] ?? null)) { + $this->removeExcludedPagesFromPageTree($pageTree[$pid]['subrow']); + } + } + } + + /** + * Filter page IDs by traversing the exclude map, finding all + * excluded pages (if any) and making an AND NOT IN statement for the select clause. + * + * @return string AND where clause part to filter out page uids. + */ + protected function getExcludePagesClause(): string + { + $pageIds = []; + + foreach ($this->excludeMap as $tableAndUid => $isExcluded) { + [$table, $uid] = explode(':', $tableAndUid); + if ($table === 'pages') { + $pageIds[] = (int)$uid; + } + } + if (!empty($pageIds)) { + return ' AND uid NOT IN (' . implode(',', $pageIds) . ')'; + } + return ''; + } + + /** + * Adds records to the export object for a specific page id. + * + * @param int $pid Page id for which to select records to add + * @param array $tables Array of table names to select from + * @param array $restrictToLanguageIds Array of sys_language_uid IDs to allow records for. + */ + protected function addRecordsForPid(int $pid, array $tables, array $restrictToLanguageIds = []): void + { + $isRestrictToLanguageIds = $restrictToLanguageIds !== []; + /** + * @var string $table + * @var TcaSchema $schema + */ + foreach ($this->tcaSchemaFactory->all() as $table => $schema) { + if ($table === 'pages') { + continue; + } + if (!$this->getBackendUser()->check('tables_select', $table)) { + continue; + } + if (!in_array($table, $tables, true) && !in_array('_ALL', $tables, true)) { + continue; + } + $languageField = null; + if ($schema->isLanguageAware()) { + $languageField = $schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName(); + } + $statement = $this->execListQueryPid($pid, $table); + while ($record = $statement->fetchAssociative()) { + // Skip the record, when languageId restrictions are enabled, and the record's language is not requested + if ($isRestrictToLanguageIds && $schema->isLanguageAware() && isset($record[$languageField]) && !in_array($record[$languageField], $restrictToLanguageIds, true)) { + continue; + } + $this->exportAddRecord($table, $record); + } + } + } + + /** + * Selects records from table / pid + * + * @param int $pid Page ID to select from + * @param string $table Table to select from + * @return Result Query statement + */ + protected function execListQueryPid(int $pid, string $table): Result + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $schema = $this->tcaSchemaFactory->get($table); + + $orderBy = ''; + if ($schema->hasCapability(TcaSchemaCapability::SortByField)) { + $orderBy = $schema->getCapability(TcaSchemaCapability::SortByField)->getFieldName(); + } elseif ($schema->hasCapability(TcaSchemaCapability::DefaultSorting)) { + $orderBy = $schema->getCapability(TcaSchemaCapability::DefaultSorting)->getValue(); + } + + if ($this->excludeDisabledRecords === false) { + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) + ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, 0)); + } else { + $queryBuilder->getRestrictions() + ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, 0)); + } + + $queryBuilder->select('*') + ->from($table) + ->where( + $queryBuilder->expr()->eq( + 'pid', + $queryBuilder->createNamedParameter($pid, Connection::PARAM_INT) + ) + ); + + $orderBys = QueryHelper::parseOrderBy((string)$orderBy); + foreach ($orderBys as $orderPair) { + [$field, $order] = $orderPair; + $queryBuilder->addOrderBy($field, $order); + } + // Ensure deterministic sorting + if (!in_array('uid', array_column($orderBys, 0))) { + $queryBuilder->addOrderBy('uid', 'ASC'); + } + + return $queryBuilder->executeQuery(); + } + + /** + * Adds the record $row from $table. + * No checking for relations done here. Pure data. + * + * @param string $table Table name + * @param array $row Record row. + * @param int $relationLevel (Internal) if the record is added as a relation, this is set to the "level" it was on. + */ + public function exportAddRecord(string $table, array $row, int $relationLevel = 0): void + { + BackendUtility::workspaceOL($table, $row); + $recordUid = (int)$row['uid']; + + if ($table === '' || $recordUid === 0 + || $this->isRecordExcluded($table, $recordUid) + || $this->excludeDisabledRecords && $this->isRecordDisabled($table, $recordUid)) { + return; + } + + $recordPid = (int)$row['pid']; + $recordIdentifier = $table . ':' . $recordUid; + if ($this->isPageInWebMount($table === 'pages' ? $recordUid : $recordPid)) { + if (!isset($this->dat['records'][$recordIdentifier])) { + // Prepare header info + $headerInfo = [ + 'uid' => $recordUid, + 'pid' => $recordPid, + 'title' => GeneralUtility::fixed_lgd_cs(BackendUtility::getRecordTitle($table, $row), 40), + ]; + $sanitizedRow = $this->filterRecordFields($table, $row); + if ($relationLevel) { + $headerInfo['relationLevel'] = $relationLevel; + } + // Set the header summary: + $this->dat['header']['records'][$table][$recordUid] = $headerInfo; + // Create entry in the PID lookup: + $this->dat['header']['pid_lookup'][$recordPid][$table][$recordUid] = 1; + // @todo: Using getRelations() from Refindex for this operation is a misuse, the method should + // be protected. It would be better to use softref parser and RelationHandler here directly, + // or fetch the relations using a sys_refindex query. Note with recent changes, 'itemArray' + // with MM contain 'sorting', 'sorting_foreign', 'fieldname' as well, which could be removed + // from export again if needed, since they are currently irrelevant during import. + // Note 'fieldname' could be handy during import, though: When a category is for instance bound + // to two different fields in a target table (e.g. 'pages'), that field indicates to which + // of those a relation is bound. This is currently most likely not handled during import and + // should have more test coverage. + $relations = $this->referenceIndex->getRelations($table, $row, 0); + // Data: + $this->dat['records'][$recordIdentifier] = ['data' => $sanitizedRow]; + // There are no refindex entries for l10n_source of pages and tt_content, so we have to add them here manually for now. + // @todo can be removed, when this can come from ReferenceIndex. + if (($table === 'pages' || $table === 'tt_content')) { + $schema = $this->tcaSchemaFactory->get($table); + $translationSourceFieldName = null; + if ($schema->isLanguageAware()) { + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $translationSourceFieldName = $languageCapability->getTranslationSourceField()?->getName(); + } + if ($translationSourceFieldName && ((int)($row[$translationSourceFieldName] ?? 0)) > 0) { + $relations[$translationSourceFieldName]['type'] = 'db'; + $relations[$translationSourceFieldName]['itemArray'][0] = [ + 'id' => $row[$translationSourceFieldName], + 'table' => $table, + ]; + } + } + if ($relations !== []) { + $this->dat['records'][$recordIdentifier]['rels'] = $relations; + } + // Add information about the relations in the record in the header: + $flatDbRelations = $this->flatDbRelations($relations); + if ($flatDbRelations !== []) { + $this->dat['header']['records'][$table][$recordUid]['rels'] = $flatDbRelations; + } + // Add information about the softrefs to header: + $flatSoftRefs = $this->flatSoftRefs($relations); + if ($flatSoftRefs !== []) { + $this->dat['header']['records'][$table][$recordUid]['softrefs'] = $flatSoftRefs; + } + } else { + $this->addError('Record ' . $recordIdentifier . ' already added.'); + } + } else { + $this->addError('Record ' . $recordIdentifier . ' was outside your database mounts!'); + } + } + + /** + * Checking if a page is in the web mounts of the user + * + * @param int $pid Page ID to check + * @return bool TRUE if OK + */ + protected function isPageInWebMount(int $pid): bool + { + if (!isset($this->pageInWebMountCache[$pid])) { + $this->pageInWebMountCache[$pid] = (bool)$this->getBackendUser()->isInWebMount($pid); + } + return $this->pageInWebMountCache[$pid]; + } + + /** + * Reduces the exported row to the values a re-import actually needs. + * + * Strips DataHandler-managed timestamps and columns whose value equals + * the effective default. Always preserves uid, pid, the disabled field + * and the record-type field. + */ + protected function filterRecordFields(string $table, array $row): array + { + if (!$this->tcaSchemaFactory->has($table)) { + return $row; + } + $setup = $this->getFilterRecordFieldsSetup($table); + $schema = $this->tcaSchemaFactory->get($table); + $alwaysKeep = $setup['alwaysKeep']; + $timestamps = $setup['timestamps']; + $columnDefaults = $setup['columnDefaults']; + $newRow = []; + foreach ($row as $fieldName => $value) { + if (in_array($fieldName, $timestamps, true)) { + continue; + } + if (!in_array($fieldName, $alwaysKeep, true) + && $this->valueMatchesEffectiveDefault($schema, $columnDefaults, $fieldName, $value) + ) { + continue; + } + $newRow[$fieldName] = $value; + } + return $newRow; + } + + /** + * @return array{alwaysKeep: list, timestamps: list, columnDefaults: array} + */ + private function getFilterRecordFieldsSetup(string $table): array + { + if (array_key_exists($table, $this->filterRecordFieldsSetupCache)) { + return $this->filterRecordFieldsSetupCache[$table]; + } + $schema = $this->tcaSchemaFactory->get($table); + $alwaysKeep = $this->defaultRecordIncludeFields; + if ($schema->hasCapability(TcaSchemaCapability::RestrictionDisabledField)) { + $disabledCapability = $schema->getCapability(TcaSchemaCapability::RestrictionDisabledField); + $alwaysKeep[] = $disabledCapability->getFieldName(); + } + if ($schema->supportsSubSchema()) { + $alwaysKeep[] = $schema->getSubSchemaTypeInformation()->getFieldName(); + } + if ($schema->isLanguageAware()) { + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $alwaysKeep[] = $languageCapability->getLanguageField()->getName(); + $alwaysKeep[] = $languageCapability->getTranslationOriginPointerField()->getName(); + $sourceField = $languageCapability->getTranslationSourceField()?->getName(); + if ($sourceField !== null) { + $alwaysKeep[] = $sourceField; + } + $diffSourceField = $languageCapability->getDiffSourceField()?->getName(); + if ($diffSourceField !== null) { + $alwaysKeep[] = $diffSourceField; + } + } + $timestamps = []; + foreach ([TcaSchemaCapability::CreatedAt, TcaSchemaCapability::UpdatedAt] as $capability) { + if (!$schema->hasCapability($capability)) { + continue; + } + $timestamps[] = $schema->getCapability($capability)->getFieldName(); + } + return $this->filterRecordFieldsSetupCache[$table] = [ + 'alwaysKeep' => $alwaysKeep, + 'timestamps' => $timestamps, + 'columnDefaults' => $this->getColumnDefaults($table), + ]; + } + + /** + * TCA is authoritative: only columns without a TCA default fall back to + * the Doctrine-reported database default. + */ + private function valueMatchesEffectiveDefault( + TcaSchema $schema, + array $columnDefaults, + string $fieldName, + mixed $value, + ): bool { + if ($schema->hasField($fieldName)) { + $field = $schema->getField($fieldName); + if ($field->hasDefaultValue()) { + return $this->valueMatchesDefault($value, $field->getDefaultValue()); + } + } + if (array_key_exists($fieldName, $columnDefaults)) { + return $this->valueMatchesDefault($value, $columnDefaults[$fieldName]); + } + return false; + } + + /** + * @return array + */ + private function getColumnDefaults(string $table): array + { + $defaults = []; + $schemaInformation = $this->connectionPool->getConnectionForTable($table)->getSchemaInformation(); + $columnInfos = $schemaInformation->listTableColumnInfos($table); + foreach ($columnInfos as $columnInfo) { + $defaults[$columnInfo->name] = $columnInfo->default; + } + return $defaults; + } + + /** + * Drivers hand defaults back as strings or ints, + * so "" never matches an int 0 default, and vice versa. + */ + private function valueMatchesDefault(mixed $value, mixed $default): bool + { + if ($value === null || $default === null) { + return $value === $default; + } + + if (is_int($value) && MathUtility::canBeInterpretedAsInteger($default)) { + return $value === (int)$default; + } + + if (MathUtility::canBeInterpretedAsInteger($value) && is_int($default)) { + return (int)$value === $default; + } + + return $value === $default; + } + + /** + * Database relations flattened to 1-dimensional array. + * The list will be unique, no table/uid combination will appear twice. + * + * @param array $relations 2-dimensional array of database relations organized by table key + * @return array 1-dimensional array where entries are table:uid and keys are array with table/id + */ + protected function flatDbRelations(array $relations): array + { + $list = []; + foreach ($relations as $relation) { + if (isset($relation['type'])) { + if ($relation['type'] === 'db') { + foreach ($relation['itemArray'] as $dbRelationData) { + $list[$dbRelationData['table'] . ':' . $dbRelationData['id']] = $dbRelationData; + } + } elseif ($relation['type'] === 'flex' && is_array($relation['flexFormRels']['db'] ?? null)) { + foreach ($relation['flexFormRels']['db'] as $subList) { + foreach ($subList as $dbRelationData) { + $list[$dbRelationData['table'] . ':' . $dbRelationData['id']] = $dbRelationData; + } + } + } + } + } + return $list; + } + + /** + * Soft references flattened to 1-dimensional array. + * + * @param array $relations 2-dimensional array of database relations organized by table key + * @return array 1-dimensional array where entries are arrays with properties of the soft link found and + * keys are a unique combination of field, spKey, structure path if applicable and token ID + */ + protected function flatSoftRefs(array $relations): array + { + $list = []; + foreach ($relations as $field => $relation) { + foreach ($relation['softrefs']['keys'] ?? [] as $spKey => $elements) { + foreach ($elements as $subKey => $el) { + $lKey = $field . ':' . $spKey . ':' . $subKey; + $list[$lKey] = array_merge(['field' => $field, 'spKey' => $spKey], $el); + } + } + if (($relation['type'] ?? '') === 'flex' && is_array($relation['flexFormRels']['softrefs'] ?? null)) { + foreach ($relation['flexFormRels']['softrefs'] as $structurePath => &$subList) { + foreach ($subList['keys'] ?? [] as $spKey => $elements) { + foreach ($elements as $subKey => $el) { + $lKey = $field . ':' . $structurePath . ':' . $spKey . ':' . $subKey; + $list[$lKey] = array_merge([ + 'field' => $field, + 'spKey' => $spKey, + 'structurePath' => $structurePath, + ], $el); + } + } + } + } + } + return $list; + } + + /** + * This analyzes the existing added records, finds all database relations to records and adds these records to the + * export file. + * This function can be called repeatedly until it returns zero added records. + * In principle it should not allow to infinite recursion, but you better set a limit... + * + * @param int $relationLevel Recursion level + * @return int number of records from relations found and added + */ + protected function exportAddRecordsFromRelations(int $relationLevel = 0): int + { + if (!isset($this->dat['records'])) { + $this->addError('There were no records available.'); + return 0; + } + + $addRecords = []; + + foreach ($this->dat['records'] as $record) { + if (!is_array($record)) { + continue; + } + foreach ($record['rels'] ?? [] as $relation) { + if (isset($relation['type'])) { + if ($relation['type'] === 'db') { + foreach ($relation['itemArray'] as $dbRelationData) { + $this->exportAddRecordsFromRelationsPushRelation($dbRelationData, $addRecords); + } + } + if ($relation['type'] === 'flex') { + // Database relations in flex form fields: + if (is_array($relation['flexFormRels']['db'] ?? null)) { + foreach ($relation['flexFormRels']['db'] as $subList) { + foreach ($subList as $dbRelationData) { + $this->exportAddRecordsFromRelationsPushRelation($dbRelationData, $addRecords); + } + } + } + + // Database oriented soft references in flex form fields: + if (is_array($relation['flexFormRels']['softrefs'] ?? null)) { + foreach ($relation['flexFormRels']['softrefs'] as $subList) { + foreach ($subList['keys'] as $elements) { + foreach ($elements as $el) { + if ($el['subst']['type'] === 'db' && $this->isSoftRefIncluded($el['subst']['tokenID'])) { + [$referencedTable, $referencedUid] = explode(':', $el['subst']['recordRef']); + $dbRelationData = [ + 'table' => $referencedTable, + 'id' => $referencedUid, + ]; + $this->exportAddRecordsFromRelationsPushRelation($dbRelationData, $addRecords, $el['subst']['tokenID']); + } + } + } + } + } + } + } + // In any case, if there are soft refs: + if (is_array($relation['softrefs']['keys'] ?? null)) { + foreach ($relation['softrefs']['keys'] as $elements) { + foreach ($elements as $el) { + if (($el['subst']['type'] ?? '') === 'db' && $this->isSoftRefIncluded($el['subst']['tokenID'])) { + [$referencedTable, $referencedUid] = explode(':', $el['subst']['recordRef']); + $dbRelationData = [ + 'table' => $referencedTable, + 'id' => $referencedUid, + ]; + $this->exportAddRecordsFromRelationsPushRelation($dbRelationData, $addRecords, $el['subst']['tokenID']); + } + } + } + } + } + } + + if (!empty($addRecords)) { + foreach ($addRecords as $recordData) { + $record = BackendUtility::getRecord($recordData['table'], $recordData['id']); + + if (is_array($record)) { + // Depending on db driver, int fields may or may not be returned as integer or as string. The + // loop aligns that detail and forces strings for everything to have exports more db agnostic. + foreach ($record as $fieldName => $fieldValue) { + $record[$fieldName] = $fieldValue === null ? $fieldValue : (string)$fieldValue; + } + $this->exportAddRecord($recordData['table'], $record, $relationLevel + 1); + } + // Set status message + // Relation pointers always larger than zero except certain "select" types with + // negative values pointing to uids - but that is not supported here. + if ($recordData['id'] > 0) { + $recordRef = $recordData['table'] . ':' . $recordData['id']; + if (!isset($this->dat['records'][$recordRef])) { + $this->dat['records'][$recordRef] = 'NOT_FOUND'; + $this->addError('Relation record ' . $recordRef . ' was not found!'); + } + } + } + } + + return count($addRecords); + } + + /** + * Helper function for exportAddRecordsFromRelations() + * + * @param array $recordData Record of relation with table/id key to add to $addRecords + * @param array $addRecords Records of relations which are already marked as to be added to the export + * @param string $tokenID Soft reference token ID, if applicable. + */ + protected function exportAddRecordsFromRelationsPushRelation(array $recordData, array &$addRecords, string $tokenID = ''): void + { + // @todo: Remove by-reference and return final array + $recordRef = $recordData['table'] . ':' . $recordData['id']; + if ( + $this->tcaSchemaFactory->has($recordData['table']) + && !$this->isTableStatic($recordData['table']) + && !$this->isRecordExcluded($recordData['table'], (int)$recordData['id']) + && (!$tokenID || $this->isSoftRefIncluded($tokenID)) + && $this->inclRelation($recordData['table']) + && !isset($this->dat['records'][$recordRef]) + ) { + $addRecords[$recordRef] = $recordData; + } + } + + /** + * Returns TRUE if the input table name is to be included as relation + * + * @param string $table Table name + * @return bool TRUE, if table is marked static + */ + protected function inclRelation(string $table): bool + { + return $this->tcaSchemaFactory->has($table) + && (in_array($table, $this->relOnlyTables, true) || in_array('_ALL', $this->relOnlyTables, true)) + && $this->getBackendUser()->check('tables_select', $table); + } + + /** + * This adds all files from sys_file records + */ + protected function exportAddFilesFromSysFilesRecords(): void + { + foreach ($this->dat['header']['records']['sys_file'] ?? [] as $sysFileUid => $_) { + $this->exportAddSysFile($sysFileUid); + } + } + + /** + * This adds the file from a sys_file record to the export + * - either as content or external file + */ + protected function exportAddSysFile(int $sysFileUid): void + { + try { + $file = $this->resourceFactory->getFileObject($sysFileUid); + $file->checkActionPermission('read'); + } catch (\Exception $e) { + $this->addError('Error when trying to add file with UID ' . $sysFileUid . ': ' . $e->getMessage()); + return; + } + + $fileUid = $file->getUid(); + $fileSha1 = $file->getStorage()->hashFile($file, 'sha1'); + if ($fileSha1 !== $file->getProperty('sha1')) { + $this->dat['records']['sys_file:' . $fileUid]['data']['sha1'] = $fileSha1; + $this->addError( + 'The SHA-1 file hash of ' . $file->getCombinedIdentifier() . ' is not up-to-date in the index! ' + . 'The file was added based on the current file hash.' + ); + } + // Build unique id based on the storage and the file identifier + $fileId = md5($file->getStorage()->getUid() . ':' . $file->getProperty('identifier_hash')); + + $fileInfo = []; + $fileInfo['filename'] = $file->getProperty('name'); + $fileInfo['filemtime'] = $file->getProperty('modification_date'); + + // Setting this data in the header + $this->dat['header']['files_fal'][$fileId] = $fileInfo; + + if (!$this->saveFilesOutsideExportFile) { + $fileInfo['content'] = $file->getContents(); + } else { + GeneralUtility::upload_copy_move( + $file->getForLocalProcessing(false), + $this->getOrCreateTemporaryFolderName() . '/' . $fileSha1 + ); + } + $fileInfo['content_sha1'] = $fileSha1; + $this->dat['files_fal'][$fileId] = $fileInfo; + } + + /** + * Add site configurations whose root page is part of the export to the export header. + */ + protected function exportAddSiteConfigurations(): void + { + $exportedPageIds = array_map('intval', array_keys($this->dat['header']['records']['pages'] ?? [])); + if ($exportedPageIds === []) { + return; + } + $siteConfigurations = []; + foreach ($this->siteConfiguration->resolveAllExistingSites(false) as $site) { + if (in_array($site->getRootPageId(), $exportedPageIds, true)) { + $siteConfigurations[$site->getIdentifier()] = $this->siteConfiguration->load($site->getIdentifier()); + } + } + if ($siteConfigurations !== []) { + $this->dat['header']['site_configurations'] = $siteConfigurations; + } + } + + /** + * This compiles and returns the data content for an exported file + * - "xml" gives xml + * - "t3d" and "t3d_compressed" gives serialized array, possibly compressed + * + * @return string The output file stream + */ + public function render(): string + { + if ($this->exportFileType === self::FILETYPE_XML) { + $out = $this->createXML(); + } else { + $out = ''; + // adding header: + $out .= $this->addFilePart(serialize($this->dat['header'])); + // adding records: + $out .= $this->addFilePart(serialize($this->dat['records'])); + // adding files: + $out .= $this->addFilePart(serialize($this->dat['files'] ?? null)); + // adding files_fal: + $out .= $this->addFilePart(serialize($this->dat['files_fal'] ?? null)); + } + return $out; + } + + /** + * Creates XML string from input array + * + * @return string XML content + */ + protected function createXML(): string + { + // Options: + $options = [ + 'alt_options' => [ + '/header' => [ + 'disableTypeAttrib' => true, + 'clearStackPath' => true, + 'parentTagMap' => [ + 'files' => 'file', + 'files_fal' => 'file', + 'records' => 'table', + 'table' => 'rec', + 'rec:rels' => 'relations', + 'relations' => 'element', + 'filerefs' => 'file', + 'pid_lookup' => 'page_contents', + 'header:relStaticTables' => 'static_tables', + 'static_tables' => 'tablename', + 'excludeMap' => 'item', + 'softrefCfg' => 'softrefExportMode', + 'extensionDependencies' => 'extkey', + 'softrefs' => 'softref_element', + ], + 'alt_options' => [ + '/pagetree' => [ + 'disableTypeAttrib' => true, + 'useIndexTagForNum' => 'node', + 'parentTagMap' => [ + 'node:subrow' => 'node', + ], + ], + '/pid_lookup/page_contents' => [ + 'disableTypeAttrib' => true, + 'parentTagMap' => [ + 'page_contents' => 'table', + ], + 'grandParentTagMap' => [ + 'page_contents/table' => 'item', + ], + ], + ], + ], + '/records' => [ + 'disableTypeAttrib' => true, + 'parentTagMap' => [ + 'records' => 'tablerow', + 'tablerow:data' => 'fieldlist', + 'tablerow:rels' => 'related', + 'related' => 'field', + 'field:itemArray' => 'relations', + 'field:flexFormRels' => 'flexform', + 'relations' => 'element', + 'filerefs' => 'file', + 'flexform:db' => 'db_relations', + 'flexform:softrefs' => 'softref_relations', + 'softref_relations' => 'structurePath', + 'db_relations' => 'path', + 'path' => 'element', + 'keys' => 'softref_key', + 'softref_key' => 'softref_element', + ], + 'alt_options' => [ + '/records/tablerow/fieldlist' => [ + 'useIndexTagForAssoc' => 'field', + ], + ], + ], + '/files' => [ + 'disableTypeAttrib' => true, + 'parentTagMap' => [ + 'files' => 'file', + ], + ], + '/files_fal' => [ + 'disableTypeAttrib' => true, + 'parentTagMap' => [ + 'files_fal' => 'file', + ], + ], + ], + ]; + // Creating XML file from $outputArray: + $charset = $this->dat['header']['charset'] ?: 'utf-8'; + $XML = '' . LF; + $XML .= (new Typo3XmlSerializer())->encodeWithReturningExceptionAsString( + $this->dat, + new Typo3XmlParserOptions([Typo3XmlParserOptions::ROOT_NODE_NAME => 'T3RecordDocument']), + $options + ); + + // POSIX text-file convention: files end with a newline. + return rtrim($XML, "\r\n") . LF; + } + + /** + * Returns a content part for a filename being build. + * + * @param string $data Data to store in part + * @return string Content stream. + */ + protected function addFilePart(string $data): string + { + $compress = $this->exportFileType === self::FILETYPE_T3DZ; + if ($compress) { + $data = (string)gzcompress($data); + } + return md5($data) . ':' . ($compress ? '1' : '0') . ':' . str_pad((string)strlen($data), 10, '0', STR_PAD_LEFT) . ':' . $data . ':'; + } + + public function saveToFile(): File + { + $saveFolder = $this->getOrCreateDefaultImportExportFolder(); + $fileName = $this->getOrGenerateExportFileNameWithFileExtension(); + $filesFolderName = $fileName . '.files'; + $fileContent = $this->render(); + + if (!($saveFolder?->checkActionPermission('write'))) { + throw new InsufficientFolderWritePermissionsException( + 'You are not allowed to write to the target folder "' . $saveFolder->getPublicUrl() . '"', + 1602432207 + ); + } + + if ($saveFolder->hasFolder($filesFolderName)) { + $saveFolder->getSubfolder($filesFolderName)->delete(); + } + + $temporaryFileName = GeneralUtility::tempnam('export'); + GeneralUtility::writeFile($temporaryFileName, $fileContent, true); + $this->skipResourceConsistencyCheckForCommands($saveFolder->getStorage(), $temporaryFileName, $fileName); + $file = $saveFolder->addFile($temporaryFileName, $fileName, DuplicationBehavior::REPLACE); + + if ($this->saveFilesOutsideExportFile) { + $filesFolder = $saveFolder->createFolder($filesFolderName); + $temporaryFilesForExport = GeneralUtility::getFilesInDir($this->getOrCreateTemporaryFolderName(), '', true); + foreach ($temporaryFilesForExport as $temporaryFileForExport) { + $this->skipResourceConsistencyCheckForCommands($filesFolder->getStorage(), $temporaryFileForExport); + $filesFolder->addFile($temporaryFileForExport); + } + $this->removeTemporaryFolderName(); + } + + return $file; + } + + public function getExportFileName(): string + { + return $this->exportFileName; + } + + public function setExportFileName(string $exportFileName): void + { + $exportFileName = trim((string)preg_replace('/[^[:alnum:]._-]*/', '', $exportFileName)); + $this->exportFileName = $exportFileName; + } + + public function getOrGenerateExportFileNameWithFileExtension(): string + { + if (!empty($this->exportFileName)) { + $exportFileName = $this->exportFileName; + } else { + $exportFileName = $this->generateExportFileName(); + } + $exportFileName .= $this->getFileExtensionByFileType(); + + return $exportFileName; + } + + protected function generateExportFileName(): string + { + if ($this->pid !== -1) { + $exportFileName = 'tree_PID' . $this->pid . '_L' . $this->levels; + } elseif (!empty($this->getRecord())) { + $exportFileName = 'recs_' . implode('-', $this->getRecord()); + $exportFileName = str_replace(':', '_', $exportFileName); + } elseif (!empty($this->getList())) { + $exportFileName = 'list_' . implode('-', $this->getList()); + $exportFileName = str_replace(':', '_', $exportFileName); + } else { + $exportFileName = 'export'; + } + + $exportFileName = substr(trim((string)preg_replace('/[^[:alnum:]_-]/', '-', $exportFileName)), 0, 20); + + return 'T3D_' . $exportFileName . '_' . date('Y-m-d_H-i'); + } + + public function getExportFileType(): string + { + return $this->exportFileType; + } + + public function setExportFileType(string $exportFileType): void + { + $supportedFileTypes = $this->getSupportedFileTypes(); + if (!in_array($exportFileType, $supportedFileTypes, true)) { + throw new \InvalidArgumentException( + sprintf( + 'File type "%s" is not valid. Supported file types are %s.', + $exportFileType, + implode(', ', array_map(static function ($fileType) { + return '"' . $fileType . '"'; + }, $supportedFileTypes)) + ), + 1602505264 + ); + } + $this->exportFileType = $exportFileType; + } + + public function getSupportedFileTypes(): array + { + if (empty($this->supportedFileTypes)) { + $supportedFileTypes = []; + $supportedFileTypes[] = self::FILETYPE_XML; + $supportedFileTypes[] = self::FILETYPE_T3D; + if (function_exists('gzcompress')) { + $supportedFileTypes[] = self::FILETYPE_T3DZ; + } + $this->supportedFileTypes = $supportedFileTypes; + } + return $this->supportedFileTypes; + } + + protected function getFileExtensionByFileType(): string + { + return match ($this->exportFileType) { + self::FILETYPE_XML => '.xml', + self::FILETYPE_T3D => '.t3d', + default => '-z.t3d', + }; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getDescription(): string + { + return $this->description; + } + + public function setDescription(string $description): void + { + $this->description = $description; + } + + public function setNotes(string $notes): void + { + $this->notes = $notes; + } + + public function getRecord(): array + { + return $this->record; + } + + public function setRecord(array $record): void + { + $this->record = $record; + } + + public function getList(): array + { + return $this->list; + } + + public function setList(array $list): void + { + $this->list = $list; + } + + public function getLevels(): int + { + return $this->levels; + } + + public function setLevels(int $levels): void + { + $this->levels = $levels; + } + + public function setTables(array $tables): void + { + $this->tables = $tables; + } + + public function setRelOnlyTables(array $relOnlyTables): void + { + $this->relOnlyTables = $relOnlyTables; + } + + public function getTreeHTML(): string + { + return $this->treeHTML; + } + + /** + * Option to enable having the files not included in the export file. + * The files are saved to a temporary folder instead. + */ + public function setSaveFilesOutsideExportFile(bool $saveFilesOutsideExportFile): void + { + $this->saveFilesOutsideExportFile = $saveFilesOutsideExportFile; + } + + public function setIncludeSiteConfigurations(bool $includeSiteConfigurations): void + { + $this->includeSiteConfigurations = $includeSiteConfigurations; + } +} diff --git a/Classes/Import.php b/Classes/Import.php new file mode 100644 index 0000000..b06ee83 --- /dev/null +++ b/Classes/Import.php @@ -0,0 +1,1677 @@ +fetchStorages(); + } + + /** + * Disable automatic site configuration import. Call this when the invoking + * code (e.g. a distribution installer) handles site configuration itself. + */ + public function disableSiteConfigurationImport(): void + { + $this->importSiteConfigurations = false; + } + + /** + * Fetch all available file storages and index by storage UID + * + * Note: It also creates a default storage record if the database table sys_file_storage is empty, + * e.g. during tests. + */ + protected function fetchStorages(): void + { + $this->storages = []; + $this->storagesAvailableForImport = []; + $this->defaultStorage = null; + + $this->storageRepository->flush(); + + $storages = $this->storageRepository->findAll(); + foreach ($storages as $storage) { + $this->storages[$storage->getUid()] = $storage; + if ($storage->isOnline() && $storage->isWritable() && $storage->getDriverType() === 'Local') { + $this->storagesAvailableForImport[$storage->getUid()] = $storage; + } + if ($this->defaultStorage === null && $storage->isDefault()) { + $this->defaultStorage = $storage; + } + } + } + + /** + * Loads the TYPO3 import file $fileName into memory. + * + * @param string $fileName File path, has to be within the TYPO3's base folder + * @throws LoadingFileFailedException + */ + public function loadFile(string $fileName): void + { + $filePath = GeneralUtility::getFileAbsFileName($fileName); + + if (empty($filePath)) { + $this->addError('File path is not valid: ' . $fileName); + } elseif (!@is_file($filePath)) { + $this->addError('File not found: ' . $filePath); + } + + if ($this->hasErrors()) { + throw new LoadingFileFailedException( + sprintf('Loading of the import file "%s" failed.', $fileName), + 1484484619 + ); + } + + $pathInfo = pathinfo($filePath); + $fileExtension = strtolower($pathInfo['extension']); + + if (!in_array($fileExtension, $this->supportedFileExtensions, true)) { + $this->addError( + sprintf( + 'File extension "%s" is not valid. Supported file extensions are %s.', + $fileExtension, + implode(', ', array_map(static function ($supportedFileExtension) { + return '"' . $supportedFileExtension . '"'; + }, $this->supportedFileExtensions)) + ) + ); + } + + if ($this->hasErrors() === false) { + if (@is_dir($filePath . '.files')) { + if (GeneralUtility::isAllowedAbsPath($filePath . '.files')) { + // copy the folder lowlevel to typo3temp, because the files would be deleted after import + GeneralUtility::copyDirectory($filePath . '.files', $this->getOrCreateTemporaryFolderName()); + } else { + $this->addError('External import files for the given import source is currently not supported.'); + } + $this->isFilesSavedOutsideImportFile = true; + } else { + $this->isFilesSavedOutsideImportFile = false; + } + if ($fileExtension === 'xml') { + $xmlContent = (string)file_get_contents($filePath); + if (strlen($xmlContent)) { + try { + $dat = (new Typo3XmlParser())->decode( + $xmlContent, + new Typo3XmlSerializerOptions([ + Typo3XmlSerializerOptions::RETURN_ROOT_NODE_NAME => true, + Typo3XmlSerializerOptions::LOAD_OPTIONS => \LIBXML_NONET | \LIBXML_NOBLANKS | \LIBXML_PARSEHUGE, + // @todo check if needed for imports/throw deprecation for invalid xml + Typo3XmlSerializerOptions::ALLOW_UNDEFINED_NAMESPACES, + ]) + ); + $this->dat = is_array($dat) ? $dat : [$dat]; + if ($this->dat['_DOCUMENT_TAG'] === 'T3RecordDocument' && is_array($this->dat['header'] ?? null) && is_array($this->dat['records'] ?? null)) { + $this->loadInit(); + } else { + $this->addError('XML file did not contain proper XML for TYPO3 Import'); + } + } catch (\Throwable $e) { + $this->addError('XML could not be parsed: ' . $e->getMessage()); + } + } else { + $this->addError('Error opening file: ' . $filePath); + } + } elseif ($fileExtension === 't3d') { + if ($fd = fopen($filePath, 'rb')) { + $this->dat['header'] = $this->getNextFilePart($fd, 'header'); + $this->dat['records'] = $this->getNextFilePart($fd, 'records'); + $this->dat['files'] = $this->getNextFilePart($fd, 'files'); + $this->dat['files_fal'] = $this->getNextFilePart($fd, 'files_fal'); + $this->loadInit(); + fclose($fd); + } else { + $this->addError('Error opening file: ' . $filePath); + } + } + } + + if ($this->hasErrors()) { + throw new LoadingFileFailedException( + sprintf('Loading of the import file "%s" failed.', $fileName), + 1484484620 + ); + } + } + + /** + * Extracts the next content part of the T3D file + * + * @param resource $fd Import file pointer + * @param string $name For error messages this indicates the section of the problem. + * @return array|null Data array or NULL in case of an error + */ + protected function getNextFilePart($fd, string $name): ?array + { + $headerLength = 32 + 1 + 1 + 1 + 10 + 1; + $headerString = fread($fd, $headerLength); + if (empty($headerString)) { + $this->addError('File does not contain data for "' . $name . '"'); + return null; + } + + $header = explode(':', $headerString); + if (str_contains($header[0], 'Warning')) { + $this->addError('File read error: Warning message in file. (' . $headerString . fgets($fd) . ')'); + return null; + } + if ((string)$header[3] !== '') { + $this->addError('File read error: InitString had a wrong length. (' . $name . ')'); + return null; + } + + $dataString = (string)fread($fd, (int)$header[2]); + $isDataCompressed = $header[1] === '1'; + fread($fd, 1); + if (!hash_equals($header[0], md5($dataString))) { + $this->addError('MD5 check failed (' . $name . ')'); + return null; + } + + if ($isDataCompressed) { + if (!function_exists('gzuncompress')) { + $this->addError('Content read error: This file requires decompression, ' + . 'but this server does not offer gzcompress()/gzuncompress() functions.'); + return null; + } + $dataString = (string)gzuncompress($dataString); + } + + return unserialize($dataString, ['allowed_classes' => false]) ?: null; + } + + /** + * Setting up the object based on the recently loaded ->dat array + */ + protected function loadInit(): void + { + $this->relStaticTables = (array)($this->dat['header']['relStaticTables'] ?? []); + $this->excludeMap = (array)($this->dat['header']['excludeMap'] ?? []); + $this->softrefCfg = (array)($this->dat['header']['softrefCfg'] ?? []); + } + + public function getMetaData(): array + { + return $this->dat['header']['meta'] ?? []; + } + + public function getSiteConfigurations(): array + { + $siteConfigurations = $this->dat['header']['site_configurations'] ?? []; + foreach ($siteConfigurations as $identifier => $config) { + $rootPageId = (int)($config['rootPageId'] ?? 0); + $siteConfigurations[$identifier]['_rootPageTitle'] = $this->dat['header']['records']['pages'][$rootPageId]['title'] ?? ''; + } + return $siteConfigurations; + } + + /** + * Checks all requirements that must be met before import. + * + * @throws PrerequisitesNotMetException + */ + public function checkImportPrerequisites(): void + { + // Check #1: Extension dependencies + $extKeysToInstall = []; + foreach ($this->dat['header']['extensionDependencies'] ?? [] as $extKey) { + if (!empty($extKey) && !ExtensionManagementUtility::isLoaded($extKey)) { + $extKeysToInstall[] = $extKey; + } + } + if ($extKeysToInstall !== []) { + $this->addError( + sprintf( + 'Before you can import this file you need to install the extensions "%s".', + implode('", "', $extKeysToInstall) + ) + ); + } + + // Check #2: Presence of imported storage paths + foreach ($this->dat['header']['records']['sys_file_storage'] ?? [] as $sysFileStorageUid => $_) { + $storageRecord = &$this->dat['records']['sys_file_storage:' . $sysFileStorageUid]['data']; + if ($storageRecord['driver'] === 'Local' + && ($storageRecord['is_writable'] ?? 1) + && ($storageRecord['is_online'] ?? 1) + ) { + $storageMapUid = -1; + foreach ($this->storages as $storage) { + if ($this->isEquivalentStorage($storage, $storageRecord)) { + $storageMapUid = $storage->getUid(); + break; + } + } + // The storage from the import does not have an equivalent storage + // in the current instance (same driver, same path, etc.). Before + // the storage record can get inserted later on take care the path + // it points to really exists and is accessible. + if ($storageMapUid === -1) { + // Unset the storage record UID when trying to create the storage object + // as the record does not already exist in database. The constructor of the + // storage object will check whether the target folder exists and set the + // isOnline flag depending on the outcome. + $storageRecordWithUid0 = $storageRecord; + $storageRecordWithUid0['uid'] = 0; + $storageObject = $this->storageRepository->createFromRecord($storageRecordWithUid0); + if (!$storageObject->isOnline()) { + $configuration = $storageObject->getConfiguration(); + $this->addError( + sprintf( + 'The file storage "%s" does not exist. ' + . 'Please create the directory prior to starting the import!', + $storageObject->getName() . $configuration['basePath'] + ) + ); + } + } + } + } + + if ($this->hasErrors()) { + throw new PrerequisitesNotMetException( + 'Prerequisites for file import are not met.', + 1484484612 + ); + } + } + + /** + * Imports the memory data into the TYPO3 database. + * + * @throws ImportFailedException + */ + public function importData(): void + { + $this->initializeImport(); + + // Write sys_file_storages first + $this->writeSysFileStorageRecords(); + // Write sys_file records and write the binary file data + $this->writeSysFileRecords(); + // Write records, first pages, then the rest + // Fields with "hard" relations to database, files and flexform fields are kept empty during this run + $this->writePages(); + $this->writeRecords(); + // Finally all the file and database record references must be fixed. This is done after all records have supposedly + // been written to database. $this->importMapId will indicate two things: + // 1) that a record WAS written to db and + // 2) that it has got a new id-number. + $this->setRelations(); + // And when all database relations are in place, we can fix file and database relations in flexform fields + // - since data structures often depend on relations to a DS record: + $this->setFlexFormRelations(); + // Finally, traverse all records and process soft references with substitution attributes. + $this->processSoftReferences(); + // Write site configurations bundled in the export, with rootPageId remapped to imported UIDs. + // Skipped when the caller handles site configuration import itself (e.g. distribution installers) + // or when the current user is not an admin (site configurations are admin-only). + if ($this->importSiteConfigurations && $this->getBackendUser()->isAdmin()) { + $this->processSiteConfigurations(); + } + // Cleanup + $this->removeTemporaryFolderName(); + + if ($this->hasErrors()) { + throw new ImportFailedException('The import has failed.', 1484484613); + } + } + + /** + * Write site configurations embedded in the import file. + * Skips any site whose identifier already exists. + * Remaps rootPageId from the export UID to the newly imported UID. + */ + protected function processSiteConfigurations(): void + { + $siteConfigurations = $this->dat['header']['site_configurations'] ?? []; + if (!is_array($siteConfigurations) || $siteConfigurations === []) { + return; + } + $importedPageIds = $this->importMapId['pages'] ?? []; + foreach ($siteConfigurations as $siteIdentifier => $configuration) { + if (!is_string($siteIdentifier) || !is_array($configuration)) { + continue; + } + $exportedRootPageId = (int)($configuration['rootPageId'] ?? 0); + $importedRootPageId = $importedPageIds[$exportedRootPageId] ?? null; + if ($importedRootPageId === null) { + continue; + } + + // Skip if a site configuration already exists for this root page. + try { + $this->siteFinder->getSiteByRootPageId((int)$importedRootPageId); + continue; + } catch (SiteNotFoundException) { + // No site exists yet — proceed with creating one. + } + + // Find a free identifier — never merge into an existing site config. + $targetIdentifier = $siteIdentifier; + $counter = 0; + while (true) { + try { + $this->siteFinder->getSiteByIdentifier($targetIdentifier); + $targetIdentifier = $siteIdentifier . '-' . (++$counter); + } catch (SiteNotFoundException) { + break; + } + } + + $configuration['rootPageId'] = (int)$importedRootPageId; + $configuration['base'] = '/' . $targetIdentifier . '/'; + // @TODO Add error handling / routes etc where page ids are used and configured + + try { + $this->siteWriter->write($targetIdentifier, $configuration); + } catch (SiteConfigurationWriteException) { + // Site configuration write failures are non-fatal; the imported data remains intact. + } + } + } + + /** + * Initialize all settings for the import + */ + protected function initializeImport(): void + { + $this->doesImport = true; + $this->importMapId = []; + $this->importNewId = []; + $this->importNewIdPids = []; + } + + /** + * Imports the sys_file_storage records from memory data. + */ + protected function writeSysFileStorageRecords(): void + { + if (!isset($this->dat['header']['records']['sys_file_storage'])) { + return; + } + + $importData = []; + + $storageUidsToBeResetToDefaultStorage = []; + foreach ($this->dat['header']['records']['sys_file_storage'] as $sysFileStorageUid => $_) { + $storageRecord = &$this->dat['records']['sys_file_storage:' . $sysFileStorageUid]['data']; + if ($storageRecord['driver'] === 'Local' + && ($storageRecord['is_writable'] ?? 1) + && ($storageRecord['is_online'] ?? 1) + ) { + foreach ($this->storages as $storage) { + if ($this->isEquivalentStorage($storage, $storageRecord)) { + $this->importMapId['sys_file_storage'][$sysFileStorageUid] = $storage->getUid(); + break; + } + } + + if (!isset($this->importMapId['sys_file_storage'][$sysFileStorageUid])) { + // Local, writable and online storage. May be used later for writing files. + // Does not currently exist, mark the storage for import. + $this->addSingle($importData, 'sys_file_storage', $sysFileStorageUid, 0); + } + } else { + // Storage with non-local drivers can be imported, but must not be used to save files as you cannot + // be sure that this is supported. In this case the default storage is used. Non-writable and + // non-online storage may be created as duplicates because you were unable to check the detailed + // configuration options at that time. + $this->addSingle($importData, 'sys_file_storage', $sysFileStorageUid, 0); + $storageUidsToBeResetToDefaultStorage[] = $sysFileStorageUid; + } + } + + // Write new storages to the database + $dataHandler = $this->createDataHandler(); + // Because all records are submitted in the correct order with positive pid numbers, + // we should internally reverse the order of submission. + $dataHandler->reverseOrder = true; + $dataHandler->isImporting = true; + $dataHandler->start($importData, []); + $dataHandler->process_datamap(); + $this->addToMapId($importData, $dataHandler->substNEWwithIDs); + + // Refresh internal storage representation after potential storage import + $this->fetchStorages(); + + // Map references of non-local / non-writable / non-online storages to the default storage + $defaultStorageUid = $this->defaultStorage?->getUid(); + foreach ($storageUidsToBeResetToDefaultStorage as $storageUidToBeResetToDefaultStorage) { + $this->importMapId['sys_file_storage'][$storageUidToBeResetToDefaultStorage] = $defaultStorageUid; + } + + // Unset the sys_file_storage records to prevent an import in writeRecords() + unset($this->dat['header']['records']['sys_file_storage']); + } + + /** + * Determines whether the passed storage object and the storage record (sys_file_storage) can be considered + * equivalent during the import. + * + * @param ResourceStorage $storageObject The storage object which should get compared + * @param array $storageRecord The storage record which should get compared + * @return bool Returns TRUE if both storage representations can be considered equal + */ + protected function isEquivalentStorage(ResourceStorage $storageObject, array &$storageRecord): bool + { + if ($storageObject->getDriverType() === $storageRecord['driver'] + && $storageObject->isWritable() === (bool)($storageRecord['is_writable'] ?? 1) + && $storageObject->isOnline() === (bool)($storageRecord['is_online'] ?? 1) + ) { + $storageRecordConfiguration = $this->flexFormTools->convertFlexFormContentToArray($storageRecord['configuration'] ?? ''); + $storageObjectConfiguration = $storageObject->getConfiguration(); + if ($storageRecordConfiguration['pathType'] === $storageObjectConfiguration['pathType'] + && $storageRecordConfiguration['basePath'] === $storageObjectConfiguration['basePath'] + ) { + return true; + } + } + return false; + } + + /** + * Imports the sys_file records and the binary files data from internal data array. + */ + protected function writeSysFileRecords(): void + { + if (!isset($this->dat['header']['records']['sys_file'])) { + return; + } + + $this->addGeneralErrorsByTable('sys_file'); + + $temporaryFolder = $this->getOrCreateTemporaryFolderName(); + $sanitizedFolderMappings = []; + + foreach ($this->dat['header']['records']['sys_file'] as $sysFileUid => $_) { + $fileRecord = &$this->dat['records']['sys_file:' . $sysFileUid]['data']; + + $temporaryFile = null; + $temporaryFilePath = $temporaryFolder . '/' . $fileRecord['sha1']; + + if ($this->isFilesSavedOutsideImportFile) { + if (is_file($temporaryFilePath) && sha1_file($temporaryFilePath) === $fileRecord['sha1']) { + $temporaryFile = $temporaryFilePath; + } else { + $this->addError(sprintf( + 'Error: Temporary file %s could not be found or does not match the checksum!', + $temporaryFilePath + )); + continue; + } + } else { + $fileId = md5($fileRecord['storage'] . ':' . $fileRecord['identifier_hash']); + if (isset($this->dat['files_fal'][$fileId]['content'])) { + $fileInfo = &$this->dat['files_fal'][$fileId]; + if (GeneralUtility::writeFile($temporaryFilePath, $fileInfo['content'], true)) { + clearstatcache(); + $temporaryFile = $temporaryFilePath; + } else { + $this->addError(sprintf( + 'Error: Temporary file %s was not written as it should have been!', + $temporaryFilePath + )); + continue; + } + } else { + $this->addError(sprintf('Error: No file found for ID %s', $fileId)); + continue; + } + } + + $storageUid = $this->importMapId['sys_file_storage'][$fileRecord['storage']] ?? $fileRecord['storage']; + if (isset($this->storagesAvailableForImport[$storageUid])) { + $storage = $this->storagesAvailableForImport[$storageUid]; + } elseif ($storageUid === 0 || $storageUid === '0') { + $storage = $this->storageRepository->findByUid(0); + } elseif ($this->defaultStorage !== null) { + $storage = $this->defaultStorage; + } else { + $this->addError(sprintf( + 'Error: No storage available for the file "%s" with storage uid "%s"', + $fileRecord['identifier'], + $fileRecord['storage'] + )); + continue; + } + + $file = null; + try { + if ($storage->hasFile($fileRecord['identifier'])) { + /** @var File $file */ + $file = $storage->getFile($fileRecord['identifier']); + if ($file->getSha1() !== $fileRecord['sha1']) { + $file = null; + } + } + } catch (Exception $e) { + // @todo: Can this exception be thrown anywhere? + $file = null; + } + + if ($file === null) { + $folderName = PathUtility::dirname(ltrim($fileRecord['identifier'], '/')); + if (in_array($folderName, $sanitizedFolderMappings, true)) { + $folderName = $sanitizedFolderMappings[$folderName]; + } + if (!$storage->hasFolder($folderName)) { + try { + $importFolder = $storage->createFolder($folderName); + if ($importFolder->getIdentifier() !== $folderName && !in_array($folderName, $sanitizedFolderMappings, true)) { + $sanitizedFolderMappings[$folderName] = $importFolder->getIdentifier(); + } + } catch (Exception $e) { + $this->addError(sprintf( + 'Error: Folder "%s" could not be created for file "%s" with storage uid "%s"', + $folderName, + $fileRecord['identifier'], + $fileRecord['storage'] + )); + continue; + } + } else { + $importFolder = $storage->getFolder($folderName); + } + + $this->callHook('before_addSysFileRecord', [ + 'fileRecord' => $fileRecord, + 'importFolder' => $importFolder, + 'temporaryFile' => $temporaryFile, + ]); + + try { + $this->skipResourceConsistencyCheckForCommands($storage, $temporaryFile, $fileRecord['name']); + $file = $storage->addFile($temporaryFile, $importFolder, $fileRecord['name']); + } catch (Exception $e) { + $this->addError(sprintf( + 'Error: File could not be added to the storage: "%s" with storage uid "%s"', + $fileRecord['identifier'], + $fileRecord['storage'] + )); + continue; + } + + if ($file->getSha1() !== $fileRecord['sha1']) { + $this->addError(sprintf( + 'Error: The hash of the written file is not identical to the import data! ' + . 'File could be corrupted! File: "%s" with storage uid "%s"', + $fileRecord['identifier'], + $fileRecord['storage'] + )); + } + } + + // save the new uid in the import id map + $this->importMapId['sys_file'][$fileRecord['uid']] = $file->getUid(); + $this->fixUidLocalInSysFileReferenceRecords((int)$fileRecord['uid'], $file->getUid()); + } + + // unset the sys_file records to prevent an import in writeRecords() + unset($this->dat['header']['records']['sys_file']); + // remove all sys_file_reference records that point to file records which are unknown + // in the system to prevent exceptions + $this->removeSysFileReferenceRecordsWithRelationToMissingFile(); + } + + /** + * Normally the importer works like the following: + * Step 1: import the records with cleared field values of relation fields (see addSingle()) + * Step 2: update the records with the right relation ids (see setRelations()) + * + * In step 2 the saving fields of type "relation to sys_file_reference" checks the related sys_file_reference + * record (created in step 1) with the FileExtensionFilter for matching file extensions of the related file. + * To make this work correct, the uid_local of sys_file_reference records has to be not empty AND has to + * relate to the correct (imported) sys_file record uid! + * + * This is fixed here. + * + * @param int $oldFileUid + * @param int $newFileUid + */ + protected function fixUidLocalInSysFileReferenceRecords(int $oldFileUid, int $newFileUid): void + { + if (!isset($this->dat['header']['records']['sys_file_reference'])) { + return; + } + + foreach ($this->dat['header']['records']['sys_file_reference'] as $sysFileReferenceUid => $_) { + if (!isset($this->dat['records']['sys_file_reference:' . $sysFileReferenceUid]['hasBeenMapped']) + && $this->dat['records']['sys_file_reference:' . $sysFileReferenceUid]['data']['uid_local'] == $oldFileUid + ) { + $this->dat['records']['sys_file_reference:' . $sysFileReferenceUid]['hasBeenMapped'] = true; + $this->dat['records']['sys_file_reference:' . $sysFileReferenceUid]['data']['uid_local'] = $newFileUid; + } + } + } + + /** + * Removes all sys_file_reference records from the import data array that are pointing to sys_file records which + * are missing in the import data to prevent exceptions on checking the related file started by the DataHandler. + */ + protected function removeSysFileReferenceRecordsWithRelationToMissingFile(): void + { + if (!isset($this->dat['header']['records']['sys_file_reference'])) { + return; + } + + foreach ($this->dat['header']['records']['sys_file_reference'] as $sysFileReferenceUid => $_) { + $fileReferenceRecord = &$this->dat['records']['sys_file_reference:' . $sysFileReferenceUid]['data']; + if (!in_array($fileReferenceRecord['uid_local'], (array)($this->importMapId['sys_file'] ?? []))) { + unset($this->dat['header']['records']['sys_file_reference'][$sysFileReferenceUid]); + unset($this->dat['records']['sys_file_reference:' . $sysFileReferenceUid]); + $this->addError(sprintf( + 'Error: sys_file_reference record "%s" with relation to sys_file record "%s"' + . ', which is not part of the import data, was not imported.', + $sysFileReferenceUid, + $fileReferenceRecord['uid_local'] + )); + } + } + } + + /** + * Writing page tree / pages to database: + * If the operation is an update operation, the root of the page tree inside will be moved to $this->pid + * unless it is the same as the root page from the import. + */ + protected function writePages(): void + { + if (!isset($this->dat['header']['records']['pages'])) { + return; + } + + $importData = []; + + // Add page tree + $remainingPages = $this->dat['header']['records']['pages']; + if (is_array($this->dat['header']['pagetree'] ?? null)) { + $pageList = []; + $this->flatInversePageTree($this->dat['header']['pagetree'], $pageList); + foreach ($pageList as $pageUid => $_) { + $pid = $this->dat['header']['records']['pages'][$pageUid]['pid'] ?? null; + if ($pid !== null) { + $pid = (int)$pid; + } + $pid = $this->importNewIdPids[$pid ?? ''] ?? $this->pid; + $this->addSingle($importData, 'pages', (int)$pageUid, $pid); + unset($remainingPages[$pageUid]); + } + } + + // Add remaining pages on root level + foreach ($remainingPages as $pageUid => $_) { + $this->addSingle($importData, 'pages', (int)$pageUid, $this->pid); + } + + // Write pages to the database + $dataHandler = $this->createDataHandler(); + $dataHandler->isImporting = true; + $this->callHook('before_writeRecordsPages', [ + 'tce' => $dataHandler, + 'data' => &$importData, + ]); + $dataHandler->suggestedInsertUids = $this->suggestedInsertUids; + $dataHandler->start($importData, []); + $dataHandler->process_datamap(); + $this->callHook('after_writeRecordsPages', [ + 'tce' => $dataHandler, + ]); + $this->addToMapId($importData, $dataHandler->substNEWwithIDs); + + // Sort pages + $this->writePagesOrder(); + } + + /** + * Organize all updated pages in page tree so they are related like in the import file. + * Only used for updates. + */ + protected function writePagesOrder(): void + { + if (!$this->update || !is_array($this->dat['header']['pagetree'] ?? null)) { + return; + } + + $importCmd = []; + + // Get uid-pid relations and traverse them in order to map to possible new IDs + $pageList = []; + $this->flatInversePageTree($this->dat['header']['pagetree'], $pageList); + foreach ($pageList as $pageUid => $pagePid) { + if ($pagePid >= 0 && $this->doRespectPid('pages', $pageUid)) { + // If the page has been assigned a new ID (because it was created), use that instead! + if (!MathUtility::canBeInterpretedAsInteger($this->importNewIdPids[$pageUid])) { + if ($this->importMapId['pages'][$pageUid]) { + $mappedUid = $this->importMapId['pages'][$pageUid]; + $importCmd['pages'][$mappedUid]['move'] = $pagePid; + } + } else { + $importCmd['pages'][$pageUid]['move'] = $pagePid; + } + } + } + + // Move pages in the database + if (!empty($importCmd)) { + $dataHandler = $this->createDataHandler(); + $this->callHook('before_writeRecordsPagesOrder', [ + 'tce' => &$dataHandler, + 'data' => &$importCmd, + ]); + $dataHandler->start([], $importCmd); + $dataHandler->process_cmdmap(); + $this->callHook('after_writeRecordsPagesOrder', [ + 'tce' => &$dataHandler, + ]); + } + } + + /** + * Checks if the position of an updated record is configured to be corrected. + * This can be disabled globally and changed individually for elements. + * + * @param string $table Table name + * @param int $uid Record UID + * @return bool TRUE if the position of the record should be updated to match the one in the import structure + */ + protected function doRespectPid(string $table, int $uid): bool + { + return ($this->importMode[$table . ':' . $uid] ?? '') !== self::IMPORT_MODE_IGNORE_PID + && (!$this->globalIgnorePid || ($this->importMode[$table . ':' . $uid] ?? '') === self::IMPORT_MODE_RESPECT_PID); + } + + /** + * Write all database records except pages (written in writePages()) + */ + protected function writeRecords(): void + { + $importData = []; + + // Write the rest of the records + if (is_array($this->dat['header']['records'] ?? null)) { + foreach ($this->dat['header']['records'] as $table => $records) { + $this->addGeneralErrorsByTable($table); + if (!$this->tcaSchemaFactory->has($table)) { + continue; + } + if ($table !== 'pages') { + $schema = $this->tcaSchemaFactory->get($table); + $rootLevelCapability = $schema->getCapability(TcaSchemaCapability::RestrictionRootLevel); + foreach ($records as $uid => $record) { + // PID: Set the main $this->pid, unless a NEW-id is found + $pid = isset($this->importMapId['pages'][$record['pid']]) + ? (int)$this->importMapId['pages'][$record['pid']] + : $this->pid; + if ($rootLevelCapability->getRootLevelType() === RootLevelCapability::TYPE_ONLY_ON_ROOTLEVEL) { + $pid = 0; + } elseif (!$rootLevelCapability->canExistOnRootLevel() && $pid === 0) { + $this->addError('Error: Record type ' . $table . ' is not allowed on pid 0'); + continue; + } + // Add record + $this->addSingle($importData, $table, $uid, $pid); + } + } + } + } else { + $this->addError('Error: No records defined in internal data array.'); + } + + // Write records to the database + $dataHandler = $this->createDataHandler(); + $this->callHook('before_writeRecordsRecords', [ + 'tce' => $dataHandler, + 'data' => &$importData, + ]); + $dataHandler->suggestedInsertUids = $this->suggestedInsertUids; + // Because all records are submitted in the correct order with positive pid numbers, + // we should internally reverse the order of submission. + $dataHandler->reverseOrder = true; + $dataHandler->isImporting = true; + $dataHandler->start($importData, []); + $dataHandler->process_datamap(); + $this->callHook('after_writeRecordsRecords', [ + 'tce' => $dataHandler, + ]); + $this->addToMapId($importData, $dataHandler->substNEWwithIDs); + + // Sort records + $this->writeRecordsOrder(); + } + + /** + * Organize all updated records so they are related like in the import file. + * Only used for updates. + */ + protected function writeRecordsOrder(): void + { + if (!$this->update) { + return; + } + + $importCmd = []; + + $pageList = []; + if (is_array($this->dat['header']['pagetree'] ?? null)) { + $this->flatInversePageTree($this->dat['header']['pagetree'], $pageList); + } + // @todo: drop by-reference and write final $this->dat at the end of method?! + if (is_array($this->dat['header']['pid_lookup'] ?? null)) { + foreach ($this->dat['header']['pid_lookup'] as $pid => &$recordsByPid) { + $mappedPid = $this->importMapId['pages'][$pid] ?? $this->pid; + if (MathUtility::canBeInterpretedAsInteger($mappedPid)) { + foreach ($recordsByPid as $table => &$records) { + // If $mappedPid === $this->pid then we are on root level and we can consider to move pages as well! + // (they will not be in the page tree!) + if ($table !== 'pages' || !isset($pageList[$pid])) { + foreach (array_reverse(array_keys($records)) as $uid) { + if ($this->doRespectPid($table, (int)$uid)) { + if (isset($this->importMapId[$table][$uid])) { + $mappedUid = $this->importMapId[$table][$uid]; + $importCmd[$table][$mappedUid]['move'] = $mappedPid; + } + } + } + } + } + } + } + } + + // Move records in the database + if (!empty($importCmd)) { + $dataHandler = $this->createDataHandler(); + $this->callHook('before_writeRecordsRecordsOrder', [ + 'tce' => $dataHandler, + 'data' => &$importCmd, + ]); + $dataHandler->start([], $importCmd); + $dataHandler->process_cmdmap(); + $this->callHook('after_writeRecordsRecordsOrder', [ + 'tce' => $dataHandler, + ]); + } + } + + /** + * Adds a single record to the $importData array. Also copies files to the temporary folder. + * However all file and database references and flexform fields are set to blank for now! + * That is processed with setRelations() later. + * + * @param array $importData Data to be modified or inserted in the database during import + * @param string $table Table name + * @param int $uid Record UID + * @param int|string $pid Page id or NEW-id, e.g. "NEW5fb3c2641281c885267727" + */ + protected function addSingle(array &$importData, string $table, int $uid, $pid): void + { + // @todo return modified $importData instead of by-reference. + if (($this->importMode[$table . ':' . $uid] ?? '') === self::IMPORT_MODE_EXCLUDE) { + return; + } + + $record = $this->dat['records'][$table . ':' . $uid]['data'] ?? null; + + if (!is_array($record)) { + if (!($table === 'pages' && $uid === 0)) { + // On root level we don't want this error message. + $this->addError('Error: No record was found in data array!'); + } + return; + } + + // Generate record ID + $ID = StringUtility::getUniqueId('NEW'); + if ($this->update + && $this->getRecordFromDatabase($table, $uid) !== null + && ($this->importMode[$table . ':' . $uid] ?? '') !== self::IMPORT_MODE_AS_NEW + ) { + $ID = $uid; + } elseif ($table === 'sys_file_metadata' + && $record['language_tag'] === '0' + && isset($this->importMapId['sys_file'][$record['file']]) + ) { + // On adding sys_file records the belonging sys_file_metadata record was also created: + // If there is one, the record needs to be overwritten instead of a new one created. + $databaseRecord = $this->getSysFileMetaDataFromDatabase( + $this->importMapId['sys_file'][$record['file']], + 0 + ); + if (is_array($databaseRecord)) { + $this->importMapId['sys_file_metadata'][$record['uid']] = $databaseRecord['uid']; + $ID = $databaseRecord['uid']; + } + } + + // Mapping of generated record ID to original record UID + $this->importNewId[$table . ':' . $ID] = ['table' => $table, 'uid' => $uid]; + if ($table === 'pages') { + $this->importNewIdPids[$uid] = $ID; + } + + // Record data + $importData[$table][$ID] = $record; + $importData[$table][$ID]['tx_impexp_origuid'] = $importData[$table][$ID]['uid']; + + // Record permissions + if ($table === 'pages') { + // Have to reset the user/group IDs so pages are owned by the importing user. + // Otherwise strange things may happen for non-admins! + unset($importData[$table][$ID]['perms_userid']); + unset($importData[$table][$ID]['perms_groupid']); + } + + // Record UID and PID + unset($importData[$table][$ID]['uid']); + // - for existing record + if (MathUtility::canBeInterpretedAsInteger($ID)) { + unset($importData[$table][$ID]['pid']); + } + // - for new record + else { + $importData[$table][$ID]['pid'] = $pid; + if ((($this->importMode[$table . ':' . $uid] ?? '') === self::IMPORT_MODE_FORCE_UID && $this->update + || $this->forceAllUids) + && $this->getBackendUser()->isAdmin() + ) { + $importData[$table][$ID]['uid'] = $uid; + $this->suggestedInsertUids[$table . ':' . $uid] = 'DELETE'; + } + } + + // Record relations + $schema = $this->tcaSchemaFactory->get($table); + foreach ($this->dat['records'][$table . ':' . $uid]['rels'] ?? [] as $field => &$relation) { + switch ($relation['type'] ?? '') { + case 'db': + case 'file': + // Set blank now, fix later in setRelations(), + // because we need to know ALL newly created IDs before we can map relations! + // In the meantime we set NO values for relations. + // + // BUT for field uid_local of table sys_file_reference the relation MUST not be cleared here, + // because the value is already the uid of the right imported sys_file record. + // @see fixUidLocalInSysFileReferenceRecords() + // If it's empty or a uid to another record the FileExtensionFilter will throw an exception or + // delete the reference record if the file extension of the related record doesn't match. + if (!($table === 'sys_file_reference' && $field === 'uid_local') && $schema->hasField($field)) { + $importData[$table][$ID][$field] = $this->getReferenceDefaultValue($schema->getField($field)->getConfiguration()); + } + $translationSourceFieldName = null; + if ($schema->isLanguageAware()) { + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $translationSourceFieldName = $languageCapability->getTranslationSourceField()?->getName(); + } + + // Set to "0" for integer fields, or else we will get a db error in DataHandler persistence. + if ($translationSourceFieldName && $field === $translationSourceFieldName) { + $importData[$table][$ID][$field] = 0; + } + break; + case 'flex': + // Set blank now, fix later in setFlexFormRelations(). + // In the meantime we set NO values for flexforms - this is mainly because file references + // inside will not be processed properly. In fact references will point to no file + // or existing files (in which case there will be double-references which is a big problem of + // course!). + // + // BUT for the field "configuration" of the table "sys_file_storage" the relation MUST NOT be + // cleared, because the configuration array contains only string values, which are furthermore + // important for the further import, e.g. the base path. + if (!($table === 'sys_file_storage' && $field === 'configuration')) { + $importData[$table][$ID][$field] = $this->getReferenceDefaultValue($schema->getField($field)->getConfiguration()); + } + break; + } + } + } + + /** + * Get the default value for a reference field. + * + * @param array $configuration The TCA configuration of the accordant field + */ + protected function getReferenceDefaultValue(array $configuration): int|float|string + { + if (!empty($configuration['MM']) || !empty($configuration['foreign_field'])) { + return 0; + } + if (array_key_exists('default', $configuration)) { + return $configuration['default']; + } + return ''; + } + + /** + * Selects sys_file_metadata database record. + */ + protected function getSysFileMetaDataFromDatabase(int $file, int $sysLanguageUid): ?array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file_metadata'); + $databaseRecord = $queryBuilder->select('uid') + ->from('sys_file_metadata') + ->where( + $queryBuilder->expr()->eq( + 'file', + $queryBuilder->createNamedParameter($file, Connection::PARAM_INT) + ), + $queryBuilder->expr()->eq( + 'sys_language_uid', + $queryBuilder->createNamedParameter($sysLanguageUid, Connection::PARAM_INT) + ) + ) + ->executeQuery() + ->fetchAssociative(); + return is_array($databaseRecord) ? $databaseRecord : null; + } + + /** + * Store the mapping between the import file record UIDs and the final record UIDs in the database after import. + * + * @param array $importData Data to be modified or inserted in the database during import + * @param array $substNEWwithIDs A map between the "NEW..." string IDs and the eventual record UID in database + */ + protected function addToMapId(array $importData, array $substNEWwithIDs): void + { + foreach ($importData as $table => $records) { + foreach ($records as $ID => $_) { + $uid = $this->importNewId[$table . ':' . $ID]['uid']; + if (isset($substNEWwithIDs[$ID])) { + $this->importMapId[$table][$uid] = $substNEWwithIDs[$ID]; + } elseif ($this->update) { + // Map same ID to same ID.... + $this->importMapId[$table][$uid] = $ID; + } else { + // If $this->importMapId contains already the right mapping, skip the error message. + // See special handling of sys_file_metadata in addSingle() => nothing to do. + if (!($table === 'sys_file_metadata' + && isset($this->importMapId[$table][$uid]) + && $this->importMapId[$table][$uid] == $ID) + ) { + $this->addError( + 'Possible error: ' . $table . ':' . $uid . ' had no new id assigned to it. ' + . 'This indicates that the record was not added to database during import. ' + . 'Please check changelog!' + ); + } + } + } + } + } + + protected function createDataHandler(): DataHandler + { + $dataHandler = GeneralUtility::makeInstance(DataHandler::class); + $dataHandler->dontProcessTransformations = true; + $dataHandler->enableLogging = $this->enableLogging; + return $dataHandler; + } + + /** + * At the end of the import process all file and database relations should be set properly. + * This means that the relations to imported records are all recreated so that the imported + * records are correctly related again. + * Relations in flexform fields are processed in setFlexFormRelations() after this function. + */ + protected function setRelations(): void + { + $updateData = []; + + foreach ($this->importNewId as $original) { + $table = $original['table']; + $uid = $original['uid']; + + if (isset($this->importMapId[$table][$uid])) { + if (!$this->tcaSchemaFactory->has($table)) { + $this->addError(sprintf('Error: This record does not have a TCA schema! (%s:%s)', $table, $uid)); + } else { + $schema = $this->tcaSchemaFactory->get($table); + $actualUid = BackendUtility::wsMapId($table, $this->importMapId[$table][$uid]); + foreach ($this->dat['records'][$table . ':' . $uid]['rels'] ?? [] as $field => $relation) { + // Field "uid_local" of sys_file_reference needs no update because the correct reference uid was already written. + // @see ImportExport::fixUidLocalInSysFileReferenceRecords() + if (isset($relation['type']) && !($table === 'sys_file_reference' && $field === 'uid_local') && $relation['type'] === 'db') { + if (!$schema->hasField($field)) { + $this->addError(sprintf('Error: Missing TCA "config" for field "%s:%s"', $table, $field)); + } elseif (is_array($relation['itemArray'] ?? null) && !empty($relation['itemArray'])) { + $fieldInfo = $schema->getField($field); + $actualRelations = $this->remapRelationsOfField($relation['itemArray'], $fieldInfo->getConfiguration(), $field); + $updateData[$table][$actualUid][$field] = implode(',', $actualRelations); + } + } + } + } + } else { + $this->addError(sprintf('Error: This record does not appear to have been created! (%s:%s)', $table, $uid)); + } + } + + if (!empty($updateData)) { + $dataHandler = $this->createDataHandler(); + $dataHandler->isImporting = true; + $this->callHook('before_setRelation', [ + 'tce' => $dataHandler, + 'data' => &$updateData, + ]); + $dataHandler->start($updateData, []); + $dataHandler->process_datamap(); + $this->callHook('after_setRelations', [ + 'tce' => $dataHandler, + ]); + } + } + + /** + * Maps the original record UIDs of the relations to the actual UIDs of the imported records and returns relations + * as strings of type [table]_[uid] - or file:[uid] or [public url] for field of type "group" and internal_type + * "file_reference". These strings have the regular DataHandler input group/select type format which means + * they will automatically be processed into a list of UIDs or MM relations. + * + * @param array $fieldRelations Relations with original record UIDs + * @param array $fieldConfig TCA configuration of the record field the relations belong to + * @param string $field The TCA fieldname of the relation operated on + * @return array Array of relation strings with actual record UIDs + */ + protected function remapRelationsOfField(array $fieldRelations, array $fieldConfig, string $field = ''): array + { + $actualRelations = []; + foreach ($fieldRelations as $relation) { + if (!$this->tcaSchemaFactory->has($relation['table'])) { + $this->addError('Lost relation due to missing TCA schema: ' . $relation['table'] . ':' . $relation['id']); + } elseif (isset($this->importMapId[$relation['table']][$relation['id']])) { + $schema = $this->tcaSchemaFactory->get($relation['table']); + $translationSourceFieldName = null; + if ($schema->isLanguageAware()) { + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $translationSourceFieldName = $languageCapability->getTranslationSourceField()?->getName(); + } + $actualUid = $this->importMapId[$relation['table']][$relation['id']]; + if ($fieldConfig['type'] === 'input' && isset($fieldConfig['wizards']['link'])) { + // If an input field has a relation to a sys_file record this need to be converted back to + // the public path. But use getPublicUrl() here, because could normally only be a local file path. + try { + $file = $this->resourceFactory->retrieveFileOrFolderObject($actualUid); + $actualRelations[] = $file->getPublicUrl(); + } catch (\Exception) { + $actualRelations[] = 'file:' . $actualUid; + } + } elseif ($translationSourceFieldName && $field === $translationSourceFieldName) { + // "l10n_source" is of type "passthrough" so the "_" syntax won't be replaced. + $actualRelations[] = $actualUid; + } else { + $actualRelations[] = $relation['table'] . '_' . $actualUid; + } + } elseif ($this->isTableStatic($relation['table']) || $this->isRecordExcluded($relation['table'], (int)$relation['id']) || $relation['id'] < 0) { + // Some select types could contain negative values, e.g. fe_groups (-1, -2). + // This must be handled on both export and import. + $actualRelations[] = $relation['table'] . '_' . $relation['id']; + } else { + $this->addError('Lost relation: ' . $relation['table'] . ':' . $relation['id']); + } + } + + return $actualRelations; + } + + /** + * After all database relations have been set in the end of the import (see setRelations()) then it is time to + * correct all relations inside FlexForm fields. The reason for doing this after is that the setting of relations + * may affect (quite often!) which data structure is used for the FlexForm field! + */ + protected function setFlexFormRelations(): void + { + $updateData = []; + + foreach ($this->importNewId as $original) { + $table = $original['table']; + $uid = $original['uid']; + + if (isset($this->importMapId[$table][$uid])) { + if (!$this->tcaSchemaFactory->has($table)) { + $this->addError(sprintf('Error: This record does not appear to have a TCA schema! (%s:%s)', $table, $uid)); + } else { + $schema = $this->tcaSchemaFactory->get($table); + $actualUid = BackendUtility::wsMapId($table, $this->importMapId[$table][$uid]); + foreach ($this->dat['records'][$table . ':' . $uid]['rels'] ?? [] as $field => $relation) { + // Field "configuration" of sys_file_storage needs no update because it has not been removed + // and has no relations. + // @see Import::addSingle() + if (isset($relation['type']) && $relation['type'] == 'flex' && !($table === 'sys_file_storage' && $field === 'configuration')) { + // Re-insert temporarily removed original FlexForm data as fallback + // @see Import::addSingle() + $updateData[$table][$actualUid][$field] = $this->dat['records'][$table . ':' . $uid]['data'][$field]; + if (!empty($relation['flexFormRels']['db']) && $schema->hasField($field)) { + $fieldInfo = $schema->getField($field); + if (!$fieldInfo->isType(TableColumnType::FLEX)) { + continue; + } + $actualRecord = BackendUtility::getRecord($table, $actualUid); + if (is_array($actualRecord)) { + $schema = $this->tcaSchemaFactory->get($table); + $dataStructureIdentifier = $this->flexFormTools->getDataStructureIdentifier( + ['config' => $fieldInfo->getConfiguration()], + $table, + $field, + $actualRecord, + $schema + ); + $dataStructure = $this->flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier, $schema); + $flexFormData = (new Typo3XmlParser())->decodeWithReturningExceptionAsString( + (string)($this->dat['records'][$table . ':' . $uid]['data'][$field] ?? ''), + new Typo3XmlSerializerOptions([ + Typo3XmlSerializerOptions::ALLOW_UNDEFINED_NAMESPACES, + ]) + ); + if (is_array($flexFormData['data'] ?? null)) { + $flexFormData['data'] = $this->remapFlexFormRelationsInData( + $flexFormData['data'], + $dataStructure, + $relation + ); + } + if (is_array($flexFormData['data'] ?? null)) { + $updateData[$table][$actualUid][$field] = $flexFormData; + } + } + } + } + } + } + } else { + $this->addError(sprintf('Error: This record does not appear to have been created! (%s:%s)', $table, $uid)); + } + } + + if (!empty($updateData)) { + $dataHandler = $this->createDataHandler(); + $dataHandler->isImporting = true; + $this->callHook('before_setFlexFormRelations', [ + 'tce' => $dataHandler, + 'data' => &$updateData, + ]); + $dataHandler->start($updateData, []); + $dataHandler->process_datamap(); + $this->callHook('after_setFlexFormRelations', [ + 'tce' => $dataHandler, + ]); + } + } + + private function remapFlexFormRelationsInData(array $data, array $dataStructure, array $relation): array + { + foreach ($dataStructure['sheets'] as $sheetKey => $sheetData) { + foreach (($sheetData['ROOT']['el'] ?? []) as $sheetElementKey => $sheetElementTca) { + if (($sheetElementTca['type'] ?? '') === 'array') { + // Section element. + if (!is_array($sheetElementTca['el'] ?? false) || !is_array($data[$sheetKey]['lDEF'][$sheetElementKey]['el'] ?? false)) { + continue; + } + foreach ($data[$sheetKey]['lDEF'][$sheetElementKey]['el'] as $valueSectionContainerKey => $valueSectionContainers) { + if (!is_array($valueSectionContainers ?? false)) { + continue; + } + foreach ($valueSectionContainers as $valueContainerType => $valueContainerElements) { + if (!is_array($sheetElementTca['el'][$valueContainerType]['el'] ?? false)) { + continue; + } + foreach ($sheetElementTca['el'][$valueContainerType]['el'] as $containerElement => $containerElementTca) { + if (!isset($data[$sheetKey]['lDEF'][$sheetElementKey]['el'][$valueSectionContainerKey][$valueContainerType]['el'][$containerElement]['vDEF'])) { + continue; + } + $structurePath = $sheetKey . '/lDEF/' . $sheetElementKey . '/el/' . $valueSectionContainerKey . '/' . $valueContainerType . '/el/' . $containerElement . '/vDEF/'; + $fieldRelations = $relation['flexFormRels']['db'][$structurePath] + ?? $relation['flexFormRels']['db'][rtrim($structurePath, '/')] + ?? null; + if (is_array($fieldRelations)) { + $data[$sheetKey]['lDEF'][$sheetElementKey]['el'][$valueSectionContainerKey][$valueContainerType]['el'][$containerElement]['vDEF'] + = implode(',', $this->remapRelationsOfField($fieldRelations, $containerElementTca['config'] ?? [])); + } + } + } + } + } elseif (isset($data[$sheetKey]['lDEF'][$sheetElementKey]['vDEF'])) { + // Simple field element. + $structurePath = $sheetKey . '/lDEF/' . $sheetElementKey . '/vDEF/'; + $fieldRelations = $relation['flexFormRels']['db'][$structurePath] + ?? $relation['flexFormRels']['db'][rtrim($structurePath, '/')] + ?? null; + if (is_array($fieldRelations)) { + $data[$sheetKey]['lDEF'][$sheetElementKey]['vDEF'] + = implode(',', $this->remapRelationsOfField($fieldRelations, $sheetElementTca['config'] ?? [])); + } + } + } + } + return $data; + } + + /************************** + * Import soft references + *************************/ + + /** + * Processing of soft references + */ + protected function processSoftReferences(): void + { + $updateData = []; + + foreach ($this->dat['header']['records'] ?? [] as $table => $records) { + if (!$this->tcaSchemaFactory->has($table)) { + continue; + } + $schema = $this->tcaSchemaFactory->get($table); + foreach ($records as $uid => $record) { + if (is_array($record['softrefs'] ?? null)) { + $actualUid = BackendUtility::wsMapId($table, $this->importMapId[$table][$uid] ?? 0); + // First, group soft references by record field ... + // (this could probably also have been done with $this->dat['records'] instead of $this->dat['header']) + $softrefs = []; + foreach ($record['softrefs'] as $softref) { + if ($softref['field'] && is_array($softref['subst'] ?? null) && $softref['subst']['tokenID']) { + $softrefs[$softref['field']][$softref['subst']['tokenID']] = $softref; + } + } + // ... then process only fields which require substitution. + foreach ($softrefs as $field => $softrefsByField) { + if ($schema->hasField($field)) { + $fieldInfo = $schema->getField($field); + if ($fieldInfo->isType(TableColumnType::FLEX)) { + $actualRecord = BackendUtility::getRecord($table, $actualUid, '*'); + if (is_array($actualRecord)) { + $schema = $this->tcaSchemaFactory->get($table); + $dataStructureIdentifier = $this->flexFormTools->getDataStructureIdentifier( + ['config' => $fieldInfo->getConfiguration()], + $table, + $field, + $actualRecord, + $schema + ); + $dataStructure = $this->flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier, $schema); + $flexFormData = (new Typo3XmlParser())->decodeWithReturningExceptionAsString( + (string)($actualRecord[$field] ?? ''), + new Typo3XmlSerializerOptions([ + Typo3XmlSerializerOptions::ALLOW_UNDEFINED_NAMESPACES, + ]) + ); + if (is_array($flexFormData['data'] ?? null)) { + $flexFormData['data'] = $this->processFlexFormSoftRefsInData( + $flexFormData['data'], + $dataStructure, + $table, + $uid, + $field, + $softrefsByField + ); + } + if (is_array($flexFormData['data'] ?? null)) { + $updateData[$table][$actualUid][$field] = $flexFormData; + } + } + } else { + // Get tokenizedContent string and proceed only if that is not blank: + $tokenizedContent = $this->dat['records'][$table . ':' . $uid]['rels'][$field]['softrefs']['tokenizedContent'] ?? ''; + if ($tokenizedContent !== '') { + $updateData[$table][$actualUid][$field] = $this->processSoftReferencesSubstTokens($tokenizedContent, $softrefsByField, $table, (string)$uid); + } + } + } + } + } + } + } + + // Update soft references in the database + $dataHandler = $this->createDataHandler(); + $dataHandler->isImporting = true; + $this->callHook('before_processSoftReferences', [ + 'tce' => $dataHandler, + 'data' => &$updateData, + ]); + $dataHandler->enableLogging = true; + $dataHandler->start($updateData, []); + $dataHandler->process_datamap(); + $this->callHook('after_processSoftReferences', [ + 'tce' => $dataHandler, + ]); + } + + private function processFlexFormSoftRefsInData(array $data, array $dataStructure, string $table, string|int $origUid, string $field, array $softrefs): array + { + foreach ($dataStructure['sheets'] as $sheetKey => $sheetData) { + foreach (($sheetData['ROOT']['el'] ?? []) as $sheetElementKey => $sheetElementTca) { + if (($sheetElementTca['type'] ?? '') === 'array') { + // Section element. + if (!is_array($sheetElementTca['el'] ?? false) || !is_array($data[$sheetKey]['lDEF'][$sheetElementKey]['el'] ?? false)) { + continue; + } + foreach ($data[$sheetKey]['lDEF'][$sheetElementKey]['el'] as $valueSectionContainerKey => $valueSectionContainers) { + if (!is_array($valueSectionContainers ?? false)) { + continue; + } + foreach ($valueSectionContainers as $valueContainerType => $valueContainerElements) { + if (!is_array($sheetElementTca['el'][$valueContainerType]['el'] ?? false)) { + continue; + } + foreach (array_keys($sheetElementTca['el'][$valueContainerType]['el']) as $containerElement) { + if (!isset($data[$sheetKey]['lDEF'][$sheetElementKey]['el'][$valueSectionContainerKey][$valueContainerType]['el'][$containerElement]['vDEF'])) { + continue; + } + $structurePath = $sheetKey . '/lDEF/' . $sheetElementKey . '/el/' . $valueSectionContainerKey . '/' . $valueContainerType . '/el/' . $containerElement . '/vDEF/'; + $softrefsByPath = array_filter($softrefs, static fn(array $softref): bool => $softref['structurePath'] === $structurePath); + if (!empty($softrefsByPath)) { + $tokenizedContent = $this->dat['records'][$table . ':' . $origUid]['rels'][$field]['flexFormRels']['softrefs'][$structurePath]['tokenizedContent'] ?? ''; + if ($tokenizedContent !== '') { + $data[$sheetKey]['lDEF'][$sheetElementKey]['el'][$valueSectionContainerKey][$valueContainerType]['el'][$containerElement]['vDEF'] + = $this->processSoftReferencesSubstTokens($tokenizedContent, $softrefsByPath, $table, (string)$origUid); + } + } + } + } + } + } elseif (isset($data[$sheetKey]['lDEF'][$sheetElementKey]['vDEF'])) { + // Simple field element. + $structurePath = $sheetKey . '/lDEF/' . $sheetElementKey . '/vDEF/'; + $softrefsByPath = array_filter($softrefs, static fn(array $softref): bool => $softref['structurePath'] === $structurePath); + if (!empty($softrefsByPath)) { + $tokenizedContent = $this->dat['records'][$table . ':' . $origUid]['rels'][$field]['flexFormRels']['softrefs'][$structurePath]['tokenizedContent'] ?? ''; + if ($tokenizedContent !== '') { + $data[$sheetKey]['lDEF'][$sheetElementKey]['vDEF'] + = $this->processSoftReferencesSubstTokens($tokenizedContent, $softrefsByPath, $table, (string)$origUid); + } + } + } + } + } + return $data; + } + + /** + * Substitution of soft reference tokens + * + * @param string $tokenizedContent Content of field with soft reference tokens in. + * @param array $softrefs Soft references + * @param string $table Table of record for which the processing occurs + * @param string $uid UID of record from table + * @return string The input content with tokens substituted according to entries in $softrefs + */ + protected function processSoftReferencesSubstTokens(string $tokenizedContent, array $softrefs, string $table, string $uid): string + { + foreach ($softrefs as $softref) { + $tokenID = $softref['subst']['tokenID']; + $insertValue = $softref['subst']['tokenValue']; + switch ((string)($this->softrefCfg[$tokenID]['mode'] ?? '')) { + case self::SOFTREF_IMPORT_MODE_EXCLUDE: + // This is the same as handling static relations: + // Do not create or update the related file or record and do not change the link in any way, + // but use the link as it was when exported. + break; + case self::SOFTREF_IMPORT_MODE_EDITABLE: + // This is the same as "exclude" with the option to manually edit the link before importing. + $insertValue = $this->softrefInputValues[$tokenID]; + break; + default: + // This is almost the same as handling relations: + // - Adjusting the record reference to link to the already imported record - if any. + [$tempTable, $tempUid] = explode(':', (string)($softref['subst']['recordRef'] ?? ':')); + if (isset($this->importMapId[$tempTable][$tempUid])) { + $insertValue = BackendUtility::wsMapId($tempTable, $this->importMapId[$tempTable][$tempUid]); + $tokenValue = (string)$softref['subst']['tokenValue']; + if (str_contains($tokenValue, ':')) { + [$tokenKey] = explode(':', $tokenValue); + $insertValue = $tokenKey . ':' . $insertValue; + } + } + } + // Finally, replace the soft reference token in tokenized content + $tokenizedContent = str_replace('{softref:' . $tokenID . '}', (string)$insertValue, $tokenizedContent); + } + return $tokenizedContent; + } + + /** + * @param string $name Name of the hook + * @param array $params Array with params + */ + protected function callHook(string $name, array $params): void + { + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/impexp/class.tx_impexp.php'][$name] ?? [] as $hook) { + GeneralUtility::callUserFunction($hook, $params, $this); + } + } + + public function setEnableLogging(bool $enableLogging): void + { + $this->enableLogging = $enableLogging; + } +} diff --git a/Classes/ImportExport.php b/Classes/ImportExport.php new file mode 100644 index 0000000..21f84a7 --- /dev/null +++ b/Classes/ImportExport.php @@ -0,0 +1,1335 @@ +iconFactory = $iconFactory; + } + + public function injectTcaSchemaFactory(TcaSchemaFactory $tcaSchemaFactory): void + { + $this->tcaSchemaFactory = $tcaSchemaFactory; + } + + public function injectFileNameValidator(FileNameValidator $fileNameValidator): void + { + $this->fileNameValidator = $fileNameValidator; + } + + public function injectPageDoktypeRegistry(PageDoktypeRegistry $pageDoktypeRegistry): void + { + $this->pageDoktypeRegistry = $pageDoktypeRegistry; + } + + public function injectDefaultUploadFolderResolver(DefaultUploadFolderResolver $defaultUploadFolderResolver): void + { + $this->defaultUploadFolderResolver = $defaultUploadFolderResolver; + } + + public function injectResourceFactory(ResourceFactory $resourceFactory): void + { + $this->resourceFactory = $resourceFactory; + } + + public function injectDiffUtility(DiffUtility $diffUtility): void + { + $this->diffUtility = $diffUtility; + } + + /** + * Displays a preview of the import or export. + * + * @return array The preview data + */ + public function renderPreview(): array + { + $previewData = [ + 'update' => $this->update, + 'showDiff' => $this->showDiff, + 'insidePageTree' => [], + 'outsidePageTree' => [], + ]; + + if (!isset($this->dat['header']['pagetree']) && !isset($this->dat['header']['records'])) { + return $previewData; + } + + // Traverse header: + $this->remainHeader = $this->dat['header']; + + // Preview of the page tree to be exported + if (is_array($this->dat['header']['pagetree'] ?? null)) { + $this->traversePageTree($this->dat['header']['pagetree'], $previewData['insidePageTree']); + foreach ($previewData['insidePageTree'] as &$line) { + $line['controls'] = $this->renderControls($line); + $line['message'] = (!empty($line['msg']) && !$this->doesImport ? '' . htmlspecialchars($line['msg']) . '' : ''); + } + } + + // Preview the remaining records that were not included in the page tree + if (is_array($this->remainHeader['records'] ?? null)) { + if (is_array($this->remainHeader['records']['pages'] ?? null)) { + $this->traversePageRecords($this->remainHeader['records']['pages'], $previewData['outsidePageTree']); + } + $this->traverseAllRecords($this->remainHeader['records'], $previewData['outsidePageTree']); + foreach ($previewData['outsidePageTree'] as &$line) { + $line['controls'] = $this->renderControls($line); + $line['message'] = (!empty($line['msg']) && !$this->doesImport ? '' . htmlspecialchars($line['msg']) . '' : ''); + } + } + + return $previewData; + } + + /** + * Go through page tree for display + * + * @param array $pageTree Page tree array with uid/subrow (from ->dat[header][pagetree]) + * @param array $lines Output lines array + * @param int $indent Indentation level + */ + protected function traversePageTree(array $pageTree, array &$lines, int $indent = 0): void + { + foreach ($pageTree as $pageUid => $page) { + if ($this->excludeDisabledRecords === true && $this->isRecordDisabled('pages', $pageUid)) { + $this->excludePageAndRecords($pageUid, $page); + continue; + } + + // Add page + $this->addRecord('pages', $pageUid, $lines, $indent); + + // Add records + foreach ($this->dat['header']['pid_lookup'][$pageUid] ?? [] as $table => $records) { + $table = (string)$table; + if ($table !== 'pages') { + foreach (array_keys($records) as $uid) { + $this->addRecord($table, (int)$uid, $lines, $indent + 1); + } + } + } + unset($this->remainHeader['pid_lookup'][$pageUid]); + + // Add subtree + if (is_array($page['subrow'] ?? null)) { + $this->traversePageTree($page['subrow'], $lines, $indent + 1); + } + } + } + + /** + * Test whether a record is disabled (e.g. hidden) + * + * @param string $table Name of the records' database table + * @param int $uid Database uid of the record + * @return bool true if the record is disabled, false otherwise + */ + protected function isRecordDisabled(string $table, int $uid): bool + { + if (!$this->tcaSchemaFactory->has($table)) { + return false; + } + $schema = $this->tcaSchemaFactory->get($table); + if (!$schema->hasCapability(TcaSchemaCapability::RestrictionDisabledField)) { + return false; + } + $disabledFieldName = $schema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getFieldName(); + return (bool)($this->dat['records'][$table . ':' . $uid]['data'][$disabledFieldName] ?? false); + } + + /** + * Exclude a page, its sub pages (recursively) and records placed in them from this import/export + * + * @param int $pageUid Uid of the page to exclude + * @param array $page Page array with uid/subrow (from ->dat[header][pagetree]) + */ + protected function excludePageAndRecords(int $pageUid, array $page): void + { + // Exclude page + unset($this->remainHeader['records']['pages'][$pageUid]); + + // Exclude records + foreach ($this->dat['header']['pid_lookup'][$pageUid] ?? [] as $table => $records) { + if ($table !== 'pages') { + foreach (array_keys($records) as $uid) { + unset($this->remainHeader['records'][$table][$uid]); + } + } + } + unset($this->remainHeader['pid_lookup'][$pageUid]); + + // Exclude subtree + foreach ($page['subrow'] ?? [] as $subPageUid => $subPage) { + $this->excludePageAndRecords($subPageUid, $subPage); + } + } + + /** + * Go through remaining pages (not in tree) + * + * @param array $pageTree Page tree array with uid/subrow (from ->dat[header][pagetree]) + * @param array $lines Output lines array + */ + protected function traversePageRecords(array $pageTree, array &$lines): void + { + foreach ($pageTree as $pageUid => $_) { + // Add page + $this->addRecord('pages', (int)$pageUid, $lines, 0, true); + + // Add records + foreach ($this->dat['header']['pid_lookup'][$pageUid] ?? [] as $table => $records) { + if ($table !== 'pages') { + foreach (array_keys($records) as $uid) { + $this->addRecord((string)$table, (int)$uid, $lines, 2); + } + } + } + unset($this->remainHeader['pid_lookup'][$pageUid]); + } + } + + /** + * Go through ALL records (if the pages are displayed first, those will not be among these!) + * + * @param array $pageTree Page tree array with uid/subrow (from ->dat[header][pagetree]) + * @param array $lines Output lines array + */ + protected function traverseAllRecords(array $pageTree, array &$lines): void + { + foreach ($pageTree as $table => $records) { + $this->addGeneralErrorsByTable($table); + if ($table !== 'pages') { + foreach (array_keys($records) as $uid) { + $this->addRecord((string)$table, (int)$uid, $lines, 0, true); + } + } + } + } + + /** + * Log general error message for a given table + * + * @param string $table database table name + */ + protected function addGeneralErrorsByTable(string $table): void + { + if ($this->update && $table === 'sys_file') { + $this->addError('Updating sys_file records is not supported! They will be imported as new records!'); + } + if ($this->forceAllUids && $table === 'sys_file') { + $this->addError('Forcing uids of sys_file records is not supported! They will be imported as new records!'); + } + } + + /** + * Add a record, its relations and soft references, to the preview + * + * @param string $table Table name + * @param int $uid Record uid + * @param array $lines Output lines array + * @param int $indent Indentation level + * @param bool $checkImportInPidRecord If you want import validation, you can set this so it checks if the import can take place on the specified page. + */ + protected function addRecord(string $table, int $uid, array &$lines, int $indent, bool $checkImportInPidRecord = false): void + { + $record = $this->dat['header']['records'][$table][$uid] ?? null; + unset($this->remainHeader['records'][$table][$uid]); + if (!is_array($record) && !($table === 'pages' && $uid === 0)) { + $this->addError('MISSING RECORD: ' . $table . ':' . $uid); + } + + // Create record information for preview + $line = []; + $line['ref'] = $table . ':' . $uid; + $line['type'] = 'record'; + $line['msg'] = ''; + if ($table === '_SOFTREF_') { + // Record is a soft reference + $line['preCode'] = $this->renderIndent($indent); + $line['title'] = '' . htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_singlereco_softReferencesFiles')) . ''; + } elseif (!$this->tcaSchemaFactory->has($table)) { + // Record is of unknown table + $line['preCode'] = $this->renderIndent($indent); + $line['title'] = '' . htmlspecialchars((string)$record['title']) . ''; + $line['msg'] = 'UNKNOWN TABLE "' . $line['ref'] . '"'; + } else { + $pidRecord = $this->getPidRecord(); + $line['preCode'] = '' + . $this->renderIndent($indent) + . $this->iconFactory + ->getIconForRecord( + $table, + (array)($this->dat['records'][$table . ':' . $uid]['data'] ?? []), + IconSize::SMALL + ) + ->setTitle($line['ref']) + ->render(); + $line['title'] = htmlspecialchars($record['title'] ?? ''); + // Link to page view + if ($table === 'pages') { + $viewID = $this->mode === 'export' ? $uid : ($this->doesImport ? ($this->importMapId['pages'][$uid] ?? 0) : 0); + if ($viewID) { + $attributes = PreviewUriBuilder::create($viewID)->serializeDispatcherAttributes(); + if ($attributes) { + $line['title'] = sprintf('%s', $attributes, $line['title']); + } + } + } + $line['active'] = !$this->isRecordDisabled($table, $uid) ? 'active' : 'hidden'; + if ($this->mode === 'import' && $pidRecord !== null) { + $schema = $this->tcaSchemaFactory->get($table); + $rootLevelCapability = $schema->getCapability(TcaSchemaCapability::RestrictionRootLevel); + if ($checkImportInPidRecord) { + if (!$this->getBackendUser()->doesUserHaveAccess($pidRecord, ($table === 'pages' ? 8 : 16))) { + $line['msg'] .= '"' . $line['ref'] . '" cannot be INSERTED on this page! '; + } + if ($this->pid > 0 + && !$this->pageDoktypeRegistry->isRecordTypeAllowedForDoktype($table, $pidRecord['doktype']) + && !$rootLevelCapability->getRootLevelType() + ) { + $line['msg'] .= '"' . $table . '" cannot be INSERTED on this page type (change page type to "Folder".) '; + } + } + if (!$this->getBackendUser()->check('tables_modify', $table)) { + $line['msg'] .= 'You are not allowed to CREATE "' . $table . '" tables! '; + } + if ($schema->hasCapability(TcaSchemaCapability::AccessReadOnly)) { + $line['msg'] .= 'TABLE "' . $table . '" is READ ONLY! '; + } + if ($schema->hasCapability(TcaSchemaCapability::AccessAdminOnly) && !$this->getBackendUser()->isAdmin()) { + $line['msg'] .= 'TABLE "' . $table . '" is ADMIN ONLY! '; + } + if ($rootLevelCapability->getRootLevelType() === RootLevelCapability::TYPE_ONLY_ON_ROOTLEVEL) { + $line['msg'] .= 'TABLE "' . $table . '" will be inserted on ROOT LEVEL! '; + } + $databaseRecord = null; + if ($this->update) { + $databaseRecord = $this->getRecordFromDatabase($table, $uid, $this->showDiff ? '*' : 'uid,pid'); + if ($databaseRecord === null) { + $line['updatePath'] = 'NEW!'; + } else { + $line['updatePath'] = htmlspecialchars($this->getRecordPath((int)($databaseRecord['pid'] ?? 0))); + } + if ($table === 'sys_file') { + $line['updateMode'] = ''; + } else { + $line['updateMode'] = $this->renderImportModeSelector( + $table, + $uid, + $databaseRecord !== null + ); + } + } + // Diff view + if ($this->showDiff) { + $diffInverse = $this->update; + // For imports, get new id: + if (isset($this->importMapId[$table][$uid]) && $newUid = $this->importMapId[$table][$uid]) { + $diffInverse = false; + $databaseRecord = $this->getRecordFromDatabase($table, $newUid, '*'); + BackendUtility::workspaceOL($table, $databaseRecord); + } + /** @var array|null $importRecord */ + $importRecord = $this->dat['records'][$table . ':' . $uid]['data'] ?? null; + if ($databaseRecord === null) { + $line['showDiffContent'] = ''; + } elseif (is_array($databaseRecord) && is_array($importRecord)) { + $line['showDiffContent'] = $this->compareRecords($databaseRecord, $importRecord, $table, $diffInverse); + } else { + $line['showDiffContent'] = 'ERROR: One of the inputs were not an array!'; + } + } + } + } + $lines[] = $line; + + // Database relations + if (is_array($record['rels'] ?? null)) { + $this->addRelations($record['rels'], $lines, $indent); + } + // Soft references + if (is_array($record['softrefs'] ?? null)) { + $this->addSoftRefs($record['softrefs'], $lines, $indent); + } + } + + /** + * Add database relations of a record to the preview + * + * @param array $relations Array of relations + * @param array $lines Output lines array + * @param int $indent Indentation level + * @param array $recursionCheck Recursion check stack + */ + protected function addRelations(array $relations, array &$lines, int $indent, array $recursionCheck = []): void + { + foreach ($relations as $relation) { + $table = $relation['table']; + $uid = $relation['id']; + $line = []; + $line['ref'] = $table . ':' . $uid; + $line['type'] = 'rel'; + $line['msg'] = ''; + if (in_array($line['ref'], $recursionCheck, true)) { + continue; + } + $iconName = 'status-status-checked'; + $staticFixed = false; + $record = null; + if ($uid > 0) { + $record = $this->dat['header']['records'][$table][$uid] ?? null; + if (!is_array($record)) { + if ($this->isTableStatic($table) || $this->isRecordExcluded($table, (int)$uid) + || ($relation['tokenID'] ?? '') && !$this->isSoftRefIncluded($relation['tokenID'] ?? '')) { + $line['title'] = htmlspecialchars('STATIC: ' . $line['ref']); + $staticFixed = true; + } else { + $databaseRecord = $this->getRecordFromDatabase($table, (int)$uid); + $recordPath = $this->getRecordPath($databaseRecord === null ? 0 : ($table === 'pages' ? (int)$databaseRecord['uid'] : (int)$databaseRecord['pid'])); + $line['title'] = sprintf( + '%s', + htmlspecialchars($recordPath), + htmlspecialchars($line['ref']) + ); + $line['msg'] = 'LOST RELATION' . ($databaseRecord === null ? ' (Record not found!)' : ' (Path: ' . $recordPath . ')'); + $iconName = 'status-dialog-warning'; + } + } else { + $recordPath = $this->getRecordPath($table === 'pages' ? (int)$record['uid'] : (int)$record['pid']); + $line['title'] = sprintf( + '%s', + htmlspecialchars($recordPath), + htmlspecialchars((string)$record['title']) + ); + } + } else { + // Negative values in relation fields. These are typically fields of e.g. fe_users. + // They are static values. They CAN theoretically be negative pointers to uids in other tables, + // but this is so rarely used that it is not supported. + $line['title'] = htmlspecialchars('FIXED: ' . $line['ref']); + $staticFixed = true; + } + + $line['preCode'] = '' + . $this->renderIndent($indent + 1) + . $this->iconFactory + ->getIcon($iconName, IconSize::SMALL) + ->setTitle($line['ref']) + ->render(); + if (!$staticFixed || $this->showStaticRelations) { + $lines[] = $line; + if (is_array($record) && is_array($record['rels'] ?? null)) { + $this->addRelations($record['rels'], $lines, $indent + 1, array_merge($recursionCheck, [$line['ref']])); + } + } + } + } + + /** + * Add soft references of a record to the preview + * + * @param array $softrefs Soft references + * @param array $lines Output lines array + * @param int $indent Indentation level + */ + protected function addSoftRefs(array $softrefs, array &$lines, int $indent): void + { + $languageService = $this->getLanguageService(); + foreach ($softrefs as $softref) { + $line = []; + $line['ref'] = 'SOFTREF'; + $line['type'] = 'softref'; + $line['msg'] = ''; + $line['preCode'] = '' + . $this->renderIndent($indent) + . $this->iconFactory + ->getIcon('status-reference-soft', IconSize::SMALL) + ->setTitle($line['ref']) + ->render(); + $line['title'] = sprintf( + '%s, "%s": %s', + $softref['field'], + $softref['spKey'], + htmlspecialchars($softref['matchString'] ?? ''), + htmlspecialchars(GeneralUtility::fixed_lgd_cs($softref['matchString'] ?? '', 60)) + ); + if ($softref['subst']['type'] ?? false) { + if ($softref['subst']['title'] ?? false) { + $line['title'] .= sprintf( + '
%s %s %s', + $this->renderIndent($indent + 1), + htmlspecialchars($languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_singlereco_title')), + htmlspecialchars(GeneralUtility::fixed_lgd_cs($softref['subst']['title'], 60)) + ); + } + if ($softref['subst']['description'] ?? false) { + $line['title'] .= sprintf( + '
%s %s %s', + $this->renderIndent($indent + 1), + htmlspecialchars($languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_singlereco_descr')), + htmlspecialchars(GeneralUtility::fixed_lgd_cs($softref['subst']['description'], 60)) + ); + } + if ($softref['subst']['type'] === 'db') { + $line['title'] .= sprintf( + '
%s %s %s', + $this->renderIndent($indent + 1), + htmlspecialchars($languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_softrefsel_record')), + $softref['subst']['recordRef'] + ); + } elseif ($softref['subst']['type'] === 'string') { + $line['title'] .= sprintf( + '
%s %s %s', + $this->renderIndent($indent + 1), + htmlspecialchars($languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_singlereco_value')), + $softref['subst']['tokenValue'] + ); + } + } + $line['_softRefInfo'] = $softref; + $tokenID = (string)($softref['subst']['tokenID'] ?? ''); + $mode = $tokenID === '' ? '' : ($this->softrefCfg[$tokenID]['mode'] ?? ''); + if (isset($softref['error']) && $mode !== Import::SOFTREF_IMPORT_MODE_EDITABLE && $mode !== Import::SOFTREF_IMPORT_MODE_EXCLUDE) { + $line['msg'] .= $softref['error']; + } + $lines[] = $line; + + // Add database relations + if (($softref['subst']['type'] ?? '') === 'db') { + [$referencedTable, $referencedUid] = explode(':', $softref['subst']['recordRef']); + $relations = [['table' => $referencedTable, 'id' => $referencedUid, 'tokenID' => $tokenID]]; + $this->addRelations($relations, $lines, $indent + 1); + } + } + } + + protected function renderIndent(int $indent): string + { + return $indent > 0 ? '' : ''; + } + + /** + * Render input controls for import or export + * + * @param array $line Output line array + * @return string HTML + */ + protected function renderControls(array $line): string + { + if ($this->mode === 'export') { + if ($line['type'] === 'record') { + return $this->renderRecordExcludeCheckbox($line['ref']); + } + if ($line['type'] === 'softref') { + return $this->renderSoftRefExportSelector($line['_softRefInfo']); + } + } elseif ($this->mode === 'import') { + if ($line['type'] === 'softref') { + return $this->renderSoftRefImportTextField($line['_softRefInfo']); + } + } + return ''; + } + + /** + * Render check box for exclusion of a record from export. + * + * @param string $recordRef The record ID of the form [table]:[id]. + * @return string HTML + */ + protected function renderRecordExcludeCheckbox(string $recordRef): string + { + return + '
' + . '' + . '' + . '
'; + } + + /** + * Render text field when importing a soft reference. + * + * @param array $softref Soft reference + * @return string HTML + */ + protected function renderSoftRefImportTextField(array $softref): string + { + if (isset($softref['subst']['tokenID'])) { + $tokenID = $softref['subst']['tokenID']; + $cfg = $this->softrefCfg[$tokenID] ?? []; + if (($cfg['mode'] ?? '') === Import::SOFTREF_IMPORT_MODE_EDITABLE) { + $html = ''; + if ($cfg['title'] ?? false) { + $html .= '' . htmlspecialchars((string)$cfg['title']) . '
'; + } + $html .= htmlspecialchars((string)$cfg['description']) . '
'; + $html .= sprintf( + '', + $tokenID, + htmlspecialchars($this->softrefInputValues[$tokenID] ?? $cfg['defValue']) + ); + return $html; + } + } + + return ''; + } + + /** + * Render select box with export options for soft references. + * An export box is shown only if a substitution scheme is found for the soft reference. + * + * @param array $softref Soft reference + * @return string HTML + */ + protected function renderSoftRefExportSelector(array $softref): string + { + $languageService = $this->getLanguageService(); + // Substitution scheme has to be around. + if (isset($softref['subst']['tokenID'])) { + $options = []; + $options[''] = ''; + $options[Import::SOFTREF_IMPORT_MODE_EDITABLE] = $languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_softrefsel_editable'); + $options[Import::SOFTREF_IMPORT_MODE_EXCLUDE] = $languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_softrefsel_exclude'); + $value = $this->softrefCfg[$softref['subst']['tokenID']]['mode'] ?? ''; + $selectHtml = $this->renderSelectBox( + 'tx_impexp[softrefCfg][' . $softref['subst']['tokenID'] . '][mode]', + $value, + $options + ); + $textFieldHtml = ''; + if ($value === Import::SOFTREF_IMPORT_MODE_EDITABLE) { + if ($softref['subst']['title'] ?? false) { + $textFieldHtml .= sprintf( + ' + + %2$s
', + $softref['subst']['tokenID'], + htmlspecialchars($softref['subst']['title']) + ); + } + if (!($softref['subst']['description'] ?? false)) { + $textFieldHtml .= sprintf( + ' + %s
+ ', + htmlspecialchars($languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_printerror_description')), + $softref['subst']['tokenID'], + htmlspecialchars($this->softrefCfg[$softref['subst']['tokenID']]['description'] ?? '') + ); + } else { + $textFieldHtml .= sprintf( + ' + %2$s', + $softref['subst']['tokenID'], + htmlspecialchars($softref['subst']['description']) + ); + } + $textFieldHtml .= sprintf( + ' + ', + $softref['subst']['tokenID'], + htmlspecialchars($softref['subst']['tokenValue']) + ); + } + return $selectHtml . $textFieldHtml; + } + return ''; + } + + /** + * Render select box with import options for the record. + * + * @param string $table Table name + * @param int $uid Record UID + * @param bool $doesRecordExist Is there already a record with this UID in the database? + * @return string HTML + */ + protected function renderImportModeSelector(string $table, int $uid, bool $doesRecordExist): string + { + $languageService = $this->getLanguageService(); + $options = []; + if (!$doesRecordExist) { + $options[] = $languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_singlereco_insert'); + if ($this->getBackendUser()->isAdmin()) { + $options[Import::IMPORT_MODE_FORCE_UID] = sprintf($languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_singlereco_forceUidSAdmin'), $uid); + } + } else { + $options[] = $languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_singlereco_update'); + $options[Import::IMPORT_MODE_AS_NEW] = $languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_singlereco_importAsNew'); + if (!$this->globalIgnorePid) { + $options[Import::IMPORT_MODE_IGNORE_PID] = $languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_singlereco_ignorePid'); + } else { + $options[Import::IMPORT_MODE_RESPECT_PID] = $languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_singlereco_respectPid'); + } + } + $options[Import::IMPORT_MODE_EXCLUDE] = $languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_singlereco_exclude'); + return $this->renderSelectBox( + 'tx_impexp[import_mode][' . $table . ':' . $uid . ']', + (string)($this->importMode[$table . ':' . $uid] ?? ''), + $options + ); + } + + /** + * Renders a select box from option values. + * + * @param string $name Form element name + * @param string $value Current value + * @param array $options Options to display (key/value pairs) + * @return string HTML + */ + protected function renderSelectBox(string $name, string $value, array $options): string + { + $optionsHtml = ''; + $isValueInOptions = false; + + foreach ($options as $k => $v) { + if ((string)$k === $value) { + $isValueInOptions = true; + $selectedHtml = ' selected="selected"'; + } else { + $selectedHtml = ''; + } + $optionsHtml .= sprintf( + '', + htmlspecialchars((string)$k), + $selectedHtml, + htmlspecialchars((string)$v) + ); + } + + // Append and select the current value as an option of the form "[value]" + // if it is not available in the options. + if (!$isValueInOptions && $value !== '') { + $optionsHtml .= sprintf( + '', + htmlspecialchars($value), + htmlspecialchars('[\'' . $value . '\']') + ); + } + + return ''; + } + + public function getOrCreateTemporaryFolderName(): string + { + if (empty($this->temporaryFolderName)) { + $this->temporaryFolderName = $this->createTemporaryFolderName(); + } + return $this->temporaryFolderName; + } + + protected function createTemporaryFolderName(): string + { + $temporaryPath = Environment::getVarPath() . '/transient'; + do { + $temporaryFolderName = sprintf( + '%s/impexp_%s_files_%d', + $temporaryPath, + $this->mode, + random_int(1, PHP_INT_MAX) + ); + } while (is_dir($temporaryFolderName)); + GeneralUtility::mkdir_deep($temporaryFolderName); + return $temporaryFolderName; + } + + public function removeTemporaryFolderName(): void + { + if (!empty($this->temporaryFolderName)) { + GeneralUtility::rmdir($this->temporaryFolderName, true); + $this->temporaryFolderName = null; + } + } + + /** + * Returns a \TYPO3\CMS\Core\Resource\Folder object for saving export files + * to the server and is also used for uploading import files. + */ + public function getOrCreateDefaultImportExportFolder(): ?Folder + { + if (empty($this->defaultImportExportFolder)) { + $this->createDefaultImportExportFolder(); + } + return $this->defaultImportExportFolder; + } + + /** + * Creates a \TYPO3\CMS\Core\Resource\Folder object for saving export files + * to the server and is also used for uploading import files. + */ + protected function createDefaultImportExportFolder(): void + { + $defaultTemporaryFolder = $this->getDefaultUploadTemporaryFolder(); + $defaultImportExportFolder = null; + if ($defaultTemporaryFolder !== null) { + $importExportFolderName = 'importexport'; + if ($defaultTemporaryFolder->hasFolder($importExportFolderName) === false) { + $defaultImportExportFolder = $defaultTemporaryFolder->createFolder($importExportFolderName); + } else { + $defaultImportExportFolder = $defaultTemporaryFolder->getSubfolder($importExportFolderName); + } + } + $this->defaultImportExportFolder = $defaultImportExportFolder; + } + + /** + * Returns a \TYPO3\CMS\Core\Resource\Folder object that could be used for uploading + * temporary files in user context. The folder _temp_ below the default upload folder + * of the user is used. + */ + protected function getDefaultUploadTemporaryFolder(): ?Folder + { + $defaultFolder = $this->defaultUploadFolderResolver->resolve($this->getBackendUser()); + if ($defaultFolder !== false) { + $tempFolderName = '_temp_'; + $createFolder = !$defaultFolder->hasFolder($tempFolderName); + if ($createFolder === true) { + try { + return $defaultFolder->createFolder($tempFolderName); + } catch (Exception $folderAccessException) { + } + } else { + return $defaultFolder->getSubfolder($tempFolderName); + } + } + return null; + } + + public function removeDefaultImportExportFolder(): void + { + if (!empty($this->defaultImportExportFolder)) { + $this->defaultImportExportFolder->delete(); + $this->defaultImportExportFolder = null; + } + } + + /** + * Checks if the input path relative to the public web path can be found in the file mounts of the backend user. + * If not, it checks all file mounts of the user for the relative path and returns it if found. + * + * @param string $dirPrefix Path relative to public web path. + * @param bool $checkAlternatives If set to false, do not look for an alternative path. + * @return string|null If a path is available, it will be returned, otherwise NULL. + * @throws \Exception + */ + protected function resolveStoragePath(string $dirPrefix, bool $checkAlternatives = true): ?string + { + try { + $this->resourceFactory->getFolderObjectFromCombinedIdentifier($dirPrefix); + return $dirPrefix; + } catch (InsufficientFolderAccessPermissionsException) { + if ($checkAlternatives) { + $storagesByUser = $this->getBackendUser()->getFileStorages(); + foreach ($storagesByUser as $storage) { + try { + $folder = $storage->getFolder(rtrim($dirPrefix, '/')); + return $folder->getPublicUrl(); + } catch (InsufficientFolderAccessPermissionsException) { + } + } + } + } + return null; + } + + /** + * Recursively flattening the $pageTree array to a one-dimensional array with uid-pid pairs. + * + * @param array $pageTree Page tree array + * @param array $list List with uid-pid pairs + * @param int $pid PID value (internal, don't set from outside) + */ + protected function flatInversePageTree(array $pageTree, array &$list, int $pid = -1): void + { + // @todo: return $list instead of by-reference?! + $pageTreeInverse = array_reverse($pageTree); + foreach ($pageTreeInverse as $page) { + $list[$page['uid']] = $pid; + if (is_array($page['subrow'] ?? null)) { + $this->flatInversePageTree($page['subrow'], $list, (int)$page['uid']); + } + } + } + + /** + * Returns TRUE if the input table name is to be regarded as a static relation (that is, not exported etc). + * + * @param string $table Table name + * @return bool TRUE, if table is marked static + */ + protected function isTableStatic(string $table): bool + { + if (!$this->tcaSchemaFactory->has($table)) { + return false; + } + return in_array($table, $this->relStaticTables, true) + || in_array('_ALL', $this->relStaticTables, true); + } + + /** + * Returns TRUE if the element should be excluded from import and export. + * + * @param string $table Table name + * @param int $uid Record UID + * @return bool TRUE, if the record should be excluded + */ + protected function isRecordExcluded(string $table, int $uid): bool + { + return (bool)($this->excludeMap[$table . ':' . $uid] ?? false); + } + + /** + * Returns TRUE if the soft reference should be included in export. + * + * @param string $tokenID Token ID for soft reference + * @return bool TRUE, if soft reference should be included + */ + protected function isSoftRefIncluded(string $tokenID): bool + { + $mode = $this->softrefCfg[$tokenID]['mode'] ?? ''; + return $tokenID && $mode !== Import::SOFTREF_IMPORT_MODE_EXCLUDE && $mode !== Import::SOFTREF_IMPORT_MODE_EDITABLE; + } + + /** + * Returns given fields of record if it exists. + * + * @param string $table Table name + * @param int $uid UID of record + * @param string $fields Field list to select. Default is "uid,pid" + * @return array|null Result of \TYPO3\CMS\Backend\Utility\BackendUtility::getRecord() which means the record if found, otherwise NULL + */ + protected function getRecordFromDatabase(string $table, int $uid, string $fields = 'uid,pid'): ?array + { + return BackendUtility::getRecord($table, $uid, $fields); + } + + /** + * Returns the page title path of a PID value. Results are cached internally + * + * @param int $pid Record PID to check + * @return string The path for the input PID + */ + protected function getRecordPath(int $pid): string + { + if (!isset($this->cacheGetRecordPath[$pid])) { + $this->cacheGetRecordPath[$pid] = (string)BackendUtility::getRecordPath( + $pid, + $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW), + 20 + ); + } + return $this->cacheGetRecordPath[$pid]; + } + + /** + * Compares two records, the current database record and the one from the import memory. + * Will return HTML code to show any differences between them! + * + * @param array $databaseRecord Database record, all fields (old values) + * @param array $importRecord Import memory record for the same table/uid, all fields (new values) + * @param string $table The table name of the record + * @param bool $inverse Inverse the diff view (switch red/green, needed for pre-update difference view) + * @return string HTML + */ + protected function compareRecords(array $databaseRecord, array $importRecord, string $table, bool $inverse = false): string + { + $diffHtml = ''; + + $languageService = $this->getLanguageService(); + $schema = $this->tcaSchemaFactory->get($table); + // Updated fields + foreach ($databaseRecord as $fieldName => $_) { + if (!$schema->hasField($fieldName)) { + continue; + } + $fieldInfo = $schema->getField($fieldName); + if (!$fieldInfo->isType(TableColumnType::PASSTHROUGH)) { + if (isset($importRecord[$fieldName])) { + if (trim((string)$databaseRecord[$fieldName]) !== trim((string)$importRecord[$fieldName])) { + $diffFieldHtml = $this->diffUtility->diff( + strip_tags((string)BackendUtility::getProcessedValue( + $table, + $fieldName, + !$inverse ? $importRecord[$fieldName] : $databaseRecord[$fieldName], + 0, + true, + true + )), + strip_tags((string)BackendUtility::getProcessedValue( + $table, + $fieldName, + !$inverse ? $databaseRecord[$fieldName] : $importRecord[$fieldName], + 0, + true, + true + )) + ); + $diffHtml .= sprintf( + '%s (%s)%s' . PHP_EOL, + htmlspecialchars($languageService->sL($fieldInfo->getLabel())), + htmlspecialchars((string)$fieldName), + $diffFieldHtml + ); + } + unset($importRecord[$fieldName]); + } + } + } + + // New fields + foreach ($importRecord as $fieldName => $_) { + if (!$schema->hasField($fieldName)) { + continue; + } + $fieldInfo = $schema->getField($fieldName); + if (!$fieldInfo->isType(TableColumnType::PASSTHROUGH)) { + $diffFieldHtml = 'Field missing in database'; + $diffHtml .= sprintf( + '%s (%s)%s' . PHP_EOL, + htmlspecialchars($languageService->sL($fieldInfo->getLabel())), + htmlspecialchars((string)$fieldName), + $diffFieldHtml + ); + } + } + + if ($diffHtml !== '') { + $diffHtml = '' . PHP_EOL . $diffHtml . '
'; + } else { + $diffHtml = 'Match'; + } + + return sprintf( + '[%s]:' . PHP_EOL . '%s', + htmlspecialchars($table . ':' . $importRecord['uid'] . ' => ' . $databaseRecord['uid']), + $diffHtml + ); + } + + /** + * Returns file processing object, initialized only once. + * + * @return ExtendedFileUtility File processor object + */ + protected function getFileProcObj(): ExtendedFileUtility + { + if ($this->fileProcObj === null) { + $this->fileProcObj = GeneralUtility::makeInstance(ExtendedFileUtility::class); + $this->fileProcObj->setActionPermissions(); + } + return $this->fileProcObj; + } + + /** + * Sets error message in the internal error log + * + * @param string $message Error message + */ + protected function addError(string $message): void + { + $this->errorLog[] = $message; + } + + public function hasErrors(): bool + { + return empty($this->errorLog) === false; + } + + protected function getBackendUser(): BackendUserAuthentication + { + return $GLOBALS['BE_USER']; + } + + protected function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } + + public function getPid(): int + { + return $this->pid; + } + + public function setPid(int $pid): void + { + $this->pid = $pid; + $this->pidRecord = null; + } + + /** + * Return record of root page of import or of export page tree + * - or null if access denied to that page. + * + * If the page is the root of the page tree, + * add some basic but missing information. + */ + protected function getPidRecord(): ?array + { + if ($this->pidRecord === null && $this->pid >= 0) { + $pidRecord = BackendUtility::readPageAccess( + $this->pid, + $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW) + ); + if (is_array($pidRecord)) { + if ($this->pid === 0) { + $pidRecord += ['title' => '[root-level]', 'uid' => 0, 'pid' => 0]; + } + $this->pidRecord = $pidRecord; + } + } + + return $this->pidRecord; + } + + /** + * Set flag to control whether disabled records and their children are excluded (true) or included (false). Defaults + * to the old behaviour of including everything. + * + * @param bool $excludeDisabledRecords Set to true if if all disabled records should be excluded, false otherwise + */ + public function setExcludeDisabledRecords(bool $excludeDisabledRecords): void + { + $this->excludeDisabledRecords = $excludeDisabledRecords; + } + + public function setExcludeMap(array $excludeMap): void + { + $this->excludeMap = $excludeMap; + } + + public function setSoftrefCfg(array $softrefCfg): void + { + $this->softrefCfg = $softrefCfg; + } + + public function setExtensionDependencies(array $extensionDependencies): void + { + $this->extensionDependencies = $extensionDependencies; + } + + public function setShowStaticRelations(bool $showStaticRelations): void + { + $this->showStaticRelations = $showStaticRelations; + } + + public function setRelStaticTables(array $relStaticTables): void + { + $this->relStaticTables = $relStaticTables; + } + + public function getErrorLog(): array + { + return $this->errorLog; + } + + public function setUpdate(bool $update): void + { + $this->update = $update; + } + + public function setImportMode(array $importMode): void + { + $this->importMode = $importMode; + } + + public function setGlobalIgnorePid(bool $globalIgnorePid): void + { + $this->globalIgnorePid = $globalIgnorePid; + } + + public function setForceAllUids(bool $forceAllUids): void + { + $this->forceAllUids = $forceAllUids; + } + + public function setShowDiff(bool $showDiff): void + { + $this->showDiff = $showDiff; + } + + public function setSoftrefInputValues(array $softrefInputValues): void + { + $this->softrefInputValues = $softrefInputValues; + } + + public function getMode(): string + { + return $this->mode; + } + + public function getImportMapId(): array + { + return $this->importMapId; + } +} diff --git a/Classes/Initialization/ImportContentOnPackageInitialization.php b/Classes/Initialization/ImportContentOnPackageInitialization.php new file mode 100644 index 0000000..5e58886 --- /dev/null +++ b/Classes/Initialization/ImportContentOnPackageInitialization.php @@ -0,0 +1,84 @@ +getPackage()->getPackagePath(); + $registryKeyPrefix = $event->getExtensionKey(); + $registryKeysToCheck = [ + $registryKeyPrefix . ':Initialisation/data.t3d', + $registryKeyPrefix . ':Initialisation/dataImported', + ]; + foreach ($registryKeysToCheck as $registryKeyToCheck) { + if ($this->registry->get('extensionDataImport', $registryKeyToCheck)) { + // Data was imported before -> early return + return; + } + } + $importFileToUse = null; + $possibleImportFiles = [ + $packagePath . 'Initialisation/data.t3d', + $packagePath . 'Initialisation/data.xml', + ]; + foreach ($possibleImportFiles as $possibleImportFile) { + if (!file_exists($possibleImportFile)) { + continue; + } + $importFileToUse = $possibleImportFile; + } + if ($importFileToUse === null) { + return; + } + try { + // If the package ships its own site configurations in Initialisation/Site/, defer to + // ImportSiteConfigurationsOnPackageInitialization which handles the remapping itself. + if (is_dir($packagePath . 'Initialisation/Site')) { + $this->importExportUtility->disableSiteConfigurationImport(); + } + $importResult = $this->importExportUtility->importT3DFile($importFileToUse, 0); + $this->registry->set('extensionDataImport', $registryKeyPrefix . ':Initialisation/dataImported', 1); + $event->addStorageEntry(__CLASS__, [ + 'importResult' => $importResult, + 'importFileToUse' => $importFileToUse, + 'import' => $this->importExportUtility->getImport(), + ]); + } catch (\ErrorException $e) { + $this->logger->warning($e->getMessage(), ['exception' => $e]); + } + } +} diff --git a/Classes/Initialization/ImportSiteConfigurationsOnPackageInitialization.php b/Classes/Initialization/ImportSiteConfigurationsOnPackageInitialization.php new file mode 100644 index 0000000..88bfa4f --- /dev/null +++ b/Classes/Initialization/ImportSiteConfigurationsOnPackageInitialization.php @@ -0,0 +1,115 @@ +hasStorageEntry(ImportContentOnPackageInitialization::class) + || !($import = $event->getStorageEntry(ImportContentOnPackageInitialization::class)->getResult()['import'] ?? null) instanceof Import + ) { + return; + } + + $extensionKey = $event->getExtensionKey(); + $importAbsFolder = $event->getPackage()->getPackagePath() . 'Initialisation/Site'; + if (!is_dir($importAbsFolder)) { + return; + } + $destinationFolder = Environment::getConfigPath() . '/sites'; + GeneralUtility::mkdir($destinationFolder); + $existingSites = $this->siteConfiguration->resolveAllExistingSites(false); + // @todo: Get rid of symfony finder here: We should use low level tools + // here to locate such files. + $finder = GeneralUtility::makeInstance(Finder::class); + $finder->directories()->ignoreUnreadableDirs()->in($importAbsFolder); + if ($finder->hasResults()) { + foreach ($finder as $siteConfigDirectory) { + $siteIdentifier = $siteConfigDirectory->getBasename(); + if (isset($existingSites[$siteIdentifier])) { + $this->logger->warning('Skipped importing site configuration from {key} due to existing site identifier {site}', [ + 'key' => $extensionKey, + 'site' => $siteIdentifier, + ]); + continue; + } + $targetDir = $destinationFolder . '/' . $siteIdentifier; + if (!$this->registry->get('siteConfigImport', $siteIdentifier) && !is_dir($targetDir)) { + GeneralUtility::mkdir($targetDir); + GeneralUtility::copyDirectory($siteConfigDirectory->getPathname(), $targetDir); + $this->registry->set('siteConfigImport', $siteIdentifier, 1); + } + } + } + $newSites = array_diff_key($this->siteConfiguration->resolveAllExistingSites(false), $existingSites); + + $importedPages = $import->getImportMapId()['pages'] ?? []; + $newSiteIdentifierList = []; + foreach ($newSites as $newSite) { + $exportedPageId = $newSite->getRootPageId(); + $siteIdentifier = $newSite->getIdentifier(); + $newSiteIdentifierList[] = $siteIdentifier; + $importedPageId = $importedPages[$exportedPageId] ?? null; + if ($importedPageId === null) { + $this->logger->warning('Imported site configuration with identifier {site} could not be mapped to imported page id', [ + 'site' => $siteIdentifier, + ]); + continue; + } + $configuration = $this->siteConfiguration->load($siteIdentifier); + $configuration['rootPageId'] = $importedPageId; + try { + $this->siteWriter->write($siteIdentifier, $configuration); + } catch (SiteConfigurationWriteException $e) { + $this->logger->warning( + sprintf( + 'Imported site configuration with identifier %s could not be written: %s', + $newSite->getIdentifier(), + $e->getMessage() + ) + ); + continue; + } + } + $event->addStorageEntry(__CLASS__, $newSiteIdentifierList); + } +} diff --git a/Classes/Utility/ImportExportUtility.php b/Classes/Utility/ImportExportUtility.php new file mode 100644 index 0000000..e065974 --- /dev/null +++ b/Classes/Utility/ImportExportUtility.php @@ -0,0 +1,99 @@ +eventDispatcher = $eventDispatcher; + } + + public function getImport(): ?Import + { + return $this->import; + } + + public function disableSiteConfigurationImport(): void + { + $this->importSiteConfigurations = false; + } + + /** + * Import a T3D file directly + * + * @param string $file The full absolute path to the file + * @param int $pid The pid under which the t3d file should be imported + * @throws \ErrorException + * @return int ID of first created page + */ + public function importT3DFile(string $file, int $pid): int + { + $this->import = GeneralUtility::makeInstance(Import::class); + $this->import->setPid($pid); + if (!$this->importSiteConfigurations) { + $this->import->disableSiteConfigurationImport(); + } + + $this->eventDispatcher->dispatch(new BeforeImportEvent($this->import, $file)); + + try { + $this->import->loadFile($file); + $this->import->importData(); + } catch (\Exception $e) { + $logger = GeneralUtility::makeInstance(LogManager::class)->getLogger(__CLASS__); + $logger->warning( + $e->getMessage() . PHP_EOL . implode(PHP_EOL, $this->import->getErrorLog()) + ); + } + + // Get id of first created page: + $importResponse = 0; + $importMapId = $this->import->getImportMapId(); + if (isset($importMapId['pages'])) { + $newPages = $importMapId['pages']; + $importResponse = (int)reset($newPages); + } + + // Check for errors during the import process: + if ($this->import->hasErrors()) { + if (!$importResponse) { + throw new \ErrorException('No page records imported', 1377625537); + } + } + return $importResponse; + } +} diff --git a/Classes/View/ExportPageTreeView.php b/Classes/View/ExportPageTreeView.php new file mode 100644 index 0000000..7653610 --- /dev/null +++ b/Classes/View/ExportPageTreeView.php @@ -0,0 +1,216 @@ +expandAll = true; + $checkSub = $levels > 0; + + $this->buildTree($pid, $levels, $checkSub); + } + + /** + * Creation of a tree structure with predefined depth to prepare the export. + * + * @param int $pid Page ID + * @param int $levels Page tree levels + * @param bool $checkSub Should root page be checked for sub pages? + */ + protected function buildTree(int $pid, int $levels, bool $checkSub): void + { + $this->reset(); + $iconFactory = GeneralUtility::makeInstance(IconFactory::class); + + // Root page + if ($pid > 0) { + $rootRecord = BackendUtility::getRecordWSOL('pages', $pid); + $rootHtml = $iconFactory->getIconForRecord('pages', $rootRecord, IconSize::SMALL)->render(); + } else { + $rootRecord = [ + 'title' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'], + 'uid' => 0, + ]; + $rootHtml = $iconFactory->getIcon('apps-pagetree-root', IconSize::SMALL)->render(); + } + + $this->tree[] = [ + 'HTML' => $rootHtml, + 'row' => $rootRecord, + 'hasSub' => $checkSub, + 'bank' => $this->bank, + ]; + + // Subtree + if ($checkSub) { + $this->getTree($pid, $levels); + } + + $idH = []; + $idH[$pid]['uid'] = $pid; + if (!empty($this->buffer_idH)) { + $idH[$pid]['subrow'] = $this->buffer_idH; + } + $this->buffer_idH = $idH; + + // Check if root page has subtree + if (empty($this->buffer_idH)) { + $this->tree[0]['hasSub'] = false; + } + } + + /** + * Compiles the HTML code for displaying the structure found inside the ->tree array + * + * @param array|string $treeArr "tree-array" - if blank string, the internal ->tree array is used. + * @return string The HTML code for the tree + */ + public function printTree($treeArr = '') + { + if (!is_array($treeArr)) { + $treeArr = $this->tree; + } + $out = ''; + $closeDepth = []; + foreach ($treeArr as $treeItem) { + if ($treeItem['isFirst'] ?? false) { + $out .= '