Browse Source
Merge pull request #40964 from nextcloud/artonge/feat/metadata/port_providers
Merge pull request #40964 from nextcloud/artonge/feat/metadata/port_providers
Support dynamic metadata request on PROPFIND requestspull/41266/head
committed by
GitHub
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 109 additions and 971 deletions
-
4apps/dav/lib/Connector/Sabre/Directory.php
-
16apps/dav/lib/Connector/Sabre/File.php
-
127apps/dav/lib/Connector/Sabre/FilesPlugin.php
-
23apps/dav/lib/Files/FileSearchBackend.php
-
3apps/dav/tests/unit/Connector/Sabre/FilesPluginTest.php
-
7apps/files/lib/Command/Scan.php
-
4apps/files_trashbin/lib/Trashbin.php
-
17core/Application.php
-
8lib/composer/composer/autoload_classmap.php
-
8lib/composer/composer/autoload_static.php
-
11lib/private/Files/Cache/Cache.php
-
15lib/private/Files/Cache/CacheQueryBuilder.php
-
20lib/private/Files/Cache/QuerySearchHelper.php
-
15lib/private/FilesMetadata/FilesMetadataManager.php
-
2lib/private/FilesMetadata/Listener/MetadataUpdate.php
-
8lib/private/FilesMetadata/Model/FilesMetadata.php
-
2lib/private/FilesMetadata/Service/IndexRequestService.php
-
42lib/private/Metadata/Capabilities.php
-
108lib/private/Metadata/FileEventListener.php
-
51lib/private/Metadata/FileMetadata.php
-
177lib/private/Metadata/FileMetadataMapper.php
-
35lib/private/Metadata/IMetadataManager.php
-
41lib/private/Metadata/IMetadataProvider.php
-
95lib/private/Metadata/MetadataManager.php
-
139lib/private/Metadata/Provider/ExifProvider.php
-
8lib/private/Server.php
-
3lib/public/FilesMetadata/IFilesMetadataManager.php
-
4lib/public/FilesMetadata/Model/IFilesMetadata.php
-
87tests/lib/Metadata/FileMetadataMapperTest.php
@ -1,42 +0,0 @@ |
|||
<?php |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
/** |
|||
* @copyright Copyright 2022 Carl Schwan <carl@carlschwan.eu> |
|||
* @license AGPL-3.0-or-later |
|||
* |
|||
* This code is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License, version 3, |
|||
* as published by the Free Software Foundation. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License, version 3, |
|||
* along with this program. If not, see <http://www.gnu.org/licenses/> |
|||
* |
|||
*/ |
|||
|
|||
namespace OC\Metadata; |
|||
|
|||
use OCP\Capabilities\IPublicCapability; |
|||
use OCP\IConfig; |
|||
|
|||
class Capabilities implements IPublicCapability { |
|||
public function __construct( |
|||
private IMetadataManager $manager, |
|||
private IConfig $config, |
|||
) { |
|||
} |
|||
|
|||
public function getCapabilities(): array { |
|||
if ($this->config->getSystemValueBool('enable_file_metadata', true)) { |
|||
return ['metadataAvailable' => $this->manager->getCapabilities()]; |
|||
} |
|||
|
|||
return []; |
|||
} |
|||
} |
@ -1,108 +0,0 @@ |
|||
<?php |
|||
|
|||
declare(strict_types=1); |
|||
/** |
|||
* @copyright Copyright 2022 Carl Schwan <carl@carlschwan.eu> |
|||
* @license AGPL-3.0-or-later |
|||
* |
|||
* This code is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License, version 3, |
|||
* as published by the Free Software Foundation. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License, version 3, |
|||
* along with this program. If not, see <http://www.gnu.org/licenses/> |
|||
* |
|||
*/ |
|||
|
|||
namespace OC\Metadata; |
|||
|
|||
use OC\Files\Filesystem; |
|||
use OCP\EventDispatcher\Event; |
|||
use OCP\EventDispatcher\IEventListener; |
|||
use OCP\Files\Events\Node\NodeDeletedEvent; |
|||
use OCP\Files\Events\Node\NodeWrittenEvent; |
|||
use OCP\Files\Events\NodeRemovedFromCache; |
|||
use OCP\Files\File; |
|||
use OCP\Files\Node; |
|||
use OCP\Files\NotFoundException; |
|||
use OCP\Files\FileInfo; |
|||
use Psr\Log\LoggerInterface; |
|||
|
|||
/** |
|||
* @template-implements IEventListener<NodeRemovedFromCache> |
|||
* @template-implements IEventListener<NodeDeletedEvent> |
|||
* @template-implements IEventListener<NodeWrittenEvent> |
|||
*/ |
|||
class FileEventListener implements IEventListener { |
|||
public function __construct( |
|||
private IMetadataManager $manager, |
|||
private LoggerInterface $logger, |
|||
) { |
|||
} |
|||
|
|||
private function shouldExtractMetadata(Node $node): bool { |
|||
try { |
|||
if ($node->getMimetype() === 'httpd/unix-directory') { |
|||
return false; |
|||
} |
|||
} catch (NotFoundException $e) { |
|||
return false; |
|||
} |
|||
if ($node->getSize(false) <= 0) { |
|||
return false; |
|||
} |
|||
|
|||
$path = $node->getPath(); |
|||
return $this->isCorrectPath($path); |
|||
} |
|||
|
|||
private function isCorrectPath(string $path): bool { |
|||
// TODO make this more dynamic, we have the same issue in other places
|
|||
return !str_starts_with($path, 'appdata_') && !str_starts_with($path, 'files_versions/') && !str_starts_with($path, 'files_trashbin/'); |
|||
} |
|||
|
|||
public function handle(Event $event): void { |
|||
if ($event instanceof NodeRemovedFromCache) { |
|||
if (!$this->isCorrectPath($event->getPath())) { |
|||
// Don't listen to paths for which we don't extract metadata
|
|||
return; |
|||
} |
|||
$view = Filesystem::getView(); |
|||
if (!$view) { |
|||
// Should not happen since a scan in the user folder should setup
|
|||
// the file system.
|
|||
$e = new \Exception(); // don't trigger, just get backtrace
|
|||
$this->logger->error('Detecting deletion of a file with possible metadata but file system setup is not setup', [ |
|||
'exception' => $e, |
|||
'app' => 'metadata' |
|||
]); |
|||
return; |
|||
} |
|||
$info = $view->getFileInfo($event->getPath()); |
|||
if ($info && $info->getType() === FileInfo::TYPE_FILE) { |
|||
$this->manager->clearMetadata($info->getId()); |
|||
} |
|||
} |
|||
|
|||
if ($event instanceof NodeDeletedEvent) { |
|||
$node = $event->getNode(); |
|||
if ($this->shouldExtractMetadata($node)) { |
|||
/** @var File $node */ |
|||
$this->manager->clearMetadata($event->getNode()->getId()); |
|||
} |
|||
} |
|||
|
|||
if ($event instanceof NodeWrittenEvent) { |
|||
$node = $event->getNode(); |
|||
if ($this->shouldExtractMetadata($node)) { |
|||
/** @var File $node */ |
|||
$this->manager->generateMetadata($event->getNode(), false); |
|||
} |
|||
} |
|||
} |
|||
} |
@ -1,51 +0,0 @@ |
|||
<?php |
|||
|
|||
declare(strict_types=1); |
|||
/** |
|||
* @copyright Copyright 2022 Carl Schwan <carl@carlschwan.eu> |
|||
* |
|||
* @license AGPL-3.0 |
|||
* |
|||
* This code is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License, version 3, |
|||
* as published by the Free Software Foundation. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License, version 3, |
|||
* along with this program. If not, see <http://www.gnu.org/licenses/> |
|||
* |
|||
*/ |
|||
|
|||
namespace OC\Metadata; |
|||
|
|||
use OCP\AppFramework\Db\Entity; |
|||
use OCP\DB\Types; |
|||
|
|||
/** |
|||
* @method string getGroupName() |
|||
* @method void setGroupName(string $groupName) |
|||
* @method string getValue() |
|||
* @method void setValue(string $value) |
|||
* @see \OC\Core\Migrations\Version240000Date20220404230027 |
|||
*/ |
|||
class FileMetadata extends Entity { |
|||
protected ?string $groupName = null; |
|||
protected ?string $value = null; |
|||
|
|||
public function __construct() { |
|||
$this->addType('groupName', 'string'); |
|||
$this->addType('value', Types::STRING); |
|||
} |
|||
|
|||
public function getDecodedValue(): array { |
|||
return json_decode($this->getValue(), true) ?? []; |
|||
} |
|||
|
|||
public function setArrayAsValue(array $value): void { |
|||
$this->setValue(json_encode($value, JSON_THROW_ON_ERROR)); |
|||
} |
|||
} |
@ -1,177 +0,0 @@ |
|||
<?php |
|||
|
|||
declare(strict_types=1); |
|||
/** |
|||
* @copyright Copyright 2022 Carl Schwan <carl@carlschwan.eu> |
|||
* @copyright Copyright 2022 Louis Chmn <louis@chmn.me> |
|||
* @license AGPL-3.0-or-later |
|||
* |
|||
* This code is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License, version 3, |
|||
* as published by the Free Software Foundation. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License, version 3, |
|||
* along with this program. If not, see <http://www.gnu.org/licenses/> |
|||
* |
|||
*/ |
|||
|
|||
namespace OC\Metadata; |
|||
|
|||
use OCP\AppFramework\Db\DoesNotExistException; |
|||
use OCP\AppFramework\Db\MultipleObjectsReturnedException; |
|||
use OCP\AppFramework\Db\QBMapper; |
|||
use OCP\AppFramework\Db\Entity; |
|||
use OCP\DB\Exception; |
|||
use OCP\DB\QueryBuilder\IQueryBuilder; |
|||
use OCP\IDBConnection; |
|||
|
|||
/** |
|||
* @template-extends QBMapper<FileMetadata> |
|||
*/ |
|||
class FileMetadataMapper extends QBMapper { |
|||
public function __construct(IDBConnection $db) { |
|||
parent::__construct($db, 'file_metadata', FileMetadata::class); |
|||
} |
|||
|
|||
/** |
|||
* @return FileMetadata[] |
|||
* @throws Exception |
|||
*/ |
|||
public function findForFile(int $fileId): array { |
|||
$qb = $this->db->getQueryBuilder(); |
|||
$qb->select('*') |
|||
->from($this->getTableName()) |
|||
->where($qb->expr()->eq('id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))); |
|||
|
|||
return $this->findEntities($qb); |
|||
} |
|||
|
|||
/** |
|||
* @throws DoesNotExistException |
|||
* @throws MultipleObjectsReturnedException |
|||
* @throws Exception |
|||
*/ |
|||
public function findForGroupForFile(int $fileId, string $groupName): FileMetadata { |
|||
$qb = $this->db->getQueryBuilder(); |
|||
$qb->select('*') |
|||
->from($this->getTableName()) |
|||
->where($qb->expr()->eq('id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))) |
|||
->andWhere($qb->expr()->eq('group_name', $qb->createNamedParameter($groupName, IQueryBuilder::PARAM_STR))); |
|||
|
|||
return $this->findEntity($qb); |
|||
} |
|||
|
|||
/** |
|||
* @return array<int, FileMetadata> |
|||
* @throws Exception |
|||
*/ |
|||
public function findForGroupForFiles(array $fileIds, string $groupName): array { |
|||
$qb = $this->db->getQueryBuilder(); |
|||
$qb->select('*') |
|||
->from($this->getTableName()) |
|||
->where($qb->expr()->in('id', $qb->createParameter('fileIds'))) |
|||
->andWhere($qb->expr()->eq('group_name', $qb->createNamedParameter($groupName, IQueryBuilder::PARAM_STR))); |
|||
|
|||
$metadata = []; |
|||
foreach (array_chunk($fileIds, 1000) as $fileIdsChunk) { |
|||
$qb->setParameter('fileIds', $fileIdsChunk, IQueryBuilder::PARAM_INT_ARRAY); |
|||
/** @var FileMetadata[] $rawEntities */ |
|||
$rawEntities = $this->findEntities($qb); |
|||
foreach ($rawEntities as $entity) { |
|||
$metadata[$entity->getId()] = $entity; |
|||
} |
|||
} |
|||
|
|||
foreach ($fileIds as $id) { |
|||
if (isset($metadata[$id])) { |
|||
continue; |
|||
} |
|||
$empty = new FileMetadata(); |
|||
$empty->setValue(''); |
|||
$empty->setGroupName($groupName); |
|||
$empty->setId($id); |
|||
$metadata[$id] = $empty; |
|||
} |
|||
return $metadata; |
|||
} |
|||
|
|||
public function clear(int $fileId): void { |
|||
$qb = $this->db->getQueryBuilder(); |
|||
$qb->delete($this->getTableName()) |
|||
->where($qb->expr()->eq('id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))); |
|||
|
|||
$qb->executeStatement(); |
|||
} |
|||
|
|||
/** |
|||
* Updates an entry in the db from an entity |
|||
* |
|||
* @param FileMetadata $entity the entity that should be created |
|||
* @return FileMetadata the saved entity with the set id |
|||
* @throws Exception |
|||
* @throws \InvalidArgumentException if entity has no id |
|||
*/ |
|||
public function update(Entity $entity): FileMetadata { |
|||
if (!($entity instanceof FileMetadata)) { |
|||
throw new \Exception("Entity should be a FileMetadata entity"); |
|||
} |
|||
|
|||
// entity needs an id
|
|||
$id = $entity->getId(); |
|||
if ($id === null) { |
|||
throw new \InvalidArgumentException('Entity which should be updated has no id'); |
|||
} |
|||
|
|||
// entity needs an group_name
|
|||
$groupName = $entity->getGroupName(); |
|||
if ($groupName === null) { |
|||
throw new \InvalidArgumentException('Entity which should be updated has no group_name'); |
|||
} |
|||
|
|||
$idType = $this->getParameterTypeForProperty($entity, 'id'); |
|||
$groupNameType = $this->getParameterTypeForProperty($entity, 'groupName'); |
|||
$value = $entity->getValue(); |
|||
$valueType = $this->getParameterTypeForProperty($entity, 'value'); |
|||
|
|||
$qb = $this->db->getQueryBuilder(); |
|||
|
|||
$qb->update($this->tableName) |
|||
->set('value', $qb->createNamedParameter($value, $valueType)) |
|||
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, $idType))) |
|||
->andWhere($qb->expr()->eq('group_name', $qb->createNamedParameter($groupName, $groupNameType))) |
|||
->executeStatement(); |
|||
|
|||
return $entity; |
|||
} |
|||
|
|||
/** |
|||
* Override the insertOrUpdate as we could be in a transaction in which case we can not afford on error. |
|||
* |
|||
* @param FileMetadata $entity the entity that should be created/updated |
|||
* @return FileMetadata the saved entity with the (new) id |
|||
* @throws Exception |
|||
* @throws \InvalidArgumentException if entity has no id |
|||
*/ |
|||
public function insertOrUpdate(Entity $entity): FileMetadata { |
|||
try { |
|||
$existingEntity = $this->findForGroupForFile($entity->getId(), $entity->getGroupName()); |
|||
} catch (\Throwable) { |
|||
$existingEntity = null; |
|||
} |
|||
|
|||
if ($existingEntity !== null) { |
|||
if ($entity->getValue() !== $existingEntity->getValue()) { |
|||
return $this->update($entity); |
|||
} else { |
|||
return $existingEntity; |
|||
} |
|||
} else { |
|||
return parent::insertOrUpdate($entity); |
|||
} |
|||
} |
|||
} |
@ -1,35 +0,0 @@ |
|||
<?php |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
namespace OC\Metadata; |
|||
|
|||
use OCP\Files\File; |
|||
|
|||
/** |
|||
* Interface to manage additional metadata for files |
|||
*/ |
|||
interface IMetadataManager { |
|||
/** |
|||
* @param class-string<IMetadataProvider> $className |
|||
*/ |
|||
public function registerProvider(string $className): void; |
|||
|
|||
/** |
|||
* Generate the metadata for one file |
|||
*/ |
|||
public function generateMetadata(File $file, bool $checkExisting = false): void; |
|||
|
|||
/** |
|||
* Clear the metadata for one file |
|||
*/ |
|||
public function clearMetadata(int $fileId): void; |
|||
|
|||
/** @return array<int, FileMetadata> */ |
|||
public function fetchMetadataFor(string $group, array $fileIds): array; |
|||
|
|||
/** |
|||
* Get the capabilities as an array of mimetype regex to the type provided |
|||
*/ |
|||
public function getCapabilities(): array; |
|||
} |
@ -1,41 +0,0 @@ |
|||
<?php |
|||
|
|||
namespace OC\Metadata; |
|||
|
|||
use OCP\Files\File; |
|||
|
|||
/** |
|||
* Interface for the metadata providers. If you want an application to provide |
|||
* some metadata, you can use this to store them. |
|||
*/ |
|||
interface IMetadataProvider { |
|||
/** |
|||
* The list of groups that this metadata provider is able to provide. |
|||
* |
|||
* @return string[] |
|||
*/ |
|||
public static function groupsProvided(): array; |
|||
|
|||
/** |
|||
* Check if the metadata provider is available. A metadata provider might be |
|||
* unavailable due to a php extension not being installed. |
|||
*/ |
|||
public static function isAvailable(): bool; |
|||
|
|||
/** |
|||
* Get the mimetypes supported as a regex. |
|||
*/ |
|||
public static function getMimetypesSupported(): string; |
|||
|
|||
/** |
|||
* Execute the extraction on the specified file. The metadata should be |
|||
* grouped by metadata |
|||
* |
|||
* Each group should be json serializable and the string representation |
|||
* shouldn't be longer than 4000 characters. |
|||
* |
|||
* @param File $file The file to extract the metadata from |
|||
* @param array<string, FileMetadata> An array containing all the metadata fetched. |
|||
*/ |
|||
public function execute(File $file): array; |
|||
} |
@ -1,95 +0,0 @@ |
|||
<?php |
|||
/** |
|||
* @copyright Copyright 2022 Carl Schwan <carl@carlschwan.eu> |
|||
* @license AGPL-3.0-or-later |
|||
* |
|||
* This code is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License, version 3, |
|||
* as published by the Free Software Foundation. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License, version 3, |
|||
* along with this program. If not, see <http://www.gnu.org/licenses/> |
|||
* |
|||
*/ |
|||
|
|||
namespace OC\Metadata; |
|||
|
|||
use OC\Metadata\Provider\ExifProvider; |
|||
use OCP\Files\File; |
|||
|
|||
class MetadataManager implements IMetadataManager { |
|||
/** @var array<string, IMetadataProvider> */ |
|||
private array $providers = []; |
|||
private array $providerClasses = []; |
|||
|
|||
public function __construct( |
|||
private FileMetadataMapper $fileMetadataMapper, |
|||
) { |
|||
// TODO move to another place, where?
|
|||
$this->registerProvider(ExifProvider::class); |
|||
} |
|||
|
|||
/** |
|||
* @param class-string<IMetadataProvider> $className |
|||
*/ |
|||
public function registerProvider(string $className):void { |
|||
if (in_array($className, $this->providerClasses)) { |
|||
return; |
|||
} |
|||
|
|||
if (call_user_func([$className, 'isAvailable'])) { |
|||
$this->providers[call_user_func([$className, 'getMimetypesSupported'])] = \OC::$server->get($className); |
|||
} |
|||
} |
|||
|
|||
public function generateMetadata(File $file, bool $checkExisting = false): void { |
|||
$existingMetadataGroups = []; |
|||
|
|||
if ($checkExisting) { |
|||
$existingMetadata = $this->fileMetadataMapper->findForFile($file->getId()); |
|||
foreach ($existingMetadata as $metadata) { |
|||
$existingMetadataGroups[] = $metadata->getGroupName(); |
|||
} |
|||
} |
|||
|
|||
foreach ($this->providers as $supportedMimetype => $provider) { |
|||
if (preg_match($supportedMimetype, $file->getMimeType())) { |
|||
if (count(array_diff($provider::groupsProvided(), $existingMetadataGroups)) > 0) { |
|||
$metaDataGroup = $provider->execute($file); |
|||
foreach ($metaDataGroup as $group => $metadata) { |
|||
$this->fileMetadataMapper->insertOrUpdate($metadata); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
public function clearMetadata(int $fileId): void { |
|||
$this->fileMetadataMapper->clear($fileId); |
|||
} |
|||
|
|||
/** |
|||
* @return array<int, FileMetadata> |
|||
*/ |
|||
public function fetchMetadataFor(string $group, array $fileIds): array { |
|||
return $this->fileMetadataMapper->findForGroupForFiles($fileIds, $group); |
|||
} |
|||
|
|||
public function getCapabilities(): array { |
|||
$capabilities = []; |
|||
foreach ($this->providers as $supportedMimetype => $provider) { |
|||
foreach ($provider::groupsProvided() as $group) { |
|||
if (isset($capabilities[$group])) { |
|||
$capabilities[$group][] = $supportedMimetype; |
|||
} |
|||
$capabilities[$group] = [$supportedMimetype]; |
|||
} |
|||
} |
|||
return $capabilities; |
|||
} |
|||
} |
@ -1,139 +0,0 @@ |
|||
<?php |
|||
|
|||
declare(strict_types=1); |
|||
/** |
|||
* @copyright Copyright 2022 Carl Schwan <carl@carlschwan.eu> |
|||
* @copyright Copyright 2022 Louis Chmn <louis@chmn.me> |
|||
* @license AGPL-3.0-or-later |
|||
* |
|||
* This code is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License, version 3, |
|||
* as published by the Free Software Foundation. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License, version 3, |
|||
* along with this program. If not, see <http://www.gnu.org/licenses/> |
|||
* |
|||
*/ |
|||
|
|||
namespace OC\Metadata\Provider; |
|||
|
|||
use OC\Metadata\FileMetadata; |
|||
use OC\Metadata\IMetadataProvider; |
|||
use OCP\Files\File; |
|||
use Psr\Log\LoggerInterface; |
|||
|
|||
class ExifProvider implements IMetadataProvider { |
|||
public function __construct( |
|||
private LoggerInterface $logger, |
|||
) { |
|||
} |
|||
|
|||
public static function groupsProvided(): array { |
|||
return ['size', 'gps']; |
|||
} |
|||
|
|||
public static function isAvailable(): bool { |
|||
return extension_loaded('exif'); |
|||
} |
|||
|
|||
/** @return array{'gps'?: FileMetadata, 'size'?: FileMetadata} */ |
|||
public function execute(File $file): array { |
|||
$exifData = []; |
|||
$fileDescriptor = $file->fopen('rb'); |
|||
|
|||
if ($fileDescriptor === false) { |
|||
return []; |
|||
} |
|||
|
|||
$data = null; |
|||
try { |
|||
// Needed to make reading exif data reliable.
|
|||
// This is to trigger this condition: https://github.com/php/php-src/blob/d64aa6f646a7b5e58359dc79479860164239580a/main/streams/streams.c#L710
|
|||
// But I don't understand why 1 as a special meaning.
|
|||
// Revert right after reading the exif data.
|
|||
$oldBufferSize = stream_set_chunk_size($fileDescriptor, 1); |
|||
$data = @exif_read_data($fileDescriptor, 'ANY_TAG', true); |
|||
stream_set_chunk_size($fileDescriptor, $oldBufferSize); |
|||
} catch (\Exception $ex) { |
|||
$this->logger->info("Couldn't extract metadata for ".$file->getId(), ['exception' => $ex]); |
|||
} |
|||
|
|||
$size = new FileMetadata(); |
|||
$size->setGroupName('size'); |
|||
$size->setId($file->getId()); |
|||
$size->setArrayAsValue([]); |
|||
|
|||
if (!$data) { |
|||
$sizeResult = getimagesizefromstring($file->getContent()); |
|||
if ($sizeResult !== false) { |
|||
$size->setArrayAsValue([ |
|||
'width' => $sizeResult[0], |
|||
'height' => $sizeResult[1], |
|||
]); |
|||
|
|||
$exifData['size'] = $size; |
|||
} |
|||
} elseif (array_key_exists('COMPUTED', $data)) { |
|||
if (array_key_exists('Width', $data['COMPUTED']) && array_key_exists('Height', $data['COMPUTED'])) { |
|||
$size->setArrayAsValue([ |
|||
'width' => $data['COMPUTED']['Width'], |
|||
'height' => $data['COMPUTED']['Height'], |
|||
]); |
|||
|
|||
$exifData['size'] = $size; |
|||
} |
|||
} |
|||
|
|||
if ($data && array_key_exists('GPS', $data) |
|||
&& array_key_exists('GPSLatitude', $data['GPS']) && array_key_exists('GPSLatitudeRef', $data['GPS']) |
|||
&& array_key_exists('GPSLongitude', $data['GPS']) && array_key_exists('GPSLongitudeRef', $data['GPS']) |
|||
) { |
|||
$gps = new FileMetadata(); |
|||
$gps->setGroupName('gps'); |
|||
$gps->setId($file->getId()); |
|||
$gps->setArrayAsValue([ |
|||
'latitude' => $this->gpsDegreesToDecimal($data['GPS']['GPSLatitude'], $data['GPS']['GPSLatitudeRef']), |
|||
'longitude' => $this->gpsDegreesToDecimal($data['GPS']['GPSLongitude'], $data['GPS']['GPSLongitudeRef']), |
|||
]); |
|||
|
|||
$exifData['gps'] = $gps; |
|||
} |
|||
|
|||
return $exifData; |
|||
} |
|||
|
|||
public static function getMimetypesSupported(): string { |
|||
return '/image\/(png|jpeg|heif|webp|tiff)/'; |
|||
} |
|||
|
|||
/** |
|||
* @param array|string $coordinates |
|||
*/ |
|||
private static function gpsDegreesToDecimal($coordinates, ?string $hemisphere): float { |
|||
if (is_string($coordinates)) { |
|||
$coordinates = array_map("trim", explode(",", $coordinates)); |
|||
} |
|||
|
|||
if (count($coordinates) !== 3) { |
|||
throw new \Exception('Invalid coordinate format: ' . json_encode($coordinates)); |
|||
} |
|||
|
|||
[$degrees, $minutes, $seconds] = array_map(function (string $rawDegree) { |
|||
$parts = explode('/', $rawDegree); |
|||
|
|||
if ($parts[1] === '0') { |
|||
return 0; |
|||
} |
|||
|
|||
return floatval($parts[0]) / floatval($parts[1] ?? 1); |
|||
}, $coordinates); |
|||
|
|||
$sign = ($hemisphere === 'W' || $hemisphere === 'S') ? -1 : 1; |
|||
return $sign * ($degrees + $minutes / 60 + $seconds / 3600); |
|||
} |
|||
} |
@ -1,87 +0,0 @@ |
|||
<?php |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
/** |
|||
* @copyright 2022 Carl Schwan <carl@carlschwan.eu> |
|||
* @license AGPL-3.0-or-later |
|||
* |
|||
* This code is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License, version 3, |
|||
* as published by the Free Software Foundation. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License, version 3, |
|||
* along with this program. If not, see <http://www.gnu.org/licenses/> |
|||
* |
|||
*/ |
|||
|
|||
namespace Test\Metadata; |
|||
|
|||
use OC\Metadata\FileMetadataMapper; |
|||
use OC\Metadata\FileMetadata; |
|||
use PHPUnit\Framework\MockObject\MockObject; |
|||
|
|||
/** |
|||
* @group DB |
|||
* @package Test\DB\QueryBuilder |
|||
*/ |
|||
class FileMetadataMapperTest extends \Test\TestCase { |
|||
/** @var IDBConnection */ |
|||
protected $connection; |
|||
|
|||
/** @var SystemConfig|MockObject */ |
|||
protected $config; |
|||
|
|||
/** @var FileMetadataMapper|MockObject */ |
|||
protected $mapper; |
|||
|
|||
protected function setUp(): void { |
|||
parent::setUp(); |
|||
|
|||
$this->connection = \OC::$server->getDatabaseConnection(); |
|||
$this->mapper = new FileMetadataMapper($this->connection); |
|||
} |
|||
|
|||
public function testFindForGroupForFiles() { |
|||
$file1 = new FileMetadata(); |
|||
$file1->setId(1); |
|||
$file1->setGroupName('size'); |
|||
$file1->setArrayAsValue([]); |
|||
|
|||
$file2 = new FileMetadata(); |
|||
$file2->setId(2); |
|||
$file2->setGroupName('size'); |
|||
$file2->setArrayAsValue(['width' => 293, 'height' => 23]); |
|||
|
|||
// not added, it's the default
|
|||
$file3 = new FileMetadata(); |
|||
$file3->setId(3); |
|||
$file3->setGroupName('size'); |
|||
$file3->setArrayAsValue([]); |
|||
|
|||
$file4 = new FileMetadata(); |
|||
$file4->setId(4); |
|||
$file4->setGroupName('size'); |
|||
$file4->setArrayAsValue(['complex' => ["yes", "maybe" => 34.0]]); |
|||
|
|||
$this->mapper->insert($file1); |
|||
$this->mapper->insert($file2); |
|||
$this->mapper->insert($file4); |
|||
|
|||
$files = $this->mapper->findForGroupForFiles([1, 2, 3, 4], 'size'); |
|||
|
|||
$this->assertEquals($files[1]->getValue(), $file1->getValue()); |
|||
$this->assertEquals($files[2]->getValue(), $file2->getValue()); |
|||
$this->assertEquals($files[3]->getDecodedValue(), $file3->getDecodedValue()); |
|||
$this->assertEquals($files[4]->getValue(), $file4->getValue()); |
|||
|
|||
$this->mapper->clear(1); |
|||
$this->mapper->clear(2); |
|||
$this->mapper->clear(4); |
|||
} |
|||
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue