commit 3a4ca58ca19a2e291319b1e9148916c1987827b3 Author: Sven Wappler Date: Mon Aug 10 22:31:28 2026 +0200 TYPO3 v15 dev-main snapshot () 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 .= '
    '; + } + + $idAttr = htmlspecialchars('pages' . $treeItem['row']['uid']); + $out .= ' +
  • + + ' . $treeItem['HTML'] . ' + ' . $this->getTitleStr($treeItem['row']) . ' + '; + + if (!($treeItem['hasSub'] ?? false)) { + $out .= '
  • '; + } + + // We have to remember if this is the last one + // on level X so the last child on level X+1 closes the
      -tag + if ($treeItem['isLast'] ?? false) { + $closeDepth[$treeItem['invertedDepth']] = 1; + } + // If this is the last one and does not have subitems, we need to close + // the tree as long as the upper levels have last items too + if (($treeItem['isLast'] ?? false) && !($treeItem['hasSub'] ?? false)) { + for ($i = $treeItem['invertedDepth']; ($closeDepth[$i] ?? 0) == 1; $i++) { + $closeDepth[$i] = 0; + $out .= '
    '; + } + } + } + return '
      ' . $out . '
    '; + } + + /** + * Returns TRUE/FALSE if the next level for $id should be expanded - based on the ->expandAll flag. + * Extending parent function + * + * @param int $id Record id/key + * @return bool + * @internal + * @see \TYPO3\CMS\Backend\Tree\View\PageTreeView::expandNext() + */ + public function expandNext($id) + { + return $this->expandAll; + } + + /** + * Returns the title for the input record. If blank, a "no title" label (localized) will be returned. + * Do NOT htmlspecialchar the string from this function - has already been done. + * + * @param array $row The input row array (where the key "title" is used for the title) + * @return string The title. + */ + protected function getTitleStr(array $row): string + { + $recordTitle = $row['title'] ?? ''; + if (trim($recordTitle) === '') { + return '[' . htmlspecialchars($this->getLanguageService()->sL('core.core:labels.no_title')) . ']'; + } + return htmlspecialchars(BackendUtility::cropToTitleLength($recordTitle)); + } +} diff --git a/Configuration/Backend/Routes.php b/Configuration/Backend/Routes.php new file mode 100644 index 0000000..a438a9f --- /dev/null +++ b/Configuration/Backend/Routes.php @@ -0,0 +1,16 @@ + [ + 'path' => '/record/importexport/export', + 'target' => \TYPO3\CMS\Impexp\Controller\ExportController::class . '::handleRequest', + ], + 'tx_impexp_import' => [ + 'path' => '/record/importexport/import', + 'target' => \TYPO3\CMS\Impexp\Controller\ImportController::class . '::handleRequest', + ], +]; diff --git a/Configuration/Icons.php b/Configuration/Icons.php new file mode 100644 index 0000000..768b90e --- /dev/null +++ b/Configuration/Icons.php @@ -0,0 +1,12 @@ + [ + 'provider' => \TYPO3\CMS\Core\Imaging\IconProvider\BitmapIconProvider::class, + 'source' => 'EXT:impexp/Resources/Public/Icons/status-reference-hard.png', + ], + 'status-reference-soft' => [ + 'provider' => \TYPO3\CMS\Core\Imaging\IconProvider\BitmapIconProvider::class, + 'source' => 'EXT:impexp/Resources/Public/Icons/status-reference-soft.png', + ], +]; diff --git a/Configuration/JavaScriptModules.php b/Configuration/JavaScriptModules.php new file mode 100644 index 0000000..4dad58f --- /dev/null +++ b/Configuration/JavaScriptModules.php @@ -0,0 +1,10 @@ + [ + 'backend', + ], + 'imports' => [ + '@typo3/impexp/' => 'EXT:impexp/Resources/Public/JavaScript/', + ], +]; diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml new file mode 100644 index 0000000..825fd78 --- /dev/null +++ b/Configuration/Services.yaml @@ -0,0 +1,8 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + TYPO3\CMS\Impexp\: + resource: '../Classes/*' diff --git a/Configuration/TCA/Overrides/pages.php b/Configuration/TCA/Overrides/pages.php new file mode 100644 index 0000000..6eb2f0f --- /dev/null +++ b/Configuration/TCA/Overrides/pages.php @@ -0,0 +1,13 @@ + [ + 'config' => [ + 'type' => 'passthrough', + ], + ], +]; + +\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addTCAcolumns('pages', $fields); diff --git a/Configuration/TCA/Overrides/sys_template.php b/Configuration/TCA/Overrides/sys_template.php new file mode 100644 index 0000000..c7f57ca --- /dev/null +++ b/Configuration/TCA/Overrides/sys_template.php @@ -0,0 +1,13 @@ + [ + 'config' => [ + 'type' => 'passthrough', + ], + ], +]; + +\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addTCAcolumns('sys_template', $fields); diff --git a/Configuration/TCA/Overrides/tt_content.php b/Configuration/TCA/Overrides/tt_content.php new file mode 100644 index 0000000..005284a --- /dev/null +++ b/Configuration/TCA/Overrides/tt_content.php @@ -0,0 +1,13 @@ + [ + 'config' => [ + 'type' => 'passthrough', + ], + ], +]; + +\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addTCAcolumns('tt_content', $fields); diff --git a/Configuration/TCA/tx_impexp_presets.php b/Configuration/TCA/tx_impexp_presets.php new file mode 100644 index 0000000..82954bf --- /dev/null +++ b/Configuration/TCA/tx_impexp_presets.php @@ -0,0 +1,53 @@ + [ + 'title' => 'impexp.db:tx_impexp_presets', + 'label' => 'title', + 'default_sortby' => 'title', + 'tstamp' => 'tstamp', + 'crdate' => 'crdate', + 'typeicon_classes' => [ + 'default' => 'actions-cog', + ], + 'hideTable' => true, + 'rootLevel' => -1, + ], + 'columns' => [ + 'title' => [ + 'label' => 'impexp.db:title', + 'config' => [ + 'type' => 'passthrough', + ], + ], + 'public' => [ + 'label' => 'impexp.db:public', + 'config' => [ + 'type' => 'passthrough', + ], + ], + 'user_uid' => [ + 'label' => 'impexp.db:user_uid', + 'config' => [ + 'type' => 'passthrough', + ], + ], + 'item_uid' => [ + 'label' => 'impexp.db:item_uid', + 'config' => [ + 'type' => 'passthrough', + ], + ], + 'preset_data' => [ + 'label' => 'impexp.db:preset_data', + 'config' => [ + 'type' => 'passthrough', + ], + ], + ], + 'types' => [ + 0 => [ + 'showitem' => 'title, public, user_uid, item_uid, preset_data', + ], + ], +]; diff --git a/Documentation/Images/AutomaticScreenshots/CheckAndPerformImport.png b/Documentation/Images/AutomaticScreenshots/CheckAndPerformImport.png new file mode 100644 index 0000000..fa62365 Binary files /dev/null and b/Documentation/Images/AutomaticScreenshots/CheckAndPerformImport.png differ diff --git a/Documentation/Images/AutomaticScreenshots/CheckAndPerformImport.rst.txt b/Documentation/Images/AutomaticScreenshots/CheckAndPerformImport.rst.txt new file mode 100644 index 0000000..4e10468 --- /dev/null +++ b/Documentation/Images/AutomaticScreenshots/CheckAndPerformImport.rst.txt @@ -0,0 +1,7 @@ +.. Automatic screenshot: Remove this line if you want to manually change this file + +.. figure:: /Images/AutomaticScreenshots/CheckAndPerformImport.png + :alt: Check and perform the import + :class: with-shadow + + Check and perform the import \ No newline at end of file diff --git a/Documentation/Images/AutomaticScreenshots/CheckExport.png b/Documentation/Images/AutomaticScreenshots/CheckExport.png new file mode 100644 index 0000000..6789b40 Binary files /dev/null and b/Documentation/Images/AutomaticScreenshots/CheckExport.png differ diff --git a/Documentation/Images/AutomaticScreenshots/CheckExport.rst.txt b/Documentation/Images/AutomaticScreenshots/CheckExport.rst.txt new file mode 100644 index 0000000..622707d --- /dev/null +++ b/Documentation/Images/AutomaticScreenshots/CheckExport.rst.txt @@ -0,0 +1,7 @@ +.. Automatic screenshot: Remove this line if you want to manually change this file + +.. figure:: /Images/AutomaticScreenshots/CheckExport.png + :alt: Check the exported data + :class: with-shadow + + Check the exported data \ No newline at end of file diff --git a/Documentation/Images/AutomaticScreenshots/ConfigureExport.png b/Documentation/Images/AutomaticScreenshots/ConfigureExport.png new file mode 100644 index 0000000..2551837 Binary files /dev/null and b/Documentation/Images/AutomaticScreenshots/ConfigureExport.png differ diff --git a/Documentation/Images/AutomaticScreenshots/ConfigureExport.rst.txt b/Documentation/Images/AutomaticScreenshots/ConfigureExport.rst.txt new file mode 100644 index 0000000..0f2af26 --- /dev/null +++ b/Documentation/Images/AutomaticScreenshots/ConfigureExport.rst.txt @@ -0,0 +1,7 @@ +.. Automatic screenshot: Remove this line if you want to manually change this file + +.. figure:: /Images/AutomaticScreenshots/ConfigureExport.png + :alt: Configure the data to be exported + :class: with-shadow + + Configure the data to be exported \ No newline at end of file diff --git a/Documentation/Images/AutomaticScreenshots/ConfigureImport.png b/Documentation/Images/AutomaticScreenshots/ConfigureImport.png new file mode 100644 index 0000000..d6853a0 Binary files /dev/null and b/Documentation/Images/AutomaticScreenshots/ConfigureImport.png differ diff --git a/Documentation/Images/AutomaticScreenshots/ConfigureImport.rst.txt b/Documentation/Images/AutomaticScreenshots/ConfigureImport.rst.txt new file mode 100644 index 0000000..c0f60b9 --- /dev/null +++ b/Documentation/Images/AutomaticScreenshots/ConfigureImport.rst.txt @@ -0,0 +1,7 @@ +.. Automatic screenshot: Remove this line if you want to manually change this file + +.. figure:: /Images/AutomaticScreenshots/ConfigureImport.png + :alt: Configure the import + :class: with-shadow + + Configure the import \ No newline at end of file diff --git a/Documentation/Images/AutomaticScreenshots/ContextMenuExport.png b/Documentation/Images/AutomaticScreenshots/ContextMenuExport.png new file mode 100644 index 0000000..859c7d1 Binary files /dev/null and b/Documentation/Images/AutomaticScreenshots/ContextMenuExport.png differ diff --git a/Documentation/Images/AutomaticScreenshots/ContextMenuExport.rst.txt b/Documentation/Images/AutomaticScreenshots/ContextMenuExport.rst.txt new file mode 100644 index 0000000..fdb40b6 --- /dev/null +++ b/Documentation/Images/AutomaticScreenshots/ContextMenuExport.rst.txt @@ -0,0 +1,7 @@ +.. Automatic screenshot: Remove this line if you want to manually change this file + +.. figure:: /Images/AutomaticScreenshots/ContextMenuExport.png + :alt: Select "More options... > Export" + :class: with-shadow + + Select "More options... > Export" \ No newline at end of file diff --git a/Documentation/Images/AutomaticScreenshots/ContextMenuImport.png b/Documentation/Images/AutomaticScreenshots/ContextMenuImport.png new file mode 100644 index 0000000..2567af9 Binary files /dev/null and b/Documentation/Images/AutomaticScreenshots/ContextMenuImport.png differ diff --git a/Documentation/Images/AutomaticScreenshots/ContextMenuImport.rst.txt b/Documentation/Images/AutomaticScreenshots/ContextMenuImport.rst.txt new file mode 100644 index 0000000..44c2003 --- /dev/null +++ b/Documentation/Images/AutomaticScreenshots/ContextMenuImport.rst.txt @@ -0,0 +1,7 @@ +.. Automatic screenshot: Remove this line if you want to manually change this file + +.. figure:: /Images/AutomaticScreenshots/ContextMenuImport.png + :alt: Select "More options... > Import" + :class: with-shadow + + Select "More options... > Import" \ No newline at end of file diff --git a/Documentation/Images/AutomaticScreenshots/DownloadExport.png b/Documentation/Images/AutomaticScreenshots/DownloadExport.png new file mode 100644 index 0000000..43832b6 Binary files /dev/null and b/Documentation/Images/AutomaticScreenshots/DownloadExport.png differ diff --git a/Documentation/Images/AutomaticScreenshots/DownloadExport.rst.txt b/Documentation/Images/AutomaticScreenshots/DownloadExport.rst.txt new file mode 100644 index 0000000..121172f --- /dev/null +++ b/Documentation/Images/AutomaticScreenshots/DownloadExport.rst.txt @@ -0,0 +1,7 @@ +.. Automatic screenshot: Remove this line if you want to manually change this file + +.. figure:: /Images/AutomaticScreenshots/DownloadExport.png + :alt: Download the export data + :class: with-shadow + + Download the export data \ No newline at end of file diff --git a/Documentation/Images/AutomaticScreenshots/ImpExp.png b/Documentation/Images/AutomaticScreenshots/ImpExp.png new file mode 100644 index 0000000..f841009 Binary files /dev/null and b/Documentation/Images/AutomaticScreenshots/ImpExp.png differ diff --git a/Documentation/Images/AutomaticScreenshots/ImpExp.rst.txt b/Documentation/Images/AutomaticScreenshots/ImpExp.rst.txt new file mode 100644 index 0000000..01736ff --- /dev/null +++ b/Documentation/Images/AutomaticScreenshots/ImpExp.rst.txt @@ -0,0 +1,7 @@ +.. Automatic screenshot: Remove this line if you want to manually change this file + +.. figure:: /Images/AutomaticScreenshots/ImpExp.png + :alt: Export module of current TYPO3 + :class: with-shadow + + Export module of current TYPO3 \ No newline at end of file diff --git a/Documentation/Images/AutomaticScreenshots/Presets.png b/Documentation/Images/AutomaticScreenshots/Presets.png new file mode 100644 index 0000000..0704989 Binary files /dev/null and b/Documentation/Images/AutomaticScreenshots/Presets.png differ diff --git a/Documentation/Images/AutomaticScreenshots/Presets.rst.txt b/Documentation/Images/AutomaticScreenshots/Presets.rst.txt new file mode 100644 index 0000000..97cea93 --- /dev/null +++ b/Documentation/Images/AutomaticScreenshots/Presets.rst.txt @@ -0,0 +1,7 @@ +.. Automatic screenshot: Remove this line if you want to manually change this file + +.. figure:: /Images/AutomaticScreenshots/Presets.png + :alt: Save or load an export preset + :class: with-shadow + + Save or load an export preset \ No newline at end of file diff --git a/Documentation/Images/AutomaticScreenshots/SelectAdvancedExportOptions.png b/Documentation/Images/AutomaticScreenshots/SelectAdvancedExportOptions.png new file mode 100644 index 0000000..5cc8872 Binary files /dev/null and b/Documentation/Images/AutomaticScreenshots/SelectAdvancedExportOptions.png differ diff --git a/Documentation/Images/AutomaticScreenshots/SelectAdvancedExportOptions.rst.txt b/Documentation/Images/AutomaticScreenshots/SelectAdvancedExportOptions.rst.txt new file mode 100644 index 0000000..b34e381 --- /dev/null +++ b/Documentation/Images/AutomaticScreenshots/SelectAdvancedExportOptions.rst.txt @@ -0,0 +1,7 @@ +.. Automatic screenshot: Remove this line if you want to manually change this file + +.. figure:: /Images/AutomaticScreenshots/SelectAdvancedExportOptions.png + :alt: Select advanced export options + :class: with-shadow + + Select advanced export options \ No newline at end of file diff --git a/Documentation/Images/AutomaticScreenshots/UpdateContent.png b/Documentation/Images/AutomaticScreenshots/UpdateContent.png new file mode 100644 index 0000000..f7f9713 Binary files /dev/null and b/Documentation/Images/AutomaticScreenshots/UpdateContent.png differ diff --git a/Documentation/Images/AutomaticScreenshots/UpdateContent.rst.txt b/Documentation/Images/AutomaticScreenshots/UpdateContent.rst.txt new file mode 100644 index 0000000..2d21b55 --- /dev/null +++ b/Documentation/Images/AutomaticScreenshots/UpdateContent.rst.txt @@ -0,0 +1,7 @@ +.. Automatic screenshot: Remove this line if you want to manually change this file + +.. figure:: /Images/AutomaticScreenshots/UpdateContent.png + :alt: Update content in an existing structure + :class: with-shadow + + Update content in an existing structure \ No newline at end of file diff --git a/Documentation/Images/AutomaticScreenshots/UploadImport.png b/Documentation/Images/AutomaticScreenshots/UploadImport.png new file mode 100644 index 0000000..c59ca87 Binary files /dev/null and b/Documentation/Images/AutomaticScreenshots/UploadImport.png differ diff --git a/Documentation/Images/AutomaticScreenshots/UploadImport.rst.txt b/Documentation/Images/AutomaticScreenshots/UploadImport.rst.txt new file mode 100644 index 0000000..16d8097 --- /dev/null +++ b/Documentation/Images/AutomaticScreenshots/UploadImport.rst.txt @@ -0,0 +1,7 @@ +.. Automatic screenshot: Remove this line if you want to manually change this file + +.. figure:: /Images/AutomaticScreenshots/UploadImport.png + :alt: Upload the export data + :class: with-shadow + + Upload the export data \ No newline at end of file diff --git a/Documentation/Images/ManualScreenshots/ImpExpV3.8.png b/Documentation/Images/ManualScreenshots/ImpExpV3.8.png new file mode 100644 index 0000000..7a08aba Binary files /dev/null and b/Documentation/Images/ManualScreenshots/ImpExpV3.8.png differ diff --git a/Documentation/Images/ManualScreenshots/ImpExpV3.8.rst.txt b/Documentation/Images/ManualScreenshots/ImpExpV3.8.rst.txt new file mode 100644 index 0000000..64f6f18 --- /dev/null +++ b/Documentation/Images/ManualScreenshots/ImpExpV3.8.rst.txt @@ -0,0 +1,5 @@ +.. figure:: /Images/ManualScreenshots/ImpExpV3.8.png + :class: with-shadow + :alt: Export module of TYPO3 3.8.0 (year 2005) + + Export module of TYPO3 3.8.0 (year 2005) diff --git a/Documentation/Images/ManualScreenshots/InstallActivate.png b/Documentation/Images/ManualScreenshots/InstallActivate.png new file mode 100644 index 0000000..690197e Binary files /dev/null and b/Documentation/Images/ManualScreenshots/InstallActivate.png differ diff --git a/Documentation/Includes.rst.txt b/Documentation/Includes.rst.txt new file mode 100644 index 0000000..b5b5ca9 --- /dev/null +++ b/Documentation/Includes.rst.txt @@ -0,0 +1 @@ +.. You can put central messages to display on all pages here diff --git a/Documentation/Index.rst b/Documentation/Index.rst new file mode 100644 index 0000000..2349c80 --- /dev/null +++ b/Documentation/Index.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt +.. _start: + +===================== +TYPO3 Import / Export +===================== + +:Extension key: + impexp + +:Package name: + typo3/cms-impexp + +:Version: + |release| + +:Language: + en + +:Author: + TYPO3 contributors + +:License: + This document is published under the + `Open Content License `__. + +:Rendered: + |today| + +---- + +This is a tool for importing and exporting records using XML or the custom T3D +format. + +---- + +**Table of Contents:** + +.. toctree:: + :maxdepth: 2 + :titlesonly: + + Introduction/Index + Installation/Index + Usage/Index + Security/Index + +.. Meta Menu + +.. toctree:: + :hidden: + + Sitemap diff --git a/Documentation/Installation/Index.rst b/Documentation/Installation/Index.rst new file mode 100644 index 0000000..c800437 --- /dev/null +++ b/Documentation/Installation/Index.rst @@ -0,0 +1,57 @@ +:navigation-title: Installation + +.. include:: /Includes.rst.txt +.. _installation: + +==================================== +Installation of the impexp extension +==================================== + +This extension is part of the TYPO3 Core, but not installed by default. + +.. contents:: Table of contents + :local: + +.. _installation_composer: + +Installation with Composer +========================== + +Check whether you are already using the extension with: + +.. code-block:: bash + + composer show | grep impexp + +This should either give you no result or something similar to: + +.. code-block:: none + + typo3/cms-impexp v12.4.11 + +If it is not installed yet, use the ``composer require`` command to install +the extension: + +.. code-block:: bash + + composer require typo3/cms-impexp + +The given version depends on the version of the TYPO3 Core you are using. + +.. _installation_legacy: + +Installation without Composer +============================= + +In an installation without Composer, the extension is already shipped but might +not be activated yet. Activate it as follows: + +#. In the backend, navigate to the :guilabel:`System > Extensions` + module. +#. Click the :guilabel:`Activate` icon for the Import / Export extension. + +.. figure:: /Images/ManualScreenshots/InstallActivate.png + :class: with-border + :alt: Extension manager showing Import / Export extension + + Extension manager showing Import / Export extension diff --git a/Documentation/Introduction/Index.rst b/Documentation/Introduction/Index.rst new file mode 100644 index 0000000..343bac8 --- /dev/null +++ b/Documentation/Introduction/Index.rst @@ -0,0 +1,70 @@ +:navigation-title: Introduction + +.. include:: /Includes.rst.txt +.. _known-problems: +.. _introduction: + +====================================================== +Introduction into the system extension "Import/Export" +====================================================== + +The system extension "Import/Export" (EXT:impexp) allows content to be exported +from one installation of TYPO3 and then imported into another. Exported data +includes content from multiple tables including :sql:`tt_content` as well as +images and other files stored in :directory:`fileadmin/`. + +This extension is often used to manage content for :ref:`distributions ` +and also training and demonstration purposes. + +.. _merging_multiple_sets_of_data: + +Merging multiple sets of data +============================= + +By default the identifiers are changed when importing data, making it possible to +merge several projects into one installation. The table identifiers are +automatically changed in such a way that content elements remain attached to +their pages and images to their content elements. + +It is also possible to keep the identifiers (`uids`) to allow the reproduction +of the exact same page and content tree. + +.. _what-doesnt-it-do: + +What doesn't it do? +=================== + +* Exported content does not include code from any installed extensions or + sitepackages. +* This extension is not used for the :guilabel:`Download` + feature in the :guilabel:`List` module. + +.. _backward_compatibility: + +Backward compatibility +====================== + +The data structure for content exports have seen very little changes since their +original inception. It is sometimes possible to export content from a fifteen +year old TYPO3 installation straight into a current installation of TYPO3. + +It is often more feasible to use the import/export tool +than it is to attempt to update old installations of TYPO3. + +The following images show the export dialog of a current TYPO3 installation and +TYPO3 v3.8.0: They correspond pretty much. + +However, several details may change due to Deprecations and Breaking Changes, +which can lead to issues with old import data. In cases where the import fails, +it is recommended to try to manually export assets/files, and re-create the +reference Index after import. The older an installation is, the more manual +rework is expected. + +Ongoing improvements to the Import/Export code base can only made, when +legacy considerations are not the first priority. A "guaranteed" fully-working +export and re-import is only given for T3D structures within the same +major version. + +.. include:: /Images/AutomaticScreenshots/ImpExp.rst.txt + +.. include:: /Images/ManualScreenshots/ImpExpV3.8.rst.txt diff --git a/Documentation/Security/Index.rst b/Documentation/Security/Index.rst new file mode 100644 index 0000000..acb2c3f --- /dev/null +++ b/Documentation/Security/Index.rst @@ -0,0 +1,67 @@ +:navigation-title: Security + +.. include:: /Includes.rst.txt +.. _security: + +========================================= +Security considerations regarding exports +========================================= + +Exported content in TYPO3 can contain sensitive or restricted information that +needs to be properly secured. This document outlines the recommended best +practices for managing the security risks associated with exporting content. +The following sections explain: + +- Why you should **disable the export extension when not in use** to reduce + the risk of unintentional data exposure. +- How to **prevent unauthorized access** by restricting the visibility of + export options in the TYPO3 interface. +- Why it is important to **secure the export directory** to block unauthorized + file access on different webserver setups. +- How to **report a security issue** to the TYPO3 Security Team if you + identify vulnerabilities not addressed in this guide. + +.. contents:: Table of contents + +.. _security-disable-extension: + +Disable the extension when not in use +===================================== + +Exported content may contain sensitive and restricted information related to +your site. It is recommended that this extension be deactivated when it is not +in use to prevent content being exported in error. + +.. _security-prevent-unauthorized-access: + +Prevent unauthorized access +=========================== + +The export function is available by default for editors without admin rights. +It is limited to content to which the editor has access. The export +functionality can be hidden in the editor's context menu by using the user +TSconfig setting :ref:`contextMenu disableItems +`. + +Note that it cannot be completely disabled as there are currently other entry +points. + +.. _security-secure-export-directory: + +Secure the export directory +=========================== + +Exports are stored in :path:`fileadmin/user_upload/_temp_/importexport/`. +TYPO3 will automatically create a :file:`.htaccess` file to prevent access to +this folder from external sources. On Nginx webservers, the :file:`.htaccess` +file has no effect. Follow the :ref:`Security guidelines for System +Administrators ` to find out how to prevent +access to specific directories on Nginx webservers. + +.. _security-reporting-issue: + +Reporting a security issue +========================== + +If you believe you have found a security-related issue that is not listed +here, please contact the :ref:`TYPO3 Security Team `. diff --git a/Documentation/Sitemap.rst b/Documentation/Sitemap.rst new file mode 100644 index 0000000..ef811fe --- /dev/null +++ b/Documentation/Sitemap.rst @@ -0,0 +1,10 @@ +:template: sitemap.html + +.. include:: /Includes.rst.txt +.. _sitemap: + +======= +Sitemap +======= + +.. The sitemap.html template will insert here the page tree automatically. diff --git a/Documentation/Usage/CommandLine.rst b/Documentation/Usage/CommandLine.rst new file mode 100644 index 0000000..16e3aaf --- /dev/null +++ b/Documentation/Usage/CommandLine.rst @@ -0,0 +1,131 @@ +:navigation-title: Command line + +.. include:: /Includes.rst.txt +.. _command_line: + +====================================================== +Using the Import/Export tool from the command line +====================================================== + +The import/export tool can alternatively also be used via the command line. +The advantage of using the CLI is that there is no PHP time limit, therefore +larger page trees can be exported and imported. + +The exports and imports can be fine-tuned through the complete set of options +also available in the :ref:`import ` or :ref:`export module ` +of the TYPO3 backend. + +.. note:: + + If your TYPO3 installation is based on Composer, you can run the command + with the shortcut :bash:`vendor/bin/typo3` instead of + :bash:`typo3/sysext/core/bin/typo3`. + +.. attention:: + + Exporting and importing content may expose sensitive data or bypass + permission boundaries. Review the :ref:`security considerations regarding + exports ` before using this functionality. + +.. _command_line-export: + +Exporting content from the command line +======================================= + +Export the entire TYPO3 page tree (or selected parts of it) to a data file of +format XML or T3D: + +.. tabs:: + + .. tab:: Composer mode + + .. code-block:: bash + + vendor/bin/typo3 impexp:export [options] [--] [] + + .. tab:: Classic mode + + .. code-block:: bash + + typo3/sysext/core/bin/typo3 impexp:export [options] [--] [] + +With these options available: + +.. code-block:: bash + + Arguments: + filename The filename to export to (without file extension) + + Options: + --type[=TYPE] The file type (xml, t3d, t3d_compressed). [default: "xml"] + --pid[=PID] The root page of the exported page tree. [default: -1] + --levels[=LEVELS] The depth of the exported page tree. + "-2": "Records on this page", "0": "This page", + "1": "1 level down", .. "999": "Infinite levels". [default: 0] + --table[=TABLE] Include all records of this table. + Examples: "_ALL", "tt_content", "sys_file_reference", etc. + (multiple values allowed) + --record[=RECORD] Include this specific record. Pattern is "{table}:{record}". + Examples: "tt_content:12", etc. (multiple values allowed) + --list[=LIST] Include records of this table and page. Pattern is "{table}:{pid}". + Examples: "be_users:0", etc. (multiple values allowed) + --include-related[=INCLUDE-RELATED] Include record relations to this table, including the related record. + Examples: "_ALL", "sys_category", etc. (multiple values allowed) + --include-static[=INCLUDE-STATIC] Include record relations to this table, excluding the related record. + Examples: "_ALL", "be_users", etc. (multiple values allowed) + --exclude[=EXCLUDE] Exclude this specific record. Pattern is "{table}:{record}". + Examples: "fe_users:3", etc. (multiple values allowed) + --exclude-disabled-records Exclude records considered disabled by their TCA configuration, + e.g. "disabled", "starttime", or "endtime" fields. + --exclude-html-css Exclude referenced HTML and CSS files. + --title[=TITLE] The meta title of the export. + --description[=DESCRIPTION] The meta description of the export. + --notes[=NOTES] The meta notes of the export. + --dependency[=DEPENDENCY] Declare required TYPO3 extensions for the export. + Examples: "news", "powermail", etc. (multiple values allowed) + --save-files-outside-export-file Save files in a separate folder named "{filename}.files" + instead of embedding them in the export file. + +.. _command_line-import: + +Importing content from the command line +======================================= + +Import an export dump file in XML or T3D format into a TYPO3 instance: + +.. tabs:: + + .. tab:: Composer mode + + .. code-block:: bash + + vendor/bin/typo3 impexp:import [options] [--] [] + + .. tab:: Classic mode + + .. code-block:: bash + + typo3/sysext/core/bin/typo3 impexp:import [options] [--] [] + +With these options available: + +.. code-block:: bash + + Arguments: + file The file path to import from (.t3d or .xml). + pid The page to import to. [default: 0] + + Options: + --update-records Update existing records with the same UID instead of inserting new ones. + --ignore-pid Prevent page ID correction for updated records + (requires --update-records). + --force-uid Force UIDs from the file. + --import-mode[=IMPORT-MODE] Set the import mode for specific records. + Pattern: "{table}:{record}={mode}". + Modes: + - For new records: "force_uid", "exclude" + - For existing records: "as_new", "ignore_pid", + "respect_pid", "exclude" + Examples: "pages:987=force_uid", "tt_content:1=as_new", + etc. (multiple values allowed) + --enable-log Log all database actions. diff --git a/Documentation/Usage/Export.rst b/Documentation/Usage/Export.rst new file mode 100644 index 0000000..e69fcac --- /dev/null +++ b/Documentation/Usage/Export.rst @@ -0,0 +1,128 @@ +:navigation-title: Export + +.. include:: /Includes.rst.txt +.. _export: + +========================================== +Exporting content from TYPO3 to XML or T3D +========================================== + +The export functionality is only available for admin users and backend +users for which the Page TSconfig option +:ref:`options.impexp.enableExportForNonAdminUser +` has been enabled. + +.. attention:: + + Exporting content may expose sensitive data or allow unwanted file access. + Review the :ref:`security considerations regarding exports ` + before using this functionality. + +.. _export-open-module: + +Open the export module +====================== + +In the page tree, right-click the page from which you want to start the +export (1) and select :guilabel:`More options ... > Export` (2). + +.. include:: /Images/AutomaticScreenshots/ContextMenuExport.rst.txt + +.. _export-configure-settings: + +Configure the export settings +============================= + +On the first tab of the export module you can fine-tune the export (1). + +- If you want to export all data of the selected page including its + subpages, select the "Infinite" option in the :guilabel:`Levels` + selection box. + +- Under :guilabel:`Include tables` you can limit the types of records to + be exported. + +- Under :guilabel:`Include relations to tables` you specify which + relations of the records should be included in the export file. The + related records will be included even if they are outside the pages + selected for export. + +- Under :guilabel:`Use static relations for tables` you select which + relations should be included without including the related record. This + is useful if the related record already exists in the target TYPO3 + instance. + +- If the same table is selected in both :guilabel:`Include relations to + tables` and :guilabel:`Use static relations for tables`, the relation + is treated as static. + +- The :guilabel:`Exclude disabled elements` checkbox excludes records + that are disabled according to their TCA configuration. This is checked + by default. + +Apply your changes via the :guilabel:`Update` button and repeat this step +until the preview meets your expectations. + +.. include:: /Images/AutomaticScreenshots/ConfigureExport.rst.txt + +.. _export-review-records: + +Review the records to be exported +================================= + +All pages selected for export are listed in the upper part of the dialog +(1). + +Below this is a detailed list of all data to be exported (2). Here you +can exclude individual records or manually make them editable, if +supported. + +If the relation to records is lost, an orange exclamation mark will be +shown. This happens if records are stored outside the export page tree +or if excluded tables break relations. + +Apply your changes by pressing the :guilabel:`Update` button and repeat +this step as needed until the preview matches your expectations (3). + +Then switch to the :guilabel:`Advanced Options` tab (4). + +.. include:: /Images/AutomaticScreenshots/CheckExport.rst.txt + +.. _export-advanced-options: + +Optionally select advanced export options +========================================= + +In the third tab of the export module you can specify further export +options (1). + +Checking :guilabel:`Save files in extra folder ..` saves linked files +in a separate folder instead of embedding them in the export file. This +is mandatory for :ref:`distributions ` or +useful when handling large file sets that would otherwise bloat the +export file and potentially exhaust memory. The folder is named +"{filename}.files". + +Apply your changes via the :guilabel:`Update` button (2) and then switch +to the :guilabel:`File & Preset` tab (3) to proceed. + +.. include:: /Images/AutomaticScreenshots/SelectAdvancedExportOptions.rst.txt + +.. _export-perform: + +Perform the export +================== + +In the second tab of the export module you can specify the metadata for +the export (1) before starting the export process. + +You can then either: + +- Download the export file (2.a), or +- Save it on the server (2.b). + +Saving on the server is currently required if you have enabled saving +related files in a separate folder (see +:ref:`export-advanced-options`). + +.. include:: /Images/AutomaticScreenshots/DownloadExport.rst.txt diff --git a/Documentation/Usage/Import.rst b/Documentation/Usage/Import.rst new file mode 100644 index 0000000..6a50050 --- /dev/null +++ b/Documentation/Usage/Import.rst @@ -0,0 +1,84 @@ +:navigaton-title: Import + +.. include:: /Includes.rst.txt +.. _import: + +=========================================== +Importing `.t3d` files in the TYPO3 backend +=========================================== + +The import functionality is only available for admin users and Backend +users for which the Page TSconfig option +:ref:`options.impexp.enableImportForNonAdminUser +` has been enabled. + +.. contents:: Table of contents + +.. note:: + + Make sure that any required extensions are installed and the database + schema is up-to-date before starting the import. Otherwise, the data + related to non-existing tables will not be imported. + +.. _import-open-module: + +Open the import module +====================== + +In the page tree, right-click the page you want to import to (1) and +select :guilabel:`More options ... > Import` (2). + +.. include:: /Images/AutomaticScreenshots/ContextMenuImport.rst.txt + +.. _import-upload-file: + +Upload the export file +====================== + +On the second tab of the import module you can upload the export file +to your target TYPO3 instance. + +Select the file to upload (1) and click the :guilabel:`Upload files` +button (2). + +Then switch to the :guilabel:`Import` tab (3). + +.. include:: /Images/AutomaticScreenshots/UploadImport.rst.txt + +.. _import-configure-settings: + +Configure the import settings +============================= + +On the first tab of the import module you can configure the import. + +First select the uploaded export file (1). Then adjust the general +settings (2). Finally, press the :guilabel:`Preview` button (3). + +- Checking :guilabel:`Update records` means that existing records with + the same UID will be updated instead of newly inserted. + +- Checking :guilabel:`Do not show differences in records` prevents + calculation of differences between existing and imported records. + Note: The compare function is currently broken and therefore disabled + in the screenshot. + +.. include:: /Images/AutomaticScreenshots/ConfigureImport.rst.txt + +.. _import-review-data: + +Review the data to be imported +============================== + +A tree with the records to be imported is displayed below the +configuration form (1). If you change any of the options (2), you can +reload this preview with the :guilabel:`Preview` button (3). + +.. include:: /Images/AutomaticScreenshots/CheckAndPerformImport.rst.txt + +.. _import-execute: + +Execute the import +================== + +Click the :guilabel:`Import` button to execute the import process. diff --git a/Documentation/Usage/Index.rst b/Documentation/Usage/Index.rst new file mode 100644 index 0000000..09110ca --- /dev/null +++ b/Documentation/Usage/Index.rst @@ -0,0 +1,33 @@ +:navigation-title: Usage + +.. include:: /Includes.rst.txt +.. _usage: + +====================================== +How to use the Import/Export extension +====================================== + +The import/export tool can be accessed via the :ref:`TYPO3 backend` or +the :ref:`command line`. + +Users with admin rights can use both the import and the export functionality. +Editors with no admin rights can only use the export functionality (unless +it is disabled). Editors can only export content they have access to. + +The import functionality can be used for :ref:`content updates` +instead of importing the entire page tree and its content. + +The export functionality can be used to export initial content for use in +:ref:`distributions`. + +It is also possible to save and load export data :ref:`presets` for +recurring export jobs. + +.. toctree:: + :titlesonly: + + Export + Import + Update + Presets + CommandLine diff --git a/Documentation/Usage/Presets.rst b/Documentation/Usage/Presets.rst new file mode 100644 index 0000000..d352be7 --- /dev/null +++ b/Documentation/Usage/Presets.rst @@ -0,0 +1,69 @@ +:navigation-title: Presets + +.. include:: /Includes.rst.txt +.. _presets: + +===================================================== +Saving and reusing export configurations with presets +===================================================== + +.. include:: /Images/AutomaticScreenshots/Presets.rst.txt + +Any configuration settings made in the :ref:`export module ` are not +saved automatically. To reuse export configurations, you need to save them +as presets. + +Presets are stored internally in the :sql:`tx_impexp_presets` table and can +be included in export files by adding this table to the export. + +.. contents:: Table of content + +.. warning:: + + If you have manually excluded records from export, the :sql:`uid` values of + those exclusions are saved in the preset. This can lead to unexpected + exclusions if you reuse the preset in another TYPO3 instance. + + Therefore, review the excluded records in the "Configuration" tab + thoroughly when you load a preset. + +.. _presets-save: + +Saving a new preset +=================== + +To save a new preset: + +1. Go to :guilabel:`Export > File & Preset > Presets`. +2. Enter a :guilabel:`Title of new preset` (A.1). +3. Click :guilabel:`Save` (A.2). + +.. _presets-load: + +Loading an existing preset +========================== + +To load a saved preset: + +1. Select the desired preset from :guilabel:`Select Preset` (B.1). +2. Click :guilabel:`Load` (B.2). + +.. _presets-modify: + +Modifying an existing preset +============================ + +To modify an existing preset: + +1. Load the preset as described above. +2. Make the required changes to the export settings. +3. Select the same preset again in :guilabel:`Select Preset` (!). +4. Click :guilabel:`Save` to overwrite the preset. + +.. _presets-visibility: + +Managing preset visibility +========================== + +Checking :guilabel:`Public` allows any TYPO3 backend user to load this preset. +If left unchecked, only the creator can access the preset. diff --git a/Documentation/Usage/Update.rst b/Documentation/Usage/Update.rst new file mode 100644 index 0000000..6e7ad28 --- /dev/null +++ b/Documentation/Usage/Update.rst @@ -0,0 +1,25 @@ +:navigation-title: Update content + +.. include:: /Includes.rst.txt +.. _content-update: + +=========================================================== +Synchronizing content and page structures across instances +=========================================================== + +The import/export tool can be used to synchronize content and page structures +between different TYPO3 installations, leaving the content +outside the exported page tree unchanged. + +.. _content-update-backend: + +Updating content using the TYPO3 backend +======================================== + +To update existing content without creating duplicates, check the option: + +:guilabel:`Import > Import Options > Update records` + +This ensures that records with matching UIDs are updated in place. + +.. include:: /Images/AutomaticScreenshots/UpdateContent.rst.txt diff --git a/Documentation/guides.xml b/Documentation/guides.xml new file mode 100644 index 0000000..d6d7fac --- /dev/null +++ b/Documentation/guides.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..d159169 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..8144813 --- /dev/null +++ b/README.rst @@ -0,0 +1,11 @@ +========================== +TYPO3 extension ``impexp`` +========================== + +This is a tool for importing and exporting records using XML or the custom T3D +format. + +:Repository: https://github.com/typo3/typo3 +:Issues: https://forge.typo3.org/ +:Read online: https://docs.typo3.org/c/typo3/cms-impexp/main/en-us/ +:Packagist: https://packagist.org/packages/typo3/cms-impexp diff --git a/Resources/Private/Language/db.xlf b/Resources/Private/Language/db.xlf new file mode 100644 index 0000000..0b4556c --- /dev/null +++ b/Resources/Private/Language/db.xlf @@ -0,0 +1,26 @@ + + + +
    + + + Export Configuration + + + Title + + + Public + + + Owner + + + Page Root + + + Configuration + + + + diff --git a/Resources/Private/Language/locallang.xlf b/Resources/Private/Language/locallang.xlf new file mode 100644 index 0000000..a2c683f --- /dev/null +++ b/Resources/Private/Language/locallang.xlf @@ -0,0 +1,434 @@ + + + +
    + + + Import + + + Export + + + Import / Export + + + Import + + + Export + + + Configuration + + + SAVED FILE + + + Saved in "%s", bytes %s + + + Problems saving file + + + Bad path: "%s" + + + File & Preset + + + Advanced Options + + + Messages + + + Structure to be exported + + + Toggle disabled records + + + Export pagetree configuration + + + Page ID + + + Tree + + + No tree exported - only tables on the page. + + + Tables on this page + + + Levels + + + Include tables + + + Export single record + + + Record + + + Export tables from pages + + + Table/Pids + + + Table "%s" from %s + + + Relations and Exclusions + + + Include relations to tables + + + Use static relations for tables + + + Show static relations + + + Exclude elements + + + Clear all manual exclusions + + + No manually excluded elements yet. Exclude by setting checkboxes below in the element display. + + + Exclude disabled elements + + + Update + + + Files + + + Save files in extra folder beside the export file + + + (supported for "Save to filename" only) + + + Site configurations + + + Include site configurations for exported root pages (admin only) + + + Extension dependencies + + + Select extensions that the exported content depends on + + + Update + + + Presets + + + Select preset + + + Load + + + Save + + + Please confirm + + + Are you sure? + + + Delete + + + Merge + + + Title of new preset + + + Public + + + Output options + + + Title + + + Description + + + Notes + + + T3D file / compressed + + + T3D file + + + XML + + + File format + + + Filename (saved in "%s") + + + Download export + + + Save to filename + + + Select file to import + + + File + + + From path: %s + + + NOTE: No decompressor available for compressed files! + + + Import Options + + + Update + + + Update records + + + This option requires that the structure you import already exists on this server and only needs to be updated with new content! + + + Ignore PID differences globally + + + The position of updated elements will not be updated to match the structure of the input file. + + + Force ALL UIDs values + + + BE VERY CAREFUL WITH THIS: the original UID value of all imported records will be forced to be the same (Admin Only). + + + Options + + + Do not show differences in records + + + Hides the resulting differences for each field of every imported records in the preview table. + + + Action + + + Preview + + + Import + + + New import + + + Enable logging + + + Write individual database actions during import to the log + + + This is disabled by default since there might be hundred of entries generated. + + + Upload file from local computer + + + Browse + + + Upload status + + + Success + + + Failure: No file uploaded - was it too big? Check system log. + + + Only import files with the extensions "t3d" or "xml" can be uploaded. + + + Upload + + + Upload error + + + The import module hasn't received any data. This may occur due to a file upload with a large file. Please check the file size of your uploaded file with the server's post_max_size and upload_max_filesize configuration. + + + Meta data + + + Title + + + Description + + + Notes + + + Packager + + + Email + + + Meta data + + + Site Configurations + + + This import contains site configurations that will not be imported because importing site configurations requires administrator privileges. + + + Identifier + + + Website Title + + + Base + + + Root Page + + + Languages + + + ID + + + Locale + + + Dependencies + + + Messages + + + Structure has been imported, here is the result + + + The Import has failed, refer to the messages tab to view errors + + + No default upload folder + + + Structure to be imported + + + [ ALL tables ] + + + Controls + + + Title + + + Message + + + Update Mode + + + Current Path + + + Result + + + Inside pagetree + + + Outside pagetree + + + Soft References Files + + + Update + + + Insert + + + Import as new + + + Ignore PID + + + Respect PID + + + Force UID [%s] (Admin) + + + Exclude + + + Title + + + Descr + + + Value + + + Record + + + Editable + + + Exclude + + + Description + + + + diff --git a/Resources/Private/Partials/Export/AdvancedOptions.fluid.html b/Resources/Private/Partials/Export/AdvancedOptions.fluid.html new file mode 100644 index 0000000..8a89d1e --- /dev/null +++ b/Resources/Private/Partials/Export/AdvancedOptions.fluid.html @@ -0,0 +1,85 @@ + +
    + +

    + +

    +
    +
    + + + +
    +
    + +
    +
    + +

    + +

    +
    +
    + + + +
    +
    + +
    +
    + +

    + +

    +
    + + +
    +
    + +
    + +
    diff --git a/Resources/Private/Partials/Export/Configuration.fluid.html b/Resources/Private/Partials/Export/Configuration.fluid.html new file mode 100644 index 0000000..745b7c2 --- /dev/null +++ b/Resources/Private/Partials/Export/Configuration.fluid.html @@ -0,0 +1,227 @@ + + +
    + +

    + +

    + +
    +
    + +
    +
    + {inData.pagetree.id} + +
    +
    + +
    +
    + +
    +
    + + + {treeHTML} + + + + + +
    +
    + +
    + + +
    + +
    + + +
    + + +

    + +

    +
    +
    + +
    +
    + + {record.icon} + {record.title} + +
    +
    +
    +
    +
    + + +

    + +

    +
    +
    + +
    +
    + + {table.iconAndTitle} + +
    +
    +
    +
    +
    + +

    + +

    + +
    + + +
    + +
    + + +
    + +
    +
    + + + +
    +
    + +
    + + + +

    + + + + {key} + {key}, + + +

    +
    + + +
    +
    + +

    + +

    +
    +
    +
    + + + +
    +
    + +
    + +
    + +
    + + diff --git a/Resources/Private/Partials/Export/Save.fluid.html b/Resources/Private/Partials/Export/Save.fluid.html new file mode 100644 index 0000000..2fddf4a --- /dev/null +++ b/Resources/Private/Partials/Export/Save.fluid.html @@ -0,0 +1,205 @@ + + +
    + +

    + +

    + +
    + + +
    + +
    + + + + + +
    + +
    +
    + +
    + + +
    + +
    +
    + + + +
    +
    + +
    +
    + +

    + +

    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + + +
    + +
    + + diff --git a/Resources/Private/Partials/Import/Import.fluid.html b/Resources/Private/Partials/Import/Import.fluid.html new file mode 100644 index 0000000..e730756 --- /dev/null +++ b/Resources/Private/Partials/Import/Import.fluid.html @@ -0,0 +1,213 @@ + + +
    + +

    + +

    +
    + + +
    + + + + + + + + +
    + + + + + +
    + +
    +
    + +

    + +

    +

    + +

    +
    +
    + + + +
    +
    + +
    +
    + + + +
    +
    +
    + +

    + +

    +
    +
    + + + +
    +
    + + +
    +
    + + + +
    +
    +
    +
    + +

    + +

    +
    +
    + + + +
    +
    + +

    + +

    +
    + + + + + + + + + + + + + + + + + + + + +
    + +
    + + diff --git a/Resources/Private/Partials/Import/MetaData.fluid.html b/Resources/Private/Partials/Import/MetaData.fluid.html new file mode 100644 index 0000000..2923f89 --- /dev/null +++ b/Resources/Private/Partials/Import/MetaData.fluid.html @@ -0,0 +1,132 @@ + + +
    + +

    + +

    +
    + + + + + + + + + + + + + + + + + + + +
    + + + {import.metaData.title} +
    + + + {import.metaData.description} +
    + + + {import.metaData.notes} +
    + + + {import.metaData.packager_name} ({import.metaData.packager_username}) +
    + + {import.metaData.packager_email} +
    +
    + +
    + + +
    + +

    + +

    + + +

    {siteIdentifier}

    +
    + + + + + + + + + + + + + + + + + +
    + + {siteConfig._rootPageTitle} [{siteConfig.rootPageId}]
    + + {siteConfig.websiteTitle}
    + + + +
    {dependency}
    +
    +
    +
    + + +
    + + + + + + + + + + + + + + + + + + + +
    + + + + + + + +
    {language.languageId}{language.title}{language.locale}{language.base}
    +
    +
    +
    + +
    +
    + + diff --git a/Resources/Private/Partials/Import/Upload.fluid.html b/Resources/Private/Partials/Import/Upload.fluid.html new file mode 100644 index 0000000..c6d8d0c --- /dev/null +++ b/Resources/Private/Partials/Import/Upload.fluid.html @@ -0,0 +1,65 @@ + + +
    + +

    + +

    + +
    + + +
    + +
    +
    + + + +
    +
    + +
    + +
    + + +

    + +

    + + + + + + + + +
    + +
    + + diff --git a/Resources/Private/Partials/Preview.fluid.html b/Resources/Private/Partials/Preview.fluid.html new file mode 100644 index 0000000..f092193 --- /dev/null +++ b/Resources/Private/Partials/Preview.fluid.html @@ -0,0 +1,171 @@ + + + + +

    + + + + + + + + + + + + + + + + + +

    +
    + +

    + +

    +
    +
    + + + + + + + + + + + + + + +

    + +

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + +
    + {line.controls} + + {line.preCode} {line.title} + + {line.message} + + {line.updateMode} + + {line.updatePath} + + {line.showDiffContent} +
    +
    +
    + + +

    + +

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + +
    + {line.controls} + + {line.preCode} {line.title} + + {line.message} + + {line.updateMode} + + {line.updatePath} + + {line.showDiffContent} +
    +
    +
    + + diff --git a/Resources/Private/Templates/Export.fluid.html b/Resources/Private/Templates/Export.fluid.html new file mode 100644 index 0000000..2d5ede7 --- /dev/null +++ b/Resources/Private/Templates/Export.fluid.html @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + +

    + +

    +
    + + +
    +
    + +
    +
    + +
    +
    + +
    + +
    + + +

    {error}

    +
    +
    +
    +
    +
    + + + +
    + + diff --git a/Resources/Private/Templates/Import.fluid.html b/Resources/Private/Templates/Import.fluid.html new file mode 100644 index 0000000..39fee8e --- /dev/null +++ b/Resources/Private/Templates/Import.fluid.html @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + +

    + +

    +
    + + +
    +
    + +
    + +
    + +
    +
    + +
    + +
    +
    + +
    + + +

    {error}

    +
    +
    +
    +
    +
    + + + + +
    + + diff --git a/Resources/Public/Icons/Extension.png b/Resources/Public/Icons/Extension.png new file mode 100644 index 0000000..bfccfa9 Binary files /dev/null and b/Resources/Public/Icons/Extension.png differ diff --git a/Resources/Public/Icons/status-reference-hard.png b/Resources/Public/Icons/status-reference-hard.png new file mode 100644 index 0000000..67dcb37 Binary files /dev/null and b/Resources/Public/Icons/status-reference-hard.png differ diff --git a/Resources/Public/Icons/status-reference-soft.png b/Resources/Public/Icons/status-reference-soft.png new file mode 100644 index 0000000..e66a668 Binary files /dev/null and b/Resources/Public/Icons/status-reference-soft.png differ diff --git a/Resources/Public/JavaScript/context-menu-actions.js b/Resources/Public/JavaScript/context-menu-actions.js new file mode 100644 index 0000000..883a8a5 --- /dev/null +++ b/Resources/Public/JavaScript/context-menu-actions.js @@ -0,0 +1,13 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ +class a{exportT3d(t,e,o){const n=o.actionUrl;t==="pages"?top.TYPO3.Backend.ContentContainer.setUrl(n+"&id="+e+"&tx_impexp[pagetree][id]="+e+"&tx_impexp[pagetree][levels]=0&tx_impexp[pagetree][tables][]=_ALL"):top.TYPO3.Backend.ContentContainer.setUrl(n+"&tx_impexp[record][]="+t+":"+e+"&tx_impexp[external_ref][tables][]=_ALL")}importT3d(t,e,o){const n=o.actionUrl;top.TYPO3.Backend.ContentContainer.setUrl(n+"&id="+e+"&table="+t)}}var r=new a;export{r as default}; diff --git a/Resources/Public/JavaScript/import-export.js b/Resources/Public/JavaScript/import-export.js new file mode 100644 index 0000000..06abb9d --- /dev/null +++ b/Resources/Public/JavaScript/import-export.js @@ -0,0 +1,13 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ +import r from"@typo3/backend/modal.js";import o from"@typo3/core/event/regular-event.js";import n from"@typo3/core/document-service.js";class s{constructor(){n.ready().then(()=>this.registerEvents())}registerEvents(){new o("click",this.triggerConfirmation).delegateTo(document,".t3js-confirm-trigger");const e=document.querySelector(".t3js-impexp-toggledisabled");e!==null&&new o("click",this.toggleDisabled).bindTo(e)}triggerConfirmation(){const e=r.confirm(this.dataset.title,this.dataset.message);e.addEventListener("confirm.button.ok",()=>{const t=document.getElementById("t3js-submit-field");t.name=this.name,t.closest("form").submit(),e.hideModal()}),e.addEventListener("confirm.button.cancel",()=>{e.hideModal()})}toggleDisabled(){const e=document.querySelectorAll('table.t3js-impexp-preview tr[data-active="hidden"] input.t3js-exclude-checkbox');if(e.length>0){const t=e.item(0);e.forEach(i=>{i.checked=!t.checked})}}}var c=new s;export{c as default}; diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..e7cc632 --- /dev/null +++ b/composer.json @@ -0,0 +1,56 @@ +{ + "name": "typo3/cms-impexp", + "type": "typo3-cms-framework", + "description": "TYPO3 CMS Import/Export - Tool for importing and exporting records using XML or the custom T3D format.", + "homepage": "https://typo3.community/", + "funding": [ + { + "type": "membership", + "url": "https://typo3.org/membership" + } + ], + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "TYPO3 Core Team", + "email": "typo3cms@typo3.org", + "role": "Developer" + } + ], + "support": { + "issues": "https://forge.typo3.org/issues/", + "forum": "https://talk.typo3.org/", + "source": "https://github.com/TYPO3/typo3/", + "docs": "https://docs.typo3.org/c/typo3/cms-impexp/main/en-us/", + "rss": "https://news.typo3.com/rss/", + "chat": "https://typo3.community/meet/slack/", + "security": "https://typo3.org/security/" + }, + "config": { + "sort-packages": true + }, + "require": { + "typo3/cms-core": "15.0.*@dev" + }, + "conflict": { + "typo3/cms": "*" + }, + "extra": { + "branch-alias": { + "dev-main": "15.0.x-dev" + }, + "typo3/cms": { + "Package": { + "partOfFactoryDefault": true + }, + "extension-key": "impexp" + } + }, + "autoload": { + "psr-4": { + "TYPO3\\CMS\\Impexp\\": "Classes/" + } + } +} diff --git a/ext_tables.sql b/ext_tables.sql new file mode 100644 index 0000000..6ff2341 --- /dev/null +++ b/ext_tables.sql @@ -0,0 +1,27 @@ +CREATE TABLE tx_impexp_presets ( + # type=passthrough fields need manual configuration + title varchar(255) DEFAULT '' NOT NULL, + public tinyint(3) DEFAULT '0' NOT NULL, + item_uid int(11) DEFAULT '0' NOT NULL, + user_uid int(11) unsigned DEFAULT '0' NOT NULL, + preset_data blob, + KEY lookup (item_uid) +); + +# Some fields need manual configuration +CREATE TABLE tt_content ( + # type=passthrough fields need manual configuration + tx_impexp_origuid int(11) DEFAULT '0' NOT NULL +); + +# Some fields need manual configuration +CREATE TABLE pages ( + # type=passthrough fields need manual configuration + tx_impexp_origuid int(11) DEFAULT '0' NOT NULL +); + +# Some fields need manual configuration +CREATE TABLE sys_template ( + # type=passthrough fields need manual configuration + tx_impexp_origuid int(11) DEFAULT '0' NOT NULL +);