From d5160a3aef3b7e2d75539c4edb3bb4b19b1622b7 Mon Sep 17 00:00:00 2001 From: Sebastian Helzle Date: Sat, 13 Jun 2026 09:44:04 +0200 Subject: [PATCH 01/23] !!! TASK: Mark compatible with Neos 9 --- composer.json | 64 +++++++++++++++++++++++++-------------------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/composer.json b/composer.json index 88d7945..83d1e6e 100644 --- a/composer.json +++ b/composer.json @@ -1,35 +1,35 @@ { - "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.0" + }, + "autoload": { + "psr-4": { + "Shel\\Neos\\TransferContent\\": "Classes" + } + }, + "extra": { + "neos": { + "package-key": "Shel.Neos.TransferContent" + } + } } From 10a8c021d7f51268060e482f3c0c76f33b4abdfb Mon Sep 17 00:00:00 2001 From: Sebastian Helzle Date: Sat, 13 Jun 2026 10:38:02 +0200 Subject: [PATCH 02/23] FEATURE: Refactor controller to new CR --- .../Controller/ContentTransferController.php | 346 +++++++++++++----- README.md | 4 +- .../Templates/ContentTransfer/Index.html | 2 +- 3 files changed, 258 insertions(+), 94 deletions(-) diff --git a/Classes/Controller/ContentTransferController.php b/Classes/Controller/ContentTransferController.php index 6143246..3d903ea 100644 --- a/Classes/Controller/ContentTransferController.php +++ b/Classes/Controller/ContentTransferController.php @@ -4,78 +4,112 @@ 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 Neos\ContentRepository\Core\ContentRepository; +use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; +use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; +use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNode; +use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyValuesToWrite; +use Neos\ContentRepository\Core\Feature\NodeMove\Command\MoveNodeAggregate; +use Neos\ContentRepository\Core\Feature\NodeMove\Dto\RelationDistributionStrategy; +use Neos\ContentRepository\Core\NodeType\NodeTypeName; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindChildNodesFilter; +use Neos\ContentRepository\Core\Projection\ContentGraph\Node; +use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; +use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; +use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace; +use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; +use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Error\Messages\Message; use Neos\Flow\Annotations as Flow; use Neos\Flow\I18n\Translator; use Neos\Flow\Mvc\Exception\StopActionException; +use Neos\Flow\Security\Context as SecurityContext; 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; +use Neos\Neos\Domain\Service\WorkspaceService; +use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; /** - * Controller - * - * @Flow\Scope("singleton") + * Controller for transferring content between sites in Neos CMS */ +#[Flow\Scope('singleton')] class ContentTransferController extends AbstractModuleController { + #[Flow\Inject] + protected readonly SiteRepository $siteRepository; - /** - * @var NodeOperations - * @Flow\Inject - */ - protected $nodeOperations; + #[Flow\Inject] + protected readonly Translator $translator; - /** - * @Flow\Inject - * @var SiteRepository - */ - protected $siteRepository; + #[Flow\Inject] + protected readonly DomainUserService $domainUserService; - /** - * @Flow\Inject - * @var WorkspaceRepository - */ - protected $workspaceRepository; + #[Flow\Inject] + protected readonly ContentRepositoryRegistry $contentRepositoryRegistry; - /** - * @Flow\Inject - * @var ContextFactory - */ - protected $contextFactory; + #[Flow\Inject] + protected readonly SecurityContext $securityContext; - /** - * @Flow\Inject - * @var Translator - */ - protected $translator; + #[Flow\Inject] + protected readonly ContentRepositoryAuthorizationService $authorizationService; - /** - * @Flow\Inject - * @var DomainUserService - */ - protected $domainUserService; + #[Flow\Inject] + protected readonly WorkspaceService $workspaceService; + + private function getContentRepository(): ContentRepository + { + return $this->contentRepositoryRegistry->get( + ContentRepositoryId::fromString('default') + ); + } /** * Shows form to transfer content */ - public function indexAction(?Site $sourceSite = null, ?Site $targetSite = null, string $targetParentNodePath = '', ?Workspace $targetWorkspace = null) - { + public function indexAction( + ?Site $sourceSite = null, + ?Site $targetSite = null, + string $targetParentNodePath = '', + ?Workspace $targetWorkspace = null + ): void { $sites = $this->siteRepository->findOnline(); - $workspaces = array_filter($this->workspaceRepository->findAll()->toArray(), function (Workspace $workspace) { - return $this->domainUserService->currentUserCanPublishToWorkspace($workspace); - }); + $contentRepository = $this->getContentRepository(); + $contentRepositoryId = $contentRepository->id; + + $roles = $this->securityContext->getRoles(); + $userId = $this->domainUserService->getCurrentUser()?->getId(); + + $workspaces = $contentRepository->findWorkspaces() + ->filter(function (Workspace $workspace) use ($contentRepositoryId, $roles, $userId): bool { + $permissions = $this->authorizationService->getWorkspacePermissions( + $contentRepositoryId, + $workspace->workspaceName, + $roles, + $userId + ); + return $permissions->write; + }) + ->getIterator(); + + // Build workspace labels with metadata titles + $workspaceOptions = []; + foreach ($workspaces as $workspace) { + $metadata = $this->workspaceService->getWorkspaceMetadata( + $contentRepositoryId, + $workspace->workspaceName + ); + $workspaceOptions[] = [ + 'workspace' => $workspace->workspaceName, + 'title' => $metadata->title->value, + ]; + } $this->view->assignMultiple([ 'sites' => $sites, - 'workspaces' => $workspaces, + 'workspaces' => $workspaceOptions, 'sourceSite' => $sourceSite, 'targetSite' => $targetSite, 'targetParentNodePath' => $targetParentNodePath, @@ -96,25 +130,29 @@ public function copyNodeAction( string $targetParentNodePath, ?Workspace $targetWorkspace = null, bool $moveNodesInstead = false - ) - { - /** @var ContentContext $sourceContext */ - $sourceContext = $this->contextFactory->create([ - 'currentSite' => $sourceSite, - 'invisibleContentShown' => true, - 'inaccessibleContentShown' => true - ]); + ): void { + $contentRepository = $this->getContentRepository(); + $targetWorkspaceName = $targetWorkspace + ? $targetWorkspace->workspaceName + : WorkspaceName::forLive(); - /** @var ContentContext $targetContext */ - $targetContext = $this->contextFactory->create([ - 'currentSite' => $targetSite, - 'workspaceName' => $targetWorkspace ? $targetWorkspace->getName() : 'live', - 'invisibleContentShown' => true, - 'inaccessibleContentShown' => true - ]); + $sourceSubgraph = $this->getContentSubgraph($contentRepository, $targetWorkspaceName); + $targetSubgraph = $this->getContentSubgraph($contentRepository, $targetWorkspaceName); - $sourceNode = $sourceContext->getNodeByIdentifier($sourceNodePath); - $targetParentNode = $targetContext->getNodeByIdentifier($targetParentNodePath); + $sourceNode = $sourceSubgraph->findNodeById( + NodeAggregateId::fromString($sourceNodePath) + ); + $targetParentNode = $targetSubgraph->findNodeById( + NodeAggregateId::fromString($targetParentNodePath) + ); + + $nodeTypeManager = $contentRepository->getNodeTypeManager(); + $sourceNodeType = $sourceNode?->nodeTypeName + ? $nodeTypeManager->getNodeType($sourceNode->nodeTypeName) + : null; + $targetNodeType = $targetParentNode?->nodeTypeName + ? $nodeTypeManager->getNodeType($targetParentNode->nodeTypeName) + : null; if ($sourceNode === null) { $this->addFlashMessage( @@ -122,52 +160,42 @@ 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 ($sourceNodeType === null || !$targetNodeType->allowsChildNodeType($sourceNodeType)) { $this->addFlashMessage( $this->translate('error.sourceNodeNotAllowedAsChildNode'), 'Error', Message::SEVERITY_ERROR ); + } elseif ($moveNodesInstead) { + $this->moveNode($contentRepository, $sourceNode, $targetParentNode, $targetWorkspaceName); } else { - try { - if ($moveNodesInstead) { - $this->nodeOperations->move($sourceNode, $targetParentNode, 'into'); - $this->addFlashMessage( - $this->translate('message.moved'), - 'Success' - ); - } else { - $this->nodeOperations->copy($sourceNode, $targetParentNode, 'into'); - $this->addFlashMessage( - $this->translate('message.copied'), - 'Success' - ); - } - } catch (NodeException $e) { - $this->addFlashMessage( - $this->translate('error.copyFailed', [$e->getReferenceCode()]), - 'Error', - Message::SEVERITY_ERROR - ); - } + $this->copyNode($contentRepository, $sourceNode, $targetParentNode, $targetWorkspaceName); } $this->redirect('index', null, null, [ @@ -178,11 +206,147 @@ public function copyNodeAction( ]); } + private function moveNode( + ContentRepository $contentRepository, + Node $sourceNode, + Node $targetParentNode, + WorkspaceName $targetWorkspaceName + ): void { + try { + $contentRepository->handle( + MoveNodeAggregate::create( + workspaceName: $targetWorkspaceName, + dimensionSpacePoint: $sourceNode->originDimensionSpacePoint->toDimensionSpacePoint(), + nodeAggregateId: $sourceNode->aggregateId, + relationDistributionStrategy: RelationDistributionStrategy::STRATEGY_GATHER_ALL, + newParentNodeAggregateId: $targetParentNode->aggregateId, + ) + ); + $this->addFlashMessage( + $this->translate('message.moved'), + 'Success' + ); + } catch (\Exception $e) { + $this->addFlashMessage( + $this->translate('error.copyFailed', [$e->getMessage()]), + 'Error', + Message::SEVERITY_ERROR + ); + } + } + + /** + * Copy a node with all its children recursively to a new parent. + * + * In Neos 9's event-sourced content repository, copy requires + * recreating node aggregates in the target location. + */ + private function copyNode( + ContentRepository $contentRepository, + Node $sourceNode, + Node $targetParentNode, + WorkspaceName $targetWorkspaceName + ): void { + try { + $sourceSubgraph = $this->getContentSubgraphFromNode($contentRepository, $sourceNode); + + $this->copyNodeRecursive( + contentRepository: $contentRepository, + sourceSubgraph: $sourceSubgraph, + sourceNode: $sourceNode, + targetParentNodeAggregateId: $targetParentNode->aggregateId, + targetWorkspaceName: $targetWorkspaceName, + sourceOriginDimensionSpacePoint: $sourceNode->originDimensionSpacePoint, + ); + + $this->addFlashMessage( + $this->translate('message.copied'), + 'Success' + ); + } catch (\Exception $e) { + $this->addFlashMessage( + $this->translate('error.copyFailed', [$e->getMessage()]), + 'Error', + Message::SEVERITY_ERROR + ); + } + } + + private function copyNodeRecursive( + ContentRepository $contentRepository, + ContentSubgraphInterface $sourceSubgraph, + Node $sourceNode, + NodeAggregateId $targetParentNodeAggregateId, + WorkspaceName $targetWorkspaceName, + OriginDimensionSpacePoint $sourceOriginDimensionSpacePoint, + ): void { + $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: $sourceOriginDimensionSpacePoint, + parentNodeAggregateId: $targetParentNodeAggregateId, + initialPropertyValues: PropertyValuesToWrite::fromArray($propertyValues), + ) + ); + + $childNodes = $sourceSubgraph->findChildNodes( + $sourceNode->aggregateId, + FindChildNodesFilter::create() + ); + + foreach ($childNodes as $childNode) { + $this->copyNodeRecursive( + contentRepository: $contentRepository, + sourceSubgraph: $sourceSubgraph, + sourceNode: $childNode, + targetParentNodeAggregateId: $newNodeAggregateId, + targetWorkspaceName: $targetWorkspaceName, + sourceOriginDimensionSpacePoint: $sourceOriginDimensionSpacePoint, + ); + } + } + + private function getContentSubgraph( + ContentRepository $contentRepository, + WorkspaceName $workspaceName + ): ContentSubgraphInterface { + return $contentRepository->getContentSubgraph( + $workspaceName, + DimensionSpacePoint::createWithoutDimensions() + ); + } + + private function getContentSubgraphFromNode( + ContentRepository $contentRepository, + Node $node + ): ContentSubgraphInterface { + return $contentRepository->getContentSubgraph( + $node->workspaceName, + $node->dimensionSpacePoint + ); + } + protected function translate(string $id, array $arguments = []): string { try { - $translation = $this->translator->translateById($id, $arguments, null, null, 'ContentTransfer', 'Shel.Neos.TransferContent'); - } catch (\Exception $e) { + $translation = $this->translator->translateById( + $id, + $arguments, + null, + null, + 'ContentTransfer', + 'Shel.Neos.TransferContent' + ); + } catch (\Exception) { // Ignore exception } return $translation ?? $id; diff --git a/README.md b/README.md index ecf0287..553b2ec 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,10 @@ ## 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 diff --git a/Resources/Private/Templates/ContentTransfer/Index.html b/Resources/Private/Templates/ContentTransfer/Index.html index 0ccb66a..3486fce 100644 --- a/Resources/Private/Templates/ContentTransfer/Index.html +++ b/Resources/Private/Templates/ContentTransfer/Index.html @@ -65,7 +65,7 @@
- From 4ec3bd3c40ce204fe3f2cca1f2779de49f68dc16 Mon Sep 17 00:00:00 2001 From: Sebastian Helzle Date: Sat, 13 Jun 2026 10:53:40 +0200 Subject: [PATCH 03/23] TASK: Convert template from fluid to Fusion --- .../Controller/ContentTransferController.php | 4 + Configuration/Views.yaml | 7 +- .../Fusion/Common/FlashMessages.fusion | 36 +++++ .../ContentTransfer/Actions/Index.fusion | 138 ++++++++++++++++++ Resources/Private/Fusion/Root.fusion | 5 + .../Templates/ContentTransfer/Index.html | 97 ------------ 6 files changed, 186 insertions(+), 101 deletions(-) create mode 100644 Resources/Private/Fusion/Common/FlashMessages.fusion create mode 100644 Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion create mode 100644 Resources/Private/Fusion/Root.fusion delete mode 100644 Resources/Private/Templates/ContentTransfer/Index.html diff --git a/Classes/Controller/ContentTransferController.php b/Classes/Controller/ContentTransferController.php index 3d903ea..6731e76 100644 --- a/Classes/Controller/ContentTransferController.php +++ b/Classes/Controller/ContentTransferController.php @@ -25,6 +25,7 @@ use Neos\Flow\I18n\Translator; use Neos\Flow\Mvc\Exception\StopActionException; use Neos\Flow\Security\Context as SecurityContext; +use Neos\Fusion\View\FusionView; use Neos\Neos\Controller\Module\AbstractModuleController; use Neos\Neos\Domain\Model\Site; use Neos\Neos\Domain\Repository\SiteRepository; @@ -38,6 +39,8 @@ #[Flow\Scope('singleton')] class ContentTransferController extends AbstractModuleController { + protected $defaultViewObjectName = FusionView::class; + #[Flow\Inject] protected readonly SiteRepository $siteRepository; @@ -115,6 +118,7 @@ public function indexAction( 'targetParentNodePath' => $targetParentNodePath, 'targetWorkspace' => $targetWorkspace, 'allowNodeMoving' => $this->settings['allowNodeMoving'], + 'flashMessages' => $this->controllerContext->getFlashMessageContainer()->getMessagesAndFlush(), ]); } 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/Resources/Private/Fusion/Common/FlashMessages.fusion b/Resources/Private/Fusion/Common/FlashMessages.fusion new file mode 100644 index 0000000..1e8bfd4 --- /dev/null +++ b/Resources/Private/Fusion/Common/FlashMessages.fusion @@ -0,0 +1,36 @@ +prototype(Shel.Neos.TransferContent:Component.FlashMessages) < prototype(Neos.Fusion:Component) { + flashMessages = ${[]} + + renderer = afx` +
+ + + +
+ ` +} + +prototype(Shel.Neos.TransferContent:Component.FlashMessages.Message) < prototype(Neos.Fusion:Component) { + message = ${{}} + + severity = ${String.toLowerCase(this.message.severity)} + severity.@process.replaceOKStatus = ${value == 'ok' ? 'success' : value} + severity.@process.replaceNoticeStatus = ${value == 'notice' ? 'info' : value} + + renderer = afx` +
+
+ +
+ {props.message.title || props.message.message} +
+
+ {props.message.message} +
+
+
+ ` +} 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..9d7b9a7 --- /dev/null +++ b/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion @@ -0,0 +1,138 @@ +prototype(Shel.Neos.TransferContent:View.ContentTransfer.index) < prototype(Neos.Fusion:Component) { + sites = ${sites} + workspaces = ${workspaces} + sourceSite = ${sourceSite} + targetSite = ${targetSite} + targetParentNodePath = ${targetParentNodePath} + targetWorkspace = ${targetWorkspace} + allowNodeMoving = ${allowNodeMoving} + flashMessages = ${flashMessages} + + i18n = ${I18n.id('').source('ContentTransfer').package('Shel.Neos.TransferContent')} + + 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 { + sourceSiteNodeName = ${this.sourceSite && this.sourceSite.nodeName ? this.sourceSite.nodeName.value : ''} + targetSiteNodeName = ${this.targetSite && this.targetSite.nodeName ? this.targetSite.nodeName.value : ''} + targetWorkspaceName = ${this.targetWorkspace && this.targetWorkspace.workspaceName ? this.targetWorkspace.workspaceName.value : ''} + helpText = ${this.i18n.id('help.text').translate()} + } + + renderer = afx` + + +
+
+
+ {props.i18n.id('copyNode')} + + + + + + {site.name} + + + + + + + + + + + + + + {site.name} + + + + + + + + + + + + + + {workspace.title} + + + + + +
+ + + +
+

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

+
+

{private.helpText}

+
+
+
+
+ + {props.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..4d64509 --- /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 3486fce..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()}

-
-
-
-
-
- -
-
-
- From 6d7620558bdd56e3ae3d243e7714af9d51fa4623 Mon Sep 17 00:00:00 2001 From: Sebastian Helzle Date: Mon, 15 Jun 2026 10:52:19 +0200 Subject: [PATCH 04/23] FEATURE: Allow choosing content repositories --- .../Controller/ContentTransferController.php | 131 ++++++++++++++---- Configuration/Settings.yaml | 3 + .../ContentTransfer/Actions/Index.fusion | 63 ++++++++- .../Translations/de/ContentTransfer.xlf | 8 ++ .../Translations/en/ContentTransfer.xlf | 6 + Resources/Public/ContentTransfer.js | 5 + 6 files changed, 183 insertions(+), 33 deletions(-) create mode 100644 Resources/Public/ContentTransfer.js diff --git a/Classes/Controller/ContentTransferController.php b/Classes/Controller/ContentTransferController.php index 6731e76..6672692 100644 --- a/Classes/Controller/ContentTransferController.php +++ b/Classes/Controller/ContentTransferController.php @@ -25,9 +25,11 @@ use Neos\Flow\I18n\Translator; use Neos\Flow\Mvc\Exception\StopActionException; use Neos\Flow\Security\Context as SecurityContext; +use Neos\Flow\Security\Policy\Role; use Neos\Fusion\View\FusionView; use Neos\Neos\Controller\Module\AbstractModuleController; use Neos\Neos\Domain\Model\Site; +use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Repository\SiteRepository; use Neos\Neos\Domain\Service\UserService as DomainUserService; use Neos\Neos\Domain\Service\WorkspaceService; @@ -62,10 +64,10 @@ class ContentTransferController extends AbstractModuleController #[Flow\Inject] protected readonly WorkspaceService $workspaceService; - private function getContentRepository(): ContentRepository + private function getContentRepository(?ContentRepositoryId $contentRepositoryId = null): ContentRepository { return $this->contentRepositoryRegistry->get( - ContentRepositoryId::fromString('default') + $contentRepositoryId ?? ContentRepositoryId::fromString('default') ); } @@ -76,26 +78,46 @@ public function indexAction( ?Site $sourceSite = null, ?Site $targetSite = null, string $targetParentNodePath = '', - ?Workspace $targetWorkspace = null + ?Workspace $targetWorkspace = null, + ?string $sourceContentRepository = null, + ?string $targetContentRepository = null, ): void { - $sites = $this->siteRepository->findOnline(); - $contentRepository = $this->getContentRepository(); + $contentRepositoryIds = []; + foreach ($this->contentRepositoryRegistry->getContentRepositoryIds() as $crId) { + $contentRepositoryIds[] = $crId->value; + } + + $defaultCr = $contentRepositoryIds[0] ?? 'default'; + $sourceContentRepository = $sourceContentRepository ?: $defaultCr; + $targetContentRepository = $targetContentRepository ?: $defaultCr; + + $allSites = $this->siteRepository->findOnline(); + + $sourceSites = []; + $targetSites = []; + foreach ($allSites as $site) { + $siteCr = $site->getConfiguration()->contentRepositoryId->value; + if ($siteCr === $sourceContentRepository) { + $sourceSites[] = $site; + } + if ($siteCr === $targetContentRepository) { + $targetSites[] = $site; + } + } + + $contentRepository = $this->getContentRepository( + ContentRepositoryId::fromString($targetContentRepository) + ); $contentRepositoryId = $contentRepository->id; - $roles = $this->securityContext->getRoles(); + $roles = $this->securityContext->getRoles() ?? []; $userId = $this->domainUserService->getCurrentUser()?->getId(); - $workspaces = $contentRepository->findWorkspaces() - ->filter(function (Workspace $workspace) use ($contentRepositoryId, $roles, $userId): bool { - $permissions = $this->authorizationService->getWorkspacePermissions( - $contentRepositoryId, - $workspace->workspaceName, - $roles, - $userId - ); - return $permissions->write; - }) - ->getIterator(); + $workspaces = $this->getWritableWorkspaces( + $contentRepository, + $roles, + $userId + ); // Build workspace labels with metadata titles $workspaceOptions = []; @@ -111,7 +133,11 @@ public function indexAction( } $this->view->assignMultiple([ - 'sites' => $sites, + 'contentRepositoryIds' => $contentRepositoryIds, + 'sourceContentRepository' => $sourceContentRepository, + 'targetContentRepository' => $targetContentRepository, + 'sourceSites' => $sourceSites, + 'targetSites' => $targetSites, 'workspaces' => $workspaceOptions, 'sourceSite' => $sourceSite, 'targetSite' => $targetSite, @@ -133,15 +159,26 @@ public function copyNodeAction( string $sourceNodePath, string $targetParentNodePath, ?Workspace $targetWorkspace = null, - bool $moveNodesInstead = false + bool $moveNodesInstead = false, + ?string $sourceContentRepository = null, + ?string $targetContentRepository = null, ): void { - $contentRepository = $this->getContentRepository(); + $sourceCrId = $sourceContentRepository + ? ContentRepositoryId::fromString($sourceContentRepository) + : $sourceSite->getConfiguration()->contentRepositoryId; + $targetCrId = $targetContentRepository + ? ContentRepositoryId::fromString($targetContentRepository) + : $targetSite->getConfiguration()->contentRepositoryId; + + $sourceCr = $this->contentRepositoryRegistry->get($sourceCrId); + $targetCr = $this->contentRepositoryRegistry->get($targetCrId); + $targetWorkspaceName = $targetWorkspace ? $targetWorkspace->workspaceName : WorkspaceName::forLive(); - $sourceSubgraph = $this->getContentSubgraph($contentRepository, $targetWorkspaceName); - $targetSubgraph = $this->getContentSubgraph($contentRepository, $targetWorkspaceName); + $sourceSubgraph = $this->getContentSubgraph($sourceCr, $targetWorkspaceName); + $targetSubgraph = $this->getContentSubgraph($targetCr, $targetWorkspaceName); $sourceNode = $sourceSubgraph->findNodeById( NodeAggregateId::fromString($sourceNodePath) @@ -150,12 +187,13 @@ public function copyNodeAction( NodeAggregateId::fromString($targetParentNodePath) ); - $nodeTypeManager = $contentRepository->getNodeTypeManager(); + $sourceNodeTypeManager = $sourceCr->getNodeTypeManager(); + $targetNodeTypeManager = $targetCr->getNodeTypeManager(); $sourceNodeType = $sourceNode?->nodeTypeName - ? $nodeTypeManager->getNodeType($sourceNode->nodeTypeName) + ? $sourceNodeTypeManager->getNodeType($sourceNode->nodeTypeName) : null; $targetNodeType = $targetParentNode?->nodeTypeName - ? $nodeTypeManager->getNodeType($targetParentNode->nodeTypeName) + ? $targetNodeTypeManager->getNodeType($targetParentNode->nodeTypeName) : null; if ($sourceNode === null) { @@ -197,9 +235,17 @@ public function copyNodeAction( Message::SEVERITY_ERROR ); } elseif ($moveNodesInstead) { - $this->moveNode($contentRepository, $sourceNode, $targetParentNode, $targetWorkspaceName); + if (!$sourceCrId->equals($targetCrId)) { + $this->addFlashMessage( + 'Moving between CRs not implemented yet', + 'Unsupported action: Moving between CRs not implemented yet', + Message::SEVERITY_ERROR + ); + } else { + $this->moveNode($sourceCr, $sourceNode, $targetParentNode, $targetWorkspaceName); + } } else { - $this->copyNode($contentRepository, $sourceNode, $targetParentNode, $targetWorkspaceName); + $this->copyNode($sourceCr, $targetCr, $sourceNode, $targetParentNode, $targetWorkspaceName); } $this->redirect('index', null, null, [ @@ -207,6 +253,8 @@ public function copyNodeAction( 'targetSite' => $targetSite, 'targetParentNodePath' => $targetParentNodePath, 'targetWorkspace' => $targetWorkspace, + 'sourceContentRepository' => $sourceContentRepository, + 'targetContentRepository' => $targetContentRepository, ]); } @@ -246,16 +294,17 @@ private function moveNode( * recreating node aggregates in the target location. */ private function copyNode( - ContentRepository $contentRepository, + ContentRepository $sourceCr, + ContentRepository $targetCr, Node $sourceNode, Node $targetParentNode, WorkspaceName $targetWorkspaceName ): void { try { - $sourceSubgraph = $this->getContentSubgraphFromNode($contentRepository, $sourceNode); + $sourceSubgraph = $this->getContentSubgraphFromNode($sourceCr, $sourceNode); $this->copyNodeRecursive( - contentRepository: $contentRepository, + contentRepository: $targetCr, sourceSubgraph: $sourceSubgraph, sourceNode: $sourceNode, targetParentNodeAggregateId: $targetParentNode->aggregateId, @@ -355,4 +404,26 @@ protected function translate(string $id, array $arguments = []): string } return $translation ?? $id; } + + /** + * @param Role[] $roles + * @throws \Exception + */ + protected function getWritableWorkspaces( + ContentRepository $contentRepository, + array $roles, + UserId $userId + ): iterable { + return $contentRepository->findWorkspaces() + ->filter(function (Workspace $workspace) use ($contentRepository, $roles, $userId): bool { + $permissions = $this->authorizationService->getWorkspacePermissions( + $contentRepository->id, + $workspace->workspaceName, + $roles, + $userId + ); + return $permissions->write; + }) + ->getIterator(); + } } diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index 18c3535..e01bae2 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -19,6 +19,9 @@ Neos: description: 'Shel.Neos.TransferContent:ContentTransfer:description' icon: 'fas fa-exchange-alt' privilegeTarget: 'Shel.Neos.TransferContent:Backend.Module.Management.ContentTransfer' + additionalResources: + javaScripts: + 'ContentTransfer': 'resource://Shel.Neos.TransferContent/Public/ContentTransfer.js' Shel: Neos: diff --git a/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion b/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion index 9d7b9a7..f2f73c1 100644 --- a/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion +++ b/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion @@ -1,5 +1,9 @@ prototype(Shel.Neos.TransferContent:View.ContentTransfer.index) < prototype(Neos.Fusion:Component) { - sites = ${sites} + contentRepositoryIds = ${contentRepositoryIds} + sourceContentRepository = ${sourceContentRepository} + targetContentRepository = ${targetContentRepository} + sourceSites = ${sourceSites} + targetSites = ${targetSites} workspaces = ${workspaces} sourceSite = ${sourceSite} targetSite = ${targetSite} @@ -27,23 +31,76 @@ prototype(Shel.Neos.TransferContent:View.ContentTransfer.index) < prototype(Neos targetSiteNodeName = ${this.targetSite && this.targetSite.nodeName ? this.targetSite.nodeName.value : ''} targetWorkspaceName = ${this.targetWorkspace && this.targetWorkspace.workspaceName ? this.targetWorkspace.workspaceName.value : ''} helpText = ${this.i18n.id('help.text').translate()} + hasMultipleCr = ${props.contentRepositoryIds != null && Array.length(props.contentRepositoryIds) > 1} } renderer = afx` + + +
+
+
+ + + + + {crId} + + + + + + + + + + {crId} + + + + +
+
+
+
+
{props.i18n.id('copyNode')} + + + - + - + Transfer document nodes Dokumentknoten kopieren + + Source content repository + Quell-Content-Repository + + + Target content repository + Ziel-Content-Repository + Source site Quellseite diff --git a/Resources/Private/Translations/en/ContentTransfer.xlf b/Resources/Private/Translations/en/ContentTransfer.xlf index 640dbe4..db97244 100644 --- a/Resources/Private/Translations/en/ContentTransfer.xlf +++ b/Resources/Private/Translations/en/ContentTransfer.xlf @@ -12,6 +12,12 @@ Transfer document nodes + + Source content repository + + + Target content repository + Source site diff --git a/Resources/Public/ContentTransfer.js b/Resources/Public/ContentTransfer.js new file mode 100644 index 0000000..74d6a4a --- /dev/null +++ b/Resources/Public/ContentTransfer.js @@ -0,0 +1,5 @@ +document.querySelectorAll('[data-auto-submit]').forEach(function(el) { + el.addEventListener('change', function() { + this.form.submit(); + }); +}); From 9d65ee078b95f30d31e271f2a34f27bc48fb25d8 Mon Sep 17 00:00:00 2001 From: Sebastian Helzle Date: Mon, 15 Jun 2026 15:38:53 +0200 Subject: [PATCH 05/23] FEATURE: Select content from tree views --- .../Controller/ContentTransferController.php | 365 +++++++++------ Configuration/Policy.yaml | 5 +- Configuration/Routes.yaml | 9 + Configuration/Settings.Flow.yaml | 16 + .../{Settings.yaml => Settings.Neos.yaml} | 8 +- Configuration/Settings.TransferContent.yaml | 6 + .../ContentTransfer/Actions/Index.fusion | 421 ++++++++++-------- .../Translations/de/ContentTransfer.xlf | 20 + .../Translations/en/ContentTransfer.xlf | 15 + Resources/Public/ContentTransfer.js | 33 +- Resources/Public/ContentTransferTree.css | 80 ++++ Resources/Public/ContentTransferTree.js | 264 +++++++++++ 12 files changed, 892 insertions(+), 350 deletions(-) create mode 100644 Configuration/Routes.yaml create mode 100644 Configuration/Settings.Flow.yaml rename Configuration/{Settings.yaml => Settings.Neos.yaml} (77%) create mode 100644 Configuration/Settings.TransferContent.yaml create mode 100644 Resources/Public/ContentTransferTree.css create mode 100644 Resources/Public/ContentTransferTree.js diff --git a/Classes/Controller/ContentTransferController.php b/Classes/Controller/ContentTransferController.php index 6672692..e4158c5 100644 --- a/Classes/Controller/ContentTransferController.php +++ b/Classes/Controller/ContentTransferController.php @@ -4,6 +4,7 @@ namespace Shel\Neos\TransferContent\Controller; +use GuzzleHttp\Psr7\Response; use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; @@ -11,9 +12,12 @@ use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyValuesToWrite; use Neos\ContentRepository\Core\Feature\NodeMove\Command\MoveNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeMove\Dto\RelationDistributionStrategy; +use Neos\ContentRepository\Core\Feature\Security\Exception\AccessDenied; use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\CountChildNodesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindChildNodesFilter; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindRootNodeAggregatesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; @@ -25,27 +29,19 @@ use Neos\Flow\I18n\Translator; use Neos\Flow\Mvc\Exception\StopActionException; use Neos\Flow\Security\Context as SecurityContext; -use Neos\Flow\Security\Policy\Role; use Neos\Fusion\View\FusionView; use Neos\Neos\Controller\Module\AbstractModuleController; -use Neos\Neos\Domain\Model\Site; -use Neos\Neos\Domain\Model\UserId; -use Neos\Neos\Domain\Repository\SiteRepository; +use Neos\Neos\Domain\NodeLabel\NodeLabelGeneratorInterface; use Neos\Neos\Domain\Service\UserService as DomainUserService; use Neos\Neos\Domain\Service\WorkspaceService; use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; +use Psr\Http\Message\ResponseInterface; -/** - * Controller for transferring content between sites in Neos CMS - */ #[Flow\Scope('singleton')] class ContentTransferController extends AbstractModuleController { protected $defaultViewObjectName = FusionView::class; - #[Flow\Inject] - protected readonly SiteRepository $siteRepository; - #[Flow\Inject] protected readonly Translator $translator; @@ -64,121 +60,171 @@ class ContentTransferController extends AbstractModuleController #[Flow\Inject] protected readonly WorkspaceService $workspaceService; - private function getContentRepository(?ContentRepositoryId $contentRepositoryId = null): ContentRepository - { - return $this->contentRepositoryRegistry->get( - $contentRepositoryId ?? ContentRepositoryId::fromString('default') - ); - } + #[Flow\Inject] + protected readonly NodeLabelGeneratorInterface $nodeLabelGenerator; + + #[Flow\InjectConfiguration(path: 'contentRepositories', package: 'Neos.ContentRepositoryRegistry')] + protected array $crSettings = []; - /** - * Shows form to transfer content - */ public function indexAction( - ?Site $sourceSite = null, - ?Site $targetSite = null, - string $targetParentNodePath = '', - ?Workspace $targetWorkspace = null, - ?string $sourceContentRepository = null, - ?string $targetContentRepository = null, + ?ContentRepositoryId $sourceContentRepository = null, + ?ContentRepositoryId $targetContentRepository = null, + ?WorkspaceName $sourceWorkspace = null, + ?WorkspaceName $targetWorkspace = null, + ?string $sourceDimensionValues = '{}', + ?string $targetDimensionValues = '{}', ): void { $contentRepositoryIds = []; foreach ($this->contentRepositoryRegistry->getContentRepositoryIds() as $crId) { $contentRepositoryIds[] = $crId->value; } - $defaultCr = $contentRepositoryIds[0] ?? 'default'; - $sourceContentRepository = $sourceContentRepository ?: $defaultCr; - $targetContentRepository = $targetContentRepository ?: $defaultCr; - - $allSites = $this->siteRepository->findOnline(); - - $sourceSites = []; - $targetSites = []; - foreach ($allSites as $site) { - $siteCr = $site->getConfiguration()->contentRepositoryId->value; - if ($siteCr === $sourceContentRepository) { - $sourceSites[] = $site; - } - if ($siteCr === $targetContentRepository) { - $targetSites[] = $site; + $sourceWorkspace = $sourceWorkspace ?? WorkspaceName::forLive(); + $targetWorkspace = $targetWorkspace ?? WorkspaceName::forLive(); + $sourceContentRepository = $sourceContentRepository ?: ContentRepositoryId::fromString('default'); + $targetContentRepository = $targetContentRepository ?: ContentRepositoryId::fromString('default'); + + $sourceWorkspaces = $this->getWorkspacesForCr($sourceContentRepository); + $targetWorkspaces = $this->getWorkspacesForCr($targetContentRepository); + + $sourceDimensions = $this->buildDimensionConfig($sourceContentRepository); + $targetDimensions = $this->buildDimensionConfig($targetContentRepository); + + $parsedSourceDimValues = json_decode($sourceDimensionValues, true, 512, JSON_THROW_ON_ERROR) ?: []; + $parsedTargetDimValues = json_decode($targetDimensionValues, true, 512, JSON_THROW_ON_ERROR) ?: []; + + // Always merge defaults for missing dimensions + 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; + } + } } } - - $contentRepository = $this->getContentRepository( - ContentRepositoryId::fromString($targetContentRepository) - ); - $contentRepositoryId = $contentRepository->id; - - $roles = $this->securityContext->getRoles() ?? []; - $userId = $this->domainUserService->getCurrentUser()?->getId(); - - $workspaces = $this->getWritableWorkspaces( - $contentRepository, - $roles, - $userId - ); - - // Build workspace labels with metadata titles - $workspaceOptions = []; - foreach ($workspaces as $workspace) { - $metadata = $this->workspaceService->getWorkspaceMetadata( - $contentRepositoryId, - $workspace->workspaceName - ); - $workspaceOptions[] = [ - 'workspace' => $workspace->workspaceName, - 'title' => $metadata->title->value, - ]; + 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; + } + } + } } $this->view->assignMultiple([ 'contentRepositoryIds' => $contentRepositoryIds, 'sourceContentRepository' => $sourceContentRepository, 'targetContentRepository' => $targetContentRepository, - 'sourceSites' => $sourceSites, - 'targetSites' => $targetSites, - 'workspaces' => $workspaceOptions, - 'sourceSite' => $sourceSite, - 'targetSite' => $targetSite, - 'targetParentNodePath' => $targetParentNodePath, + 'sourceWorkspaces' => $sourceWorkspaces, + 'targetWorkspaces' => $targetWorkspaces, + 'sourceWorkspace' => $sourceWorkspace, 'targetWorkspace' => $targetWorkspace, + 'sourceDimensions' => $sourceDimensions, + 'targetDimensions' => $targetDimensions, + 'sourceDimensionValues' => $parsedSourceDimValues, + 'targetDimensionValues' => $parsedTargetDimValues, 'allowNodeMoving' => $this->settings['allowNodeMoving'], 'flashMessages' => $this->controllerContext->getFlashMessageContainer()->getMessagesAndFlush(), ]); } + /** + * @throws AccessDenied + * @throws \JsonException + */ + public function treeChildrenAction( + ContentRepositoryId $contentRepositoryId, + WorkspaceName $workspaceName = null, + ?NodeAggregateId $parentNodeId = null, + string $dimensionValues = '{}', + ): ResponseInterface { + $cr = $this->contentRepositoryRegistry->get($contentRepositoryId); + $parsedDimValues = json_decode($dimensionValues, true, 512, JSON_THROW_ON_ERROR) ?: []; + $dsp = DimensionSpacePoint::fromArray($parsedDimValues); + $subgraph = $cr->getContentSubgraph($workspaceName, $dsp); + //\Neos\Flow\var_dump($dsp); + //die('hard'); + + $nodeTypeFilter = $this->getNodeTypeFilterForCr($contentRepositoryId); + + if ($parentNodeId === null) { + $rootNodeAggregate = $cr->getContentGraph($workspaceName)->findRootNodeAggregates( + FindRootNodeAggregatesFilter::create() + )->first(); + $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[] = [ + 'nodeAggregateId' => $child->aggregateId->value, + 'label' => $label, + 'nodeType' => $child->nodeTypeName->value, + 'hasChildren' => $hasChildren, + ]; + } + + return new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['children' => $result], JSON_THROW_ON_ERROR) + ); + } + /** * @throws StopActionException * @Flow\Validate(argumentName="sourceNodePath", type="\Neos\Flow\Validation\Validator\NotEmptyValidator") * @Flow\Validate(argumentName="targetParentNodePath", type="\Neos\Flow\Validation\Validator\NotEmptyValidator") */ public function copyNodeAction( - Site $sourceSite, - Site $targetSite, - string $sourceNodePath, - string $targetParentNodePath, - ?Workspace $targetWorkspace = null, + string $sourceNodePath = '', + string $targetParentNodePath = '', + ?WorkspaceName $sourceWorkspace = null, + ?WorkspaceName $targetWorkspace = null, bool $moveNodesInstead = false, - ?string $sourceContentRepository = null, - ?string $targetContentRepository = null, + ?ContentRepositoryId $sourceContentRepository = null, + ?ContentRepositoryId $targetContentRepository = null, + string $sourceDimensionValues = '{}', + string $targetDimensionValues = '{}', ): void { - $sourceCrId = $sourceContentRepository - ? ContentRepositoryId::fromString($sourceContentRepository) - : $sourceSite->getConfiguration()->contentRepositoryId; - $targetCrId = $targetContentRepository - ? ContentRepositoryId::fromString($targetContentRepository) - : $targetSite->getConfiguration()->contentRepositoryId; + $sourceContentRepository = $sourceContentRepository ?? ContentRepositoryId::fromString('default'); + $targetContentRepository = $targetContentRepository ?? ContentRepositoryId::fromString('default'); + + $sourceCr = $this->contentRepositoryRegistry->get($sourceContentRepository); + $targetCr = $this->contentRepositoryRegistry->get($targetContentRepository); - $sourceCr = $this->contentRepositoryRegistry->get($sourceCrId); - $targetCr = $this->contentRepositoryRegistry->get($targetCrId); + $sourceWorkspace = $sourceWorkspace ?: WorkspaceName::forLive(); + $targetWorkspace = $targetWorkspace ?: WorkspaceName::forLive(); - $targetWorkspaceName = $targetWorkspace - ? $targetWorkspace->workspaceName - : WorkspaceName::forLive(); + $parsedSourceDim = json_decode($sourceDimensionValues, true, 512, JSON_THROW_ON_ERROR) ?: []; + $parsedTargetDim = json_decode($targetDimensionValues, true, 512, JSON_THROW_ON_ERROR) ?: []; - $sourceSubgraph = $this->getContentSubgraph($sourceCr, $targetWorkspaceName); - $targetSubgraph = $this->getContentSubgraph($targetCr, $targetWorkspaceName); + $sourceSubgraph = $sourceCr->getContentSubgraph( + $sourceWorkspace, + DimensionSpacePoint::fromArray($parsedSourceDim) + ); + $targetSubgraph = $targetCr->getContentSubgraph( + $targetWorkspace, + DimensionSpacePoint::fromArray($parsedTargetDim) + ); $sourceNode = $sourceSubgraph->findNodeById( NodeAggregateId::fromString($sourceNodePath) @@ -228,33 +274,33 @@ public function copyNodeAction( 'Error', Message::SEVERITY_ERROR ); - } elseif ($sourceNodeType === null || !$targetNodeType->allowsChildNodeType($sourceNodeType)) { + } elseif (!$targetNodeType->allowsChildNodeType($sourceNodeType)) { $this->addFlashMessage( $this->translate('error.sourceNodeNotAllowedAsChildNode'), 'Error', Message::SEVERITY_ERROR ); } elseif ($moveNodesInstead) { - if (!$sourceCrId->equals($targetCrId)) { + if (!$sourceContentRepository->equals($targetContentRepository)) { $this->addFlashMessage( - 'Moving between CRs not implemented yet', - 'Unsupported action: Moving between CRs not implemented yet', + $this->translate('error.cannotMoveAcrossCr'), + 'Error', Message::SEVERITY_ERROR ); } else { - $this->moveNode($sourceCr, $sourceNode, $targetParentNode, $targetWorkspaceName); + $this->moveNode($sourceCr, $sourceNode, $targetParentNode, $targetWorkspace); } } else { - $this->copyNode($sourceCr, $targetCr, $sourceNode, $targetParentNode, $targetWorkspaceName); + $this->copyNode($sourceCr, $targetCr, $sourceNode, $targetParentNode, $targetWorkspace); } $this->redirect('index', null, null, [ - 'sourceSite' => $sourceSite, - 'targetSite' => $targetSite, - 'targetParentNodePath' => $targetParentNodePath, - 'targetWorkspace' => $targetWorkspace, 'sourceContentRepository' => $sourceContentRepository, 'targetContentRepository' => $targetContentRepository, + 'sourceWorkspace' => $sourceWorkspace, + 'targetWorkspace' => $targetWorkspace, + 'sourceDimensionValues' => $sourceDimensionValues, + 'targetDimensionValues' => $targetDimensionValues, ]); } @@ -287,12 +333,6 @@ private function moveNode( } } - /** - * Copy a node with all its children recursively to a new parent. - * - * In Neos 9's event-sourced content repository, copy requires - * recreating node aggregates in the target location. - */ private function copyNode( ContentRepository $sourceCr, ContentRepository $targetCr, @@ -301,7 +341,10 @@ private function copyNode( WorkspaceName $targetWorkspaceName ): void { try { - $sourceSubgraph = $this->getContentSubgraphFromNode($sourceCr, $sourceNode); + $sourceSubgraph = $sourceCr->getContentSubgraph( + $sourceNode->workspaceName, + $sourceNode->dimensionSpacePoint + ); $this->copyNodeRecursive( contentRepository: $targetCr, @@ -368,24 +411,73 @@ private function copyNodeRecursive( } } - private function getContentSubgraph( - ContentRepository $contentRepository, - WorkspaceName $workspaceName - ): ContentSubgraphInterface { - return $contentRepository->getContentSubgraph( - $workspaceName, - DimensionSpacePoint::createWithoutDimensions() - ); + private function buildDimensionConfig(ContentRepositoryId $crId): array + { + $crConfig = $this->crSettings[$crId->value] ?? []; + $contentDimensions = $crConfig['contentDimensions'] ?? []; + + $result = []; + foreach ($contentDimensions as $dimId => $dimConfig) { + if (!is_array($dimConfig)) { + continue; + } + + $values = []; + foreach ($dimConfig['values'] ?? [] as $valId => $valConfig) { + if (!is_array($valConfig)) { + continue; + } + $values[] = [ + 'value' => $valId, + 'label' => $valConfig['label'] ?? $valId, + ]; + } + + $result[] = [ + 'id' => $dimId, + 'label' => $dimConfig['label'] ?? $dimId, + 'values' => $values, + ]; + } + return $result; } - private function getContentSubgraphFromNode( - ContentRepository $contentRepository, - Node $node - ): ContentSubgraphInterface { - return $contentRepository->getContentSubgraph( - $node->workspaceName, - $node->dimensionSpacePoint - ); + private function getNodeTypeFilterForCr(ContentRepositoryId $crId): string + { + $filters = $this->settings['nodeTypeFilters'] ?? ['default' => 'Neos.Neos:Document']; + return $filters[$crId->value] ?? $filters['default'] ?? 'Neos.Neos:Document'; + } + + private function getWorkspacesForCr(ContentRepositoryId $crId): array + { + $contentRepository = $this->contentRepositoryRegistry->get($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[] = [ + 'workspace' => $workspace->workspaceName, + 'title' => $metadata->title->value, + ]; + } + return $result; } protected function translate(string $id, array $arguments = []): string @@ -400,30 +492,7 @@ protected function translate(string $id, array $arguments = []): string 'Shel.Neos.TransferContent' ); } catch (\Exception) { - // Ignore exception } return $translation ?? $id; } - - /** - * @param Role[] $roles - * @throws \Exception - */ - protected function getWritableWorkspaces( - ContentRepository $contentRepository, - array $roles, - UserId $userId - ): iterable { - return $contentRepository->findWorkspaces() - ->filter(function (Workspace $workspace) use ($contentRepository, $roles, $userId): bool { - $permissions = $this->authorizationService->getWorkspacePermissions( - $contentRepository->id, - $workspace->workspaceName, - $roles, - $userId - ); - return $permissions->write; - }) - ->getIterator(); - } } 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 77% rename from Configuration/Settings.yaml rename to Configuration/Settings.Neos.yaml index e01bae2..a0e3bdf 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.Neos.yaml @@ -22,8 +22,6 @@ Neos: additionalResources: javaScripts: 'ContentTransfer': 'resource://Shel.Neos.TransferContent/Public/ContentTransfer.js' - -Shel: - Neos: - TransferContent: - allowNodeMoving: false + 'ContentTransferTree': 'resource://Shel.Neos.TransferContent/Public/ContentTransferTree.js' + styleSheets: + '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/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion b/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion index f2f73c1..68a5bdf 100644 --- a/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion +++ b/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion @@ -1,195 +1,236 @@ prototype(Shel.Neos.TransferContent:View.ContentTransfer.index) < prototype(Neos.Fusion:Component) { - contentRepositoryIds = ${contentRepositoryIds} - sourceContentRepository = ${sourceContentRepository} - targetContentRepository = ${targetContentRepository} - sourceSites = ${sourceSites} - targetSites = ${targetSites} - workspaces = ${workspaces} - sourceSite = ${sourceSite} - targetSite = ${targetSite} - targetParentNodePath = ${targetParentNodePath} - targetWorkspace = ${targetWorkspace} - allowNodeMoving = ${allowNodeMoving} - flashMessages = ${flashMessages} - - i18n = ${I18n.id('').source('ContentTransfer').package('Shel.Neos.TransferContent')} - - 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' + contentRepositoryIds = ${contentRepositoryIds} + sourceContentRepository = ${sourceContentRepository} + targetContentRepository = ${targetContentRepository} + sourceWorkspaces = ${sourceWorkspaces} + targetWorkspaces = ${targetWorkspaces} + sourceWorkspace = ${sourceWorkspace} + targetWorkspace = ${targetWorkspace} + sourceDimensions = ${sourceDimensions} + targetDimensions = ${targetDimensions} + sourceDimensionValues = ${sourceDimensionValues} + targetDimensionValues = ${targetDimensionValues} + allowNodeMoving = ${allowNodeMoving} + flashMessages = ${flashMessages} + + prototype(Neos.Fusion.Form:LabelRenderer) { + translationPackage = 'Shel.Neos.TransferContent' + translationSource = 'ContentTransfer' } - } - - @private { - sourceSiteNodeName = ${this.sourceSite && this.sourceSite.nodeName ? this.sourceSite.nodeName.value : ''} - targetSiteNodeName = ${this.targetSite && this.targetSite.nodeName ? this.targetSite.nodeName.value : ''} - targetWorkspaceName = ${this.targetWorkspace && this.targetWorkspace.workspaceName ? this.targetWorkspace.workspaceName.value : ''} - helpText = ${this.i18n.id('help.text').translate()} - hasMultipleCr = ${props.contentRepositoryIds != null && Array.length(props.contentRepositoryIds) > 1} - } - - renderer = afx` - - - -
-
-
- - - - - {crId} - - - - - - - - - - {crId} - - - - -
-
-
-
- - -
-
-
- {props.i18n.id('copyNode')} - - - - - - - - - {site.name} - - - - - - - - - - - - - - {site.name} - - - - - - - - - - - - - - {workspace.title} - - - - - -
- - - -
-

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

-
-

{private.helpText}

-
-
-
-
- - {props.i18n.id('submit').translate()} - -
-
- ` + + 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} + + + + + + + + + + {crId} + + + + +
+ +
+ +
{private.i18n.id('sourceDimension').translate()}
+ +
+ +
+ +
+
+
+
+ +
+ +
{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/Translations/de/ContentTransfer.xlf b/Resources/Private/Translations/de/ContentTransfer.xlf index 1b08cfa..8c1d00a 100644 --- a/Resources/Private/Translations/de/ContentTransfer.xlf +++ b/Resources/Private/Translations/de/ContentTransfer.xlf @@ -15,6 +15,22 @@ 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 @@ -85,6 +101,10 @@ 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 diff --git a/Resources/Private/Translations/en/ContentTransfer.xlf b/Resources/Private/Translations/en/ContentTransfer.xlf index db97244..1d5a9c7 100644 --- a/Resources/Private/Translations/en/ContentTransfer.xlf +++ b/Resources/Private/Translations/en/ContentTransfer.xlf @@ -12,6 +12,18 @@ Transfer document nodes + + Source document + + + Target document + + + Source dimensions + + + Target dimensions + Source content repository @@ -65,6 +77,9 @@ An error occurred while copying. Reference code "{0}" + + Cannot move nodes across different content repositories + The node was copied diff --git a/Resources/Public/ContentTransfer.js b/Resources/Public/ContentTransfer.js index 74d6a4a..6a1a0bd 100644 --- a/Resources/Public/ContentTransfer.js +++ b/Resources/Public/ContentTransfer.js @@ -1,5 +1,28 @@ -document.querySelectorAll('[data-auto-submit]').forEach(function(el) { - el.addEventListener('change', function() { - this.form.submit(); - }); -}); +;(function () { + function init() { + document.querySelectorAll('[data-auto-submit]').forEach(function(el) { + el.addEventListener('change', function() { + if (this.matches('.ct-dimension-select')) { + var group = this.closest('.ct-dimension-group'); + if (group) { + var hiddenInput = group.querySelector('[data-dimension-values]'); + if (hiddenInput) { + var values = {}; + group.querySelectorAll('.ct-dimension-select').forEach(function(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..2d38ec0 --- /dev/null +++ b/Resources/Public/ContentTransferTree.css @@ -0,0 +1,80 @@ +.neos { + .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: 4px; + font-size: 12px; + 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..015f299 --- /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 {{ workspace: string, title: string }} WorkspaceOption + */ + + /** + * @param {HTMLElement} container + */ + const initTree = (container) => { + /** @type {string} */ + const contentRepository = container.getAttribute('data-content-repository') ?? ''; + /** @type {string} */ + const inputName = container.getAttribute('data-input-name') ?? ''; + /** @type {string} */ + let workspaceName = container.getAttribute('data-workspace-name') || 'live'; + /** @type {string} */ + const workspacesRaw = container.getAttribute('data-workspaces') ?? '[]'; + /** @type {string} */ + const dimensionValues = container.getAttribute('data-dimension-values') || '{}'; + /** @type {string} */ + const label = container.getAttribute('data-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.workspace; + opt.textContent = ws.title; + if (ws.workspace === 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(); + } +})(); From 0ec2e1a2a9b2f4c72da33390970536ba2062738f Mon Sep 17 00:00:00 2001 From: Sebastian Helzle Date: Tue, 16 Jun 2026 16:20:33 +0200 Subject: [PATCH 06/23] TASK: Add editorconfig --- .editorconfig | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .editorconfig 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 From db677ec4da3798c05b617bc43a9a73dbb6b57ce8 Mon Sep 17 00:00:00 2001 From: Sebastian Helzle Date: Fri, 19 Jun 2026 12:03:33 +0200 Subject: [PATCH 07/23] BUGFIX: Show selection state for content repository and improve layout --- Configuration/Settings.Neos.yaml | 1 + .../ContentTransfer/Actions/Index.fusion | 98 ++++++++++++------- Resources/Public/ContentTransfer.css | 11 +++ Resources/Public/ContentTransferTree.css | 10 +- 4 files changed, 80 insertions(+), 40 deletions(-) create mode 100644 Resources/Public/ContentTransfer.css diff --git a/Configuration/Settings.Neos.yaml b/Configuration/Settings.Neos.yaml index a0e3bdf..46f807c 100644 --- a/Configuration/Settings.Neos.yaml +++ b/Configuration/Settings.Neos.yaml @@ -24,4 +24,5 @@ Neos: '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/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion b/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion index 68a5bdf..f010a3a 100644 --- a/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion +++ b/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion @@ -38,43 +38,26 @@ prototype(Shel.Neos.TransferContent:View.ContentTransfer.index) < prototype(Neos
-
- +
- + - {crId} - - - - - - - - - {crId} @@ -92,10 +75,19 @@ prototype(Shel.Neos.TransferContent:View.ContentTransfer.index) < prototype(Neos
{private.i18n.id('sourceDimension').translate()}
- +
-
- +
+
+
+ + + + + {crId} + + + + +
-
- -
+
- +
-

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

+

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

- + {private.i18n.id('submit').translate()}
diff --git a/Resources/Public/ContentTransfer.css b/Resources/Public/ContentTransfer.css new file mode 100644 index 0000000..c50c6c8 --- /dev/null +++ b/Resources/Public/ContentTransfer.css @@ -0,0 +1,11 @@ +#contentTransferForm { + h5 { + margin-bottom: 1em; + } + + .neos-span6 { + display: flex; + flex-direction: column; + gap: 1em; + } +} diff --git a/Resources/Public/ContentTransferTree.css b/Resources/Public/ContentTransferTree.css index 2d38ec0..79598bb 100644 --- a/Resources/Public/ContentTransferTree.css +++ b/Resources/Public/ContentTransferTree.css @@ -1,4 +1,10 @@ .neos { + #contentTransferForm { + h4 { + margin-bottom: 1em; + } + } + .content-transfer-tree { margin-bottom: 16px; } @@ -68,8 +74,8 @@ } .ct-selection-info { - margin-top: 4px; - font-size: 12px; + margin-top: 1em; + font-size: 80%; color: #adadad; font-style: italic; } From 470080c7c6071bdc88a1a23a61dd347b047013f7 Mon Sep 17 00:00:00 2001 From: Sebastian Helzle Date: Fri, 19 Jun 2026 12:22:05 +0200 Subject: [PATCH 08/23] BUGFIX: Solve issues when calling copy action --- .../Controller/ContentTransferController.php | 27 ++++++++++++------- .../ContentTransfer/Actions/Index.fusion | 8 ++++++ .../Translations/de/ContentTransfer.xlf | 8 ++++++ .../Translations/en/ContentTransfer.xlf | 6 +++++ 4 files changed, 40 insertions(+), 9 deletions(-) diff --git a/Classes/Controller/ContentTransferController.php b/Classes/Controller/ContentTransferController.php index e4158c5..6a81cc4 100644 --- a/Classes/Controller/ContentTransferController.php +++ b/Classes/Controller/ContentTransferController.php @@ -26,8 +26,10 @@ use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; 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\Flow\Security\Context as SecurityContext; use Neos\Fusion\View\FusionView; use Neos\Neos\Controller\Module\AbstractModuleController; @@ -146,9 +148,6 @@ public function treeChildrenAction( $parsedDimValues = json_decode($dimensionValues, true, 512, JSON_THROW_ON_ERROR) ?: []; $dsp = DimensionSpacePoint::fromArray($parsedDimValues); $subgraph = $cr->getContentSubgraph($workspaceName, $dsp); - //\Neos\Flow\var_dump($dsp); - //die('hard'); - $nodeTypeFilter = $this->getNodeTypeFilterForCr($contentRepositoryId); if ($parentNodeId === null) { @@ -190,9 +189,11 @@ public function treeChildrenAction( } /** + * @throws AccessDenied * @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 Exception + * @throws MissingActionNameException */ public function copyNodeAction( string $sourceNodePath = '', @@ -289,16 +290,24 @@ public function copyNodeAction( ); } else { $this->moveNode($sourceCr, $sourceNode, $targetParentNode, $targetWorkspace); + $this->addFlashMessage( + $this->translate('message.moveStarted'), + 'Success', + ); } } else { $this->copyNode($sourceCr, $targetCr, $sourceNode, $targetParentNode, $targetWorkspace); + $this->addFlashMessage( + $this->translate('message.copyStarted'), + 'Success', + ); } $this->redirect('index', null, null, [ - 'sourceContentRepository' => $sourceContentRepository, - 'targetContentRepository' => $targetContentRepository, - 'sourceWorkspace' => $sourceWorkspace, - 'targetWorkspace' => $targetWorkspace, + 'sourceContentRepository' => $sourceContentRepository->value, + 'targetContentRepository' => $targetContentRepository->value, + 'sourceWorkspace' => $sourceWorkspace->value, + 'targetWorkspace' => $targetWorkspace->value, 'sourceDimensionValues' => $sourceDimensionValues, 'targetDimensionValues' => $targetDimensionValues, ]); diff --git a/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion b/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion index f010a3a..0113d29 100644 --- a/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion +++ b/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion @@ -177,6 +177,10 @@ prototype(Shel.Neos.TransferContent:View.ContentTransfer.index) < prototype(Neos data-workspaces={Json.stringify(props.sourceWorkspaces)} data-label={private.i18n.id('sourceDocument').translate()} > + + Cannot move nodes across different content repositories Knoten können nicht zwischen verschiedenen Content-Repositories verschoben werden + + The copy action has been started + Der Kopiervorgang wurde gestartet + The node was copied Der Knoten wurde kopiert + + The move action has been started + Inhalte werden verschoben + The node was moved Der Knoten wurde verschoben diff --git a/Resources/Private/Translations/en/ContentTransfer.xlf b/Resources/Private/Translations/en/ContentTransfer.xlf index 1d5a9c7..1731dd4 100644 --- a/Resources/Private/Translations/en/ContentTransfer.xlf +++ b/Resources/Private/Translations/en/ContentTransfer.xlf @@ -80,9 +80,15 @@ Cannot move nodes across different content repositories + + The copy action has been started + The node was copied + + The move action has been started + The node was moved From 7b7f70b5303f11e4a9acc07a7fbcc31c64ae3735 Mon Sep 17 00:00:00 2001 From: Sebastian Helzle Date: Sun, 21 Jun 2026 16:28:28 +0200 Subject: [PATCH 09/23] BUGFIX: Only update tethered nodes during copy and use correct DSP --- .../Controller/ContentTransferController.php | 91 +++++++++++++------ .../Translations/de/ContentTransfer.xlf | 12 +-- .../Translations/en/ContentTransfer.xlf | 6 -- 3 files changed, 65 insertions(+), 44 deletions(-) diff --git a/Classes/Controller/ContentTransferController.php b/Classes/Controller/ContentTransferController.php index 6a81cc4..6c737b2 100644 --- a/Classes/Controller/ContentTransferController.php +++ b/Classes/Controller/ContentTransferController.php @@ -9,6 +9,7 @@ use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNode; +use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyValuesToWrite; use Neos\ContentRepository\Core\Feature\NodeMove\Command\MoveNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeMove\Dto\RelationDistributionStrategy; @@ -290,16 +291,13 @@ public function copyNodeAction( ); } else { $this->moveNode($sourceCr, $sourceNode, $targetParentNode, $targetWorkspace); - $this->addFlashMessage( - $this->translate('message.moveStarted'), - 'Success', - ); } } else { - $this->copyNode($sourceCr, $targetCr, $sourceNode, $targetParentNode, $targetWorkspace); - $this->addFlashMessage( - $this->translate('message.copyStarted'), - 'Success', + $this->copyNode( + $sourceCr, + $targetCr, + $sourceNode, + $targetParentNode, ); } @@ -346,8 +344,7 @@ private function copyNode( ContentRepository $sourceCr, ContentRepository $targetCr, Node $sourceNode, - Node $targetParentNode, - WorkspaceName $targetWorkspaceName + Node $targetParentNode ): void { try { $sourceSubgraph = $sourceCr->getContentSubgraph( @@ -360,8 +357,8 @@ private function copyNode( sourceSubgraph: $sourceSubgraph, sourceNode: $sourceNode, targetParentNodeAggregateId: $targetParentNode->aggregateId, - targetWorkspaceName: $targetWorkspaceName, - sourceOriginDimensionSpacePoint: $sourceNode->originDimensionSpacePoint, + targetWorkspaceName: $targetParentNode->workspaceName, + sourceOriginDimensionSpacePoint: $targetParentNode->originDimensionSpacePoint, ); $this->addFlashMessage( @@ -377,6 +374,9 @@ private function copyNode( } } + /** + * @throws AccessDenied + */ private function copyNodeRecursive( ContentRepository $contentRepository, ContentSubgraphInterface $sourceSubgraph, @@ -385,23 +385,58 @@ private function copyNodeRecursive( WorkspaceName $targetWorkspaceName, OriginDimensionSpacePoint $sourceOriginDimensionSpacePoint, ): void { - $newNodeAggregateId = NodeAggregateId::create(); + if ($sourceNode->classification->isTethered()) { + $targetSubgraph = $contentRepository->getContentSubgraph( + $targetWorkspaceName, + $sourceOriginDimensionSpacePoint->toDimensionSpacePoint() + ); + $existingNode = $targetSubgraph->findNodeByPath( + $sourceNode->name, + $targetParentNodeAggregateId + ); - $propertyValues = []; - foreach ($sourceNode->properties as $propertyName => $propertyValue) { - $propertyValues[$propertyName] = $propertyValue; - } + if ($existingNode === null) { + return; + } - $contentRepository->handle( - CreateNodeAggregateWithNode::create( - workspaceName: $targetWorkspaceName, - nodeAggregateId: $newNodeAggregateId, - nodeTypeName: $sourceNode->nodeTypeName, - originDimensionSpacePoint: $sourceOriginDimensionSpacePoint, - parentNodeAggregateId: $targetParentNodeAggregateId, - initialPropertyValues: PropertyValuesToWrite::fromArray($propertyValues), - ) - ); + $propertyValues = []; + foreach ($sourceNode->properties as $propertyName => $propertyValue) { + $propertyValues[$propertyName] = $propertyValue; + } + + if ($propertyValues !== []) { + $contentRepository->handle( + SetNodeProperties::create( + workspaceName: $targetWorkspaceName, + nodeAggregateId: $existingNode->aggregateId, + originDimensionSpacePoint: $sourceOriginDimensionSpacePoint, + propertyValues: PropertyValuesToWrite::fromArray($propertyValues), + ) + ); + } + + $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: $sourceOriginDimensionSpacePoint, + parentNodeAggregateId: $targetParentNodeAggregateId, + initialPropertyValues: PropertyValuesToWrite::fromArray($propertyValues), + ) + ); + + $parentNodeAggregateId = $newNodeAggregateId; + } $childNodes = $sourceSubgraph->findChildNodes( $sourceNode->aggregateId, @@ -413,7 +448,7 @@ private function copyNodeRecursive( contentRepository: $contentRepository, sourceSubgraph: $sourceSubgraph, sourceNode: $childNode, - targetParentNodeAggregateId: $newNodeAggregateId, + targetParentNodeAggregateId: $parentNodeAggregateId, targetWorkspaceName: $targetWorkspaceName, sourceOriginDimensionSpacePoint: $sourceOriginDimensionSpacePoint, ); diff --git a/Resources/Private/Translations/de/ContentTransfer.xlf b/Resources/Private/Translations/de/ContentTransfer.xlf index 76b3c33..30353dc 100644 --- a/Resources/Private/Translations/de/ContentTransfer.xlf +++ b/Resources/Private/Translations/de/ContentTransfer.xlf @@ -105,21 +105,13 @@ Cannot move nodes across different content repositories Knoten können nicht zwischen verschiedenen Content-Repositories verschoben werden - - The copy action has been started - Der Kopiervorgang wurde gestartet - The node was copied - Der Knoten wurde kopiert - - - The move action has been started - Inhalte werden verschoben + Der Inhalt wurde kopiert The node was moved - Der Knoten wurde verschoben + Der Inhalt wurde verschoben diff --git a/Resources/Private/Translations/en/ContentTransfer.xlf b/Resources/Private/Translations/en/ContentTransfer.xlf index 1731dd4..1d5a9c7 100644 --- a/Resources/Private/Translations/en/ContentTransfer.xlf +++ b/Resources/Private/Translations/en/ContentTransfer.xlf @@ -80,15 +80,9 @@ Cannot move nodes across different content repositories - - The copy action has been started - The node was copied - - The move action has been started - The node was moved From a71cd0d06face04037d592e3a1df584f044daf97 Mon Sep 17 00:00:00 2001 From: Sebastian Helzle Date: Sun, 21 Jun 2026 16:42:03 +0200 Subject: [PATCH 10/23] TASK: Render flash messages via js --- .../Controller/ContentTransferController.php | 11 +++++- .../Fusion/Common/FlashMessages.fusion | 36 ------------------- .../ContentTransfer/Actions/Index.fusion | 6 ++-- Resources/Public/ContentTransfer.js | 13 +++++++ 4 files changed, 27 insertions(+), 39 deletions(-) delete mode 100644 Resources/Private/Fusion/Common/FlashMessages.fusion diff --git a/Classes/Controller/ContentTransferController.php b/Classes/Controller/ContentTransferController.php index 6c737b2..b90a885 100644 --- a/Classes/Controller/ContentTransferController.php +++ b/Classes/Controller/ContentTransferController.php @@ -118,6 +118,15 @@ public function indexAction( } } + $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([ 'contentRepositoryIds' => $contentRepositoryIds, 'sourceContentRepository' => $sourceContentRepository, @@ -131,7 +140,7 @@ public function indexAction( 'sourceDimensionValues' => $parsedSourceDimValues, 'targetDimensionValues' => $parsedTargetDimValues, 'allowNodeMoving' => $this->settings['allowNodeMoving'], - 'flashMessages' => $this->controllerContext->getFlashMessageContainer()->getMessagesAndFlush(), + 'flashMessagesData' => $flashMessagesData, ]); } diff --git a/Resources/Private/Fusion/Common/FlashMessages.fusion b/Resources/Private/Fusion/Common/FlashMessages.fusion deleted file mode 100644 index 1e8bfd4..0000000 --- a/Resources/Private/Fusion/Common/FlashMessages.fusion +++ /dev/null @@ -1,36 +0,0 @@ -prototype(Shel.Neos.TransferContent:Component.FlashMessages) < prototype(Neos.Fusion:Component) { - flashMessages = ${[]} - - renderer = afx` -
- - - -
- ` -} - -prototype(Shel.Neos.TransferContent:Component.FlashMessages.Message) < prototype(Neos.Fusion:Component) { - message = ${{}} - - severity = ${String.toLowerCase(this.message.severity)} - severity.@process.replaceOKStatus = ${value == 'ok' ? 'success' : value} - severity.@process.replaceNoticeStatus = ${value == 'notice' ? 'info' : value} - - renderer = afx` -
-
- -
- {props.message.title || props.message.message} -
-
- {props.message.message} -
-
-
- ` -} diff --git a/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion b/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion index 0113d29..2b651e4 100644 --- a/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion +++ b/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion @@ -11,7 +11,7 @@ prototype(Shel.Neos.TransferContent:View.ContentTransfer.index) < prototype(Neos sourceDimensionValues = ${sourceDimensionValues} targetDimensionValues = ${targetDimensionValues} allowNodeMoving = ${allowNodeMoving} - flashMessages = ${flashMessages} + flashMessagesData = ${flashMessagesData} prototype(Neos.Fusion.Form:LabelRenderer) { translationPackage = 'Shel.Neos.TransferContent' @@ -34,7 +34,9 @@ prototype(Shel.Neos.TransferContent:View.ContentTransfer.index) < prototype(Neos } renderer = afx` - + Date: Sun, 21 Jun 2026 17:20:32 +0200 Subject: [PATCH 11/23] TASK: Move transfer methods into separate service class --- .../Controller/ContentTransferController.php | 372 +++--------------- Classes/Service/ContentTransferService.php | 292 ++++++++++++++ 2 files changed, 351 insertions(+), 313 deletions(-) create mode 100644 Classes/Service/ContentTransferService.php diff --git a/Classes/Controller/ContentTransferController.php b/Classes/Controller/ContentTransferController.php index b90a885..b2e9290 100644 --- a/Classes/Controller/ContentTransferController.php +++ b/Classes/Controller/ContentTransferController.php @@ -5,40 +5,22 @@ namespace Shel\Neos\TransferContent\Controller; use GuzzleHttp\Psr7\Response; -use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; -use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; -use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNode; -use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties; -use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyValuesToWrite; -use Neos\ContentRepository\Core\Feature\NodeMove\Command\MoveNodeAggregate; -use Neos\ContentRepository\Core\Feature\NodeMove\Dto\RelationDistributionStrategy; use Neos\ContentRepository\Core\Feature\Security\Exception\AccessDenied; use Neos\ContentRepository\Core\NodeType\NodeTypeName; -use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; -use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\CountChildNodesFilter; -use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindChildNodesFilter; -use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindRootNodeAggregatesFilter; -use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; -use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; -use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; 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\Flow\Security\Context as SecurityContext; use Neos\Fusion\View\FusionView; use Neos\Neos\Controller\Module\AbstractModuleController; -use Neos\Neos\Domain\NodeLabel\NodeLabelGeneratorInterface; -use Neos\Neos\Domain\Service\UserService as DomainUserService; -use Neos\Neos\Domain\Service\WorkspaceService; -use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; use Psr\Http\Message\ResponseInterface; +use Shel\Neos\TransferContent\Service\ContentTransferService; #[Flow\Scope('singleton')] class ContentTransferController extends AbstractModuleController @@ -49,26 +31,11 @@ class ContentTransferController extends AbstractModuleController protected readonly Translator $translator; #[Flow\Inject] - protected readonly DomainUserService $domainUserService; - - #[Flow\Inject] - protected readonly ContentRepositoryRegistry $contentRepositoryRegistry; - - #[Flow\Inject] - protected readonly SecurityContext $securityContext; - - #[Flow\Inject] - protected readonly ContentRepositoryAuthorizationService $authorizationService; - - #[Flow\Inject] - protected readonly WorkspaceService $workspaceService; - - #[Flow\Inject] - protected readonly NodeLabelGeneratorInterface $nodeLabelGenerator; - - #[Flow\InjectConfiguration(path: 'contentRepositories', package: 'Neos.ContentRepositoryRegistry')] - protected array $crSettings = []; + protected readonly ContentTransferService $contentTransferService; + /** + * @throws \JsonException + */ public function indexAction( ?ContentRepositoryId $sourceContentRepository = null, ?ContentRepositoryId $targetContentRepository = null, @@ -77,26 +44,22 @@ public function indexAction( ?string $sourceDimensionValues = '{}', ?string $targetDimensionValues = '{}', ): void { - $contentRepositoryIds = []; - foreach ($this->contentRepositoryRegistry->getContentRepositoryIds() as $crId) { - $contentRepositoryIds[] = $crId->value; - } + $contentRepositoryIds = $this->contentTransferService->getContentRepositoryIds(); $sourceWorkspace = $sourceWorkspace ?? WorkspaceName::forLive(); $targetWorkspace = $targetWorkspace ?? WorkspaceName::forLive(); $sourceContentRepository = $sourceContentRepository ?: ContentRepositoryId::fromString('default'); $targetContentRepository = $targetContentRepository ?: ContentRepositoryId::fromString('default'); - $sourceWorkspaces = $this->getWorkspacesForCr($sourceContentRepository); - $targetWorkspaces = $this->getWorkspacesForCr($targetContentRepository); + $sourceWorkspaces = $this->contentTransferService->getWorkspacesForCr($sourceContentRepository); + $targetWorkspaces = $this->contentTransferService->getWorkspacesForCr($targetContentRepository); - $sourceDimensions = $this->buildDimensionConfig($sourceContentRepository); - $targetDimensions = $this->buildDimensionConfig($targetContentRepository); + $sourceDimensions = $this->contentTransferService->buildDimensionConfig($sourceContentRepository); + $targetDimensions = $this->contentTransferService->buildDimensionConfig($targetContentRepository); $parsedSourceDimValues = json_decode($sourceDimensionValues, true, 512, JSON_THROW_ON_ERROR) ?: []; $parsedTargetDimValues = json_decode($targetDimensionValues, true, 512, JSON_THROW_ON_ERROR) ?: []; - // Always merge defaults for missing dimensions if (!empty($sourceDimensions)) { foreach ($sourceDimensions as $dim) { if (!array_key_exists($dim['id'], $parsedSourceDimValues)) { @@ -145,7 +108,6 @@ public function indexAction( } /** - * @throws AccessDenied * @throws \JsonException */ public function treeChildrenAction( @@ -154,56 +116,26 @@ public function treeChildrenAction( ?NodeAggregateId $parentNodeId = null, string $dimensionValues = '{}', ): ResponseInterface { - $cr = $this->contentRepositoryRegistry->get($contentRepositoryId); - $parsedDimValues = json_decode($dimensionValues, true, 512, JSON_THROW_ON_ERROR) ?: []; - $dsp = DimensionSpacePoint::fromArray($parsedDimValues); - $subgraph = $cr->getContentSubgraph($workspaceName, $dsp); - $nodeTypeFilter = $this->getNodeTypeFilterForCr($contentRepositoryId); - - if ($parentNodeId === null) { - $rootNodeAggregate = $cr->getContentGraph($workspaceName)->findRootNodeAggregates( - FindRootNodeAggregatesFilter::create() - )->first(); - $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[] = [ - 'nodeAggregateId' => $child->aggregateId->value, - 'label' => $label, - 'nodeType' => $child->nodeTypeName->value, - 'hasChildren' => $hasChildren, - ]; - } + $children = $this->contentTransferService->getTreeChildrenData( + $contentRepositoryId, + $workspaceName, + $parentNodeId, + $dimensionValues, + ); return new Response( 200, ['Content-Type' => 'application/json'], - json_encode(['children' => $result], JSON_THROW_ON_ERROR) + json_encode(['children' => $children], JSON_THROW_ON_ERROR) ); } /** - * @throws AccessDenied + * @throws MissingActionNameException * @throws StopActionException * @throws \JsonException + * @throws AccessDenied * @throws Exception - * @throws MissingActionNameException */ public function copyNodeAction( string $sourceNodePath = '', @@ -219,8 +151,8 @@ public function copyNodeAction( $sourceContentRepository = $sourceContentRepository ?? ContentRepositoryId::fromString('default'); $targetContentRepository = $targetContentRepository ?? ContentRepositoryId::fromString('default'); - $sourceCr = $this->contentRepositoryRegistry->get($sourceContentRepository); - $targetCr = $this->contentRepositoryRegistry->get($targetContentRepository); + $sourceCr = $this->contentTransferService->getContentRepository($sourceContentRepository); + $targetCr = $this->contentTransferService->getContentRepository($targetContentRepository); $sourceWorkspace = $sourceWorkspace ?: WorkspaceName::forLive(); $targetWorkspace = $targetWorkspace ?: WorkspaceName::forLive(); @@ -244,13 +176,11 @@ public function copyNodeAction( NodeAggregateId::fromString($targetParentNodePath) ); - $sourceNodeTypeManager = $sourceCr->getNodeTypeManager(); - $targetNodeTypeManager = $targetCr->getNodeTypeManager(); $sourceNodeType = $sourceNode?->nodeTypeName - ? $sourceNodeTypeManager->getNodeType($sourceNode->nodeTypeName) + ? $sourceCr->getNodeTypeManager()->getNodeType($sourceNode->nodeTypeName) : null; $targetNodeType = $targetParentNode?->nodeTypeName - ? $targetNodeTypeManager->getNodeType($targetParentNode->nodeTypeName) + ? $targetCr->getNodeTypeManager()->getNodeType($targetParentNode->nodeTypeName) : null; if ($sourceNode === null) { @@ -299,15 +229,44 @@ public function copyNodeAction( Message::SEVERITY_ERROR ); } else { - $this->moveNode($sourceCr, $sourceNode, $targetParentNode, $targetWorkspace); + 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 { - $this->copyNode( - $sourceCr, - $targetCr, - $sourceNode, - $targetParentNode, - ); + try { + $this->contentTransferService->copyNode( + $sourceCr, + $targetCr, + $sourceNode, + $targetParentNode, + ); + $this->addFlashMessage( + $this->translate('message.copied'), + 'Success' + ); + } catch (\Exception $e) { + $this->addFlashMessage( + $this->translate('error.copyFailed', [$e->getMessage()]), + 'Error', + Message::SEVERITY_ERROR + ); + } } $this->redirect('index', null, null, [ @@ -320,219 +279,6 @@ public function copyNodeAction( ]); } - private function moveNode( - ContentRepository $contentRepository, - Node $sourceNode, - Node $targetParentNode, - WorkspaceName $targetWorkspaceName - ): void { - try { - $contentRepository->handle( - MoveNodeAggregate::create( - workspaceName: $targetWorkspaceName, - dimensionSpacePoint: $sourceNode->originDimensionSpacePoint->toDimensionSpacePoint(), - nodeAggregateId: $sourceNode->aggregateId, - relationDistributionStrategy: RelationDistributionStrategy::STRATEGY_GATHER_ALL, - newParentNodeAggregateId: $targetParentNode->aggregateId, - ) - ); - $this->addFlashMessage( - $this->translate('message.moved'), - 'Success' - ); - } catch (\Exception $e) { - $this->addFlashMessage( - $this->translate('error.copyFailed', [$e->getMessage()]), - 'Error', - Message::SEVERITY_ERROR - ); - } - } - - private function copyNode( - ContentRepository $sourceCr, - ContentRepository $targetCr, - Node $sourceNode, - Node $targetParentNode - ): void { - try { - $sourceSubgraph = $sourceCr->getContentSubgraph( - $sourceNode->workspaceName, - $sourceNode->dimensionSpacePoint - ); - - $this->copyNodeRecursive( - contentRepository: $targetCr, - sourceSubgraph: $sourceSubgraph, - sourceNode: $sourceNode, - targetParentNodeAggregateId: $targetParentNode->aggregateId, - targetWorkspaceName: $targetParentNode->workspaceName, - sourceOriginDimensionSpacePoint: $targetParentNode->originDimensionSpacePoint, - ); - - $this->addFlashMessage( - $this->translate('message.copied'), - 'Success' - ); - } catch (\Exception $e) { - $this->addFlashMessage( - $this->translate('error.copyFailed', [$e->getMessage()]), - 'Error', - Message::SEVERITY_ERROR - ); - } - } - - /** - * @throws AccessDenied - */ - private function copyNodeRecursive( - ContentRepository $contentRepository, - ContentSubgraphInterface $sourceSubgraph, - Node $sourceNode, - NodeAggregateId $targetParentNodeAggregateId, - WorkspaceName $targetWorkspaceName, - OriginDimensionSpacePoint $sourceOriginDimensionSpacePoint, - ): void { - if ($sourceNode->classification->isTethered()) { - $targetSubgraph = $contentRepository->getContentSubgraph( - $targetWorkspaceName, - $sourceOriginDimensionSpacePoint->toDimensionSpacePoint() - ); - $existingNode = $targetSubgraph->findNodeByPath( - $sourceNode->name, - $targetParentNodeAggregateId - ); - - if ($existingNode === null) { - return; - } - - $propertyValues = []; - foreach ($sourceNode->properties as $propertyName => $propertyValue) { - $propertyValues[$propertyName] = $propertyValue; - } - - if ($propertyValues !== []) { - $contentRepository->handle( - SetNodeProperties::create( - workspaceName: $targetWorkspaceName, - nodeAggregateId: $existingNode->aggregateId, - originDimensionSpacePoint: $sourceOriginDimensionSpacePoint, - propertyValues: PropertyValuesToWrite::fromArray($propertyValues), - ) - ); - } - - $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: $sourceOriginDimensionSpacePoint, - parentNodeAggregateId: $targetParentNodeAggregateId, - initialPropertyValues: PropertyValuesToWrite::fromArray($propertyValues), - ) - ); - - $parentNodeAggregateId = $newNodeAggregateId; - } - - $childNodes = $sourceSubgraph->findChildNodes( - $sourceNode->aggregateId, - FindChildNodesFilter::create() - ); - - foreach ($childNodes as $childNode) { - $this->copyNodeRecursive( - contentRepository: $contentRepository, - sourceSubgraph: $sourceSubgraph, - sourceNode: $childNode, - targetParentNodeAggregateId: $parentNodeAggregateId, - targetWorkspaceName: $targetWorkspaceName, - sourceOriginDimensionSpacePoint: $sourceOriginDimensionSpacePoint, - ); - } - } - - private function buildDimensionConfig(ContentRepositoryId $crId): array - { - $crConfig = $this->crSettings[$crId->value] ?? []; - $contentDimensions = $crConfig['contentDimensions'] ?? []; - - $result = []; - foreach ($contentDimensions as $dimId => $dimConfig) { - if (!is_array($dimConfig)) { - continue; - } - - $values = []; - foreach ($dimConfig['values'] ?? [] as $valId => $valConfig) { - if (!is_array($valConfig)) { - continue; - } - $values[] = [ - 'value' => $valId, - 'label' => $valConfig['label'] ?? $valId, - ]; - } - - $result[] = [ - 'id' => $dimId, - 'label' => $dimConfig['label'] ?? $dimId, - 'values' => $values, - ]; - } - return $result; - } - - private function getNodeTypeFilterForCr(ContentRepositoryId $crId): string - { - $filters = $this->settings['nodeTypeFilters'] ?? ['default' => 'Neos.Neos:Document']; - return $filters[$crId->value] ?? $filters['default'] ?? 'Neos.Neos:Document'; - } - - private function getWorkspacesForCr(ContentRepositoryId $crId): array - { - $contentRepository = $this->contentRepositoryRegistry->get($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[] = [ - 'workspace' => $workspace->workspaceName, - 'title' => $metadata->title->value, - ]; - } - return $result; - } - protected function translate(string $id, array $arguments = []): string { try { diff --git a/Classes/Service/ContentTransferService.php b/Classes/Service/ContentTransferService.php new file mode 100644 index 0000000..937bfbd --- /dev/null +++ b/Classes/Service/ContentTransferService.php @@ -0,0 +1,292 @@ +contentRepositoryRegistry->get($crId); + } + + public function getContentRepositoryIds(): array + { + $ids = []; + foreach ($this->contentRepositoryRegistry->getContentRepositoryIds() as $crId) { + $ids[] = $crId->value; + } + return $ids; + } + + 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[] = [ + 'workspace' => $workspace->workspaceName, + 'title' => $metadata->title->value, + ]; + } + return $result; + } + + public function buildDimensionConfig(ContentRepositoryId $crId): array + { + $crConfig = $this->crSettings[$crId->value] ?? []; + $contentDimensions = $crConfig['contentDimensions'] ?? []; + + $result = []; + foreach ($contentDimensions as $dimId => $dimConfig) { + if (!is_array($dimConfig)) { + continue; + } + + $values = []; + foreach ($dimConfig['values'] ?? [] as $valId => $valConfig) { + if (!is_array($valConfig)) { + continue; + } + $values[] = [ + 'value' => $valId, + 'label' => $valConfig['label'] ?? $valId, + ]; + } + + $result[] = [ + 'id' => $dimId, + 'label' => $dimConfig['label'] ?? $dimId, + 'values' => $values, + ]; + } + return $result; + } + + public function getNodeTypeFilterForCr(ContentRepositoryId $crId): string + { + $filters = $this->crSettings['nodeTypeFilters'] ?? ['default' => 'Neos.Neos:Document']; + return $filters[$crId->value] ?? $filters['default'] ?? 'Neos.Neos:Document'; + } + + public function getTreeChildrenData( + ContentRepositoryId $contentRepositoryId, + WorkspaceName $workspaceName, + ?NodeAggregateId $parentNodeId, + string $dimensionValues, + ): array { + $cr = $this->getContentRepository($contentRepositoryId); + $parsedDimValues = json_decode($dimensionValues, true, 512, JSON_THROW_ON_ERROR) ?: []; + $dsp = DimensionSpacePoint::fromArray($parsedDimValues); + $subgraph = $cr->getContentSubgraph($workspaceName, $dsp); + $nodeTypeFilter = $this->getNodeTypeFilterForCr($contentRepositoryId); + + if ($parentNodeId === null) { + $rootNodeAggregate = $cr->getContentGraph($workspaceName)->findRootNodeAggregates( + FindRootNodeAggregatesFilter::create() + )->first(); + $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[] = [ + 'nodeAggregateId' => $child->aggregateId->value, + 'label' => $label, + 'nodeType' => $child->nodeTypeName->value, + '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, + ): void { + $sourceSubgraph = $sourceCr->getContentSubgraph( + $sourceNode->workspaceName, + $sourceNode->dimensionSpacePoint + ); + + $this->copyNodeRecursive( + contentRepository: $targetCr, + sourceSubgraph: $sourceSubgraph, + sourceNode: $sourceNode, + targetParentNodeAggregateId: $targetParentNode->aggregateId, + targetWorkspaceName: $targetParentNode->workspaceName, + sourceOriginDimensionSpacePoint: $targetParentNode->originDimensionSpacePoint, + ); + } + + private function copyNodeRecursive( + ContentRepository $contentRepository, + ContentSubgraphInterface $sourceSubgraph, + Node $sourceNode, + NodeAggregateId $targetParentNodeAggregateId, + WorkspaceName $targetWorkspaceName, + OriginDimensionSpacePoint $sourceOriginDimensionSpacePoint, + ): void { + if ($sourceNode->classification->isTethered()) { + $targetSubgraph = $contentRepository->getContentSubgraph( + $targetWorkspaceName, + $sourceOriginDimensionSpacePoint->toDimensionSpacePoint() + ); + $existingNode = $targetSubgraph->findNodeByPath( + $sourceNode->name, + $targetParentNodeAggregateId + ); + + if ($existingNode === null) { + return; + } + + $propertyValues = []; + foreach ($sourceNode->properties as $propertyName => $propertyValue) { + $propertyValues[$propertyName] = $propertyValue; + } + + if ($propertyValues !== []) { + $contentRepository->handle( + SetNodeProperties::create( + workspaceName: $targetWorkspaceName, + nodeAggregateId: $existingNode->aggregateId, + originDimensionSpacePoint: $sourceOriginDimensionSpacePoint, + propertyValues: PropertyValuesToWrite::fromArray($propertyValues), + ) + ); + } + + $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: $sourceOriginDimensionSpacePoint, + parentNodeAggregateId: $targetParentNodeAggregateId, + initialPropertyValues: PropertyValuesToWrite::fromArray($propertyValues), + ) + ); + + $parentNodeAggregateId = $newNodeAggregateId; + } + + $childNodes = $sourceSubgraph->findChildNodes( + $sourceNode->aggregateId, + FindChildNodesFilter::create() + ); + + foreach ($childNodes as $childNode) { + $this->copyNodeRecursive( + contentRepository: $contentRepository, + sourceSubgraph: $sourceSubgraph, + sourceNode: $childNode, + targetParentNodeAggregateId: $parentNodeAggregateId, + targetWorkspaceName: $targetWorkspaceName, + sourceOriginDimensionSpacePoint: $sourceOriginDimensionSpacePoint, + ); + } + } +} From 57dae05adcfe6166539ce0c5bbd7c9d4cd892048 Mon Sep 17 00:00:00 2001 From: Sebastian Helzle Date: Sun, 21 Jun 2026 17:22:03 +0200 Subject: [PATCH 12/23] TASK: Adjust css selector for selection form --- .../Fusion/Features/ContentTransfer/Actions/Index.fusion | 1 + Resources/Public/ContentTransfer.css | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion b/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion index 2b651e4..7a77754 100644 --- a/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion +++ b/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion @@ -40,6 +40,7 @@ prototype(Shel.Neos.TransferContent:View.ContentTransfer.index) < prototype(Neos
diff --git a/Resources/Public/ContentTransfer.css b/Resources/Public/ContentTransfer.css index c50c6c8..390fac3 100644 --- a/Resources/Public/ContentTransfer.css +++ b/Resources/Public/ContentTransfer.css @@ -1,4 +1,4 @@ -#contentTransferForm { +#selectionForm { h5 { margin-bottom: 1em; } From 9e1d874d890898081dedd198ae607f58b90150b3 Mon Sep 17 00:00:00 2001 From: Sebastian Helzle Date: Sun, 21 Jun 2026 17:29:40 +0200 Subject: [PATCH 13/23] FEATURE: Show dimension specializations in dropdown --- Classes/Service/ContentTransferService.php | 27 ++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/Classes/Service/ContentTransferService.php b/Classes/Service/ContentTransferService.php index 937bfbd..df4a730 100644 --- a/Classes/Service/ContentTransferService.php +++ b/Classes/Service/ContentTransferService.php @@ -107,10 +107,7 @@ public function buildDimensionConfig(ContentRepositoryId $crId): array if (!is_array($valConfig)) { continue; } - $values[] = [ - 'value' => $valId, - 'label' => $valConfig['label'] ?? $valId, - ]; + array_push($values, ...$this->flattenDimensionValues($valId, $valConfig)); } $result[] = [ @@ -122,6 +119,28 @@ public function buildDimensionConfig(ContentRepositoryId $crId): array return $result; } + private function flattenDimensionValues(string $valueId, array $config, string $breadcrumb = ''): array + { + $label = $config['label'] ?? $valueId; + $fullLabel = $breadcrumb !== '' ? $breadcrumb . ' → ' . $label : $label; + + $result = [ + [ + 'value' => $valueId, + 'label' => $fullLabel, + ], + ]; + + foreach ($config['specializations'] ?? [] as $specId => $specConfig) { + if (!is_array($specConfig)) { + continue; + } + array_push($result, ...$this->flattenDimensionValues($specId, $specConfig, $fullLabel)); + } + + return $result; + } + public function getNodeTypeFilterForCr(ContentRepositoryId $crId): string { $filters = $this->crSettings['nodeTypeFilters'] ?? ['default' => 'Neos.Neos:Document']; From 3fa874368448752112681a2438497bf75e7b475e Mon Sep 17 00:00:00 2001 From: Sebastian Helzle Date: Sun, 21 Jun 2026 18:49:47 +0200 Subject: [PATCH 14/23] TASK: Improve static typing --- .../Controller/ContentTransferController.php | 19 +- Classes/Dto/CopyResult.php | 14 ++ Classes/Dto/DimensionConfigDto.php | 18 ++ Classes/Dto/DimensionValueDto.php | 14 ++ Classes/Dto/TreeNodeDto.php | 19 ++ Classes/Dto/WorkspaceDto.php | 16 ++ Classes/Service/ContentTransferService.php | 180 ++++++++++++++---- .../ContentTransfer/Actions/Index.fusion | 3 +- .../Translations/de/ContentTransfer.xlf | 4 +- .../Translations/en/ContentTransfer.xlf | 2 +- Resources/Public/ContentTransferTree.js | 18 +- 11 files changed, 253 insertions(+), 54 deletions(-) create mode 100644 Classes/Dto/CopyResult.php create mode 100644 Classes/Dto/DimensionConfigDto.php create mode 100644 Classes/Dto/DimensionValueDto.php create mode 100644 Classes/Dto/TreeNodeDto.php create mode 100644 Classes/Dto/WorkspaceDto.php diff --git a/Classes/Controller/ContentTransferController.php b/Classes/Controller/ContentTransferController.php index b2e9290..193f3c7 100644 --- a/Classes/Controller/ContentTransferController.php +++ b/Classes/Controller/ContentTransferController.php @@ -62,20 +62,20 @@ public function indexAction( if (!empty($sourceDimensions)) { foreach ($sourceDimensions as $dim) { - if (!array_key_exists($dim['id'], $parsedSourceDimValues)) { - $firstValue = $dim['values'][0]['value'] ?? null; + if (!array_key_exists($dim->id, $parsedSourceDimValues)) { + $firstValue = $dim->values[0]->value ?? null; if ($firstValue !== null) { - $parsedSourceDimValues[$dim['id']] = $firstValue; + $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 (!array_key_exists($dim->id, $parsedTargetDimValues)) { + $firstValue = $dim->values[0]->value ?? null; if ($firstValue !== null) { - $parsedTargetDimValues[$dim['id']] = $firstValue; + $parsedTargetDimValues[$dim->id] = $firstValue; } } } @@ -250,14 +250,17 @@ public function copyNodeAction( } } else { try { - $this->contentTransferService->copyNode( + $result = $this->contentTransferService->copyNode( $sourceCr, $targetCr, $sourceNode, $targetParentNode, ); $this->addFlashMessage( - $this->translate('message.copied'), + $this->translate('message.copied', [ + (string)$result->nodeCount, + (string)$result->variantCount, + ]), 'Success' ); } catch (\Exception $e) { 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 @@ +contentRepositoryRegistry->get($crId); } + /** + * @return list + */ public function getContentRepositoryIds(): array { $ids = []; @@ -59,6 +68,9 @@ public function getContentRepositoryIds(): array return $ids; } + /** + * @return list + */ public function getWorkspacesForCr(ContentRepositoryId $crId): array { $contentRepository = $this->getContentRepository($crId); @@ -83,14 +95,17 @@ public function getWorkspacesForCr(ContentRepositoryId $crId): array $crId, $workspace->workspaceName ); - $result[] = [ - 'workspace' => $workspace->workspaceName, - 'title' => $metadata->title->value, - ]; + $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] ?? []; @@ -110,25 +125,28 @@ public function buildDimensionConfig(ContentRepositoryId $crId): array array_push($values, ...$this->flattenDimensionValues($valId, $valConfig)); } - $result[] = [ - 'id' => $dimId, - 'label' => $dimConfig['label'] ?? $dimId, - 'values' => $values, - ]; + $result[] = new DimensionConfigDto( + id: $dimId, + label: $dimConfig['label'] ?? $dimId, + values: $values, + ); } return $result; } + /** + * @return list + */ private function flattenDimensionValues(string $valueId, array $config, string $breadcrumb = ''): array { $label = $config['label'] ?? $valueId; $fullLabel = $breadcrumb !== '' ? $breadcrumb . ' → ' . $label : $label; $result = [ - [ - 'value' => $valueId, - 'label' => $fullLabel, - ], + new DimensionValueDto( + value: $valueId, + label: $fullLabel, + ), ]; foreach ($config['specializations'] ?? [] as $specId => $specConfig) { @@ -141,12 +159,72 @@ private function flattenDimensionValues(string $valueId, array $config, string $ return $result; } + /** + * @return array> + */ + private function getDimensionValuesMap(ContentRepositoryId $crId): array + { + $crConfig = $this->crSettings[$crId->value] ?? []; + $contentDimensions = $crConfig['contentDimensions'] ?? []; + + $map = []; + foreach ($contentDimensions as $dimId => $dimConfig) { + if (!is_array($dimConfig)) { + continue; + } + $values = []; + $this->collectDimensionValues($dimConfig['values'] ?? [], $values); + $map[$dimId] = $values; + } + return $map; + } + + private function collectDimensionValues(array $valuesConfig, array &$values): void + { + foreach ($valuesConfig as $valId => $valConfig) { + if (!is_array($valConfig)) { + continue; + } + $values[] = $valId; + $this->collectDimensionValues($valConfig['specializations'] ?? [], $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']; return $filters[$crId->value] ?? $filters['default'] ?? 'Neos.Neos:Document'; } + /** + * @return list + */ public function getTreeChildrenData( ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, @@ -182,12 +260,12 @@ public function getTreeChildrenData( CountChildNodesFilter::create(nodeTypes: $nodeTypeFilter) ) > 0; - $result[] = [ - 'nodeAggregateId' => $child->aggregateId->value, - 'label' => $label, - 'nodeType' => $child->nodeTypeName->value, - 'hasChildren' => $hasChildren, - ]; + $result[] = new TreeNodeDto( + nodeAggregateId: $child->aggregateId, + label: $label, + nodeTypeName: $child->nodeTypeName, + hasChildren: $hasChildren, + ); } return $result; @@ -215,19 +293,49 @@ public function copyNode( ContentRepository $targetCr, Node $sourceNode, Node $targetParentNode, - ): void { - $sourceSubgraph = $sourceCr->getContentSubgraph( - $sourceNode->workspaceName, - $sourceNode->dimensionSpacePoint + ): CopyResult { + $sourceContentGraph = $sourceCr->getContentGraph($sourceNode->workspaceName); + $sourceAggregate = $sourceContentGraph->findNodeAggregateById($sourceNode->aggregateId); + + if ($sourceAggregate === null) { + return new CopyResult(nodeCount: 0, variantCount: 0); + } + + $compatibleODSPs = $this->filterCompatibleOriginDimensionSpacePoints( + $sourceAggregate->occupiedDimensionSpacePoints, + $targetCr, ); - $this->copyNodeRecursive( - contentRepository: $targetCr, - sourceSubgraph: $sourceSubgraph, - sourceNode: $sourceNode, - targetParentNodeAggregateId: $targetParentNode->aggregateId, - targetWorkspaceName: $targetParentNode->workspaceName, - sourceOriginDimensionSpacePoint: $targetParentNode->originDimensionSpacePoint, + $nodeCount = 0; + foreach ($compatibleODSPs as $odsp) { + $dsp = $odsp->toDimensionSpacePoint(); + $sourceVariantSubgraph = $sourceCr->getContentSubgraph($sourceNode->workspaceName, $dsp); + $sourceVariant = $sourceVariantSubgraph->findNodeById($sourceNode->aggregateId); + + if ($sourceVariant === null) { + continue; + } + + $targetVariantSubgraph = $targetCr->getContentSubgraph($targetParentNode->workspaceName, $dsp); + $targetParentVariant = $targetVariantSubgraph->findNodeById($targetParentNode->aggregateId); + + if ($targetParentVariant === null) { + continue; + } + + $nodeCount += $this->copyNodeRecursive( + contentRepository: $targetCr, + sourceSubgraph: $sourceVariantSubgraph, + sourceNode: $sourceVariant, + targetParentNodeAggregateId: $targetParentVariant->aggregateId, + targetWorkspaceName: $targetParentNode->workspaceName, + sourceOriginDimensionSpacePoint: $targetParentVariant->originDimensionSpacePoint, + ); + } + + return new CopyResult( + nodeCount: $nodeCount, + variantCount: count($compatibleODSPs), ); } @@ -238,7 +346,9 @@ private function copyNodeRecursive( NodeAggregateId $targetParentNodeAggregateId, WorkspaceName $targetWorkspaceName, OriginDimensionSpacePoint $sourceOriginDimensionSpacePoint, - ): void { + ): int { + $count = 0; + if ($sourceNode->classification->isTethered()) { $targetSubgraph = $contentRepository->getContentSubgraph( $targetWorkspaceName, @@ -250,7 +360,7 @@ private function copyNodeRecursive( ); if ($existingNode === null) { - return; + return 0; } $propertyValues = []; @@ -292,13 +402,15 @@ private function copyNodeRecursive( $parentNodeAggregateId = $newNodeAggregateId; } + $count++; + $childNodes = $sourceSubgraph->findChildNodes( $sourceNode->aggregateId, FindChildNodesFilter::create() ); foreach ($childNodes as $childNode) { - $this->copyNodeRecursive( + $count += $this->copyNodeRecursive( contentRepository: $contentRepository, sourceSubgraph: $sourceSubgraph, sourceNode: $childNode, @@ -307,5 +419,7 @@ private function copyNodeRecursive( sourceOriginDimensionSpacePoint: $sourceOriginDimensionSpacePoint, ); } + + return $count; } } diff --git a/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion b/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion index 7a77754..6d28691 100644 --- a/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion +++ b/Resources/Private/Fusion/Features/ContentTransfer/Actions/Index.fusion @@ -137,10 +137,11 @@ prototype(Shel.Neos.TransferContent:View.ContentTransfer.index) < prototype(Neos
{private.i18n.id('targetDimension').translate()}
- +