From d6520920c8983d31b5cd5c708da3703a70677d3e Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Wed, 26 Oct 2022 11:23:47 +0200 Subject: [PATCH] Add DAV endpoint for location grouping Signed-off-by: Louis Chemineau --- appinfo/info.xml | 2 +- lib/Sabre/Location/LocationPhoto.php | 181 +++++++++++++++++++++++ lib/Sabre/Location/LocationRoot.php | 155 +++++++++++++++++++ lib/Sabre/Location/LocationsHome.php | 126 ++++++++++++++++ lib/Sabre/PhotosHome.php | 14 +- lib/Sabre/{Album => }/PropFindPlugin.php | 24 ++- lib/Sabre/RootCollection.php | 16 +- 7 files changed, 510 insertions(+), 8 deletions(-) create mode 100644 lib/Sabre/Location/LocationPhoto.php create mode 100644 lib/Sabre/Location/LocationRoot.php create mode 100644 lib/Sabre/Location/LocationsHome.php rename lib/Sabre/{Album => }/PropFindPlugin.php (86%) diff --git a/appinfo/info.xml b/appinfo/info.xml index c4ffcdf1a..335c87aff 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -40,7 +40,7 @@ OCA\Photos\Sabre\PublicRootCollection - OCA\Photos\Sabre\Album\PropFindPlugin + OCA\Photos\Sabre\PropFindPlugin \ No newline at end of file diff --git a/lib/Sabre/Location/LocationPhoto.php b/lib/Sabre/Location/LocationPhoto.php new file mode 100644 index 000000000..58a06a5dc --- /dev/null +++ b/lib/Sabre/Location/LocationPhoto.php @@ -0,0 +1,181 @@ + + * + * @author Louis Chemineau + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * 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 + * along with this program. If not, see . + * + */ + +namespace OCA\Photos\Sabre\Location; + +use OC\Metadata\FileMetadata; +use OCA\Photos\DB\Location\LocationFile; +use OCA\Photos\DB\Location\LocationInfo; +use OCP\Files\IRootFolder; +use OCP\Files\Node; +use OCP\Files\File; +use OCP\Files\NotFoundException; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\IFile; + +class LocationPhoto implements IFile { + private LocationInfo $locationInfo; + private LocationFile $locationFile; + private IRootFolder $rootFolder; + + public const TAG_FAVORITE = '_$!!$_'; + + public function __construct( + LocationInfo $locationInfo, + LocationFile $locationFile, + IRootFolder $rootFolder + ) { + $this->locationInfo = $locationInfo; + $this->locationFile = $locationFile; + $this->rootFolder = $rootFolder; + } + + /** + * @return void + */ + public function delete() { + throw new Forbidden('Cannot remove from a location'); + } + + public function getName() { + return $this->locationFile->getFileId() . "-" . $this->locationFile->getName(); + } + + /** + * @return never + */ + public function setName($name) { + throw new Forbidden('Cannot rename from a location'); + } + + public function getLastModified() { + return $this->locationFile->getMTime(); + } + + public function put($data) { + $nodes = $this->userFolder->getById($this->file->getFileId()); + $node = current($nodes); + if ($node) { + /** @var Node $node */ + if ($node instanceof File) { + return $node->putContent($data); + } else { + throw new NotFoundException("Photo is a folder"); + } + } else { + throw new NotFoundException("Photo not found for user"); + } + } + + public function get() { + $nodes = $this->rootFolder + ->getUserFolder($this->locationInfo->getUserId()) + ->getById($this->locationFile->getFileId()); + $node = current($nodes); + if ($node) { + /** @var Node $node */ + if ($node instanceof File) { + return $node->fopen('r'); + } else { + throw new NotFoundException("Photo is a folder"); + } + } else { + throw new NotFoundException("Photo not found for user"); + } + } + + public function getFileId(): int { + return $this->locationFile->getFileId(); + } + + public function getFileInfo(): Node { + $nodes = $this->rootFolder + ->getUserFolder($this->locationInfo->getUserId()) + ->getById($this->locationFile->getFileId()); + $node = current($nodes); + if ($node) { + return $node; + } else { + throw new NotFoundException("Photo not found for user"); + } + } + + public function getContentType() { + return $this->locationFile->getMimeType(); + } + + public function getETag() { + return $this->locationFile->getEtag(); + } + + public function getSize() { + return $this->locationFile->getSize(); + } + + public function getFile(): LocationFile { + return $this->locationFile; + } + + public function isFavorite(): bool { + $tagManager = \OCP\Server::get(\OCP\ITagManager::class); + $tagger = $tagManager->load('files'); + if ($tagger === null) { + return false; + } + $tags = $tagger->getTagsForObjects([$this->getFileId()]); + + if ($tags === false || empty($tags)) { + return false; + } + + return array_search(self::TAG_FAVORITE, current($tags)) !== false; + } + + public function setFavoriteState($favoriteState): bool { + $tagManager = \OCP\Server::get(\OCP\ITagManager::class); + $tagger = $tagManager->load('files'); + + switch ($favoriteState) { + case "0": + return $tagger->removeFromFavorites($this->locationFile->getFileId()); + case "1": + return $tagger->addToFavorites($this->locationFile->getFileId()); + default: + new \Exception('Favorite state is invalide, should be 0 or 1.'); + } + } + + public function setMetadata(string $key, FileMetadata $value): void { + $this->metaData[$key] = $value; + } + + public function hasMetadata(string $key): bool { + return isset($this->metaData[$key]); + } + + public function getMetadata(string $key): FileMetadata { + return $this->metaData[$key]; + } +} diff --git a/lib/Sabre/Location/LocationRoot.php b/lib/Sabre/Location/LocationRoot.php new file mode 100644 index 000000000..a83b75140 --- /dev/null +++ b/lib/Sabre/Location/LocationRoot.php @@ -0,0 +1,155 @@ + + * + * @author Louis Chemineau + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * 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 + * along with this program. If not, see . + * + */ + +namespace OCA\Photos\Sabre\Location; + +use OCA\Photos\DB\Location\LocationFile; +use OCA\Photos\DB\Location\LocationInfo; +use OCA\Photos\DB\Location\LocationMapper; +use OCA\Photos\Service\ReverseGeoCoderService; +use OCP\Files\IRootFolder; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\ICollection; + +class LocationRoot implements ICollection { + protected LocationMapper $locationMapper; + protected ReverseGeoCoderService $reverseGeoCoderService; + protected LocationInfo $locationInfo; + protected IRootFolder $rootFolder; + /** @var LocationFile[] */ + protected ?array $children = null; + + public function __construct( + LocationMapper $locationMapper, + ReverseGeoCoderService $reverseGeoCoderService, + LocationInfo $locationInfo, + IRootFolder $rootFolder + ) { + $this->locationMapper = $locationMapper; + $this->reverseGeoCoderService = $reverseGeoCoderService; + $this->locationInfo = $locationInfo; + $this->rootFolder = $rootFolder; + } + + /** + * @return never + */ + public function delete() { + throw new Forbidden('Not allowed to delete a location collection'); + } + + public function getName(): string { + return $this->reverseGeoCoderService->getLocationNameForLocationId( + $this->locationInfo->getLocationId() + ); + } + + /** + * @return never + */ + public function setName($name) { + throw new Forbidden('Cannot change the location collection name'); + } + + /** + * @param string $name + * @param null|resource|string $data + * @return never + */ + public function createFile($name, $data = null) { + throw new Forbidden('Cannot create a file in a location collection'); + } + + /** + * @return never + */ + public function createDirectory($name) { + throw new Forbidden('Not allowed to create directories in this folder'); + } + + /** + * @return LocationPhoto[] + */ + public function getChildren(): array { + if ($this->children === null) { + $this->children = array_map( + fn (LocationFile $file) => new LocationPhoto($this->locationInfo, $file, $this->rootFolder), + $this->locationMapper->findFilesForUserAndLocation($this->locationInfo->getUserId(), $this->locationInfo->getLocationId()) + ); + } + + return $this->children; + } + + public function getChild($name): LocationPhoto { + foreach ($this->getChildren() as $child) { + if ($child->getName() === $name) { + return $child; + } + } + + throw new NotFound("$name not found"); + } + + public function childExists($name): bool { + try { + $this->getChild($name); + return true; + } catch (NotFound $e) { + return false; + } + } + + public function getLastModified(): int { + return 0; + } + + public function getFirstPhoto(): int { + return $this->getChildren()[0]->getFileId(); + } + + /** + * @return int[] + */ + public function getFileIds(): array { + return array_map(function (LocationPhoto $file) { + return $file->getFileId(); + }, $this->getChildren()); + } + + /** + * @return int|null + */ + public function getCover() { + $children = $this->getChildren(); + + if (count($children) > 0) { + return $children[0]->getFileId(); + } else { + return null; + } + } +} diff --git a/lib/Sabre/Location/LocationsHome.php b/lib/Sabre/Location/LocationsHome.php new file mode 100644 index 000000000..56821a336 --- /dev/null +++ b/lib/Sabre/Location/LocationsHome.php @@ -0,0 +1,126 @@ + + * + * @author Louis Chemineau + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * 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 + * along with this program. If not, see . + * + */ + +namespace OCA\Photos\Sabre\Location; + +use OCP\Files\IRootFolder; +use OCA\Photos\DB\Location\LocationInfo; +use OCA\Photos\DB\Location\LocationMapper; +use OCA\Photos\Service\ReverseGeoCoderService; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\ICollection; + +class LocationsHome implements ICollection { + protected array $principalInfo; + protected string $userId; + protected IRootFolder $rootFolder; + protected ReverseGeoCoderService $reverseGeoCoderService; + protected LocationMapper $locationMapper; + + public const NAME = 'locations'; + + /** + * @var LocationRoot[] + */ + protected ?array $children = null; + + public function __construct( + array $principalInfo, + string $userId, + IRootFolder $rootFolder, + ReverseGeoCoderService $reverseGeoCoderService, + LocationMapper $locationMapper + ) { + $this->principalInfo = $principalInfo; + $this->userId = $userId; + $this->rootFolder = $rootFolder; + $this->reverseGeoCoderService = $reverseGeoCoderService; + $this->locationMapper = $locationMapper; + } + + /** + * @return never + */ + public function delete() { + throw new Forbidden(); + } + + public function getName(): string { + return self::NAME; + } + + /** + * @return never + */ + public function setName($name) { + throw new Forbidden('Permission denied to rename this folder'); + } + + public function createFile($name, $data = null) { + throw new Forbidden('Not allowed to create files in this folder'); + } + + public function createDirectory($name) { + throw new Forbidden('Not allowed to create folder in this folder'); + } + + public function getChild($name) { + foreach ($this->getChildren() as $child) { + if ($child->getName() === $name) { + return $child; + } + } + + throw new NotFound(); + } + + /** + * @return AlbumRoot[] + */ + public function getChildren(): array { + if ($this->children === null) { + $this->children = array_map( + fn (LocationInfo $locationInfo) => new LocationRoot($this->locationMapper, $this->reverseGeoCoderService, $locationInfo, $this->rootFolder), + $this->locationMapper->findLocationsForUser($this->userId) + ); + } + + 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/Sabre/PhotosHome.php b/lib/Sabre/PhotosHome.php index fc2e6b4cf..1f3ddc411 100644 --- a/lib/Sabre/PhotosHome.php +++ b/lib/Sabre/PhotosHome.php @@ -24,8 +24,11 @@ namespace OCA\Photos\Sabre; use OCA\Photos\Album\AlbumMapper; +use OCA\Photos\DB\Location\LocationMapper; use OCA\Photos\Sabre\Album\AlbumsHome; use OCA\Photos\Sabre\Album\SharedAlbumsHome; +use OCA\Photos\Sabre\Location\LocationsHome; +use OCA\Photos\Service\ReverseGeoCoderService; use OCA\Photos\Service\UserConfigService; use OCP\Files\IRootFolder; use OCP\IUserManager; @@ -36,6 +39,8 @@ class PhotosHome implements ICollection { private AlbumMapper $albumMapper; + private LocationMapper $locationMapper; + private ReverseGeoCoderService $reverseGeoCoderService; private array $principalInfo; private string $userId; private IRootFolder $rootFolder; @@ -46,6 +51,8 @@ class PhotosHome implements ICollection { public function __construct( array $principalInfo, AlbumMapper $albumMapper, + LocationMapper $locationMapper, + ReverseGeoCoderService $reverseGeoCoderService, string $userId, IRootFolder $rootFolder, IUserManager $userManager, @@ -54,6 +61,8 @@ public function __construct( ) { $this->principalInfo = $principalInfo; $this->albumMapper = $albumMapper; + $this->locationMapper = $locationMapper; + $this->reverseGeoCoderService = $reverseGeoCoderService; $this->userId = $userId; $this->rootFolder = $rootFolder; $this->userManager = $userManager; @@ -97,6 +106,8 @@ public function getChild($name) { return new AlbumsHome($this->principalInfo, $this->albumMapper, $this->userId, $this->rootFolder, $this->userConfigService); case SharedAlbumsHome::NAME: return new SharedAlbumsHome($this->principalInfo, $this->albumMapper, $this->userId, $this->rootFolder, $this->userManager, $this->groupManager, $this->userConfigService); + case LocationsHome::NAME: + return new LocationsHome($this->principalInfo, $this->userId, $this->rootFolder, $this->reverseGeoCoderService, $this->locationMapper); } throw new NotFound(); @@ -109,11 +120,12 @@ public function getChildren(): array { return [ new AlbumsHome($this->principalInfo, $this->albumMapper, $this->userId, $this->rootFolder, $this->userConfigService), new SharedAlbumsHome($this->principalInfo, $this->albumMapper, $this->userId, $this->rootFolder, $this->userManager, $this->groupManager, $this->userConfigService), + new LocationsHome($this->principalInfo, $this->userId, $this->rootFolder, $this->reverseGeoCoderService, $this->locationMapper), ]; } public function childExists($name): bool { - return $name === AlbumsHome::NAME || $name === SharedAlbumsHome::NAME; + return $name === AlbumsHome::NAME || $name === SharedAlbumsHome::NAME || $name === LocationsHome::NAME; } public function getLastModified(): int { diff --git a/lib/Sabre/Album/PropFindPlugin.php b/lib/Sabre/PropFindPlugin.php similarity index 86% rename from lib/Sabre/Album/PropFindPlugin.php rename to lib/Sabre/PropFindPlugin.php index 9f3572cef..ef59cc265 100644 --- a/lib/Sabre/Album/PropFindPlugin.php +++ b/lib/Sabre/PropFindPlugin.php @@ -21,11 +21,15 @@ * */ -namespace OCA\Photos\Sabre\Album; +namespace OCA\Photos\Sabre; use OC\Metadata\IMetadataManager; use OCA\DAV\Connector\Sabre\FilesPlugin; use OCA\Photos\Album\AlbumMapper; +use OCA\Photos\Sabre\Album\AlbumPhoto; +use OCA\Photos\Sabre\Album\AlbumRoot; +use OCA\Photos\Sabre\Location\LocationPhoto; +use OCA\Photos\Sabre\Location\LocationRoot; use OCP\IConfig; use OCP\IPreview; use OCP\Files\NotFoundException; @@ -91,7 +95,7 @@ public function initialize(Server $server) { } public function propFind(PropFind $propFind, INode $node): void { - if ($node instanceof AlbumPhoto) { + if ($node instanceof AlbumPhoto || $node instanceof LocationPhoto) { // Checking if the node is truly available and ignoring if not // Should be pre-emptively handled by the NodeDeletedEvent try { @@ -144,6 +148,22 @@ public function propFind(PropFind $propFind, INode $node): void { } } } + + if ($node instanceof LocationRoot) { + $propFind->handle(self::LAST_PHOTO_PROPERTYNAME, fn () => $node->getFirstPhoto()); + $propFind->handle(self::NBITEMS_PROPERTYNAME, fn () => count($node->getChildren())); + + // TODO detect dynamically which metadata groups are requested and + // preload all of them and not just size + if ($this->metadataEnabled && in_array(FilesPlugin::FILE_METADATA_SIZE, $propFind->getRequestedProperties(), true)) { + $fileIds = $node->getFileIds(); + $preloadedMetadata = $this->metadataManager->fetchMetadataFor('size', $fileIds); + + foreach ($node->getChildren() as $child) { + $child->setMetadata('size', $preloadedMetadata[$child->getFileId()]); + } + } + } } public function handleUpdateProperties($path, PropPatch $propPatch): void { diff --git a/lib/Sabre/RootCollection.php b/lib/Sabre/RootCollection.php index 8bcb42c7e..c6748c906 100644 --- a/lib/Sabre/RootCollection.php +++ b/lib/Sabre/RootCollection.php @@ -24,6 +24,8 @@ namespace OCA\Photos\Sabre; use OCA\Photos\Album\AlbumMapper; +use OCA\Photos\DB\Location\LocationMapper; +use OCA\Photos\Service\ReverseGeoCoderService; use OCA\Photos\Service\UserConfigService; use OCP\Files\IRootFolder; use OCP\IUserSession; @@ -33,7 +35,9 @@ use OCP\IGroupManager; class RootCollection extends AbstractPrincipalCollection { - private AlbumMapper $folderMapper; + private AlbumMapper $albumMapper; + private LocationMapper $locationMapper; + private ReverseGeoCoderService $reverseGeoCoderService; private IUserSession $userSession; private IRootFolder $rootFolder; private IUserManager $userManager; @@ -41,7 +45,9 @@ class RootCollection extends AbstractPrincipalCollection { private UserConfigService $userConfigService; public function __construct( - AlbumMapper $folderMapper, + AlbumMapper $albumMapper, + LocationMapper $locationMapper, + ReverseGeoCoderService $reverseGeoCoderService, IUserSession $userSession, IRootFolder $rootFolder, PrincipalBackend\BackendInterface $principalBackend, @@ -51,7 +57,9 @@ public function __construct( ) { parent::__construct($principalBackend, 'principals/users'); - $this->folderMapper = $folderMapper; + $this->albumMapper = $albumMapper; + $this->locationMapper = $locationMapper; + $this->reverseGeoCoderService = $reverseGeoCoderService; $this->userSession = $userSession; $this->rootFolder = $rootFolder; $this->userManager = $userManager; @@ -74,7 +82,7 @@ public function getChildForPrincipal(array $principalInfo): PhotosHome { if (is_null($user) || $name !== $user->getUID()) { throw new \Sabre\DAV\Exception\Forbidden(); } - return new PhotosHome($principalInfo, $this->folderMapper, $name, $this->rootFolder, $this->userManager, $this->groupManager, $this->userConfigService); + return new PhotosHome($principalInfo, $this->albumMapper, $this->locationMapper, $this->reverseGeoCoderService, $name, $this->rootFolder, $this->userManager, $this->groupManager, $this->userConfigService); } public function getName(): string {