diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e2067501d..bbb05464c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,7 +36,11 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.2 + + - name: Add additional dependencies + run: | + composer require --no-update --no-interaction neos/contentgraph-doctrinedbaladapter:"~9.0.0" - name: Cache dependencies uses: actions/cache@v3 @@ -47,13 +51,13 @@ jobs: - name: Install dependencies uses: php-actions/composer@v6 with: - php_version: 8.1 + php_version: 8.2 version: 2 - name: PHPStan uses: php-actions/phpstan@v3 with: - php_version: 8.1 + php_version: 8.2 version: 2.1.17 command: analyse path: 'Classes/' @@ -68,8 +72,22 @@ jobs: strategy: fail-fast: false matrix: - php-versions: ['8.1'] - flow-versions: ['8.3'] + php-versions: ['8.2'] + flow-versions: ['9.0'] + + services: + mariadb: + # see https://mariadb.com/kb/en/mariadb-server-release-dates/ + # this should be a current release, e.g. the LTS version + image: mariadb:10.8 + env: + MYSQL_USER: neos + MYSQL_PASSWORD: neos + MYSQL_DATABASE: neos_functional_testing + MYSQL_ROOT_PASSWORD: neos + ports: + - "3306:3306" + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 steps: - uses: actions/checkout@v3 @@ -96,12 +114,11 @@ jobs: run: | git clone https://github.com/neos/flow-base-distribution.git -b ${{ matrix.flow-versions }} ${FLOW_FOLDER} cd ${FLOW_FOLDER} - composer require --no-update --no-interaction flowpack/entity-usage:"^1.1" - composer require --no-update --no-interaction flowpack/entity-usage-databasestorage:"^0.1" git -C ../${{ env.PACKAGE_FOLDER }} checkout -b build composer config repositories.package '{ "type": "path", "url": "../${{ env.PACKAGE_FOLDER }}", "options": { "symlink": false } }' composer require --no-update --no-interaction flowpack/media-ui:"dev-build as dev-${PACKAGE_TARGET_VERSION}" + composer require --no-update --no-interaction neos/contentgraph-doctrinedbaladapter:"~9.0.0" - name: Composer Install run: | @@ -113,11 +130,37 @@ jobs: cd ${FLOW_FOLDER} bin/phpunit --colors -c Build/BuildEssentials/PhpUnit/UnitTests.xml Packages/Application/Flowpack.Media.Ui/Tests/Unit/ + - name: Setup Flow configuration + run: | + cd ${FLOW_FOLDER} + rm -f Configuration/Testing/Settings.yaml + cat <> Configuration/Testing/Settings.yaml + Neos: + Flow: + persistence: + backendOptions: + host: '127.0.0.1' + driver: pdo_mysql + user: 'neos' + password: 'neos' + dbname: 'neos_functional_testing' + EOF + - name: Run Functional tests run: | cd ${FLOW_FOLDER} bin/phpunit --colors -c Build/BuildEssentials/PhpUnit/FunctionalTests.xml Packages/Application/Flowpack.Media.Ui/Tests/Functional/ + - name: Show log on failure + if: ${{ failure() }} + run: | + cd ${FLOW_PATH_ROOT} + cat Data/Logs/System_Testing.log + for file in Data/Logs/Exceptions/*; do + echo $file + cat $file + done + js-unit-tests: runs-on: ubuntu-latest diff --git a/Classes/Domain/Model/AssetSource/NeosAssetProxyRepository.php b/Classes/Domain/Model/AssetSource/NeosAssetProxyRepository.php index 2f2a36fd2..11a89e421 100644 --- a/Classes/Domain/Model/AssetSource/NeosAssetProxyRepository.php +++ b/Classes/Domain/Model/AssetSource/NeosAssetProxyRepository.php @@ -111,7 +111,7 @@ public function orderBy(array $orderings): void $this->assetRepository->setDefaultOrderings($orderings); } - public function filterByType(AssetTypeFilter $assetType = null): void + public function filterByType(?AssetTypeFilter $assetType = null): void { $this->assetTypeFilter = (string)$assetType ?: 'All'; $this->initializeObject(); @@ -126,7 +126,7 @@ public function filterByMediaType(string $mediaType): void * NOTE: This needs to be refactored to use an asset collection identifier instead of Media's domain model before * it can become a public API for other asset sources. */ - public function filterByCollection(AssetCollection $assetCollection = null): void + public function filterByCollection(?AssetCollection $assetCollection = null): void { $this->activeAssetCollection = $assetCollection; } diff --git a/Classes/GraphQL/MediaApi.php b/Classes/GraphQL/MediaApi.php index 532c2bd0a..afe36dd04 100644 --- a/Classes/GraphQL/MediaApi.php +++ b/Classes/GraphQL/MediaApi.php @@ -280,13 +280,12 @@ public function assetUsageCount(Types\AssetId $id, Types\AssetSourceId $assetSou #[Query] public function unusedAssets(int $limit = 20, int $offset = 0): Types\Assets { - $assets = []; try { - $assets = $this->usageDetailsService->getUnusedAssets($limit, $offset, Types\AssetSourceId::default()); + return $this->usageDetailsService->getUnusedAssets($limit, $offset, Types\AssetSourceId::default()); } catch (MediaUiException $e) { $this->logger->error('Could not retrieve unused assets', ['exception' => $e]); } - return Types\Assets::fromAssets($assets); + return Types\Assets::empty(); } #[Description('Provides a list of changes to assets since a given timestamp')] diff --git a/Classes/GraphQL/Types/AssetCollection.php b/Classes/GraphQL/Types/AssetCollection.php index e560947f0..abee2bbff 100644 --- a/Classes/GraphQL/Types/AssetCollection.php +++ b/Classes/GraphQL/Types/AssetCollection.php @@ -17,4 +17,9 @@ private function __construct( public readonly ?AssetCollectionPath $path = null, ) { } + + public function equals(?AssetCollection $assetCollection): bool + { + return $assetCollection !== null && $this->id->equals($assetCollection->id); + } } diff --git a/Classes/GraphQL/Types/AssetCollectionId.php b/Classes/GraphQL/Types/AssetCollectionId.php index 0f73d18c9..c01a16c13 100644 --- a/Classes/GraphQL/Types/AssetCollectionId.php +++ b/Classes/GraphQL/Types/AssetCollectionId.php @@ -38,4 +38,9 @@ public function isUnassigned(): bool { return $this->value === self::UNASSIGNED; } + + public function equals(?AssetCollectionId $id): bool + { + return $this->value === $id?->value; + } } diff --git a/Classes/GraphQL/Types/AssetCollections.php b/Classes/GraphQL/Types/AssetCollections.php index 0104d563f..a18d85704 100644 --- a/Classes/GraphQL/Types/AssetCollections.php +++ b/Classes/GraphQL/Types/AssetCollections.php @@ -13,6 +13,9 @@ #[ListBased(itemClassName: AssetCollection::class)] final class AssetCollections implements \IteratorAggregate { + /** + * @param AssetCollection[] $collections + */ private function __construct(public readonly array $collections) { } diff --git a/Classes/GraphQL/Types/AssetSourceId.php b/Classes/GraphQL/Types/AssetSourceId.php index 31fdf0997..fc2ec9bf4 100644 --- a/Classes/GraphQL/Types/AssetSourceId.php +++ b/Classes/GraphQL/Types/AssetSourceId.php @@ -11,7 +11,7 @@ #[Description('Unique identifier of an Asset source (e.g. "neos")')] #[Flow\Proxy(false)] #[StringBased] -final class AssetSourceId implements \JsonSerializable +final class AssetSourceId implements \JsonSerializable, \Stringable { private function __construct(public readonly string $value) { @@ -26,4 +26,14 @@ public static function default(): self { return new self('neos'); } + + public static function fromString(string $value): self + { + return new self($value); + } + + public function __toString(): string + { + return $this->value; + } } diff --git a/Classes/Service/AssetCollectionService.php b/Classes/Service/AssetCollectionService.php index 63f434f6a..cf1cb9755 100644 --- a/Classes/Service/AssetCollectionService.php +++ b/Classes/Service/AssetCollectionService.php @@ -9,12 +9,11 @@ use Flowpack\Media\Ui\Domain\Model\HierarchicalAssetCollectionInterface; use Flowpack\Media\Ui\GraphQL\Types; use Flowpack\Media\Ui\Utility\AssetCollectionUtility; -use Neos\ContentRepository\Domain\Service\ContextFactoryInterface; use Neos\Flow\Annotations as Flow; use Neos\Flow\ObjectManagement\ObjectManagerInterface; use Neos\Media\Domain\Model\AssetCollection; use Neos\Media\Domain\Repository\AssetCollectionRepository; -use Neos\Neos\Domain\Service\ContentContext; +use Neos\Neos\Domain\Repository\DomainRepository; #[Flow\Scope('singleton')] class AssetCollectionService @@ -37,11 +36,8 @@ class AssetCollectionService #[Flow\Inject] protected AssetCollectionRepository $assetCollectionRepository; - /** - * @var ContextFactoryInterface - */ #[Flow\Inject] - protected $contextFactory; + protected DomainRepository $domainRepository; /** * Queries the asset count for all asset collections once and caches the result. @@ -98,11 +94,11 @@ public function updatePathForNestedAssetCollections(HierarchicalAssetCollectionI */ public function getDefaultCollectionForCurrentSite(): ?AssetCollection { - /** @var ContentContext $context */ - $context = $this->contextFactory->create([ - 'workspaceName' => 'live', - ]); + $domain = $this->domainRepository->findOneByActiveRequest(); + if ($domain !== null) { + return $domain->getSite()->getAssetCollection(); + } - return $context->getCurrentSite()?->getAssetCollection(); + return null; } } diff --git a/Classes/Service/UsageDetailsService.php b/Classes/Service/UsageDetailsService.php index 989153a1d..27a760294 100644 --- a/Classes/Service/UsageDetailsService.php +++ b/Classes/Service/UsageDetailsService.php @@ -14,23 +14,22 @@ * source code. */ +use Doctrine\DBAL\Connection; use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\NonUniqueResultException; -use Doctrine\ORM\NoResultException; use Flowpack\Media\Ui\Domain\Model\Dto\AssetUsageDetails; use Flowpack\Media\Ui\Domain\Model\Dto\UsageMetadataSchema; -use Flowpack\Media\Ui\Exception; +use Flowpack\Media\Ui\GraphQL\Context\AssetSourceContext; use Flowpack\Media\Ui\GraphQL\Types; use GuzzleHttp\Psr7\ServerRequest; use GuzzleHttp\Psr7\Uri; -use Neos\ContentRepository\Domain\Model\Node; -use Neos\ContentRepository\Domain\Model\NodeInterface; -use Neos\ContentRepository\Domain\Repository\WorkspaceRepository; -use Neos\ContentRepository\Domain\Service\NodeTypeManager; -use Neos\ContentRepository\Exception\NodeConfigurationException; +use Neos\ContentRepository\Core\NodeType\NodeTypeNames; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindClosestNodeFilter; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\NodeType\NodeTypeCriteria; +use Neos\ContentRepository\Core\Projection\ContentGraph\Node; +use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; +use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; use Neos\Flow\Core\Bootstrap; -use Neos\Flow\Exception as FlowException; use Neos\Flow\Http\Exception as HttpException; use Neos\Flow\Http\HttpRequestHandlerInterface; use Neos\Flow\I18n\Translator; @@ -39,19 +38,25 @@ use Neos\Flow\Mvc\Routing\UriBuilder; use Neos\Flow\ObjectManagement\ObjectManagerInterface; use Neos\Flow\Package\PackageManager; +use Neos\Flow\Reflection\Exception\ClassLoadingForReflectionFailedException; +use Neos\Flow\Reflection\Exception\InvalidClassException; use Neos\Flow\Reflection\ReflectionService; +use Neos\Flow\Security\Context as SecurityContext; +use Neos\Media\Domain\Model\Asset; use Neos\Media\Domain\Model\AssetInterface; use Neos\Media\Domain\Model\AssetVariantInterface; use Neos\Media\Domain\Service\AssetService; use Neos\Media\Domain\Strategy\AssetUsageStrategyInterface; +use Neos\Neos\AssetUsage\AssetUsageStrategy; +use Neos\Neos\AssetUsage\Domain\AssetUsageRepository; +use Neos\Neos\AssetUsage\Dto\AssetUsageReference; use Neos\Neos\Controller\BackendUserTranslationTrait; -use Neos\Neos\Controller\CreateContentContextTrait; -use Neos\Neos\Domain\Model\Dto\AssetUsageInNodeProperties; use Neos\Neos\Domain\Model\Site; +use Neos\Neos\Domain\NodeLabel\NodeLabelGeneratorInterface; use Neos\Neos\Domain\Repository\SiteRepository; -use Neos\Neos\Domain\Service\UserService as DomainUserService; -use Neos\Neos\Domain\Strategy\AssetUsageInNodePropertiesStrategy; -use Neos\Neos\Service\LinkingService; +use Neos\Neos\Domain\Repository\WorkspaceMetadataAndRoleRepository; +use Neos\Neos\Domain\Service\NodeTypeNameFactory; +use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; use Neos\Neos\Service\UserService; use function Wwwision\Types\instantiate; @@ -59,38 +64,34 @@ #[Flow\Scope('singleton')] final class UsageDetailsService { - # TODO: Use ContextFactory instead of trait - use CreateContentContextTrait; use BackendUserTranslationTrait; - #[Flow\InjectConfiguration('contentDimensions', 'Neos.ContentRepository')] - protected array $contentDimensionsConfiguration = []; - private array $accessibleWorkspaces = []; public function __construct( - protected readonly Translator $translator, - protected readonly PackageManager $packageManager, - protected readonly EntityManagerInterface $entityManager, - protected readonly ReflectionService $reflectionService, - protected readonly LinkingService $linkingService, - protected readonly ObjectManagerInterface $objectManager, - protected readonly DomainUserService $domainUserService, - protected readonly AssetService $assetService, - protected readonly WorkspaceRepository $workspaceRepository, - protected readonly NodeTypeManager $nodeTypeManager, - protected readonly SiteRepository $siteRepository, - protected readonly UserService $userService, - protected readonly Bootstrap $bootstrap, + private readonly Translator $translator, + private readonly PackageManager $packageManager, + private readonly EntityManagerInterface $entityManager, + private readonly ReflectionService $reflectionService, + private readonly ObjectManagerInterface $objectManager, + private readonly AssetService $assetService, + private readonly SiteRepository $siteRepository, + private readonly UserService $userService, + private readonly Bootstrap $bootstrap, + private readonly ContentRepositoryRegistry $contentRepositoryRegistry, + private readonly NodeLabelGeneratorInterface $nodeLabelGenerator, + private readonly WorkspaceMetadataAndRoleRepository $workspaceMetadataAndRoleRepository, + private readonly ContentRepositoryAuthorizationService $contentRepositoryAuthorizationService, + private readonly SecurityContext $securityContext, + private readonly Connection $dbal, + private readonly AssetSourceContext $assetSourceContext, ) { } public function resolveUsagesForAsset(AssetInterface $asset): Types\UsageDetailsGroups { $includeSites = $this->siteRepository->countAll() > 1; - $includeDimensions = count($this->contentDimensionsConfiguration) > 0; - - $groups = array_map(function ($strategy) use ($asset, $includeSites, $includeDimensions) { + $groups = array_filter(array_map(function ($strategy) use ($asset, $includeSites) { $usageByStrategy = [ 'serviceId' => get_class($strategy), 'label' => get_class($strategy), @@ -105,7 +106,7 @@ public function resolveUsagesForAsset(AssetInterface $asset): Types\UsageDetails // Should be solved via an interface in the future if (method_exists($strategy, 'getLabel')) { $usageByStrategy['label'] = $strategy->getLabel(); - } elseif ($strategy instanceof AssetUsageInNodePropertiesStrategy) { + } elseif ($strategy instanceof AssetUsageStrategy) { $usageByStrategy['label'] = $this->translateById('assetUsage.assetUsageInNodePropertiesStrategy.label'); } @@ -114,20 +115,19 @@ public function resolveUsagesForAsset(AssetInterface $asset): Types\UsageDetails $usageByStrategy['usages'] = $strategy->getUsageDetails($asset); } else { // If the strategy does not implement the UsageDetailsProviderInterface, we provide some default usage data - try { - $usageReferences = $strategy->getUsageReferences($asset); - if (count($usageReferences) && $usageReferences[0] instanceof AssetUsageInNodeProperties) { - $usageByStrategy['metadataSchema'] = $this->getNodePropertiesUsageMetadataSchema($includeSites, - $includeDimensions)->toArray(); - $usageByStrategy['usages'] = array_map(function (AssetUsageInNodeProperties $usage) use ( - $includeSites, - $includeDimensions - ) { + $usageReferences = $strategy->getUsageReferences($asset); + if (count($usageReferences) && $usageReferences[0] instanceof AssetUsageReference) { + $includeDimensions = $this->containsContentRepositoryWithDimensions($usageReferences); + $usageByStrategy['metadataSchema'] = $this->getNodePropertiesUsageMetadataSchema( + $includeSites, + $includeDimensions + )->toArray(); + $usageByStrategy['usages'] = array_map( + function (AssetUsageReference $usage) use ($includeSites, $includeDimensions) { return $this->getNodePropertiesUsageDetails($usage, $includeSites, $includeDimensions); - }, $usageReferences); - } - } catch (NodeConfigurationException) { - // TODO: Handle error + }, + $usageReferences + ); } } // TODO: Already return a graphql compatible type before, so we don't have to map it here @@ -138,7 +138,7 @@ static function (AssetUsageDetails $usage) { $usageByStrategy['usages'] ); return instantiate(Types\UsageDetailsGroup::class, $usageByStrategy); - }, $this->getUsageStrategies()); + }, $this->getUsageStrategies())); $groups = array_filter($groups, static function (Types\UsageDetailsGroup $usageByStrategy) { return !$usageByStrategy->usages->isEmpty(); @@ -154,121 +154,180 @@ protected function getNodePropertiesUsageMetadataSchema( $schema = new UsageMetadataSchema(); if ($includeSites) { - $schema->withMetadata('site', $this->translateById('assetUsage.header.site'), - UsageMetadataSchema::TYPE_TEXT); + $schema->withMetadata( + 'site', + $this->translateById('assetUsage.header.site'), + UsageMetadataSchema::TYPE_TEXT + ); } $schema - ->withMetadata('document', $this->translateById('assetUsage.header.document'), - UsageMetadataSchema::TYPE_TEXT) - ->withMetadata('workspace', $this->translateById('assetUsage.header.workspace'), - UsageMetadataSchema::TYPE_TEXT) - ->withMetadata('lastModified', $this->translateById('assetUsage.header.lastModified'), - UsageMetadataSchema::TYPE_DATETIME); + ->withMetadata( + 'document', + $this->translateById('assetUsage.header.document'), + UsageMetadataSchema::TYPE_TEXT + ) + ->withMetadata( + 'workspace', + $this->translateById('assetUsage.header.workspace'), + UsageMetadataSchema::TYPE_TEXT + ) + ->withMetadata( + 'lastModified', + $this->translateById('assetUsage.header.lastModified'), + UsageMetadataSchema::TYPE_DATETIME + ); if ($includeDimensions) { - $schema->withMetadata('contentDimensions', $this->translateById('assetUsage.header.contentDimensions'), - UsageMetadataSchema::TYPE_JSON); + $schema->withMetadata( + 'contentDimensions', + $this->translateById('assetUsage.header.contentDimensions'), + UsageMetadataSchema::TYPE_JSON + ); } return $schema; } protected function getNodePropertiesUsageDetails( - AssetUsageInNodeProperties $usage, + AssetUsageReference $usage, bool $includeSites, bool $includeDimensions ): AssetUsageDetails { - /** @var Node $node */ - $node = $this->getNodeFrom($usage); - $siteNode = $this->getSiteNodeFrom($node); - $site = $siteNode ? $this->siteRepository->findOneByNodeName($siteNode->getName()) : null; - $closestDocumentNode = $node ? $this->getClosestDocumentNode($node) : null; - $accessible = $this->usageIsAccessible($usage->getWorkspaceName()); - $workspace = $this->workspaceRepository->findByIdentifier($usage->getWorkspaceName()); - $label = $accessible && $node ? $node->getLabel() : $this->translateById('assetUsage.assetUsageInNodePropertiesStrategy.inaccessibleNode'); + $accessible = $this->usageIsAccessible($usage); + + $node = null; + $site = null; + $closestDocumentNode = null; + if ($accessible) { + /** @var Node $node */ + $node = $this->getNodeFrom($usage); + $siteNode = $this->getSiteNodeFrom($node); + $site = $siteNode ? $this->siteRepository->findSiteBySiteNode($siteNode) : null; + $closestDocumentNode = $node ? $this->getClosestDocumentNode($node) : null; + } + + $label = $accessible && $node ? $this->nodeLabelGenerator->getLabel($node) : $this->translateById( + 'assetUsage.assetUsageInNodePropertiesStrategy.inaccessibleNode' + ); $url = $accessible && $closestDocumentNode ? $this->buildNodeUri($site, $closestDocumentNode) : ''; + $workspaceMetadata = $this->workspaceMetadataAndRoleRepository->loadWorkspaceMetadata( + $usage->getContentRepositoryId(), + $usage->getWorkspaceName() + ); + $metadata = [ [ 'name' => 'workspace', - 'value' => $workspace ? $workspace->getTitle() : $usage->getWorkspaceName(), + 'value' => $workspaceMetadata ? $workspaceMetadata->title->value : $usage->getWorkspaceName(), ], [ 'name' => 'document', - 'value' => $closestDocumentNode ? $closestDocumentNode->getLabel() : $this->translateById('assetUsage.assetUsageInNodePropertiesStrategy.metadataNotAvailable'), + 'value' => $closestDocumentNode ? $this->nodeLabelGenerator->getLabel( + $closestDocumentNode + ) : $this->translateById('assetUsage.assetUsageInNodePropertiesStrategy.metadataNotAvailable'), + ], + [ + 'name' => 'nodeExists', + 'value' => $node?->name?->value, ], [ 'name' => 'lastModified', - 'value' => $node && $node->getLastPublicationDateTime() ? $node->getLastModificationDateTime()->format(DATE_W3C) : null, + 'value' => $node?->timestamps->lastModified?->format(DATE_W3C) ?? $node?->timestamps->created?->format( + DATE_W3C + ), ] ]; - if ($node) { - if ($includeSites) { - $metadata[] = [ - 'name' => 'site', - 'value' => $site ? $site->getName() : $this->translateById('assetUsage.assetUsageInNodePropertiesStrategy.metadataNotAvailable'), - ]; - } + if ($includeSites) { + $metadata[] = [ + 'name' => 'site', + 'value' => $site ? $site->getName() : $this->translateById( + 'assetUsage.assetUsageInNodePropertiesStrategy.metadataNotAvailable' + ), + ]; + } - // Only add content dimensions if they are configured - if ($includeDimensions) { - $metadata[] = [ - 'name' => 'contentDimensions', - 'value' => json_encode($this->resolveDimensionValuesForNode($node)), - ]; - } + // Only add content dimensions if they are configured + if ($includeDimensions) { + $metadata[] = [ + 'name' => 'contentDimensions', + 'value' => json_encode($node ? ($this->resolveDimensionValuesForNode($node)) : []), + ]; } return new AssetUsageDetails($label, $url, $metadata); } - protected function resolveDimensionValuesForNode(NodeInterface $node): array + protected function resolveDimensionValuesForNode(Node $node): array { $dimensionValues = []; - foreach ($node->getDimensions() as $dimensionName => $dimensionValuesForName) { - $dimensionValues[$this->contentDimensionsConfiguration[$dimensionName]['label'] ?? $dimensionName] = array_map(function ( - $dimensionValue - ) use ($dimensionName) { - return $this->contentDimensionsConfiguration[$dimensionName]['presets'][$dimensionValue]['label'] ?? $dimensionValue; - }, $dimensionValuesForName); + $contentDimensions = $this->contentRepositoryRegistry->get( + $node->contentRepositoryId + )->getContentDimensionSource()->getContentDimensionsOrderedByPriority(); + + foreach ($node->originDimensionSpacePoint->coordinates as $nodeDimensionName => $nodeDimensionValue) { + foreach ($contentDimensions as $contentDimensionName => $contentDimension) { + if ($contentDimensionName === $nodeDimensionName) { + foreach ($contentDimension->values as $presetKey => $preset) { + if ($presetKey === $nodeDimensionValue) { + $dimensionValues[$contentDimension->getConfigurationValue( + 'label' + )][] = $preset->getConfigurationValue('label'); + } + } + } + } } + return $dimensionValues; } - protected function getNodeFrom(AssetUsageInNodeProperties $assetUsage): ?NodeInterface + protected function getNodeFrom(AssetUsageReference $assetUsage): ?Node { - $context = $this->_contextFactory->create( - [ - 'workspaceName' => $assetUsage->getWorkspaceName(), - 'dimensions' => $assetUsage->getDimensionValues(), - 'targetDimensions' => [], - 'invisibleContentShown' => true, - 'removedContentShown' => true - ] - ); - return $context->getNodeByIdentifier($assetUsage->getNodeIdentifier()); + return $this->contentRepositoryRegistry + ->get($assetUsage->getContentRepositoryId()) + ->getContentGraph($assetUsage->getWorkspaceName()) + ->getSubgraph( + $assetUsage + ->getOriginDimensionSpacePoint() + ->toDimensionSpacePoint(), + VisibilityConstraints::withoutRestrictions() + ) + ->findNodeById($assetUsage->getNodeAggregateId()); } - protected function getClosestDocumentNode(NodeInterface $node): ?NodeInterface + protected function getClosestDocumentNode(Node $node): ?Node { $parentNode = $node; - while ($parentNode && !$parentNode->getNodeType()->isOfType('Neos.Neos:Document')) { - $parentNode = $parentNode->getParent(); + $contentRepository = $this->contentRepositoryRegistry->get($parentNode->contentRepositoryId); + while ($parentNode + && !$contentRepository->getNodeTypeManager()->getNodeType( + $parentNode->nodeTypeName + )?->isOfType('Neos.Neos:Document') + ) { + $subgraph = $this->contentRepositoryRegistry->subgraphForNode($parentNode); + $parentNode = $subgraph->findParentNode($parentNode->aggregateId); } return $parentNode; } - protected function usageIsAccessible(string $workspaceName): bool + protected function usageIsAccessible(AssetUsageReference $usage): bool { - if (array_key_exists($workspaceName, $this->accessibleWorkspaces)) { - return $this->accessibleWorkspaces[$workspaceName]; + $cacheKey = $usage->getWorkspaceName()->value; + if (array_key_exists($cacheKey, $this->accessibleWorkspaces)) { + return $this->accessibleWorkspaces[$cacheKey]; } - $workspace = $this->workspaceRepository->findByIdentifier($workspaceName); - $accessible = $this->domainUserService->currentUserCanReadWorkspace($workspace); - $this->accessibleWorkspaces[$workspaceName] = $accessible; - return $accessible; + $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions( + $usage->getContentRepositoryId(), + $usage->getWorkspaceName(), + $this->securityContext->getRoles(), + $this->userService->getBackendUser()?->getId() + ); + + $this->accessibleWorkspaces[$cacheKey] = $workspacePermissions->read; + return $workspacePermissions->read; } /** @@ -280,7 +339,7 @@ protected function usageIsAccessible(string $workspaceName): bool * * @throws HttpException|MissingActionNameException */ - protected function buildNodeUri(?Site $site, NodeInterface $node): string + protected function buildNodeUri(?Site $site, Node $node): string { $requestHandler = $this->bootstrap->getActiveRequestHandler(); @@ -300,7 +359,9 @@ protected function buildNodeUri(?Site $site, NodeInterface $node): string $serverRequest = $serverRequest->withUri(new Uri((string)$domain)); } - $request = ActionRequest::fromHttpRequest($serverRequest);//$this->getActionRequestForUriBuilder($domain ? $domain->getHostname() : null); + $request = ActionRequest::fromHttpRequest( + $serverRequest + ); $uriBuilder = new UriBuilder(); $uriBuilder->setRequest($request); @@ -328,7 +389,9 @@ protected function buildNodeUri(?Site $site, NodeInterface $node): string protected function getUsageStrategies(): array { $usageStrategies = []; - $assetUsageStrategyImplementations = $this->reflectionService->getAllImplementationClassNamesForInterface(AssetUsageStrategyInterface::class); + $assetUsageStrategyImplementations = $this->reflectionService->getAllImplementationClassNamesForInterface( + AssetUsageStrategyInterface::class + ); foreach ($assetUsageStrategyImplementations as $assetUsageStrategyImplementationClassName) { $usageStrategies[] = $this->objectManager->get($assetUsageStrategyImplementationClassName); } @@ -337,88 +400,100 @@ protected function getUsageStrategies(): array /** * Returns all assets which have no usage reference provided by `Flowpack.EntityUsage` - * - * @return AssetInterface[] - * @throws Exception */ - public function getUnusedAssets(int $limit = 20, int $offset = 0, Types\AssetSourceId $assetSourceId = null): array - { - // TODO: This method has to be implemented in a more generic way at some point to increase support with other implementations - $this->canQueryAssetUsage(); - - return $this->entityManager->createQuery(sprintf(/** @lang DQL */ ' - SELECT a - FROM Neos\Media\Domain\Model\Asset a - WHERE - a.assetSourceIdentifier = :assetSourceIdentifier AND - %s AND - NOT EXISTS ( - SELECT e - FROM Flowpack\EntityUsage\DatabaseStorage\Domain\Model\EntityUsage e - WHERE a.Persistence_Object_Identifier = e.entityId - ) - ORDER BY a.lastModified DESC - ', $this->getAssetVariantFilterClause('a'))) - ->setParameter('assetSourceIdentifier', $assetSourceId->value ?? 'neos') - ->setFirstResult($offset) - ->setMaxResults($limit) - ->getResult(); - } + public function getUnusedAssets( + int $limit = 20, + int $offset = 0, + ?Types\AssetSourceId $assetSourceId = null + ): Types\Assets { + $queryBuilder = $this->dbal->createQueryBuilder(); + $unusedAssetIds = []; + $assetSourceIdentifier = $assetSourceId ?? Types\AssetSourceId::default(); - /** - * Checks for the presence of the - * - * @throws Exception - */ - protected function canQueryAssetUsage(): void - { try { - $this->packageManager->getPackage('Flowpack.EntityUsage.DatabaseStorage'); - } catch (FlowException $e) { - throw new Exception('This method requires "flowpack/entity-usage-databasestorage" to be installed.', - 1619178077); + $unusedAssetIds = $queryBuilder + ->select('a.persistence_object_identifier') + ->from('neos_media_domain_model_asset'/** @type Asset */, 'a') + ->leftJoin( + 'a', + AssetUsageRepository::TABLE, + 'u', + 'a.persistence_object_identifier = u.assetid' + ) + ->where('a.assetSourceIdentifier = :assetSourceId') + ->andWhere('a.dtype NOT IN (:assetVariantFilter)') + ->andWhere('u.assetid IS NULL') + ->setParameter('assetVariantFilter', implode(',', $this->getAssetVariantNames())) + ->setParameter('assetSourceId', $assetSourceIdentifier) + ->setFirstResult($offset) + ->setMaxResults($limit) + ->fetchFirstColumn(); + } catch (\Doctrine\DBAL\Exception) { + // TODO: Log the error } + + $assetProxies = array_map( + fn(string $id) => $this->assetSourceContext->getAssetProxy( + Types\AssetId::fromString($id), + $assetSourceIdentifier + ), + $unusedAssetIds + ); + + return Types\Assets::fromAssetProxies($assetProxies); } /** - * Returns a DQL clause filtering any implementation of AssetVariantInterface + * Returns number of assets which have no usage reference provided by `Flowpack.EntityUsage` */ - protected function getAssetVariantFilterClause(string $alias): string + public function getUnusedAssetCount(?Types\AssetSourceId $assetSourceId = null): int { - $variantClassNames = $this->reflectionService->getAllImplementationClassNamesForInterface(AssetVariantInterface::class); + $queryBuilder = $this->dbal->createQueryBuilder(); + $assetSourceIdentifier = $assetSourceId ?? Types\AssetSourceId::default(); - return implode(' AND ', array_map(static function ($className) use ($alias) { - return sprintf("%s NOT INSTANCE OF %s", $alias, $className); - }, $variantClassNames)); + try { + $queryBuilder + ->select('a.persistence_object_identifier') + ->from('neos_media_domain_model_asset'/** @type Asset */, 'a') + ->leftJoin( + 'a', + AssetUsageRepository::TABLE, + 'u', + 'a.persistence_object_identifier = u.assetid' + ) + ->where('a.assetSourceIdentifier = :assetSourceId') + ->andWhere('a.dtype NOT IN (:assetVariantFilter)') + ->andWhere('u.assetid IS NULL') + ->setParameter('assetVariantFilter', implode(',', $this->getAssetVariantNames())) + ->setParameter('assetSourceId', $assetSourceIdentifier); + return (int)$this->dbal + ->fetchOne( + 'SELECT COUNT(*) FROM (' . $queryBuilder->getSQL() . ') s', + $queryBuilder->getParameters() + ); + } catch (\Doctrine\DBAL\Exception) { + // TODO: Log the error + } + return 0; } /** - * Returns number of assets which have no usage reference provided by `Flowpack.EntityUsage` - * - * @throws NoResultException - * @throws NonUniqueResultException - * @throws Exception + * Returns the list of asset variant class names + * @return string[] */ - public function getUnusedAssetCount(): int + protected function getAssetVariantNames(): array { - // TODO: This method has to be implemented in a more generic way at some point to increase support with other implementations - $this->canQueryAssetUsage(); - - return (int)$this->entityManager->createQuery(sprintf(/** @lang DQL */ ' - SELECT COUNT(a.Persistence_Object_Identifier) - FROM Neos\Media\Domain\Model\Asset a - WHERE - a.assetSourceIdentifier = :assetSourceIdentifier AND - %s AND - NOT EXISTS ( - SELECT e - FROM Flowpack\EntityUsage\DatabaseStorage\Domain\Model\EntityUsage e - WHERE a.Persistence_Object_Identifier = e.entityId - ) - ORDER BY a.lastModified DESC - ', $this->getAssetVariantFilterClause('a'))) - ->setParameter('assetSourceIdentifier', 'neos') - ->getSingleScalarResult(); + try { + $variantClassNames = $this->reflectionService->getAllImplementationClassNamesForInterface( + AssetVariantInterface::class + ); + } catch (\Exception) { + return []; + } + + return array_map(static function ($className) { + return strtolower(str_replace('Domain_Model_', '', str_replace('\\', '_', $className))); + }, $variantClassNames); } protected function translateById(string $id): ?string @@ -429,10 +504,34 @@ protected function translateById(string $id): ?string /** * Resolve the site node in the context of the given node */ - protected function getSiteNodeFrom(NodeInterface $node): ?NodeInterface + protected function getSiteNodeFrom(Node $node): ?Node { - // Take the first two path segments of the node path - $sitePath = implode('/', array_slice(explode('/', $node->getPath(), 4), 0, 3)); - return $node->getContext()->getNode($sitePath); + return $this->contentRepositoryRegistry + ->subgraphForNode($node) + ->findClosestNode( + $node->aggregateId, + FindClosestNodeFilter::create( + NodeTypeCriteria::createWithAllowedNodeTypeNames( + NodeTypeNames::with( + NodeTypeNameFactory::forSite() + ) + ) + ) + ); + } + + /** + * @param AssetUsageReference[] $usageReferences + */ + protected function containsContentRepositoryWithDimensions(array $usageReferences): bool + { + foreach ($usageReferences as $usageReference) { + if ($this->contentRepositoryRegistry->get( + $usageReference->getContentRepositoryId() + )->getContentDimensionSource()->getContentDimensionsOrderedByPriority()) { + return true; + } + } + return false; } } diff --git a/Configuration/Settings.Features.yaml b/Configuration/Settings.Features.yaml index 0c77c98b9..08d4505bb 100644 --- a/Configuration/Settings.Features.yaml +++ b/Configuration/Settings.Features.yaml @@ -16,8 +16,8 @@ Neos: # Settings for the property editor propertyEditor: collapsed: false - # Query the usage of each asset when loading them, note: this requires a more performant implementation of the asset usage than the Neos default provides - queryAssetUsage: false + # Query the usage of each asset when loading them + queryAssetUsage: true # Show similar assets, note: this requires an additional package to be installed showSimilarAssets: false # Show variants and the editor to modify them diff --git a/Configuration/Testing/Settings.Features.yaml b/Configuration/Testing/Settings.Features.yaml new file mode 100644 index 000000000..34f052f5a --- /dev/null +++ b/Configuration/Testing/Settings.Features.yaml @@ -0,0 +1,7 @@ +Neos: + Neos: + Ui: + frontendConfiguration: + Flowpack.Media.Ui: + pollForChanges: false + queryAssetUsage: true diff --git a/Resources/Private/Translations/de/Main.xlf b/Resources/Private/Translations/de/Main.xlf index 3f80df8a6..f3706e8b6 100644 --- a/Resources/Private/Translations/de/Main.xlf +++ b/Resources/Private/Translations/de/Main.xlf @@ -1039,10 +1039,6 @@ Not supported: AssetProxyQueryInterface::setLimit does not accept `null`. Nicht unterstützt: AssetProxyQueryInterface::setLimit akzeptiert kein `null`. - - This method requires "flowpack/entity-usage-databasestorage" to be installed.' - Diese Methode erfordert die Installation von "flowpack/entity-usage-databasestorage". - Asset could not be deleted, because it is still in use. Die Datei konnte nicht gelöscht werden, da sie noch verwendet wird. diff --git a/Resources/Private/Translations/en/Main.xlf b/Resources/Private/Translations/en/Main.xlf index d46a81ed2..9fb5f54e8 100644 --- a/Resources/Private/Translations/en/Main.xlf +++ b/Resources/Private/Translations/en/Main.xlf @@ -792,9 +792,6 @@ Not supported: AssetProxyQueryInterface::setLimit does not accept `null`. - - This method requires "flowpack/entity-usage-databasestorage" to be installed. - Asset could not be deleted, because it is still in use. diff --git a/Tests/Functional/AbstractMediaTestCase.php b/Tests/Functional/AbstractMediaTestCase.php index 745cd1095..8ad009ca7 100644 --- a/Tests/Functional/AbstractMediaTestCase.php +++ b/Tests/Functional/AbstractMediaTestCase.php @@ -1,4 +1,5 @@ objectManager = self::$bootstrap->getObjectManager(); + + $this->truncateAndSetupFlowEntities(); + + $this->cleanupPersistentResourcesDirectory(); + self::$bootstrap->getObjectManager()->forgetInstance(ResourceManager::class); + $session = $this->objectManager->get(\Neos\Flow\Session\SessionInterface::class); + if ($session->isStarted()) { + $session->destroy( + sprintf( + 'assure that session is fresh, in setUp() method of functional test %s.', + get_class($this) . '::' . $this->getName() + ) + ); + } + + $privilegeManager = $this->objectManager->get(\Neos\Flow\Security\Authorization\TestingPrivilegeManager::class); + $privilegeManager->reset(); + + if ($this->testableSecurityEnabled === true || static::$testablePersistenceEnabled === true) { + $this->persistenceManager = $this->objectManager->get( + PersistenceManagerInterface::class + ); + } else { + $privilegeManager->setOverrideDecision(true); + } + + // HTTP must be initialized before Session and Security because they rely + // on an HTTP request being available via the request handler: + $this->setupHttp(); + + $session = $this->objectManager->get(\Neos\Flow\Session\SessionInterface::class); + if ($session->isStarted()) { + $session->destroy( + sprintf( + 'assure that session is fresh, in setUp() method of functional test %s.', + get_class($this) . '::' . $this->getName() + ) + ); + } + + $this->setupSecurity(); + } + + protected function persist(): void + { + $this->persistenceManager->persistAll(); + $this->persistenceManager->clearState(); + } + public function tearDown(): void { - $persistenceManager = self::$bootstrap->getObjectManager()->get(PersistenceManagerInterface::class); - if (is_callable([$persistenceManager, 'tearDown'])) { - $persistenceManager->tearDown(); + try { + $this->persistenceManager->persistAll(); + } catch (\Exception $exception) { } - self::$bootstrap->getObjectManager()->forgetInstance(PersistenceManagerInterface::class); - parent::tearDown(); + + //if (is_callable([$this->persistenceManager, 'tearDown'])) { + // $this->persistenceManager->tearDown(); + //} + //$persistenceManager = self::$bootstrap->getObjectManager()->get(PersistenceManagerInterface::class); + //if (is_callable([$persistenceManager, 'tearDown'])) { + // $persistenceManager->tearDown(); + //} + //self::$bootstrap->getObjectManager()->forgetInstance(PersistenceManagerInterface::class); + //parent::tearDown(); } /** * Creates an Image object from a file using a mock resource (in order to avoid a database resource pointer entry) - * @param string $imagePathAndFilename - * @return PersistentResource */ - protected function getMockResourceByImagePath($imagePathAndFilename) + protected function getMockResourceByImagePath(string $imagePathAndFilename): PersistentResource { $imagePathAndFilename = Files::getUnixStylePath($imagePathAndFilename); $hash = sha1_file($imagePathAndFilename); copy($imagePathAndFilename, 'resource://' . $hash); - return $mockResource = $this->createMockResourceAndPointerFromHash($hash); + return $this->createMockResourceAndPointerFromHash($hash); } /** * Creates a mock ResourcePointer and PersistentResource from a given hash. * Make sure that a file representation already exists, e.g. with * file_put_content('resource://' . $hash) before - * - * @param string $hash - * @return PersistentResource */ - protected function createMockResourceAndPointerFromHash($hash) + protected function createMockResourceAndPointerFromHash(string $hash): PersistentResource { $mockResource = $this->getMockBuilder(PersistentResource::class)->setMethods(['getHash', 'getUri'])->getMock(); $mockResource->expects(self::any()) - ->method('getHash') - ->will(self::returnValue($hash)); + ->method('getHash') + ->will(self::returnValue($hash)); $mockResource->expects(self::any()) ->method('getUri') ->will(self::returnValue('resource://' . $hash)); @@ -80,11 +141,12 @@ protected function createMockResourceAndPointerFromHash($hash) /** * Builds a temporary directory to work on. - * @return void */ - protected function prepareTemporaryDirectory() + protected function prepareTemporaryDirectory(): void { - $this->temporaryDirectory = Files::concatenatePaths([FLOW_PATH_DATA, 'Temporary', 'Testing', str_replace('\\', '_', __CLASS__)]); + $this->temporaryDirectory = Files::concatenatePaths( + [FLOW_PATH_DATA, 'Temporary', 'Testing', str_replace('\\', '_', __CLASS__)] + ); if (!file_exists($this->temporaryDirectory)) { Files::createDirectoryRecursively($this->temporaryDirectory); } @@ -109,4 +171,15 @@ protected static function createFile(): Types\UploadedFile 'errorStatus' => 0, ]); } + + /** + * @template T of object + * @param class-string $className + * + * @return T + */ + private function getObject(string $className): object + { + return $this->objectManager->get($className); + } } diff --git a/Tests/Functional/GraphQL/AssetApiTest.php b/Tests/Functional/GraphQL/AssetApiTest.php index 84f0bc05c..1a0916017 100644 --- a/Tests/Functional/GraphQL/AssetApiTest.php +++ b/Tests/Functional/GraphQL/AssetApiTest.php @@ -19,7 +19,6 @@ use Flowpack\Media\Ui\GraphQL\Types; use Flowpack\Media\Ui\Tests\Functional\AbstractMediaTestCase; use Neos\Flow\Persistence\Doctrine\PersistenceManager; -use Neos\Flow\Tests\Behavior\Features\Bootstrap\SecurityOperationsTrait; use function Wwwision\Types\instantiate; @@ -28,7 +27,6 @@ */ class AssetApiTest extends AbstractMediaTestCase { - use SecurityOperationsTrait; /** * @var boolean @@ -46,7 +44,7 @@ public function setUp(): void $this->mediaApi = $this->objectManager->get(MediaApi::class); $this->assetResolver = $this->objectManager->get(AssetResolver::class); - $this->iAmAuthenticatedWithRole('Neos.Neos:Editor'); + $this->authenticateRoles(['Neos.Neos:Editor']); } public function testUploadFile(): void diff --git a/Tests/Functional/GraphQL/AssetCollectionApiTest.php b/Tests/Functional/GraphQL/AssetCollectionApiTest.php index f654d782c..857e90d51 100644 --- a/Tests/Functional/GraphQL/AssetCollectionApiTest.php +++ b/Tests/Functional/GraphQL/AssetCollectionApiTest.php @@ -37,6 +37,8 @@ public function setUp(): void } $this->mediaApi = $this->objectManager->get(MediaApi::class); + + $this->authenticateRoles(['Neos.Neos:Editor']); } public function testCreateAssetCollection(): void @@ -54,6 +56,21 @@ public function testCreateAssetCollection(): void $this->assertInstanceOf(Types\AssetCollection::class, $childCollection); $this->assertTrue(str_starts_with($childCollection->path->value, $assetCollection->path->value)); + + $this->persist(); + + $assetCollections = $this->mediaApi->assetCollections(); + $this->assertNotEmpty($assetCollections->collections); + + // Assert that the created asset collection is in the list of asset collections + $foundCollection = false; + foreach ($assetCollections->collections as $collection) { + if ($collection->equals($assetCollection)) { + $foundCollection = true; + break; + } + } + $this->assertTrue($foundCollection, 'The created asset collection was not found in the list of asset collections.'); } public function testDeleteAssetCollection(): void @@ -61,12 +78,14 @@ public function testDeleteAssetCollection(): void $assetCollection = $this->mediaApi->createAssetCollection( Types\AssetCollectionTitle::fromString('Test Collection'), ); - $result = $this->mediaApi->deleteAssetCollection($assetCollection->id); + $result = $this->mediaApi->deleteAssetCollection($assetCollection->id); $this->assertTrue($result->success); - $assetCollection = $this->mediaApi->assetCollection($assetCollection->id); - $this->assertNull($assetCollection); + $this->persist(); + + $deletedAssetCollection = $this->mediaApi->assetCollection($assetCollection->id); + $this->assertNull($deletedAssetCollection); } public function testDeleteNonExistingAssetCollection(): void diff --git a/Tests/Functional/GraphQL/TagApiTest.php b/Tests/Functional/GraphQL/TagApiTest.php index 168377eb5..4386b4b92 100644 --- a/Tests/Functional/GraphQL/TagApiTest.php +++ b/Tests/Functional/GraphQL/TagApiTest.php @@ -41,6 +41,8 @@ public function setUp(): void $this->mediaApi = $this->objectManager->get(MediaApi::class); $this->assetCollectionResolver = $this->objectManager->get(AssetCollectionResolver::class); $this->assetResolver = $this->objectManager->get(AssetResolver::class); + + $this->authenticateRoles(['Neos.Neos:Editor']); } public function testCreateTag(): void @@ -61,9 +63,10 @@ public function testDeleteTag(): void { $tag = $this->mediaApi->createTag(Types\TagLabel::fromString('Test Tag')); $result = $this->mediaApi->deleteTag($tag->id); - $this->assertTrue($result->success); + $this->persist(); + $deletedTag = $this->mediaApi->tag($tag->id); $this->assertNull($deletedTag); } diff --git a/composer.json b/composer.json index cb1fb90a9..95c5fbbea 100644 --- a/composer.json +++ b/composer.json @@ -3,10 +3,10 @@ "description": "This module allows managing media assets including pictures, videos, audio and documents.", "type": "neos-package", "require": { - "php": ">=8.1", - "neos/media": "^8.3", - "neos/neos": "^8.3", - "neos/neos-ui": "^8.3", + "php": "^8.2", + "neos/media": "~9.0", + "neos/neos": "~9.0", + "neos/neos-ui": "~9.0", "webonyx/graphql-php": "^15", "wwwision/types": "^1.6", "wwwision/types-graphql": "^1.3" @@ -15,9 +15,7 @@ "phpunit/phpunit": "^9.5" }, "suggest": { - "phpstan/phpstan": "For running code quality checks", - "flowpack/neos-asset-usage": "Allows filtering unused assets and other related features", - "flowpack/entity-usage-databasestorage": "Required for the asset usage features" + "phpstan/phpstan": "For running code quality checks" }, "scripts": { "test": "../../../bin/phpunit --enforce-time-limit --bootstrap ../../Libraries/autoload.php --testdox Tests",