diff --git a/appinfo/info.xml b/appinfo/info.xml index d590505e4..c4ffcdf1a 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -1,13 +1,12 @@ - + photos Photos Your memories under your control Your memories under your control - 2.1.0 + 2.2.0 agpl - John Molakvoæ + John Molakvoæ Photos multimedia @@ -15,8 +14,8 @@ - https://github.com/nextcloud/photos - https://github.com/nextcloud/photos/issues + https://github.com/nextcloud/photos + https://github.com/nextcloud/photos/issues https://github.com/nextcloud/photos.git @@ -30,6 +29,11 @@ + + OCA\Photos\Command\UpdateReverseGeocodingFilesCommand + OCA\Photos\Command\MapMediaToLocationCommand + + OCA\Photos\Sabre\RootCollection @@ -39,4 +43,4 @@ OCA\Photos\Sabre\Album\PropFindPlugin - + \ No newline at end of file diff --git a/composer.json b/composer.json index 595b6bc8d..c03b850ed 100644 --- a/composer.json +++ b/composer.json @@ -20,5 +20,8 @@ "vimeo/psalm": "^4.22", "sabre/dav": "^4.2.1", "nextcloud/ocp": "dev-master" + }, + "require": { + "hexogen/kdtree": "^0.2.0" } } diff --git a/composer.lock b/composer.lock index 96c85359e..4719543f4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,70 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "35d14b088efbd1d2f344adcab31683b3", - "packages": [], + "content-hash": "807a478aabd3b0507dfa0c5bfea979f7", + "packages": [ + { + "name": "hexogen/kdtree", + "version": "0.2.0", + "source": { + "type": "git", + "url": "https://github.com/hexogen/kdtree.git", + "reference": "5d75517670f7ecf149688757f8540c2648bd5230" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hexogen/kdtree/zipball/5d75517670f7ecf149688757f8540c2648bd5230", + "reference": "5d75517670f7ecf149688757f8540c2648bd5230", + "shasum": "" + }, + "require": { + "php": "~7.1" + }, + "require-dev": { + "league/csv": "^8.0", + "mockery/mockery": "dev-master", + "phpunit/phpunit": "^7.0", + "squizlabs/php_codesniffer": "^2.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "Hexogen\\KDTree\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Volodymyr Basarab", + "email": "volodymyrbas@gmail.com", + "homepage": "https://github.com/hexogen", + "role": "Developer" + } + ], + "description": "file system KDTree index", + "homepage": "https://github.com/hexogen/kdtree", + "keywords": [ + "algorithms", + "data structures", + "hexogen", + "kdtree", + "search" + ], + "support": { + "issues": "https://github.com/hexogen/kdtree/issues", + "source": "https://github.com/hexogen/kdtree/tree/master" + }, + "time": "2018-12-23T19:57:09+00:00" + } + ], "packages-dev": [ { "name": "amphp/amp", diff --git a/lib/Album/AlbumFile.php b/lib/Album/AlbumFile.php index d6f09caa2..7d084bfd2 100644 --- a/lib/Album/AlbumFile.php +++ b/lib/Album/AlbumFile.php @@ -23,19 +23,11 @@ namespace OCA\Photos\Album; -use OC\Metadata\FileMetadata; +use OCA\Photos\DB\PhotosFile; -class AlbumFile { - private int $fileId; - private string $name; - private string $mimeType; - private int $size; - private int $mtime; - private string $etag; +class AlbumFile extends PhotosFile { private int $added; private string $owner; - /** @var array */ - private array $metaData = []; public function __construct( int $fileId, @@ -47,52 +39,19 @@ public function __construct( int $added, string $owner ) { - $this->fileId = $fileId; - $this->name = $name; - $this->mimeType = $mimeType; - $this->size = $size; - $this->mtime = $mtime; - $this->etag = $etag; + parent::__construct( + $fileId, + $name, + $mimeType, + $size, + $mtime, + $etag + ); + $this->added = $added; $this->owner = $owner; } - public function getFileId(): int { - return $this->fileId; - } - - public function getName(): string { - return $this->name; - } - - public function getMimeType(): string { - return $this->mimeType; - } - - public function getSize(): int { - return $this->size; - } - - public function getMTime(): int { - return $this->mtime; - } - - public function getEtag(): string { - return $this->etag; - } - - 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]; - } - public function getAdded(): int { return $this->added; } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 4a1dc328a..3dda9a2e0 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -29,11 +29,16 @@ use OCA\Photos\Listener\SabrePluginAuthInitListener; use OCA\DAV\Connector\Sabre\Principal; use OCA\Photos\Listener\CacheEntryRemovedListener; +use OCA\Photos\Listener\LocationManagerNodeEventListener; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\Files\Cache\CacheEntryRemovedEvent; +use OCP\Files\Events\Node\NodeWrittenEvent; +use OCP\Share\Events\ShareCreatedEvent; +use OCP\Share\Events\ShareDeletedEvent; +use OCP\User\Events\UserDeletedEvent; class Application extends App implements IBootstrap { public const APP_ID = 'photos'; @@ -65,8 +70,19 @@ public function __construct() { public function register(IRegistrationContext $context): void { /** Register $principalBackend for the DAV collection */ $context->registerServiceAlias('principalBackend', Principal::class); + $context->registerEventListener(CacheEntryRemovedEvent::class, CacheEntryRemovedListener::class); + + $context->registerEventListener(CacheEntryRemovedEvent::class, LocationManagerNodeEventListener::class); + // Priority of -1 to be triggered after event listeners populating metadata. + $context->registerEventListener(NodeWrittenEvent::class, LocationManagerNodeEventListener::class, -1); + $context->registerEventListener(UserDeletedEvent::class, LocationManagerNodeEventListener::class); + $context->registerEventListener(ShareCreatedEvent::class, LocationManagerNodeEventListener::class); + $context->registerEventListener(ShareDeletedEvent::class, LocationManagerNodeEventListener::class); + $context->registerEventListener(SabrePluginAuthInitEvent::class, SabrePluginAuthInitListener::class); + + require_once __DIR__ . '/../../vendor/autoload.php'; } public function boot(IBootContext $context): void { diff --git a/lib/Command/MapMediaToLocationCommand.php b/lib/Command/MapMediaToLocationCommand.php new file mode 100644 index 000000000..d440aa3af --- /dev/null +++ b/lib/Command/MapMediaToLocationCommand.php @@ -0,0 +1,117 @@ + + * + * @author Louis Chemineau + * + * @license AGPL-3.0-or-later + * + * 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\Command; + +use OCP\IConfig; +use OCP\IUserManager; +use OCP\Files\IRootFolder; +use OCP\Files\Folder; +use OCA\Photos\Service\MediaLocationManager; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class MapMediaToLocationCommand extends Command { + private IRootFolder $rootFolder; + private MediaLocationManager $mediaLocationManager; + private IConfig $config; + private IUserManager $userManager; + + public function __construct( + IRootFolder $rootFolder, + MediaLocationManager $mediaLocationManager, + IConfig $config, + IUserManager $userManager + ) { + parent::__construct(); + $this->config = $config; + $this->rootFolder = $rootFolder; + $this->mediaLocationManager = $mediaLocationManager; + $this->userManager = $userManager; + } + + /** + * Configure the command + * + * @return void + */ + protected function configure() { + $this->setName('photos:map-media-to-location') + ->setDescription('Reverse geocode media coordinates.') + ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'Limit the mapping to a user.', null); + } + + /** + * Execute the command + * + * @param InputInterface $input + * @param OutputInterface $output + * + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + if (!$this->config->getSystemValueBool('enable_file_metadata', true)) { + throw new \Exception('File metadata is not enabled.'); + } + + $userId = $input->getOption('user'); + if ($userId === null) { + $this->scanForAllUsers(); + } else { + $this->scanFilesForUser($userId); + } + + return 0; + } + + private function scanForAllUsers() { + $users = $this->userManager->search(''); + + foreach ($users as $user) { + $this->scanFilesForUser($user->getUID()); + } + } + + private function scanFilesForUser(string $userId) { + $userFolder = $this->rootFolder->getUserFolder($userId); + $this->scanFolder($userFolder); + } + + private function scanFolder(Folder $folder) { + foreach ($folder->getDirectoryListing() as $node) { + if ($node instanceof Folder) { + $this->scanFolder($node); + continue; + } + + if (!str_starts_with($node->getMimeType(), 'image')) { + continue; + } + + $this->mediaLocationManager->addLocationForFileAndUser($node->getId(), $node->getOwner()->getUID()); + } + } +} diff --git a/lib/Command/UpdateReverseGeocodingFilesCommand.php b/lib/Command/UpdateReverseGeocodingFilesCommand.php new file mode 100644 index 000000000..abfbcb9c6 --- /dev/null +++ b/lib/Command/UpdateReverseGeocodingFilesCommand.php @@ -0,0 +1,71 @@ + + * + * @author Louis Chemineau + * + * @license AGPL-3.0-or-later + * + * 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\Command; + +use OCA\Photos\Service\ReverseGeoCoderService; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class UpdateReverseGeocodingFilesCommand extends Command { + private ReverseGeoCoderService $rgcService; + + public function __construct( + ReverseGeoCoderService $rgcService + ) { + parent::__construct(); + $this->rgcService = $rgcService; + } + + /** + * Configure the command + * + * @return void + */ + protected function configure() { + $this->setName('photos:update-reverse-geocoding-files') + ->setDescription('Update the necessary reverse geocoding files'); + } + + /** + * Execute the command + * + * @param InputInterface $input + * @param OutputInterface $output + * + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $this->rgcService->buildKDTree(true); + } catch (\Exception $ex) { + $output->writeln('Failed to update reverse geocoding files'); + $output->writeln($ex->getMessage()); + return 1; + } + + return 0; + } +} diff --git a/lib/DB/Location/LocationFile.php b/lib/DB/Location/LocationFile.php new file mode 100644 index 000000000..c6f6bfcea --- /dev/null +++ b/lib/DB/Location/LocationFile.php @@ -0,0 +1,57 @@ + + * + * @author Louis Chemineau + * + * @license AGPL-3.0-or-later + * + * 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\DB\Location; + +use OCA\Photos\DB\PhotosFile; + +class LocationFile extends PhotosFile { + private int $locationId; + + public function __construct( + int $fileId, + string $name, + string $mimeType, + int $size, + int $mtime, + string $etag, + int $locationId, + ) { + parent::__construct( + $fileId, + $name, + $mimeType, + $size, + $mtime, + $etag + ); + + $this->locationId = $locationId; + } + + public function getLocationId(): int { + return $this->locationId; + } +} diff --git a/lib/DB/Location/LocationInfo.php b/lib/DB/Location/LocationInfo.php new file mode 100644 index 000000000..f8b9b76df --- /dev/null +++ b/lib/DB/Location/LocationInfo.php @@ -0,0 +1,47 @@ + + * + * @author Louis Chemineau + * + * @license AGPL-3.0-or-later + * + * 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\DB\Location; + +class LocationInfo { + private string $userId; + private int $locationId; + + public function __construct( + string $userId, + int $locationId + ) { + $this->userId = $userId; + $this->locationId = $locationId; + } + + public function getUserId(): string { + return $this->userId; + } + + public function getLocationId(): int { + return $this->locationId; + } +} diff --git a/lib/DB/Location/LocationMapper.php b/lib/DB/Location/LocationMapper.php new file mode 100644 index 000000000..7b48d3ecb --- /dev/null +++ b/lib/DB/Location/LocationMapper.php @@ -0,0 +1,128 @@ + + * + * @author Louis Chemineau + * + * @license AGPL-3.0-or-later + * + * 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\DB\Location; + +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\Files\IMimeTypeLoader; +use OCP\IDBConnection; + +class LocationMapper { + public const TABLE_NAME = 'photos_locations'; + + private IDBConnection $connection; + private IMimeTypeLoader $mimeTypeLoader; + + public function __construct( + IDBConnection $connection, + IMimeTypeLoader $mimeTypeLoader + ) { + $this->connection = $connection; + $this->mimeTypeLoader = $mimeTypeLoader; + } + + /** @return LocationInfo[] */ + public function findLocationsForUser(string $userId): array { + $qb = $this->connection->getQueryBuilder(); + + $rows = $qb->selectDistinct('location_id') + ->from(self::TABLE_NAME) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))) + ->executeQuery() + ->fetchAll(); + + return array_map(fn ($row) => new LocationInfo($userId, $row['location_id']), $rows); + } + + /** @return LocationFile[] */ + public function findFilesForUserAndLocation(string $userId, int $locationId) { + $qb = $this->connection->getQueryBuilder(); + + $rows = $qb->select("fileid", "name", "mimetype", "size", "mtime", "etag", "location_id") + ->from(self::TABLE_NAME, 'l') + ->leftJoin("p", "filecache", "f", $qb->expr()->eq("l.file_id", "f.fileid")) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))) + ->andWhere($qb->expr()->eq('location_id', $qb->createNamedParameter($locationId, IQueryBuilder::PARAM_INT))) + ->executeQuery(); + + return array_map( + fn ($row) => new LocationFile( + (int)$row['fileid'], + $row['name'], + $this->mimeTypeLoader->getMimetypeById($row['mimetype']), + (int)$row['size'], + (int)$row['mtime'], + $row['etag'], + (int)$row['location_id'] + ), + $rows->fetchAll(), + ); + } + + public function addLocationForFileAndUser(int $locationId, int $fileId, string $userId): void { + try { + $query = $this->connection->getQueryBuilder(); + $query->insert(self::TABLE_NAME) + ->values([ + "user_id" => $query->createNamedParameter($userId), + "location_id" => $query->createNamedParameter($locationId, IQueryBuilder::PARAM_INT), + "file_id" => $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT), + ]) + ->executeStatement(); + } catch (\Exception $ex) { + if ($ex->getPrevious() instanceof UniqueConstraintViolationException) { + $this->updateLocationForFile($locationId, $fileId); + } + } + } + + public function updateLocationForFile(int $locationId, int $fileId): void { + $query = $this->connection->getQueryBuilder(); + $query->update(self::TABLE_NAME) + ->set("location_id", $query->createNamedParameter($locationId, IQueryBuilder::PARAM_INT)) + ->where($query->expr()->eq('file_id', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } + + public function removeLocationForFile(int $fileId, ?string $userId = null): void { + $query = $this->connection->getQueryBuilder(); + $query->delete(self::TABLE_NAME) + ->where($query->expr()->eq('file_id', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))); + + if ($userId !== null) { + $query->where($query->expr()->eq('user_id', $query->createNamedParameter($userId))); + } + + $query->executeStatement(); + } + + public function removeLocationForUser(string $userId): void { + $query = $this->connection->getQueryBuilder(); + $query->delete(self::TABLE_NAME) + ->where($query->expr()->eq('user_id', $query->createNamedParameter($userId))) + ->executeStatement(); + } +} diff --git a/lib/DB/PhotosFile.php b/lib/DB/PhotosFile.php new file mode 100644 index 000000000..d78ba8443 --- /dev/null +++ b/lib/DB/PhotosFile.php @@ -0,0 +1,91 @@ + + * + * @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\DB; + +use OC\Metadata\FileMetadata; + +class PhotosFile { + private int $fileId; + private string $name; + private string $mimeType; + private int $size; + private int $mtime; + private string $etag; + /** @var array */ + private array $metaData = []; + + public function __construct( + int $fileId, + string $name, + string $mimeType, + int $size, + int $mtime, + string $etag, + ) { + $this->fileId = $fileId; + $this->name = $name; + $this->mimeType = $mimeType; + $this->size = $size; + $this->mtime = $mtime; + $this->etag = $etag; + } + + public function getFileId(): int { + return $this->fileId; + } + + public function getName(): string { + return $this->name; + } + + public function getMimeType(): string { + return $this->mimeType; + } + + public function getSize(): int { + return $this->size; + } + + public function getMTime(): int { + return $this->mtime; + } + + public function getEtag(): string { + return $this->etag; + } + + 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/Jobs/MapMediaToLocationJob.php b/lib/Jobs/MapMediaToLocationJob.php new file mode 100644 index 000000000..760bea91a --- /dev/null +++ b/lib/Jobs/MapMediaToLocationJob.php @@ -0,0 +1,51 @@ + + * + * @author Louis Chemineau + * + * @license AGPL-3.0-or-later + * + * 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\Jobs; + +use OCA\Photos\Service\MediaLocationManager; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\QueuedJob; + +class MapMediaToLocationJob extends QueuedJob { + private MediaLocationManager $mediaLocationManager; + + public function __construct( + ITimeFactory $time, + MediaLocationManager $mediaLocationManager, + ) { + parent::__construct($time); + $this->mediaLocationManager = $mediaLocationManager; + } + + protected function run($argument) { + [$fileId, $ownerId] = $argument; + + $this->mediaLocationManager->addLocationForFileAndUser( + $fileId, + $ownerId, + ); + } +} diff --git a/lib/Listener/LocationManagerNodeEventListener.php b/lib/Listener/LocationManagerNodeEventListener.php new file mode 100644 index 000000000..0778834cd --- /dev/null +++ b/lib/Listener/LocationManagerNodeEventListener.php @@ -0,0 +1,131 @@ + + * + * @author Louis Chemineau + * + * @license AGPL-3.0-or-later + * + * 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\Listener; + +use OCA\Photos\Jobs\MapMediaToLocationJob; +use OCA\Photos\Service\MediaLocationManager; +use OCP\BackgroundJob\IJobList; +use OCP\IConfig; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\Cache\CacheEntryRemovedEvent; +use OCP\Files\Events\Node\NodeWrittenEvent; +use OCP\Files\Folder; +use OCP\Files\File; +use OCP\Files\Node; +use OCP\Share\Events\ShareCreatedEvent; +use OCP\Share\Events\ShareDeletedEvent; +use OCP\User\Events\UserDeletedEvent; + +/** + * Listener to create, update or remove location info from the database. + */ +class LocationManagerNodeEventListener implements IEventListener { + private MediaLocationManager $mediaLocationManager; + private IConfig $config; + private IJobList $jobList; + + public function __construct( + MediaLocationManager $mediaLocationManager, + IConfig $config, + IJobList $jobList + ) { + $this->mediaLocationManager = $mediaLocationManager; + $this->config = $config; + $this->jobList = $jobList; + } + + public function handle(Event $event): void { + if (!$this->config->getSystemValueBool('enable_file_metadata', true)) { + return; + } + + if ($event instanceof CacheEntryRemovedEvent) { + if ($this->isCorrectPath($event->getPath())) { + $this->mediaLocationManager->clearLocationForFile($event->getFileId()); + } + } + + if ($event instanceof NodeWrittenEvent) { + if (!$this->isCorrectPath($event->getNode()->getPath())) { + return; + } + + if (!str_starts_with($event->getNode()->getMimeType(), 'image')) { + return; + } + + $fileId = $event->getNode()->getId(); + $ownerId = $event->getNode()->getOwner()->getUID(); + + $this->jobList->add(MapMediaToLocationJob::class, [$fileId, $ownerId]); + } + + if ($event instanceof UserDeletedEvent) { + $this->mediaLocationManager->clearLocationForUser($event->getUser()->getUID()); + } + + if ($event instanceof ShareCreatedEvent) { + $receiverId = $event->getShare()->getSharedWith(); + + $this->forEachSubNode( + $event->getShare()->getNode(), + fn ($fileId) => $this->jobList->add(MapMediaToLocationJob::class, [$fileId, $receiverId]), + ); + } + + if ($event instanceof ShareDeletedEvent) { + $receiverId = $event->getShare()->getSharedWith(); + + $this->forEachSubNode( + $event->getShare()->getNode(), + fn ($fileId) => $this->mediaLocationManager->clearLocationForFile($fileId, $receiverId), + ); + } + } + + 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/'); + } + + + private function forEachSubNode(Node $node, callable $callback) { + if ($node instanceof Folder) { + foreach ($node->getDirectoryListing() as $subNode) { + $this->forEachSubNode($subNode, $callback); + } + } + + if ($node instanceof File) { + if (!str_starts_with($node->getMimeType(), 'image')) { + return; + } + + $callback($node->getId()); + } + } +} diff --git a/lib/Migration/Version20002Date20221012131022.php b/lib/Migration/Version20002Date20221012131022.php new file mode 100644 index 000000000..a371fbdb2 --- /dev/null +++ b/lib/Migration/Version20002Date20221012131022.php @@ -0,0 +1,73 @@ + + * + * @author Your name + * + * @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\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Auto-generated migration step: Please modify to your needs! + */ +class Version20002Date20221012131022 extends SimpleMigrationStep { + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + $modified = false; + + if (!$schema->hasTable("photos_locations")) { + $modified = true; + $table = $schema->createTable("photos_locations"); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('location_id', Types::BIGINT, [ + 'notnull' => true, + ]); + $table->addColumn('file_id', Types::BIGINT, [ + 'notnull' => true, + ]); + + $table->addUniqueConstraint(['user_id', 'file_id'], 'locations_unique_idx'); + } + + if ($modified) { + return $schema; + } else { + return null; + } + } +} diff --git a/lib/Service/MediaLocationManager.php b/lib/Service/MediaLocationManager.php new file mode 100644 index 000000000..ee34cc6c6 --- /dev/null +++ b/lib/Service/MediaLocationManager.php @@ -0,0 +1,91 @@ + + * + * @author Louis Chemineau + * + * @license AGPL-3.0-or-later + * + * 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\Service; + +use OC\Metadata\IMetadataManager; +use OCA\Photos\DB\Location\LocationMapper; + +class MediaLocationManager { + private IMetadataManager $metadataManager; + private ReverseGeoCoderService $rgcService; + private LocationMapper $locationMapper; + + public function __construct( + IMetadataManager $metadataManager, + ReverseGeoCoderService $rgcService, + LocationMapper $locationMapper + ) { + $this->metadataManager = $metadataManager; + $this->rgcService = $rgcService; + $this->locationMapper = $locationMapper; + } + + public function addLocationForFileAndUser(int $fileId, string $userId) { + $locationId = $this->getLocationIdForFile($fileId); + + if ($locationId === -1) { + return; + } + + $this->locationMapper->addLocationForFileAndUser($locationId, $fileId, $userId); + } + + public function updateLocationForFile(int $fileId) { + $locationId = $this->getLocationIdForFile($fileId); + + if ($locationId === -1) { + return; + } + + $this->locationMapper->updateLocationForFile($locationId, $fileId); + } + + public function clearLocationForFile(int $fileId, ?string $userId = null): void { + $this->locationMapper->removeLocationForFile($fileId, $userId); + } + + public function clearLocationForUser(string $userId): void { + $this->locationMapper->removeLocationForUser($userId); + } + + private function getLocationIdForFile(int $fileId): int { + $gpsMetadata = $this->metadataManager->fetchMetadataFor('gps', [$fileId])[$fileId]; + $metadata = $gpsMetadata->getMetadata(); + + if (count($metadata) === 0) { + return -1; + } + + $latitude = $metadata['latitude']; + $longitude = $metadata['longitude']; + + if ($latitude === null || $longitude === null) { + return -1; + } + + return $this->rgcService->getLocationIdForCoordinates($latitude, $longitude); + } +} diff --git a/lib/Service/ReverseGeoCoderService.php b/lib/Service/ReverseGeoCoderService.php new file mode 100644 index 000000000..a9338dfff --- /dev/null +++ b/lib/Service/ReverseGeoCoderService.php @@ -0,0 +1,167 @@ + + * + * @author Louis Chemineau + * + * @license AGPL-3.0-or-later + * + * 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\Service; + +use OCP\Files\IAppData; +use OCP\Files\SimpleFS\ISimpleFolder; +use OCP\Files\NotFoundException; +use OCP\Http\Client\IClientService; +use Hexogen\KDTree\FSTreePersister; +use Hexogen\KDTree\FSKDTree; +use Hexogen\KDTree\KDTree; +use Hexogen\KDTree\Item; +use Hexogen\KDTree\ItemList; +use Hexogen\KDTree\ItemFactory; +use Hexogen\KDTree\NearestSearch; +use Hexogen\KDTree\Point; + +class ReverseGeoCoderService { + private IClientService $clientService; + private ISimpleFolder $geoNameFolder; + private ?NearestSearch $fsSearcher = null; + /** @var array */ + private ?array $citiesMapping = null; + + public function __construct( + IAppData $appData, + IClientService $clientService + ) { + $this->clientService = $clientService; + + try { + $this->geoNameFolder = $appData->getFolder("geonames"); + } catch (\Exception $ex) { + if ($ex instanceof NotFoundException) { + $this->geoNameFolder = $appData->newFolder("geonames"); + } + + throw $ex; + } + } + + public function getLocationIdForCoordinates(float $latitude, float $longitude): int { + if ($this->fsSearcher === null) { + $this->buildKDTree(); + $kdTreeFileContent = $this->geoNameFolder->getFile("cities1000.bin")->getContent(); + $kdTreeTmpFileName = tempnam(sys_get_temp_dir(), "nextcloud_photos_"); + file_put_contents($kdTreeTmpFileName, $kdTreeFileContent); + $fsTree = new FSKDTree($kdTreeTmpFileName, new ItemFactory()); + $this->fsSearcher = new NearestSearch($fsTree); + } + + $result = $this->fsSearcher->search(new Point([$latitude, $longitude]), 1); + return $result[0]->getId(); + } + + public function getLocationNameForLocationId(int $locationId): string { + if ($this->citiesMapping === null) { + $this->downloadCities1000(); + $cities1000 = $this->loadCities1000(); + foreach ($cities1000 as $city) { + $this->citiesMapping[$city['id']] = $city['name']; + } + } + + return $this->citiesMapping[$locationId] ?? ''; + } + + private function downloadCities1000(bool $force = false) { + if ($this->geoNameFolder->fileExists('cities1000.csv') && !$force) { + return; + } + + // Download zip file to a tmp file. + $response = $this->clientService->newClient()->get("http://download.geonames.org/export/dump/cities1000.zip"); + $cities1000ZipTmpFileName = tempnam(sys_get_temp_dir(), "nextcloud_photos_"); + file_put_contents($cities1000ZipTmpFileName, $response->getBody()); + + // Unzip the txt file into a stream. + $zip = new \ZipArchive; + $res = $zip->open($cities1000ZipTmpFileName); + if ($res !== true) { + throw new \Exception("Fail to unzip location file: $res", $res); + } + $cities1000TxtSteam = $zip->getStream('cities1000.txt'); + + // Dump the txt file info into a smaller csv file. + $destinationStream = $this->geoNameFolder->newFile('cities1000.csv')->write(); + + while (($fields = fgetcsv($cities1000TxtSteam, 0, " ")) !== false) { + $result = fputcsv( + $destinationStream, + [ + 'id' => (int)$fields[0], + 'name' => $fields[1], + 'latitude' => (float)$fields[4], + 'longitude' => (float)$fields[5], + ] + ); + + if ($result === false) { + throw new \Exception('Failed to write csv line to tmp stream'); + } + } + + $zip->close(); + } + + private function loadCities1000(): array { + $csvStream = $this->geoNameFolder->getFile('cities1000.csv')->read(); + $cities = []; + + while (($fields = fgetcsv($csvStream)) !== false) { + $cities[] = [ + 'id' => (int)$fields[0], + 'name' => $fields[1], + 'latitude' => (float)$fields[2], + 'longitude' => (float)$fields[3], + ]; + } + + return $cities; + } + + public function buildKDTree($force = false) { + if ($this->geoNameFolder->fileExists('cities1000.bin') && !$force) { + return; + } + + $this->downloadCities1000($force); + $cities1000 = $this->loadCities1000(); + + $itemList = new ItemList(2); + foreach ($cities1000 as $city) { + $itemList->addItem(new Item($city['id'], [$city['latitude'], $city['longitude']])); + } + $tree = new KDTree($itemList); + + // Persiste KDTree in app data. + $persister = new FSTreePersister('/'); + $kdTreeTmpFileName = tempnam(sys_get_temp_dir(), "nextcloud_photos_"); + $persister->convert($tree, $kdTreeTmpFileName); + $kdTreeString = file_get_contents($kdTreeTmpFileName); + $this->geoNameFolder->newFile('cities1000.bin', $kdTreeString); + } +} diff --git a/package.json b/package.json index 2a115a179..3ed70c0d6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "photos", "description": "Your memories under your control", - "version": "2.1.0", + "version": "2.2.0", "author": "John Molakvoæ ", "contributors": [ "John Molakvoæ " @@ -95,4 +95,4 @@ "wait-on": "^6.0.1", "workbox-webpack-plugin": "^6.5.4" } -} +} \ No newline at end of file