diff --git a/README.md b/README.md index b0172e8..7961d78 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,20 @@ Install the package with: composer require netgen/ibexa-import-export ``` +### Register the routes + +The bundle ships with its own routing file but does not auto-import it. Add it +to your application's routing configuration (typically `config/routes.yaml` or +an Ibexa Admin UI routes file): + +```yaml +ibexa.import_export: + resource: '@NetgenIbexaImportExportBundle/Resources/config/routing.yaml' +``` + +After clearing the cache the module routes become available and the +**Import/Export** menu item appears under **Admin**. + Licensed under [GPLv2](LICENSE) Import/Export module diff --git a/bundle/Controller/Ajax/Preview.php b/bundle/Controller/Ajax/Preview.php index 593c0e2..a6bb997 100644 --- a/bundle/Controller/Ajax/Preview.php +++ b/bundle/Controller/Ajax/Preview.php @@ -14,6 +14,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Yaml; use function array_key_exists; @@ -42,55 +43,43 @@ public function __invoke(Request $request): Response { $this->denyAccessUnlessGranted('ibexa:import_export:access'); - /** @var \Symfony\Component\HttpFoundation\File\UploadedFile $file */ + /** @var \Symfony\Component\HttpFoundation\File\UploadedFile|null $file */ $file = $request->files->get('file'); - $originalFilename = $file->getClientOriginalName(); $errors = []; $skippedContent = []; $skippedContentFields = []; - $fileExtension = pathinfo($originalFilename, PATHINFO_EXTENSION); + if ($file === null) { + return new Response( + $this->render('@NetgenIbexaImportExport/preview.html.twig', [ + 'import_structure' => null, + 'import_mode' => null, + 'errors' => ['No file uploaded.'], + 'skipped_content' => $skippedContent, + 'skipped_content_fields' => $skippedContentFields, + ])->getContent(), + Response::HTTP_BAD_REQUEST, + ); + } + $originalFilename = $file->getClientOriginalName(); + $fileExtension = pathinfo($originalFilename, PATHINFO_EXTENSION); $isYaml = in_array(mb_strtolower($fileExtension), ['yml', 'yaml'], true); if ($isYaml === false) { $errors[] = 'Uploaded file is not a valid yaml file!'; } else { - $yamlParsed = Yaml::parseFile($file->getRealPath()); - - if (count($yamlParsed) > 1) { - $importStructure = 'Subtree'; - } else { - $importStructure = 'Singular content'; - } - $importMode = $yamlParsed[0]['mode']; - - if ($importMode === 'create' && $importStructure === 'Subtree') { - $rootRemoteId = $yamlParsed[0]['remote_id'] ?? null; - $rootName = $yamlParsed[0]['exported_content_name'] ?? null; - - if (is_string($rootRemoteId) && $rootRemoteId !== '') { - try { - $this->repository->sudo( - fn () => $this->contentService->loadContentByRemoteId($rootRemoteId), - ); - - $errors[] = sprintf( - 'Subtree import cannot be executed because the root content %s(remote id: %s) already exists.', - $rootName, - $rootRemoteId, - ); - } catch (NotFoundException) { - // Do nothing - } - } + try { + $yamlParsed = Yaml::parseFile($file->getRealPath()); + } catch (ParseException) { + $yamlParsed = null; } - if (count($errors) > 0) { + if (!is_array($yamlParsed) || $yamlParsed === [] || !isset($yamlParsed[0]['mode'])) { $response = $this->render('@NetgenIbexaImportExport/preview.html.twig', [ - 'import_structure' => $importStructure, - 'import_mode' => $importMode, - 'errors' => $errors, + 'import_structure' => null, + 'import_mode' => null, + 'errors' => ['The uploaded file is not a valid migration YAML.'], 'skipped_content' => $skippedContent, 'skipped_content_fields' => $skippedContentFields, ]); @@ -98,6 +87,13 @@ public function __invoke(Request $request): Response return new Response($response->getContent(), Response::HTTP_BAD_REQUEST); } + if (count($yamlParsed) > 1) { + $importStructure = 'Subtree'; + } else { + $importStructure = 'Singular content'; + } + $importMode = $yamlParsed[0]['mode']; + foreach ($yamlParsed as $content) { $contentRemoteId = $importMode === 'update' ? $content['match']['content_remote_id'] : $content['remote_id']; @@ -152,6 +148,7 @@ public function __invoke(Request $request): Response || $importMode === 'update' && !array_key_exists($contentRemoteId, $skippedContent) ) { $attributes = $content['attributes']; + $isMultiLanguage = !array_key_exists('lang', $content); foreach ($attributes as $field => $value) { $fieldTypeIdentifier = $contentType->getFieldDefinition($field)->fieldTypeIdentifier; @@ -165,14 +162,29 @@ public function __invoke(Request $request): Response continue; } - $skipsField = $fieldHandler->skipsField($value); - if (is_string($skipsField)) { - $skippedContentFields[$contentRemoteId]['fields'][$fieldTypeIdentifier . '-' . $field][] = $skipsField; - $skippedContentFields[$contentRemoteId]['name'] = $content['exported_content_name']; - } elseif (is_array($skipsField) && count($skipsField) > 0) { - foreach ($skipsField as $skippedField) { - $skippedContentFields[$contentRemoteId]['fields'][$fieldTypeIdentifier . '-' . $field][] = $skippedField; + if ($isMultiLanguage && is_array($value)) { + foreach ($value as $langCode => $langValue) { + $skipsField = $fieldHandler->skipsField($langValue); + if (is_string($skipsField)) { + $skippedContentFields[$contentRemoteId]['fields'][$fieldTypeIdentifier . '-' . $field][] = $skipsField . ' (' . $langCode . ')'; + $skippedContentFields[$contentRemoteId]['name'] = $content['exported_content_name']; + } elseif (is_array($skipsField) && count($skipsField) > 0) { + foreach ($skipsField as $skippedField) { + $skippedContentFields[$contentRemoteId]['fields'][$fieldTypeIdentifier . '-' . $field][] = $skippedField . ' (' . $langCode . ')'; + $skippedContentFields[$contentRemoteId]['name'] = $content['exported_content_name']; + } + } + } + } else { + $skipsField = $fieldHandler->skipsField($value); + if (is_string($skipsField)) { + $skippedContentFields[$contentRemoteId]['fields'][$fieldTypeIdentifier . '-' . $field][] = $skipsField; $skippedContentFields[$contentRemoteId]['name'] = $content['exported_content_name']; + } elseif (is_array($skipsField) && count($skipsField) > 0) { + foreach ($skipsField as $skippedField) { + $skippedContentFields[$contentRemoteId]['fields'][$fieldTypeIdentifier . '-' . $field][] = $skippedField; + $skippedContentFields[$contentRemoteId]['name'] = $content['exported_content_name']; + } } } } diff --git a/bundle/Controller/Export/Export.php b/bundle/Controller/Export/Export.php index 88d9872..db30b0f 100644 --- a/bundle/Controller/Export/Export.php +++ b/bundle/Controller/Export/Export.php @@ -20,12 +20,19 @@ use Symfony\Component\Yaml\Yaml; use Symfony\Contracts\Translation\TranslatorInterface; +use function array_filter; +use function array_map; +use function array_unique; +use function array_values; use function basename; +use function count; use function file_put_contents; +use function is_array; use function is_dir; use function mkdir; use function sprintf; use function str_replace; +use function usort; final class Export extends AbstractController { @@ -66,136 +73,203 @@ public function __invoke(Request $request): Response $form = $this->createForm(ExportType::class); $form->handleRequest($request); - $fileName = null; + $fileNames = []; if ($form->isSubmitted() && $form->isValid()) { - $contentId = $form->get('source')->getData(); + $contentIds = (array) $form->get('source')->getData(); + $contentIds = array_values(array_unique(array_filter($contentIds, static fn ($id): bool => $id !== null && $id !== ''))); $migrationType = $form->get('migration_type')->getData(); $sourceStructure = $form->get('source_structure')->getData(); - try { - $content = $this->repository->sudo( - fn () => $this->contentService->loadContent((int) $contentId), + foreach ($contentIds as $contentId) { + $generated = $this->exportSingleContent( + (int) $contentId, + (string) $migrationType, + (string) $sourceStructure, + $phpPath, ); - } catch (NotFoundException) { + + if ($generated !== null) { + $fileNames[] = $generated; + } + } + + if (count($fileNames) > 0) { $this->addFlash( - 'error', + 'success', $this->translator->trans( - 'netgen.ibexa_import_export.error.export.content', + 'netgen.ibexa_import_export.success.export', [], 'import_export', ), ); - - return $this->render( - '@NetgenIbexaImportExport/export.html.twig', - [ - 'form' => $form->createView(), - 'file' => $fileName, - ], - ); } + } - if ($sourceStructure === 'subtree') { - $subtreePath = $content->contentInfo->getMainLocation()->pathString; - $process = new Process( - [ - $phpPath, - '../bin/console', - 'kaliop:migration:generate', - '--type=content', - '--match-type=subtree', - '--match-value=' . $subtreePath, - '--mode=' . $migrationType, - '../' . $this->migrationsPath, - $migrationType . '_subtree', - ], - ); - } else { - $process = new Process( - [ - $phpPath, - '../bin/console', - 'kaliop:migration:generate', - '--type=content', - '--match-type=content_id', - '--match-value=' . $contentId, - '--mode=' . $migrationType, - '../' . $this->migrationsPath, - $migrationType . '_content', - ], - ); - } + return $this->render( + '@NetgenIbexaImportExport/export.html.twig', + [ + 'form' => $form->createView(), + 'files' => $fileNames, + ], + ); + } - $process->run(); + /** + * Generates the migration YAML for a single content (or its subtree) and post-processes it. + * + * Returns the generated file name on success, or null on any failure (a flash message is set). + */ + private function exportSingleContent( + int $contentId, + string $migrationType, + string $sourceStructure, + string $phpPath, + ): ?string { + try { + $content = $this->repository->sudo( + fn () => $this->contentService->loadContent($contentId), + ); + } catch (NotFoundException) { + $this->addFlash( + 'error', + $this->translator->trans( + 'netgen.ibexa_import_export.error.export.content', + [], + 'import_export', + ), + ); - if (!$process->isSuccessful()) { - $error = sprintf( - 'The command "%s" failed. Exit Code: %s(%s) Working directory: %s', - $process->getCommandLine(), - $process->getExitCode(), - $process->getExitCodeText(), - $process->getWorkingDirectory(), - ); + return null; + } - $this->logger->error($error); - $this->logger->error($process->getErrorOutput()); + // Suffix the migration name with the content id so concurrent generations + // within the same second do not collide on file name. + $migrationName = $sourceStructure === 'subtree' + ? $migrationType . '_subtree_' . $contentId + : $migrationType . '_content_' . $contentId; - $this->addFlash( - 'error', - $this->translator->trans( - 'netgen.ibexa_import_export.error.export', - [], - 'import_export', - ), + if ($sourceStructure === 'subtree') { + $subtreePath = $content->contentInfo->getMainLocation()->pathString; + $process = new Process( + [ + $phpPath, + '../bin/console', + 'kaliop:migration:generate', + '--type=content', + '--match-type=subtree', + '--match-value=' . $subtreePath, + '--mode=' . $migrationType, + '--lang=all', + '../' . $this->migrationsPath, + $migrationName, + ], + ); + } else { + $process = new Process( + [ + $phpPath, + '../bin/console', + 'kaliop:migration:generate', + '--type=content', + '--match-type=content_id', + '--match-value=' . $contentId, + '--mode=' . $migrationType, + '--lang=all', + '../' . $this->migrationsPath, + $migrationName, + ], + ); + } + + $process->run(); + + if (!$process->isSuccessful()) { + $error = sprintf( + 'The command "%s" failed. Exit Code: %s(%s) Working directory: %s', + $process->getCommandLine(), + $process->getExitCode(), + $process->getExitCodeText(), + $process->getWorkingDirectory(), + ); + + $this->logger->error($error); + $this->logger->error($process->getErrorOutput()); + + $this->addFlash( + 'error', + $this->translator->trans( + 'netgen.ibexa_import_export.error.export', + [], + 'import_export', + ), + ); + + return null; + } + + $fileName = str_replace("\n", '', basename($process->getOutput())); + $projectRoot = $this->container->getParameter('kernel.project_dir'); + $filePath = $projectRoot . '/' . $this->migrationsPath . '/' . $fileName; + $yamlParsed = Yaml::parseFile($filePath); + + foreach ($yamlParsed as &$content) { + if ($migrationType === 'create') { + $location = $this->repository->sudo( + fn () => $this->locationService->loadLocation($content['parent_location']), ); - } else { - $fileName = str_replace("\n", '', basename($process->getOutput())); - $projectRoot = $this->container->getParameter('kernel.project_dir'); - $filePath = $projectRoot . '/' . $this->migrationsPath . '/' . $fileName; - $yamlParsed = Yaml::parseFile($filePath); - - foreach ($yamlParsed as &$content) { - if ($migrationType === 'create') { - $location = $this->repository->sudo( - fn () => $this->locationService->loadLocation($content['parent_location']), - ); - $locationRemoteId = $location->remoteId; - $content['parent_location'] = $locationRemoteId; - $content['exported_content_name'] = $this->repository->sudo( - fn () => $this->contentService->loadContentByRemoteId($content['remote_id'])->getName(), - ); - } elseif ($migrationType === 'update') { - $remoteId = $content['new_remote_id'] ?? $content['match']['content_remote_id'] ?? ''; - - $content['exported_content_name'] = $this->repository->sudo( - fn () => $this->contentService->loadContentByRemoteId($remoteId)->getName(), - ); - } + $locationRemoteId = $location->remoteId; + $content['parent_location'] = $locationRemoteId; + + // The kaliop generator stores other_parent_locations as numeric ids; convert each to remote id + // so the migration is portable across environments. + if (isset($content['other_parent_locations']) && is_array($content['other_parent_locations'])) { + $content['other_parent_locations'] = array_map( + fn ($otherLocationId) => $this->repository->sudo( + fn () => $this->locationService->loadLocation((int) $otherLocationId)->remoteId, + ), + $content['other_parent_locations'], + ); } - $yaml = Yaml::dump($yamlParsed); - file_put_contents($filePath, $yaml); + $content['exported_content_name'] = $this->repository->sudo( + fn () => $this->contentService->loadContentByRemoteId($content['remote_id'])->getName(), + ); + // parent depth + 1 = depth of this content's location + $content['__sort_depth'] = $location->depth + 1; + } elseif ($migrationType === 'update') { + $remoteId = $content['new_remote_id'] ?? $content['match']['content_remote_id'] ?? ''; - $this->addFlash( - 'success', - $this->translator->trans( - 'netgen.ibexa_import_export.success.export', - [], - 'import_export', - ), + $loadedContent = $this->repository->sudo( + fn () => $this->contentService->loadContentByRemoteId($remoteId), ); + $content['exported_content_name'] = $loadedContent->getName(); - $this->logger->info('Export successful: ' . $process->getOutput()); + $mainLocation = $this->repository->sudo( + fn () => $this->locationService->loadLocation($loadedContent->contentInfo->mainLocationId), + ); + $content['__sort_depth'] = $mainLocation->depth; } } + unset($content); - return $this->render( - '@NetgenIbexaImportExport/export.html.twig', - [ - 'form' => $form->createView(), - 'file' => $fileName ?? null, - ], + // Sort entries so parents (shallower depth) come before children. Required for create-mode + // imports where a child cannot be created before its parent location exists. + usort( + $yamlParsed, + static fn (array $a, array $b): int => ($a['__sort_depth'] ?? 0) <=> ($b['__sort_depth'] ?? 0), ); + + foreach ($yamlParsed as &$content) { + unset($content['__sort_depth']); + } + unset($content); + + $yaml = Yaml::dump($yamlParsed); + file_put_contents($filePath, $yaml); + + $this->logger->info('Export successful: ' . $process->getOutput()); + + return $fileName; } } diff --git a/bundle/Controller/Import/Import.php b/bundle/Controller/Import/Import.php index 6b85408..b2c58b5 100644 --- a/bundle/Controller/Import/Import.php +++ b/bundle/Controller/Import/Import.php @@ -17,14 +17,20 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\Process; +use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Yaml; use Symfony\Contracts\Translation\TranslatorInterface; +use function array_values; use function date; use function file_put_contents; +use function is_array; use function is_dir; use function mkdir; +use function preg_match; +use function preg_replace; use function sprintf; +use function uniqid; final class Import extends AbstractController { @@ -66,7 +72,41 @@ public function __invoke(Request $request): Response /** @var \Symfony\Component\HttpFoundation\File\UploadedFile $uploadedFile */ $uploadedFile = $form->get('package')->getData(); - $yamlParsed = Yaml::parseFile($uploadedFile->getRealPath()); + try { + $yamlParsed = Yaml::parseFile($uploadedFile->getRealPath()); + } catch (ParseException $e) { + $this->logger->error('Import YAML parse error: ' . $e->getMessage()); + $this->addFlash( + 'error', + $this->translator->trans( + 'netgen.ibexa_import_export.error.import.invalid_file', + [], + 'import_export', + ), + ); + + return $this->render( + '@NetgenIbexaImportExport/import.html.twig', + ['form' => $form->createView()], + ); + } + + if (!is_array($yamlParsed) || $yamlParsed === [] || !isset($yamlParsed[0]['mode'])) { + $this->addFlash( + 'error', + $this->translator->trans( + 'netgen.ibexa_import_export.error.import.invalid_file', + [], + 'import_export', + ), + ); + + return $this->render( + '@NetgenIbexaImportExport/import.html.twig', + ['form' => $form->createView()], + ); + } + $importMode = $yamlParsed[0]['mode']; if ($importMode === 'create') { @@ -93,10 +133,18 @@ public function __invoke(Request $request): Response ], ); } + // Rewrite the root entry's parent_location BEFORE the skip-existing loop runs. + // After array_values reindex below, $yamlParsed[0] may no longer be the original + // root (if it gets unset because it already exists), so this rewrite must target + // the export's root entry while it still sits at index 0. $yamlParsed[0]['parent_location'] = $parentLocation->remoteId; foreach ($yamlParsed as $key => $content) { - $contentRemoteId = $content['remote_id']; - $locationRemoteId = $content['location_remote_id']; + $contentRemoteId = $content['remote_id'] ?? null; + $locationRemoteId = $content['location_remote_id'] ?? null; + + if ($contentRemoteId === null || $locationRemoteId === null) { + continue; + } try { $this->repository->sudo(fn () => $this->contentService->loadContentByRemoteId($contentRemoteId)); @@ -106,9 +154,16 @@ public function __invoke(Request $request): Response // Do nothing } } + // Reindex so the dumped YAML is a sequence (kaliop expects a list of steps, + // not an associative map keyed by surviving indexes). + $yamlParsed = array_values($yamlParsed); } else { foreach ($yamlParsed as $key => $content) { - $contentRemoteId = $content['match']['content_remote_id']; + $contentRemoteId = $content['match']['content_remote_id'] ?? null; + + if ($contentRemoteId === null) { + continue; + } try { $this->repository->sudo(fn () => $this->contentService->loadContentByRemoteId($contentRemoteId)); @@ -116,13 +171,17 @@ public function __invoke(Request $request): Response unset($yamlParsed[$key]); } } + $yamlParsed = array_values($yamlParsed); } $yaml = Yaml::dump($yamlParsed); $projectRoot = $this->container->getParameter('kernel.project_dir'); $randomTimeComponent = date('YmdHis'); - $newFilePath = $projectRoot . '/' . $this->migrationsPath . '/' . $randomTimeComponent . $uploadedFile->getClientOriginalName(); + // Server-generate the filename: never trust getClientOriginalName(), which a malicious + // upload could craft to traverse out of the migrations directory. + $safeOriginalName = preg_replace('/[^A-Za-z0-9._-]/', '_', $uploadedFile->getClientOriginalName()); + $newFilePath = $projectRoot . '/' . $this->migrationsPath . '/' . $randomTimeComponent . '_' . uniqid() . '_' . $safeOriginalName; file_put_contents($newFilePath, $yaml); @@ -138,7 +197,11 @@ public function __invoke(Request $request): Response $process->setInput($additionalAnswers); $process->run(); - if (!$process->isSuccessful()) { + $output = $process->getOutput(); + $hasFailedMigrations = preg_match('/failed\s+([1-9]\d*)/i', $output) === 1; + $importFailed = !$process->isSuccessful() || $hasFailedMigrations; + + if ($importFailed) { $error = sprintf( 'The command "%s" failed. Exit Code: %s(%s) Working directory: %s', $process->getCommandLine(), @@ -149,6 +212,7 @@ public function __invoke(Request $request): Response $this->logger->error($error); $this->logger->error($process->getErrorOutput()); + $this->logger->error($output); $this->addFlash( 'error', @@ -158,18 +222,18 @@ public function __invoke(Request $request): Response 'import_export', ), ); - } - - $this->addFlash( - 'success', - $this->translator->trans( - 'netgen.ibexa_import_export.success.import', - [], - 'import_export', - ), - ); + } else { + $this->addFlash( + 'success', + $this->translator->trans( + 'netgen.ibexa_import_export.success.import', + [], + 'import_export', + ), + ); - $this->logger->info('Import successful: ' . $process->getOutput()); + $this->logger->info('Import successful: ' . $output); + } return $this->redirectToRoute('netgen_import_export.route.admin.import'); } diff --git a/bundle/DependencyInjection/NetgenIbexaImportExportExtension.php b/bundle/DependencyInjection/NetgenIbexaImportExportExtension.php index 9f42005..727e614 100644 --- a/bundle/DependencyInjection/NetgenIbexaImportExportExtension.php +++ b/bundle/DependencyInjection/NetgenIbexaImportExportExtension.php @@ -4,6 +4,7 @@ namespace Netgen\IbexaImportExportBundle\DependencyInjection; +use Netgen\IbexaImportExport\Core\Executor\ContentManagerWithTranslations; use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\Loader\DelegatingLoader; use Symfony\Component\Config\Loader\LoaderResolver; @@ -52,6 +53,11 @@ public function load(array $configs, ContainerBuilder $container): void $loader->load('services/*.yaml', 'glob'); $loader->load('default_settings.yaml'); + $container->setParameter( + 'ez_migration_bundle.executor.content_manager.class', + ContentManagerWithTranslations::class, + ); + $this->processExtensionConfiguration($configs, $container); } diff --git a/bundle/Form/ExportType.php b/bundle/Form/ExportType.php index 8a1a0ee..8d0e808 100644 --- a/bundle/Form/ExportType.php +++ b/bundle/Form/ExportType.php @@ -4,7 +4,7 @@ namespace Netgen\IbexaImportExportBundle\Form; -use Netgen\ContentBrowser\Form\Type\ContentBrowserType; +use Netgen\ContentBrowser\Form\Type\ContentBrowserMultipleType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\FormBuilderInterface; @@ -40,11 +40,13 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'label' => 'netgen.ibexa_import_export.form.export.source_structure', ])->add( 'source', - ContentBrowserType::class, + ContentBrowserMultipleType::class, [ 'label' => 'netgen.ibexa_import_export.form.export.source', 'item_type' => 'ibexa_content', 'required' => true, + 'min' => 1, + 'max' => null, ], ); } diff --git a/bundle/Form/ImportType.php b/bundle/Form/ImportType.php index 233ef7e..5224303 100644 --- a/bundle/Form/ImportType.php +++ b/bundle/Form/ImportType.php @@ -32,7 +32,9 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ContentBrowserType::class, [ 'item_type' => 'ibexa_location', - 'required' => true, + // Required only for create-mode imports — update mode does not use this field. + // The controller validates presence at runtime when needed. + 'required' => false, 'label' => 'netgen.ibexa_import_export.form.import.parent_location', ], ); diff --git a/bundle/Resources/public/admin/import_export.js b/bundle/Resources/public/admin/import_export.js index 339669a..48db5d7 100644 --- a/bundle/Resources/public/admin/import_export.js +++ b/bundle/Resources/public/admin/import_export.js @@ -13,8 +13,86 @@ var previewDiv = doc.querySelector('#ibexa-import-export__preview'); var previewButton = doc.querySelector('#ibexa-import-export__preview-button'); var formContainer = doc.querySelector('.ibexa-import-export__form'); + var parentLocationField = doc.querySelector('.ibexa-import-export__field-parent-location'); + var submitHint = doc.querySelector('.ibexa-import-export__submit-hint'); var previewUrl = formContainer.dataset.previewUrl; + // Tracks the latest preview state. Updated on every preview response. + var previewSucceeded = false; + + function getImportMode() { + var previewSection = previewDiv.querySelector('[data-import-mode]'); + return previewSection ? previewSection.dataset.importMode : ''; + } + + // The content browser's single widget renders two hidden inputs: + // .js-item-type — carries the item-type config (e.g. "ibexa_location"), NOT the picked value + // .js-value — carries the actually picked location value (or empty when nothing is selected) + // Always target .js-value to read the real selection. + function getParentLocationValue() { + if (!parentLocationField) { + return ''; + } + var valueInput = parentLocationField.querySelector('.js-value'); + return valueInput ? (valueInput.value || '') : ''; + } + + function applyImportModeToForm() { + if (!parentLocationField) { + return; + } + + var importMode = getImportMode(); + var valueInput = parentLocationField.querySelector('.js-value'); + + // parent_location is irrelevant for update-mode imports; grey it out. + if (importMode === 'update') { + parentLocationField.classList.add('ibexa-import-export__field-parent-location--disabled'); + if (valueInput) { + valueInput.disabled = true; + } + } else { + parentLocationField.classList.remove('ibexa-import-export__field-parent-location--disabled'); + if (valueInput) { + valueInput.disabled = false; + } + } + } + + function refreshSubmitState() { + if (!previewButton) { + return; + } + + var importMode = getImportMode(); + // Block submit only when create-mode and no destination has been chosen yet. + // For update-mode, parent_location is unused so it doesn't gate submit. + var needsLocation = importMode === 'create' && getParentLocationValue() === ''; + var canSubmit = previewSucceeded && !needsLocation; + + previewButton.disabled = !canSubmit; + previewButton.style.opacity = canSubmit ? 1 : 0.5; + previewButton.title = needsLocation + ? 'Choose an import location to enable the import.' + : ''; + + if (submitHint) { + submitHint.hidden = !needsLocation; + } + } + + // Watch the parent_location wrapper for any DOM/value change made by the content browser + // (it doesn't reliably fire native input/change events). Each mutation triggers a refresh. + if (parentLocationField && typeof MutationObserver !== 'undefined') { + var observer = new MutationObserver(refreshSubmitState); + observer.observe(parentLocationField, { + subtree: true, + childList: true, + attributes: true, + characterData: true, + }); + } + if (packageInput) { packageInput.addEventListener('change', function() { var formData = new FormData(); @@ -25,20 +103,19 @@ xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); xhr.onreadystatechange = function () { if (xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status === 200) { - previewDiv.innerHTML = xhr.responseText; - previewButton.disabled = false; - previewButton.style.opacity = 1; - } else { - previewDiv.innerHTML = xhr.responseText; - previewButton.disabled = true; - previewButton.style.opacity = 0.5; - } + previewDiv.innerHTML = xhr.responseText; + previewSucceeded = xhr.status === 200; + + applyImportModeToForm(); + refreshSubmitState(); } }; xhr.send(formData); }); } + + // Initial state: no preview yet, so submit is disabled. + refreshSubmitState(); }); })(window, window.document, window.ibexa, window.React, window.ReactDOM); diff --git a/bundle/Resources/public/admin/import_export.scss b/bundle/Resources/public/admin/import_export.scss index 8d28606..7991944 100644 --- a/bundle/Resources/public/admin/import_export.scss +++ b/bundle/Resources/public/admin/import_export.scss @@ -100,6 +100,81 @@ color: white !important; } +/** + * --- Generated export files list (shown above the form after a successful export) --- + * Uses its own block instead of cramming buttons into the alert's actions slot, + * which would squeeze the alert title and break layout for many files. + */ +.ibexa-import-export__files-list { + list-style: none; + padding: 0; + margin: 0 0 2rem 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.ibexa-import-export__files-list-item { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.5rem 1rem; + background-color: #ffffff; + border: 1px solid #e0e0e0; + border-radius: 0.375rem; + + .ibexa-btn { + flex: 0 0 auto; + background-color: #007bff !important; + border-color: #007bff !important; + color: white !important; + } +} + +.ibexa-import-export__files-list-name { + font-family: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace; + font-size: 0.9rem; + color: #333; + word-break: break-all; + flex: 1 1 auto; +} + +/** + * --- Disabled state for the parent_location field on Import --- + * Applied by JS when the uploaded YAML is detected to be in update mode + * (parent_location is unused in that mode). + */ +.ibexa-import-export__field-parent-location--disabled { + opacity: 0.5; + pointer-events: none; + user-select: none; + cursor: not-allowed; + position: relative; +} + +.ibexa-import-export__field-parent-location--disabled::after { + content: "(not used in update mode)"; + display: block; + margin-top: 0.5rem; + font-style: italic; + font-size: 0.85rem; + color: #6b7280; +} + +/** + * --- Hint shown above the import submit button when a location is required but missing --- + */ +.ibexa-import-export__submit-hint { + margin: 0.5rem 0; + padding: 0.5rem 0.75rem; + background-color: #fff3cd; + border-left: 3px solid #f59e0b; + border-radius: 0.25rem; + color: #856404; + font-size: 0.9rem; + font-style: italic; +} + /* OVERRIDE: Set Import Button to Red/Danger color */ .ibexa-import-export-import .ibexa-import-export__form input[type="submit"] { background-color: #dc3545 !important; @@ -108,11 +183,19 @@ filter: none; } -.ibexa-import-export-import .ibexa-import-export__form input[type="submit"]:hover { +.ibexa-import-export-import .ibexa-import-export__form input[type="submit"]:hover:not([disabled]) { background-color: #c82333 !important; /* Slightly darker red on hover */ border-color: #bd2130 !important; } +/* Disabled import button — clearly grey instead of muted-red so the user notices */ +.ibexa-import-export-import .ibexa-import-export__form input[type="submit"][disabled] { + background-color: #adb5bd !important; + border-color: #adb5bd !important; + cursor: not-allowed; + opacity: 1 !important; /* override the inline 0.5 from JS — colour change conveys disabled clearly */ +} + /** * ==================================================================== diff --git a/bundle/Resources/translations/import_export.en.yaml b/bundle/Resources/translations/import_export.en.yaml index af58bb4..53f6c36 100644 --- a/bundle/Resources/translations/import_export.en.yaml +++ b/bundle/Resources/translations/import_export.en.yaml @@ -2,11 +2,14 @@ netgen.ibexa_import_export.title: 'Import/Export' netgen.ibexa_import_export.import: 'Import' netgen.ibexa_import_export.export: 'Export' netgen.ibexa_import_export.export.download_file: 'Download file' +netgen.ibexa_import_export.index.import_description: 'Upload a package file to import content into the site.' +netgen.ibexa_import_export.index.export_description: 'Generate and download a package file containing selected content.' netgen.ibexa_import_export.preview.import_details: 'Import details' netgen.ibexa_import_export.preview.structure: 'Structure' netgen.ibexa_import_export.preview.type: 'Type' netgen.ibexa_import_export.preview.skipped_content: 'Skipped content' netgen.ibexa_import_export.preview.skipped_fields: 'Skipped fields' +netgen.ibexa_import_export.preview.global_errors: 'Global errors' netgen.ibexa_import_export.preview.remote_id: 'Remote id' netgen.ibexa_import_export.error.php: 'The PHP executable could not be found. It is needed for executing parallel subprocesses, so add it to your PATH environment variable and try again.' @@ -14,6 +17,7 @@ netgen.ibexa_import_export.error.export: 'Export failed. Please check your logs netgen.ibexa_import_export.error.export.content: 'You must enter the content you wish to export.' netgen.ibexa_import_export.error.import: 'Import failed. Please check your logs for details.' netgen.ibexa_import_export.error.import.parent_location: 'You must enter parent location where you want to import your content.' +netgen.ibexa_import_export.error.import.invalid_file: 'The uploaded file is not a valid migration YAML.' netgen.ibexa_import_export.success.export: 'Content successfully exported. Please download your export file.' netgen.ibexa_import_export.success.export.file_ready: 'Your export file is ready.' diff --git a/bundle/Resources/views/export.html.twig b/bundle/Resources/views/export.html.twig index eca670a..4a259c5 100644 --- a/bundle/Resources/views/export.html.twig +++ b/bundle/Resources/views/export.html.twig @@ -19,23 +19,27 @@ {% block content %}
- {% if file is not null %} - {% embed '@ibexadesign/ui/component/alert/alert.html.twig' with { + {% if files is defined and files|length > 0 %} + {% include '@ibexadesign/ui/component/alert/alert.html.twig' with { type: 'success', title: 'netgen.ibexa_import_export.success.export.file_ready'|trans(domain='import_export'), show_close_btn: true, } %} - {% block actions %} - + {% for file in files %} +
  • + - {{ 'netgen.ibexa_import_export.export.download_file'|trans(domain='import_export') }} - - {% endblock %} - {% endembed %} + > + {{ 'netgen.ibexa_import_export.export.download_file'|trans(domain='import_export') }} + + {{ file }} +
  • + {% endfor %} + {% endif %}
    diff --git a/bundle/Resources/views/import.html.twig b/bundle/Resources/views/import.html.twig index 559c499..f2bf228 100644 --- a/bundle/Resources/views/import.html.twig +++ b/bundle/Resources/views/import.html.twig @@ -23,7 +23,12 @@
    {{ form_start(form) }} {{ form_row(form.package) }} - {{ form_row(form.parent_location) }} +
    + {{ form_row(form.parent_location) }} +
    + {{ form_end(form) }}
    diff --git a/bundle/Resources/views/preview.html.twig b/bundle/Resources/views/preview.html.twig index d8f9960..91f5033 100644 --- a/bundle/Resources/views/preview.html.twig +++ b/bundle/Resources/views/preview.html.twig @@ -1,6 +1,9 @@ {% trans_default_domain 'import_export' %} -
    +
    {# 1. Import Details: Always displayed, acts as a general summary block #}
    diff --git a/lib/Core/Executor/ContentManagerWithTranslations.php b/lib/Core/Executor/ContentManagerWithTranslations.php new file mode 100644 index 0000000..56e5ef9 --- /dev/null +++ b/lib/Core/Executor/ContentManagerWithTranslations.php @@ -0,0 +1,177 @@ +authenticateUserByContext($context); + + try { + $contentCollection = $this->contentMatcher->match($matchConditions); + + /** @var Content $content */ + foreach ($contentCollection as $content) { + $language = $this->getLanguageCodeFromContext($context); + + if ($language === 'all') { + $content = $this->repository->getContentService()->loadContent( + $content->id, + $content->versionInfo->languageCodes, + ); + } + + $location = $this->repository->getLocationService()->loadLocation($content->contentInfo->mainLocationId); + $contentType = $this->repository->getContentTypeService()->loadContentType( + $content->contentInfo->contentTypeId, + ); + + $contentData = [ + 'type' => reset($this->supportedStepTypes), + 'mode' => $mode, + ]; + + switch ($mode) { + case 'create': + $contentData = array_merge( + $contentData, + [ + 'content_type' => $contentType->identifier, + 'parent_location' => $location->parentLocationId, + 'priority' => $location->priority, + 'is_hidden' => $location->invisible, + 'sort_field' => $this->sortConverter->sortField2Hash($location->sortField), + 'sort_order' => $this->sortConverter->sortOrder2Hash($location->sortOrder), + 'remote_id' => $content->contentInfo->remoteId, + 'location_remote_id' => $location->remoteId, + 'section' => $content->contentInfo->sectionId, + 'object_states' => $this->getObjectStates($content), + ], + ); + $locationService = $this->repository->getLocationService(); + $locations = $locationService->loadLocations($content->contentInfo); + if (count($locations) > 1) { + $otherParentLocations = []; + foreach ($locations as $otherLocation) { + if ($otherLocation->id !== $location->id) { + $otherParentLocations[] = $otherLocation->parentLocationId; + } + } + $contentData['other_parent_locations'] = $otherParentLocations; + } + + break; + + case 'update': + $contentData = array_merge( + $contentData, + [ + 'match' => [ + ContentMatcher::MATCH_CONTENT_REMOTE_ID => $content->contentInfo->remoteId, + ], + 'new_remote_id' => $content->contentInfo->remoteId, + 'section' => $content->contentInfo->sectionId, + 'object_states' => $this->getObjectStates($content), + ], + ); + + break; + + case 'delete': + $contentData = array_merge( + $contentData, + [ + 'match' => [ + ContentMatcher::MATCH_CONTENT_REMOTE_ID => $content->contentInfo->remoteId, + ], + ], + ); + + break; + + default: + throw new InvalidStepDefinitionException("Executor 'content' doesn't support mode '{$mode}'"); + } + + if ($mode !== 'delete') { + if ($language === 'all') { + $languages = $content->versionInfo->languageCodes; + } else { + $contentData = array_merge( + $contentData, + [ + 'lang' => $language, + ], + ); + $languages = [$language]; + } + + $attributes = []; + foreach ($languages as $lang) { + foreach ($content->getFieldsByLanguage($lang) as $fieldIdentifier => $field) { + $fieldDefinition = $contentType->getFieldDefinition($fieldIdentifier); + + if ($language === 'all' + && !$fieldDefinition->isTranslatable + && $lang !== $content->contentInfo->mainLanguageCode + ) { + continue; + } + + $fieldValue = $this->fieldHandlerManager->fieldValueToHash( + $fieldDefinition->fieldTypeIdentifier, + $contentType->identifier, + $field->value, + ); + if ($language === 'all') { + $attributes[$field->fieldDefIdentifier][$lang] = $fieldValue; + } else { + $attributes[$field->fieldDefIdentifier] = $fieldValue; + } + } + } + + $contentData = array_merge( + $contentData, + [ + 'section' => $content->contentInfo->sectionId, + 'owner' => $content->contentInfo->ownerId, + 'modification_date' => $content->contentInfo->modificationDate->getTimestamp(), + 'publication_date' => $content->contentInfo->publishedDate->getTimestamp(), + 'always_available' => (bool) $content->contentInfo->alwaysAvailable, + 'attributes' => $attributes, + ], + ); + } + + $data[] = $contentData; + } + } finally { + $this->authenticateUserByReference($currentUser); + } + + return $data; + } +} diff --git a/lib/Core/FieldHandler/EzRelationList.php b/lib/Core/FieldHandler/EzRelationList.php index 44a34b5..966bcb9 100644 --- a/lib/Core/FieldHandler/EzRelationList.php +++ b/lib/Core/FieldHandler/EzRelationList.php @@ -11,6 +11,7 @@ use Kaliop\eZMigrationBundle\Core\FieldHandler\AbstractFieldHandler; use Symfony\Contracts\Translation\TranslatorInterface; +use function array_values; use function count; final class EzRelationList extends AbstractFieldHandler implements FieldValueConverterInterface @@ -37,11 +38,13 @@ public function hashToFieldValue($fieldHash, array $context = []): RelationListV $relatedContent = $this->contentService->loadContentByRemoteId($remoteId); $ids[$key] = $relatedContent->id; } catch (NotFoundException) { - continue; + // Drop missing references entirely; otherwise the original remote-id string + // would leak into the value alongside resolved integer ids. + unset($ids[$key]); } } - return new RelationListValue($ids); + return new RelationListValue(array_values($ids)); } public function fieldValueToHash($fieldValue, array $context = []): array