diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..cf9fc11 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,39 @@ +name: build + +on: + push: + branches: + - 'main' + pull_request: ~ + +jobs: + test: + name: "Test (PHP ${{ matrix.php-versions }}, Neos ${{ matrix.neos-versions }})" + + strategy: + fail-fast: false + matrix: + php-versions: ['8.2'] + neos-versions: ['9.0'] + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + path: ${{ env.FLOW_FOLDER }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: mbstring, xml, json, zlib, iconv, intl, pdo_sqlite + ini-values: date.timezone="Africa/Tunis", opcache.fast_shutdown=0, apc.enable_cli=on + + # disabled as long as we have no official 9.0 version + #- name: Set Neos Version + # run: composer require neos/neos ^${{ matrix.neos-versions }} --no-progress --no-interaction + + - name: Run Tests + run: composer test diff --git a/.gitignore b/.gitignore index 6bcdb04..42f4f49 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ Resources/Private/Scripts/TaxonomyEditor/node_modules +composer.lock +Packages +vendor diff --git a/Classes/Command/TaxonomyCommandController.php b/Classes/Command/TaxonomyCommandController.php index 201cbed..cfd197c 100644 --- a/Classes/Command/TaxonomyCommandController.php +++ b/Classes/Command/TaxonomyCommandController.php @@ -1,15 +1,27 @@ + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ + +declare(strict_types=1); + namespace Sitegeist\Taxonomy\Command; +use Neos\ContentRepository\Core\Projection\ContentGraph\Node; +use Neos\ContentRepository\Core\Projection\ContentGraph\NodePath; +use Neos\ContentRepository\Core\Projection\ContentGraph\Subtree; use Neos\Flow\Annotations as Flow; use Neos\Flow\Cli\CommandController; -use Neos\ContentRepository\Domain\Model\NodeInterface; -use Neos\ContentRepository\Domain\Service\ImportExport\NodeExportService; -use Neos\ContentRepository\Domain\Service\ImportExport\NodeImportService; -use Neos\ContentRepository\Domain\Repository\NodeDataRepository; -use Neos\Eel\FlowQuery\FlowQuery; -use Neos\Flow\Persistence\PersistenceManagerInterface; -use Sitegeist\Taxonomy\Service\DimensionService; +use Neos\Flow\Cli\Exception\StopCommandException; +use Neos\Flow\Mvc\Exception\StopActionException; use Sitegeist\Taxonomy\Service\TaxonomyService; /** @@ -17,31 +29,6 @@ */ class TaxonomyCommandController extends CommandController { - - /** - * @var array - * @Flow\InjectConfiguration - */ - protected $configuration; - - /** - * @Flow\Inject - * @var NodeImportService - */ - protected $nodeImportService; - - /** - * @var NodeExportService - * @Flow\Inject - */ - protected $nodeExportService; - - /** - * @var NodeDataRepository - * @Flow\Inject - */ - protected $nodeDataRepository; - /** * @var TaxonomyService * @Flow\Inject @@ -49,237 +36,68 @@ class TaxonomyCommandController extends CommandController protected $taxonomyService; /** - * @var DimensionService - * @Flow\Inject - */ - protected $dimensionService; - - /** - * @var PersistenceManagerInterface - * @Flow\Inject + * List all vocabularies */ - protected $persistenceManager; - - /** - * List taxonomy vocabularies - * - * @param string $vocabularyNode vocabularay nodename(path) to prune (globbing is supported) - * @return void - */ - public function listCommand() + public function vocabulariesCommand(): void { - $taxonomyRoot = $this->taxonomyService->getRoot(); - - /** - * @var NodeInterface[] $vocabularyNodes - */ - $vocabularyNodes = (new FlowQuery([$taxonomyRoot])) - ->children('[instanceof ' . $this->taxonomyService->getVocabularyNodeType() . ' ]') - ->get(); - - /** - * @var NodeInterface $vocabularyNode - */ - foreach ($vocabularyNodes as $vocabularyNode) { - $this->outputLine($vocabularyNode->getName()); - } + $subgraph = $this->taxonomyService->getDefaultSubgraph(); + $vocabularies = $this->taxonomyService->findAllVocabularies($subgraph); + $this->output->outputTable( + array_map( + fn(Node $node) => [ + $node->nodeName?->value ?? $node->nodeAggregateId->value, + $node->getProperty('title'), + $node->getProperty('description') + ], + iterator_to_array($vocabularies->getIterator()) + ), + ['name', 'title', 'description'] + ); } /** - * Import taxonomy content + * List taxonomies inside a vocabulary * - * @param string $filename relative path and filename to the XML file to read. - * @param string $vocabularyNode vocabularay nodename(path) to import (globbing is supported) - * @return void + * @param string $vocabulary name of the vocabulary to access + * @param string $path path to the taxonomy starting at the vocabulary */ - public function importCommand($filename, $vocabulary = null) + public function taxonomiesCommand(string $vocabulary, string $path = ''): void { - $xmlReader = new \XMLReader(); - $xmlReader->open($filename, null, LIBXML_PARSEHUGE); + $subgraph = $this->taxonomyService->getDefaultSubgraph(); - $taxonomyRoot = $this->taxonomyService->getRoot(); - - while ($xmlReader->read()) { - if ($xmlReader->nodeType != \XMLReader::ELEMENT || $xmlReader->name !== 'vocabulary') { - continue; - } - - $vocabularyName = (string) $xmlReader->getAttribute('name'); - if (is_string($vocabulary) && fnmatch($vocabulary, $vocabularyName) == false) { - continue; - } - - $this->nodeImportService->import($xmlReader, $taxonomyRoot->getPath()); - $this->outputLine('Imported vocabulary %s from file %s', [$vocabularyName, $filename]); + if ($path) { + $startPoint = $this->taxonomyService->findTaxonomyByVocabularyNameAndPath($subgraph, $vocabulary, $path); + } else { + $startPoint = $this->taxonomyService->findVocabularyByName($subgraph, $vocabulary); } - } - - /** - * Export taxonomy content - * - * @param string $filename filename for the xml that is written. - * @param string $vocabularyNode vocabularay nodename(path) to export (globbing is supported) - * @return void - */ - public function exportCommand($filename, $vocabulary = null) - { - $xmlWriter = new \XMLWriter(); - $xmlWriter->openUri($filename); - $xmlWriter->setIndent(true); - - $xmlWriter->startDocument('1.0', 'UTF-8'); - $xmlWriter->startElement('root'); - - $taxonomyRoot = $this->taxonomyService->getRoot(); - /** - * @var NodeInterface[] $vocabularyNodes - */ - $vocabularyNodes = (new FlowQuery([$taxonomyRoot])) - ->children('[instanceof ' . $this->taxonomyService->getVocabularyNodeType() . ' ]') - ->get(); - - /** - * @var NodeInterface $vocabularyNode - */ - foreach ($vocabularyNodes as $vocabularyNode) { - $vocabularyName = $vocabularyNode->getName(); - if (is_string($vocabulary) && fnmatch($vocabulary, $vocabularyName) == false) { - continue; - } - $xmlWriter->startElement('vocabulary'); - $xmlWriter->writeAttribute('name', $vocabularyName); - $this->nodeExportService->export($vocabularyNode->getPath(), 'live', $xmlWriter, false, false); - $this->outputLine('Exported vocabulary %s to file %s', [$vocabularyName, $filename]); - $xmlWriter->endElement(); - } - - $xmlWriter->endElement(); - $xmlWriter->endDocument(); - - $xmlWriter->flush(); - } - - /** - * Prune taxonomy content - * - * @param string $vocabularyNode vocabularay nodename(path) to prune (globbing is supported) - * @return void - */ - public function pruneCommand($vocabulary) - { - $taxonomyRoot = $this->taxonomyService->getRoot(); - - /** - * @var NodeInterface[] $vocabularyNodes - */ - $vocabularyNodes = (new FlowQuery([$taxonomyRoot])) - ->children('[instanceof ' . $this->taxonomyService->getVocabularyNodeType() . ' ]') - ->get(); - - /** - * @var NodeInterface $vocabularyNode - */ - foreach ($vocabularyNodes as $vocabularyNode) { - $vocabularyName = $vocabularyNode->getName(); - if (is_string($vocabulary) && fnmatch($vocabulary, $vocabularyName) == false) { - continue; - } - $this->nodeDataRepository->removeAllInPath($vocabularyNode->getPath()); - $dimensionNodes = $this->nodeDataRepository->findByPath($vocabularyNode->getPath()); - foreach ($dimensionNodes as $node) { - $this->nodeDataRepository->remove($node); - } - - $this->outputLine('Pruned vocabulary %s', [$vocabularyName]); - } - } - - /** - * Reset a taxonimy dimension and create fresh variants from the base dimension - * - * @param string $dimensionName - * @param string $dimensionValue - * @return void - */ - public function pruneDimensionCommand($dimensionName, $dimensionValue) - { - $taxonomyRoot = $this->taxonomyService->getRoot(); - $targetSubgraph = $this->dimensionService->getDimensionSubgraphByTargetValues([$dimensionName => $dimensionValue]); - - if (!$targetSubgraph) { - $this->outputLine('Target subgraph not found'); + if (!$startPoint) { + $this->outputLine('nothing found'); $this->quit(1); } - $targetContextValues = $this->dimensionService->getDimensionValuesForSubgraph($targetSubgraph); - $flowQuery = new FlowQuery([$taxonomyRoot]); - $taxonomyRootInTargetContest = $flowQuery->context($targetContextValues)->get(0); + $subtree = $this->taxonomyService->findSubtree($startPoint); - if (!$taxonomyRootInTargetContest) { - $this->outputLine('Not root in target context found'); - $this->quit(1); + if ($subtree) { + $this->output->outputTable( + $this->subtreeToTableRowsRecursively($subtree), + ['name', 'title', 'description'] + ); } - - if ($taxonomyRootInTargetContest == $taxonomyRoot) { - $this->outputLine('The root is the default context and cannot be pruned'); - $this->quit(1); - } - - $this->outputLine('Removing content all below ' . $taxonomyRootInTargetContest->getContextPath()); - $flowQuery = new FlowQuery([$taxonomyRootInTargetContest]); - $allNodes = $flowQuery->find('[instanceof ' . $this->taxonomyService->getVocabularyNodeType() . '],[instanceof ' . $this->taxonomyService->getTaxonomyNodeType() . ']')->get(); - foreach ($allNodes as $node) { - $this->outputLine(' - remove: ' . $node->getContextPath()); - $node->remove(); - } - $this->outputLine('Done'); } /** - * Make sure all values from default are present in the target dimension aswell - * - * @param string $dimensionName - * @param string $dimensionValue - * @return void + * @return array> */ - public function populateDimensionCommand($dimensionName, $dimensionValue) + private function subtreeToTableRowsRecursively(Subtree $subtree): array { - $taxonomyRoot = $this->taxonomyService->getRoot(); - $targetSubgraph = $this->dimensionService->getDimensionSubgraphByTargetValues([$dimensionName => $dimensionValue]); - - if (!$targetSubgraph) { - $this->outputLine('Target subgraph not found'); - $this->quit(1); - } - - $targetContextValues = $this->dimensionService->getDimensionValuesForSubgraph($targetSubgraph); - $flowQuery = new FlowQuery([$taxonomyRoot]); - $taxonomyRootInTargetContest = $flowQuery->context($targetContextValues)->get(0); - - if (!$taxonomyRootInTargetContest) { - $this->outputLine('Not root in target context found'); - $this->quit(1); - } - - if ($taxonomyRootInTargetContest == $taxonomyRoot) { - $this->outputLine('The root is the default context and cannot be recreated'); - $this->quit(1); - } - - if ($taxonomyRootInTargetContest == $taxonomyRoot) { - $this->outputLine('The root is the default context and cannot be recreated'); - $this->quit(1); - } - - $this->outputLine('Populating taxonomy content from default below' . $taxonomyRootInTargetContest->getContextPath()); - $targetContext = $taxonomyRootInTargetContest->getContext(); - $flowQuery = new FlowQuery([$taxonomyRoot]); - $allNodes = $flowQuery->find('[instanceof ' . $this->taxonomyService->getVocabularyNodeType() . '],[instanceof ' . $this->taxonomyService->getTaxonomyNodeType() . ']')->get(); - foreach ($allNodes as $node) { - $this->outputLine(' - adopt: ' . $node->getContextPath()); - $targetContext->adoptNode($node); - } - $this->outputLine('Done'); + $rows = array_map(fn(Subtree $subtree)=>$this->subtreeToTableRowsRecursively($subtree), $subtree->children); + $row = [ + str_repeat(' ', $subtree->level) . ($subtree->node->nodeName?->value ?? $subtree->node->nodeAggregateId->value), + (string) $subtree->node->getProperty('title'), + (string) $subtree->node->getProperty('description') + ]; + + return array_merge([$row], ...$rows); } } diff --git a/Classes/Controller/ModuleController.php b/Classes/Controller/ModuleController.php index cbaa8bb..cbbf311 100644 --- a/Classes/Controller/ModuleController.php +++ b/Classes/Controller/ModuleController.php @@ -1,5 +1,4 @@ contentRepository = $this->taxonomyService->getContentRepository(); + $this->nodeAddressFactory = NodeAddressFactory::create($this->contentRepository); + } - /** - * Initialize the view - * - * @param ViewInterface $view - * @return void - */ - public function initializeView(ViewInterface $view) + public function initializeView(ViewInterface $view): void { $fusionPathes = ['resource://Sitegeist.Taxonomy/Private/Fusion/Backend']; - if ($this->additionalFusionIncludePathes && is_array($this->additionalFusionIncludePathes)) { + if (is_array($this->additionalFusionIncludePathes) && !empty($this->additionalFusionIncludePathes)) { $fusionPathes = Arrays::arrayMergeRecursiveOverrule($fusionPathes, $this->additionalFusionIncludePathes); } $this->view->setFusionPathPatterns($fusionPathes); - $this->view->assign('contentDimensionOptions', $this->getContentDimensionOptions()); } /** * Show an overview of available vocabularies - * - * @param NodeInterface $root - * @return void */ - public function indexAction(NodeInterface $root = null) + public function indexAction(string $rootNodeAddress = null): void { - if (!$root) { - $root = $this->taxonomyService->getRoot(); + if (is_null($rootNodeAddress)) { + $subgraph = $this->taxonomyService->getDefaultSubgraph(); + $rootNode = $this->taxonomyService->findOrCreateRoot($subgraph); + } else { + $rootNode = $this->taxonomyService->getNodeByNodeAddress($rootNodeAddress); + $subgraph = $this->taxonomyService->getSubgraphForNode($rootNode); } - $flowQuery = new FlowQuery([$root]); - $vocabularyNodes = $flowQuery->children('[instanceof Sitegeist.Taxonomy:Vocabulary]')->get(); - - // fetch name and base node of vocabulary - $vocabularies = []; - foreach ($vocabularyNodes as $vocabulary) { - $vocabularies[] = [ - 'node' => $vocabulary, - 'defaultNode' => $this->getNodeInDefaultDimensions($vocabulary) - ]; - } - usort($vocabularies, function (array $vocabularyA, array $vocabularyB) { - return strcmp( - $vocabularyA['node']->getProperty('title') ?: '', - $vocabularyB['node']->getProperty('title') ?: '' - ); - }); + $vocabularies = $this->taxonomyService->findAllVocabularies($subgraph); - $this->view->assign('taxonomyRoot', $root); + $this->view->assign('rootNode', $rootNode); + $this->view->assign('rootNodeAddress', $this->nodeAddressFactory->createFromNode($rootNode)->serializeForUri()); $this->view->assign('vocabularies', $vocabularies); } /** * Switch to a modified content context and redirect to the given action * + * @phpstan-param array $dimensions * @param string $targetAction the target action to redirect to * @param string $targetProperty the property in the target action that will accept the node - * @param NodeInterface $contextNode the node to adjust the context for + * @param string $contextNodeAddress the node to adjust the context for * @param array $dimensions array with dimensionName, presetName combinations * @return void */ - public function changeContextAction($targetAction, $targetProperty, NodeInterface $contextNode, $dimensions = []) + public function changeDimensionAction(string $targetAction, string $targetProperty, string $contextNodeAddress, array $dimensions = []) { - $contextProperties = $contextNode->getContext()->getProperties(); - - $newContextProperties = []; - foreach ($dimensions as $dimensionName => $presetName) { - $newContextProperties['dimensions'][$dimensionName] = $this->getContentDimensionValues( - $dimensionName, - $presetName - ); - $newContextProperties['targetDimensions'][$dimensionName] = $presetName; + $contextNode = $this->taxonomyService->getNodeByNodeAddress($contextNodeAddress); + foreach ($dimensions as $dimensionName => $dimensionValue) { + $contextNodeInDimension = $this->dimensionHelper->findVariantInDimension($contextNode, $dimensionName, $dimensionValue); + if ($contextNodeInDimension instanceof Node) { + $contextNode = $contextNodeInDimension; + } } - $modifiedContext = $this->contextFactory->create(array_merge($contextProperties, $newContextProperties)); - - $nodeInModifiedContext = $modifiedContext->getNodeByIdentifier($contextNode->getIdentifier()); - - $this->redirect($targetAction, null, null, [$targetProperty => $nodeInModifiedContext]); + $this->redirect($targetAction, null, null, [$targetProperty => $this->nodeHelper->serializedNodeAddress($contextNode)]); } /** - * Prepare all available content dimensions for use in a select box - * - * @return array the list of available content dimensions and their presets + * Show the given vocabulary */ - protected function getContentDimensionOptions() + public function vocabularyAction(string $vocabularyNodeAddress): void { - $result = []; - - if (is_array($this->contentDimensions) === false || count($this->contentDimensions) === 0) { - return $result; - } - - foreach ($this->contentDimensions as $dimensionName => $dimensionConfiguration) { - $dimensionOption = []; - $dimensionOption['label'] = array_key_exists('label', $dimensionConfiguration) ? - $dimensionConfiguration['label'] : $dimensionName; - $dimensionOption['presets'] = []; - - foreach ($dimensionConfiguration['presets'] as $presetKey => $presetConfiguration) { - $dimensionOption['presets'][$presetKey] = array_key_exists('label', $presetConfiguration) ? - $presetConfiguration['label'] : $presetKey; - } - - $result[$dimensionName] = $dimensionOption; - } - - return $result; + $vocabularyNode = $this->taxonomyService->getNodeByNodeAddress($vocabularyNodeAddress); + $subgraph = $this->taxonomyService->getSubgraphForNode($vocabularyNode); + $rootNode = $this->taxonomyService->findOrCreateRoot($subgraph); + $vocabularySubtree = $this->taxonomyService->findSubtree($vocabularyNode); + + $this->view->assign('rootNode', $rootNode); + $this->view->assign('vocabularyNode', $vocabularyNode); + $this->view->assign('vocabularySubtree', $vocabularySubtree); } /** - * Get the content dimension values for a given content dimension and preset - * - * @param $dimensionName - * @param $presetName - * @return array the values assiged to the preset identified by $dimensionName and $presetName + * Display a form that allows to create a new vocabulary */ - protected function getContentDimensionValues($dimensionName, $presetName) + public function newVocabularyAction(string $rootNodeAddress): void { - return $this->contentDimensions[$dimensionName]['presets'][$presetName]['values']; + $node = $this->taxonomyService->getNodeByNodeAddress($rootNodeAddress); + $this->view->assign('rootNode', $node); } /** - * @param NodeInterface $node - * @return NodeInterface|null + * Create a new vocabulary + * + * @param array $properties */ - protected function getNodeInDefaultDimensions(NodeInterface $node) : ?NodeInterface + public function createVocabularyAction(string $rootNodeAddress, string $name, array $properties): void { - if (!$this->defaultRoot) { - $this->defaultRoot = $this->taxonomyService->getRoot(); - } + $contentRepository = $this->taxonomyService->getContentRepository(); + + $rootNode = $this->taxonomyService->getNodeByNodeAddress($rootNodeAddress); + $subgraph = $this->taxonomyService->getSubgraphForNode($rootNode); + $liveWorkspace = $this->taxonomyService->getLiveWorkspace(); + $generalizations = $contentRepository->getVariationGraph()->getRootGeneralizations(); + $nodeAddress = $this->nodeAddressFactory->createFromUriString($rootNodeAddress); + $originDimensionSpacePoint = OriginDimensionSpacePoint::fromDimensionSpacePoint($nodeAddress->dimensionSpacePoint); + + // create node + $nodeAggregateId = NodeAggregateId::create(); + $nodeTypeName = $this->taxonomyService->getVocabularyNodeTypeName(); + $commandResult = $contentRepository->handle( + CreateNodeAggregateWithNode::create( + $liveWorkspace->currentContentStreamId, + $nodeAggregateId, + $nodeTypeName, + $originDimensionSpacePoint, + $rootNode->nodeAggregateId, + null, + NodeName::transliterateFromString($name), + PropertyValuesToWrite::fromArray($properties) + ) + ); + $commandResult->block(); - $flowQuery = new FlowQuery([$this->defaultRoot]); - $defaultNode = $flowQuery->find('#' . $node->getIdentifier())->get(0); - if ($defaultNode && $defaultNode !== $node) { - return $defaultNode; - } else { - return null; - } - } + // create required generalizations + foreach ($generalizations as $dimensionSpacePoint) { + $originDimensionSpacePoint2 = OriginDimensionSpacePoint::fromDimensionSpacePoint($dimensionSpacePoint); + if ($originDimensionSpacePoint->equals($originDimensionSpacePoint2)) { + continue; + } - /** - * @param NodeInterface $node - * @param array $parents - * @return array - */ - public function fetchChildTaxonomies(NodeInterface $node, array $parents = []) : array - { - $flowQuery = new FlowQuery([$node]); - $childTaxonomies = $flowQuery->children('[instanceof ' . $this->taxonomyService->getTaxonomyNodeType() . ']')->get(); - $result = []; - foreach ($childTaxonomies as $childTaxonomy) { - $result[] = [ - 'node' => $childTaxonomy, - 'defaultNode' => $this->getNodeInDefaultDimensions($childTaxonomy), - 'children' => $this->fetchChildTaxonomies($childTaxonomy, array_merge($parents, [$childTaxonomy])), - 'parents' => $parents - ]; + $contentRepository->handle( + CreateNodeVariant::create( + $liveWorkspace->currentContentStreamId, + $nodeAggregateId, + $originDimensionSpacePoint, + $originDimensionSpacePoint2 + ) + ); } - return $result; - } - /** - * Show the given vocabulary - * - * @param NodeInterface $vocabulary - * @return void - */ - public function vocabularyAction(NodeInterface $vocabulary) - { - $flowQuery = new FlowQuery([$vocabulary]); - $root = $flowQuery->closest('[instanceof ' . $this->taxonomyService->getRootNodeType() . ']')->get(0); - - $this->view->assign('taxonomyRoot', $root); - $this->view->assign('vocabulary', $vocabulary); - $this->view->assign('defaultVocabulary', $this->getNodeInDefaultDimensions($vocabulary)); - $taxonomies = $this->fetchChildTaxonomies($vocabulary); - usort($taxonomies, function (array $taxonomyA, array $taxonomyB) { - return strcmp( - $taxonomyA['node']->getProperty('title') ?: '', - $taxonomyB['node']->getProperty('title') ?: '' + $this->rebaseCurrentUserWorkspace(); + + $newVocabularyNode = $subgraph->findNodeById($nodeAggregateId); + + if ($newVocabularyNode) { + $this->addFlashMessage( + sprintf('Created vocabulary %s', $newVocabularyNode->getLabel()), + 'Create Vocabulary' ); - }); - $this->view->assign('taxonomies', $taxonomies); + } + + $this->redirect('index'); } /** - * Display a form that allows to create a new vocabulary - * - * @param NodeInterface $taxonomyRoot - * @return void + * Show a form that allows to modify the given vocabulary */ - public function newVocabularyAction(NodeInterface $taxonomyRoot) + public function editVocabularyAction(string $vocabularyNodeAddress): void { - $this->view->assign('taxonomyRoot', $taxonomyRoot); + $contentRepository = $this->taxonomyService->getContentRepository(); + $vocabularyNode = $this->taxonomyService->getNodeByNodeAddress($vocabularyNodeAddress); + $subgraph = $contentRepository->getContentGraph()->getSubgraph( + $vocabularyNode->subgraphIdentity->contentStreamId, + $vocabularyNode->subgraphIdentity->dimensionSpacePoint, + $vocabularyNode->subgraphIdentity->visibilityConstraints, + ); + + $rootNode = $this->taxonomyService->findOrCreateRoot($subgraph); + + $this->view->assign('rootNode', $rootNode); + $this->view->assign('vocabularyNode', $vocabularyNode); } /** - * Create a new vocabulary + * Apply changes to the given vocabulary * - * @param NodeInterface $taxonomyRoot - * @param array $properties - * @return void + * @param array $properties */ - public function createVocabularyAction(NodeInterface $taxonomyRoot, array $properties) + public function updateVocabularyAction(string $vocabularyNodeAddress, string $name, array $properties): void { - $vocabularyNodeType = $this->nodeTypeManager->getNodeType($this->taxonomyService->getVocabularyNodeType()); - $vocabularyProperties = $vocabularyNodeType->getProperties(); - - $nodeTemplate = new NodeTemplate(); - $nodeTemplate->setNodeType($vocabularyNodeType); - $nodeTemplate->setName(CrUtitlity::renderValidNodeName($properties['title'])); - foreach($properties as $name => $value) { - if (array_key_exists($name, $vocabularyProperties)) { - $nodeTemplate->setProperty($name, $value); - } + $vocabularyNode = $this->taxonomyService->getNodeByNodeAddress($vocabularyNodeAddress); + $subgraph = $this->taxonomyService->getSubgraphForNode($vocabularyNode); + $rootNode = $this->taxonomyService->findOrCreateRoot($subgraph); + + $commandResult = $this->contentRepository->handle( + SetNodeProperties::create( + $vocabularyNode->subgraphIdentity->contentStreamId, + $vocabularyNode->nodeAggregateId, + $vocabularyNode->originDimensionSpacePoint, + PropertyValuesToWrite::fromArray($properties) + ) + ); + + if ($name != $vocabularyNode->nodeName?->value) { + $commandResult = $this->contentRepository->handle( + ChangeNodeAggregateName::create( + $vocabularyNode->subgraphIdentity->contentStreamId, + $vocabularyNode->nodeAggregateId, + NodeName::transliterateFromString($name) + ) + ); } - $vocabulary = $taxonomyRoot->createNodeFromTemplate($nodeTemplate); + $commandResult->block(); + $this->rebaseCurrentUserWorkspace(); - $this->addFlashMessage( - sprintf('Created vocabulary %s at path %s', $properties['title'], $vocabulary->getLabel()) - ); - $this->redirect('index', null, null, ['root' => $taxonomyRoot]); - } + $updatedVocabularyNode = $subgraph->findNodeById($vocabularyNode->nodeAggregateId); - /** - * Show a form that allows to modify the given vocabulary - * - * @param NodeInterface $vocabulary - * @return void - */ - public function editVocabularyAction(NodeInterface $vocabulary) - { - $taxonomyRoot = $this->taxonomyService->getRoot($vocabulary->getContext()); - $this->view->assign('taxonomyRoot', $taxonomyRoot); - $this->view->assign('vocabulary', $vocabulary); - $this->view->assign('defaultVocabulary', $this->getNodeInDefaultDimensions($vocabulary)); + if ($updatedVocabularyNode) { + $this->addFlashMessage( + sprintf('Updated vocabulary %s', $updatedVocabularyNode->getLabel()) + ); + } + + $this->redirect('index', null, null, ['rootNodeAddress' => $this->nodeAddressFactory->createFromNode($rootNode)]); } /** - * Apply changes to the given vocabulary - * - * @param NodeInterface $vocabulary - * @param array $properties - * @return void + * Delete the given vocabulary */ - public function updateVocabularyAction(NodeInterface $vocabulary, array $properties) + public function deleteVocabularyAction(string $vocabularyNodeAddress): void { - $taxonomyRoot = $this->taxonomyService->getRoot($vocabulary->getContext()); - $vocabularyProperties = $vocabulary->getNodeType()->getProperties(); - foreach($properties as $name => $value) { - if (array_key_exists($name, $vocabularyProperties)) { - $previous = $vocabulary->getProperty($name); - if ($previous !== $value) { - $vocabulary->setProperty($name, $value); - } - } - } + $vocabularyNode = $this->taxonomyService->getNodeByNodeAddress($vocabularyNodeAddress); + $subgraph = $this->taxonomyService->getSubgraphForNode($vocabularyNode); + $rootNode = $this->taxonomyService->findOrCreateRoot($subgraph); + $liveWorkspace = $this->taxonomyService->getLiveWorkspace(); + + $commandResult = $this->contentRepository->handle( + RemoveNodeAggregate::create( + $liveWorkspace->currentContentStreamId, + $vocabularyNode->nodeAggregateId, + $vocabularyNode->originDimensionSpacePoint->toDimensionSpacePoint(), + NodeVariantSelectionStrategy::STRATEGY_ALL_VARIANTS + ) + ); + $commandResult->block(); + $this->rebaseCurrentUserWorkspace(); $this->addFlashMessage( - sprintf('Updated vocabulary %s', $vocabulary->getLabel()) + sprintf('Deleted vocabulary %s', $vocabularyNode->getLabel()) ); - $this->redirect('index', null, null, ['root' => $taxonomyRoot]); + + $this->redirect('index', null, null, ['rootNodeAddress' => $this->nodeAddressFactory->createFromNode($rootNode)]); } /** - * Delete the given vocabulary - * - * @param NodeInterface $vocabulary - * @return void - * @throws \Exception + * Show a form to create a new taxonomy */ - public function deleteVocabularyAction(NodeInterface $vocabulary) + public function newTaxonomyAction(string $parentNodeAddress): void { - if ($vocabulary->isAutoCreated()) { - throw new \Exception('cannot delete autocrated vocabularies'); + $parentNode = $this->taxonomyService->getNodeByNodeAddress($parentNodeAddress); + $subgraph = $this->taxonomyService->getSubgraphForNode($parentNode); + $rootNode = $this->taxonomyService->findOrCreateRoot($subgraph); + $vocabularyNode = null; + + if ($parentNode->nodeTypeName->equals($this->taxonomyService->getTaxonomyNodeTypeName())) { + $vocabularyNode = $this->taxonomyService->findVocabularyForNode($parentNode); + } elseif ($parentNode->nodeTypeName->equals($this->taxonomyService->getVocabularyNodeTypeName())) { + $vocabularyNode = $parentNode; } else { - $path = $vocabulary->getPath(); - $vocabulary->remove(); - $this->addFlashMessage( - sprintf('Deleted vocabulary %s', $path) - ); } - $taxonomyRoot = $this->taxonomyService->getRoot($vocabulary->getContext()); - $this->redirect('index', null, null, ['root' => $taxonomyRoot]); - } - /** - * Show a form to create a new taxonomy - * - * @param NodeInterface $parent - * @return void - */ - public function newTaxonomyAction(NodeInterface $parent) - { - $flowQuery = new FlowQuery([$parent]); - $vocabulary = $flowQuery->closest('[instanceof ' . $this->taxonomyService->getVocabularyNodeType() . ']')->get(0); - $this->view->assign('vocabulary', $vocabulary); - $this->view->assign('parent', $parent); + $this->view->assign('rootNode', $rootNode); + $this->view->assign('vocabularyNode', $vocabularyNode); + $this->view->assign('parentNode', $parentNode); } /** * Create a new taxonomy - * - * @param NodeInterface $parent - * @param array $properties - * @return void + * @param array $properties */ - public function createTaxonomyAction(NodeInterface $parent, array $properties) + public function createTaxonomyAction(string $parentNodeAddress, string $name, array $properties): void { - $taxonomyNodeType = $this->nodeTypeManager->getNodeType($this->taxonomyService->getTaxonomyNodeType()); - $taxomonyProperties = $taxonomyNodeType->getProperties(); - - $nodeTemplate = new NodeTemplate(); - $nodeTemplate->setNodeType($taxonomyNodeType); - $nodeTemplate->setName(CrUtitlity::renderValidNodeName($properties['title'])); + $parentNode = $this->taxonomyService->getNodeByNodeAddress($parentNodeAddress); + if ($parentNode->nodeTypeName->equals($this->taxonomyService->getVocabularyNodeTypeName())) { + $vocabularyNode = $parentNode; + } else { + $vocabularyNode = $this->taxonomyService->findVocabularyForNode($parentNode); + } + $subgraph = $this->taxonomyService->getSubgraphForNode($parentNode); + $liveWorkspace = $this->taxonomyService->getLiveWorkspace(); + + $generalizations = $this->contentRepository->getVariationGraph()->getRootGeneralizations(); + $nodeAddress = $this->nodeAddressFactory->createFromUriString($parentNodeAddress); + $originDimensionSpacePoint = OriginDimensionSpacePoint::fromDimensionSpacePoint($nodeAddress->dimensionSpacePoint); + + // create node + $nodeAggregateId = NodeAggregateId::create(); + $nodeTypeName = $this->taxonomyService->getTaxonomyNodeTypeName(); + $commandResult = $this->contentRepository->handle( + CreateNodeAggregateWithNode::create( + $liveWorkspace->currentContentStreamId, + $nodeAggregateId, + $nodeTypeName, + $originDimensionSpacePoint, + $parentNode->nodeAggregateId, + null, + NodeName::transliterateFromString($name), + PropertyValuesToWrite::fromArray($properties) + ) + ); + $commandResult->block(); - foreach($properties as $name => $value) { - if (array_key_exists($name, $taxomonyProperties)) { - $nodeTemplate->setProperty($name, $value); + // create required generalizations + foreach ($generalizations as $dimensionSpacePoint) { + $originDimensionSpacePoint2 = OriginDimensionSpacePoint::fromDimensionSpacePoint($dimensionSpacePoint); + if ($originDimensionSpacePoint->equals($originDimensionSpacePoint2)) { + continue; } - } - $taxonomy = $parent->createNodeFromTemplate($nodeTemplate); + $commandResult = $this->contentRepository->handle( + CreateNodeVariant::create( + $liveWorkspace->currentContentStreamId, + $nodeAggregateId, + $originDimensionSpacePoint, + $originDimensionSpacePoint2 + ) + ); + } - $this->addFlashMessage( - sprintf('Created taxonomy %s at path %s', $taxonomy->getLabel(), $taxonomy->getPath()) - ); + $this->rebaseCurrentUserWorkspace(); + $newTaxonomyNode = $subgraph->findNodeById($nodeAggregateId); - $flowQuery = new FlowQuery([$taxonomy]); - $vocabulary = $flowQuery - ->closest('[instanceof ' . $this->taxonomyService->getVocabularyNodeType() . ']') - ->get(0); + if ($newTaxonomyNode) { + $this->addFlashMessage( + sprintf('Created taxonomy %s', $newTaxonomyNode->getLabel()), + 'Create taxomony' + ); + } $this->redirect( 'vocabulary', null, null, - ['vocabulary' => $vocabulary->getContextPath()] + ['vocabularyNodeAddress' => $this->nodeAddressFactory->createFromNode($vocabularyNode)] ); } /** * Display a form that allows to modify the given taxonomy - * - * @param NodeInterface $taxonomy - * @return void */ - public function editTaxonomyAction(NodeInterface $taxonomy) + public function editTaxonomyAction(string $taxonomyNodeAddress): void { - $flowQuery = new FlowQuery([$taxonomy]); - $vocabulary = $flowQuery - ->closest('[instanceof ' . $this->taxonomyService->getVocabularyNodeType() . ']') - ->get(0); + $taxonomyNode = $this->taxonomyService->getNodeByNodeAddress($taxonomyNodeAddress); + $vocabularyNode = $this->taxonomyService->findVocabularyForNode($taxonomyNode); - $this->view->assign('vocabulary', $vocabulary); - $this->view->assign('defaultVocabulary', $this->getNodeInDefaultDimensions($vocabulary)); - - $this->view->assign('taxonomy', $taxonomy); - $this->view->assign('defaultTaxonomy', $this->getNodeInDefaultDimensions($taxonomy)); + $this->view->assign('vocabularyNode', $vocabularyNode); + $this->view->assign('taxonomyNode', $taxonomyNode); } /** * Apply changes to the given taxonomy * - * @param NodeInterface $taxonomy - * @param array $properties - * @return void + * @param array $properties */ - public function updateTaxonomyAction(NodeInterface $taxonomy, array $properties) + public function updateTaxonomyAction(string $taxonomyNodeAddress, string $name, array $properties): void { - $taxonomyProperties = $taxonomy->getNodeType()->getProperties(); - foreach($properties as $name => $value) { - if (array_key_exists($name, $taxonomyProperties)) { - $previous = $taxonomy->getProperty($name); - if ($previous !== $value) { - $taxonomy->setProperty($name, $value); - } - } + $taxonomyNode = $this->taxonomyService->getNodeByNodeAddress($taxonomyNodeAddress); + $vocabularyNode = $this->taxonomyService->findVocabularyForNode($taxonomyNode); + $subgraph = $this->taxonomyService->getSubgraphForNode($taxonomyNode); + + $commandResult = $this->contentRepository->handle( + SetNodeProperties::create( + $taxonomyNode->subgraphIdentity->contentStreamId, + $taxonomyNode->nodeAggregateId, + $taxonomyNode->originDimensionSpacePoint, + PropertyValuesToWrite::fromArray($properties) + ) + ); + if ($name != $taxonomyNode->nodeName?->value) { + $commandResult = $this->contentRepository->handle( + ChangeNodeAggregateName::create( + $taxonomyNode->subgraphIdentity->contentStreamId, + $taxonomyNode->nodeAggregateId, + NodeName::transliterateFromString($name) + ) + ); } + $commandResult->block(); + $this->rebaseCurrentUserWorkspace(); - $this->addFlashMessage( - sprintf('Updated taxonomy %s', $taxonomy->getPath()) - ); + $updatedTaxonomyNode = $subgraph->findNodeById($vocabularyNode->nodeAggregateId); - $flowQuery = new FlowQuery([$taxonomy]); - $vocabulary = $flowQuery - ->closest('[instanceof ' . $this->taxonomyService->getVocabularyNodeType() . ']') - ->get(0); + if ($updatedTaxonomyNode) { + $this->addFlashMessage( + sprintf('Updated taxonomy %s', $updatedTaxonomyNode->getLabel()) + ); + } - $this->redirect('vocabulary', null, null, ['vocabulary' => $vocabulary->getContextPath()]); + $this->redirect('vocabulary', null, null, ['vocabularyNodeAddress' => $this->nodeAddressFactory->createFromNode($vocabularyNode)]); } /** * Delete the given taxonomy - * - * @param NodeInterface $taxonomy - * @return void */ - public function deleteTaxonomyAction(NodeInterface $taxonomy) + public function deleteTaxonomyAction(string $taxonomyNodeAddress): void { - if ($taxonomy->isAutoCreated()) { - throw new \Exception('cannot delete autocrated taxonomies'); - } - - $flowQuery = new FlowQuery([$taxonomy]); - $vocabulary = $flowQuery - ->closest('[instanceof ' . $this->taxonomyService->getVocabularyNodeType() . ']') - ->get(0); - - $taxonomy->remove(); + $taxonomyNode = $this->taxonomyService->getNodeByNodeAddress($taxonomyNodeAddress); + $vocabularyNode = $this->taxonomyService->findVocabularyForNode($taxonomyNode); + $liveWorkspace = $this->taxonomyService->getLiveWorkspace(); + + $commandResult = $this->contentRepository->handle( + RemoveNodeAggregate::create( + $liveWorkspace->currentContentStreamId, + $taxonomyNode->nodeAggregateId, + $taxonomyNode->originDimensionSpacePoint->toDimensionSpacePoint(), + NodeVariantSelectionStrategy::STRATEGY_ALL_VARIANTS + ) + ); + $commandResult->block(); + $this->rebaseCurrentUserWorkspace(); $this->addFlashMessage( - sprintf('Deleted taxonomy %s', $taxonomy->getPath()) + sprintf('Deleted taxonomy %s', $taxonomyNode->getLabel()) ); - $this->redirect('vocabulary', null, null, ['vocabulary' => $vocabulary]); + $this->redirect('vocabulary', null, null, ['vocabularyNodeAddress' => $this->nodeAddressFactory->createFromNode($vocabularyNode)]); } - + protected function rebaseCurrentUserWorkspace(): void + { + $account = $this->securityContext->getAccount(); + if (is_null($account)) { + throw new \Exception('no account found'); + } + $workspaceName = WorkspaceNameBuilder::fromAccountIdentifier( + $account->getAccountIdentifier() + ); + $workspace = $this->contentRepository->getWorkspaceFinder()->findOneByName($workspaceName); + if (is_null($workspace)) { + throw new \Exception('no workspace found'); + } + $this->contentRepository->handle(RebaseWorkspace::create($workspaceName)); + } } diff --git a/Classes/Controller/SecondaryInspectorController.php b/Classes/Controller/SecondaryInspectorController.php index 89fd03f..d92b13b 100644 --- a/Classes/Controller/SecondaryInspectorController.php +++ b/Classes/Controller/SecondaryInspectorController.php @@ -1,5 +1,4 @@ taxonomyService->getNodeByNodeAddress($contextNode); + $subgraph = $this->taxonomyService->getSubgraphForNode($node); + + $path = AbsoluteNodePath::fromString($startingPoint); + $startNode = $subgraph->findNodeByAbsolutePath($path); + if (!$startNode) { + return; + } + $taxonomySubtree = $this->taxonomyService->findSubtree($startNode); + if (!$taxonomySubtree) { + return; + } + $this->view->assign('value', $this->toJson($taxonomySubtree)); + } + /** - * @param NodeInterface $contextNode - * @return void + * @return mixed[] */ - public function treeAction(NodeInterface $contextNode): void + protected function toJson(Subtree $subtree, string $pathSoFar = null): array { - $taxonomyTreeAsArray = $this->taxonomyService - ->getTaxonomyTreeAsArray($contextNode); + $label = $subtree->node->getLabel(); + $pathSegment = $subtree->node->nodeName?->value ?? $label; + $path = $pathSoFar ? $pathSoFar . ' - ' . $pathSegment : $pathSegment; + $identifier = $subtree->node->nodeAggregateId->value; + $nodeType = $subtree->node->nodeTypeName->value; + $title = $subtree->node->getProperty('title'); + $description = $subtree->node->getProperty('description'); + $children = array_map(fn(Subtree $child)=>$this->toJson($child), $subtree->children); - $this->view->assign('value', $taxonomyTreeAsArray); + return [ + 'identifier' => $identifier, + 'path' => $path, + 'nodeType' => $nodeType, + 'label' => $label, + 'title' => is_string($title) ? $title : $label, + 'description' => is_string($description) ? $description : '', + 'children' => $children + ]; } } diff --git a/Classes/Eel/TaxonomyHelper.php b/Classes/Eel/TaxonomyHelper.php deleted file mode 100644 index ff8ea71..0000000 --- a/Classes/Eel/TaxonomyHelper.php +++ /dev/null @@ -1,58 +0,0 @@ -taxonomyService->getRoot($context); - } - - /** - * @param string $vocabulary Name of the vocabulary node - * @param ContentContext $context - * @return NodeInterface - */ - public function vocabulary($vocabulary, ContentContext $context = null) - { - return $this->taxonomyService->getVocabulary($vocabulary, $context); - } - - /** - * @param string $vocabulary Name of the vocabulary node - * @param string $path Path of the taxonomy node - * @param ContentContext $context - * @return NodeInterface - */ - public function taxonomy($vocabulary, $path, ContentContext $context = null) - { - return $this->taxonomyService->getTaxonomy($vocabulary, $path, $context); - } - - /** - * @param string $methodName - * @return bool - */ - public function allowsCallOfMethod($methodName) - { - return true; - } -} diff --git a/Classes/FlowQuery/CreateNodeHashTrait.php b/Classes/FlowQuery/CreateNodeHashTrait.php new file mode 100644 index 0000000..89b0b55 --- /dev/null +++ b/Classes/FlowQuery/CreateNodeHashTrait.php @@ -0,0 +1,32 @@ +id, dimensionSpacePoint->hash + * and visibilityConstraints->hash. To be used for ensuring uniqueness or removing nodes. + * + * @see Node::equals() for comparison + */ + protected function createNodeHash(Node $node): string + { + return md5( + implode( + ':', + [ + $node->nodeAggregateId->value, + $node->subgraphIdentity->contentRepositoryId->value, + $node->subgraphIdentity->contentStreamId->value, + $node->subgraphIdentity->dimensionSpacePoint->hash, + $node->subgraphIdentity->visibilityConstraints->getHash() + ] + ) + ); + } +} diff --git a/Classes/FlowQuery/FlattenSubtreeTrait.php b/Classes/FlowQuery/FlattenSubtreeTrait.php new file mode 100644 index 0000000..3840c21 --- /dev/null +++ b/Classes/FlowQuery/FlattenSubtreeTrait.php @@ -0,0 +1,20 @@ +node]); + foreach ($subtree->children as $child) { + $nodes = $nodes->merge($this->flattenSubtree($child)); + } + return $nodes; + } +} diff --git a/Classes/FlowQuery/ReferencedTaxonomiesOperation.php b/Classes/FlowQuery/ReferencedTaxonomiesOperation.php new file mode 100644 index 0000000..61445fb --- /dev/null +++ b/Classes/FlowQuery/ReferencedTaxonomiesOperation.php @@ -0,0 +1,79 @@ + $context (or array-like object) onto which this operation should be applied + * @return boolean true if the operation can be applied onto the $context, false otherwise + */ + public function canEvaluate($context) + { + return isset($context[0]) && ($context[0] instanceof Node); + } + + /** + * {@inheritdoc} + * + * @param FlowQuery $flowQuery the FlowQuery object + * @param array $arguments the arguments for this operation + * @return void + */ + public function evaluate(FlowQuery $flowQuery, array $arguments) + { + $nodes = []; + $findReferencesFilter = FindReferencesFilter::create( + nodeTypes: NodeTypeCriteria::create( + NodeTypeNames::fromArray([$this->taxonomyService->getTaxonomyNodeTypeName()]), + NodeTypeNames::createEmpty() + ), + referenceName: 'taxonomyReferences' + ); + + /** + * @var Node $node + */ + foreach ($flowQuery->getContext() as $node) { + $subgraph = $this->contentRepositoryRegistry->subgraphForNode($node); + $references = $subgraph->findReferences($node->nodeAggregateId, $findReferencesFilter); + foreach ($references as $reference) { + $nodes[] = $reference->node; + } + } + $flowQuery->setContext($nodes); + } +} diff --git a/Classes/FlowQuery/ReferencingTaxonomiesOperation.php b/Classes/FlowQuery/ReferencingTaxonomiesOperation.php new file mode 100644 index 0000000..b8b36c4 --- /dev/null +++ b/Classes/FlowQuery/ReferencingTaxonomiesOperation.php @@ -0,0 +1,81 @@ + $context (or array-like object) onto which this operation should be applied + * @return boolean true if the operation can be applied onto the $context, false otherwise + */ + public function canEvaluate($context) + { + return isset($context[0]) && ($context[0] instanceof Node); + } + + /** + * {@inheritdoc} + * + * @param FlowQuery $flowQuery the FlowQuery object + * @param array $arguments the arguments for this operation + * @return void + */ + public function evaluate(FlowQuery $flowQuery, array $arguments) + { + $nodes = []; + $findBackReferencesFilter = FindBackReferencesFilter::create( + nodeTypes: NodeTypeCriteria::fromFilterString('Neos.Neos:Document'), + referenceName: 'taxonomyReferences' + ); + + /** + * @var Node $node + */ + foreach ($flowQuery->getContext() as $node) { + $subgraph = $this->contentRepositoryRegistry->subgraphForNode($node); + $references = $subgraph->findBackReferences($node->nodeAggregateId, $findBackReferencesFilter); + foreach ($references as $reference) { + $nodes[] = $reference->node; + } + } + + $nodesByHash = []; + foreach ($nodes as $node) { + $hash = $this->createNodeHash($node); + if (!array_key_exists($hash, $nodesByHash)) { + $nodesByHash[$hash] = $node; + } + } + $flowQuery->setContext(array_values($nodesByHash)); + + $flowQuery->setContext($nodes); + } +} diff --git a/Classes/FlowQuery/SubTaxonomiesOperation.php b/Classes/FlowQuery/SubTaxonomiesOperation.php new file mode 100644 index 0000000..d1a4c7b --- /dev/null +++ b/Classes/FlowQuery/SubTaxonomiesOperation.php @@ -0,0 +1,87 @@ + $context (or array-like object) onto which this operation should be applied + * @return boolean true if the operation can be applied onto the $context, false otherwise + */ + public function canEvaluate($context) + { + return isset($context[0]) && ($context[0] instanceof Node && $context[0]->nodeTypeName->equals($this->taxonomyService->getTaxonomyNodeTypeName())); + } + + /** + * {@inheritdoc} + * + * @param FlowQuery $flowQuery the FlowQuery object + * @param array $arguments the arguments for this operation + * @return void + */ + public function evaluate(FlowQuery $flowQuery, array $arguments) + { + $nodes = Nodes::createEmpty(); + + $filter = FindSubtreeFilter::create( + NodeTypeCriteria::create( + NodeTypeNames::fromArray([$this->taxonomyService->getTaxonomyNodeTypeName()]), + NodeTypeNames::createEmpty() + ) + ); + + foreach ($flowQuery->getContext() as $node) { + $subgraph = $this->contentRepositoryRegistry->subgraphForNode($node); + $subtree = $subgraph->findSubtree($node->nodeAggregateId, $filter); + if ($subtree) { + foreach ($subtree->children as $child) { + $nodes = $nodes->merge($this->flattenSubtree($child)); + } + } + } + + $nodesByHash = []; + foreach ($nodes as $node) { + $hash = $this->createNodeHash($node); + if (!array_key_exists($hash, $nodesByHash)) { + $nodesByHash[$hash] = $node; + } + } + $flowQuery->setContext($nodes); + } +} diff --git a/Classes/FlowQuery/WithSubTaxonomiesOperation.php b/Classes/FlowQuery/WithSubTaxonomiesOperation.php new file mode 100644 index 0000000..25c8a02 --- /dev/null +++ b/Classes/FlowQuery/WithSubTaxonomiesOperation.php @@ -0,0 +1,88 @@ + $context (or array-like object) onto which this operation should be applied + * @return boolean true if the operation can be applied onto the $context, false otherwise + */ + public function canEvaluate($context) + { + return isset($context[0]) && ($context[0] instanceof Node && $context[0]->nodeTypeName->equals($this->taxonomyService->getTaxonomyNodeTypeName())); + } + + /** + * {@inheritdoc} + * + * @param FlowQuery $flowQuery the FlowQuery object + * @param array $arguments the arguments for this operation + * @return void + */ + public function evaluate(FlowQuery $flowQuery, array $arguments) + { + $filter = FindSubtreeFilter::create( + nodeTypes: NodeTypeCriteria::create( + NodeTypeNames::fromArray([$this->taxonomyService->getTaxonomyNodeTypeName()]), + NodeTypeNames::createEmpty() + ) + ); + + $nodes = Nodes::createEmpty(); + foreach ($flowQuery->getContext() as $node) { + $subgraph = $this->contentRepositoryRegistry->subgraphForNode($node); + $subtree = $subgraph->findSubtree($node->nodeAggregateId, $filter); + if ($subtree) { + $nodes = $nodes->merge($this->flattenSubtree($subtree)); + } + } + + $nodesByHash = []; + foreach ($nodes as $node) { + $hash = $this->createNodeHash($node); + if (!array_key_exists($hash, $nodesByHash)) { + $nodesByHash[$hash] = $node; + } + } + $flowQuery->setContext(array_values($nodesByHash)); + } +} diff --git a/Classes/Hooks/ContentRepositoryHooks.php b/Classes/Hooks/ContentRepositoryHooks.php deleted file mode 100644 index 4e7c127..0000000 --- a/Classes/Hooks/ContentRepositoryHooks.php +++ /dev/null @@ -1,69 +0,0 @@ -getNodeType()->isOfType($this->taxonomyService->getRootNodeType()) || - $node->getNodeType()->isOfType($this->taxonomyService->getVocabularyNodeType()) || - $node->getNodeType()->isOfType($this->taxonomyService->getTaxonomyNodeType())) { - if ($node->isAutoCreated() == false && $this->preventCascade == false) { - $this->preventCascade = true; - $this->dimensionService->ensureBaseVariantsExist($node); - $this->preventCascade = false; - } - } - } - - /** - * Signal that is triggered on node remove - * - * @param NodeInterface $node - */ - public function nodeRemoved(NodeInterface $node) - { - if ($node->getNodeType()->isOfType($this->taxonomyService->getRootNodeType()) || - $node->getNodeType()->isOfType($this->taxonomyService->getVocabularyNodeType()) || - $node->getNodeType()->isOfType($this->taxonomyService->getTaxonomyNodeType())) { - if ($node->isAutoCreated() == false && $this->preventCascade == false) { - $this->preventCascade = true; - $this->dimensionService->removeOtherVariants($node); - $this->preventCascade = false; - } - } - } -} diff --git a/Classes/Package.php b/Classes/Package.php deleted file mode 100644 index ed89312..0000000 --- a/Classes/Package.php +++ /dev/null @@ -1,39 +0,0 @@ -getSignalSlotDispatcher(); - $dispatcher->connect( - Node::class, - 'nodeAdded', - ContentRepositoryHooks::class, - 'nodeAdded' - ); - - $dispatcher = $bootstrap->getSignalSlotDispatcher(); - $dispatcher->connect( - Node::class, - 'nodeRemoved', - ContentRepositoryHooks::class, - 'nodeRemoved' - ); - } - } -} diff --git a/Classes/Service/DimensionService.php b/Classes/Service/DimensionService.php deleted file mode 100644 index 87fe259..0000000 --- a/Classes/Service/DimensionService.php +++ /dev/null @@ -1,175 +0,0 @@ -fallbackGraphService->getInterDimensionalFallbackGraph(); - // find all dimensionGraphs that have no fallbacks - $baseDimensionGraphs = array_filter( - $interDimensionalFallbackGraph->getSubgraphs(), - function ($subgraph) { - try { - $weight = $subgraph->getWeight(); - return (array_sum($weight) === 0); - } catch (\TypeError $e) { - // TODO: - // Yep, we're catching a TypeError here. That's because - // $subgraph->getWeight() is supposed to return an array, but it doesn't if - // there's no dimension configuration at all. This sure will be fixed in - // future releases of the content repository and should be adjusted at this - // point as well. - return true; - } - } - ); - - return $baseDimensionGraphs; - } - - /** - * @return array|ContentSubgraph[] - */ - public function getDimensionSubgraphByTargetValues($targetValues) - { - $interDimensionalFallbackGraph = $this->fallbackGraphService->getInterDimensionalFallbackGraph(); - $allSubgraphs = $interDimensionalFallbackGraph->getSubgraphs(); - $matchingSubgraphs = array_filter( - $allSubgraphs, - function($subgraph) use ($targetValues){ - foreach($subgraph->getDimensionValues() as $targetDimension => $targetValue) { - $value = (string) $subgraph->getDimensionValue($targetDimension); - if (!array_key_exists($targetDimension, $targetValues)) { - return false; - } - if ($targetValues[$targetDimension] != $value) { - return false; - } - } - return true; - } - ); - - if (count($matchingSubgraphs) == 1) { - return array_pop($matchingSubgraphs); - } - } - - /** - * @return array|ContentSubgraph[] - */ - public function getAllDimensionSubgraphs() - { - $interDimensionalFallbackGraph = $this->fallbackGraphService->getInterDimensionalFallbackGraph(); - return $interDimensionalFallbackGraph; - } - - /** - * @param NodeInterface $node - * @return NodeInterface[] new variants; - */ - public function ensureBaseVariantsExist(NodeInterface $node) - { - $results = []; - $baseDimensionSubgraphs = $this->getBaseDimensionSubgraphs(); - if (count($baseDimensionSubgraphs) > 0) { - $nodeContext = $node->getContext(); - foreach ($baseDimensionSubgraphs as $baseDimensionSubgraph) { - $baseDimensionValues = $this->getDimensionValuesForSubgraph($baseDimensionSubgraph); - $baseDimensionContext = array_merge($nodeContext->getProperties(), $baseDimensionValues); - $targetContext = $this->contextFactory->create($baseDimensionContext); - $adoptedNode = $targetContext->adoptNode($node, true); - $results[] = $adoptedNode; - } - } - $this->persistenceManager->persistAll(); - return $results; - } - - /** - * @param NodeInterface $node - * @return NodeInterface[] removed variants; - */ - public function removeOtherVariants(NodeInterface $node) - { - $results = []; - /** - * @var array $otherNodeVariants - */ - $otherNodeVariants = $node->getOtherNodeVariants(); - foreach ($otherNodeVariants as $nodeVariant) { - /** - * @var NodeInterface $nodeVariant - */ - $nodeVariant->remove(); - $results[] = $nodeVariant; - } - $this->persistenceManager->persistAll(); - return $results; - } - - /** - * @param ContentSubgraph $baseDimensionSubgraph - * @return array - */ - public function getDimensionValuesForSubgraph(ContentSubgraph $baseDimensionSubgraph): array - { - $baseDimensionValues = [ - 'dimensions' => array_map( - function (ContentDimensionValue $contentDimensionValue) { - return [$contentDimensionValue->getValue()]; - }, - $baseDimensionSubgraph->getDimensionValues() - ), - 'targetDimensions' => array_map( - function (ContentDimensionValue $contentDimensionValue) { - return $contentDimensionValue->getValue(); - }, - $baseDimensionSubgraph->getDimensionValues() - ), - ]; - return $baseDimensionValues; - } -} diff --git a/Classes/Service/TaxonomyService.php b/Classes/Service/TaxonomyService.php index 70733b9..bc42328 100644 --- a/Classes/Service/TaxonomyService.php +++ b/Classes/Service/TaxonomyService.php @@ -1,225 +1,249 @@ + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. */ -class TaxonomyService -{ - /** - * @Flow\Inject - * @var NodeDataRepository - */ - protected $nodeDataRepository; - - /** - * @Flow\Inject - * @var NodeFactory - */ - protected $nodeFactory; +declare(strict_types=1); - /** - * @Flow\Inject - * @var ContextFactoryInterface - */ - protected $contextFactory; - - /** - * @Flow\Inject - * @var NodeTypeManager - */ - protected $nodeTypeManager; - - /** - * @Flow\Inject - * @var PersistenceManagerInterface - */ - protected $persistenceManager; - - /** - * @var string - * @Flow\InjectConfiguration(path="contentRepository.rootNodeName") - */ - protected $rootNodeName; +namespace Sitegeist\Taxonomy\Service; - /** - * @var string - * @Flow\InjectConfiguration(path="contentRepository.rootNodeType") - */ - protected $rootNodeType; +use Neos\ContentRepository\Core\ContentRepository; +use Neos\ContentRepository\Core\Factory\ContentRepositoryId; +use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\CreateRootNodeAggregateWithNode; +use Neos\ContentRepository\Core\NodeType\NodeTypeName; +use Neos\ContentRepository\Core\NodeType\NodeTypeNames; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindChildNodesFilter; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindClosestNodeFilter; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindSubtreeFilter; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\NodeType\NodeTypeCriteria; +use Neos\ContentRepository\Core\Projection\ContentGraph\Node; +use Neos\ContentRepository\Core\Projection\ContentGraph\NodePath; +use Neos\ContentRepository\Core\Projection\ContentGraph\Nodes; +use Neos\ContentRepository\Core\Projection\ContentGraph\Subtree; +use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; +use Neos\ContentRepository\Core\Projection\Workspace\Workspace; +use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; +use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; +use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; +use Neos\Flow\Annotations as Flow; +use Neos\Neos\FrontendRouting\NodeAddressFactory; - /** - * @var string - * @Flow\InjectConfiguration(path="contentRepository.vocabularyNodeType") - */ - protected $vocabularyNodeType; +class TaxonomyService +{ + #[Flow\Inject(lazy:false)] + protected ContentRepositoryRegistry $crRegistry; - /** - * @var string - * @Flow\InjectConfiguration(path="contentRepository.taxonomyNodeType") - */ - protected $taxonomyNodeType; + protected ContentRepository|null $contentRepository = null; /** - * @var NodeInterface[] + * @var mixed[] */ - protected $taxonomyDataRootNodes = []; + #[Flow\InjectConfiguration] + protected array $configuration = []; - /** - * @return string - */ - public function getRootNodeName() + public function getRootNodeTypeName(): NodeTypeName { - return $this->rootNodeName; + return NodeTypeName::fromString('Sitegeist.Taxonomy:Root'); } - /** - * @return string - */ - public function getRootNodeType() + public function getVocabularyNodeTypeName(): NodeTypeName { - return $this->rootNodeType; + return NodeTypeName::fromString('Sitegeist.Taxonomy:Vocabulary'); } - /** - * @return string - */ - public function getVocabularyNodeType() + public function getTaxonomyNodeTypeName(): NodeTypeName { - return $this->vocabularyNodeType; + return NodeTypeName::fromString('Sitegeist.Taxonomy:Taxonomy'); } - /** - * @return string - */ - public function getTaxonomyNodeType() + public function getContentRepository(): ContentRepository { - return $this->taxonomyNodeType; + if (is_null($this->contentRepository)) { + $crid = $this->configuration['contentRepository']['identifier'] ?? null; + if (!is_string($crid)) { + throw new \InvalidArgumentException(); + } + $this->contentRepository = $this->crRegistry->get(ContentRepositoryId::fromString($crid)); + } + return $this->contentRepository; } - /** - * @param Context $context - * @return NodeInterface - */ - public function getRoot(Context $context = null) + public function findVocabularyForNode(Node $node): Node { - if ($context === null) { - $context = $this->contextFactory->create(); + $subgraph = $this->crRegistry->subgraphForNode($node); + + $vocabularyNode = $subgraph->findClosestNode( + $node->nodeAggregateId, + FindClosestNodeFilter::create( + nodeTypes: NodeTypeCriteria::create( + NodeTypeNames::fromArray([ $this->getVocabularyNodeTypeName()]), + NodeTypeNames::createEmpty() + ) + ) + ); + + if ($vocabularyNode) { + return $vocabularyNode; } - $contextHash = md5(json_encode($context->getProperties())); + throw new \InvalidArgumentException('node seems to be outside of vocabulary'); + } - // return memoized root-node - if (array_key_exists($contextHash, $this->taxonomyDataRootNodes) - && $this->taxonomyDataRootNodes[$contextHash] instanceof NodeInterface - ) { - return $this->taxonomyDataRootNodes[$contextHash]; + public function findOrCreateRoot(ContentSubgraphInterface $subgraph): Node + { + $rootNode = $subgraph->findRootNodeByType($this->getRootNodeTypeName()); + if ($rootNode instanceof Node) { + return $rootNode; } - // return existing root-node - // - // TODO: Find a better way to determine the root node - $taxonomyDataRootNodeData = $this->nodeDataRepository->findOneByPath( - '/' . $this->getRootNodeName(), - $context->getWorkspace() - ); + $contentRepository = $this->getContentRepository(); + $liveWorkspace = $this->getLiveWorkspace(); - if ($taxonomyDataRootNodeData !== null) { - $this->taxonomyDataRootNodes[$contextHash] = $this->nodeFactory->createFromNodeData( - $taxonomyDataRootNodeData, - $context - ); + $commandResult = $contentRepository->handle( + CreateRootNodeAggregateWithNode::create( + $liveWorkspace->currentContentStreamId, + NodeAggregateId::create(), + $this->getRootNodeTypeName() + ) + ); + $commandResult->block(); - return $this->taxonomyDataRootNodes[$contextHash]; + $rootNode = $subgraph->findRootNodeByType($this->getRootNodeTypeName()); + if ($rootNode instanceof Node) { + return $rootNode; } - // create root-node - $nodeTemplate = new NodeTemplate(); - $nodeTemplate->setNodeType($this->nodeTypeManager->getNodeType($this->rootNodeType)); - $nodeTemplate->setName($this->getRootNodeName()); - - $rootNode = $context->getRootNode(); - $this->taxonomyDataRootNodes[$contextHash] = $rootNode->createNodeFromTemplate($nodeTemplate); + throw new \Exception('taxonomy root could neither be found nor created'); + } - // We fetch the workspace to be sure it's known to the persistence manager and persist all - // so the workspace and site node are persisted before we import any nodes to it. - $this->taxonomyDataRootNodes[$contextHash]->getContext()->getWorkspace(); - $this->persistenceManager->persistAll(); + public function findAllVocabularies(ContentSubgraphInterface $subgraph): Nodes + { + $root = $this->findOrCreateRoot($subgraph); + return $subgraph->findChildNodes( + $root->nodeAggregateId, + FindChildNodesFilter::create( + nodeTypes: NodeTypeCriteria::create( + NodeTypeNames::fromArray([$this->getVocabularyNodeTypeName()]), + NodeTypeNames::createEmpty() + ) + ) + ); + } - return $this->taxonomyDataRootNodes[$contextHash]; + public function findVocabularyByName(ContentSubgraphInterface $subgraph, string $vocabularyName): ?Node + { + // @todo find root -> find named child + $vocabularies = $this->findAllVocabularies($subgraph); + foreach ($vocabularies as $vocabulary) { + if ($vocabulary->nodeName?->value == $vocabularyName) { + return $vocabulary; + } + } + return null; } - /** - * @param string $vocabularyName - * @param Context|null $context - * @param $vocabulary - */ - public function getVocabulary($vocabularyName, Context $context = null) + public function findTaxonomyByVocabularyNameAndPath(ContentSubgraphInterface $subgraph, string $vocabularyName, string $taxonomyPath): ?Node { - if ($context === null) { - $context = $this->contextFactory->create(); + $vocabulary = $this->findVocabularyByName($subgraph, $vocabularyName); + if (!$vocabulary instanceof Node) { + return null; } + $taxonomy = $subgraph->findNodeByPath( + NodePath::fromString($taxonomyPath), + $vocabulary->nodeAggregateId + ); + return $taxonomy; + } + + public function findSubtree(Node $StartNode): ?Subtree + { + $subgraph = $this->crRegistry->subgraphForNode($StartNode); + + $vocabularySubtree = $subgraph->findSubtree( + $StartNode->nodeAggregateId, + FindSubtreeFilter::create( + nodeTypes: NodeTypeCriteria::create( + NodeTypeNames::fromArray([$this->getTaxonomyNodeTypeName(), $this->getVocabularyNodeTypeName()]), + NodeTypeNames::createEmpty() + ) + ) + ); - $root = $this->getRoot($context); - return $root->getNode($vocabularyName); + return $vocabularySubtree ? $this->orderSubtreeByNameRecursive($vocabularySubtree) : null; } - /** - * @param string $vocabularyName - * @param string $taxonomyPath - * @param Context|null $context - * @param $vocabulary - */ - public function getTaxonomy($vocabularyName, $taxonomyPath, Context $context = null) + private function orderSubtreeByNameRecursive(Subtree $subtree): Subtree { - $vocabulary = $this->getVocabulary($vocabularyName, $context); - if ($vocabulary) { - return $vocabulary->getNode($taxonomyPath); - } + $children = $subtree->children; + $children = array_map( + fn(Subtree $item) => $this->orderSubtreeByNameRecursive($item), + $children + ); + usort( + $children, + fn(Subtree $a, Subtree $b) => $a->node->nodeName?->value <=> $b->node->nodeName?->value + ); + return new Subtree( + $subtree->level, + $subtree->node, + $children + ); } - /** - * @param NodeInterface $startingPoint - * @return array - */ - public function getTaxonomyTreeAsArray(NodeInterface $startingPoint): array + public function getNodeByNodeAddress(string $serializedNodeAddress): Node { - $result = []; + $contentRepository = $this->getContentRepository(); + $nodeAddress = NodeAddressFactory::create($contentRepository)->createFromUriString($serializedNodeAddress); + $subgraph = $contentRepository->getContentGraph()->getSubgraph( + $nodeAddress->contentStreamId, + $nodeAddress->dimensionSpacePoint, + VisibilityConstraints::withoutRestrictions() + ); + $node = $subgraph->findNodeById($nodeAddress->nodeAggregateId); + if (is_null($node)) { + throw new \InvalidArgumentException('nodeAddress does not resolve to a node'); + } + return $node; + } - $result['identifier'] = $startingPoint->getIdentifier(); - $result['path'] = $startingPoint->getPath(); - $result['nodeType'] = $startingPoint->getNodeType()->getName(); - $result['label'] = $startingPoint->getLabel(); - $result['title'] = $startingPoint->getProperty('title'); - $result['description'] = $startingPoint->getProperty('description'); + public function getDefaultSubgraph(): ContentSubgraphInterface + { + $contentRepository = $this->getContentRepository(); + $liveWorkspace = $this->getLiveWorkspace(); + $generalizations = $contentRepository->getVariationGraph()->getRootGeneralizations(); + $dimensionSpacePoint = reset($generalizations); + if (!$dimensionSpacePoint) { + throw new \Exception('default dimensionSpacePoint could not be found'); + } + $contentGraph = $contentRepository->getContentGraph(); + $subgraph = $contentGraph->getSubgraph( + $liveWorkspace->currentContentStreamId, + $dimensionSpacePoint, + VisibilityConstraints::withoutRestrictions() + ); + return $subgraph; + } - $result['children'] = []; + public function getSubgraphForNode(Node $node): ContentSubgraphInterface + { + return $this->crRegistry->subgraphForNode($node); + } - foreach ($startingPoint->getChildNodes() as $childNode) { - $result['children'][] = $this->getTaxonomyTreeAsArray($childNode); + public function getLiveWorkspace(): Workspace + { + $liveWorkspace = $this->getContentRepository()->getWorkspaceFinder()->findOneByName(WorkspaceName::forLive()); + if (!$liveWorkspace) { + throw new \Exception('live workspace could not be found'); } - usort($result['children'], function (array $childA, array $childB) { - return strcmp( - $childA['title'] ?: '', - $childB['title'] ?: '' - ); - }); - - return $result; + return $liveWorkspace; } } diff --git a/Classes/ViewHelpers/DimensionInformationViewHelper.php b/Classes/ViewHelpers/DimensionInformationViewHelper.php deleted file mode 100644 index 1ad887c..0000000 --- a/Classes/ViewHelpers/DimensionInformationViewHelper.php +++ /dev/null @@ -1,62 +0,0 @@ -registerArgument('node', NodeInterface::class, 'Node', true); - $this->registerArgument('dimension', 'string', 'Dimension', false, null); - } - - /** - * @return string value with replaced text - * @api - */ - public function render() - { - return self::renderStatic( - [ - 'node' => $this->arguments['node'], - 'dimension' => $this->arguments['dimension'] - ], - $this->buildRenderChildrenClosure(), - $this->renderingContext - ); - } - - /** - * @param array $arguments - * @param \Closure $renderChildrenClosure - * @param RenderingContextInterface $renderingContext - * @return string - * @throws InvalidVariableException - */ - public static function renderStatic( - array $arguments, - \Closure $renderChildrenClosure, - RenderingContextInterface $renderingContext - ) { - /** - * @var NodeInterface $node - */ - $node = $arguments['node']; - $dimension = $arguments['dimension']; - if ($dimension) { - return $node->getContext()->getTargetDimensions()[$dimension]; - } else { - return json_encode($node->getContext()->getTargetDimensions()); - } - } -} diff --git a/Configuration/Objects.yaml b/Configuration/Objects.yaml deleted file mode 100644 index 53b87b9..0000000 --- a/Configuration/Objects.yaml +++ /dev/null @@ -1,10 +0,0 @@ - -Sitegeist\Taxonomy\Hooks\ContentRepositoryHooks: - properties: - logger: - object: - factoryObjectName: Neos\Flow\Log\PsrLoggerFactoryInterface - factoryMethodName: get - arguments: - 1: - value: systemLogger diff --git a/Configuration/Policy.yaml b/Configuration/Policy.yaml index ccda8dd..6915edf 100644 --- a/Configuration/Policy.yaml +++ b/Configuration/Policy.yaml @@ -1,7 +1,7 @@ privilegeTargets: 'Neos\Flow\Security\Authorization\Privilege\Method\MethodPrivilege': 'Sitegeist.Taxonomy:Module.Show': - matcher: 'method(Sitegeist\Taxonomy\Controller\ModuleController->(index|vocabulary|taxonomy|changeContext)Action())' + matcher: 'method(Sitegeist\Taxonomy\Controller\ModuleController->(index|vocabulary|taxonomy|changeDimension)Action())' 'Sitegeist.Taxonomy:Module.ManageVocabularyActions': matcher: 'method(Sitegeist\Taxonomy\Controller\ModuleController->(newVocabulary|createVocabulary|editVocabulary|updateVocabulary|deleteVocabulary)Action())' 'Sitegeist.Taxonomy:Module.ManageTaxonomyActions': diff --git a/Configuration/Routes.yaml b/Configuration/Routes.yaml index a771047..200ba9f 100644 --- a/Configuration/Routes.yaml +++ b/Configuration/Routes.yaml @@ -1,9 +1,9 @@ - name: 'Sitegeist.Taxonomy: Secondary Inspector' - uriPattern: 'taxonomy/secondary-inspector/{@action}' + uriPattern: 'neos/taxonomy/secondary-inspector/{@action}' defaults: '@package': 'Sitegeist.Taxonomy' '@subpackage': '' '@controller': 'SecondaryInspector' '@format': 'json' - httpMethods: ['GET'] \ No newline at end of file + httpMethods: ['GET'] diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index f54f8ac..6c0fb52 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -9,22 +9,7 @@ Sitegeist: additionalTaxonomyFieldPrototypes: [] contentRepository: - rootNodeName: 'taxonomies' - rootNodeType: 'Sitegeist.Taxonomy:Root' - vocabularyNodeType: 'Sitegeist.Taxonomy:Vocabulary' - taxonomyNodeType: 'Sitegeist.Taxonomy:Taxonomy' - - Silhouettes: - properties: - taxonomyReferences: - type: references - ui: - label: 'Taxonomy References' - inspector: - editor: 'Sitegeist.Taxonomy:TaxonomyEditor' - editorOptions: - startingPoint: '/taxonomies' - placeholder: 'assign Taxonomies' + identifier: 'default' Neos: Neos: @@ -66,10 +51,6 @@ Neos: startingPoint: '/taxonomies' placeholder: 'assign Taxonomies' - Fusion: - defaultContext: - Taxonomy: \Sitegeist\Taxonomy\Eel\TaxonomyHelper - Flow: mvc: routes: @@ -80,7 +61,7 @@ Neos: providers: Neos.Neos:Backend: requestPatterns: - 'Sitegeist.Taxonomy:secondaryInspector': + 'Sitegeist.Taxonomy:SecondaryInspector': pattern: ControllerObjectName patternOptions: controllerObjectNamePattern: 'Sitegeist\Taxonomy\Controller\SecondaryInspectorController' diff --git a/NodeTypes/Mixin.Referencable.yaml b/NodeTypes/Mixin.Referencable.yaml new file mode 100644 index 0000000..c4996f1 --- /dev/null +++ b/NodeTypes/Mixin.Referencable.yaml @@ -0,0 +1,5 @@ +# @deprecated use Sitegeist.Taxonomy:Mixin.TaxonomyReferences instead +Sitegeist.Taxonomy:Mixin.Referencable: + abstract: true + superTypes: + 'Sitegeist.Taxonomy:Mixin.TaxonomyReferences': true diff --git a/Configuration/NodeTypes.Mixin.Referencable.yaml b/NodeTypes/Mixin.TaxonomyReferences.yaml similarity index 72% rename from Configuration/NodeTypes.Mixin.Referencable.yaml rename to NodeTypes/Mixin.TaxonomyReferences.yaml index 7cb632a..d0e6621 100644 --- a/Configuration/NodeTypes.Mixin.Referencable.yaml +++ b/NodeTypes/Mixin.TaxonomyReferences.yaml @@ -1,4 +1,4 @@ -Sitegeist.Taxonomy:Mixin.Referencable: +Sitegeist.Taxonomy:Mixin.TaxonomyReferences: abstract: true ui: inspector: @@ -18,5 +18,6 @@ Sitegeist.Taxonomy:Mixin.Referencable: group: taxonomy editor: 'Sitegeist.Taxonomy:TaxonomyEditor' editorOptions: - startingPoint: '/taxonomies' + nodeTypes: [ 'Sitegeist.Taxonomy:Taxonomy' ] + startingPoint: '/' placeholder: 'assign Taxonomies' diff --git a/Configuration/NodeTypes.Root.yaml b/NodeTypes/Root.yaml similarity index 79% rename from Configuration/NodeTypes.Root.yaml rename to NodeTypes/Root.yaml index f071fc0..9c2e41f 100644 --- a/Configuration/NodeTypes.Root.yaml +++ b/NodeTypes/Root.yaml @@ -1,7 +1,7 @@ Sitegeist.Taxonomy:Root: label: ${'Taxonomy:Root'} superTypes: - 'Neos.Neos:Node': TRUE + 'Neos.ContentRepository:Root': TRUE constraints: nodeTypes: '*': FALSE diff --git a/Configuration/NodeTypes.Taxonomy.yaml b/NodeTypes/Taxonomy.yaml similarity index 75% rename from Configuration/NodeTypes.Taxonomy.yaml rename to NodeTypes/Taxonomy.yaml index 449c459..c17014a 100644 --- a/Configuration/NodeTypes.Taxonomy.yaml +++ b/NodeTypes/Taxonomy.yaml @@ -1,5 +1,5 @@ Sitegeist.Taxonomy:Taxonomy: - label: "${String.stripTags(q(node).property('title') + ': ' + q(node).property('_path'))}" + label: "${String.stripTags(q(node).property('title'))}" ui: label: i18n icon: 'icon-tag' diff --git a/Configuration/NodeTypes.Vocabulary.yaml b/NodeTypes/Vocabulary.yaml similarity index 75% rename from Configuration/NodeTypes.Vocabulary.yaml rename to NodeTypes/Vocabulary.yaml index b3d867e..89657a7 100644 --- a/Configuration/NodeTypes.Vocabulary.yaml +++ b/NodeTypes/Vocabulary.yaml @@ -1,5 +1,5 @@ Sitegeist.Taxonomy:Vocabulary: - label: "${String.stripTags(q(node).property('title') + ': ' + q(node).property('_path'))}" + label: "${String.stripTags(q(node).property('title'))}" ui: label: i18n icon: 'icon-tags' diff --git a/README.md b/README.md index 12c5ba4..ef67b45 100644 --- a/README.md +++ b/README.md @@ -36,33 +36,17 @@ We use semantic-versioning, so every breaking change will increase the major ver Sitegeist.Taxonomy defines three basic node types: -- `Sitegeist.Taxonomy:Root` - The root node at the path `/taxonomies`, allows only vocabulary nodes as children +- `Sitegeist.Taxonomy:Root` - The root node at the path `/`, allows only vocabulary nodes as children - `Sitegeist.Taxonomy:Vocabulary` - The root of a hierarchy of meaning, allows only taxonomies nodes as children -- `Sitegeist.Taxonomy:Taxonomy` - An item in the hierarchy that represents a specific meaning allows only taxonomy - nodes as children - -If you have to enforce the existence of a specific vocabulary or taxonomy, you can use a derived node type: +- `Sitegeist.Taxonomy:Taxonomy` - An item in the hierarchy that represents a specific meaning allows only taxonomy nodes as children ```YAML - Vendor.Site:Taxonomy.Root: - superTypes: - Sitegeist.Taxonomy:Root: TRUE + Sitegeist.Taxonomy:Root: childNodes: animals: type: 'Sitegeist.Taxonomy:Vocabulary' ``` -And configure the taxonomy package to use this root node type instead of the default: - -```YAML - Sitegeist: - Taxonomy: - contentRepository: - rootNodeType: 'Vendor.Site:Taxonomy.Root' - vocabularyNodeType: 'Sitegeist.Taxonomy:Vocabulary' - taxonomyNodeType: 'Sitegeist.Taxonomy:Taxonomy' -``` - ## Referencing taxonomies Since taxonomies are nodes, they are simply referenced via `reference` or `references` properties: @@ -76,7 +60,7 @@ Since taxonomies are nodes, they are simply referenced via `reference` or `refer group: taxonomy editorOptions: nodeTypes: ['Sitegeist.Taxonomy:Taxonomy'] - startingPoint: '/taxonomies' + startingPoint: '/' placeholder: 'assign Taxonomies' ``` @@ -88,7 +72,7 @@ startingPoint: ui: inspector: editorOptions: - startingPoint: '/taxonomies/animals/mammals' + startingPoint: '//animals/mammals' ``` ## Content-Dimensions @@ -97,14 +81,46 @@ Vocabularies and Taxonomies will always be created in all base dimensions. This always be referenced. The title and description of a taxons and vocabularies can be translated as is required for the project. +## Querying Taxonomies + +The flow Query operations `referenceNodes()` and `backReferenceNodes` in combination to search for documents that have +similar taxons assigned. + +```neosfusion +similarDocuments = ${ + q(documentNode) + .referenceNodes('taxonomyReferences') // the taxons the current document references + .backReferenceNodes('taxonomyReferences') // all nodes that reference one of the same taxons + .filter('[instanceof Neos.Neos:Document]') // only documents + .remove(documentNode) // but nut the current one + .get() + } +``` + +The package includes the following flowQuery operations: + +- `referencedTaxonomies()`: the taxons referenced by the documents in flowQuery context +- `referencingTaxonomies()`: the documents referencing by the taxons in flowQuery context +- `subTaxonomies()` : sub-taxons of taxons in the context +- `withSubTaxonomies()`: current taxons in the context plus sub-taxons + +```neosfusion +similarDocuments = ${ + q(documentNode) + .referencedTaxonomies() // the taxons the current document references + .withSubTaxonomies() // including all sub taxons + .referencingTaxonomies() // the documents that reference the same taxons + .remove(documentNode) // but nut the current one + .get() + } +``` + ## CLI Commands The taxonomy package includes some CLI commands for managing the taxonomies. -- `taxonomy:list` List all taxonomy vocabularies -- `taxonomy:import` Import taxonomy content, expects filename + vocabulary-name (with globbing) -- `taxonomy:export` Export taxonomy content, expects filename + vocabulary-name (with globbing) -- `taxonomy:prune` Prune taxonomy content, expects vocabulary-name (with globbing) +- `taxonomy:vocabularies` List all vocabularies +- `taxonomy:taxonomies` List taxonomies inside a vocabulary ## Privileges diff --git a/Resources/Private/Fusion/Backend/Form/Taxonomy.fusion b/Resources/Private/Fusion/Backend/Form/Taxonomy.fusion index 1e53440..5b9f3de 100644 --- a/Resources/Private/Fusion/Backend/Form/Taxonomy.fusion +++ b/Resources/Private/Fusion/Backend/Form/Taxonomy.fusion @@ -5,26 +5,41 @@ prototype(Sitegeist.Taxonomy:Form.Taxonomy) < prototype(Neos.Fusion:Component) { i18nTaxonomy = ${Translation.value('').package("Sitegeist.Taxonomy").source('NodeTypes/Taxonomy')} targetAction = null - taxonomy = null - defaultTaxonomy = null - parent = null - vocabulary = null + taxonomyNode = null + parentNode = null additionalFieldPrototypeNames = ${Configuration.setting('Sitegeist.Taxonomy.backendModule.additionalTaxonomyFieldPrototypes')} renderer = afx` - + - - + +
+
+ + +
+
- +
diff --git a/Resources/Private/Fusion/Backend/Form/Vocabulary.fusion b/Resources/Private/Fusion/Backend/Form/Vocabulary.fusion index 07b7b0a..8cee6ed 100644 --- a/Resources/Private/Fusion/Backend/Form/Vocabulary.fusion +++ b/Resources/Private/Fusion/Backend/Form/Vocabulary.fusion @@ -4,31 +4,45 @@ prototype(Sitegeist.Taxonomy:Form.Vocabulary) < prototype(Neos.Fusion:Component) i18nVocabulary = ${Translation.value('').package("Sitegeist.Taxonomy").source('NodeTypes/Vocabulary')} targetAction = null - vocabulary = null - defaultVocabulary = null - taxonomyRoot = null + rootNode = null + vocabularyNode = null + additionalFieldPrototypeNames = ${Configuration.setting('Sitegeist.Taxonomy.backendModule.additionalVocabularyFieldPrototypes')} renderer = afx` - - - - + + +
+
+ + +
+
- +
@@ -39,8 +53,8 @@ prototype(Sitegeist.Taxonomy:Form.Vocabulary) < prototype(Neos.Fusion:Component)
@@ -50,7 +64,7 @@ prototype(Sitegeist.Taxonomy:Form.Vocabulary) < prototype(Neos.Fusion:Component) {props.i18nMain.id('generic.cancel')} diff --git a/Resources/Private/Fusion/Backend/Fragments/DimensionSelector.fusion b/Resources/Private/Fusion/Backend/Fragments/DimensionSelector.fusion new file mode 100644 index 0000000..b8f2741 --- /dev/null +++ b/Resources/Private/Fusion/Backend/Fragments/DimensionSelector.fusion @@ -0,0 +1,33 @@ +prototype(Sitegeist.Taxonomy:Views.Fragments.DimensionSelector) < prototype(Neos.Fusion:Component) { + + targetAction = null + targetProperty = null + contextNode = null + + renderer = afx` + + + + + + + {iterator.isFirst ? '' : ' '} + + {String.firstLetterToUpperCase(dimensionKey)}: {Neos.Dimension.currentValue(props.contextNode, dimensionKey).value} + + {String.firstLetterToUpperCase(dimensionKey)}: {dimensionValue.value} + + + + + + ` +} diff --git a/Resources/Private/Fusion/Backend/Fragments/LanguageSelector.fusion b/Resources/Private/Fusion/Backend/Fragments/LanguageSelector.fusion deleted file mode 100644 index 11016e2..0000000 --- a/Resources/Private/Fusion/Backend/Fragments/LanguageSelector.fusion +++ /dev/null @@ -1,34 +0,0 @@ -prototype(Sitegeist.Taxonomy:Views.Fragments.LanguageSelector) < prototype(Neos.Fusion:Component) { - - targetAction = null - targetProperty = null - contentDimensionOptions = null - contextNode = null - - renderer = afx` - - - - - - - {iterator.isFirst ? '' : ' '} - - - {presetName} - - - - - - - ` -} diff --git a/Resources/Private/Fusion/Backend/Views/Taxonomy.Edit.fusion b/Resources/Private/Fusion/Backend/Views/Taxonomy.Edit.fusion index 56f1ae3..18e1d05 100644 --- a/Resources/Private/Fusion/Backend/Views/Taxonomy.Edit.fusion +++ b/Resources/Private/Fusion/Backend/Views/Taxonomy.Edit.fusion @@ -5,13 +5,11 @@ prototype(Sitegeist.Taxonomy:Views.Module.Taxonomy.Edit) < prototype(Neos.Fusion i18nTaxonomy = ${Translation.value('').package("Sitegeist.Taxonomy").source('NodeTypes/Taxonomy')} renderer = afx` - {props.i18nMain.id('taxon')}: {taxonomy.properties.title} - {props.i18nMain.id('generic.default')}: {defaultTaxonomy.properties.title} + {props.i18nMain.id('taxon')}: {taxonomyNode.properties.title} [{taxonomyNode.nodeName.value}] ` diff --git a/Resources/Private/Fusion/Backend/Views/Taxonomy.List.fusion b/Resources/Private/Fusion/Backend/Views/Taxonomy.List.fusion index 30de808..6921eb3 100644 --- a/Resources/Private/Fusion/Backend/Views/Taxonomy.List.fusion +++ b/Resources/Private/Fusion/Backend/Views/Taxonomy.List.fusion @@ -7,42 +7,39 @@ prototype(Sitegeist.Taxonomy:Views.Module.Taxonomy.List) < prototype(Neos.Fusion renderer = afx`
- {props.i18n.id('vocabulary')} {vocabulary.properties.title} - ({defaultVocabulary.properties.title}) + {props.i18n.id('vocabulary')} "{vocabularyNode.properties.title}" [{vocabularyNode.nodeName.value}]
-

- {vocabulary.properties.description} + {vocabularyNode.properties.description}

-

+

{props.i18n.id('vocabulary.empty')}

- +
- - - + +
{props.i18nTaxonomy.id('properties.title')} - {defaultVocabulary ? 'Default' : ''} + {props.i18nTaxonomy.id('properties.description')}
@@ -50,7 +47,7 @@ prototype(Sitegeist.Taxonomy:Views.Module.Taxonomy.List) < prototype(Neos.Fusion
@@ -59,7 +56,7 @@ prototype(Sitegeist.Taxonomy:Views.Module.Taxonomy.List) < prototype(Neos.Fusion   @@ -71,23 +68,21 @@ prototype(Sitegeist.Taxonomy:Views.Module.Taxonomy.List) < prototype(Neos.Fusion prototype(Sitegeist.Taxonomy:Views.Module.Taxonomy.List.Item) < prototype(Neos.Fusion:Component) { - taxon = null + + taxonomySubtree = null renderer = afx` - + 1}>       - -   - {props.taxon.node.properties.title} - - - {props.taxon.defaultNode ? props.taxon.defaultNode.properties.title : ''} + +     + {props.taxonomySubtree.node.properties.title} [{props.taxonomySubtree.node.nodeName.value}] - {props.taxon.node.properties.description} + {props.taxonomySubtree.node.properties.description}
@@ -96,7 +91,7 @@ prototype(Sitegeist.Taxonomy:Views.Module.Taxonomy.List.Item) < prototype(Neos.F @@ -105,33 +100,33 @@ prototype(Sitegeist.Taxonomy:Views.Module.Taxonomy.List.Item) < prototype(Neos.F   - +   -
+
-
Do you really want to delete the taxonomy "{taxon.node.properties.title}"? This action cannot be undone.
+
Do you really want to delete the taxonomySubtreeomy "{ props.taxonomySubtree.node.properties.title}"? This action cannot be undone.
@@ -148,8 +143,8 @@ prototype(Sitegeist.Taxonomy:Views.Module.Taxonomy.List.Item) < prototype(Neos.F - - + + ` diff --git a/Resources/Private/Fusion/Backend/Views/Taxonomy.New.fusion b/Resources/Private/Fusion/Backend/Views/Taxonomy.New.fusion index 6f6fea6..67ca02c 100644 --- a/Resources/Private/Fusion/Backend/Views/Taxonomy.New.fusion +++ b/Resources/Private/Fusion/Backend/Views/Taxonomy.New.fusion @@ -4,11 +4,11 @@ prototype(Sitegeist.Taxonomy:Views.Module.Taxonomy.New) < prototype(Neos.Fusion: i18nTaxonomy = ${Translation.value('').package("Sitegeist.Taxonomy").source('NodeTypes/Taxonomy')} renderer = afx` - {props.i18nMain.id('taxon.createBelow')} "{parent.properties.title}" + {props.i18nMain.id('taxon.createBelow')}: "{parentNode.properties.title}" [{parentNode.nodeName.value}] ` } diff --git a/Resources/Private/Fusion/Backend/Views/Vocabulary.Edit.fusion b/Resources/Private/Fusion/Backend/Views/Vocabulary.Edit.fusion index e31edec..b3aba80 100644 --- a/Resources/Private/Fusion/Backend/Views/Vocabulary.Edit.fusion +++ b/Resources/Private/Fusion/Backend/Views/Vocabulary.Edit.fusion @@ -5,14 +5,13 @@ prototype(Sitegeist.Taxonomy:Views.Module.Vocabulary.Edit) < prototype(Neos.Fusi renderer = afx`
- {props.i18nMain.id('vocabulary')}: {vocabulary.properties.title} + {props.i18nMain.id('vocabulary')}: "{vocabularyNode.properties.title}" [{vocabularyNode.nodeName.value}] {props.i18nMain.id('generic.default')}: {defaultVocabulary.properties.title}
` diff --git a/Resources/Private/Fusion/Backend/Views/Vocabulary.List.fusion b/Resources/Private/Fusion/Backend/Views/Vocabulary.List.fusion index ed34f3f..d418ed3 100644 --- a/Resources/Private/Fusion/Backend/Views/Vocabulary.List.fusion +++ b/Resources/Private/Fusion/Backend/Views/Vocabulary.List.fusion @@ -8,11 +8,10 @@ prototype(Sitegeist.Taxonomy:Views.Module.Vocabulary.List) < prototype(Neos.Fusi {props.i18n.id('vocabularies')}
-
@@ -27,36 +26,40 @@ prototype(Sitegeist.Taxonomy:Views.Module.Vocabulary.List) < prototype(Neos.Fusi
+

- - {vocabulary.node.properties.title} ({vocabulary.defaultNode.properties.title}) + + {vocabulary.properties.title} [{vocabulary.nodeName.value}]

-

{vocabulary.node.properties.description}

+

{vocabulary.properties.description}

- -
+ +
-
Do you really want to delete the vocabulary "{vocabulary.node.properties.title}"? This action cannot be undone.
+
Do you really want to delete the vocabulary "{vocabulary.properties.title}"? This action cannot be undone.