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;