diff --git a/appinfo/info.xml b/appinfo/info.xml index 50bd0cfc2..810eac8c6 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -75,5 +75,16 @@ The app does not send any sensitive data to cloud providers or similar services. + + + + + + OCA\Recognize\Dav\RootCollection + + + OCA\Recognize\Dav\Faces\PropFindPlugin + + diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 2eaa13160..f16217d67 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -8,6 +8,7 @@ namespace OCA\Recognize\AppInfo; +use OCA\DAV\Connector\Sabre\Principal; use OCA\Recognize\Hooks\FileListener; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; @@ -32,6 +33,8 @@ public function __construct() { public function register(IRegistrationContext $context): void { @include_once __DIR__ . '/../../vendor/autoload.php'; + /** Register $principalBackend for the DAV collection */ + $context->registerServiceAlias('principalBackend', Principal::class); } /** diff --git a/lib/Dav/Faces/FacePhoto.php b/lib/Dav/Faces/FacePhoto.php new file mode 100644 index 000000000..6ec46ee0e --- /dev/null +++ b/lib/Dav/Faces/FacePhoto.php @@ -0,0 +1,151 @@ +detectionMapper = $detectionMapper; + $this->cluster = $cluster; + $this->faceDetection = $faceDetection; + $this->userFolder = $userFolder; + $this->tagManager = $tagManager; + $this->metadataManager = $metadataManager; + $this->preview = $preview; + } + + /** + * @inheritDoc + */ + public function getName() { + $file = $this->getFile(); + return $file->getId() . '-' . $file->getName(); + } + + /** + * @inheritDoc + * @throws \OCP\DB\Exception + */ + public function delete() { + $this->faceDetection->setClusterId(null); + $this->detectionMapper->update($this->faceDetection); + } + + /** + * @inheritDoc + */ + public function setName($name) { + throw new Forbidden('Cannot rename photos through faces API'); + } + + /** + * @inheritDoc + */ + public function put($data) { + throw new Forbidden('Can\'t write to photos trough the faces api'); + } + + public function getCluster() : FaceCluster { + return $this->cluster; + } + + public function getFile() : File { + if ($this->file === null) { + $nodes = $this->userFolder->getById($this->faceDetection->getFileId()); + $node = current($nodes); + if ($node) { + if ($node instanceof File) { + return $this->file = $node; + } else { + throw new NotFound("Photo is a folder"); + } + } else { + throw new NotFound("Photo not found for user"); + } + } else { + return $this->file; + } + } + + public function getFaceDetection() : FaceDetection { + return $this->faceDetection; + } + + /** + * @inheritDoc + * @throws \Sabre\DAV\Exception\NotFound + */ + public function get() { + return $this->getFile()->fopen('r'); + } + + /** + * @inheritDoc + */ + public function getContentType() { + return $this->getFile()->getMimeType(); + } + + /** + * @inheritDoc + */ + public function getETag() { + return $this->getFile()->getEtag(); + } + + /** + * @inheritDoc + */ + public function getSize() { + return $this->getFile()->getSize(); + } + + /** + * @inheritDoc + */ + public function getLastModified() { + return $this->getFile()->getMTime(); + } + + public function getMetadata(): array { + $file = $this->getFile(); + $sizeMetadata = $this->metadataManager->fetchMetadataFor('size', [$file->getId()])[$file->getId()]; + return $sizeMetadata->getMetadata(); + } + + public function hasPreview(): bool { + return $this->preview->isAvailable($this->getFile()); + } + + public function isFavorite(): bool { + $tagger = $this->tagManager->load('files'); + $tags = $tagger->getTagsForObjects([$this->getFile()->getId()]); + + if ($tags === false || empty($tags)) { + return false; + } + + return array_search(ITags::TAG_FAVORITE, current($tags)) !== false; + } +} diff --git a/lib/Dav/Faces/FaceRoot.php b/lib/Dav/Faces/FaceRoot.php new file mode 100644 index 000000000..b47204750 --- /dev/null +++ b/lib/Dav/Faces/FaceRoot.php @@ -0,0 +1,138 @@ +clusterMapper = $clusterMapper; + $this->cluster = $cluster; + $this->user = $user; + $this->detectionMapper = $detectionMapper; + $this->rootFolder = $rootFolder; + $this->metadataManager = $metadataManager; + $this->tagManager = $tagManager; + $this->previewManager = $previewManager; + } + + /** + * @inheritDoc + */ + public function getName() { + return $this->cluster->getTitle() !== '' ? $this->cluster->getTitle() : ''.$this->cluster->getId(); + } + + /** + * @inheritDoc + */ + public function setName($name) { + $this->cluster->setTitle(basename($name)); + $this->clusterMapper->update($this->cluster); + } + + /** + * @inheritDoc + */ + public function createDirectory($name) { + throw new Forbidden('Not allowed to create directories in this folder'); + } + + /** + * @inheritDoc + */ + public function createFile($name, $data = null) { + throw new Forbidden('Not allowed to create files in this folder'); + } + + /** + * @throws \OCP\Files\NotPermittedException + * @throws \OC\User\NoUserException + */ + public function getChildren(): array { + if (count($this->children) === 0) { + $this->children = array_map(function (FaceDetection $detection) { + return new FacePhoto($this->detectionMapper, $this->cluster, $detection, $this->rootFolder->getUserFolder($this->user->getUID()), $this->tagManager, $this->metadataManager, $this->previewManager); + }, $this->detectionMapper->findByClusterId($this->cluster->getId())); + } + return $this->children; + } + + public function getChild($name): FacePhoto { + if (count($this->children) !== 0) { + foreach ($this->getChildren() as $child) { + if ($child->getName() === $name) { + return $child; + } + } + throw new NotFound("$name not found"); + } + [$fileId,] = explode('-', $name); + try { + $detection = $this->detectionMapper->findByFileIdAndClusterId((int)$fileId, $this->cluster->getId()); + } catch (DoesNotExistException $e) { + throw new NotFound(); + } + return new FacePhoto($this->detectionMapper, $this->cluster, $detection, $this->rootFolder->getUserFolder($this->user->getUID()), $this->tagManager, $this->metadataManager, $this->previewManager); + } + + public function childExists($name): bool { + try { + $this->getChild($name); + return true; + } catch (NotFound $e) { + return false; + } + } + + public function moveInto($targetName, $sourcePath, INode $sourceNode) { + if ($sourceNode instanceof FacePhoto) { + $sourceNode->getFaceDetection()->setClusterId($this->cluster->getId()); + $this->detectionMapper->update($sourceNode->getFaceDetection()); + return true; + } + throw new Forbidden('Not a photo with a detected face, you can only move photos from the faces collection here'); + } + + /** + * @inheritDoc + * @throws \OCP\DB\Exception + */ + public function delete() { + $this->clusterMapper->delete($this->cluster); + } + + /** + * @inheritDoc + */ + public function getLastModified() : int { + return 0; + } +} diff --git a/lib/Dav/Faces/FacesHome.php b/lib/Dav/Faces/FacesHome.php new file mode 100644 index 000000000..f40f81b7c --- /dev/null +++ b/lib/Dav/Faces/FacesHome.php @@ -0,0 +1,111 @@ +faceClusterMapper = $faceClusterMapper; + $this->user = $user; + $this->faceDetectionMapper = $faceDetectionMapper; + $this->rootFolder = $rootFolder; + $this->tagManager = $tagManager; + $this->metadataManager = $metadataManager; + $this->previewManager = $previewManager; + } + + public function delete() { + throw new Forbidden(); + } + + public function getName(): string { + return 'faces'; + } + + public function setName($name) { + throw new Forbidden('Permission denied to rename this folder'); + } + + public function createDirectory($name) { + throw new Forbidden('Not allowed to create directories in this folder'); + } + + public function createFile($name, $data = null) { + throw new Forbidden('Not allowed to create files in this folder'); + } + + public function getChild($name) : FaceRoot { + if (count($this->children) !== 0) { + foreach ($this->getChildren() as $child) { + if ($child->getName() === $name) { + return $child; + } + } + throw new NotFound(); + } + try { + $cluster = $this->faceClusterMapper->findByUserAndTitle($this->user->getUID(), $name); + } catch (DoesNotExistException $e) { + try { + $cluster = $this->faceClusterMapper->find((int) $name); + if ($cluster->getUserId() !== $this->user->getUID()) { + throw new NotFound(); + } + } catch (DoesNotExistException $e) { + throw new NotFound(); + } + } + + return new FaceRoot($this->faceClusterMapper, $cluster, $this->user, $this->faceDetectionMapper, $this->rootFolder, $this->metadataManager, $this->tagManager, $this->previewManager); + } + + /** + * @return \OCA\Recognize\Dav\Faces\FaceRoot[] + * @throws \OCP\DB\Exception + */ + public function getChildren(): array { + $clusters = $this->faceClusterMapper->findByUserId($this->user->getUID()); + if (count($this->children) === 0) { + $this->children = array_map(function (FaceCluster $cluster) { + return new FaceRoot($this->faceClusterMapper, $cluster, $this->user, $this->faceDetectionMapper, $this->rootFolder, $this->metadataManager, $this->tagManager, $this->previewManager); + }, $clusters); + } + return $this->children; + } + + public function childExists($name): bool { + try { + $this->getChild($name); + return true; + } catch (NotFound $e) { + return false; + } + } + + public function getLastModified(): int { + return 0; + } +} diff --git a/lib/Dav/Faces/PropFindPlugin.php b/lib/Dav/Faces/PropFindPlugin.php new file mode 100644 index 000000000..d54788e6c --- /dev/null +++ b/lib/Dav/Faces/PropFindPlugin.php @@ -0,0 +1,59 @@ +faceDetectionMapper = $faceDetectionMapper; + } + + public function initialize(Server $server) { + $this->server = $server; + + $this->server->on('propFind', [$this, 'propFind']); + } + + + public function propFind(PropFind $propFind, INode $node) { + if ($node instanceof FacePhoto) { + $propFind->handle(self::FILE_NAME_PROPERTYNAME, function () use ($node) { + return $node->getFile()->getName(); + }); + + $propFind->handle(self::FACE_DETECTIONS_PROPERTYNAME, function () use ($node) { + return json_encode(array_map(fn (FaceDetectionWithTitle $face) => $face->toArray(), array_values(array_filter($this->faceDetectionMapper->findByFileIdWithTitle($node->getFile()->getId()), fn (FaceDetection $face) => $face->getClusterId() !== null)))); + }); + + $propFind->handle(FilesPlugin::INTERNAL_FILEID_PROPERTYNAME, fn () => $node->getFile()->getId()); + $propFind->handle(self::FILE_NAME_PROPERTYNAME, fn () => $node->getFile()->getName()); + $propFind->handle(self::REALPATH_PROPERTYNAME, fn () => $node->getFile()->getPath()); + $propFind->handle(FilesPlugin::FILE_METADATA_SIZE, fn () => json_encode($node->getMetadata())); + $propFind->handle(FilesPlugin::HAS_PREVIEW_PROPERTYNAME, fn () => json_encode($node->hasPreview())); + $propFind->handle(TagsPlugin::FAVORITE_PROPERTYNAME, fn () => $node->isFavorite() ? 1 : 0); + } + + if ($node instanceof File) { + $propFind->handle(self::FACE_DETECTIONS_PROPERTYNAME, function () use ($node) { + return json_encode(array_map(fn (FaceDetectionWithTitle $face) => $face->toArray(), array_values(array_filter($this->faceDetectionMapper->findByFileIdWithTitle($node->getId()), fn (FaceDetection $face) => $face->getClusterId() !== null)))); + }); + } + } +} diff --git a/lib/Dav/RecognizeHome.php b/lib/Dav/RecognizeHome.php new file mode 100644 index 000000000..d2b94244a --- /dev/null +++ b/lib/Dav/RecognizeHome.php @@ -0,0 +1,85 @@ +principalInfo = $principalInfo; + $this->faceClusterMapper = $faceClusterMapper; + $this->user = $user; + $this->faceDetectionMapper = $faceDetectionMapper; + $this->rootFolder = $rootFolder; + $this->tagManager = $tagManager; + $this->metadataManager = $metadataManager; + $this->previewManager = $previewManager; + } + + public function getName(): string { + [, $name] = \Sabre\Uri\split($this->principalInfo['uri']); + return $name; + } + + public function setName($name) { + throw new Forbidden('Permission denied to rename this folder'); + } + + public function delete() { + throw new Forbidden(); + } + + public function createFile($name, $data = null) { + throw new Forbidden('Not allowed to create files in this folder'); + } + + public function createDirectory($name) { + throw new Forbidden('Permission denied to create folders in this folder'); + } + + private function getFacesHome() { + return new FacesHome($this->faceClusterMapper, $this->user, $this->faceDetectionMapper, $this->rootFolder, $this->tagManager, $this->metadataManager, $this->previewManager); + } + + public function getChild($name) { + if ($name === 'faces') { + return $this->getFacesHome(); + } + + throw new NotFound(); + } + + /** + * @return FacesHome[] + */ + public function getChildren(): array { + return [$this->getFacesHome()]; + } + + public function childExists($name): bool { + return $name === 'faces'; + } + + public function getLastModified(): int { + return 0; + } +} diff --git a/lib/Dav/RootCollection.php b/lib/Dav/RootCollection.php new file mode 100644 index 000000000..b55f72875 --- /dev/null +++ b/lib/Dav/RootCollection.php @@ -0,0 +1,48 @@ +userSession = $userSession; + $this->faceClusterMapper = $faceClusterMapper; + $this->faceDetectionMapper = $faceDetectionMapper; + $this->rootFolder = $rootFolder; + $this->tagManager = $tagManager; + $this->metadataManager = $metadataManager; + $this->previewManager = $previewManager; + } + + public function getChildForPrincipal(array $principalInfo): RecognizeHome { + [, $name] = \Sabre\Uri\split($principalInfo['uri']); + $user = $this->userSession->getUser(); + if (is_null($user) || $name !== $user->getUID()) { + throw new Forbidden(); + } + return new RecognizeHome($principalInfo, $this->faceClusterMapper, $user, $this->faceDetectionMapper, $this->rootFolder, $this->tagManager, $this->metadataManager, $this->previewManager); + } + + public function getName() { + return 'recognize'; + } +} diff --git a/lib/Db/FaceClusterMapper.php b/lib/Db/FaceClusterMapper.php index 954195897..d1844347f 100644 --- a/lib/Db/FaceClusterMapper.php +++ b/lib/Db/FaceClusterMapper.php @@ -13,6 +13,8 @@ public function __construct(IDBConnection $db) { } /** + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException * @throws \OCP\DB\Exception */ public function find(int $id): FaceCluster { @@ -34,6 +36,21 @@ public function findByUserId(string $userId): array { return $this->findEntities($qb); } + + /** + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException + * @throws \OCP\DB\Exception + */ + public function findByUserAndTitle(string $userId, string $title) : Entity { + $qb = $this->db->getQueryBuilder(); + $qb->select(FaceCluster::$columns) + ->from('recognize_face_clusters') + ->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId))) + ->andWhere($qb->expr()->eq('title', $qb->createPositionalParameter($title))); + return $this->findEntity($qb); + } + /** * @throws \OCP\DB\Exception */ diff --git a/lib/Db/FaceDetection.php b/lib/Db/FaceDetection.php index ca832a748..0e8e4ce32 100644 --- a/lib/Db/FaceDetection.php +++ b/lib/Db/FaceDetection.php @@ -53,7 +53,7 @@ public function __construct() { public function toArray(): array { $array = []; - foreach (self::$fields as $field) { + foreach (static::$fields as $field) { $array[$field] = $this->{$field}; } return $array; diff --git a/lib/Db/FaceDetectionMapper.php b/lib/Db/FaceDetectionMapper.php index 6f05edd83..365991da9 100644 --- a/lib/Db/FaceDetectionMapper.php +++ b/lib/Db/FaceDetectionMapper.php @@ -2,6 +2,7 @@ namespace OCA\Recognize\Db; +use OCP\AppFramework\Db\Entity; use OCP\AppFramework\Db\QBMapper; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; @@ -67,4 +68,40 @@ public function assocWithCluster(FaceDetection $faceDetection, FaceCluster $face $faceDetection->setClusterId($faceCluster->getId()); $this->update($faceDetection); } + + public function findByFileIdWithTitle(int $fileId) { + $qb = $this->db->getQueryBuilder(); + $qb->select(array_merge(array_map(fn ($col) => 'd.'.$col, FaceDetection::$columns), ['c.title'])) + ->from('recognize_face_detections', 'd') + ->where($qb->expr()->eq('file_id', $qb->createPositionalParameter($fileId, IQueryBuilder::PARAM_INT))) + ->leftJoin('d', 'recognize_face_clusters', 'c', $qb->expr()->eq('d.cluster_id', 'c.id')); + return $this->findEntities($qb); + } + + /** + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException + * @throws \OCP\DB\Exception + */ + public function findByFileIdAndClusterId(int $fileId, int $clusterId) : FaceDetection { + $qb = $this->db->getQueryBuilder(); + $qb->select(FaceDetection::$columns) + ->from('recognize_face_detections', 'd') + ->leftJoin('d', 'recognize_face_clusters', 'c', $qb->expr()->eq('d.cluster_id', 'c.id')) + ->where($qb->expr()->eq('d.file_id', $qb->createPositionalParameter($fileId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('c.id', $qb->createPositionalParameter($clusterId, IQueryBuilder::PARAM_INT))); + return $this->findEntity($qb); + } + + protected function mapRowToEntity(array $row): Entity { + try { + return parent::mapRowToEntity($row); + } catch (\Exception $e) { + $entity = FaceDetectionWithTitle::fromRow($row); + if ($entity->getTitle() === '') { + $entity->setTitle($entity->getClusterId()); + } + return $entity; + } + } } diff --git a/lib/Db/FaceDetectionWithTitle.php b/lib/Db/FaceDetectionWithTitle.php new file mode 100644 index 000000000..a0ccc89f6 --- /dev/null +++ b/lib/Db/FaceDetectionWithTitle.php @@ -0,0 +1,19 @@ +addType('title', 'string'); + } +} diff --git a/lib/Service/ClassifyImagesService.php b/lib/Service/ClassifyImagesService.php new file mode 100644 index 000000000..e69de29bb diff --git a/lib/Service/FaceClusterAnalyzer.php b/lib/Service/FaceClusterAnalyzer.php index c17ffec5c..39c0ca9d9 100644 --- a/lib/Service/FaceClusterAnalyzer.php +++ b/lib/Service/FaceClusterAnalyzer.php @@ -13,7 +13,7 @@ class FaceClusterAnalyzer { public const MIN_CLUSTER_DENSITY = 3; - public const MAX_INNER_CLUSTER_RADIUS = 0.35; + public const MAX_INNER_CLUSTER_RADIUS = 0.4; private FaceDetectionMapper $faceDetections; private FaceClusterMapper $faceClusters;