diff --git a/Classes/AssetSource/CantoAssetProxy.php b/Classes/AssetSource/CantoAssetProxy.php
new file mode 100644
index 0000000..20b1386
--- /dev/null
+++ b/Classes/AssetSource/CantoAssetProxy.php
@@ -0,0 +1,285 @@
+assetSource = $assetSource;
+ $assetProxy->identifier = $jsonObject->scheme . '|' . $jsonObject->id;
+ $assetProxy->label = $jsonObject->name;
+ $assetProxy->filename = Transliterator::urlize($jsonObject->name);
+ $assetProxy->lastModified = \DateTimeImmutable::createFromFormat('U', $jsonObject->time);
+ $assetProxy->fileSize = $jsonObject->size;
+ $assetProxy->mediaType = MediaTypes::getMediaTypeFromFilename($jsonObject->name);
+ $assetProxy->tags = $jsonObject->tag ?? [];
+
+ $assetProxy->widthInPixels = $jsonObject->width ?? null;
+ $assetProxy->heightInPixels = $jsonObject->height ?? null;
+
+ $assetProxy->thumbnailUri = new Uri($jsonObject->url->directUrlPreview);
+ $assetProxy->previewUri = new Uri($jsonObject->url->directUrlPreview);
+ $assetProxy->originalUri = new Uri($jsonObject->url->directUrlOriginal);
+
+# \Neos\Flow\var_dump($assetProxy->getMediaType());
+
+ return $assetProxy;
+ }
+
+ /**
+ * @return AssetSourceInterface
+ */
+ public function getAssetSource(): AssetSourceInterface
+ {
+ return $this->assetSource;
+ }
+
+ /**
+ * @return string
+ */
+ public function getIdentifier(): string
+ {
+ return $this->identifier;
+ }
+
+ /**
+ * @return string
+ */
+ public function getLabel(): string
+ {
+ return $this->label;
+ }
+
+ /**
+ * @return string
+ */
+ public function getFilename(): string
+ {
+ return $this->filename;
+ }
+
+ /**
+ * @return \DateTimeInterface
+ */
+ public function getLastModified(): \DateTimeInterface
+ {
+ return $this->lastModified;
+ }
+
+ /**
+ * @return int
+ */
+ public function getFileSize(): int
+ {
+ return $this->fileSize;
+ }
+
+ /**
+ * @return string
+ */
+ public function getMediaType(): string
+ {
+ return $this->mediaType;
+ }
+
+ /**
+ * @param string $propertyName
+ * @return bool
+ */
+ public function hasIptcProperty(string $propertyName): bool
+ {
+ return isset($this->iptcProperties[$propertyName]);
+ }
+
+ /**
+ * @param string $propertyName
+ * @return string
+ */
+ public function getIptcProperty(string $propertyName): string
+ {
+ return $this->iptcProperties[$propertyName] ?? '';
+ }
+
+ /**
+ * @return array
+ */
+ public function getIptcProperties(): array
+ {
+ return $this->iptcProperties;
+ }
+
+ /**
+ * @return int|null
+ */
+ public function getWidthInPixels(): ?int
+ {
+ return $this->widthInPixels;
+ }
+
+ /**
+ * @return int|null
+ */
+ public function getHeightInPixels(): ?int
+ {
+ return $this->heightInPixels;
+ }
+
+ /**
+ * @return UriInterface
+ */
+ public function getThumbnailUri(): ?UriInterface
+ {
+ return $this->thumbnailUri;
+ }
+
+ /**
+ * @return UriInterface
+ */
+ public function getPreviewUri(): ?UriInterface
+ {
+ return $this->previewUri;
+ }
+
+ /**
+ * @return resource
+ */
+ public function getImportStream()
+ {
+ return fopen($this->assetSource->getCantoClient()->directUri($this->identifier), 'rb');
+ }
+
+ /**
+ * @return string
+ */
+ public function getLocalAssetIdentifier(): ?string
+ {
+ $importedAsset = $this->importedAssetRepository->findOneByAssetSourceIdentifierAndRemoteAssetIdentifier($this->assetSource->getIdentifier(), $this->identifier);
+ return ($importedAsset instanceof ImportedAsset ? $importedAsset->getLocalAssetIdentifier() : null);
+ }
+
+ /**
+ * @return array
+ */
+ public function getTags(): array
+ {
+ return $this->tags;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isImported(): bool
+ {
+ return true;
+ }
+}
diff --git a/Classes/AssetSource/CantoAssetProxyQuery.php b/Classes/AssetSource/CantoAssetProxyQuery.php
new file mode 100644
index 0000000..a2813cc
--- /dev/null
+++ b/Classes/AssetSource/CantoAssetProxyQuery.php
@@ -0,0 +1,246 @@
+assetSource = $assetSource;
+ }
+
+ /**
+ * @param int $offset
+ */
+ public function setOffset(int $offset): void
+ {
+ $this->offset = $offset;
+ }
+
+ /**
+ * @return int
+ */
+ public function getOffset(): int
+ {
+ return $this->offset;
+ }
+
+
+ /**
+ * @param int $limit
+ */
+ public function setLimit(int $limit): void
+ {
+ $this->limit = $limit;
+ }
+
+ /**
+ * @return int
+ */
+ public function getLimit(): int
+ {
+ return $this->limit;
+ }
+
+ /**
+ * @param string $searchTerm
+ */
+ public function setSearchTerm(string $searchTerm)
+ {
+ $this->searchTerm = $searchTerm;
+ }
+
+ /**
+ * @return string
+ */
+ public function getSearchTerm(): string
+ {
+ return $this->searchTerm;
+ }
+
+ /**
+ * @param string $assetTypeFilter
+ */
+ public function setAssetTypeFilter(string $assetTypeFilter)
+ {
+ $this->assetTypeFilter = $assetTypeFilter;
+ }
+
+ /**
+ * @return string
+ */
+ public function getAssetTypeFilter(): string
+ {
+ return $this->assetTypeFilter;
+ }
+
+ /**
+ * @return array
+ */
+ public function getOrderings(): array
+ {
+ return $this->orderings;
+ }
+
+ /**
+ * @param array $orderings
+ */
+ public function setOrderings(array $orderings): void
+ {
+ $this->orderings = $orderings;
+ }
+
+ /**
+ * @return string
+ */
+ public function getParentFolderIdentifier(): string
+ {
+ return $this->parentFolderIdentifier;
+ }
+
+ /**
+ * @param string $parentFolderIdentifier
+ */
+ public function setParentFolderIdentifier(string $parentFolderIdentifier): void
+ {
+ $this->parentFolderIdentifier = $parentFolderIdentifier;
+ }
+
+ /**
+ * @return AssetProxyQueryResultInterface
+ */
+ public function execute(): AssetProxyQueryResultInterface
+ {
+ return new CantoAssetProxyQueryResult($this);
+ }
+
+ /**
+ * @return int
+ */
+ public function count(): int
+ {
+ $response = $this->sendSearchRequest(1, []);
+ $responseObject = \GuzzleHttp\json_decode($response->getBody());
+ return $responseObject->found ?? 0;
+ }
+
+ /**
+ * @return CantoAssetProxy[]
+ */
+ public function getArrayResult(): array
+ {
+ $assetProxies = [];
+ $response = $this->sendSearchRequest($this->limit, $this->orderings);
+ $responseObject = \GuzzleHttp\json_decode($response->getBody());
+
+ foreach ($responseObject->results as $rawAsset) {
+ $assetProxies[] = CantoAssetProxy::fromJsonObject($rawAsset, $this->assetSource);
+ }
+ return $assetProxies;
+ }
+
+ /**
+ * @param int $limit
+ * @param array $orderings
+ * @return Response
+ * @throws AuthenticationFailedException
+ * @throws ConnectionException
+ * @throws IdentityProviderException
+ */
+ private function sendSearchRequest(int $limit, array $orderings): Response
+ {
+ $searchTerm = $this->searchTerm;
+
+ switch ($this->assetTypeFilter) {
+ case 'Image':
+ $formatTypes = ['image'];
+ $fileTypes = [];
+ break;
+ case 'Video':
+ $formatTypes = ['video'];
+ $fileTypes = [];
+ break;
+ case 'Audio':
+ $formatTypes = ['audio'];
+ $fileTypes = [];
+ break;
+ case 'Document':
+ $formatTypes = ['document'];
+ $fileTypes = ['pdf'];
+ break;
+ case 'All':
+ default:
+ $formatTypes = ['image', 'video', 'audio', 'document'];
+ $fileTypes = [];
+ break;
+ }
+
+ return $this->assetSource->getCantoClient()->search($searchTerm, $formatTypes, $fileTypes, $this->offset, $limit, $orderings);
+ }
+}
diff --git a/Classes/AssetSource/CantoAssetProxyQueryResult.php b/Classes/AssetSource/CantoAssetProxyQueryResult.php
new file mode 100644
index 0000000..756da01
--- /dev/null
+++ b/Classes/AssetSource/CantoAssetProxyQueryResult.php
@@ -0,0 +1,154 @@
+query = $query;
+ }
+
+ /**
+ * @return void
+ */
+ private function initialize(): void
+ {
+ if ($this->assetProxies === null) {
+ $this->assetProxies = $this->query->getArrayResult();
+ $this->assetProxiesIterator = new \ArrayIterator($this->assetProxies);
+ }
+ }
+
+ /**
+ * @return AssetProxyQueryInterface
+ */
+ public function getQuery(): AssetProxyQueryInterface
+ {
+ return clone $this->query;
+ }
+
+ /**
+ * @return AssetProxyInterface|null
+ */
+ public function getFirst(): ?AssetProxyInterface
+ {
+ $this->initialize();
+ return reset($this->assetProxies);
+ }
+
+ /**
+ * @return AssetProxyInterface[]
+ */
+ public function toArray(): array
+ {
+ $this->initialize();
+ return $this->assetProxies;
+ }
+
+ public function current()
+ {
+ $this->initialize();
+ return $this->assetProxiesIterator->current();
+ }
+
+ public function next()
+ {
+ $this->initialize();
+ $this->assetProxiesIterator->next();
+ }
+
+ public function key()
+ {
+ $this->initialize();
+ return $this->assetProxiesIterator->key();
+
+ }
+
+ public function valid()
+ {
+ $this->initialize();
+ return $this->assetProxiesIterator->valid();
+ }
+
+ public function rewind()
+ {
+ $this->initialize();
+ $this->assetProxiesIterator->rewind();
+ }
+
+ public function offsetExists($offset)
+ {
+ $this->initialize();
+ return $this->assetProxiesIterator->offsetExists($offset);
+ }
+
+ public function offsetGet($offset)
+ {
+ $this->initialize();
+ return $this->assetProxiesIterator->offsetGet($offset);
+ }
+
+ public function offsetSet($offset, $value)
+ {
+ $this->initialize();
+ $this->assetProxiesIterator->offsetSet($offset, $value);
+ }
+
+ public function offsetUnset($offset)
+ {
+ }
+
+ /**
+ * @return int
+ */
+ public function count(): int
+ {
+ if ($this->numberOfAssetProxies === null) {
+ if (is_array($this->assetProxies)) {
+ return count($this->assetProxies);
+ } else {
+ return $this->query->count();
+ }
+ }
+ }
+}
diff --git a/Classes/AssetSource/CantoAssetProxyRepository.php b/Classes/AssetSource/CantoAssetProxyRepository.php
new file mode 100644
index 0000000..e65b52a
--- /dev/null
+++ b/Classes/AssetSource/CantoAssetProxyRepository.php
@@ -0,0 +1,179 @@
+assetSource = $assetSource;
+ }
+
+ /**
+ * @var string
+ */
+ private $assetTypeFilter = 'All';
+
+ /**
+ * @var array
+ */
+ private $orderings = [];
+
+ /**
+ * @var VariableFrontend
+ */
+ protected $assetProxyCache;
+
+ /**
+ * @param string $identifier
+ * @return AssetProxyInterface
+ * @throws AssetNotFoundExceptionInterface
+ * @throws AssetSourceConnectionExceptionInterface
+ * @throws AuthenticationFailedException
+ * @throws AssetNotFoundException
+ * @throws Exception
+ */
+ public function getAssetProxy(string $identifier): AssetProxyInterface
+ {
+ $client = $this->assetSource->getCantoClient();
+
+ $response = $client->getFile($identifier);
+ $responseObject = \GuzzleHttp\json_decode($response->getBody());
+
+ if (!$responseObject instanceof \stdClass) {
+ throw new AssetNotFoundException('Asset not found', 1526636260);
+ }
+
+ return CantoAssetProxy::fromJsonObject($responseObject, $this->assetSource);
+ }
+
+ /**
+ * @param AssetTypeFilter $assetType
+ */
+ public function filterByType(AssetTypeFilter $assetType = null): void
+ {
+ $this->assetTypeFilter = (string)$assetType ?: 'All';
+ }
+
+ /**
+ * @return AssetProxyQueryResultInterface
+ */
+ public function findAll(): AssetProxyQueryResultInterface
+ {
+ $query = new CantoAssetProxyQuery($this->assetSource);
+ $query->setAssetTypeFilter($this->assetTypeFilter);
+ $query->setOrderings($this->orderings);
+ return new CantoAssetProxyQueryResult($query);
+ }
+
+ /**
+ * @param string $searchTerm
+ * @return AssetProxyQueryResultInterface
+ */
+ public function findBySearchTerm(string $searchTerm): AssetProxyQueryResultInterface
+ {
+ $query = new CantoAssetProxyQuery($this->assetSource);
+ $query->setSearchTerm($searchTerm);
+ $query->setAssetTypeFilter($this->assetTypeFilter);
+ $query->setOrderings($this->orderings);
+ return new CantoAssetProxyQueryResult($query);
+ }
+
+ /**
+ * @param Tag $tag
+ * @return AssetProxyQueryResultInterface
+ */
+ public function findByTag(Tag $tag): AssetProxyQueryResultInterface
+ {
+ $query = new CantoAssetProxyQuery($this->assetSource);
+ $query->setSearchTerm($tag->getLabel());
+ $query->setAssetTypeFilter($this->assetTypeFilter);
+ $query->setOrderings($this->orderings);
+ return new CantoAssetProxyQueryResult($query);
+ }
+
+ /**
+ * @return AssetProxyQueryResultInterface
+ */
+ public function findUntagged(): AssetProxyQueryResultInterface
+ {
+ $query = new CantoAssetProxyQuery($this->assetSource);
+ $query->setAssetTypeFilter($this->assetTypeFilter);
+ $query->setOrderings($this->orderings);
+ return new CantoAssetProxyQueryResult($query);
+ }
+
+ /**
+ * @return int
+ */
+ public function countAll(): int
+ {
+ $query = new CantoAssetProxyQuery($this->assetSource);
+ return $query->count();
+ }
+
+ /**
+ * Sets the property names to order results by. Expected like this:
+ * array(
+ * 'filename' => SupportsSorting::ORDER_ASCENDING,
+ * 'lastModified' => SupportsSorting::ORDER_DESCENDING
+ * )
+ *
+ * @param array $orderings The property names to order by by default
+ * @return void
+ * @api
+ */
+ public function orderBy(array $orderings): void
+ {
+ $this->orderings = $orderings;
+ }
+
+ /**
+ * @return StringFrontend
+ */
+ public function getAssetProxyCache(): StringFrontend
+ {
+ if ($this->assetProxyCache instanceof DependencyProxy) {
+ $this->assetProxyCache->_activateDependency();
+ }
+ return $this->assetProxyCache;
+ }
+}
diff --git a/Classes/AssetSource/CantoAssetSource.php b/Classes/AssetSource/CantoAssetSource.php
new file mode 100644
index 0000000..3a16d11
--- /dev/null
+++ b/Classes/AssetSource/CantoAssetSource.php
@@ -0,0 +1,245 @@
+assetSourceIdentifier = $assetSourceIdentifier;
+ $this->assetSourceOptions = $assetSourceOptions;
+
+ foreach ($assetSourceOptions as $optionName => $optionValue) {
+ switch ($optionName) {
+ case 'apiBaseUri':
+ $uri = new Uri($optionValue);
+ $this->apiBaseUri = $uri->__toString();
+ break;
+ case 'oAuthBaseUri':
+ $uri = new Uri($optionValue);
+ $this->oAuthBaseUri = $uri->__toString();
+ break;
+ case 'appId':
+ if (!is_string($optionValue) || empty($optionValue)) {
+ throw new \InvalidArgumentException(sprintf('Invalid app id specified for Canto asset source %s', $assetSourceIdentifier), 1607085646);
+ }
+ $this->appId = $optionValue;
+ break;
+ case 'appSecret':
+ if (!is_string($optionValue) || empty($optionValue)) {
+ throw new \InvalidArgumentException(sprintf('Invalid app secret specified for Canto asset source %s', $assetSourceIdentifier), 1607085604);
+ }
+ $this->appSecret = $optionValue;
+ break;
+ case 'mediaTypes':
+ if (!is_array($optionValue)) {
+ throw new \InvalidArgumentException(sprintf('Invalid media types specified for Canto asset source %s', $assetSourceIdentifier), 1542809628);
+ }
+ foreach ($optionValue as $mediaType => $mediaTypeOptions) {
+ if (MediaTypes::getFilenameExtensionsFromMediaType($mediaType) === []) {
+ throw new \InvalidArgumentException(sprintf('Unknown media type "%s" specified for Canto asset source %s', $mediaType, $assetSourceIdentifier), 1542809775);
+ }
+ }
+ break;
+ case 'iconPath':
+ case 'description':
+ $this->$optionName = (string)$optionValue;
+ break;
+ default:
+ throw new \InvalidArgumentException(sprintf('Unknown asset source option "%s" specified for Canto asset source "%s". Please check your settings.', $optionName, $assetSourceIdentifier), 1525790910);
+ }
+ }
+ }
+
+ /**
+ * @param string $assetSourceIdentifier
+ * @param array $assetSourceOptions
+ * @return AssetSourceInterface
+ */
+ public static function createFromConfiguration(string $assetSourceIdentifier, array $assetSourceOptions): AssetSourceInterface
+ {
+ return new static($assetSourceIdentifier, $assetSourceOptions);
+ }
+
+ /**
+ * @return string
+ */
+ public function getIdentifier(): string
+ {
+ return $this->assetSourceIdentifier;
+ }
+
+ /**
+ * @return string
+ */
+ public function getLabel(): string
+ {
+ return 'Canto';
+ }
+
+ /**
+ * @return AssetProxyRepositoryInterface
+ */
+ public function getAssetProxyRepository(): AssetProxyRepositoryInterface
+ {
+ if ($this->assetProxyRepository === null) {
+ $this->assetProxyRepository = new CantoAssetProxyRepository($this);
+ }
+
+ return $this->assetProxyRepository;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isReadOnly(): bool
+ {
+ return true;
+ }
+
+ /**
+ * @return array
+ */
+ public function getAssetSourceOptions(): array
+ {
+ return $this->assetSourceOptions;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isAutoTaggingEnabled(): bool
+ {
+ return $this->autoTaggingEnable;
+ }
+
+ /**
+ * @return string
+ */
+ public function getAutoTaggingInUseTag(): string
+ {
+ return $this->autoTaggingInUseTag;
+ }
+
+ /**
+ * @return string
+ */
+ public function getIconUri(): string
+ {
+ return $this->resourceManager->getPublicPackageResourceUriByPath($this->iconPath);
+ }
+
+ /**
+ * @return string
+ */
+ public function getDescription(): string
+ {
+ return $this->description;
+ }
+
+ /**
+ * @return CantoClient
+ * @throws AuthenticationFailedException
+ * @throws IdentityProviderException
+ */
+ public function getCantoClient(): CantoClient
+ {
+ if ($this->cantoClient === null) {
+ $this->cantoClient = new CantoClient(
+ $this->apiBaseUri,
+ $this->oAuthBaseUri,
+ $this->appId,
+ $this->appSecret
+ );
+
+ $this->cantoClient->authenticate();
+ }
+ return $this->cantoClient;
+ }
+}
diff --git a/Classes/Command/CantoCommandController.php b/Classes/Command/CantoCommandController.php
new file mode 100644
index 0000000..c55b936
--- /dev/null
+++ b/Classes/Command/CantoCommandController.php
@@ -0,0 +1,115 @@
+assetRepository->findAllIterator();
+
+ !$quiet && $this->outputLine('Tagging used assets of asset source "%s" via Canto API:', [$assetSourceIdentifier]);
+
+ try {
+ $cantoAssetSource = new CantoAssetSource($assetSourceIdentifier, $this->assetSourcesConfiguration[$assetSourceIdentifier]['assetSourceOptions']);
+ $cantoClient = $cantoAssetSource->getCantoClient();
+ } catch (MissingClientSecretException $e) {
+ $this->outputLine('
{authenticationError}
+