diff --git a/Classes/Domain/Model/Dto/AssetUsageDetails.php b/Classes/Domain/Model/Dto/AssetUsageDetails.php index 7ad80c49f..05e7eb4c3 100644 --- a/Classes/Domain/Model/Dto/AssetUsageDetails.php +++ b/Classes/Domain/Model/Dto/AssetUsageDetails.php @@ -24,6 +24,9 @@ final class AssetUsageDetails implements \JsonSerializable { private string $label; + /** + * @var array + */ private array $metadata; private string $url; @@ -39,6 +42,9 @@ public function getLabel(): string return $this->label; } + /** + * @return array + */ public function getMetadata(): array { return $this->metadata; diff --git a/Classes/GraphQL/Context/AssetSourceContext.php b/Classes/GraphQL/Context/AssetSourceContext.php index 2dd9c8cbf..889a639f4 100644 --- a/Classes/GraphQL/Context/AssetSourceContext.php +++ b/Classes/GraphQL/Context/AssetSourceContext.php @@ -14,7 +14,7 @@ * source code. */ -use Neos\Flow\Annotations as Flow; +use Flowpack\Media\Ui\GraphQL\Types as Types; use Neos\Flow\Persistence\PersistenceManagerInterface; use Neos\Media\Domain\Model\Asset; use Neos\Media\Domain\Model\AssetInterface; @@ -23,61 +23,41 @@ use Neos\Media\Domain\Repository\AssetRepository; use Neos\Media\Domain\Service\AssetSourceService; use Neos\Media\Exception\AssetSourceServiceException; -use t3n\GraphQL\Context as BaseContext; -class AssetSourceContext extends BaseContext -{ - - /** - * @Flow\Inject - * @var AssetRepository - */ - protected $assetRepository; - - /** - * @Flow\Inject - * @var AssetSourceService - */ - protected $assetSourceService; - - /** - * @Flow\Inject - * @var PersistenceManagerInterface - */ - protected $persistenceManager; +use function Wwwision\Types\instantiate; +class AssetSourceContext +{ /** - * @var array + * @var AssetSourceInterface[] */ - protected $assetSources; + protected array $assetSources = []; /** - * @var array + * @var AssetInterface[] */ - protected $localAssetData = []; + protected array $localAssetData = []; /** - * @return void */ - public function initializeObject(): void + public function __construct( + protected readonly PersistenceManagerInterface $persistenceManager, + protected readonly AssetSourceService $assetSourceService, + protected readonly AssetRepository $assetRepository, + ) { $this->assetSources = $this->assetSourceService->getAssetSources(); } /** - * @return array + * @return AssetSourceInterface[] */ public function getAssetSources(): array { return $this->assetSources; } - /** - * @param string $id - * @param string $assetSourceIdentifier - * @return AssetProxyInterface|null - */ - public function getAssetProxy(string $id, string $assetSourceIdentifier): ?AssetProxyInterface + public function getAssetProxy(Types\AssetId $id, Types\AssetSourceId $assetSourceIdentifier): ?AssetProxyInterface { $activeAssetSource = $this->getAssetSource($assetSourceIdentifier); if (!$activeAssetSource) { @@ -85,56 +65,41 @@ public function getAssetProxy(string $id, string $assetSourceIdentifier): ?Asset } try { - return $activeAssetSource->getAssetProxyRepository()->getAssetProxy($id); - } catch (\Exception $e) { - // Some assetproxy repositories like the NeosAssetProxyRepository throw exceptions if an asset was not found + return $activeAssetSource->getAssetProxyRepository()->getAssetProxy($id->value); + } catch (\Exception) { + // Some assetProxy repositories like the NeosAssetProxyRepository throw exceptions if an asset was not found return null; } } - /** - * @param AssetProxyInterface $assetProxy - * @return AssetInterface|null - */ public function getAssetForProxy(AssetProxyInterface $assetProxy): ?AssetInterface { - $assetIdentifier = $assetProxy->getLocalAssetIdentifier(); - - if (!$assetIdentifier) { - return null; - } + $localAssetId = Types\LocalAssetId::fromAssetProxy($assetProxy); + return $localAssetId ? $this->getAssetByLocalIdentifier($localAssetId) : null; + } - if (array_key_exists($assetIdentifier, $this->localAssetData)) { - return $this->localAssetData[$assetIdentifier]; + public function getAssetByLocalIdentifier(Types\LocalAssetId $localAssetIdentifier): ?AssetInterface + { + if (array_key_exists($localAssetIdentifier->value, $this->localAssetData)) { + return $this->localAssetData[$localAssetIdentifier->value]; } - /** @var Asset $asset */ - $asset = $this->assetRepository->findByIdentifier($assetIdentifier); - - return $this->localAssetData[$assetIdentifier] = $asset; + $asset = $this->assetRepository->findByIdentifier($localAssetIdentifier->value); + return $this->localAssetData[$localAssetIdentifier->value] = $asset; } - /** - * @param string $assetSourceName - * @return AssetSourceInterface|null - */ - public function getAssetSource(string $assetSourceName): ?AssetSourceInterface + public function getAssetSource(Types\AssetSourceId $assetSourceId): ?AssetSourceInterface { - return $this->assetSources[$assetSourceName] ?? null; + return $this->assetSources[$assetSourceId->value] ?? null; } - /** - * @param string $assetSourceIdentifier - * @param string $assetIdentifier - * @return AssetProxyInterface|null - */ - public function importAsset(string $assetSourceIdentifier, string $assetIdentifier): ?AssetProxyInterface + public function importAsset(Types\AssetSourceId $assetSourceIdentifier, Types\AssetId $assetIdentifier): ?AssetProxyInterface { try { - $this->assetSourceService->importAsset($assetSourceIdentifier, $assetIdentifier); + $this->assetSourceService->importAsset($assetSourceIdentifier->value, $assetIdentifier->value); $this->persistenceManager->persistAll(); return $this->getAssetProxy($assetIdentifier, $assetSourceIdentifier); - } catch (AssetSourceServiceException | \Exception $e) { + } catch (AssetSourceServiceException|\Exception $e) { } return null; } diff --git a/Classes/GraphQL/MediaApi.php b/Classes/GraphQL/MediaApi.php new file mode 100644 index 000000000..32638a1d3 --- /dev/null +++ b/Classes/GraphQL/MediaApi.php @@ -0,0 +1,379 @@ +assetProxyIteratorBuilder->build( + $assetSourceId, + $tagId, + $assetCollectionId, + $mediaType, + $assetType, + SearchTerm::from($searchTerm), + ); + + if (!$iterator) { + $this->logger->error('Could not build asset query for given variables', func_get_args()); + return 0; + } + return count($iterator); + } + + #[Description('Provides a filterable list of asset proxies. These are the main entities for media management.')] + #[Query] + public function assets( + Types\AssetSourceId $assetSourceId = null, + Types\AssetCollectionId $assetCollectionId = null, + Types\MediaType $mediaType = null, + Types\AssetType $assetType = null, + Types\TagId $tagId = null, + Types\SortBy $sortBy = null, + Types\SortDirection $sortDirection = null, + string $searchTerm = null, + int $limit = 20, + int $offset = 0, + ): ?Types\Assets { + $iterator = $this->assetProxyIteratorBuilder->build( + $assetSourceId, + $tagId, + $assetCollectionId, + $mediaType, + $assetType, + SearchTerm::from($searchTerm), + $sortBy, + $sortDirection, + ); + + if (!$iterator) { + $this->logger->error('Could not build assets query for given variables', func_get_args()); + return null; + } + + $iterator->setOffset($offset); + $iterator->setLimit($limit); + + $assets = []; + foreach ($iterator as $assetProxy) { + $assets[] = Types\Asset::fromAssetProxy($assetProxy); + } + return instantiate(Types\Assets::class, $assets); + } + + #[Description('All asset collections')] + #[Query] + public function assetCollections(): Types\AssetCollections + { + return instantiate( + Types\AssetCollections::class, + array_map(function (HierarchicalAssetCollectionInterface $assetCollection) { + return instantiate(Types\AssetCollection::class, [ + 'id' => $this->persistenceManager->getIdentifierByObject($assetCollection), + 'title' => $assetCollection->getTitle(), + 'path' => $assetCollection->getPath(), + ]); + }, $this->assetCollectionRepository->findAll()->toArray()) + ); + } + + #[Description('All configured asset sources (by default only the "neos" source)')] + #[Query] + public function assetSources(): Types\AssetSources + { + return instantiate( + Types\AssetSources::class, + array_map(static function (AssetSourceInterface $assetSource) { + return AssetSource::fromAssetSource($assetSource); + }, $this->assetSourceContext->getAssetSources()) + ); + } + + /** + * @throws MediaUiException + */ + #[Description('Provides number of unused assets in local asset source')] + #[Query] + public function unusedAssetCount(): int + { + return $this->usageDetailsService->getUnusedAssetCount(); + } + + #[Description('Provides a list of all tags')] + #[Query] + public function tags(): Types\Tags + { + return instantiate( + Types\Tags::class, + array_map(function (Tag $tag) { + return instantiate(Types\Tag::class, [ + 'id' => $this->persistenceManager->getIdentifierByObject($tag), + 'label' => $tag->getLabel(), + ]); + }, $this->tagRepository->findAll()->toArray()) + ); + } + + #[Description('Get tag by id')] + #[Query] + public function tag(?Types\TagId $id): ?Types\Tag + { + /** @var Tag $tag */ + $tag = $id ? $this->tagRepository->findByIdentifier($id->value) : null; + return $tag ? instantiate(Types\Tag::class, [ + 'id' => $id, + 'label' => $tag->getLabel(), + ]) : null; + } + + #[Description('Returns an asset collection by id')] + #[Query] + public function assetCollection(?Types\AssetCollectionId $id): ?Types\AssetCollection + { + /** @var HierarchicalAssetCollectionInterface $assetCollection */ + $assetCollection = $id ? $this->assetCollectionRepository->findByIdentifier($id) : null; + return $assetCollection ? instantiate(Types\AssetCollection::class, [ + 'id' => $id, + 'title' => $assetCollection->getTitle(), + 'path' => $assetCollection->getPath(), + ]) : null; + } + + #[Description('Returns an asset by id')] + #[Query] + public function asset(Types\AssetId $id, Types\AssetSourceId $assetSourceId): ?Types\Asset + { + $assetProxy = $this->assetSourceContext->getAssetProxy($id, $assetSourceId); + return $assetProxy ? Types\Asset::fromAssetProxy($assetProxy) : null; + } + + #[Description('Provides configuration values for interacting with the media API')] + #[Query] + public function config(): Types\Config + { + $defaultAssetCollection = $this->assetCollectionService->getDefaultCollectionForCurrentSite(); + + return instantiate(Types\Config::class, [ + 'uploadMaxFileSize' => $this->getMaximumFileUploadSize(), + 'uploadMaxFileUploadLimit' => $this->getMaximumFileUploadLimit(), + 'currentServerTime' => Types\DateTime::now(), + 'defaultAssetCollectionId' => $defaultAssetCollection ? $this->persistenceManager->getIdentifierByObject($defaultAssetCollection) : null, + 'canManageTags' => $this->privilegeManager->isPrivilegeTargetGranted('Flowpack.Media.Ui:ManageTags'), + 'canManageAssetCollections' => $this->privilegeManager->isPrivilegeTargetGranted('Flowpack.Media.Ui:ManageAssetCollections'), + 'canManageAssets' => $this->privilegeManager->isPrivilegeTargetGranted('Flowpack.Media.Ui:ManageAssets'), + ]); + } + + /** + * Returns the lowest configured maximum upload file size + */ + protected function getMaximumFileUploadSize(): int + { + try { + return (int)min( + Files::sizeStringToBytes(ini_get('post_max_size')), + Files::sizeStringToBytes(ini_get('upload_max_filesize')) + ); + } catch (FilesException) { + return 0; + } + } + + /** + * Returns the maximum number of files that can be uploaded + */ + protected function getMaximumFileUploadLimit(): int + { + return (int)($this->settings['maximumFileUploadLimit'] ?? 10); + } + + #[Description('Returns a list of accessible and inaccessible relations for the given asset')] + #[QUERY] + public function assetUsageDetails(Types\AssetId $id, Types\AssetSourceId $assetSourceId): Types\UsageDetailsGroups + { + $assetProxy = $this->assetSourceContext->getAssetProxy($id, $assetSourceId); + + if (!$assetProxy || !$assetProxy->getLocalAssetIdentifier()) { + return Types\UsageDetailsGroups::empty(); + } + + $asset = $this->assetSourceContext->getAssetForProxy($assetProxy); + + if (!$asset) { + return Types\UsageDetailsGroups::empty(); + } + + return $this->usageDetailsService->resolveUsagesForAsset($asset); + } + + #[Description('Returns the total usage count for the given asset')] + #[Query] + public function assetUsageCount(Types\AssetId $id, Types\AssetSourceId $assetSourceId): int + { + $assetProxy = $this->assetSourceContext->getAssetProxy($id, $assetSourceId); + if (!$assetProxy || !$assetProxy->getLocalAssetIdentifier()) { + return 0; + } + + $asset = $this->assetSourceContext->getAssetForProxy($assetProxy); + if (!$asset) { + return 0; + } + + return $this->assetService->getUsageCount($asset); + } + + #[Description('Provides a list of all unused assets in local asset source')] + #[Query] + public function unusedAssets(int $limit = 20, int $offset = 0): Types\Assets + { + /** @var AssetInterface[] $assetProxies */ + $assetProxies = []; + try { + $assetProxies = $this->usageDetailsService->getUnusedAssets($limit, $offset, Types\AssetSourceId::default()); + } catch (MediaUiException $e) { + $this->logger->error('Could not retrieve unused assets', ['exception' => $e]); + } + + $assets = []; + foreach ($assetProxies as $assetProxy) { + $assets[] = Types\Asset::fromAssetProxy($assetProxy->getAssetProxy()); + } + return instantiate(Types\Assets::class, $assets); + } + + #[Description('Provides a list of changes to assets since a given timestamp')] + #[Query] + public function changedAssets(\DateTime $since = null): Types\ChangedAssetsResult + { + $changes = $this->assetChangeLog->getChanges(); + + # TODO: Move this filter functionality into the changelog service for optimisation? + $filteredChanges = []; + $lastModified = null; + foreach ($changes->changes as $change) { + if ($since !== null && $change->lastModified <= $since) { + continue; + } + if ($lastModified === null || $change->lastModified > $lastModified) { + $lastModified = $change->lastModified; + } + $filteredChanges[] = $change; + } + + return instantiate(Types\ChangedAssetsResult::class, [ + 'lastModified' => $lastModified, + 'changes' => instantiate(Types\AssetChanges::class, $filteredChanges), + ]); + } + + #[Description('Returns a list of variants for the given asset')] + #[Query] + public function assetVariants(Types\AssetId $id, Types\AssetSourceId $assetSourceId): Types\AssetVariants + { + $assetProxy = $this->assetSourceContext->getAssetProxy($id, $assetSourceId); + if (!($assetProxy instanceof NeosAssetProxy) || !($assetProxy->getAsset() instanceof VariantSupportInterface)) { + return Types\AssetVariants::empty(); + } + $asset = $this->assetSourceContext->getAssetByLocalIdentifier($assetProxy->getLocalAssetIdentifier()); + + /** @var VariantSupportInterface $originalAsset */ + $originalAsset = ($asset instanceof AssetVariantInterface ? $asset->getOriginalAsset() : $asset); + + return instantiate(Types\AssetVariants::class, array_map(static function (AssetVariantInterface $assetVariant) { + return Types\AssetVariant::fromAssetVariant($assetVariant); + }, $originalAsset->getVariants())); + } + +// /** +// * Returns a list of similar asset to the given asset +// * @return AssetProxyInterface[] +// * @throws PropertyNotAccessibleException +// */ +// public function similarAssets($_, array $variables, AssetSourceContext $assetSourceContext): array +// { +// [ +// 'id' => $id, +// 'assetSourceId' => $assetSourceId, +// ] = $variables + ['id' => null, 'assetSourceId' => null]; +// +// $assetProxy = $assetSourceContext->getAssetProxy($id, $assetSourceId); +// +// if (!$assetProxy) { +// return []; +// } +// +// $asset = $assetSourceContext->getAssetForProxy($assetProxy); +// +// if (!$asset) { +// return []; +// } +// +// $similarAssets = $this->similarityService->getSimilarAssets($asset); +// return array_map(function ($asset) use ($assetSourceContext) { +// $assetId = $this->persistenceManager->getIdentifierByObject($asset); +// return $assetSourceContext->getAssetProxy($assetId, $asset->getAssetSourceIdentifier()); +// }, $similarAssets); +// } +} diff --git a/Classes/GraphQL/Middleware/GraphQLMiddleware.php b/Classes/GraphQL/Middleware/GraphQLMiddleware.php new file mode 100644 index 000000000..168a855ae --- /dev/null +++ b/Classes/GraphQL/Middleware/GraphQLMiddleware.php @@ -0,0 +1,182 @@ +url + if (!\in_array($request->getMethod(), ['POST', 'OPTIONS'], + true) || $request->getUri()->getPath() !== $this->uriPath) { + return $handler->handle($request); + } + if ($this->simulateControllerObjectName !== null) { + $mockActionRequest = ActionRequest::fromHttpRequest($request); + // Simulate a request to the specified controller to trigger authentication + $mockActionRequest->setControllerObjectName($this->simulateControllerObjectName); + $this->securityContext->setRequest($mockActionRequest); + } + $response = $this->responseFactory->createResponse(); + $response = $this->addCorsHeaders($response); + if ($request->getMethod() === 'POST') { + $response = $this->handlePostRequest($request, $response); + } + return $response; + } + + private function handlePostRequest(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $api = $this->serviceLocator->get($this->apiObjectName); + $resolver = new Resolver( + $api, + $this->typeNamespaces === [] ? [(new ReflectionClass($api))->getNamespaceName()] : $this->typeNamespaces, + $this->customResolvers, + ); + $config = ServerConfig::create() + ->setSchema($this->getSchema($resolver)) + ->setFieldResolver($resolver) + ->setErrorsHandler($this->handleGraphQLErrors(...)); + if ($this->debugMode) { + $config->setDebugFlag(); + } + $server = new StandardServer($config); + try { + $request = $this->parseRequestBody($request); + } catch (\JsonException $_) { + return new Response(400, [], 'Invalid JSON request'); + } + + $bodyStream = $this->streamFactory->createStream(); + $newResponse = $server->processPsrRequest($request, $response, $bodyStream); + // For some reason we need to rewind the stream in order to prevent an empty response body + $bodyStream->rewind(); + return $newResponse; + } + + /** + * @throws \JsonException + */ + private function parseRequestBody(ServerRequestInterface $request): ServerRequestInterface + { + if (!empty($request->getParsedBody())) { + return $request; + } + $parsedBody = json_decode($request->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR); + return $request->withParsedBody($parsedBody); + } + + private function addCorsHeaders(ResponseInterface $response): ResponseInterface + { + return $response + ->withHeader('Access-Control-Allow-Origin', $this->corsOrigin) + ->withHeader('Access-Control-Allow-Methods', 'POST,OPTIONS') + ->withHeader('Access-Control-Allow-Headers', + 'Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range'); + } + + private function handleGraphQLErrors(array $errors, callable $formatter): array + { + return array_map(fn(Throwable $error) => $this->handleGraphQLError($error, $formatter), $errors); + } + + private function handleGraphQLError(Throwable $error, callable $formatter): array + { + if (!$error instanceof ClientAware || !$error->isClientSafe()) { + $this->throwableStorage->logThrowable($error); + } + $formattedError = $formatter($error); + $originalException = $error->getPrevious(); + if ($originalException instanceof FlowException) { + $formattedError['extensions']['statusCode'] = $originalException->getStatusCode(); + $formattedError['extensions']['referenceCode'] = $originalException->getReferenceCode(); + } + if ($originalException?->getPrevious() instanceof CoerceException) { + $formattedError['extensions']['issues'] = $originalException->getPrevious()->issues; + } + return $formattedError; + } + + private function getSchema(Resolver $resolver): Schema + { + $cacheKey = md5($this->apiObjectName); + if ($this->schemaCache->has($cacheKey)) { + $documentNode = AST::fromArray($this->schemaCache->get($cacheKey)); + } else { + /** @var GraphQLGenerator $generator */ + $generator = $this->serviceLocator->get(GraphQLGenerator::class); + $schema = $generator->generate($this->apiObjectName, $this->customResolvers)->render(); + try { + $documentNode = Parser::parse($schema); + } catch (SyntaxError $e) { + throw new \RuntimeException(sprintf('Failed to parse GraphQL Schema: %s', $e->getMessage()), 1652975280, + $e); + } + try { + $this->schemaCache->set($cacheKey, AST::toArray($documentNode)); + } catch (Exception $e) { + throw new \RuntimeException(sprintf('Failed to store parsed GraphQL Scheme in cache: %s', + $e->getMessage()), 1652975323, $e); + } + } + return BuildSchema::build($documentNode, $resolver->typeConfigDecorator(...)); + } +} diff --git a/Classes/GraphQL/Middleware/GraphQLMiddlewareFactory.php b/Classes/GraphQL/Middleware/GraphQLMiddlewareFactory.php new file mode 100644 index 000000000..bf5e06632 --- /dev/null +++ b/Classes/GraphQL/Middleware/GraphQLMiddlewareFactory.php @@ -0,0 +1,55 @@ +debugMode, + $this->corsOrigin, + $this->streamFactory, + $this->responseFactory, + $this->schemaCache, + $this->throwableStorage, + $this->securityContext, + $this->objectManager, + $this->customResolversFactory->create($customResolversSettings ?? []), + ); + } +} diff --git a/Classes/GraphQL/Resolver.php b/Classes/GraphQL/Resolver.php new file mode 100644 index 000000000..cccdb9536 --- /dev/null +++ b/Classes/GraphQL/Resolver.php @@ -0,0 +1,165 @@ + $typeNamespaces + */ + public function __construct( + private readonly object $api, + private readonly array $typeNamespaces, + CustomResolvers $customResolvers = null, + ) { + $this->customResolvers = $customResolvers ?? CustomResolvers::create(); + } + + /** + * @param array|null> $args + */ + public function __invoke( + object|string|null $objectValue, + array $args, + mixed $contextValue, + ResolveInfo $info + ): mixed { + $fieldName = $info->fieldName; + $objectValue ??= $this->api; + + $customResolver = $this->customResolvers->get($info->parentType->name, $fieldName); + if ($customResolver !== null) { + $objectValue = ($customResolver->callback)($objectValue, ... + $this->convertArguments($args, $info->fieldDefinition)); + } elseif (method_exists($objectValue, $fieldName)) { + $objectValue = $objectValue->{$fieldName}(...$this->convertArguments($args, $info->fieldDefinition)); + } elseif (property_exists($objectValue, $fieldName)) { + $objectValue = $objectValue->{$fieldName}; + } else { + return null; + } + if ($objectValue instanceof BackedEnum) { + $objectValue = $objectValue->value; + } + # TODO: Automatically convert primitive types to their respective PHP types without the need implement JsonSerializable +// if (is_object($objectValue)) { +// $objectValue = (new Normalizer())->normalize($objectValue->value); +// } + return $objectValue; + } + + public function typeConfigDecorator(array $typeConfig, TypeDefinitionNode $typeDefinitionNode): array + { + if ($typeDefinitionNode instanceof InterfaceTypeDefinitionNode) { + $typeConfig['resolveType'] = static fn( + $value, + $context, + ResolveInfo $info + ) => $info->schema->getType(substr($value::class, strrpos($value::class, '\\') + 1)); + } + if ($typeDefinitionNode instanceof EnumTypeDefinitionNode) { + $className = $this->resolveClassName($typeConfig['name']); + $schema = Parser::getSchema($className); + if ($schema instanceof EnumSchema) { + $typeConfig['values'] = array_map(static fn(EnumCaseSchema $caseSchema + ) => $caseSchema->instantiate(null), $schema->caseSchemas); + } + } + return $typeConfig; + } + + /** + * @param array|null> $arguments + * @return array|object|null> + */ + private function convertArguments(array $arguments, FieldDefinition $fieldDefinition): array + { + $result = []; + foreach ($arguments as $name => $value) { + $argumentDefinition = $fieldDefinition->getArg($name); + $result[$name] = $this->convertArgument($value, $argumentDefinition); + } + return $result; + } + + /** + * @param string|bool|int|UnitEnum|array|null $argument + * @return string|bool|int|array|object|null + */ + private function convertArgument( + string|bool|int|UnitEnum|array|null $argument, + ?Argument $argumentDefinition + ): string|bool|int|array|object|null { + if ($argument === null) { + return null; + } + $type = $argumentDefinition?->getType(); + if ($type instanceof NonNull) { + $type = $type->getWrappedType(); + } + $argumentType = $type->name; + if ($type instanceof ListOfType) { + $type = $type->ofType; + if ($type instanceof NonNull) { + $type = $type->getWrappedType(); + } + $argumentType = $type->name . 's'; + } + if (str_ends_with($argumentType, 'Input')) { + $argumentType = substr($argumentType, 0, -5); + } + + $className = $this->resolveClassName($argumentType); + if ($className !== null) { + try { + return instantiate($className, $argument); + } catch (CoerceException $e) { + throw new RequestError($e->getMessage(), 1688654808, $e); + } catch (InvalidArgumentException $e) { + throw new RequestError(sprintf('Validation error for %s: %s', $argumentType, $e->getMessage()), + 1688654808, $e); + } + } + return $argument; + } + + /** + * @param string $argumentType + * @return class-string|null + */ + private function resolveClassName(string $argumentType): ?string + { + foreach ($this->typeNamespaces as $namespace) { + $className = rtrim($namespace, '\\') . '\\' . $argumentType; + if (class_exists($className)) { + return $className; + } + } + return null; + } +} diff --git a/Classes/GraphQL/Resolver/CustomResolversFactory.php b/Classes/GraphQL/Resolver/CustomResolversFactory.php new file mode 100644 index 000000000..87f2b2e35 --- /dev/null +++ b/Classes/GraphQL/Resolver/CustomResolversFactory.php @@ -0,0 +1,44 @@ + $settingsForType) { + Assert::string($typeName); + Assert::isArray($settingsForType); + foreach ($settingsForType as $fieldName => $customResolverSettings) { + Assert::string($fieldName); + Assert::isArray($customResolverSettings); + Assert::keyExists($customResolverSettings, 'resolverClassName'); + $resolverClass = $this->objectManager->get($customResolverSettings['resolverClassName']); + $customResolvers[] = new CustomResolver( + $typeName, + $fieldName, + $resolverClass->{ + $customResolverSettings['resolverMethodName'] ?? $fieldName + }(...), + $customResolverSettings['description'] ?? null, + ); + } + } + return CustomResolvers::create(...$customResolvers); + } +} diff --git a/Classes/GraphQL/Resolver/Type/AssetCollectionResolver.php b/Classes/GraphQL/Resolver/Type/AssetCollectionResolver.php index 541c258c0..9b53a73d6 100644 --- a/Classes/GraphQL/Resolver/Type/AssetCollectionResolver.php +++ b/Classes/GraphQL/Resolver/Type/AssetCollectionResolver.php @@ -14,60 +14,51 @@ * source code. */ +use Flowpack\Media\Ui\Domain\Model\HierarchicalAssetCollectionInterface; +use Flowpack\Media\Ui\GraphQL\Types; use Flowpack\Media\Ui\Service\AssetCollectionService; use Neos\Flow\Annotations as Flow; +use Neos\Flow\Persistence\PersistenceManagerInterface; +use Neos\Media\Domain\Model\AssetCollection; +use Neos\Media\Domain\Repository\AssetCollectionRepository; use Neos\Media\Domain\Repository\AssetRepository; use Neos\Neos\Domain\Model\Site; use Neos\Neos\Domain\Repository\SiteRepository; -use t3n\GraphQL\ResolverInterface; -use Neos\Media\Domain\Model\AssetCollection; -use Neos\Flow\Persistence\PersistenceManagerInterface; -/** - * @Flow\Scope("singleton") - */ -class AssetCollectionResolver implements ResolverInterface +use function Wwwision\Types\instantiate; + +#[Flow\Scope('singleton')] +class AssetCollectionResolver { /** - * @Flow\Inject * @var PersistenceManagerInterface */ + #[Flow\Inject] protected $persistenceManager; - /** - * @Flow\Inject - * @var AssetRepository - */ - protected $assetRepository; + #[Flow\Inject] + protected AssetRepository $assetRepository; - /** - * @Flow\Inject - * @var AssetCollectionService - */ - protected $assetCollectionService; + #[Flow\Inject] + protected AssetCollectionService $assetCollectionService; - /** - * @Flow\Inject - * @var SiteRepository - */ - protected $siteRepository; + #[Flow\Inject] + protected SiteRepository $siteRepository; - protected array|null $siteDefaultAssetCollections = null; + #[Flow\Inject] + protected AssetCollectionRepository $assetCollectionRepository; - public function id(AssetCollection $assetCollection): string - { - return $this->persistenceManager->getIdentifierByObject($assetCollection); - } + protected array|null $siteDefaultAssetCollections = null; - public function assetCount(AssetCollection $assetCollection): int + public function assetCount(Types\AssetCollection $assetCollection): int { - return $this->assetCollectionService->getAssetCollectionAssetCount($this->id($assetCollection)); + return $this->assetCollectionService->getAssetCollectionAssetCount((string)$assetCollection->id); } /** * Returns true if the asset collection is empty and is not assigned as default collection for a site */ - public function canDelete(AssetCollection $assetCollection): bool + public function canDelete(Types\AssetCollection $assetCollection): bool { if ($this->siteDefaultAssetCollections === null) { $this->siteDefaultAssetCollections = []; @@ -77,11 +68,44 @@ public function canDelete(AssetCollection $assetCollection): bool if (!$siteAssetCollection) { continue; } - $this->siteDefaultAssetCollections[$this->id($site->getAssetCollection())] = true; + $siteAssetCollectionId = $this->persistenceManager->getIdentifierByObject($siteAssetCollection); + $this->siteDefaultAssetCollections[$siteAssetCollectionId] = true; } } - return !array_key_exists($this->id($assetCollection), $this->siteDefaultAssetCollections) - && $this->assetCount($assetCollection) === 0; + return !array_key_exists( + $assetCollection->id->value, + $this->siteDefaultAssetCollections + ) && $this->assetCount($assetCollection) === 0; + } + + public function tags(Types\AssetCollection $assetCollection): Types\Tags + { + /** @var AssetCollection $originalAssetCollection */ + $originalAssetCollection = $this->assetCollectionRepository->findByIdentifier($assetCollection->id->value); + return $originalAssetCollection ? instantiate( + Types\Tags::class, + $originalAssetCollection->getTags() + ) : Types\Tags::empty(); + } + + public function parent(Types\AssetCollection $assetCollection): ?Types\AssetCollection + { + /** @var HierarchicalAssetCollectionInterface $originalAssetCollection */ + $originalAssetCollection = $this->assetCollectionRepository->findByIdentifier($assetCollection->id->value); + return $originalAssetCollection ? instantiate( + Types\AssetCollection::class, + $originalAssetCollection->getParent() + ) : null; + } + + public function assets(Types\AssetCollection $assetCollection): Types\Assets + { + /** @var AssetCollection $originalAssetCollection */ + $originalAssetCollection = $this->assetCollectionRepository->findByIdentifier($assetCollection->id->value); + return $originalAssetCollection ? instantiate( + Types\Assets::class, + $this->assetRepository->findByAssetCollection($originalAssetCollection) + ) : Types\Assets::empty(); } } diff --git a/Classes/GraphQL/Resolver/Type/AssetResolver.php b/Classes/GraphQL/Resolver/Type/AssetResolver.php index e8b6e8f68..f2071d7f8 100644 --- a/Classes/GraphQL/Resolver/Type/AssetResolver.php +++ b/Classes/GraphQL/Resolver/Type/AssetResolver.php @@ -1,4 +1,5 @@ getIdentifier(); - } - - public function localId(AssetProxyInterface $assetProxy): ?string - { - return $assetProxy->getLocalAssetIdentifier(); - } +// public function __construct( +// protected readonly FileTypeIconService $fileTypeIconService, +// protected readonly ResourceManager $resourceManager, +// protected readonly AssetService $assetService, +// protected readonly AssetSourceContext $assetSourceContext +// ) { +// } /** * Returns the title of the associated local asset data or the label of the proxy as fallback */ - public function label(AssetProxyInterface $assetProxy, array $variables, AssetSourceContext $assetSourceContext): ?string + public function label(Types\Asset $asset): ?string { - $localAssetData = $assetSourceContext->getAssetForProxy($assetProxy); + $localAssetData = $this->assetSourceContext->getAssetByLocalIdentifier($asset->localId); if ($localAssetData && $localAssetData->getTitle()) { return $localAssetData->getTitle(); } - return $assetProxy->getLabel(); + $assetProxy = $this->assetSourceContext->getAssetProxy($asset->id, $asset->assetSource->id); + return $assetProxy?->getLabel(); } /** * Returns true if the asset is at least used once */ - public function isInUse(AssetProxyInterface $assetProxy, array $variables, AssetSourceContext $assetSourceContext): ?bool + public function isInUse(Types\Asset $asset): bool { - if (!$assetProxy->getLocalAssetIdentifier()) { + if (!$asset->localId) { return false; } - return $this->assetService->isInUse($assetSourceContext->getAssetForProxy($assetProxy)); + $localAssetData = $this->assetSourceContext->getAssetByLocalIdentifier($asset->localId); + return $localAssetData && $this->assetService->isInUse($localAssetData); } /** * Returns the caption of the associated local asset data */ - public function caption(AssetProxyInterface $assetProxy, array $variables, AssetSourceContext $assetSourceContext): ?string + public function caption(Types\Asset $asset): ?string { - $localAssetData = $assetSourceContext->getAssetForProxy($assetProxy); + $localAssetData = $this->assetSourceContext->getAssetByLocalIdentifier($asset->localId); return $localAssetData instanceof Asset ? $localAssetData->getCaption() : null; } - public function imported(AssetProxyInterface $assetProxy): bool + public function imported(Types\Asset $asset): bool { + $assetProxy = $this->assetSourceContext->getAssetProxy($asset->id, $asset->assetSource->id); // TODO: Find better way to make sure the asset originates from somewhere outside Neos - return $assetProxy->getLocalAssetIdentifier() && $assetProxy->getAssetSource()->getIdentifier() !== 'neos'; + return $assetProxy?->getLocalAssetIdentifier() && $assetProxy?->getAssetSource()->getIdentifier() !== 'neos'; } /** * Returns a matching icon uri for the given asset-proxy - * - * @return array{ - * extension: string, - * mediaType: string, - * typeIcon: array{ - * width: int, - * height: int, - * url: string, - * alt: string - * }, - * size: int, - * url: string - * } */ - public function file(AssetProxyInterface $assetProxy): array + public function file(Types\Asset $asset): ?Types\File { + $assetProxy = $this->assetSourceContext->getAssetProxy($asset->id, $asset->assetSource->id); + + if (!$assetProxy) { + return null; + } + $icon = $this->fileTypeIconService::getIcon($assetProxy->getFilename()); - if ($assetProxy instanceof ProvidesOriginalUriInterface) { + if ($asset instanceof ProvidesOriginalUriInterface) { $url = (string)$assetProxy->getOriginalUri(); } else { $url = (string)$assetProxy->getPreviewUri(); } - return [ + return instantiate(Types\File::class, [ 'extension' => $icon['alt'], 'mediaType' => $assetProxy->getMediaType(), 'typeIcon' => [ @@ -142,91 +131,104 @@ public function file(AssetProxyInterface $assetProxy): array ], 'size' => $assetProxy->getFileSize(), 'url' => $url, - ]; + ]); } - /** - * Returns the iptc properties for assetproxies that implement the interface - */ - public function iptcProperty(AssetProxyInterface $assetProxy, array $variables): ?string - { - $iptcProperties = $this->iptcProperties($assetProxy); - return $iptcProperties[$variables['property']] ?? null; - } +// /** +// * Returns the iptc properties for assetproxies that implement the interface +// */ +// public function iptcProperty(AssetProxyInterface $assetProxy, array $variables): ?string +// { +// $iptcProperties = $this->iptcProperties($assetProxy); +// return $iptcProperties[$variables['property']] ?? null; +// } /** * Returns the iptc properties for asset-proxies that implement the interface - * @return array{propertyName: string, value: string}[] */ - public function iptcProperties(AssetProxyInterface $assetProxy): array + public function iptcProperties(Types\Asset $asset): Types\IptcProperties { + $assetProxy = $this->assetSourceContext->getAssetProxy($asset->id, $asset->assetSource->id); if ($assetProxy instanceof SupportsIptcMetadataInterface) { $properties = $assetProxy->getIptcProperties(); - return array_map(static function ($key) use ($properties) { - return ['propertyName' => $key, 'value' => $properties[$key]]; - }, array_keys($properties)); + return instantiate(Types\IptcProperties::class, + array_map(static function ($key) use ($properties) { + return instantiate( + Types\IptcProperty::class, + ['propertyName' => $key, 'value' => $properties[$key]] + ); + }, array_keys($properties)) + ); } - return []; + return Types\IptcProperties::empty(); } - public function copyrightNotice(AssetProxyInterface $assetProxy, array $variables, AssetSourceContext $assetSourceContext): ?string + public function copyrightNotice(Types\Asset $asset): ?string { - $localAssetData = $assetSourceContext->getAssetForProxy($assetProxy); + $localAssetData = $this->assetSourceContext->getAssetByLocalIdentifier($asset->localId); return $localAssetData instanceof Asset ? $localAssetData->getCopyrightNotice() : null; } - public function lastModified(AssetProxyInterface $assetProxy, array $variables, AssetSourceContext $assetSourceContext): ?string + public function lastModified(Types\Asset $asset): ?string { - return $assetProxy->getLastModified() ? $assetProxy->getLastModified()->format(DATE_W3C) : null; + $assetProxy = $this->assetSourceContext->getAssetProxy($asset->id, $asset->assetSource->id); + return $assetProxy?->getLastModified() ? $assetProxy?->getLastModified()->format(DATE_W3C) : null; } - /** - * @return Tag[] - */ - public function tags(AssetProxyInterface $assetProxy, array $variables, AssetSourceContext $assetSourceContext): array + public function tags(Types\Asset $asset): Types\Tags { - $localAssetData = $assetSourceContext->getAssetForProxy($assetProxy); - return $localAssetData instanceof Asset ? $localAssetData->getTags()->toArray() : []; + $localAssetData = $this->assetSourceContext->getAssetByLocalIdentifier($asset->localId); + return $localAssetData instanceof Asset ? instantiate( + Types\Tags::class, + array_map(function (Tag $tag) { + return instantiate(Types\Tag::class, [ + 'id' => $this->persistenceManager->getIdentifierByObject($tag), + 'label' => $tag->getLabel(), + ]); + }, $localAssetData->getTags()->toArray()) + ) : Types\Tags::empty(); } - /** - * @return AssetCollection[] - */ - public function collections(AssetProxyInterface $assetProxy, array $variables, AssetSourceContext $assetSourceContext): array + public function collections(Types\Asset $asset): Types\AssetCollections { - $localAssetData = $assetSourceContext->getAssetForProxy($assetProxy); - return $localAssetData instanceof Asset ? $localAssetData->getAssetCollections()->toArray() : []; + $localAssetData = $this->assetSourceContext->getAssetByLocalIdentifier($asset->localId); + return $localAssetData instanceof Asset ? instantiate( + Types\AssetCollections::class, + array_map(function (HierarchicalAssetCollectionInterface $assetCollection) { + return instantiate(Types\AssetCollection::class, [ + 'id' => $this->persistenceManager->getIdentifierByObject($assetCollection), + 'title' => $assetCollection->getTitle(), + 'path' => $assetCollection->getPath() ? instantiate( + Types\AssetCollectionPath::class, + $assetCollection->getPath(), + ) : '', + ]); + }, + $localAssetData->getAssetCollections()->toArray()) + ) : Types\AssetCollections::empty(); } - public function width(AssetProxyInterface $assetProxy): int + public function thumbnailUrl(Types\Asset $asset): ?Types\Url { - return $assetProxy->getWidthInPixels() ?? 0; + $assetProxy = $this->assetSourceContext->getAssetProxy($asset->id, $asset->assetSource->id); + return $assetProxy ? instantiate(Types\Url::class, $assetProxy->getThumbnailUri()) : null; } - public function height(AssetProxyInterface $assetProxy): int + public function previewUrl(Types\Asset $asset): ?Types\Url { - return $assetProxy->getHeightInPixels() ?? 0; - } - - public function thumbnailUrl(AssetProxyInterface $assetProxy): string - { - return (string)$assetProxy->getThumbnailUri(); - } - - public function previewUrl(AssetProxyInterface $assetProxy): string - { - return (string)$assetProxy->getPreviewUri(); - } - - public function thumbnail( - AssetProxyInterface $assetProxy, - int $maximumWidth, - int $maximumHeight, - string $ratioMode, - bool $allowUpScaling, - bool $allowCropping - ): array { - // TODO: Implement - throw new \RuntimeException('Not implemented yet', 1590840085); + $assetProxy = $this->assetSourceContext->getAssetProxy($asset->id, $asset->assetSource->id); + return $assetProxy ? instantiate(Types\Url::class, $assetProxy->getPreviewUri()) : null; } +// +// public function thumbnail( +// AssetProxyInterface $assetProxy, +// int $maximumWidth, +// int $maximumHeight, +// string $ratioMode, +// bool $allowUpScaling, +// bool $allowCropping +// ): array { +// // TODO: Implement +// throw new \RuntimeException('Not implemented yet', 1590840085); +// } } diff --git a/Classes/GraphQL/Resolver/Type/AssetSourceResolver.php b/Classes/GraphQL/Resolver/Type/AssetSourceResolver.php deleted file mode 100644 index f37ee91d5..000000000 --- a/Classes/GraphQL/Resolver/Type/AssetSourceResolver.php +++ /dev/null @@ -1,57 +0,0 @@ -getIdentifier(); - } - - public function supportsTagging(AssetSourceInterface $assetSource): bool - { - return $assetSource->getAssetProxyRepository() instanceof SupportsTaggingInterface; - } - - public function supportsCollections(AssetSourceInterface $assetSource): bool - { - return $assetSource->getAssetProxyRepository() instanceof SupportsCollectionsInterface; - } - - public function description(AssetSourceInterface $assetSource): string - { - if (method_exists($assetSource, 'getDescription')) { - return $assetSource->getDescription(); - } - return ''; - } - - public function iconUri(AssetSourceInterface $assetSource): string - { - if (method_exists($assetSource, 'getIconUri')) { - return $assetSource->getIconUri(); - } - return ''; - } -} diff --git a/Classes/GraphQL/Resolver/Type/AssetVariantResolver.php b/Classes/GraphQL/Resolver/Type/AssetVariantResolver.php index b59f6de29..5366a39f4 100644 --- a/Classes/GraphQL/Resolver/Type/AssetVariantResolver.php +++ b/Classes/GraphQL/Resolver/Type/AssetVariantResolver.php @@ -1,4 +1,5 @@ persistenceManager->getIdentifierByObject($assetVariant); + $imageVariant = $this->imageVariantRepository->findByIdentifier($assetVariant->id->value); + return $imageVariant ? instantiate( + Types\Url::class, + $this->resourceManager->getPublicPersistentResourceUri($imageVariant->getResource()) + ) : null; } - public function previewUrl(ImageVariant $assetVariant): string + public function hasCrop(Types\AssetVariant $assetVariant): bool { - return (string)$this->resourceManager->getPublicPersistentResourceUri($assetVariant->getResource()); - } - - public function width(ImageVariant $assetVariant): int - { - return $assetVariant->getWidth(); - } - - public function height(ImageVariant $assetVariant): int - { - return $assetVariant->getHeight(); - } - - public function presetIdentifier(ImageVariant $assetVariant): ?string - { - return $assetVariant->getPresetIdentifier(); - } - - public function variantName(ImageVariant $assetVariant): ?string - { - return $assetVariant->getPresetVariantName(); - } - - public function hasCrop(ImageVariant $assetVariant): bool - { - foreach ($assetVariant->getAdjustments() as $adjustment) { + $imageVariant = $this->imageVariantRepository->findByIdentifier($assetVariant->id->value); + foreach ($imageVariant?->getAdjustments() as $adjustment) { if ($adjustment instanceof CropImageAdjustment) { return true; } } - return false; } - public function cropInformation(ImageVariant $assetVariant): array + public function cropInformation(Types\AssetVariant $assetVariant): Types\CropInformation { + $imageVariant = $this->imageVariantRepository->findByIdentifier($assetVariant->id->value); + if (!$imageVariant instanceof ImageVariant) { + return Types\CropInformation::empty(); + } $cropInformation = []; - foreach ($assetVariant->getAdjustments() as $adjustment) { - if ($adjustment instanceof CropImageAdjustment) { + foreach ($imageVariant?->getAdjustments() as $adjustment) { + if (!$adjustment instanceof CropImageAdjustment) { + continue; + } + $cropInformation = [ + 'width' => $adjustment->getWidth(), + 'height' => $adjustment->getHeight(), + 'x' => $adjustment->getX(), + 'y' => $adjustment->getY(), + ]; + $aspectRatio = $adjustment->getAspectRatio(); + if ($aspectRatio !== null) { + [ + $x, + $y, + $width, + $height + ] = CropImageAdjustment::calculateDimensionsByAspectRatio( + $imageVariant->getOriginalAsset()->getWidth(), + $imageVariant->getOriginalAsset()->getHeight(), $aspectRatio + ); $cropInformation = [ - 'width' => $adjustment->getWidth(), - 'height' => $adjustment->getHeight(), - 'x' => $adjustment->getX(), - 'y' => $adjustment->getY(), + 'width' => $width, + 'height' => $height, + 'x' => $x, + 'y' => $y, ]; - $aspectRatio = $adjustment->getAspectRatio(); - if ($aspectRatio !== null) { - [$x, $y, $width, $height] = CropImageAdjustment::calculateDimensionsByAspectRatio($assetVariant->getOriginalAsset()->getWidth(), $assetVariant->getOriginalAsset()->getHeight(), $aspectRatio); - $cropInformation = [ - 'width' => $width, - 'height' => $height, - 'x' => $x, - 'y' => $y, - ]; - } } - } - - return $cropInformation; + return instantiate(Types\CropInformation::class, $cropInformation); } } diff --git a/Classes/GraphQL/Resolver/Type/MutationResolver.php b/Classes/GraphQL/Resolver/Type/MutationResolver.php index 825db41a4..f976514c1 100644 --- a/Classes/GraphQL/Resolver/Type/MutationResolver.php +++ b/Classes/GraphQL/Resolver/Type/MutationResolver.php @@ -42,12 +42,12 @@ use Neos\Neos\Domain\Repository\SiteRepository; use Neos\Utility\MediaTypes; use Psr\Log\LoggerInterface; -use t3n\GraphQL\ResolverInterface; +//use t3n\GraphQL\ResolverInterface; /** * @Flow\Scope("singleton") */ -class MutationResolver implements ResolverInterface +class MutationResolver { protected const STATE_ADDED = 'ADDED'; protected const STATE_EXISTS = 'EXISTS'; diff --git a/Classes/GraphQL/Resolver/Type/QueryResolver.php b/Classes/GraphQL/Resolver/Type/QueryResolver.php deleted file mode 100644 index a59cbc4cd..000000000 --- a/Classes/GraphQL/Resolver/Type/QueryResolver.php +++ /dev/null @@ -1,416 +0,0 @@ -assetProxyIteratorBuilder->build($assetSourceContext, $variables); - - if (!$iterator) { - $this->systemLogger->error('Could not build asset query for given variables', $variables); - return 0; - } - - return count($iterator); - } - - /** - * Returns a list of accessible and inaccessible relations for the given asset - */ - public function assetUsageDetails($_, array $variables, AssetSourceContext $assetSourceContext): array - { - [ - 'id' => $id, - 'assetSourceId' => $assetSourceId, - ] = $variables + ['id' => null, 'assetSourceId' => null]; - - $assetProxy = $assetSourceContext->getAssetProxy($id, $assetSourceId); - - if (!$assetProxy || !$assetProxy->getLocalAssetIdentifier()) { - return []; - } - - $asset = $assetSourceContext->getAssetForProxy($assetProxy); - - if (!$asset) { - return []; - } - - return $this->assetUsageService->resolveUsagesForAsset($asset); - } - - /** - * Returns the total usage count for the given asset - */ - public function assetUsageCount($_, array $variables, AssetSourceContext $assetSourceContext): int - { - [ - 'id' => $id, - 'assetSourceId' => $assetSourceId, - ] = $variables + ['id' => null, 'assetSourceId' => null]; - - $assetProxy = $assetSourceContext->getAssetProxy($id, $assetSourceId); - - if (!$assetProxy || !$assetProxy->getLocalAssetIdentifier()) { - return 0; - } - - $asset = $assetSourceContext->getAssetForProxy($assetProxy); - - if (!$asset) { - return 0; - } - - return $this->assetService->getUsageCount($asset); - } - - /** - * Returns an array with helpful configurations for interacting with the API - */ - public function config($_): array - { - $defaultAssetCollection = $this->assetCollectionService->getDefaultCollectionForCurrentSite(); - - return [ - 'uploadMaxFileSize' => $this->getMaximumFileUploadSize(), - 'uploadMaxFileUploadLimit' => $this->getMaximumFileUploadLimit(), - 'currentServerTime' => (new \DateTime())->format(DATE_W3C), - 'defaultAssetCollectionId' => $defaultAssetCollection ? $this->persistenceManager->getIdentifierByObject($defaultAssetCollection) : null, - 'canManageTags' => $this->privilegeManager->isPrivilegeTargetGranted('Flowpack.Media.Ui:ManageTags'), - 'canManageAssetCollections' => $this->privilegeManager->isPrivilegeTargetGranted('Flowpack.Media.Ui:ManageAssetCollections'), - 'canManageAssets' => $this->privilegeManager->isPrivilegeTargetGranted('Flowpack.Media.Ui:ManageAssets'), - ]; - } - - /** - * Returns the lowest configured maximum upload file size - */ - protected function getMaximumFileUploadSize(): int - { - try { - return (int)min( - Files::sizeStringToBytes(ini_get('post_max_size')), - Files::sizeStringToBytes(ini_get('upload_max_filesize')) - ); - } catch (FilesException $e) { - return 0; - } - } - - /** - * Returns the maximum number of files that can be uploaded - */ - protected function getMaximumFileUploadLimit(): int - { - return (int)($this->settings['maximumFileUploadLimit'] ?? 10); - } - - /** - * Provides a filterable list of asset proxies. These are the main entities for media management. - */ - public function assets( - $_, - array $variables, - AssetSourceContext $assetSourceContext - ): ?AssetProxyIteratorAggregate { - ['limit' => $limit, 'offset' => $offset] = $variables + ['limit' => 20, 'offset' => 0]; - $iterator = $this->assetProxyIteratorBuilder->build($assetSourceContext, $variables); - - if (!$iterator) { - $this->systemLogger->error('Could not build assets query for given variables', $variables); - return null; - } - - $iterator->setOffset($offset); - $iterator->setLimit($limit); - - return $iterator; - } - - /** - * Provides a list of all unused assets in local asset source - * @return AssetProxyInterface[] - * @throws MediaUiException - */ - public function unusedAssets($_, array $variables, AssetSourceContext $assetSourceContext): array - { - ['limit' => $limit, 'offset' => $offset] = $variables + ['limit' => 20, 'offset' => 0]; - - /** @var NeosAssetSource $neosAssetSource */ - $neosAssetSource = $assetSourceContext->getAssetSource('neos'); - - return array_map(static function ($asset) use ($neosAssetSource) { - return new NeosAssetProxy($asset, $neosAssetSource); - }, $this->assetUsageService->getUnusedAssets($limit, $offset)); - } - - /** - * Provides number of unused assets in local asset source - * @throws MediaUiException - */ - public function unusedAssetCount(): int - { - return $this->assetUsageService->getUnusedAssetCount(); - } - - /** - * Provides a list of all tags - * @return Tag[] - */ - public function tags($_, array $variables): array - { - return $this->tagRepository->findAll()->toArray(); - } - - /** - * Get tag by id - */ - public function tag($_, array $variables): ?Tag - { - $id = $variables['id'] ?? null; - /** @var Tag $tag */ - $tag = $id ? $this->tagRepository->findByIdentifier($id) : null; - return $tag; - } - - /** - * Returns the list of all registered asset sources. By default the asset source `neos` should always exist. - * @return AssetSourceInterface[] - */ - public function assetSources($_, array $variables, AssetSourceContext $assetSourceContext): array - { - return $assetSourceContext->getAssetSources(); - } - - /** - * Returns all asset collections - * @return AssetCollection[] - */ - public function assetCollections($_, array $variables): array - { - return $this->assetCollectionRepository->findAll()->toArray(); - } - - /** - * Returns an asset collection by id - */ - public function assetCollection($_, array $variables): ?AssetCollection - { - $id = $variables['id'] ?? null; - /** @var AssetCollection $assetCollection */ - $assetCollection = $id ? $this->assetCollectionRepository->findByIdentifier($id) : null; - return $assetCollection; - } - - /** - * Returns an asset proxy by id - */ - public function asset($_, array $variables, AssetSourceContext $assetSourceContext): ?AssetProxyInterface - { - [ - 'id' => $id, - 'assetSourceId' => $assetSourceId, - ] = $variables + ['id' => null, 'assetSourceId' => null]; - - return $assetSourceContext->getAssetProxy($id, $assetSourceId); - } - - /** - * Retrieves the variants of an asset - * @return AssetVariantInterface[] - */ - public function assetVariants($_, array $variables, AssetSourceContext $assetSourceContext): array - { - $assetProxy = $this->asset($_, $variables, $assetSourceContext); - if (!($assetProxy instanceof NeosAssetProxy) || !($assetProxy->getAsset() instanceof VariantSupportInterface)) { - return []; - } - $asset = $this->persistenceManager->getObjectByIdentifier($assetProxy->getLocalAssetIdentifier(), Asset::class); - - /** @var VariantSupportInterface $originalAsset */ - $originalAsset = ($asset instanceof AssetVariantInterface ? $asset->getOriginalAsset() : $asset); - - return $originalAsset->getVariants(); - } - - /** - * Returns a list of changes to assets since a given timestamp - */ - public function changedAssets($_, array $variables): array - { - /** @var string $since */ - $since = $variables['since'] ?? null; - $changes = $this->assetChangeLog->getChanges(); - - $filteredChanges = []; - $lastModified = null; - foreach ($changes as $change) { - if ($since !== null && $change['lastModified'] <= $since) { - continue; - } - if ($lastModified === null || $change['lastModified'] > $lastModified) { - $lastModified = $change['lastModified']; - } - $filteredChanges[] = $change; - } - - return [ - 'lastModified' => $lastModified, - 'changes' => $filteredChanges, - ]; - } - - /** - * Returns a list of similar asset to the given asset - * @return AssetProxyInterface[] - * @throws PropertyNotAccessibleException - */ - public function similarAssets($_, array $variables, AssetSourceContext $assetSourceContext): array - { - [ - 'id' => $id, - 'assetSourceId' => $assetSourceId, - ] = $variables + ['id' => null, 'assetSourceId' => null]; - - $assetProxy = $assetSourceContext->getAssetProxy($id, $assetSourceId); - - if (!$assetProxy) { - return []; - } - - $asset = $assetSourceContext->getAssetForProxy($assetProxy); - - if (!$asset) { - return []; - } - - $similarAssets = $this->similarityService->getSimilarAssets($asset); - return array_map(function ($asset) use ($assetSourceContext) { - $assetId = $this->persistenceManager->getIdentifierByObject($asset); - return $assetSourceContext->getAssetProxy($assetId, $asset->getAssetSourceIdentifier()); - }, $similarAssets); - } -} diff --git a/Classes/GraphQL/Resolver/Type/TagResolver.php b/Classes/GraphQL/Resolver/Type/TagResolver.php deleted file mode 100644 index fb65b71c7..000000000 --- a/Classes/GraphQL/Resolver/Type/TagResolver.php +++ /dev/null @@ -1,37 +0,0 @@ -persistenceManager->getIdentifierByObject($tag); - } -} diff --git a/Classes/GraphQL/Types/Asset.php b/Classes/GraphQL/Types/Asset.php new file mode 100644 index 000000000..5a40dd489 --- /dev/null +++ b/Classes/GraphQL/Types/Asset.php @@ -0,0 +1,44 @@ + see resolver + */ +#[Description('An asset (Image, Document, Video or Audio)')] +#[Flow\Proxy(false)] +final class Asset +{ + private function __construct( + public readonly AssetId $id, + public readonly LocalAssetId|null $localId, + public readonly Filename $filename, + public readonly AssetSource $assetSource, + # TODO: Introduce Type for pixel dimensions + #[Description('The width in pixels (only for Images and Videos)')] + public readonly int $width, + #[Description('The height in pixels (only for Images and Videos)')] + public readonly int $height, + ) { + } + + public static function fromAssetProxy(AssetProxyInterface $assetProxy): self + { + return instantiate(self::class, [ + 'id' => $assetProxy->getIdentifier(), + 'localId' => $assetProxy->getLocalAssetIdentifier(), + 'filename' => $assetProxy->getFilename(), + 'width' => $assetProxy->getWidthInPixels(), + 'height' => $assetProxy->getHeightInPixels(), + 'assetSource' => AssetSource::fromAssetSource($assetProxy->getAssetSource()), + ]); + } +} diff --git a/Classes/GraphQL/Types/AssetChange.php b/Classes/GraphQL/Types/AssetChange.php new file mode 100644 index 000000000..12682b584 --- /dev/null +++ b/Classes/GraphQL/Types/AssetChange.php @@ -0,0 +1,23 @@ +value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/Classes/GraphQL/Types/AssetChanges.php b/Classes/GraphQL/Types/AssetChanges.php new file mode 100644 index 000000000..263684338 --- /dev/null +++ b/Classes/GraphQL/Types/AssetChanges.php @@ -0,0 +1,32 @@ +changes; + } +} diff --git a/Classes/GraphQL/Types/AssetCollection.php b/Classes/GraphQL/Types/AssetCollection.php new file mode 100644 index 000000000..fecb0d9d4 --- /dev/null +++ b/Classes/GraphQL/Types/AssetCollection.php @@ -0,0 +1,21 @@ +value; + } + + public function __toString(): string + { + return $this->value; + } + + public function isUnassigned(): bool + { + return $this->value === self::UNASSIGNED; + } +} diff --git a/Classes/GraphQL/Types/AssetCollectionPath.php b/Classes/GraphQL/Types/AssetCollectionPath.php new file mode 100644 index 000000000..3976c89e8 --- /dev/null +++ b/Classes/GraphQL/Types/AssetCollectionPath.php @@ -0,0 +1,24 @@ +value; + } +} diff --git a/Classes/GraphQL/Types/AssetCollectionTitle.php b/Classes/GraphQL/Types/AssetCollectionTitle.php new file mode 100644 index 000000000..6f919f7a7 --- /dev/null +++ b/Classes/GraphQL/Types/AssetCollectionTitle.php @@ -0,0 +1,24 @@ +value; + } +} diff --git a/Classes/GraphQL/Types/AssetCollections.php b/Classes/GraphQL/Types/AssetCollections.php new file mode 100644 index 000000000..49efeb1d3 --- /dev/null +++ b/Classes/GraphQL/Types/AssetCollections.php @@ -0,0 +1,31 @@ +collections; + } + + public static function empty(): self + { + return instantiate(self::class, []); + } +} diff --git a/Classes/GraphQL/Types/AssetId.php b/Classes/GraphQL/Types/AssetId.php new file mode 100644 index 000000000..3a9aac44e --- /dev/null +++ b/Classes/GraphQL/Types/AssetId.php @@ -0,0 +1,24 @@ +value; + } +} diff --git a/Classes/GraphQL/Types/AssetSource.php b/Classes/GraphQL/Types/AssetSource.php new file mode 100644 index 000000000..fd4a03e92 --- /dev/null +++ b/Classes/GraphQL/Types/AssetSource.php @@ -0,0 +1,43 @@ + $assetSource->getIdentifier(), + 'label' => $assetSource->getLabel(), + 'description' => $assetSource->getDescription(), + 'iconUri' => $assetSource->getIconUri() ? instantiate(Url::class, + $assetSource->getIconUri()) : null, + 'readOnly' => $assetSource->isReadonly(), + 'supportsTagging' => $assetSource->getAssetProxyRepository() instanceof SupportsTaggingInterface, + 'supportsCollections' => $assetSource->getAssetProxyRepository() instanceof SupportsCollectionsInterface, + ]); + } +} diff --git a/Classes/GraphQL/Types/AssetSourceId.php b/Classes/GraphQL/Types/AssetSourceId.php new file mode 100644 index 000000000..31fdf0997 --- /dev/null +++ b/Classes/GraphQL/Types/AssetSourceId.php @@ -0,0 +1,29 @@ +value; + } + + public static function default(): self + { + return new self('neos'); + } +} diff --git a/Classes/GraphQL/Types/AssetSources.php b/Classes/GraphQL/Types/AssetSources.php new file mode 100644 index 000000000..4ad3bab13 --- /dev/null +++ b/Classes/GraphQL/Types/AssetSources.php @@ -0,0 +1,31 @@ +collections; + } + + public static function empty(): self + { + return instantiate(self::class, []); + } +} diff --git a/Classes/GraphQL/Types/AssetType.php b/Classes/GraphQL/Types/AssetType.php new file mode 100644 index 000000000..5ab66d700 --- /dev/null +++ b/Classes/GraphQL/Types/AssetType.php @@ -0,0 +1,29 @@ +value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/Classes/GraphQL/Types/AssetVariant.php b/Classes/GraphQL/Types/AssetVariant.php new file mode 100644 index 000000000..9b430becc --- /dev/null +++ b/Classes/GraphQL/Types/AssetVariant.php @@ -0,0 +1,45 @@ +getWidth(); + $height = $assetVariant->getHeight(); + } + return instantiate(self::class, [ + 'id' => $assetVariant->getIdentifier(), + 'width' => $width, + 'height' => $height, + ]); + } +} diff --git a/Classes/GraphQL/Types/AssetVariants.php b/Classes/GraphQL/Types/AssetVariants.php new file mode 100644 index 000000000..15c055083 --- /dev/null +++ b/Classes/GraphQL/Types/AssetVariants.php @@ -0,0 +1,29 @@ +assetVariants; + } +} diff --git a/Classes/GraphQL/Types/Assets.php b/Classes/GraphQL/Types/Assets.php new file mode 100644 index 000000000..5dde2a238 --- /dev/null +++ b/Classes/GraphQL/Types/Assets.php @@ -0,0 +1,29 @@ +assets; + } +} diff --git a/Classes/GraphQL/Types/ChangedAssetsResult.php b/Classes/GraphQL/Types/ChangedAssetsResult.php new file mode 100644 index 000000000..0f934d7a2 --- /dev/null +++ b/Classes/GraphQL/Types/ChangedAssetsResult.php @@ -0,0 +1,19 @@ + 0, + 'height' => 0, + 'x' => 0, + 'y' => 0, + ]); + } +} diff --git a/Classes/GraphQL/Types/DateTime.php b/Classes/GraphQL/Types/DateTime.php new file mode 100644 index 000000000..7aecc6659 --- /dev/null +++ b/Classes/GraphQL/Types/DateTime.php @@ -0,0 +1,38 @@ +value; + } + + public function __toString(): string + { + return $this->value; + } + + public static function now(): self + { + return instantiate(self::class, (new Now())->format(DATE_W3C)); + } +} diff --git a/Classes/GraphQL/Types/File.php b/Classes/GraphQL/Types/File.php new file mode 100644 index 000000000..b484d8d10 --- /dev/null +++ b/Classes/GraphQL/Types/File.php @@ -0,0 +1,20 @@ +value; + } +} diff --git a/Classes/GraphQL/Types/FileSize.php b/Classes/GraphQL/Types/FileSize.php new file mode 100644 index 000000000..1639796b6 --- /dev/null +++ b/Classes/GraphQL/Types/FileSize.php @@ -0,0 +1,24 @@ +value; + } +} diff --git a/Classes/GraphQL/Types/Filename.php b/Classes/GraphQL/Types/Filename.php new file mode 100644 index 000000000..968aa2212 --- /dev/null +++ b/Classes/GraphQL/Types/Filename.php @@ -0,0 +1,24 @@ +value; + } +} diff --git a/Classes/GraphQL/Types/Image.php b/Classes/GraphQL/Types/Image.php new file mode 100644 index 000000000..75285747a --- /dev/null +++ b/Classes/GraphQL/Types/Image.php @@ -0,0 +1,21 @@ +properties; + } + + public static function empty(): self + { + return new self([]); + } +} diff --git a/Classes/GraphQL/Types/IptcProperty.php b/Classes/GraphQL/Types/IptcProperty.php new file mode 100644 index 000000000..1830029a4 --- /dev/null +++ b/Classes/GraphQL/Types/IptcProperty.php @@ -0,0 +1,19 @@ +value; + } +} diff --git a/Classes/GraphQL/Types/LocalAssetId.php b/Classes/GraphQL/Types/LocalAssetId.php new file mode 100644 index 000000000..f7ca46360 --- /dev/null +++ b/Classes/GraphQL/Types/LocalAssetId.php @@ -0,0 +1,35 @@ +value; + } + + public static function fromAssetProxy(AssetProxyInterface $assetProxy): ?self + { + return $assetProxy->getLocalAssetIdentifier() ? instantiate( + self::class, + $assetProxy->getLocalAssetIdentifier() + ) : null; + } +} diff --git a/Classes/GraphQL/Types/MediaType.php b/Classes/GraphQL/Types/MediaType.php new file mode 100644 index 000000000..16e73a982 --- /dev/null +++ b/Classes/GraphQL/Types/MediaType.php @@ -0,0 +1,29 @@ +value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/Classes/GraphQL/Types/MetadataName.php b/Classes/GraphQL/Types/MetadataName.php new file mode 100644 index 000000000..16cf3efb4 --- /dev/null +++ b/Classes/GraphQL/Types/MetadataName.php @@ -0,0 +1,27 @@ +value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/Classes/GraphQL/Types/ServiceId.php b/Classes/GraphQL/Types/ServiceId.php new file mode 100644 index 000000000..50c16055c --- /dev/null +++ b/Classes/GraphQL/Types/ServiceId.php @@ -0,0 +1,27 @@ +value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/Classes/GraphQL/Types/SortBy.php b/Classes/GraphQL/Types/SortBy.php new file mode 100644 index 000000000..d667643fa --- /dev/null +++ b/Classes/GraphQL/Types/SortBy.php @@ -0,0 +1,47 @@ +value; + } + + public function __toString(): string + { + return $this->value; + } +} + +//enum SortBy +//{ +// #[Description('The resource file name')] +// case name; +// +// #[Description('Last modification date')] +// case lastModified; +// +// #[Description('The resource file size')] +// case size; +//} diff --git a/Classes/GraphQL/Types/SortDirection.php b/Classes/GraphQL/Types/SortDirection.php new file mode 100644 index 000000000..c6c182133 --- /dev/null +++ b/Classes/GraphQL/Types/SortDirection.php @@ -0,0 +1,34 @@ +value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/Classes/GraphQL/Types/Tag.php b/Classes/GraphQL/Types/Tag.php new file mode 100644 index 000000000..139d46e08 --- /dev/null +++ b/Classes/GraphQL/Types/Tag.php @@ -0,0 +1,19 @@ +value; + } + + public function __toString(): string + { + return $this->value; + } + + public function isUntagged(): bool + { + return $this->value === self::UNTAGGED; + } +} diff --git a/Classes/GraphQL/Types/Tags.php b/Classes/GraphQL/Types/Tags.php new file mode 100644 index 000000000..9bf341213 --- /dev/null +++ b/Classes/GraphQL/Types/Tags.php @@ -0,0 +1,27 @@ +tags; + } + + public static function empty(): self + { + return new self([]); + } +} diff --git a/Classes/GraphQL/Types/Url.php b/Classes/GraphQL/Types/Url.php new file mode 100644 index 000000000..828573a39 --- /dev/null +++ b/Classes/GraphQL/Types/Url.php @@ -0,0 +1,24 @@ +value; + } +} diff --git a/Classes/GraphQL/Types/UsageDetails.php b/Classes/GraphQL/Types/UsageDetails.php new file mode 100644 index 000000000..4f16b0406 --- /dev/null +++ b/Classes/GraphQL/Types/UsageDetails.php @@ -0,0 +1,41 @@ + $usage->getLabel(), + 'url' => $usage->getUrl(), + // TODO: Simplify this instantiation + 'metadata' => instantiate( + UsageDetailsMetadataList::class, + array_map(static function (array $metadata) { + return instantiate( + UsageDetailsMetadata::class, + $metadata, + ); + }, $usage->getMetadata()) + ), + ]); + } +} diff --git a/Classes/GraphQL/Types/UsageDetailsGroup.php b/Classes/GraphQL/Types/UsageDetailsGroup.php new file mode 100644 index 000000000..beb04296d --- /dev/null +++ b/Classes/GraphQL/Types/UsageDetailsGroup.php @@ -0,0 +1,22 @@ +groups; + } + + public static function empty(): self + { + return instantiate(self::class, []); + } +} diff --git a/Classes/GraphQL/Types/UsageDetailsList.php b/Classes/GraphQL/Types/UsageDetailsList.php new file mode 100644 index 000000000..9086af0fb --- /dev/null +++ b/Classes/GraphQL/Types/UsageDetailsList.php @@ -0,0 +1,37 @@ +items; + } + + public static function empty(): self + { + return instantiate(self::class, []); + } + + public function isEmpty(): bool + { + return count($this->items) === 0; + } +} diff --git a/Classes/GraphQL/Types/UsageDetailsMetadata.php b/Classes/GraphQL/Types/UsageDetailsMetadata.php new file mode 100644 index 000000000..aff1e318a --- /dev/null +++ b/Classes/GraphQL/Types/UsageDetailsMetadata.php @@ -0,0 +1,19 @@ +items; + } + + public static function empty(): self + { + return instantiate(self::class, []); + } +} diff --git a/Classes/GraphQL/Types/UsageDetailsMetadataSchema.php b/Classes/GraphQL/Types/UsageDetailsMetadataSchema.php new file mode 100644 index 000000000..00835c24e --- /dev/null +++ b/Classes/GraphQL/Types/UsageDetailsMetadataSchema.php @@ -0,0 +1,18 @@ +schemas; + } + + public static function empty(): self + { + return instantiate(self::class, []); + } +} diff --git a/Classes/GraphQL/Types/UsageDetailsMetadataType.php b/Classes/GraphQL/Types/UsageDetailsMetadataType.php new file mode 100644 index 000000000..ad339bc49 --- /dev/null +++ b/Classes/GraphQL/Types/UsageDetailsMetadataType.php @@ -0,0 +1,37 @@ +value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/Classes/GraphQL/Types/VariantName.php b/Classes/GraphQL/Types/VariantName.php new file mode 100644 index 000000000..83cc60ac4 --- /dev/null +++ b/Classes/GraphQL/Types/VariantName.php @@ -0,0 +1,29 @@ +value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/Classes/GraphQL/Types/VariantPresetIdentifier.php b/Classes/GraphQL/Types/VariantPresetIdentifier.php new file mode 100644 index 000000000..e3394578d --- /dev/null +++ b/Classes/GraphQL/Types/VariantPresetIdentifier.php @@ -0,0 +1,29 @@ +value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/Classes/Infrastructure/Neos/Media/AssetProxyIteratorBuilder.php b/Classes/Infrastructure/Neos/Media/AssetProxyIteratorBuilder.php index 210fe9a17..190adb02a 100644 --- a/Classes/Infrastructure/Neos/Media/AssetProxyIteratorBuilder.php +++ b/Classes/Infrastructure/Neos/Media/AssetProxyIteratorBuilder.php @@ -8,6 +8,7 @@ use Flowpack\Media\Ui\Domain\Model\AssetSource\NeosAssetProxyRepository; use Flowpack\Media\Ui\Domain\Model\SearchTerm; use Flowpack\Media\Ui\GraphQL\Context\AssetSourceContext; +use Flowpack\Media\Ui\GraphQL\Types; use Neos\Flow\Annotations as Flow; use Neos\Media\Domain\Model\AssetCollection; use Neos\Media\Domain\Model\AssetSource\AssetProxyQueryInterface; @@ -22,56 +23,31 @@ use Neos\Media\Domain\Repository\TagRepository; use Psr\Log\LoggerInterface; -/** - * @Flow\Scope("singleton") - */ +#[Flow\Scope('singleton')] class AssetProxyIteratorBuilder { - /** - * @Flow\Inject - * @var LoggerInterface - */ - protected $systemLogger; - - /** - * @Flow\Inject - * @var TagRepository - */ - protected $tagRepository; - - /** - * @Flow\Inject - * @var AssetCollectionRepository - */ - protected $assetCollectionRepository; + public function __construct( + protected readonly AssetSourceContext $assetSourceContext, + protected AssetCollectionRepository $assetCollectionRepository, + protected TagRepository $tagRepository, + protected LoggerInterface $logger, + ) { + } public function build( - AssetSourceContext $assetSourceContext, - array $variables - ): ?AssetProxyIteratorAggregate - { - [ - 'assetSourceId' => $assetSourceId, - 'tagId' => $tagId, - 'assetCollectionId' => $assetCollectionId, - 'mediaType' => $mediaType, - 'assetType' => $assetType, - 'searchTerm' => $searchTerm, - 'sortBy' => $sortBy, - 'sortDirection' => $sortDirection - ] = $variables + [ - 'assetSourceId' => 'neos', - 'tagId' => null, - 'assetCollectionId' => null, - 'mediaType' => null, - 'assetType' => null, - 'searchTerm' => null, - 'sortBy' => null, - 'sortDirection' => null, - ]; - - $activeAssetSource = $assetSourceContext->getAssetSource($assetSourceId); + Types\AssetSourceId $assetSourceId = null, + Types\TagId $tagId = null, + Types\AssetCollectionId $assetCollectionId = null, + Types\MediaType $mediaType = null, + Types\AssetType $assetType = null, + SearchTerm $searchTerm = null, + Types\SortBy $sortBy = null, + Types\SortDirection $sortDirection = null, + ): ?AssetProxyIteratorAggregate { + $assetSourceId = $assetSourceId ?: Types\AssetSourceId::default(); + + $activeAssetSource = $this->assetSourceContext->getAssetSource($assetSourceId); if (!$activeAssetSource) { return null; } @@ -96,7 +72,7 @@ public function build( return AssetProxyQueryIterator::from($queryResult); } - if ($searchTerm = SearchTerm::from($searchTerm)) { + if ($searchTerm) { return $this->applySearchTerm($searchTerm, $assetProxyRepository); } @@ -105,37 +81,43 @@ public function build( ); } - protected function filterByAssetType(?string $assetType, AssetProxyRepositoryInterface $assetProxyRepository): void - { - if (is_string($assetType) && !empty($assetType)) { + protected function filterByAssetType( + ?Types\AssetType $assetType, + AssetProxyRepositoryInterface $assetProxyRepository + ): void { + if ($assetType) { try { - $assetTypeFilter = new AssetTypeFilter(ucfirst($assetType)); + $assetTypeFilter = new AssetTypeFilter(ucfirst((string)$assetType)); $assetProxyRepository->filterByType($assetTypeFilter); - } catch (\InvalidArgumentException $e) { - $this->systemLogger->warning('Ignoring invalid asset type when filtering assets ' . $assetType); + } catch (\InvalidArgumentException) { + $this->logger->warning('Ignoring invalid asset type when filtering assets ' . $assetType); } } } - protected function filterByMediaType($mediaType, AssetProxyRepositoryInterface $assetProxyRepository): void - { - if (is_string($mediaType) && !empty($mediaType) && $assetProxyRepository instanceof NeosAssetProxyRepository) { + protected function filterByMediaType( + ?Types\MediaType $mediaType, + AssetProxyRepositoryInterface $assetProxyRepository + ): void { + if ($mediaType && $assetProxyRepository instanceof NeosAssetProxyRepository) { try { - $assetProxyRepository->filterByMediaType($mediaType); - } catch (\InvalidArgumentException $e) { - $this->systemLogger->warning('Ignoring invalid media-type when filtering assets ' . $mediaType); + $assetProxyRepository->filterByMediaType((string)$mediaType); + } catch (\InvalidArgumentException) { + $this->logger->warning('Ignoring invalid media-type when filtering assets ' . $mediaType); } } } - protected function filterByAssetCollection($assetCollectionId, AssetProxyRepositoryInterface $assetProxyRepository): void - { + protected function filterByAssetCollection( + ?Types\AssetCollectionId $assetCollectionId, + AssetProxyRepositoryInterface $assetProxyRepository + ): void { if ($assetCollectionId && $assetProxyRepository instanceof SupportsCollectionsInterface) { - if ($assetProxyRepository instanceof NeosAssetProxyRepository && $assetCollectionId === 'UNASSIGNED') { + if ($assetProxyRepository instanceof NeosAssetProxyRepository && $assetCollectionId->isUnassigned()) { $assetProxyRepository->filterUnassigned(); } else { /** @var AssetCollection $assetCollection */ - $assetCollection = $this->assetCollectionRepository->findByIdentifier($assetCollectionId); + $assetCollection = $this->assetCollectionRepository->findByIdentifier((string)$assetCollectionId); if ($assetCollection) { $assetProxyRepository->filterByCollection($assetCollection); } @@ -143,10 +125,14 @@ protected function filterByAssetCollection($assetCollectionId, AssetProxyReposit } } - protected function sort(?string $sortBy, AssetProxyRepositoryInterface $assetProxyRepository, ?string $sortDirection): void - { + protected function sort( + ?Types\SortBy $sortBy, + AssetProxyRepositoryInterface $assetProxyRepository, + ?Types\SortDirection $sortDirection + ): void { if ($sortBy && $assetProxyRepository instanceof SupportsSortingInterface) { - switch ($sortBy) { + // TODO: Refactor when sortBy is a proper enum + switch ($sortBy->value) { case 'name': $assetProxyRepository->orderBy(['resource.filename' => $sortDirection]); break; @@ -166,15 +152,17 @@ protected function sort(?string $sortBy, AssetProxyRepositoryInterface $assetPro * but returns a new query in case of other AssetProxyRepositories as their interface does not allow * combining all filters. */ - protected function filterByTag(?string $tagId, AssetProxyRepositoryInterface $assetProxyRepository): ?AssetProxyQueryInterface - { + protected function filterByTag( + ?Types\TagId $tagId, + AssetProxyRepositoryInterface $assetProxyRepository + ): ?AssetProxyQueryInterface { if (!$tagId || !$assetProxyRepository instanceof SupportsTaggingInterface) { return null; } if ($assetProxyRepository instanceof NeosAssetProxyRepository) { // Add our custom filter - if ($tagId === 'UNTAGGED') { + if ($tagId->isUntagged()) { $assetProxyRepository->filterUntagged(); } else { /** @var Tag $tag */ @@ -185,7 +173,7 @@ protected function filterByTag(?string $tagId, AssetProxyRepositoryInterface $as } } else { // Return a new query for other AssetProxyRepositories - if ($tagId === 'UNTAGGED') { + if ($tagId->isUntagged()) { return $assetProxyRepository->findUntagged()->getQuery(); } @@ -198,8 +186,10 @@ protected function filterByTag(?string $tagId, AssetProxyRepositoryInterface $as return null; } - protected function applySearchTerm(SearchTerm $searchTerm, AssetProxyRepositoryInterface $assetProxyRepository): AssetProxyIteratorAggregate - { + protected function applySearchTerm( + SearchTerm $searchTerm, + AssetProxyRepositoryInterface $assetProxyRepository + ): AssetProxyIteratorAggregate { if ($identifier = $searchTerm->getAssetIdentifierIfPresent()) { // Reset the type filter as it prevents the asset from being found if it is not of the same type $assetProxyRepository->filterByType(null); diff --git a/Classes/Service/AssetChangeLog.php b/Classes/Service/AssetChangeLog.php index a234ee504..5d59ab092 100644 --- a/Classes/Service/AssetChangeLog.php +++ b/Classes/Service/AssetChangeLog.php @@ -1,4 +1,5 @@ cache = $cache; - $this->cacheLifetime = $cacheLifetime; + public function __construct( + private readonly StringFrontend $cache, + private readonly int $cacheLifetime, + ) { } /** * Stores the asset id and the current timestamp in the cache. * The hash for the last change is also updated. * - * @throws CacheException|InvalidDataException + * @throws CacheException|InvalidDataException|\JsonException */ public function add(string $assetId, \DateTimeInterface $lastModified, string $type): void { - $this->cache->set(md5($assetId), json_encode([ - 'assetId' => $assetId, - 'lastModified' => $lastModified->format(DATE_W3C), - 'type' => $type, - ], JSON_THROW_ON_ERROR), ['changedAssets'], $this->cacheLifetime); + $change = instantiate( + Types\AssetChange::class, + [ + 'assetId' => $assetId, + 'lastModified' => $lastModified->format(DATE_W3C), + 'type' => $type, + ] + ); + $this->cache->set( + md5($assetId), + json_encode($change, JSON_THROW_ON_ERROR), + ['changedAssets'], + $this->cacheLifetime + ); } - /** - * @return array[] the assetId and timestamp for each change - */ - public function getChanges(): array + public function getChanges(): Types\AssetChanges { try { $cachedChanges = $this->cache->getByTag('changedAssets'); - } catch (NotSupportedByBackendException $e) { - return []; + } catch (NotSupportedByBackendException) { + return Types\AssetChanges::empty(); } - return array_map(static function ($entry) { - return json_decode($entry, true); + return array_map(static function (array $entry) { + try { + $changeData = json_decode($entry, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + return instantiate(Types\AssetChange::class, $changeData); }, array_filter(array_values($cachedChanges))); } } diff --git a/Classes/Service/UsageDetailsService.php b/Classes/Service/UsageDetailsService.php index 73cfb1d4a..c433d66f7 100644 --- a/Classes/Service/UsageDetailsService.php +++ b/Classes/Service/UsageDetailsService.php @@ -15,9 +15,12 @@ */ 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\Types; use GuzzleHttp\Psr7\ServerRequest; use GuzzleHttp\Psr7\Uri; use Neos\ContentRepository\Domain\Model\Node; @@ -51,109 +54,43 @@ use Neos\Neos\Service\LinkingService; use Neos\Neos\Service\UserService; -/** - * @Flow\Scope("singleton") - */ -class UsageDetailsService +use function Wwwision\Types\instantiate; + +#[Flow\Scope('singleton')] +final class UsageDetailsService { + # TODO: Use ContextFactory instead of trait use CreateContentContextTrait; use BackendUserTranslationTrait; - /** - * @Flow\Inject - * @var Bootstrap - */ - protected $bootstrap; - - /** - * @Flow\Inject - * @var UserService - */ - protected $userService; - - /** - * @Flow\Inject - * @var SiteRepository - */ - protected $siteRepository; - - /** - * @Flow\Inject - * @var NodeTypeManager - */ - protected $nodeTypeManager; - - /** - * @Flow\Inject - * @var WorkspaceRepository - */ - protected $workspaceRepository; - - /** - * @Flow\Inject - * @var AssetService - */ - protected $assetService; - - /** - * @Flow\Inject - * @var DomainUserService - */ - protected $domainUserService; - - /** - * @Flow\Inject - * @var ObjectManagerInterface - */ - protected $objectManager; - - /** - * @Flow\Inject - * @var LinkingService - */ - protected $linkingService; - - /** - * @Flow\Inject - * @var ReflectionService - */ - protected $reflectionService; - - /** - * @Flow\Inject - * @var EntityManagerInterface - */ - protected $entityManager; - - /** - * @Flow\Inject - * @var PackageManager - */ - protected $packageManager; - - /** - * @Flow\InjectConfiguration(path="contentDimensions", package="Neos.ContentRepository") - * @var array - */ - protected $contentDimensionsConfiguration; - - /** - * @Flow\Inject - * @var Translator - */ - protected $translator; + #[Flow\InjectConfiguration('contentDimensions', 'Neos.ContentRepository')] + protected array $contentDimensionsConfiguration = []; private array $accessibleWorkspaces = []; - /** - * @return AssetUsageDetails[] - */ - public function resolveUsagesForAsset(AssetInterface $asset): array + 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, + ) { + } + + public function resolveUsagesForAsset(AssetInterface $asset): Types\UsageDetailsGroups { $includeSites = $this->siteRepository->countAll() > 1; $includeDimensions = count($this->contentDimensionsConfiguration) > 0; - return array_filter(array_map(function ($strategy) use ($asset, $includeSites, $includeDimensions) { + $groups = array_map(function ($strategy) use ($asset, $includeSites, $includeDimensions) { $usageByStrategy = [ 'serviceId' => get_class($strategy), 'label' => get_class($strategy), @@ -162,7 +99,7 @@ public function resolveUsagesForAsset(AssetInterface $asset): array ]; if (!$strategy instanceof AssetUsageStrategyInterface) { - return $usageByStrategy; + return instantiate(Types\UsageDetailsGroup::class, $usageByStrategy); } // Should be solved via an interface in the future @@ -189,14 +126,25 @@ public function resolveUsagesForAsset(AssetInterface $asset): array return $this->getNodePropertiesUsageDetails($usage, $includeSites, $includeDimensions); }, $usageReferences); } - } catch (NodeConfigurationException $e) { + } catch (NodeConfigurationException) { // TODO: Handle error } } - return $usageByStrategy; - }, $this->getUsageStrategies()), static function ($usageByStrategy) { - return count($usageByStrategy['usages']) > 0; + // TODO: Already return a graphql compatible type before, so we don't have to map it here + $usageByStrategy['usages'] = array_map( + static function (AssetUsageDetails $usage) { + return Types\UsageDetails::fromUsage($usage); + }, + $usageByStrategy['usages'] + ); + return instantiate(Types\UsageDetailsGroup::class, $usageByStrategy); + }, $this->getUsageStrategies()); + + $groups = array_filter($groups, static function (Types\UsageDetailsGroup $usageByStrategy) { + return !$usageByStrategy->usages->isEmpty(); }); + + return instantiate(Types\UsageDetailsGroups::class, $groups); } protected function getNodePropertiesUsageMetadataSchema( @@ -390,10 +338,10 @@ protected function getUsageStrategies(): array /** * Returns all assets which have no usage reference provided by `Flowpack.EntityUsage` * - * @return array + * @return AssetInterface[] * @throws Exception */ - public function getUnusedAssets(int $limit = 20, int $offset = 0): array + 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(); @@ -411,7 +359,7 @@ public function getUnusedAssets(int $limit = 20, int $offset = 0): array ) ORDER BY a.lastModified DESC ', $this->getAssetVariantFilterClause('a'))) - ->setParameter('assetSourceIdentifier', 'neos') + ->setParameter('assetSourceIdentifier', $assetSourceId->value ?? 'neos') ->setFirstResult($offset) ->setMaxResults($limit) ->getResult(); @@ -447,6 +395,8 @@ protected function getAssetVariantFilterClause(string $alias): string /** * Returns number of assets which have no usage reference provided by `Flowpack.EntityUsage` * + * @throws NoResultException + * @throws NonUniqueResultException * @throws Exception */ public function getUnusedAssetCount(): int diff --git a/Classes/Transform/FlowErrorTransform.php b/Classes/Transform/FlowErrorTransform.php index 86846fe12..fc49771bc 100644 --- a/Classes/Transform/FlowErrorTransform.php +++ b/Classes/Transform/FlowErrorTransform.php @@ -6,7 +6,7 @@ use GraphQL\Error\Error; use GraphQL\Executor\ExecutionResult; -use GraphQLTools\Transforms\Transform; +//use GraphQLTools\Transforms\Transform; use Neos\Flow\Annotations as Flow; use Neos\Flow\Log\ThrowableStorageInterface; @@ -14,7 +14,7 @@ * This transform is used to convert exceptions to errors in the GraphQL response. * To be able to localize error messages we extend the FlowErrorTransform from the t3n.GraphQL package. */ -class FlowErrorTransform extends \t3n\GraphQL\Transform\FlowErrorTransform +class FlowErrorTransform { public function transformResult(ExecutionResult $result): ExecutionResult { diff --git a/Configuration/Caches.yaml b/Configuration/Caches.yaml index cba1ba896..03a233c07 100644 --- a/Configuration/Caches.yaml +++ b/Configuration/Caches.yaml @@ -1,3 +1,10 @@ Flowpack_Media_Ui_PollingCache: frontend: Neos\Cache\Frontend\StringFrontend backend: Neos\Cache\Backend\FileBackend + +Flowpack_Media_Ui_GraphQLSchemaCache: + frontend: Neos\Cache\Frontend\VariableFrontend + backend: Neos\Cache\Backend\SimpleFileBackend + backendOptions: + # 0 = don't expire + defaultLifetime: 0 diff --git a/Configuration/Development/Caches.yaml b/Configuration/Development/Caches.yaml new file mode 100644 index 000000000..e154f7b3d --- /dev/null +++ b/Configuration/Development/Caches.yaml @@ -0,0 +1,3 @@ +Flowpack_Media_Ui_GraphQLSchemaCache: + # disable GraphQL schema caching in development mode + backend: Neos\Cache\Backend\NullBackend diff --git a/Configuration/Development/Settings.yaml b/Configuration/Development/Settings.yaml new file mode 100644 index 000000000..e7ddc1df6 --- /dev/null +++ b/Configuration/Development/Settings.yaml @@ -0,0 +1,5 @@ +Flowpack: + Media: + Ui: + GraphQL: + debugMode: true diff --git a/Configuration/Objects.yaml b/Configuration/Objects.yaml index 678767d78..bfe61cba4 100644 --- a/Configuration/Objects.yaml +++ b/Configuration/Objects.yaml @@ -10,3 +10,67 @@ 2: # cache lifetime value: 10 + +'Flowpack\Media\Ui\GraphQL\Middleware\GraphQLMiddlewareFactory': + arguments: + 1: + setting: Flowpack.Media.Ui.GraphQL.debugMode + 2: + setting: Flowpack.Media.Ui.GraphQL.corsOrigin + 3: + object: + factoryObjectName: 'Neos\Flow\Cache\CacheManager' + factoryMethodName: 'getCache' + arguments: + 1: + value: 'Flowpack_Media_Ui_GraphQLSchemaCache' + +'Flowpack.Media.Ui:GraphQLMiddleware': + className: 'Flowpack\Media\Ui\GraphQL\Middleware\GraphQLMiddleware' + scope: singleton + factoryObjectName: 'Flowpack\Media\Ui\GraphQL\Middleware\GraphQLMiddlewareFactory' + arguments: + 1: + # GraphQL URL + value: '/neos/graphql/media-assets' + 2: + # PHP Class with the Query/Mutation attributed methods + value: 'Flowpack\Media\Ui\GraphQL\MediaApi' + 3: + # Look for classes in the following namespaces when resolving types: + value: + - 'Flowpack\Media\Ui\GraphQL\Types' + 4: + # Simulate a request to the Neos NodeController in order to initialize the security context and trigger the default Neos backend authentication provider + value: 'Neos\Neos\Controller\Frontend\NodeController' + 5: + # Custom resolvers + value: + Asset: + file: &assetResolver + resolverClassName: 'Flowpack\Media\Ui\GraphQL\Resolver\Type\AssetResolver' + imported: *assetResolver + iptcProperties: *assetResolver + label: *assetResolver + caption: *assetResolver + copyrightNotice: *assetResolver + lastModified: *assetResolver + tags: *assetResolver + collections: *assetResolver + thumbnailUrl: *assetResolver + previewUrl: *assetResolver + isInUse: + <<: *assetResolver + description: 'Check if the asset is used as reported by registered AssetUsageStrategies' + AssetCollection: + assetCount: &assetCollectionResolver + resolverClassName: 'Flowpack\Media\Ui\GraphQL\Resolver\Type\AssetCollectionResolver' + canDelete: *assetCollectionResolver + tags: *assetCollectionResolver +# assets: *assetCollectionResolver +# parent: *assetCollectionResolver + AssetVariant: + previewUrl: &assetVariantResolver + resolverClassName: 'Flowpack\Media\Ui\GraphQL\Resolver\Type\AssetVariantResolver' + hasCrop: *assetVariantResolver + cropInformation: *assetVariantResolver diff --git a/Configuration/Routes.yaml b/Configuration/Routes.yaml index ce99b0e77..1a6c08e69 100644 --- a/Configuration/Routes.yaml +++ b/Configuration/Routes.yaml @@ -1,7 +1,7 @@ -- name: 'Media API' - uriPattern: 'neos/graphql/' - subRoutes: - 'GraphQLSubroutes': - package: 't3n.GraphQL' - variables: - 'endpoint': 'media-assets' +#- name: 'Media API' +# uriPattern: 'neos/graphql/' +# subRoutes: +# 'GraphQLSubroutes': +# package: 't3n.GraphQL' +# variables: +# 'endpoint': 'media-assets' diff --git a/Configuration/Settings.Features.yaml b/Configuration/Settings.Features.yaml index 0c77c98b9..0d8bb4cdf 100644 --- a/Configuration/Settings.Features.yaml +++ b/Configuration/Settings.Features.yaml @@ -12,7 +12,7 @@ Neos: assetsPerPage: 20 maximumLinks: 5 # Background polling for changes by other users - pollForChanges: true + pollForChanges: false # Settings for the property editor propertyEditor: collapsed: false diff --git a/Configuration/Settings.Flow.yaml b/Configuration/Settings.Flow.yaml index 3716b1d9a..a2eafe759 100644 --- a/Configuration/Settings.Flow.yaml +++ b/Configuration/Settings.Flow.yaml @@ -10,11 +10,17 @@ Neos: providers: 'Neos.Neos:Backend': requestPatterns: - 'Flowpack.Media.Ui:GraphQLControllers': - pattern: 'ControllerObjectName' - patternOptions: - controllerObjectNamePattern: 't3n\GraphQL\Controller\.*' +# 'Flowpack.Media.Ui:GraphQLControllers': +# pattern: 'ControllerObjectName' +# patternOptions: +# controllerObjectNamePattern: 't3n\GraphQL\Controller\.*' 'Flowpack.Media.Ui:Controllers': pattern: 'ControllerObjectName' patternOptions: controllerObjectNamePattern: 'Flowpack\Media\Ui\Controller\.*' + + http: + middlewares: + 'Flowpack.Media.Ui:GraphQL': + position: 'before routing' + middleware: 'Flowpack.Media.Ui:GraphQLMiddleware' diff --git a/Configuration/Settings.GraphQL.yaml b/Configuration/Settings.GraphQL.yaml deleted file mode 100644 index ce61b3db7..000000000 --- a/Configuration/Settings.GraphQL.yaml +++ /dev/null @@ -1,13 +0,0 @@ -t3n: - GraphQL: - endpoints: - 'media-assets': - logRequests: false - context: 'Flowpack\Media\Ui\GraphQL\Context\AssetSourceContext' - errorTransform: 'Flowpack\Media\Ui\Transform\FlowErrorTransform' - schemas: - root: - typeDefs: 'resource://Flowpack.Media.Ui/Private/GraphQL/schema.root.graphql' - resolverPathPattern: 'Flowpack\Media\Ui\GraphQL\Resolver\Type\{Type}Resolver' - resolvers: - AssetProxy: 'Flowpack\Media\Ui\GraphQL\Resolver\Type\AssetResolver' diff --git a/Configuration/Settings.MediaUi.yaml b/Configuration/Settings.MediaUi.yaml new file mode 100644 index 000000000..efe5552a8 --- /dev/null +++ b/Configuration/Settings.MediaUi.yaml @@ -0,0 +1,8 @@ +Flowpack: + Media: + Ui: + maximumFileUploadLimit: 10 + GraphQL: + debugMode: false + # The "Access-Control-Allow-Origin" response header + corsOrigin: '*' diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml deleted file mode 100644 index 6f687b629..000000000 --- a/Configuration/Settings.yaml +++ /dev/null @@ -1,4 +0,0 @@ -Flowpack: - Media: - Ui: - maximumFileUploadLimit: 10 diff --git a/Resources/Private/GraphQL/schema.root.graphql b/Resources/Private/GraphQL/schema.root.graphql index adae83606..1ea058cd8 100644 --- a/Resources/Private/GraphQL/schema.root.graphql +++ b/Resources/Private/GraphQL/schema.root.graphql @@ -174,32 +174,32 @@ type Config { An asset (Image, Document, Video or Audio) """ type Asset { - id: AssetId! - localId: LocalAssetId - assetSource: AssetSource! - imported: Boolean! - isInUse: Boolean! + id: AssetId! #✅ + localId: LocalAssetId #✅ + assetSource: AssetSource! #✅ + imported: Boolean! #✅ + isInUse: Boolean! #✅ - label: String! - caption: String - filename: Filename! + label: String! #✅ + caption: String #✅ + filename: Filename! #✅ - tags: [Tag!]! - collections: [AssetCollection!]! + tags: [Tag!]! #✅ + collections: [AssetCollection!]! # WIP - copyrightNotice: String + copyrightNotice: String #✅ iptcProperty(property: IptcPropertyName): IptcProperty - lastModified: DateTime - iptcProperties: [IptcProperty!] + lastModified: DateTime #✅ + iptcProperties: [IptcProperty!] #✅ # width in pixels (only for Images and Videos) - width: Int + width: Int #✅ # height in pixels (only for Images and Videos) - height: Int + height: Int #✅ - file: File! - thumbnailUrl: Url - previewUrl: Url + file: File! #✅ + thumbnailUrl: Url #✅ + previewUrl: Url #✅ thumbnail(maximumWidth: Int, maximumHeight: Int, ratioMode: RatioMode, allowUpScaling: Boolean): Image } @@ -273,14 +273,14 @@ type Tag { A collection of assets. One asset can belong to multiple collections """ type AssetCollection { - id: AssetCollectionId - title: AssetCollectionTitle! + id: AssetCollectionId #/ + title: AssetCollectionTitle! #/ assets: [Asset!]! - parent: AssetCollection - tags: [Tag!]! - assetCount: Int! - path: AssetCollectionPath - canDelete: Boolean! + parent: AssetCollection #/ + tags: [Tag!]! #/ + assetCount: Int! #/ + path: AssetCollectionPath #/ + canDelete: Boolean! #/ } """ diff --git a/composer.json b/composer.json index 26e3f577c..4411eff93 100644 --- a/composer.json +++ b/composer.json @@ -1,43 +1,44 @@ { - "name": "flowpack/media-ui", - "description": "This module allows managing media assets including pictures, videos, audio and documents.", - "type": "neos-package", - "require": { - "php": "^8.1", - "neos/media": "^7.3 || ~8.0", - "neos/neos": "^7.3 || ~8.0", - "neos/neos-ui": "^7.3 || ~8.0", - "t3n/graphql": "^2.1 || ^3.0.2", - "t3n/graphql-upload": "^1.0 || ^2.0" - }, - "require-dev": { - "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" - }, - "scripts": { - "test": "../../../bin/phpunit --enforce-time-limit --bootstrap ../../Libraries/autoload.php --testdox Tests", - "test:ci": "phpunit --enforce-time-limit --bootstrap vendor/autoload.php --testdox Tests", - "codestyle": "phpstan analyse --autoload-file ../../Libraries/autoload.php", - "codestyle:ci": "phpstan analyse" - }, - "license": "GPL-3.0-or-later", - "autoload": { - "psr-4": { - "Flowpack\\Media\\Ui\\": "Classes" - } - }, - "autoload-dev": { - "psr-4": { - "Flowpack\\Media\\Ui\\Tests\\": "Tests" - } - }, - "config": { - "allow-plugins": { - "neos/composer-plugin": true - } + "name": "flowpack/media-ui", + "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", + "webonyx/graphql-php": "^15", + "wwwision/types": "^1.5.1", + "wwwision/types-graphql": "^1.2" + }, + "require-dev": { + "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" + }, + "scripts": { + "test": "../../../bin/phpunit --enforce-time-limit --bootstrap ../../Libraries/autoload.php --testdox Tests", + "test:ci": "phpunit --enforce-time-limit --bootstrap vendor/autoload.php --testdox Tests", + "codestyle": "phpstan analyse --autoload-file ../../Libraries/autoload.php", + "codestyle:ci": "phpstan analyse" + }, + "license": "GPL-3.0-or-later", + "autoload": { + "psr-4": { + "Flowpack\\Media\\Ui\\": "Classes" } + }, + "autoload-dev": { + "psr-4": { + "Flowpack\\Media\\Ui\\Tests\\": "Tests" + } + }, + "config": { + "allow-plugins": { + "neos/composer-plugin": true + } + } }