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