diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..911a4a7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{yml,yaml,json}] +indent_size = 2 + +[*.md] +indent_size = 2 +trim_trailing_whitespace = false + +[*.js] +ij_html_space_inside_empty_tag = true +ij_javascript_spaces_within_imports = true +ij_typescript_use_double_quotes = false diff --git a/FUNDING.yml b/.github/FUNDING.yml similarity index 100% rename from FUNDING.yml rename to .github/FUNDING.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..102df6e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,65 @@ +name: Tests + +on: + push: + branches: [ main, '1.x' ] + pull_request: + branches: [ main, '1.x' ] + +jobs: + phpstan: + runs-on: ubuntu-latest + + strategy: + matrix: + phpVersion: + - '8.3' + neosVersion: + - '9.1' + + name: 'PHPStan - Neos ${{ matrix.neosVersion }}' + + steps: + - name: Checkout package + uses: actions/checkout@v3 + with: + path: package + + - name: Checkout Neos base distribution + uses: actions/checkout@v3 + with: + repository: neos/neos-base-distribution + ref: ${{ matrix.neosVersion }} + path: neos-base-distribution + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.phpVersion }} + tools: composer:v2, phpstan + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ matrix.neosVersion }}-${{ hashFiles('neos-base-distribution/composer.json') }} + restore-keys: ${{ runner.os }}-composer-${{ matrix.neosVersion }}- + + - name: Copy package into distribution + run: | + mkdir -p neos-base-distribution/Packages/Plugins + cp -R "$GITHUB_WORKSPACE/package" neos-base-distribution/Packages/Plugins/Shel.Neos.TransferContent + + - name: Install dependencies + working-directory: neos-base-distribution + run: | + composer config --no-plugins allow-plugins.neos/composer-plugin true + composer install --no-interaction --no-progress + + - name: PHPStan analysis + working-directory: neos-base-distribution/Packages/Plugins/Shel.Neos.TransferContent + run: phpstan analyse -c phpstan.neon --level=max --no-progress diff --git a/Classes/Controller/ContentTransferController.php b/Classes/Controller/ContentTransferController.php index 6143246..a20ebef 100644 --- a/Classes/Controller/ContentTransferController.php +++ b/Classes/Controller/ContentTransferController.php @@ -4,117 +4,203 @@ namespace Shel\Neos\TransferContent\Controller; -use Neos\ContentRepository\Domain\Model\Workspace; -use Neos\ContentRepository\Domain\Repository\WorkspaceRepository; -use Neos\ContentRepository\Domain\Service\ContextFactory; -use Neos\ContentRepository\Exception\NodeException; +use GuzzleHttp\Psr7\Response; +use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; +use Neos\ContentRepository\Core\Feature\Security\Exception\AccessDenied; +use Neos\ContentRepository\Core\NodeType\NodeTypeName; +use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; +use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; +use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\Error\Messages\Message; use Neos\Flow\Annotations as Flow; +use Neos\Flow\Http\Exception; use Neos\Flow\I18n\Translator; use Neos\Flow\Mvc\Exception\StopActionException; +use Neos\Flow\Mvc\Routing\Exception\MissingActionNameException; +use Neos\Fusion\View\FusionView; use Neos\Neos\Controller\Module\AbstractModuleController; -use Neos\Neos\Domain\Model\Site; -use Neos\Neos\Domain\Repository\SiteRepository; -use Neos\Neos\Domain\Service\ContentContext; -use Neos\Neos\Domain\Service\UserService as DomainUserService; -use Neos\Neos\Service\NodeOperations; - -/** - * Controller - * - * @Flow\Scope("singleton") - */ +use Psr\Http\Message\ResponseInterface; +use Shel\Neos\TransferContent\Service\ContentTransferService; + +#[Flow\Scope('singleton')] class ContentTransferController extends AbstractModuleController { + protected $defaultViewObjectName = FusionView::class; - /** - * @var NodeOperations - * @Flow\Inject - */ - protected $nodeOperations; + #[Flow\Inject] + protected Translator $translator; - /** - * @Flow\Inject - * @var SiteRepository - */ - protected $siteRepository; + #[Flow\Inject] + protected ContentTransferService $contentTransferService; /** - * @Flow\Inject - * @var WorkspaceRepository + * @throws \JsonException */ - protected $workspaceRepository; + public function indexAction( + ?ContentRepositoryId $sourceContentRepository = null, + ?ContentRepositoryId $targetContentRepository = null, + ?WorkspaceName $sourceWorkspace = null, + ?WorkspaceName $targetWorkspace = null, + ?string $sourceDimensionValues = '{}', + ?string $targetDimensionValues = '{}', + ): void { + $contentRepositoryIds = $this->contentTransferService->getContentRepositoryIds(); - /** - * @Flow\Inject - * @var ContextFactory - */ - protected $contextFactory; + $sourceWorkspace = $sourceWorkspace ?? WorkspaceName::forLive(); + $targetWorkspace = $targetWorkspace ?? WorkspaceName::forLive(); + $sourceContentRepository = $sourceContentRepository ?: ContentRepositoryId::fromString('default'); + $targetContentRepository = $targetContentRepository ?: ContentRepositoryId::fromString('default'); - /** - * @Flow\Inject - * @var Translator - */ - protected $translator; + $sourceWorkspaces = $this->contentTransferService->getWorkspacesForCr($sourceContentRepository); + $targetWorkspaces = $this->contentTransferService->getWorkspacesForCr($targetContentRepository); - /** - * @Flow\Inject - * @var DomainUserService - */ - protected $domainUserService; + $sourceDimensions = $this->contentTransferService->buildDimensionConfig($sourceContentRepository); + $targetDimensions = $this->contentTransferService->buildDimensionConfig($targetContentRepository); - /** - * Shows form to transfer content - */ - public function indexAction(?Site $sourceSite = null, ?Site $targetSite = null, string $targetParentNodePath = '', ?Workspace $targetWorkspace = null) - { - $sites = $this->siteRepository->findOnline(); - $workspaces = array_filter($this->workspaceRepository->findAll()->toArray(), function (Workspace $workspace) { - return $this->domainUserService->currentUserCanPublishToWorkspace($workspace); - }); + $decodedSource = json_decode($sourceDimensionValues ?? '{}', true, 512, JSON_THROW_ON_ERROR); + $parsedSourceDimValues = is_array($decodedSource) ? $decodedSource : []; + $decodedTarget = json_decode($targetDimensionValues ?? '{}', true, 512, JSON_THROW_ON_ERROR); + $parsedTargetDimValues = is_array($decodedTarget) ? $decodedTarget : []; + + if (!empty($sourceDimensions)) { + foreach ($sourceDimensions as $dim) { + if (!array_key_exists($dim->id, $parsedSourceDimValues)) { + $firstValue = $dim->values[0]->value ?? null; + if ($firstValue !== null) { + $parsedSourceDimValues[$dim->id] = $firstValue; + } + } + } + } + if (!empty($targetDimensions)) { + foreach ($targetDimensions as $dim) { + if (!array_key_exists($dim->id, $parsedTargetDimValues)) { + $firstValue = $dim->values[0]->value ?? null; + if ($firstValue !== null) { + $parsedTargetDimValues[$dim->id] = $firstValue; + } + } + } + } + + $flashMessages = $this->controllerContext->getFlashMessageContainer()->getMessagesAndFlush(); + $flashMessagesData = array_map(static function (Message $message): array { + return [ + 'title' => $message->getTitle(), + 'message' => $message->getMessage(), + 'severity' => strtolower($message->getSeverity()), + ]; + }, $flashMessages); $this->view->assignMultiple([ - 'sites' => $sites, - 'workspaces' => $workspaces, - 'sourceSite' => $sourceSite, - 'targetSite' => $targetSite, - 'targetParentNodePath' => $targetParentNodePath, + 'contentRepositoryIds' => $contentRepositoryIds, + 'sourceContentRepository' => $sourceContentRepository, + 'targetContentRepository' => $targetContentRepository, + 'sourceWorkspaces' => $sourceWorkspaces, + 'targetWorkspaces' => $targetWorkspaces, + 'sourceWorkspace' => $sourceWorkspace, 'targetWorkspace' => $targetWorkspace, + 'sourceDimensions' => $sourceDimensions, + 'targetDimensions' => $targetDimensions, + 'sourceDimensionValues' => $parsedSourceDimValues, + 'targetDimensionValues' => $parsedTargetDimValues, 'allowNodeMoving' => $this->settings['allowNodeMoving'], + 'flashMessagesData' => $flashMessagesData, ]); } /** + * @throws \JsonException + */ + public function treeChildrenAction( + ContentRepositoryId $contentRepositoryId, + ?WorkspaceName $workspaceName = null, + ?NodeAggregateId $parentNodeId = null, + string $dimensionValues = '{}', + ): ResponseInterface { + $workspaceName = $workspaceName ?? WorkspaceName::forLive(); + $children = $this->contentTransferService->getTreeChildrenData( + $contentRepositoryId, + $workspaceName, + $parentNodeId, + $dimensionValues, + ); + + return new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['children' => $children], JSON_THROW_ON_ERROR) + ); + } + + /** + * @throws MissingActionNameException * @throws StopActionException - * @Flow\Validate(argumentName="sourceNodePath", type="\Neos\Flow\Validation\Validator\NotEmptyValidator") - * @Flow\Validate(argumentName="targetParentNodePath", type="\Neos\Flow\Validation\Validator\NotEmptyValidator") + * @throws \JsonException + * @throws AccessDenied + * @throws Exception */ public function copyNodeAction( - Site $sourceSite, - Site $targetSite, - string $sourceNodePath, - string $targetParentNodePath, - ?Workspace $targetWorkspace = null, - bool $moveNodesInstead = false - ) - { - /** @var ContentContext $sourceContext */ - $sourceContext = $this->contextFactory->create([ - 'currentSite' => $sourceSite, - 'invisibleContentShown' => true, - 'inaccessibleContentShown' => true - ]); + string $sourceNodePath = '', + string $targetParentNodePath = '', + ?WorkspaceName $sourceWorkspace = null, + ?WorkspaceName $targetWorkspace = null, + bool $moveNodesInstead = false, + ?ContentRepositoryId $sourceContentRepository = null, + ?ContentRepositoryId $targetContentRepository = null, + string $sourceDimensionValues = '{}', + string $targetDimensionValues = '{}', + ): void { + $sourceContentRepository = $sourceContentRepository ?? ContentRepositoryId::fromString('default'); + $targetContentRepository = $targetContentRepository ?? ContentRepositoryId::fromString('default'); - /** @var ContentContext $targetContext */ - $targetContext = $this->contextFactory->create([ - 'currentSite' => $targetSite, - 'workspaceName' => $targetWorkspace ? $targetWorkspace->getName() : 'live', - 'invisibleContentShown' => true, - 'inaccessibleContentShown' => true - ]); + $sourceCr = $this->contentTransferService->getContentRepository($sourceContentRepository); + $targetCr = $this->contentTransferService->getContentRepository($targetContentRepository); + + $sourceWorkspace = $sourceWorkspace ?: WorkspaceName::forLive(); + $targetWorkspace = $targetWorkspace ?: WorkspaceName::forLive(); - $sourceNode = $sourceContext->getNodeByIdentifier($sourceNodePath); - $targetParentNode = $targetContext->getNodeByIdentifier($targetParentNodePath); + $decodedSourceDim = json_decode($sourceDimensionValues, true, 512, JSON_THROW_ON_ERROR); + $parsedSourceDim = []; + if (is_array($decodedSourceDim)) { + foreach ($decodedSourceDim as $key => $value) { + if (is_string($key) && is_string($value)) { + $parsedSourceDim[$key] = $value; + } + } + } + $decodedTargetDim = json_decode($targetDimensionValues, true, 512, JSON_THROW_ON_ERROR); + $parsedTargetDim = []; + if (is_array($decodedTargetDim)) { + foreach ($decodedTargetDim as $key => $value) { + if (is_string($key) && is_string($value)) { + $parsedTargetDim[$key] = $value; + } + } + } + + $sourceSubgraph = $sourceCr->getContentSubgraph( + $sourceWorkspace, + DimensionSpacePoint::fromArray($parsedSourceDim) + ); + $targetSubgraph = $targetCr->getContentSubgraph( + $targetWorkspace, + DimensionSpacePoint::fromArray($parsedTargetDim) + ); + + $sourceNode = $sourceSubgraph->findNodeById( + NodeAggregateId::fromString($sourceNodePath) + ); + $targetParentNode = $targetSubgraph->findNodeById( + NodeAggregateId::fromString($targetParentNodePath) + ); + + $sourceNodeType = $sourceNode?->nodeTypeName + ? $sourceCr->getNodeTypeManager()->getNodeType($sourceNode->nodeTypeName) + : null; + $targetNodeType = $targetParentNode?->nodeTypeName + ? $targetCr->getNodeTypeManager()->getNodeType($targetParentNode->nodeTypeName) + : null; if ($sourceNode === null) { $this->addFlashMessage( @@ -122,48 +208,91 @@ public function copyNodeAction( 'Error', Message::SEVERITY_ERROR ); - } else if ($targetParentNode === null) { + } elseif ($targetParentNode === null) { $this->addFlashMessage( $this->translate('error.targetParentNodeNotFound'), 'Error', Message::SEVERITY_ERROR ); - } else if (!$sourceNode->getNodeType()->isOfType('Neos.Neos:Document')) { + } elseif ($sourceNodeType === null || !$sourceNodeType->isOfType( + NodeTypeName::fromString('Neos.Neos:Document') + )) { $this->addFlashMessage( - $this->translate('error.invalidSourceNode', [$sourceNode->getNodeType()]), + $this->translate('error.invalidSourceNode', [ + $sourceNode->nodeTypeName->value + ]), 'Error', Message::SEVERITY_ERROR ); - } else if (!$targetParentNode->getNodeType()->isOfType('Neos.Neos:Document')) { + } elseif ($targetNodeType === null || !$targetNodeType->isOfType( + NodeTypeName::fromString('Neos.Neos:Document') + )) { $this->addFlashMessage( - $this->translate('error.invalidTargetParentNode', [$targetParentNode->getNodeType()]), + $this->translate('error.invalidTargetParentNode', [ + $targetParentNode->nodeTypeName->value + ]), 'Error', Message::SEVERITY_ERROR ); - } else if (!$targetParentNode->isNodeTypeAllowedAsChildNode($sourceNode->getNodeType())) { + } elseif (!$targetNodeType->allowsChildNodeType($sourceNodeType)) { $this->addFlashMessage( $this->translate('error.sourceNodeNotAllowedAsChildNode'), 'Error', Message::SEVERITY_ERROR ); - } else { - try { - if ($moveNodesInstead) { - $this->nodeOperations->move($sourceNode, $targetParentNode, 'into'); + } elseif ($moveNodesInstead) { + if (!$sourceContentRepository->equals($targetContentRepository)) { + $this->addFlashMessage( + $this->translate('error.cannotMoveAcrossCr'), + 'Error', + Message::SEVERITY_ERROR + ); + } else { + try { + $this->contentTransferService->moveNode( + $sourceCr, + $sourceNode, + $targetParentNode, + $targetWorkspace, + ); $this->addFlashMessage( $this->translate('message.moved'), 'Success' ); + } catch (\Exception $e) { + $this->addFlashMessage( + $this->translate('error.copyFailed', [$e->getMessage()]), + 'Error', + Message::SEVERITY_ERROR + ); + } + } + } else { + try { + $result = $this->contentTransferService->copyNode( + $sourceCr, + $targetCr, + $sourceNode, + $targetParentNode, + ); + + if ($result->nodeCount === 0) { + $this->addFlashMessage( + $this->translate('message.copiedNone'), + $this->translate('warning'), + Message::SEVERITY_WARNING + ); } else { - $this->nodeOperations->copy($sourceNode, $targetParentNode, 'into'); $this->addFlashMessage( - $this->translate('message.copied'), - 'Success' + $this->translate('message.copied', [ + (string)$result->nodeCount, + (string)$result->variantCount, + ]) ); } - } catch (NodeException $e) { + } catch (\Exception $e) { $this->addFlashMessage( - $this->translate('error.copyFailed', [$e->getReferenceCode()]), + $this->translate('error.copyFailed', [$e->getMessage()]), 'Error', Message::SEVERITY_ERROR ); @@ -171,19 +300,30 @@ public function copyNodeAction( } $this->redirect('index', null, null, [ - 'sourceSite' => $sourceSite, - 'targetSite' => $targetSite, - 'targetParentNodePath' => $targetParentNodePath, - 'targetWorkspace' => $targetWorkspace, + 'sourceContentRepository' => $sourceContentRepository->value, + 'targetContentRepository' => $targetContentRepository->value, + 'sourceWorkspace' => $sourceWorkspace->value, + 'targetWorkspace' => $targetWorkspace->value, + 'sourceDimensionValues' => $sourceDimensionValues, + 'targetDimensionValues' => $targetDimensionValues, ]); } + /** + * @param array $arguments + */ protected function translate(string $id, array $arguments = []): string { try { - $translation = $this->translator->translateById($id, $arguments, null, null, 'ContentTransfer', 'Shel.Neos.TransferContent'); - } catch (\Exception $e) { - // Ignore exception + $translation = $this->translator->translateById( + $id, + $arguments, + null, + null, + 'ContentTransfer', + 'Shel.Neos.TransferContent' + ); + } catch (\Exception) { } return $translation ?? $id; } diff --git a/Classes/Dto/CopyResult.php b/Classes/Dto/CopyResult.php new file mode 100644 index 0000000..97e29c9 --- /dev/null +++ b/Classes/Dto/CopyResult.php @@ -0,0 +1,14 @@ + $values + */ + public function __construct( + public string $id, + public string $label, + public array $values, + ) { + } +} diff --git a/Classes/Dto/DimensionValueDto.php b/Classes/Dto/DimensionValueDto.php new file mode 100644 index 0000000..4864279 --- /dev/null +++ b/Classes/Dto/DimensionValueDto.php @@ -0,0 +1,14 @@ + + */ + #[Flow\InjectConfiguration(path: 'contentRepositories', package: 'Neos.ContentRepositoryRegistry')] + protected array $crSettings; + + public function __construct( + protected ContentRepositoryRegistry $contentRepositoryRegistry, + protected SecurityContext $securityContext, + protected DomainUserService $domainUserService, + protected ContentRepositoryAuthorizationService $authorizationService, + protected WorkspaceService $workspaceService, + protected NodeLabelGeneratorInterface $nodeLabelGenerator, + ) { + } + + public function getContentRepository(ContentRepositoryId $crId): ContentRepository + { + return $this->contentRepositoryRegistry->get($crId); + } + + /** + * @return list + */ + public function getContentRepositoryIds(): array + { + $ids = []; + foreach ($this->contentRepositoryRegistry->getContentRepositoryIds() as $crId) { + $ids[] = $crId->value; + } + return $ids; + } + + /** + * @return list + */ + public function getWorkspacesForCr(ContentRepositoryId $crId): array + { + $contentRepository = $this->getContentRepository($crId); + $roles = $this->securityContext->getRoles(); + $userId = $this->domainUserService->getCurrentUser()?->getId(); + + $workspaces = $contentRepository->findWorkspaces() + ->filter(function (Workspace $workspace) use ($crId, $roles, $userId): bool { + $permissions = $this->authorizationService->getWorkspacePermissions( + $crId, + $workspace->workspaceName, + $roles, + $userId + ); + return $permissions->write; + }) + ->getIterator(); + + $result = []; + foreach ($workspaces as $workspace) { + $metadata = $this->workspaceService->getWorkspaceMetadata( + $crId, + $workspace->workspaceName + ); + $result[] = new WorkspaceDto( + workspaceName: $workspace->workspaceName, + title: $metadata->title->value, + ); + } + return $result; + } + + /** + * @return list + */ + public function buildDimensionConfig(ContentRepositoryId $crId): array + { + $crConfig = $this->crSettings[$crId->value] ?? []; + if (!is_array($crConfig)) { + $crConfig = []; + } + $contentDimensions = $crConfig['contentDimensions'] ?? []; + if (!is_array($contentDimensions)) { + $contentDimensions = []; + } + + $result = []; + foreach ($contentDimensions as $dimId => $dimConfig) { + if (!is_array($dimConfig)) { + continue; + } + if (!is_string($dimId)) { + continue; + } + + $values = []; + $dimValues = $dimConfig['values'] ?? []; + if (is_array($dimValues)) { + foreach ($dimValues as $valId => $valConfig) { + if (!is_array($valConfig)) { + continue; + } + if (!is_string($valId)) { + continue; + } + $typedConfig = []; + foreach ($valConfig as $k => $v) { + if (is_string($k)) { + $typedConfig[$k] = $v; + } + } + array_push($values, ...$this->flattenDimensionValues($valId, $typedConfig)); + } + } + + $dimLabel = $dimConfig['label'] ?? $dimId; + if (!is_string($dimLabel)) { + $dimLabel = $dimId; + } + + $result[] = new DimensionConfigDto( + id: $dimId, + label: $dimLabel, + values: $values, + ); + } + return $result; + } + + /** + * @param array $config + * @return list + */ + private function flattenDimensionValues(string $valueId, array $config, string $breadcrumb = ''): array + { + $label = $config['label'] ?? $valueId; + if (!is_string($label)) { + $label = $valueId; + } + $fullLabel = $breadcrumb !== '' ? $breadcrumb . ' → ' . $label : $label; + + $result = [ + new DimensionValueDto( + value: $valueId, + label: $fullLabel, + ), + ]; + + $specializations = $config['specializations'] ?? []; + if (is_array($specializations)) { + foreach ($specializations as $specId => $specConfig) { + if (!is_array($specConfig)) { + continue; + } + if (!is_string($specId)) { + continue; + } + $typedSpecConfig = []; + foreach ($specConfig as $k => $v) { + if (is_string($k)) { + $typedSpecConfig[$k] = $v; + } + } + array_push($result, ...$this->flattenDimensionValues($specId, $typedSpecConfig, $fullLabel)); + } + } + + return $result; + } + + /** + * @return array> + */ + private function getDimensionValuesMap(ContentRepositoryId $crId): array + { + $crConfig = $this->crSettings[$crId->value] ?? []; + if (!is_array($crConfig)) { + $crConfig = []; + } + $contentDimensions = $crConfig['contentDimensions'] ?? []; + if (!is_array($contentDimensions)) { + $contentDimensions = []; + } + + $map = []; + foreach ($contentDimensions as $dimId => $dimConfig) { + if (!is_array($dimConfig)) { + continue; + } + if (!is_string($dimId)) { + continue; + } + $values = []; + $dimValues = $dimConfig['values'] ?? []; + if (is_array($dimValues)) { + $typedDimValues = []; + foreach ($dimValues as $k => $v) { + if (is_string($k)) { + $typedDimValues[$k] = $v; + } + } + $this->collectDimensionValues($typedDimValues, $values); + } + $map[$dimId] = $values; + } + return $map; + } + + /** + * @param array $valuesConfig + * @param list &$values + */ + private function collectDimensionValues(array $valuesConfig, array &$values): void + { + foreach ($valuesConfig as $valId => $valConfig) { + if (!is_string($valId)) { + continue; + } + if (!is_array($valConfig)) { + continue; + } + $values[] = $valId; + $specializations = $valConfig['specializations'] ?? []; + if (is_array($specializations)) { + $typedSpecs = []; + foreach ($specializations as $k => $v) { + if (is_string($k)) { + $typedSpecs[$k] = $v; + } + } + $this->collectDimensionValues($typedSpecs, $values); + } + } + } + + /** + * @return list + */ + private function filterCompatibleOriginDimensionSpacePoints( + OriginDimensionSpacePointSet $odspSet, + ContentRepository $targetCr, + ): array { + $targetDimValues = $this->getDimensionValuesMap($targetCr->id); + + $compatible = []; + foreach ($odspSet as $odsp) { + $allMatch = true; + foreach ($odsp->coordinates as $dimName => $dimValue) { + $targetValues = $targetDimValues[$dimName] ?? []; + if (!in_array($dimValue, $targetValues, true)) { + $allMatch = false; + break; + } + } + if ($allMatch) { + $compatible[] = $odsp; + } + } + return $compatible; + } + + public function getNodeTypeFilterForCr(ContentRepositoryId $crId): string + { + $filters = $this->crSettings['nodeTypeFilters'] ?? ['default' => 'Neos.Neos:Document']; + if (!is_array($filters)) { + return 'Neos.Neos:Document'; + } + $filter = $filters[$crId->value] ?? $filters['default'] ?? 'Neos.Neos:Document'; + if (!is_string($filter)) { + return 'Neos.Neos:Document'; + } + return $filter; + } + + /** + * @return list + */ + public function getTreeChildrenData( + ContentRepositoryId $contentRepositoryId, + WorkspaceName $workspaceName, + ?NodeAggregateId $parentNodeId, + string $dimensionValues, + ): array { + $cr = $this->getContentRepository($contentRepositoryId); + $decodedDimValues = json_decode($dimensionValues, true, 512, JSON_THROW_ON_ERROR); + $parsedDimValues = []; + if (is_array($decodedDimValues)) { + foreach ($decodedDimValues as $key => $value) { + if (is_string($key) && is_string($value)) { + $parsedDimValues[$key] = $value; + } + } + } + $dsp = DimensionSpacePoint::fromArray($parsedDimValues); + $subgraph = $cr->getContentSubgraph($workspaceName, $dsp); + $nodeTypeFilter = $this->getNodeTypeFilterForCr($contentRepositoryId); + + if ($parentNodeId === null) { + $rootNodeAggregate = $cr->getContentGraph($workspaceName)->findRootNodeAggregates( + FindRootNodeAggregatesFilter::create() + )->first(); + if ($rootNodeAggregate === null) { + return []; + } + $children = $subgraph->findChildNodes( + $rootNodeAggregate->nodeAggregateId, + FindChildNodesFilter::create(nodeTypes: 'Neos.Neos:Node') + ); + } else { + $children = $subgraph->findChildNodes( + $parentNodeId, + FindChildNodesFilter::create(nodeTypes: $nodeTypeFilter) + ); + } + + $result = []; + foreach ($children as $child) { + $label = $this->nodeLabelGenerator->getLabel($child); + $hasChildren = $subgraph->countChildNodes( + $child->aggregateId, + CountChildNodesFilter::create(nodeTypes: $nodeTypeFilter) + ) > 0; + + $result[] = new TreeNodeDto( + nodeAggregateId: $child->aggregateId, + label: $label, + nodeTypeName: $child->nodeTypeName, + hasChildren: $hasChildren, + ); + } + + return $result; + } + + public function moveNode( + ContentRepository $contentRepository, + Node $sourceNode, + Node $targetParentNode, + WorkspaceName $targetWorkspaceName, + ): void { + $contentRepository->handle( + MoveNodeAggregate::create( + workspaceName: $targetWorkspaceName, + dimensionSpacePoint: $sourceNode->originDimensionSpacePoint->toDimensionSpacePoint(), + nodeAggregateId: $sourceNode->aggregateId, + relationDistributionStrategy: RelationDistributionStrategy::STRATEGY_GATHER_ALL, + newParentNodeAggregateId: $targetParentNode->aggregateId, + ) + ); + } + + public function copyNode( + ContentRepository $sourceCr, + ContentRepository $targetCr, + Node $sourceNode, + Node $targetParentNode, + ): CopyResult { + $sourceContentGraph = $sourceCr->getContentGraph($sourceNode->workspaceName); + $sourceAggregate = $sourceContentGraph->findNodeAggregateById($sourceNode->aggregateId); + + if ($sourceAggregate === null) { + return new CopyResult(nodeCount: 0, variantCount: 0); + } + + $compatibleSourceODSPs = $this->filterCompatibleOriginDimensionSpacePoints( + $sourceAggregate->occupiedDimensionSpacePoints, + $targetCr, + ); + + $targetDimensionNames = array_keys($this->getDimensionValuesMap($targetCr->id)); + $targetParentCoordinates = $targetParentNode->originDimensionSpacePoint->coordinates; + + $nodeCount = 0; + $variantCount = 0; + $first = true; + $aggregateIdMapping = []; + $firstOriginForVariant = null; + + foreach ($compatibleSourceODSPs as $sourceODSP) { + $mergedCoordinates = []; + foreach ($targetDimensionNames as $dimName) { + if (isset($sourceODSP->coordinates[$dimName])) { + $mergedCoordinates[$dimName] = $sourceODSP->coordinates[$dimName]; + } elseif (isset($targetParentCoordinates[$dimName])) { + $mergedCoordinates[$dimName] = $targetParentCoordinates[$dimName]; + } + } + + if ($mergedCoordinates === []) { + continue; + } + + $mergedDSP = DimensionSpacePoint::fromArray($mergedCoordinates); + + $sourceVariantSubgraph = $sourceCr->getContentSubgraph( + $sourceNode->workspaceName, + $sourceODSP->toDimensionSpacePoint() + ); + $sourceVariant = $sourceVariantSubgraph->findNodeById($sourceNode->aggregateId); + + if ($sourceVariant === null) { + continue; + } + + $targetVariantSubgraph = $targetCr->getContentSubgraph( + $targetParentNode->workspaceName, + $mergedDSP + ); + $targetParentVariant = $targetVariantSubgraph->findNodeById($targetParentNode->aggregateId); + + if ($targetParentVariant === null || !$targetParentVariant->dimensionSpacePoint->equals($mergedDSP)) { + $targetCr->handle( + CreateNodeVariant::create( + workspaceName: $targetParentNode->workspaceName, + nodeAggregateId: $targetParentNode->aggregateId, + sourceOrigin: $targetParentNode->originDimensionSpacePoint, + targetOrigin: OriginDimensionSpacePoint::fromDimensionSpacePoint($mergedDSP), + ) + ); + + $targetVariantSubgraph = $targetCr->getContentSubgraph( + $targetParentNode->workspaceName, + $mergedDSP + ); + $targetParentVariant = $targetVariantSubgraph->findNodeById($targetParentNode->aggregateId); + + if ($targetParentVariant === null) { + continue; + } + } + + $variantCount++; + + if ($first) { + $first = false; + $firstOriginForVariant = OriginDimensionSpacePoint::fromDimensionSpacePoint( + $targetParentVariant->dimensionSpacePoint + ); + + $nodeCount += $this->copyFirstVariantRecursive( + contentRepository: $targetCr, + sourceSubgraph: $sourceVariantSubgraph, + sourceNode: $sourceVariant, + targetParentNodeAggregateId: $targetParentVariant->aggregateId, + targetWorkspaceName: $targetParentNode->workspaceName, + targetDimensionSpacePoint: $targetParentVariant->dimensionSpacePoint, + aggregateIdMapping: $aggregateIdMapping, + ); + } else { + if ($firstOriginForVariant === null) { + continue; + } + $nodeCount += $this->createVariantsForTree( + contentRepository: $targetCr, + sourceSubgraph: $sourceVariantSubgraph, + sourceNode: $sourceVariant, + aggregateIdMapping: $aggregateIdMapping, + targetWorkspaceName: $targetParentNode->workspaceName, + targetDimensionSpacePoint: $mergedDSP, + sourceOriginForVariant: $firstOriginForVariant, + ); + } + } + + return new CopyResult( + nodeCount: $nodeCount, + variantCount: $variantCount, + ); + } + + /** + * @param array $aggregateIdMapping + */ + private function copyFirstVariantRecursive( + ContentRepository $contentRepository, + ContentSubgraphInterface $sourceSubgraph, + Node $sourceNode, + NodeAggregateId $targetParentNodeAggregateId, + WorkspaceName $targetWorkspaceName, + DimensionSpacePoint $targetDimensionSpacePoint, + array &$aggregateIdMapping, + ): int { + $count = 0; + + if ($sourceNode->classification->isTethered()) { + if ($sourceNode->name === null) { + return 0; + } + $targetSubgraph = $contentRepository->getContentSubgraph( + $targetWorkspaceName, + $targetDimensionSpacePoint + ); + $existingNode = $targetSubgraph->findNodeByPath( + $sourceNode->name, + $targetParentNodeAggregateId + ); + + if ($existingNode === null) { + return 0; + } + + $propertyValues = []; + foreach ($sourceNode->properties as $propertyName => $propertyValue) { + $propertyValues[$propertyName] = $propertyValue; + } + + if ($propertyValues !== []) { + $contentRepository->handle( + SetNodeProperties::create( + workspaceName: $targetWorkspaceName, + nodeAggregateId: $existingNode->aggregateId, + originDimensionSpacePoint: OriginDimensionSpacePoint::fromDimensionSpacePoint( + $targetDimensionSpacePoint + ), + propertyValues: PropertyValuesToWrite::fromArray($propertyValues), + ) + ); + } + + $aggregateIdMapping[$sourceNode->aggregateId->value] = $existingNode->aggregateId; + $parentNodeAggregateId = $existingNode->aggregateId; + } else { + $newNodeAggregateId = NodeAggregateId::create(); + + $propertyValues = []; + foreach ($sourceNode->properties as $propertyName => $propertyValue) { + $propertyValues[$propertyName] = $propertyValue; + } + + $contentRepository->handle( + CreateNodeAggregateWithNode::create( + workspaceName: $targetWorkspaceName, + nodeAggregateId: $newNodeAggregateId, + nodeTypeName: $sourceNode->nodeTypeName, + originDimensionSpacePoint: OriginDimensionSpacePoint::fromDimensionSpacePoint( + $targetDimensionSpacePoint + ), + parentNodeAggregateId: $targetParentNodeAggregateId, + initialPropertyValues: PropertyValuesToWrite::fromArray($propertyValues), + ) + ); + + $aggregateIdMapping[$sourceNode->aggregateId->value] = $newNodeAggregateId; + $parentNodeAggregateId = $newNodeAggregateId; + } + + $count++; + + $childNodes = $sourceSubgraph->findChildNodes( + $sourceNode->aggregateId, + FindChildNodesFilter::create() + ); + + foreach ($childNodes as $childNode) { + $count += $this->copyFirstVariantRecursive( + contentRepository: $contentRepository, + sourceSubgraph: $sourceSubgraph, + sourceNode: $childNode, + targetParentNodeAggregateId: $parentNodeAggregateId, + targetWorkspaceName: $targetWorkspaceName, + targetDimensionSpacePoint: $targetDimensionSpacePoint, + aggregateIdMapping: $aggregateIdMapping, + ); + } + + return $count; + } + + /** + * @param array $aggregateIdMapping + * @throws AccessDenied + */ + private function createVariantsForTree( + ContentRepository $contentRepository, + ContentSubgraphInterface $sourceSubgraph, + Node $sourceNode, + array $aggregateIdMapping, + WorkspaceName $targetWorkspaceName, + DimensionSpacePoint $targetDimensionSpacePoint, + OriginDimensionSpacePoint $sourceOriginForVariant, + ): int { + $count = 0; + + $targetAggregateId = $aggregateIdMapping[$sourceNode->aggregateId->value] ?? null; + if ($targetAggregateId === null) { + return 0; + } + + $targetVariantSubgraph = $contentRepository->getContentSubgraph( + $targetWorkspaceName, + $targetDimensionSpacePoint + ); + $existingVariant = $targetVariantSubgraph->findNodeById($targetAggregateId); + + if ($existingVariant === null) { + $contentRepository->handle( + CreateNodeVariant::create( + workspaceName: $targetWorkspaceName, + nodeAggregateId: $targetAggregateId, + sourceOrigin: $sourceOriginForVariant, + targetOrigin: OriginDimensionSpacePoint::fromDimensionSpacePoint($targetDimensionSpacePoint), + ) + ); + } + + $propertyValues = []; + foreach ($sourceNode->properties as $propertyName => $propertyValue) { + $propertyValues[$propertyName] = $propertyValue; + } + + if ($propertyValues !== []) { + $contentRepository->handle( + SetNodeProperties::create( + workspaceName: $targetWorkspaceName, + nodeAggregateId: $targetAggregateId, + originDimensionSpacePoint: OriginDimensionSpacePoint::fromDimensionSpacePoint( + $targetDimensionSpacePoint + ), + propertyValues: PropertyValuesToWrite::fromArray($propertyValues), + ) + ); + } + + $count++; + + $childNodes = $sourceSubgraph->findChildNodes( + $sourceNode->aggregateId, + FindChildNodesFilter::create() + ); + + foreach ($childNodes as $childNode) { + $count += $this->createVariantsForTree( + contentRepository: $contentRepository, + sourceSubgraph: $sourceSubgraph, + sourceNode: $childNode, + aggregateIdMapping: $aggregateIdMapping, + targetWorkspaceName: $targetWorkspaceName, + targetDimensionSpacePoint: $targetDimensionSpacePoint, + sourceOriginForVariant: $sourceOriginForVariant, + ); + } + + return $count; + } +} diff --git a/Configuration/Policy.yaml b/Configuration/Policy.yaml index 86a135f..0ea749a 100644 --- a/Configuration/Policy.yaml +++ b/Configuration/Policy.yaml @@ -1,11 +1,12 @@ privilegeTargets: Neos\Flow\Security\Authorization\Privilege\Method\MethodPrivilege: Shel.Neos.TransferContent:Backend.Module.Management.ContentTransfer: + label: 'Content transfer' + description: 'Allows to copy or move content' matcher: 'method(Shel\Neos\TransferContent\Controller\ContentTransferController->.*Action())' roles: 'Neos.Neos:Administrator': privileges: - - - privilegeTarget: 'Shel.Neos.TransferContent:Backend.Module.Management.ContentTransfer' + - privilegeTarget: 'Shel.Neos.TransferContent:Backend.Module.Management.ContentTransfer' permission: GRANT diff --git a/Configuration/Routes.yaml b/Configuration/Routes.yaml new file mode 100644 index 0000000..ff8a48b --- /dev/null +++ b/Configuration/Routes.yaml @@ -0,0 +1,9 @@ +- + name: 'ContentTransfer - Tree children' + uriPattern: 'neos/contenttransfer/tree-children' + defaults: + '@package': 'Shel.Neos.TransferContent' + '@controller': 'ContentTransfer' + '@action': 'treeChildren' + '@format': 'json' + httpMethods: ['GET'] diff --git a/Configuration/Settings.Flow.yaml b/Configuration/Settings.Flow.yaml new file mode 100644 index 0000000..da08073 --- /dev/null +++ b/Configuration/Settings.Flow.yaml @@ -0,0 +1,16 @@ +Neos: + Flow: + mvc: + routes: + 'Shel.Neos.TransferContent': + position: 'before Neos.Neos' + + security: + authentication: + providers: + 'Neos.Neos:Backend': + requestPatterns: + 'Shel.Neos.TransferContent:API': + pattern: ControllerObjectName + patternOptions: + controllerObjectNamePattern: 'Shel\Neos\TransferContent\Controller\.*' diff --git a/Configuration/Settings.yaml b/Configuration/Settings.Neos.yaml similarity index 54% rename from Configuration/Settings.yaml rename to Configuration/Settings.Neos.yaml index 18c3535..9298042 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.Neos.yaml @@ -1,9 +1,5 @@ Neos: Neos: - fusion: - autoInclude: - Shel.Neos.TransferContent: true - userInterface: translation: autoInclude: @@ -19,8 +15,10 @@ Neos: description: 'Shel.Neos.TransferContent:ContentTransfer:description' icon: 'fas fa-exchange-alt' privilegeTarget: 'Shel.Neos.TransferContent:Backend.Module.Management.ContentTransfer' - -Shel: - Neos: - TransferContent: - allowNodeMoving: false + additionalResources: + javaScripts: + 'ContentTransfer': 'resource://Shel.Neos.TransferContent/Public/ContentTransfer.js' + 'ContentTransferTree': 'resource://Shel.Neos.TransferContent/Public/ContentTransferTree.js' + styleSheets: + 'ContentTransfer': 'resource://Shel.Neos.TransferContent/Public/ContentTransfer.css' + 'ContentTransferTree': 'resource://Shel.Neos.TransferContent/Public/ContentTransferTree.css' diff --git a/Configuration/Settings.TransferContent.yaml b/Configuration/Settings.TransferContent.yaml new file mode 100644 index 0000000..8fbe56b --- /dev/null +++ b/Configuration/Settings.TransferContent.yaml @@ -0,0 +1,6 @@ +Shel: + Neos: + TransferContent: + allowNodeMoving: false + nodeTypeFilters: + default: 'Neos.Neos:Document' diff --git a/Configuration/Views.yaml b/Configuration/Views.yaml index bec1e83..a5669f1 100644 --- a/Configuration/Views.yaml +++ b/Configuration/Views.yaml @@ -1,5 +1,4 @@ -- - requestFilter: 'mainRequest.isPackage("Neos.Neos") && isPackage("Shel.Neos.TransferContent") && isController("ContentTransfer")' +- requestFilter: 'isPackage("Shel.Neos.TransferContent") && isController("ContentTransfer")' options: - 'layoutRootPaths': ['resource://Neos.Neos/Private/Layouts'] - 'partialRootPaths': ['resource://Shel.Neos.TransferContent/Private/Partials', 'resource://Neos.Neos/Private/Partials'] + fusionPathPatterns: + - 'resource://Shel.Neos.TransferContent/Private/Fusion' diff --git a/README.md b/README.md index ecf0287..5fd90fe 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,23 @@ ## Introduction -This plugin will add a new backend module for copying and moving nodes between sites in a +This plugin will add a new backend module for copying and moving nodes between sites in a Neos multi site installation. -It's currently compatible with Neos 4.3 and the 5.* branch +Compatible with Neos 9.x and PHP 8.3+ ## Example ![Preview](Documentation/example.png) -### Warning +### Notes and limitations -This package was built to solve a very specific issue and should only be used by -website administrators who know what their doing. +* This package was built to solve a very specific issue and should only be used by +website administrators who know what they're doing. +* Also note that references and links inside the copied nodes are not updated to link to their copied target and therefore still link to the site where they were copied from, or are missing. +* The copied content includes variants of the copied nodes if they match the variants of the selected node. This means that additional nodes in f.e. in a translation might not be copied. -Future versions of this package might improve the usability and PRs to do this are very welcome. - -Also note that references and links inside the copied nodes are not updated to link to their copied target -and therefore still link to the site where they were copied from. +Future versions of this package might solve these limitations. PRs and feature sponsorships are very welcome. ## Installation @@ -41,6 +40,7 @@ Then run `composer update` in your project directory. ### Settings You can also optionally allow to move nodes instead of copying them by setting the following setting in your `Settings.yaml`: + ```yaml Shel: Neos: @@ -62,6 +62,11 @@ Please create detailed issues and PRs. Or you can also support me directly via [patreon](https://www.patreon.com/shelzle). +## Thanks + +* The implementation of the initial release was supported by [Allcompare](https://www.allcompare.com). +* The Neos 9 compatible release was supported by [Ahorn](https://ahorn.io). + ## License THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR diff --git a/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion b/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion new file mode 100644 index 0000000..6d28691 --- /dev/null +++ b/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion @@ -0,0 +1,270 @@ +prototype(Shel.Neos.TransferContent:View.ContentTransfer.index) < prototype(Neos.Fusion:Component) { + contentRepositoryIds = ${contentRepositoryIds} + sourceContentRepository = ${sourceContentRepository} + targetContentRepository = ${targetContentRepository} + sourceWorkspaces = ${sourceWorkspaces} + targetWorkspaces = ${targetWorkspaces} + sourceWorkspace = ${sourceWorkspace} + targetWorkspace = ${targetWorkspace} + sourceDimensions = ${sourceDimensions} + targetDimensions = ${targetDimensions} + sourceDimensionValues = ${sourceDimensionValues} + targetDimensionValues = ${targetDimensionValues} + allowNodeMoving = ${allowNodeMoving} + flashMessagesData = ${flashMessagesData} + + prototype(Neos.Fusion.Form:LabelRenderer) { + translationPackage = 'Shel.Neos.TransferContent' + translationSource = 'ContentTransfer' + } + + prototype(Neos.Fusion.Form:Neos.BackendModule.FieldContainer) { + translation.label { + package = 'Shel.Neos.TransferContent' + source = 'ContentTransfer' + } + } + + @private { + i18n = ${I18n.id('').source('ContentTransfer').package('Shel.Neos.TransferContent')} + helpText = ${private.i18n.id('help.text').translate()} + hasMultipleCr = ${props.contentRepositoryIds != null && Array.length(props.contentRepositoryIds) > 1} + hasSourceDimensions = ${props.sourceDimensions != null && Array.length(props.sourceDimensions) > 0} + hasTargetDimensions = ${props.targetDimensions != null && Array.length(props.targetDimensions) > 0} + } + + renderer = afx` + + + +
+
+
+
+ + + + + {crId} + + + + +
+ +
+ +
{private.i18n.id('sourceDimension').translate()}
+ +
+ +
+ +
+
+
+
+
+
+
+ + + + + {crId} + + + + +
+
+ +
{private.i18n.id('targetDimension').translate()}
+ +
+ +
+ +
+
+
+
+
+
+
+
+ + +
+
+
+

{private.i18n.id('sourceDocument').translate()}

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

{private.i18n.id('targetDocument').translate()}

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

{private.i18n.id('help.headline')}

+
+

{private.helpText}

+
+
+
+
+
+ + {private.i18n.id('submit').translate()} + +
+ ` +} + +Shel.Neos.TransferContent.ContentTransferController.index = Shel.Neos.TransferContent:View.ContentTransfer.index diff --git a/Resources/Private/Fusion/Root.fusion b/Resources/Private/Fusion/Root.fusion new file mode 100644 index 0000000..6d45519 --- /dev/null +++ b/Resources/Private/Fusion/Root.fusion @@ -0,0 +1,5 @@ +include: 'resource://Neos.Neos/Private/Fusion/Root.fusion' +include: 'resource://Neos.Fusion/Private/Fusion/Root.fusion' +include: 'resource://Neos.Fusion.Form/Private/Fusion/Root.fusion' + +include: **/* diff --git a/Resources/Private/Templates/ContentTransfer/Index.html b/Resources/Private/Templates/ContentTransfer/Index.html deleted file mode 100644 index 0ccb66a..0000000 --- a/Resources/Private/Templates/ContentTransfer/Index.html +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - -
-
-
- {neos:backend.translate(id: 'Shel.Neos.TransferContent:ContentTransfer:copyNode')} - -
- -
- - -
-
- -
- -
- - -
-
- -
- -
- - -
-
- -
- -
- - -
-
- -
- - -
- - -
-
- - - -
- -
-
-

{neos:backend.translate(id: 'Shel.Neos.TransferContent:ContentTransfer:help.headline')}

-
-

{neos:backend.translate(id: 'Shel.Neos.TransferContent:ContentTransfer:help.text') -> f:format.raw()}

-
-
-
-
-
- -
-
-
- diff --git a/Resources/Private/Translations/de/ContentTransfer.xlf b/Resources/Private/Translations/de/ContentTransfer.xlf index 0e7096d..90626e1 100644 --- a/Resources/Private/Translations/de/ContentTransfer.xlf +++ b/Resources/Private/Translations/de/ContentTransfer.xlf @@ -15,6 +15,30 @@ Transfer document nodes Dokumentknoten kopieren + + Source document + Quelldokument + + + Target document + Zieldokument + + + Source dimensions + Quell-Dimensionen + + + Target dimensions + Ziel-Dimensionen + + + Source content repository + Quell-Content-Repository + + + Target content repository + Ziel-Content-Repository + Source site Quellseite @@ -77,13 +101,25 @@ An error occurred while copying. Reference code "{0}" Beim Kopieren ist ein Fehler aufgetreten. Referenz "{0}" + + Cannot move nodes across different content repositories + Knoten können nicht zwischen verschiedenen Content-Repositories verschoben werden + - The node was copied - Der Knoten wurde kopiert + Copied {0} node(s) in {1} variant(s) + {0} Knoten in {1} Variante(n) kopiert + + + The document was marked as copied, but no nodes were created. Check if the target node exists in the selected dimension. + Das Dokument wurde als kopiert markiert, aber es wurden keine Kopie erzeugt. Überprüfen Sie, ob das Zieldokument in der ausgewählten Dimension existiert. The node was moved - Der Knoten wurde verschoben + Der Inhalt wurde verschoben + + + Warning + Warnung diff --git a/Resources/Private/Translations/en/ContentTransfer.xlf b/Resources/Private/Translations/en/ContentTransfer.xlf index 640dbe4..5ae2fcf 100644 --- a/Resources/Private/Translations/en/ContentTransfer.xlf +++ b/Resources/Private/Translations/en/ContentTransfer.xlf @@ -12,6 +12,24 @@ Transfer document nodes + + Source document + + + Target document + + + Source dimensions + + + Target dimensions + + + Source content repository + + + Target content repository + Source site @@ -59,12 +77,21 @@ An error occurred while copying. Reference code "{0}" + + Cannot move nodes across different content repositories + - The node was copied + Copied {0} node(s) in {1} variant(s) + + + The document was marked as copied, but no nodes were created. Check if the target node exists in the selected dimension. The node was moved + + Warning + diff --git a/Resources/Public/ContentTransfer.css b/Resources/Public/ContentTransfer.css new file mode 100644 index 0000000..390fac3 --- /dev/null +++ b/Resources/Public/ContentTransfer.css @@ -0,0 +1,11 @@ +#selectionForm { + h5 { + margin-bottom: 1em; + } + + .neos-span6 { + display: flex; + flex-direction: column; + gap: 1em; + } +} diff --git a/Resources/Public/ContentTransfer.js b/Resources/Public/ContentTransfer.js new file mode 100644 index 0000000..3fff4f9 --- /dev/null +++ b/Resources/Public/ContentTransfer.js @@ -0,0 +1,51 @@ +;(() => { + 'use strict'; + + /** + * @typedef {{ title: string, message: string, severity: string }} FlashMessage + */ + + const renderFlashMessages = () => { + /** @type {FlashMessage[] | undefined} */ + const messages = window.flashMessagesData; + if (messages?.length) { + messages.forEach((m) => { + if (window.NeosCMS?.Notification?.[m.severity]) { + window.NeosCMS.Notification[m.severity](m.title || m.message, m.message || ''); + } + }); + } + }; + + const init = () => { + renderFlashMessages(); + + document.querySelectorAll('[data-auto-submit]').forEach((el) => { + el.addEventListener('change', function () { + if (this.matches('.ct-dimension-select')) { + /** @type {HTMLElement | null} */ + const group = this.closest('.ct-dimension-group'); + if (group) { + /** @type {HTMLInputElement | null} */ + const hiddenInput = group.querySelector('[data-dimension-values]'); + if (hiddenInput) { + /** @type {Record} */ + const values = {}; + group.querySelectorAll('.ct-dimension-select').forEach((select) => { + values[select.getAttribute('data-dimension-id')] = select.value; + }); + hiddenInput.value = JSON.stringify(values); + } + } + } + this.form.submit(); + }); + }); + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/Resources/Public/ContentTransferTree.css b/Resources/Public/ContentTransferTree.css new file mode 100644 index 0000000..79598bb --- /dev/null +++ b/Resources/Public/ContentTransferTree.css @@ -0,0 +1,86 @@ +.neos { + #contentTransferForm { + h4 { + margin-bottom: 1em; + } + } + + .content-transfer-tree { + margin-bottom: 16px; + } + + .ct-tree-header { + margin-bottom: 8px; + } + + .ct-tree-wrapper { + max-height: 400px; + overflow-y: auto; + border: 2px solid #3f3f3f; + padding: 4px 0; + background: #3f3f3f; + } + + .ct-tree, + ul.ct-tree-children { + list-style: none; + margin: 0; + padding: 0; + } + + ul { + .ct-tree-children { + padding-left: 20px; + } + + li.ct-tree-node { + margin: 0; + padding: 0; + line-height: 1.8; + } + } + + .ct-tree-toggle { + display: inline-block; + width: 16px; + cursor: pointer; + color: #adadad; + font-size: 10px; + user-select: none; + text-align: center; + + &:empty::before { + content: "•"; + display: inline-block; + width: 16px; + height: 16px; + } + } + + .ct-tree-label { + cursor: pointer; + padding: 2px 6px; + border-radius: 2px; + transition: background-color 0.1s; + } + + .ct-tree-label:hover { + background-color: #323232; + } + + .ct-tree-label--selected { + background-color: #323232; + font-weight: 600; + } + + .ct-selection-info { + margin-top: 1em; + font-size: 80%; + color: #adadad; + font-style: italic; + } + + .ct-workspace-selector { + margin-bottom: 4px; + } +} diff --git a/Resources/Public/ContentTransferTree.js b/Resources/Public/ContentTransferTree.js new file mode 100644 index 0000000..0ef1cc9 --- /dev/null +++ b/Resources/Public/ContentTransferTree.js @@ -0,0 +1,264 @@ +;(() => { + 'use strict'; + + const BASE_URL = '/neos/contenttransfer/tree-children'; + + const ICON_EXPANDED = '▼'; + const ICON_COLLAPSED = '▶'; + const ICON_NO_CHILDREN = ''; + + /** + * @typedef {{ nodeAggregateId: string, label: string, nodeType: string, hasChildren: boolean }} TreeNode + */ + + /** + * @typedef {{ workspaceName: string, title: string }} WorkspaceOption + */ + + /** + * @param {HTMLElement} container + */ + const initTree = (container) => { + /** @type {string} */ + const contentRepository = container.dataset.contentRepository ?? ''; + /** @type {string} */ + const inputName = container.dataset.inputName ?? ''; + /** @type {string} */ + let workspaceName = container.dataset.workspaceName || 'live'; + /** @type {string} */ + const workspacesRaw = container.dataset.workspaces ?? '[]'; + /** @type {string} */ + const dimensionValues = container.dataset.dimensionValues || '{}'; + /** @type {string} */ + const label = container.dataset.label ?? ''; + + /** @type {HTMLInputElement | null} */ + const hiddenInput = container.querySelector( + `[data-tree-input="${inputName}"]` + ); + + /** @type {HTMLInputElement | null} */ + let hiddenWs = container.querySelector( + `[data-tree-input="${inputName.replace('NodePath', 'Workspace').replace('ParentNodePath', 'Workspace')}"]` + ); + if (!hiddenWs) { + hiddenWs = container.querySelector('[data-tree-input="targetWorkspace"]') + ?? container.querySelector('[data-tree-input="sourceWorkspace"]'); + } + + /** @type {HTMLDivElement | null} */ + const treeWrapper = container.querySelector('.ct-tree-wrapper'); + /** @type {HTMLDivElement | null} */ + const selectionInfo = container.querySelector('.ct-selection-info'); + /** @type {string | null} */ + let selectedNodeId = null; + + /** @type {WorkspaceOption[]} */ + let workspaces = []; + try { + workspaces = JSON.parse(workspacesRaw) || []; + } catch (e) { + workspaces = []; + } + + /** + * @param {string | null} parentNodeId + * @returns {string} + */ + const buildUrl = (parentNodeId) => { + const params = new URLSearchParams(); + params.set('contentRepositoryId', contentRepository); + params.set('workspaceName', workspaceName); + if (parentNodeId) { + params.set('parentNodeId', parentNodeId); + } + params.set('dimensionValues', dimensionValues); + return `${BASE_URL}?${params.toString()}`; + }; + + const clearTree = () => { + treeWrapper.innerHTML = ''; + selectedNodeId = null; + if (selectionInfo) selectionInfo.textContent = ''; + if (hiddenInput) hiddenInput.value = ''; + }; + + /** + * @param {string | null} parentNodeId + * @param {HTMLLIElement | null} parentLi + */ + const loadChildren = (parentNodeId, parentLi) => { + /** @type {HTMLUListElement | null} */ + let childrenUl = parentLi ? parentLi.querySelector('.ct-tree-children') : null; + if (childrenUl?.getAttribute('data-loaded') === 'true') { + const toggle = parentLi?.querySelector('.ct-tree-toggle'); + if (childrenUl.style.display === 'none') { + childrenUl.style.display = ''; + if (toggle) toggle.textContent = ICON_EXPANDED; + } else { + childrenUl.style.display = 'none'; + if (toggle) toggle.textContent = ICON_COLLAPSED; + } + return; + } + + const url = buildUrl(parentNodeId); + if (parentLi) { + const toggle = parentLi.querySelector('.ct-tree-toggle'); + if (toggle) toggle.textContent = '⏳'; + } else if (treeWrapper) { + treeWrapper.innerHTML = '
Loading...
'; + } + + fetch(url) + .then((response) => response.json()) + .then((data) => { + /** @type {TreeNode[]} */ + const children = data.children || []; + if (!childrenUl && parentLi) { + childrenUl = document.createElement('ul'); + childrenUl.className = 'ct-tree-children'; + parentLi.appendChild(childrenUl); + } + if (childrenUl) { + childrenUl.innerHTML = ''; + children.forEach((child) => { + childrenUl.appendChild(createNodeElement(child)); + }); + childrenUl.setAttribute('data-loaded', 'true'); + childrenUl.style.display = ''; + } else if (!parentLi && treeWrapper) { + treeWrapper.innerHTML = ''; + const rootUl = document.createElement('ul'); + rootUl.className = 'ct-tree'; + children.forEach((child) => { + rootUl.appendChild(createNodeElement(child)); + }); + treeWrapper.appendChild(rootUl); + } + if (parentLi) { + const toggle = parentLi.querySelector('.ct-tree-toggle'); + if (toggle) + toggle.textContent = children.length > 0 ? ICON_EXPANDED : ICON_NO_CHILDREN; + } + }) + .catch(() => { + if (parentLi) { + const toggle = parentLi.querySelector('.ct-tree-toggle'); + toggle.textContent = ICON_COLLAPSED; + } else if (treeWrapper) { + treeWrapper.innerHTML = '
Failed to load tree
'; + } + }); + }; + + /** + * @param {TreeNode} nodeData + * @returns {HTMLLIElement} + */ + const createNodeElement = (nodeData) => { + const li = document.createElement('li'); + li.className = 'ct-tree-node'; + + const toggle = document.createElement('span'); + toggle.className = 'ct-tree-toggle'; + if (nodeData.hasChildren) { + toggle.textContent = ICON_COLLAPSED; + toggle.addEventListener('click', (e) => { + e.stopPropagation(); + loadChildren(nodeData.nodeAggregateId, li); + }); + } + li.appendChild(toggle); + + const labelEl = document.createElement('span'); + labelEl.className = 'ct-tree-label'; + labelEl.textContent = nodeData.label; + labelEl.setAttribute('data-node-id', nodeData.nodeAggregateId); + labelEl.addEventListener('click', () => { + selectNode(nodeData.nodeAggregateId, nodeData.label); + }); + li.appendChild(labelEl); + + if (nodeData.hasChildren) { + const childrenUl = document.createElement('ul'); + childrenUl.className = 'ct-tree-children'; + childrenUl.style.display = 'none'; + childrenUl.setAttribute('data-loaded', 'false'); + li.appendChild(childrenUl); + } + + return li; + }; + + /** + * @param {string} nodeId + * @param {string} nodeLabel + */ + const selectNode = (nodeId, nodeLabel) => { + selectedNodeId = nodeId; + container.querySelectorAll('.ct-tree-label.ct-tree-label--selected').forEach((el) => { + el.classList.remove('ct-tree-label--selected'); + }); + const selectedLabel = container.querySelector(`.ct-tree-label[data-node-id="${nodeId}"]`); + if (selectedLabel) { + selectedLabel.classList.add('ct-tree-label--selected'); + const li = selectedLabel.closest('.ct-tree-node'); + if (li) { + container.querySelectorAll('.ct-tree-node.ct-tree-node--selected').forEach((el) => { + el.classList.remove('ct-tree-node--selected'); + }); + li.classList.add('ct-tree-node--selected'); + } + } + if (hiddenInput) hiddenInput.value = nodeId; + if (selectionInfo) { + selectionInfo.textContent = `${label}: ${nodeLabel}`; + } + }; + + const buildWorkspaceSelector = () => { + if (workspaces.length <= 1) return; + const header = container.querySelector('.ct-tree-header'); + if (!header) return; + + const select = document.createElement('select'); + select.className = 'ct-workspace-selector neos-span12'; + workspaces.forEach((ws) => { + const opt = document.createElement('option'); + opt.value = ws.workspaceName; + opt.textContent = ws.title; + if (ws.workspaceName === workspaceName) { + opt.selected = true; + } + select.appendChild(opt); + }); + select.addEventListener('change', () => { + workspaceName = select.value; + if (hiddenInput) hiddenInput.value = ''; + if (hiddenWs) hiddenWs.value = workspaceName; + if (selectionInfo) selectionInfo.textContent = ''; + clearTree(); + loadChildren(null, null); + }); + header.appendChild(select); + }; + + clearTree(); + loadChildren(null, null); + buildWorkspaceSelector(); + if (hiddenWs) hiddenWs.value = workspaceName; + }; + + const init = () => { + document.querySelectorAll('.content-transfer-tree').forEach((container) => { + initTree(container); + }); + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/composer.json b/composer.json index 88d7945..90eee32 100644 --- a/composer.json +++ b/composer.json @@ -1,35 +1,38 @@ { - "name": "shel/neos-transfercontent", - "type": "neos-plugin", - "description": "Backend module for transferring content in Neos CMS multi sites", - "license": "GPL-3.0", - "keywords": [ - "neos", - "neoscms", - "flow", - "copy", - "transfer" - ], - "authors": [ - { - "name": "Sebastian Helzle", - "email": "sebastian@helzle.it", - "homepage": "https://www.helzle.it", - "role": "Developer" - } - ], - "require": { - "php": ">=7.4", - "neos/neos": "^5.3 || ^7.3 || ^8.0" - }, - "autoload": { - "psr-4": { - "Shel\\Neos\\TransferContent\\": "Classes" - } - }, - "extra": { - "neos": { - "package-key": "Shel.Neos.TransferContent" - } + "name": "shel/neos-transfercontent", + "type": "neos-plugin", + "description": "Backend module for transferring content in Neos CMS multi sites", + "license": "GPL-3.0", + "keywords": [ + "neos", + "neoscms", + "flow", + "copy", + "transfer" + ], + "authors": [ + { + "name": "Sebastian Helzle", + "email": "sebastian@helzle.it", + "homepage": "https://www.helzle.it", + "role": "Developer" } + ], + "require": { + "php": ">=8.3", + "neos/neos": "^9.1" + }, + "autoload": { + "psr-4": { + "Shel\\Neos\\TransferContent\\": "Classes" + } + }, + "scripts": { + "phpstan": "phpstan analyse -c phpstan.neon" + }, + "extra": { + "neos": { + "package-key": "Shel.Neos.TransferContent" + } + } } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..37bdcdc --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,8 @@ +parameters: + level: max + paths: + - Classes + scanDirectories: + - ../../Libraries + bootstrapFiles: + - ../../Libraries/autoload.php