Merge branch 'master' into stable24

old_stable24
Varun Patil 2023-02-09 17:54:36 -08:00
commit cac12462d0
57 changed files with 2468 additions and 414 deletions

View File

@ -2,6 +2,14 @@
This file is manually updated. Please file an issue if something is missing.
## v4.11.0, v3.11.0 (unreleased)
- **Feature**: Show map of photos ([#396](https://github.com/pulsejet/memories/pull/396))
To index existing images, you must run `occ memories:index -f`
- **Feature**: Show list of places using reverse geocoding (MySQL/Postgres only) ([#395](https://github.com/pulsejet/memories/issues/395))
To configure this feature, you need to run `occ memories:places-setup` followed by `occ memories:index -f`
- Other minor fixes and features ([milestone](https://github.com/pulsejet/memories/milestone/7?closed=1))
## v4.10.0, v3.10.0 (2023-01-17)
- **Feature**: Allow sharing albums using public links ([#274](https://github.com/pulsejet/memories/issues/274))

View File

@ -20,7 +20,8 @@ Memories is a _batteries-included_ photo management solution for Nextcloud with
- **✏️ Edit Metadata**: Edit dates on photos quickly and easily.
- **📦 Archive**: Store photos you don't want to see in your timeline in a separate folder.
- **📹 Video Transcoding**: Memories transcodes videos and uses HLS for maximal performance.
- **⚡️ Performance**: In general, Memories is extremely fast.
- **🗺️ Map**: View your photos on a map, tagged with accurate reverse geocoding.
- **⚡️ Performance**: Memories is very fast.
## 🌐 Online Demo

View File

@ -18,7 +18,8 @@ Memories is a *batteries-included* photo management solution for Nextcloud with
- **✏️ Edit Metadata**: Edit dates on photos quickly and easily.
- **📦 Archive**: Store photos you don't want to see in your timeline in a separate folder.
- **📹 Video Transcoding**: Memories transcodes videos and uses HLS for maximal performance.
- **⚡️ Performance**: In general, Memories is extremely fast.
- **🗺️ Map**: View your photos on a map, tagged with accurate reverse geocoding.
- **⚡️ Performance**: Memories is very fast.
## 🌐 Online Demo
@ -48,6 +49,7 @@ Memories is a *batteries-included* photo management solution for Nextcloud with
<commands>
<command>OCA\Memories\Command\Index</command>
<command>OCA\Memories\Command\VideoSetup</command>
<command>OCA\Memories\Command\PlacesSetup</command>
</commands>
<navigations>
<navigation>

View File

@ -21,12 +21,14 @@ return [
['name' => 'Page#videos', 'url' => '/videos', 'verb' => 'GET'],
['name' => 'Page#archive', 'url' => '/archive', 'verb' => 'GET'],
['name' => 'Page#thisday', 'url' => '/thisday', 'verb' => 'GET'],
['name' => 'Page#map', 'url' => '/map', 'verb' => 'GET'],
// Routes with params
w(['name' => 'Page#folder', 'url' => '/folders/{path}', 'verb' => 'GET'], 'path'),
w(['name' => 'Page#albums', 'url' => '/albums/{id}', 'verb' => 'GET'], 'id'),
w(['name' => 'Page#recognize', 'url' => '/recognize/{name}', 'verb' => 'GET'], 'name'),
w(['name' => 'Page#facerecognition', 'url' => '/facerecognition/{name}', 'verb' => 'GET'], 'name'),
w(['name' => 'Page#places', 'url' => '/places/{id}', 'verb' => 'GET'], 'id'),
w(['name' => 'Page#tags', 'url' => '/tags/{name}', 'verb' => 'GET'], 'name'),
// Public folder share
@ -61,6 +63,11 @@ return [
['name' => 'People#facerecognitionPeople', 'url' => '/api/facerecognition/people', 'verb' => 'GET'],
['name' => 'People#facerecognitionPeoplePreview', 'url' => '/api/facerecognition/people/preview/{id}', 'verb' => 'GET'],
['name' => 'Places#places', 'url' => '/api/places', 'verb' => 'GET'],
['name' => 'Places#preview', 'url' => '/api/places/preview/{id}', 'verb' => 'GET'],
['name' => 'Map#clusters', 'url' => '/api/map/clusters', 'verb' => 'GET'],
['name' => 'Archive#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'],
['name' => 'Image#preview', 'url' => '/api/image/preview/{id}', 'verb' => 'GET'],

View File

@ -33,6 +33,7 @@ use OCP\Files\IRootFolder;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IPreview;
use OCP\ITempManager;
use OCP\IUser;
use OCP\IUserManager;
use Symfony\Component\Console\Command\Command;
@ -68,6 +69,7 @@ class Index extends Command
protected IDBConnection $connection;
protected Connection $connectionForSchema;
protected TimelineWrite $timelineWrite;
protected ITempManager $tempManager;
// Stats
private int $nUser = 0;
@ -85,7 +87,8 @@ class Index extends Command
IPreview $preview,
IConfig $config,
IDBConnection $connection,
Connection $connectionForSchema
Connection $connectionForSchema,
ITempManager $tempManager
) {
parent::__construct();
@ -95,6 +98,7 @@ class Index extends Command
$this->config = $config;
$this->connection = $connection;
$this->connectionForSchema = $connectionForSchema;
$this->tempManager = $tempManager;
$this->timelineWrite = new TimelineWrite($connection);
}
@ -295,6 +299,11 @@ class Index extends Command
return;
}
// skip IMDB name
if ('IMDB' === $folder->getName()) {
return;
}
$nodes = $folder->getDirectoryListing();
foreach ($nodes as $i => &$node) {
@ -306,6 +315,7 @@ class Index extends Command
$progress = (float) (($progress_i / $progress_n) * 100);
$this->outputSection->overwrite(sprintf('%.2f%%', $progress).' scanning '.$node->getPath());
$this->parseFile($node, $opts);
$this->tempManager->clean();
}
}
} catch (\Exception $e) {

View File

@ -0,0 +1,416 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022, Varun Patil <radialapps@gmail.com>
* @author Varun Patil <radialapps@gmail.com>
* @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 <http://www.gnu.org/licenses/>.
*/
namespace OCA\Memories\Command;
use OCP\IConfig;
use OCP\IDBConnection;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
const GIS_TYPE_NONE = 0;
const GIS_TYPE_MYSQL = 1;
const GIS_TYPE_POSTGRES = 2;
const APPROX_PLACES = 635189;
const PLANET_URL = 'https://github.com/pulsejet/memories-assets/releases/download/geo-0.0.2/planet_coarse_boundaries.zip';
class PlacesSetup extends Command
{
protected IConfig $config;
protected OutputInterface $output;
protected IDBConnection $connection;
protected int $gisType = GIS_TYPE_NONE;
public function __construct(
IConfig $config,
IDBConnection $connection
) {
parent::__construct();
$this->config = $config;
$this->connection = $connection;
}
protected function configure(): void
{
$this
->setName('memories:places-setup')
->setDescription('Setup reverse geocoding')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->output = $output;
$this->output->writeln('Attempting to set up reverse geocoding');
// Detect the GIS type
$this->detectGisType();
$this->config->setSystemValue('memories.gis_type', $this->gisType);
// Make sure we support something
if (GIS_TYPE_NONE === $this->gisType) {
$this->output->writeln('<error>No supported GIS type detected</error>');
return 1;
}
// Check if the database is already set up
$hasDb = false;
try {
$this->output->writeln('');
$this->connection->executeQuery('SELECT osm_id FROM memories_planet_geometry LIMIT 1')->fetch();
$this->output->writeln('<error>Database is already set up</error>');
$this->output->writeln('<error>This will drop and re-download the planet database</error>');
$this->output->writeln('<error>This is generally not necessary to do frequently </error>');
$hasDb = true;
} catch (\Exception $e) {
}
// Ask confirmation
$tempdir = sys_get_temp_dir();
$this->output->writeln('');
$this->output->writeln('Are you sure you want to download the planet database?');
$this->output->writeln("This will take a very long time and use some disk space in {$tempdir}");
$this->output->write('Proceed? [y/N] ');
$handle = fopen('php://stdin', 'r');
$line = fgets($handle);
if ('y' !== trim($line)) {
$this->output->writeln('Aborting');
return 1;
}
// Drop the table
$p = $this->connection->getDatabasePlatform();
if ($hasDb) {
$this->output->writeln('');
$this->output->write('Dropping table ... ');
$this->connection->executeStatement($p->getDropTableSQL('memories_planet_geometry'));
$this->output->writeln('OK');
}
// Setup the database
$this->output->write('Setting up database ... ');
$this->setupDatabase();
$this->output->writeln('OK');
// Truncate tables
$this->output->write('Truncating tables ... ');
$this->connection->executeStatement($p->getTruncateTableSQL('*PREFIX*memories_planet', false));
$this->connection->executeStatement($p->getTruncateTableSQL('memories_planet_geometry', false));
$this->output->writeln('OK');
// Download the data
$this->output->write('Downloading data ... ');
$datafile = $this->downloadPlanet();
$this->output->writeln('OK');
// Start importing
$this->output->writeln('');
$this->output->writeln('Importing data (this will take a while) ...');
// Start time
$start = time();
// Create place insertion statement
$query = $this->connection->getQueryBuilder();
$query->insert('memories_planet')
->values([
'osm_id' => $query->createParameter('osm_id'),
'admin_level' => $query->createParameter('admin_level'),
'name' => $query->createParameter('name'),
])
;
$insertPlace = $this->connection->prepare($query->getSQL());
// Create geometry insertion statement
$query = $this->connection->getQueryBuilder();
$geomParam = $query->createParameter('geometry');
if (GIS_TYPE_MYSQL === $this->gisType) {
$geomParam = "ST_GeomFromText({$geomParam})";
} elseif (GIS_TYPE_POSTGRES === $this->gisType) {
$geomParam = "POLYGON({$geomParam}::text)";
}
$query->insert('memories_planet_geometry')
->values([
'id' => $query->createParameter('id'),
'poly_id' => $query->createParameter('poly_id'),
'type_id' => $query->createParameter('type_id'),
'osm_id' => $query->createParameter('osm_id'),
'geometry' => $query->createFunction($geomParam),
])
;
$sql = str_replace('*PREFIX*memories_planet_geometry', 'memories_planet_geometry', $query->getSQL());
$insertGeometry = $this->connection->prepare($sql);
// Iterate over the data file
$handle = fopen($datafile, 'r');
if ($handle) {
$count = 0;
while (($line = fgets($handle)) !== false) {
// Skip empty lines
if ('' === trim($line)) {
continue;
}
++$count;
// Decode JSON
$data = json_decode($line, true);
if (null === $data) {
$this->output->writeln('<error>Failed to decode JSON</error>');
continue;
}
// Extract data
$osmId = $data['osm_id'];
$adminLevel = $data['admin_level'];
$name = $data['name'];
$boundaries = $data['geometry'];
// Skip some places
if ($adminLevel <= 1 || $adminLevel >= 10) {
// <=1: These are too general, e.g. "Earth"? or invalid
// >=10: These are too specific, e.g. "Community Board"
continue;
}
// Insert place into database
$insertPlace->bindValue('osm_id', $osmId);
$insertPlace->bindValue('admin_level', $adminLevel);
$insertPlace->bindValue('name', $name);
$insertPlace->execute();
// Insert polygons into database
$idx = 0;
foreach ($boundaries as &$polygon) {
// $polygon is a struct as
// [ "t" => "e", "c" => [lon, lat], [lon, lat], ... ] ]
$polyid = $polygon['i'];
$typeid = $polygon['t'];
$pkey = $polygon['k'];
$coords = $polygon['c'];
// Create parameters
++$idx;
$geometry = '';
if (\count($coords) < 3) {
$this->output->writeln('<error>Invalid polygon</error>');
continue;
}
if (GIS_TYPE_MYSQL === $this->gisType) {
$points = implode(',', array_map(function (&$point) {
$x = $point[0];
$y = $point[1];
return "{$x} {$y}";
}, $coords));
$geometry = "POLYGON(({$points}))";
} elseif (GIS_TYPE_POSTGRES === $this->gisType) {
$geometry = implode(',', array_map(function (&$point) {
$x = $point[0];
$y = $point[1];
return "({$x},{$y})";
}, $coords));
}
try {
$insertGeometry->bindValue('id', $pkey);
$insertGeometry->bindValue('poly_id', $polyid);
$insertGeometry->bindValue('type_id', $typeid);
$insertGeometry->bindValue('osm_id', $osmId);
$insertGeometry->bindValue('geometry', $geometry);
$insertGeometry->execute();
} catch (\Exception $e) {
$this->output->writeln('<error>Failed to insert into database</error>');
$this->output->writeln($e->getMessage());
continue;
}
}
// Print progress
if (0 === $count % 500) {
$end = time();
$elapsed = $end - $start;
$rate = $count / $elapsed;
$remaining = APPROX_PLACES - $count;
$eta = round($remaining / $rate);
$rate = round($rate, 1);
$this->output->writeln("Inserted {$count} places, {$rate}/s, ETA: {$eta}s, Last: {$name}");
}
}
fclose($handle);
}
// Delete file
unlink($datafile);
// Done
$this->output->writeln('');
$this->output->writeln('Planet database imported successfully!');
$this->output->writeln('If this is the first time you did this, you should now run:');
$this->output->writeln('occ memories:index -f');
// Mark success
$this->config->setSystemValue('memories.gis_type', $this->gisType);
return 0;
}
protected function detectGisType()
{
// Test MySQL-like support in databse
try {
$res = $this->connection->executeQuery("SELECT ST_GeomFromText('POINT(1 1)')")->fetch();
if (0 === \count($res)) {
throw new \Exception('Invalid result');
}
$this->output->writeln('MySQL-like support detected!');
// Make sure this is actually MySQL
$res = $this->connection->executeQuery('SELECT VERSION()')->fetch();
if (0 === \count($res)) {
throw new \Exception('Invalid result');
}
if (false === strpos($res['VERSION()'], 'MariaDB') && false === strpos($res['VERSION()'], 'MySQL')) {
throw new \Exception('MySQL not detected');
}
$this->gisType = GIS_TYPE_MYSQL;
} catch (\Exception $e) {
$this->output->writeln('No MySQL-like support detected');
}
// Test Postgres native geometry like support in database
if (GIS_TYPE_NONE === $this->gisType) {
try {
$res = $this->connection->executeQuery("SELECT POINT('1,1')")->fetch();
if (0 === \count($res)) {
throw new \Exception('Invalid result');
}
$this->output->writeln('Postgres native geometry support detected!');
$this->gisType = GIS_TYPE_POSTGRES;
} catch (\Exception $e) {
$this->output->writeln('No Postgres native geometry support detected');
}
}
}
protected function setupDatabase(): void
{
try {
$sql = 'CREATE TABLE memories_planet_geometry (
id varchar(255) NOT NULL PRIMARY KEY,
poly_id varchar(255) NOT NULL,
type_id int NOT NULL,
osm_id int NOT NULL,
geometry polygon NOT NULL
);';
$this->connection->executeQuery($sql);
// Add indexes
$this->connection->executeQuery('CREATE INDEX planet_osm_id_idx ON memories_planet_geometry (osm_id);');
// Add spatial index
if (GIS_TYPE_MYSQL === $this->gisType) {
$this->connection->executeQuery('CREATE SPATIAL INDEX planet_osm_polygon_geometry_idx ON memories_planet_geometry (geometry);');
} elseif (GIS_TYPE_POSTGRES === $this->gisType) {
$this->connection->executeQuery('CREATE INDEX planet_osm_polygon_geometry_idx ON memories_planet_geometry USING GIST (geometry);');
}
} catch (\Exception $e) {
$this->output->writeln('Failed to create planet table');
$this->output->writeln($e->getMessage());
exit;
}
}
protected function ensureDeleted(string $filename)
{
unlink($filename);
if (file_exists($filename)) {
$this->output->writeln('<error>Failed to delete data file</error>');
$this->output->writeln("Please delete {$filename} manually");
exit;
}
}
protected function downloadPlanet(): string
{
$filename = sys_get_temp_dir().'/planet_coarse_boundaries.zip';
$this->ensureDeleted($filename);
$txtfile = sys_get_temp_dir().'/planet_coarse_boundaries.txt';
$this->ensureDeleted($txtfile);
$fp = fopen($filename, 'w+');
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, PLANET_URL);
curl_setopt($ch, CURLOPT_FILE, $fp);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 60000);
curl_exec($ch);
curl_close($ch);
fclose($fp);
// Unzip
$zip = new \ZipArchive();
$res = $zip->open($filename);
if (true === $res) {
$zip->extractTo(sys_get_temp_dir());
$zip->close();
} else {
$this->output->writeln('Failed to unzip planet file');
exit;
}
// Check if file exists
if (!file_exists($txtfile)) {
$this->output->writeln('Failed to find planet data file after unzip');
exit;
}
// Delete zip file
unlink($filename);
return $txtfile;
}
}

View File

@ -29,6 +29,9 @@ use OCA\Memories\Db\TimelineRoot;
use OCA\Memories\Exif;
use OCP\App\IAppManager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataDisplayResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
@ -291,6 +294,45 @@ class ApiBase extends Controller
return $node;
}
/**
* Given a list of file ids, return the first preview image possible.
*/
protected function getPreviewFromImageList(array &$list, int $quality = 512)
{
// Get preview manager
$previewManager = \OC::$server->get(\OCP\IPreview::class);
// Try to get a preview
$userFolder = $this->rootFolder->getUserFolder($this->getUID());
foreach ($list as &$img) {
// Get the file
$files = $userFolder->getById($img);
if (0 === \count($files)) {
continue;
}
// Check read permission
if (!($files[0]->getPermissions() & \OCP\Constants::PERMISSION_READ)) {
continue;
}
// Get preview image
try {
$preview = $previewManager->getPreview($files[0], $quality, $quality, false);
$response = new DataDisplayResponse($preview->getContent(), Http::STATUS_OK, [
'Content-Type' => $preview->getMimeType(),
]);
$response->cacheFor(3600 * 24, false, false);
return $response;
} catch (\Exception $e) {
continue;
}
}
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
/**
* Check if albums are enabled for this user.
*/
@ -329,6 +371,14 @@ class ApiBase extends Controller
return \OCA\Memories\Util::facerecognitionIsEnabled($this->config, $this->getUID());
}
/**
* Check if geolocation is enabled for this user.
*/
protected function placesIsEnabled(): bool
{
return \OCA\Memories\Util::placesGISType() > 0;
}
/**
* Helper to get one file or null from a fiolder.
*/

View File

@ -237,6 +237,19 @@ class DaysController extends ApiBase
}
}
// Filter only for one place
if ($this->placesIsEnabled()) {
if ($locationId = $this->request->getParam('place')) {
$transforms[] = [$this->timelineQuery, 'transformPlaceFilter', (int) $locationId];
}
}
// Filter geological bounds
$bounds = $this->request->getParam('mapbounds');
if ($bounds) {
$transforms[] = [$this->timelineQuery, 'transformMapBoundsFilter', $bounds];
}
// Limit number of responses for day query
$limit = $this->request->getParam('limit');
if ($limit) {

View File

@ -27,6 +27,7 @@ use bantu\IniGetWrapper\IniGetWrapper;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\ISession;
use OCP\ITempManager;
use OCP\Security\ISecureRandom;
class DownloadController extends ApiBase
@ -193,6 +194,9 @@ class DownloadController extends ApiBase
// So we need to add a number to the end of the name
$nameCounts = [];
/** @var ITempManager for clearing temp files */
$tempManager = \OC::$server->get(ITempManager::class);
// Send each file
foreach ($fileIds as $fileId) {
if (connection_aborted()) {
@ -265,6 +269,9 @@ class DownloadController extends ApiBase
if (false !== $handle) {
fclose($handle);
}
// Clear any temp files
$tempManager->clean();
}
}

View File

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Varun Patil <radialapps@gmail.com>
* @author Varun Patil <radialapps@gmail.com>
* @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 <http://www.gnu.org/licenses/>.
*/
namespace OCA\Memories\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
class MapController extends ApiBase
{
/**
* @NoAdminRequired
*/
public function clusters(): JSONResponse
{
// Get the folder to show
$root = null;
try {
$root = $this->getRequestRoot();
} catch (\Exception $e) {
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_NOT_FOUND);
}
// Make sure we have bounds and zoom level
// Zoom level is used to determine the grid length
$bounds = $this->request->getParam('bounds');
$zoomLevel = $this->request->getParam('zoom');
if (!$bounds || !$zoomLevel || !is_numeric($zoomLevel)) {
return new JSONResponse(['message' => 'Invalid parameters'], Http::STATUS_PRECONDITION_FAILED);
}
// A tweakable parameter to determine the number of boxes in the map
$clusterDensity = 1;
$gridLen = 180.0 / (2 ** $zoomLevel * $clusterDensity);
try {
$clusters = $this->timelineQuery->getMapClusters($gridLen, $bounds, $root);
// Get previews for each cluster
$clusterIds = array_map(function ($cluster) {
return (int) $cluster['id'];
}, $clusters);
$previews = $this->timelineQuery->getMapClusterPreviews($clusterIds, $root);
// Merge the responses
$fileMap = [];
foreach ($previews as &$preview) {
$fileMap[$preview['mapcluster']] = $preview;
}
foreach ($clusters as &$cluster) {
$cluster['preview'] = $fileMap[$cluster['id']] ?? null;
}
return new JSONResponse($clusters);
} catch (\Exception $e) {
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
}

View File

@ -25,7 +25,6 @@ namespace OCA\Memories\Controller;
use OCA\Memories\AppInfo\Application;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\StreamResponse;
@ -73,11 +72,7 @@ class OtherController extends ApiBase
'Content-Type' => 'application/javascript',
'Service-Worker-Allowed' => '/',
]);
$policy = new ContentSecurityPolicy();
$policy->addAllowedWorkerSrcDomain("'self'");
$policy->addAllowedScriptDomain("'self'");
$policy->addAllowedConnectDomain("'self'");
$response->setContentSecurityPolicy($policy);
$response->setContentSecurityPolicy(PageController::getCSP());
return $response;
}

View File

@ -63,28 +63,23 @@ class PageController extends Controller
// Configuration
$uid = $user->getUID();
$this->initialState->provideInitialState('timelinePath', $this->config->getUserValue(
$pi = function ($key, $default) use ($uid) {
$this->initialState->provideInitialState($key, $this->config->getUserValue(
$uid,
Application::APPNAME,
'timelinePath',
'EMPTY'
));
$this->initialState->provideInitialState('foldersPath', $this->config->getUserValue(
$uid,
Application::APPNAME,
'foldersPath',
'/'
));
$this->initialState->provideInitialState('showHidden', $this->config->getUserValue(
$uid,
Application::APPNAME,
'showHidden',
false
$key,
$default
));
};
// User configuration
$pi('timelinePath', 'EMPTY');
$pi('foldersPath', '/');
$pi('showHidden', false);
$pi('enableTopMemories', 'true');
// Apps enabled
$this->initialState->provideInitialState('systemtags', true === $this->appManager->isEnabledForUser('systemtags'));
$this->initialState->provideInitialState('maps', true === $this->appManager->isEnabledForUser('maps'));
$this->initialState->provideInitialState('recognize', \OCA\Memories\Util::recognizeIsEnabled($this->appManager));
$this->initialState->provideInitialState('facerecognitionInstalled', \OCA\Memories\Util::facerecognitionIsInstalled($this->appManager));
$this->initialState->provideInitialState('facerecognitionEnabled', \OCA\Memories\Util::facerecognitionIsEnabled($this->config, $uid));
@ -95,6 +90,7 @@ class PageController extends Controller
$response = new TemplateResponse($this->appName, 'main');
$response->setContentSecurityPolicy(self::getCSP());
$response->cacheFor(0);
return $response;
}
@ -102,9 +98,21 @@ class PageController extends Controller
/** Get the common content security policy */
public static function getCSP()
{
// Image domains MUST be added to the connect domain list
// because of the service worker fetch() call
$addImageDomain = function ($url) use (&$policy) {
$policy->addAllowedImageDomain($url);
$policy->addAllowedConnectDomain($url);
};
// Create base policy
$policy = new ContentSecurityPolicy();
$policy->addAllowedWorkerSrcDomain("'self'");
$policy->addAllowedScriptDomain("'self'");
$policy->addAllowedFrameDomain("'self'");
$policy->addAllowedImageDomain("'self'");
$policy->addAllowedMediaDomain("'self'");
$policy->addAllowedConnectDomain("'self'");
// Video player
$policy->addAllowedWorkerSrcDomain('blob:');
@ -114,9 +122,10 @@ class PageController extends Controller
// Image editor
$policy->addAllowedConnectDomain('data:');
// Allow nominatim for metadata
$policy->addAllowedConnectDomain('nominatim.openstreetmap.org');
// Allow OSM
$policy->addAllowedFrameDomain('www.openstreetmap.org');
$addImageDomain('https://*.tile.openstreetmap.org');
$addImageDomain('https://*.a.ssl.fastly.net');
return $policy;
}
@ -132,6 +141,10 @@ class PageController extends Controller
// Video configuration
$initialState->provideInitialState('notranscode', $config->getSystemValue('memories.no_transcode', 'UNSET'));
$initialState->provideInitialState('video_default_quality', $config->getSystemValue('memories.video_default_quality', '0'));
// Geo configuration
$initialState->provideInitialState('places_gis', $config->getSystemValue('memories.gis_type', '-1'));
}
/**
@ -214,6 +227,16 @@ class PageController extends Controller
return $this->main();
}
/**
* @NoAdminRequired
*
* @NoCSRFRequired
*/
public function places()
{
return $this->main();
}
/**
* @NoAdminRequired
*
@ -223,4 +246,14 @@ class PageController extends Controller
{
return $this->main();
}
/**
* @NoAdminRequired
*
* @NoCSRFRequired
*/
public function map()
{
return $this->main();
}
}

View File

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Varun Patil <radialapps@gmail.com>
* @author Varun Patil <radialapps@gmail.com>
* @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 <http://www.gnu.org/licenses/>.
*/
namespace OCA\Memories\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
class PlacesController extends ApiBase
{
/**
* @NoAdminRequired
*
* Get list of places with counts of images
*/
public function places(): JSONResponse
{
$user = $this->userSession->getUser();
if (null === $user) {
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
}
// Check tags enabled for this user
if (!$this->placesIsEnabled()) {
return new JSONResponse(['message' => 'Places not enabled'], Http::STATUS_PRECONDITION_FAILED);
}
// If this isn't the timeline folder then things aren't going to work
$root = $this->getRequestRoot();
if ($root->isEmpty()) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
// Run actual query
$list = $this->timelineQuery->getPlaces($root);
return new JSONResponse($list, Http::STATUS_OK);
}
/**
* @NoAdminRequired
*
* @NoCSRFRequired
*
* Get preview for a location
*/
public function preview(int $id): Http\Response
{
$user = $this->userSession->getUser();
if (null === $user) {
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
}
// Check tags enabled for this user
if (!$this->placesIsEnabled()) {
return new JSONResponse(['message' => 'Places not enabled'], Http::STATUS_PRECONDITION_FAILED);
}
// If this isn't the timeline folder then things aren't going to work
$root = $this->getRequestRoot();
if ($root->isEmpty()) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
// Run actual query
$list = $this->timelineQuery->getPlacePreviews($id, $root);
if (null === $list || 0 === \count($list)) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
shuffle($list);
// Get preview from image list
return $this->getPreviewFromImageList(array_map(static function (&$item) {
return (int) $item['fileid'];
}, $list));
}
}

View File

@ -110,6 +110,7 @@ class PublicController extends AuthPublicShareController
$response->setHeaderTitle($share->getNode()->getName());
$response->setFooterVisible(false); // wth is that anyway?
$response->setContentSecurityPolicy(PageController::getCSP());
$response->cacheFor(0);
return $response;
}

View File

@ -24,7 +24,6 @@ declare(strict_types=1);
namespace OCA\Memories\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataDisplayResponse;
use OCP\AppFramework\Http\JSONResponse;
class TagsController extends ApiBase
@ -90,38 +89,11 @@ class TagsController extends ApiBase
if (null === $list || 0 === \count($list)) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
shuffle($list);
// Get preview manager
$previewManager = \OC::$server->get(\OCP\IPreview::class);
// Try to get a preview
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
foreach ($list as &$img) {
// Get the file
$files = $userFolder->getById($img['fileid']);
if (0 === \count($files)) {
continue;
}
// Check read permission
if (!($files[0]->getPermissions() & \OCP\Constants::PERMISSION_READ)) {
continue;
}
// Get preview image
try {
$preview = $previewManager->getPreview($files[0], 512, 512, false);
$response = new DataDisplayResponse($preview->getContent(), Http::STATUS_OK, [
'Content-Type' => $preview->getMimeType(),
]);
$response->cacheFor(3600 * 24, false, false);
return $response;
} catch (\Exception $e) {
continue;
}
}
return new JSONResponse([], Http::STATUS_NOT_FOUND);
// Get preview from image list
return $this->getPreviewFromImageList(array_map(static function (&$item) {
return (int) $item['fileid'];
}, $list));
}
}

View File

@ -222,29 +222,45 @@ class VideoController extends ApiBase
}
// Check for environment variables
$env = '';
$env = [];
// QSV with VAAPI
$vaapi = $this->config->getSystemValue('memories.qsv', false);
if ($vaapi) {
$env .= 'VAAPI=1 ';
if ($this->config->getSystemValue('memories.qsv', false)) {
$env[] = 'VAAPI=1';
}
// NVENC
$nvenc = $this->config->getSystemValue('memories.nvenc', false);
if ($nvenc) {
$env .= 'NVENC=1 ';
if ($this->config->getSystemValue('memories.nvenc', false)) {
$env[] = 'NVENC=1';
}
// Bind address / port
$port = $this->config->getSystemValue('memories.govod_port', 47788);
$env[] = "GOVOD_BIND='127.0.0.1:{$port}'";
// Paths
$ffmpegPath = $this->config->getSystemValue('memories.ffmpeg_path', 'ffmpeg');
$ffprobePath = $this->config->getSystemValue('memories.ffprobe_path', 'ffprobe');
$tmpPath = $this->config->getSystemValue('memories.tmp_path', sys_get_temp_dir());
$env .= "FFMPEG='{$ffmpegPath}' FFPROBE='{$ffprobePath}' GOVOD_TEMPDIR='{$tmpPath}/go-vod' ";
$env[] = "FFMPEG='{$ffmpegPath}'";
$env[] = "FFPROBE='{$ffprobePath}'";
// Check if already running
// (Re-)create Temp dir
$instanceId = $this->config->getSystemValue('instanceid', 'default');
$defaultTmp = sys_get_temp_dir().'/go-vod/'.$instanceId;
$tmpPath = $this->config->getSystemValue('memories.tmp_path', $defaultTmp);
shell_exec("rm -rf '{$tmpPath}'");
mkdir($tmpPath, 0755, true);
// Remove trailing slash from temp path if present
if ('/' === substr($tmpPath, -1)) {
$tmpPath = substr($tmpPath, 0, -1);
}
$env[] = "GOVOD_TEMPDIR='{$tmpPath}'";
// Kill already running and start new
\OCA\Memories\Util::pkill($transcoder);
shell_exec("{$env} nohup {$transcoder} > {$tmpPath}/go-vod.log 2>&1 & > /dev/null");
$env = implode(' ', $env);
shell_exec("{$env} nohup {$transcoder} > '{$tmpPath}.log' 2>&1 & > /dev/null");
// wait for 1s and try again
sleep(1);
@ -258,7 +274,8 @@ class VideoController extends ApiBase
// Make sure query params are repeated
// For example, in folder sharing, we need the params on every request
$url = "http://127.0.0.1:47788/{$client}{$path}/{$profile}";
$port = $this->config->getSystemValue('memories.govod_port', 47788);
$url = "http://127.0.0.1:{$port}/{$client}{$path}/{$profile}";
if ($params = $_SERVER['QUERY_STRING']) {
$url .= "?{$params}";
}

View File

@ -14,8 +14,10 @@ class TimelineQuery
use TimelineQueryFilters;
use TimelineQueryFolders;
use TimelineQueryLivePhoto;
use TimelineQueryMap;
use TimelineQueryPeopleFaceRecognition;
use TimelineQueryPeopleRecognize;
use TimelineQueryPlaces;
use TimelineQueryTags;
protected IDBConnection $connection;
@ -40,7 +42,16 @@ class TimelineQuery
{
$params = $query->getParameters();
foreach ($params as $key => $value) {
$sql = str_replace(':'.$key, $query->getConnection()->getDatabasePlatform()->quoteStringLiteral($value), $sql);
if (\is_array($value)) {
$value = implode(',', $value);
} elseif (\is_bool($value)) {
$value = $value ? '1' : '0';
} elseif (null === $value) {
$value = 'NULL';
}
$value = $query->getConnection()->getDatabasePlatform()->quoteStringLiteral($value);
$sql = str_replace(':'.$key, $value, $sql);
}
return $sql;
@ -82,12 +93,29 @@ class TimelineQuery
}
}
$gisType = \OCA\Memories\Util::placesGISType();
$address = -1 === $gisType ? 'Geocoding Unconfigured' : null;
if (!$basic && $gisType > 0) {
$qb = $this->connection->getQueryBuilder();
$qb->select('e.name')
->from('memories_places', 'mp')
->innerJoin('mp', 'memories_planet', 'e', $qb->expr()->eq('mp.osm_id', 'e.osm_id'))
->where($qb->expr()->eq('mp.fileid', $qb->createNamedParameter($id, \PDO::PARAM_INT)))
->orderBy('e.admin_level', 'DESC')
;
$places = $qb->executeQuery()->fetchAll(\PDO::FETCH_COLUMN);
if (\count($places) > 0) {
$address = implode(', ', $places);
}
}
return [
'fileid' => (int) $row['fileid'],
'dayid' => (int) $row['dayid'],
'datetaken' => $utcTs,
'w' => (int) $row['w'],
'h' => (int) $row['h'],
'datetaken' => $utcTs,
'address' => $address,
'exif' => $exif,
];
}

View File

@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace OCA\Memories\Db;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
trait TimelineQueryMap
{
protected IDBConnection $connection;
public function transformMapBoundsFilter(IQueryBuilder &$query, string $userId, string $bounds, $table = 'm')
{
$bounds = explode(',', $bounds);
$bounds = array_map('floatval', $bounds);
if (4 !== \count($bounds)) {
return;
}
$latCol = $table.'.lat';
$lonCol = $table.'.lon';
$query->andWhere(
$query->expr()->andX(
$query->expr()->gte($latCol, $query->createNamedParameter($bounds[0], IQueryBuilder::PARAM_STR)),
$query->expr()->lte($latCol, $query->createNamedParameter($bounds[1], IQueryBuilder::PARAM_STR)),
$query->expr()->gte($lonCol, $query->createNamedParameter($bounds[2], IQueryBuilder::PARAM_STR)),
$query->expr()->lte($lonCol, $query->createNamedParameter($bounds[3], IQueryBuilder::PARAM_STR))
)
);
}
public function getMapClusters(
float $gridLen,
string $bounds,
TimelineRoot &$root
): array {
$query = $this->connection->getQueryBuilder();
// Get the average location of each cluster
$lat = $query->createFunction('AVG(c.lat) AS lat');
$lon = $query->createFunction('AVG(c.lon) AS lon');
$count = $query->createFunction('COUNT(m.fileid) AS count');
$update = $query->createFunction('MAX(c.last_update) as u');
$query->select($lat, $lon, $update, $count)
->from('memories_mapclusters', 'c')
;
if ($gridLen > 0.02) {
// Coarse grouping
$query->addSelect($query->createFunction('MAX(c.id) as id'));
$query->addGroupBy($query->createFunction("CAST(c.lat / {$gridLen} AS INT)"));
$query->addGroupBy($query->createFunction("CAST(c.lon / {$gridLen} AS INT)"));
} else {
// Fine grouping
$query->addSelect('c.id')->groupBy('c.id');
}
// JOIN with memories for files from the current user
$query->innerJoin('c', 'memories', 'm', $query->expr()->eq('c.id', 'm.mapcluster'));
// JOIN with filecache for existing files
$query = $this->joinFilecache($query, $root, true, false);
// Bound the query to the map bounds
$this->transformMapBoundsFilter($query, '', $bounds, 'c');
// Execute query
$cursor = $this->executeQueryWithCTEs($query);
$res = $cursor->fetchAll();
$cursor->closeCursor();
// Post-process results
$clusters = [];
foreach ($res as &$cluster) {
$c = [
'center' => [
(float) $cluster['lat'],
(float) $cluster['lon'],
],
'count' => (float) $cluster['count'],
'u' => (int) $cluster['u'],
];
if (\array_key_exists('id', $cluster)) {
$c['id'] = (int) $cluster['id'];
}
$clusters[] = $c;
}
return $clusters;
}
public function getMapClusterPreviews(array $clusterIds, TimelineRoot &$root)
{
$query = $this->connection->getQueryBuilder();
// SELECT all photos with this tag
$fileid = $query->createFunction('MAX(m.fileid) AS fileid');
$query->select($fileid)->from('memories', 'm')->where(
$query->expr()->in('m.mapcluster', $query->createNamedParameter($clusterIds, IQueryBuilder::PARAM_INT_ARRAY))
);
// WHERE these photos are in the user's requested folder recursively
$query = $this->joinFilecache($query, $root, true, false);
// GROUP BY the cluster
$query->groupBy('m.mapcluster');
// Get the fileIds
$cursor = $this->executeQueryWithCTEs($query);
$fileIds = $cursor->fetchAll(\PDO::FETCH_COLUMN);
// SELECT these files from the filecache
$query = $this->connection->getQueryBuilder();
$query->select('m.fileid', 'm.dayid', 'm.mapcluster', 'f.etag')
->from('memories', 'm')
->innerJoin('m', 'filecache', 'f', $query->expr()->eq('m.fileid', 'f.fileid'))
->where($query->expr()->in('m.fileid', $query->createNamedParameter($fileIds, IQueryBuilder::PARAM_INT_ARRAY)))
;
$files = $query->executeQuery()->fetchAll();
// Post-process
foreach ($files as &$row) {
$row['fileid'] = (int) $row['fileid'];
$row['mapcluster'] = (int) $row['mapcluster'];
$row['dayid'] = (int) $row['dayid'];
}
return $files;
}
}

View File

@ -61,63 +61,11 @@ trait TimelineQueryPeopleFaceRecognition
public function getPeopleFaceRecognition(TimelineRoot &$root, int $currentModel, bool $show_clusters = false, bool $show_singles = false, bool $show_hidden = false)
{
$query = $this->connection->getQueryBuilder();
// SELECT all face clusters
$count = $query->func()->count($query->createFunction('DISTINCT m.fileid'), 'count');
$query->select('frp.id', 'frp.user as user_id', 'frp.name', $count)->from('facerecog_persons', 'frp');
// WHERE there are faces with this cluster
$query->innerJoin('frp', 'facerecog_faces', 'frf', $query->expr()->eq('frp.id', 'frf.person'));
// WHERE faces are from images.
$query->innerJoin('frf', 'facerecog_images', 'fri', $query->expr()->eq('fri.id', 'frf.image'));
// WHERE these items are memories indexed photos
$query->innerJoin('fri', 'memories', 'm', $query->expr()->andX(
$query->expr()->eq('fri.file', 'm.fileid'),
$query->expr()->eq('fri.model', $query->createNamedParameter($currentModel)),
));
// WHERE these photos are in the user's requested folder recursively
$query = $this->joinFilecache($query, $root, true, false);
if ($show_clusters) {
// GROUP by ID of face cluster
$query->groupBy('frp.id');
$query->where($query->expr()->isNull('frp.name'));
} else {
// GROUP by name of face clusters
$query->groupBy('frp.name');
$query->where($query->expr()->isNotNull('frp.name'));
return $this->getFaceRecognitionClusters($root, $currentModel, $show_singles, $show_hidden);
}
// By default hides individual faces when they have no name.
if ($show_clusters && !$show_singles) {
$query->having($query->expr()->gt('count', $query->createNamedParameter(1)));
}
// By default it shows the people who were not hidden
if (!$show_hidden) {
$query->andWhere($query->expr()->eq('frp.is_visible', $query->createNamedParameter(true)));
}
// ORDER by number of faces in cluster
$query->orderBy('count', 'DESC');
$query->addOrderBy('name', 'ASC');
$query->addOrderBy('frp.id'); // tie-breaker
// FETCH all faces
$cursor = $this->executeQueryWithCTEs($query);
$faces = $cursor->fetchAll();
// Post process
foreach ($faces as &$row) {
$row['id'] = $row['name'] ?: (int) $row['id'];
$row['count'] = (int) $row['count'];
}
return $faces;
return $this->getFaceRecognitionPersons($root, $currentModel);
}
public function getFaceRecognitionPreview(TimelineRoot &$root, $currentModel, $previewId)
@ -214,6 +162,113 @@ trait TimelineQueryPeopleFaceRecognition
return $previews;
}
private function getFaceRecognitionClusters(TimelineRoot &$root, int $currentModel, bool $show_singles = false, bool $show_hidden = false)
{
$query = $this->connection->getQueryBuilder();
// SELECT all face clusters
$count = $query->func()->count($query->createFunction('DISTINCT m.fileid'));
$query->select('frp.id')->from('facerecog_persons', 'frp');
$query->selectAlias($count, 'count');
$query->selectAlias('frp.user', 'user_id');
// WHERE there are faces with this cluster
$query->innerJoin('frp', 'facerecog_faces', 'frf', $query->expr()->eq('frp.id', 'frf.person'));
// WHERE faces are from images.
$query->innerJoin('frf', 'facerecog_images', 'fri', $query->expr()->eq('fri.id', 'frf.image'));
// WHERE these items are memories indexed photos
$query->innerJoin('fri', 'memories', 'm', $query->expr()->andX(
$query->expr()->eq('fri.file', 'm.fileid'),
$query->expr()->eq('fri.model', $query->createNamedParameter($currentModel)),
));
// WHERE these photos are in the user's requested folder recursively
$query = $this->joinFilecache($query, $root, true, false);
// GROUP by ID of face cluster
$query->groupBy('frp.id');
$query->addGroupBy('frp.user');
$query->where($query->expr()->isNull('frp.name'));
// By default hides individual faces when they have no name.
if (!$show_singles) {
$query->having($count, $query->createNamedParameter(1));
}
// By default it shows the people who were not hidden
if (!$show_hidden) {
$query->andWhere($query->expr()->eq('frp.is_visible', $query->createNamedParameter(true)));
}
// ORDER by number of faces in cluster and id for response stability.
$query->orderBy('count', 'DESC');
$query->addOrderBy('frp.id', 'DESC');
// It is not worth displaying all unnamed clusters. We show 15 to name them progressively,
$query->setMaxResults(15);
// FETCH all faces
$cursor = $this->executeQueryWithCTEs($query);
$faces = $cursor->fetchAll();
// Post process
foreach ($faces as &$row) {
$row['id'] = (int) $row['id'];
$row['count'] = (int) $row['count'];
}
return $faces;
}
private function getFaceRecognitionPersons(TimelineRoot &$root, int $currentModel)
{
$query = $this->connection->getQueryBuilder();
// SELECT all face clusters
$count = $query->func()->count($query->createFunction('DISTINCT m.fileid'));
$query->select('frp.name')->from('facerecog_persons', 'frp');
$query->selectAlias($count, 'count');
$query->selectAlias('frp.user', 'user_id');
// WHERE there are faces with this cluster
$query->innerJoin('frp', 'facerecog_faces', 'frf', $query->expr()->eq('frp.id', 'frf.person'));
// WHERE faces are from images.
$query->innerJoin('frf', 'facerecog_images', 'fri', $query->expr()->eq('fri.id', 'frf.image'));
// WHERE these items are memories indexed photos
$query->innerJoin('fri', 'memories', 'm', $query->expr()->andX(
$query->expr()->eq('fri.file', 'm.fileid'),
$query->expr()->eq('fri.model', $query->createNamedParameter($currentModel)),
));
// WHERE these photos are in the user's requested folder recursively
$query = $this->joinFilecache($query, $root, true, false);
// GROUP by name of face clusters
$query->where($query->expr()->isNotNull('frp.name'));
$query->groupBy('frp.user');
$query->addGroupBy('frp.name');
// ORDER by number of faces in cluster
$query->orderBy('count', 'DESC');
$query->addOrderBy('frp.name', 'ASC');
// FETCH all faces
$cursor = $this->executeQueryWithCTEs($query);
$faces = $cursor->fetchAll();
// Post process
foreach ($faces as &$row) {
$row['id'] = $row['name'];
$row['count'] = (int) $row['count'];
}
return $faces;
}
/** Convert face fields to object */
private function processFaceRecognitionDetection(&$row, $days = false)
{

View File

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace OCA\Memories\Db;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
trait TimelineQueryPlaces
{
protected IDBConnection $connection;
public function transformPlaceFilter(IQueryBuilder &$query, string $userId, int $locationId)
{
$query->innerJoin('m', 'memories_places', 'mp', $query->expr()->andX(
$query->expr()->eq('mp.fileid', 'm.fileid'),
$query->expr()->eq('mp.osm_id', $query->createNamedParameter($locationId)),
));
}
public function getPlaces(TimelineRoot &$root)
{
$query = $this->connection->getQueryBuilder();
// SELECT location name and count of photos
$count = $query->func()->count($query->createFunction('DISTINCT m.fileid'), 'count');
$query->select('e.osm_id', 'e.name', $count)->from('memories_planet', 'e');
// WHERE there are items with this osm_id
$query->innerJoin('e', 'memories_places', 'mp', $query->expr()->eq('mp.osm_id', 'e.osm_id'));
// WHERE these items are memories indexed photos
$query->innerJoin('mp', 'memories', 'm', $query->expr()->eq('m.fileid', 'mp.fileid'));
// WHERE these photos are in the user's requested folder recursively
$query = $this->joinFilecache($query, $root, true, false);
// GROUP and ORDER by tag name
$query->groupBy('e.osm_id', 'e.name');
$query->orderBy($query->createFunction('LOWER(e.name)'), 'ASC');
$query->addOrderBy('e.osm_id'); // tie-breaker
// FETCH all tags
$cursor = $this->executeQueryWithCTEs($query);
$places = $cursor->fetchAll();
// Post process
foreach ($places as &$row) {
$row['osm_id'] = (int) $row['osm_id'];
$row['count'] = (int) $row['count'];
}
return $places;
}
public function getPlacePreviews(int $id, TimelineRoot &$root)
{
$query = $this->connection->getQueryBuilder();
// SELECT all photos with this tag
$query->select('f.fileid', 'f.etag')->from('memories_places', 'mp')
->where($query->expr()->eq('mp.osm_id', $query->createNamedParameter($id)))
;
// WHERE these items are memories indexed photos
$query->innerJoin('mp', 'memories', 'm', $query->expr()->eq('m.fileid', 'mp.fileid'));
// WHERE these photos are in the user's requested folder recursively
$query = $this->joinFilecache($query, $root, true, false);
// MAX 8
$query->setMaxResults(8);
// FETCH tag previews
$cursor = $this->executeQueryWithCTEs($query);
$ans = $cursor->fetchAll();
// Post-process
foreach ($ans as &$row) {
$row['fileid'] = (int) $row['fileid'];
}
return $ans;
}
}

View File

@ -10,11 +10,18 @@ use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\File;
use OCP\IDBConnection;
use OCP\IPreview;
use Psr\Log\LoggerInterface;
require_once __DIR__.'/../ExifFields.php';
const DELETE_TABLES = ['memories', 'memories_livephoto', 'memories_places'];
const TRUNCATE_TABLES = ['memories_mapclusters'];
class TimelineWrite
{
use TimelineWriteMap;
use TimelineWriteOrphans;
use TimelineWritePlaces;
protected IDBConnection $connection;
protected IPreview $preview;
protected LivePhoto $livePhoto;
@ -76,7 +83,7 @@ class TimelineWrite
// Check if need to update
$query = $this->connection->getQueryBuilder();
$query->select('fileid', 'mtime')
$query->select('fileid', 'mtime', 'mapcluster')
->from('memories')
->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)))
;
@ -154,27 +161,32 @@ class TimelineWrite
$exifJson = json_encode(['error' => 'Exif data encoding error']);
}
if ($prevRow) {
// Update existing row
// No need to set objectid again
$query->update('memories')
->set('dayid', $query->createNamedParameter($dayId, IQueryBuilder::PARAM_INT))
->set('datetaken', $query->createNamedParameter($dateTaken, IQueryBuilder::PARAM_STR))
->set('mtime', $query->createNamedParameter($mtime, IQueryBuilder::PARAM_INT))
->set('isvideo', $query->createNamedParameter($isvideo, IQueryBuilder::PARAM_INT))
->set('video_duration', $query->createNamedParameter($videoDuration, IQueryBuilder::PARAM_INT))
->set('w', $query->createNamedParameter($w, IQueryBuilder::PARAM_INT))
->set('h', $query->createNamedParameter($h, IQueryBuilder::PARAM_INT))
->set('exif', $query->createNamedParameter($exifJson, IQueryBuilder::PARAM_STR))
->set('liveid', $query->createNamedParameter($liveid, IQueryBuilder::PARAM_STR))
->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)))
;
$query->executeStatement();
} else {
// Try to create new row
// Store location data
$lat = null;
$lon = null;
$mapCluster = $prevRow ? (int) $prevRow['mapcluster'] : -1;
if (\array_key_exists('GPSLatitude', $exif) && \array_key_exists('GPSLongitude', $exif)) {
$lat = (float) $exif['GPSLatitude'];
$lon = (float) $exif['GPSLongitude'];
try {
$query->insert('memories')
->values([
$mapCluster = $this->getMapCluster($mapCluster, $lat, $lon);
$mapCluster = $mapCluster <= 0 ? null : $mapCluster;
} catch (\Error $e) {
$logger = \OC::$server->get(LoggerInterface::class);
$logger->log(3, 'Error updating map cluster data: '.$e->getMessage(), ['app' => 'memories']);
}
try {
$this->updatePlacesData($fileId, $lat, $lon);
} catch (\Error $e) {
$logger = \OC::$server->get(LoggerInterface::class);
$logger->log(3, 'Error updating places data: '.$e->getMessage(), ['app' => 'memories']);
}
}
// Parameters for insert or update
$params = [
'fileid' => $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT),
'objectid' => $query->createNamedParameter((string) $fileId, IQueryBuilder::PARAM_STR),
'dayid' => $query->createNamedParameter($dayId, IQueryBuilder::PARAM_INT),
@ -186,8 +198,27 @@ class TimelineWrite
'h' => $query->createNamedParameter($h, IQueryBuilder::PARAM_INT),
'exif' => $query->createNamedParameter($exifJson, IQueryBuilder::PARAM_STR),
'liveid' => $query->createNamedParameter($liveid, IQueryBuilder::PARAM_STR),
])
'lat' => $query->createNamedParameter($lat, IQueryBuilder::PARAM_STR),
'lon' => $query->createNamedParameter($lon, IQueryBuilder::PARAM_STR),
'mapcluster' => $query->createNamedParameter($mapCluster, IQueryBuilder::PARAM_INT),
];
if ($prevRow) {
// Update existing row
// No need to set objectid again
$query->update('memories')
->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)))
;
foreach ($params as $key => $value) {
if ('objectid' !== $key && 'fileid' !== $key) {
$query->set($key, $value);
}
}
$query->executeStatement();
} else {
// Try to create new row
try {
$query->insert('memories')->values($params);
$query->executeStatement();
} catch (\Exception $ex) {
error_log('Failed to create memories record: '.$ex->getMessage());
@ -202,15 +233,27 @@ class TimelineWrite
*/
public function deleteFile(File &$file)
{
$deleteFrom = function ($table) use (&$file) {
// Get full record
$query = $this->connection->getQueryBuilder();
$query->select('*')
->from('memories')
->where($query->expr()->eq('fileid', $query->createNamedParameter($file->getId(), IQueryBuilder::PARAM_INT)))
;
$record = $query->executeQuery()->fetch();
// Delete all records regardless of existence
foreach (DELETE_TABLES as $table) {
$query = $this->connection->getQueryBuilder();
$query->delete($table)
->where($query->expr()->eq('fileid', $query->createNamedParameter($file->getId(), IQueryBuilder::PARAM_INT)))
;
$query->executeStatement();
};
$deleteFrom('memories');
$deleteFrom('memories_livephoto');
}
// Delete from map cluster
if ($record && ($cid = (int) $record['mapcluster']) > 0) {
$this->removeFromCluster($cid, (float) $record['lat'], (float) $record['lon']);
}
}
/**
@ -221,51 +264,8 @@ class TimelineWrite
public function clear()
{
$p = $this->connection->getDatabasePlatform();
$t1 = $p->getTruncateTableSQL('`*PREFIX*memories`', false);
$t2 = $p->getTruncateTableSQL('`*PREFIX*memories_livephoto`', false);
$this->connection->executeStatement("{$t1}; {$t2}");
}
/**
* Mark a file as not orphaned.
*/
public function unorphan(File &$file)
{
$query = $this->connection->getQueryBuilder();
$query->update('memories')
->set('orphan', $query->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
->where($query->expr()->eq('fileid', $query->createNamedParameter($file->getId(), IQueryBuilder::PARAM_INT)))
;
$query->executeStatement();
}
/**
* Mark all files in the table as orphaned.
*
* @return int Number of rows affected
*/
public function orphanAll(): int
{
$query = $this->connection->getQueryBuilder();
$query->update('memories')
->set('orphan', $query->createNamedParameter(true, IQueryBuilder::PARAM_BOOL))
;
return $query->executeStatement();
}
/**
* Remove all entries that are orphans.
*
* @return int Number of rows affected
*/
public function removeOrphans(): int
{
$query = $this->connection->getQueryBuilder();
$query->delete('memories')
->where($query->expr()->eq('orphan', $query->createNamedParameter(true, IQueryBuilder::PARAM_BOOL)))
;
return $query->executeStatement();
foreach (array_merge(DELETE_TABLES, TRUNCATE_TABLES) as $table) {
$this->connection->executeStatement($p->getTruncateTableSQL('*PREFIX*'.$table, false));
}
}
}

View File

@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace OCA\Memories\Db;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
const CLUSTER_DEG = 0.0003;
trait TimelineWriteMap
{
protected IDBConnection $connection;
protected function getMapCluster(int $prevCluster, float $lat, float $lon): int
{
// Get all possible clusters within CLUSTER_DEG radius
$query = $this->connection->getQueryBuilder();
$query->select('id', 'lat', 'lon')
->from('memories_mapclusters')
->andWhere($query->expr()->gte('lat', $query->createNamedParameter($lat - CLUSTER_DEG, IQueryBuilder::PARAM_STR)))
->andWhere($query->expr()->lte('lat', $query->createNamedParameter($lat + CLUSTER_DEG, IQueryBuilder::PARAM_STR)))
->andWhere($query->expr()->gte('lon', $query->createNamedParameter($lon - CLUSTER_DEG, IQueryBuilder::PARAM_STR)))
->andWhere($query->expr()->lte('lon', $query->createNamedParameter($lon + CLUSTER_DEG, IQueryBuilder::PARAM_STR)))
;
$rows = $query->executeQuery()->fetchAll();
// Find cluster closest to the point
$minDist = PHP_INT_MAX;
$minId = -1;
foreach ($rows as $r) {
$clusterLat = (float) $r['lat'];
$clusterLon = (float) $r['lon'];
$dist = ($lat - $clusterLat) ** 2 + ($lon - $clusterLon) ** 2;
if ($dist < $minDist) {
$minDist = $dist;
$minId = $r['id'];
}
}
// If no cluster found, create a new one
if ($minId <= 0) {
$this->removeFromCluster($prevCluster, $lat, $lon);
return $this->createMapCluster($lat, $lon);
}
// If the file was previously in the same cluster, return that
if ($prevCluster === $minId) {
return $minId;
}
// If the file was previously in a different cluster,
// remove it from the first cluster and add it to the second
$this->removeFromCluster($prevCluster, $lat, $lon);
$this->addToCluster($minId, $lat, $lon);
return $minId;
}
protected function addToCluster(int $clusterId, float $lat, float $lon): void
{
if ($clusterId <= 0) {
return;
}
$query = $this->connection->getQueryBuilder();
$query->update('memories_mapclusters')
->set('point_count', $query->createFunction('point_count + 1'))
->set('lat_sum', $query->createFunction("lat_sum + {$lat}"))
->set('lon_sum', $query->createFunction("lon_sum + {$lon}"))
->set('lat', $query->createFunction('lat_sum / point_count'))
->set('lon', $query->createFunction('lon_sum / point_count'))
->set('last_update', $query->createNamedParameter(time(), IQueryBuilder::PARAM_INT))
->where($query->expr()->eq('id', $query->createNamedParameter($clusterId, IQueryBuilder::PARAM_INT)))
;
$query->executeStatement();
}
private function createMapCluster(float $lat, float $lon): int
{
$query = $this->connection->getQueryBuilder();
$query->insert('memories_mapclusters')
->values([
'point_count' => $query->createNamedParameter(1, IQueryBuilder::PARAM_INT),
'lat_sum' => $query->createNamedParameter($lat, IQueryBuilder::PARAM_STR),
'lon_sum' => $query->createNamedParameter($lon, IQueryBuilder::PARAM_STR),
'lat' => $query->createNamedParameter($lat, IQueryBuilder::PARAM_STR),
'lon' => $query->createNamedParameter($lon, IQueryBuilder::PARAM_STR),
'last_update' => $query->createNamedParameter(time(), IQueryBuilder::PARAM_INT),
])
;
$query->executeStatement();
return (int) $query->getLastInsertId();
}
private function removeFromCluster(int $clusterId, float $lat, float $lon): void
{
if ($clusterId <= 0) {
return;
}
$query = $this->connection->getQueryBuilder();
$query->update('memories_mapclusters')
->set('point_count', $query->createFunction('point_count - 1'))
->set('lat_sum', $query->createFunction("lat_sum - {$lat}"))
->set('lon_sum', $query->createFunction("lon_sum - {$lon}"))
->set('lat', $query->createFunction('lat_sum / point_count'))
->set('lon', $query->createFunction('lon_sum / point_count'))
->set('last_update', $query->createNamedParameter(time(), IQueryBuilder::PARAM_INT))
->where($query->expr()->eq('id', $query->createNamedParameter($clusterId, IQueryBuilder::PARAM_INT)))
;
$query->executeStatement();
}
}

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace OCA\Memories\Db;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\File;
use OCP\IDBConnection;
trait TimelineWriteOrphans
{
protected IDBConnection $connection;
/**
* Mark a file as not orphaned.
*/
public function unorphan(File &$file)
{
$query = $this->connection->getQueryBuilder();
$query->update('memories')
->set('orphan', $query->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
->where($query->expr()->eq('fileid', $query->createNamedParameter($file->getId(), IQueryBuilder::PARAM_INT)))
;
$query->executeStatement();
}
/**
* Mark all files in the table as orphaned.
*
* @return int Number of rows affected
*/
public function orphanAll(): int
{
$query = $this->connection->getQueryBuilder();
$query->update('memories')
->set('orphan', $query->createNamedParameter(true, IQueryBuilder::PARAM_BOOL))
;
return $query->executeStatement();
}
/**
* Remove all entries that are orphans.
*
* @return int Number of rows affected
*/
public function removeOrphans(): int
{
$query = $this->connection->getQueryBuilder();
$query->delete('memories')
->where($query->expr()->eq('orphan', $query->createNamedParameter(true, IQueryBuilder::PARAM_BOOL)))
;
return $query->executeStatement();
}
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace OCA\Memories\Db;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
trait TimelineWritePlaces
{
protected IDBConnection $connection;
/**
* Add places data for a file.
*/
protected function updatePlacesData(int $fileId, float $lat, float $lon): void
{
// Get GIS type
$gisType = \OCA\Memories\Util::placesGISType();
// Construct WHERE clause depending on GIS type
$where = null;
if (1 === $gisType) {
$where = "ST_Contains(geometry, ST_GeomFromText('POINT({$lon} {$lat})'))";
} elseif (2 === $gisType) {
$where = "POINT('{$lon},{$lat}') <@ geometry";
} else {
return;
}
// Make query to memories_planet table
$query = $this->connection->getQueryBuilder();
$query->select($query->createFunction('DISTINCT(osm_id)'))
->from('memories_planet_geometry')
->where($query->createFunction($where))
;
// Cancel out inner rings
$query->groupBy('poly_id', 'osm_id');
$query->having($query->createFunction('SUM(type_id) > 0'));
// memories_planet_geometry has no *PREFIX*
$sql = str_replace('*PREFIX*memories_planet_geometry', 'memories_planet_geometry', $query->getSQL());
// Run query
$result = $this->connection->executeQuery($sql);
$rows = $result->fetchAll();
// Delete previous records
$query = $this->connection->getQueryBuilder();
$query->delete('memories_places')
->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)))
;
$query->executeStatement();
// Insert records
foreach ($rows as $row) {
$query = $this->connection->getQueryBuilder();
$query->insert('memories_places')
->values([
'fileid' => $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT),
'osm_id' => $query->createNamedParameter($row['osm_id'], IQueryBuilder::PARAM_INT),
])
;
$query->executeStatement();
}
}
}

View File

@ -180,25 +180,17 @@ class Exif
*/
public static function getDateTaken(File &$file, array &$exif)
{
// Try to parse the date from exif metadata
$dt = $exif['DateTimeOriginal'] ?? null;
if (!isset($dt) || empty($dt)) {
$dt = $exif['CreateDate'] ?? null;
}
// Check if found something
try {
return self::parseExifDate($dt);
} catch (\Exception $ex) {
} catch (\ValueError $ex) {
}
// Fall back to creation time
$dateTaken = $file->getCreationTime();
// Fall back to modification time
if (0 === $dateTaken) {
$dateTaken = $file->getMtime();
}
return self::forgetTimezone($dateTaken);
}

View File

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2023 Your name <your@email.com>
* @author Your name <your@email.com>
* @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 <http://www.gnu.org/licenses/>.
*/
namespace OCA\Memories\Migration;
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 Version401100Date20230206002744 extends SimpleMigrationStep
{
/**
* @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
*/
public function preSchemaChange(IOutput $output, \Closure $schemaClosure, array $options): void
{
}
/**
* @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
*/
public function changeSchema(IOutput $output, \Closure $schemaClosure, array $options): ?ISchemaWrapper
{
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if (!$schema->hasTable('memories_places')) {
$table = $schema->createTable('memories_places');
$table->addColumn('id', 'integer', [
'autoincrement' => true,
'notnull' => true,
]);
$table->addColumn('fileid', Types::BIGINT, [
'notnull' => true,
'length' => 20,
]);
$table->addColumn('osm_id', Types::INTEGER, [
'notnull' => true,
]);
$table->setPrimaryKey(['id']);
$table->addIndex(['fileid'], 'memories_places_fileid_index');
$table->addIndex(['osm_id'], 'memories_places_osm_id_index');
}
if (!$schema->hasTable('memories_planet')) {
$table = $schema->createTable('memories_planet');
$table->addColumn('id', 'integer', [
'autoincrement' => true,
'notnull' => true,
]);
$table->addColumn('osm_id', Types::INTEGER, [
'notnull' => true,
]);
$table->addColumn('name', Types::TEXT, [
'notnull' => true,
]);
$table->addColumn('admin_level', Types::INTEGER, [
'notnull' => true,
]);
$table->setPrimaryKey(['id']);
$table->addIndex(['osm_id'], 'memories_planet_osm_id_index');
}
return $schema;
}
/**
* @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
*/
public function postSchemaChange(IOutput $output, \Closure $schemaClosure, array $options): void
{
}
}

View File

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2023 Your name <your@email.com>
* @author Your name <your@email.com>
* @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 <http://www.gnu.org/licenses/>.
*/
namespace OCA\Memories\Migration;
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 Version401100Date20230208181533 extends SimpleMigrationStep
{
/**
* @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
*/
public function preSchemaChange(IOutput $output, \Closure $schemaClosure, array $options): void
{
}
/**
* @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
*/
public function changeSchema(IOutput $output, \Closure $schemaClosure, array $options): ?ISchemaWrapper
{
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
// Add lat lon to memories
$table = $schema->getTable('memories');
if (!$table->hasColumn('lat')) {
$table->addColumn('lat', Types::DECIMAL, [
'notnull' => false,
'default' => null,
'precision' => 8,
'scale' => 6,
]);
$table->addColumn('lon', Types::DECIMAL, [
'notnull' => false,
'default' => null,
'precision' => 9,
'scale' => 6,
]);
$table->addIndex(['lat', 'lon'], 'memories_lat_lon_index');
$table->addColumn('mapcluster', Types::INTEGER, [
'notnull' => false,
'default' => null,
]);
$table->addIndex(['mapcluster'], 'memories_mapcluster_index');
}
// Add clusters table
if (!$schema->hasTable('memories_mapclusters')) {
$table = $schema->createTable('memories_mapclusters');
$table->addColumn('id', Types::INTEGER, [
'autoincrement' => true,
'notnull' => true,
]);
$table->addColumn('point_count', Types::INTEGER, [
'notnull' => true,
]);
$table->addColumn('lat_sum', Types::FLOAT, [
'notnull' => false,
'default' => null,
]);
$table->addColumn('lon_sum', Types::FLOAT, [
'notnull' => false,
'default' => null,
]);
$table->addColumn('lat', Types::FLOAT, [
'notnull' => false,
'default' => null,
]);
$table->addColumn('lon', Types::FLOAT, [
'notnull' => false,
'default' => null,
]);
$table->addColumn('last_update', Types::INTEGER, [
'notnull' => false,
'default' => null,
]);
$table->setPrimaryKey(['id']);
$table->addIndex(['lat', 'lon'], 'memories_clst_ll_idx');
}
return $schema;
}
/**
* @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
*/
public function postSchemaChange(IOutput $output, \Closure $schemaClosure, array $options): void
{
}
}

View File

@ -142,6 +142,15 @@ class Util
return false;
}
/**
* Check if geolocation (places) is enabled and available.
* Returns the type of the GIS.
*/
public static function placesGISType(): int
{
return (int) \OC::$server->get(\OCP\IConfig::class)->getSystemValue('memories.gis_type', -1);
}
/**
* Kill all instances of a process by name.
* Similar to pkill, which may not be available on all systems.

130
package-lock.json generated
View File

@ -16,6 +16,7 @@
"camelcase": "^7.0.1",
"filerobot-image-editor": "^4.3.7",
"justified-layout": "^4.1.0",
"leaflet": "^1.9.3",
"moment": "^2.29.4",
"path-posix": "^1.0.0",
"photoswipe": "^5.3.4",
@ -27,6 +28,7 @@
"vue-material-design-icons": "^5.1.2",
"vue-router": "^3.6.5",
"vue-virtual-scroller": "1.1.2",
"vue2-leaflet": "^2.7.1",
"webdav": "^4.11.2"
},
"devDependencies": {
@ -2297,6 +2299,12 @@
"@types/range-parser": "*"
}
},
"node_modules/@types/geojson": {
"version": "7946.0.10",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz",
"integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==",
"peer": true
},
"node_modules/@types/http-proxy": {
"version": "1.17.9",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.9.tgz",
@ -2319,6 +2327,15 @@
"dev": true,
"peer": true
},
"node_modules/@types/leaflet": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.0.tgz",
"integrity": "sha512-7LeOSj7EloC5UcyOMo+1kc3S1UT3MjJxwqsMT1d2PTyvQz53w0Y0oSSk9nwZnOZubCmBvpSNGceucxiq+ZPEUw==",
"peer": true,
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/mime": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz",
@ -6166,9 +6183,9 @@
"peer": true
},
"node_modules/json5": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
"integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
"bin": {
"json5": "lib/cli.js"
@ -6253,6 +6270,11 @@
"resolved": "https://registry.npmjs.org/layerr/-/layerr-0.1.2.tgz",
"integrity": "sha512-ob5kTd9H3S4GOG2nVXyQhOu9O8nBgP555XxWPkJI0tR0JeRilfyTp8WtPdIJHLXBmHMSdEq5+KMxiYABeScsIQ=="
},
"node_modules/leaflet": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.3.tgz",
"integrity": "sha512-iB2cR9vAkDOu5l3HAay2obcUHZ7xwUBBjph8+PGtmW/2lYhbLizWtG7nTeYht36WfOslixQF9D/uSIzhZgGMfQ=="
},
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@ -6287,9 +6309,9 @@
}
},
"node_modules/loader-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.3.tgz",
"integrity": "sha512-THWqIsn8QRnvLl0shHYVBN9syumU8pYWEHPTmkiVGd+7K5eFNVSY6AJhRvgGF70gg1Dz+l/k8WicvFCxdEs60A==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz",
"integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==",
"dev": true,
"peer": true,
"dependencies": {
@ -9518,9 +9540,9 @@
}
},
"node_modules/vue-loader/node_modules/json5": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"dev": true,
"peer": true,
"dependencies": {
@ -9531,9 +9553,9 @@
}
},
"node_modules/vue-loader/node_modules/loader-utils": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
"integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
"dev": true,
"peer": true,
"dependencies": {
@ -9600,9 +9622,9 @@
}
},
"node_modules/vue-style-loader/node_modules/json5": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"dev": true,
"peer": true,
"dependencies": {
@ -9613,9 +9635,9 @@
}
},
"node_modules/vue-style-loader/node_modules/loader-utils": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
"integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
"dev": true,
"peer": true,
"dependencies": {
@ -9677,6 +9699,16 @@
"vue": "^2.5.0"
}
},
"node_modules/vue2-leaflet": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/vue2-leaflet/-/vue2-leaflet-2.7.1.tgz",
"integrity": "sha512-K7HOlzRhjt3Z7+IvTqEavIBRbmCwSZSCVUlz9u4Rc+3xGCLsHKz4TAL4diAmfHElCQdPPVdZdJk8wPUt2fu6WQ==",
"peerDependencies": {
"@types/leaflet": "^1.5.7",
"leaflet": "^1.3.4",
"vue": "^2.5.17"
}
},
"node_modules/watchpack": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
@ -12124,6 +12156,12 @@
"@types/range-parser": "*"
}
},
"@types/geojson": {
"version": "7946.0.10",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz",
"integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==",
"peer": true
},
"@types/http-proxy": {
"version": "1.17.9",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.9.tgz",
@ -12146,6 +12184,15 @@
"dev": true,
"peer": true
},
"@types/leaflet": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.0.tgz",
"integrity": "sha512-7LeOSj7EloC5UcyOMo+1kc3S1UT3MjJxwqsMT1d2PTyvQz53w0Y0oSSk9nwZnOZubCmBvpSNGceucxiq+ZPEUw==",
"peer": true,
"requires": {
"@types/geojson": "*"
}
},
"@types/mime": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz",
@ -15203,9 +15250,9 @@
"peer": true
},
"json5": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
"integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true
},
"jsonfile": {
@ -15259,6 +15306,11 @@
"resolved": "https://registry.npmjs.org/layerr/-/layerr-0.1.2.tgz",
"integrity": "sha512-ob5kTd9H3S4GOG2nVXyQhOu9O8nBgP555XxWPkJI0tR0JeRilfyTp8WtPdIJHLXBmHMSdEq5+KMxiYABeScsIQ=="
},
"leaflet": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.3.tgz",
"integrity": "sha512-iB2cR9vAkDOu5l3HAay2obcUHZ7xwUBBjph8+PGtmW/2lYhbLizWtG7nTeYht36WfOslixQF9D/uSIzhZgGMfQ=="
},
"leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@ -15285,9 +15337,9 @@
"peer": true
},
"loader-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.3.tgz",
"integrity": "sha512-THWqIsn8QRnvLl0shHYVBN9syumU8pYWEHPTmkiVGd+7K5eFNVSY6AJhRvgGF70gg1Dz+l/k8WicvFCxdEs60A==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz",
"integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==",
"dev": true,
"peer": true,
"requires": {
@ -17760,9 +17812,9 @@
},
"dependencies": {
"json5": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"dev": true,
"peer": true,
"requires": {
@ -17770,9 +17822,9 @@
}
},
"loader-utils": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
"integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
"dev": true,
"peer": true,
"requires": {
@ -17829,9 +17881,9 @@
},
"dependencies": {
"json5": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"dev": true,
"peer": true,
"requires": {
@ -17839,9 +17891,9 @@
}
},
"loader-utils": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
"integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
"dev": true,
"peer": true,
"requires": {
@ -17896,6 +17948,12 @@
"date-format-parse": "^0.2.7"
}
},
"vue2-leaflet": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/vue2-leaflet/-/vue2-leaflet-2.7.1.tgz",
"integrity": "sha512-K7HOlzRhjt3Z7+IvTqEavIBRbmCwSZSCVUlz9u4Rc+3xGCLsHKz4TAL4diAmfHElCQdPPVdZdJk8wPUt2fu6WQ==",
"requires": {}
},
"watchpack": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",

View File

@ -36,6 +36,7 @@
"camelcase": "^7.0.1",
"filerobot-image-editor": "^4.3.7",
"justified-layout": "^4.1.0",
"leaflet": "^1.9.3",
"moment": "^2.29.4",
"path-posix": "^1.0.0",
"photoswipe": "^5.3.4",
@ -47,6 +48,7 @@
"vue-material-design-icons": "^5.1.2",
"vue-router": "^3.6.5",
"vue-virtual-scroller": "1.1.2",
"vue2-leaflet": "^2.7.1",
"webdav": "^4.11.2"
},
"browserslist": [

View File

@ -29,7 +29,7 @@ php -S localhost:8080 &
# Get test photo files
cd data/admin/files
wget https://github.com/pulsejet/memories-test/raw/main/Files.zip
wget https://github.com/pulsejet/memories-assets/raw/main/Files.zip
unzip Files.zip
cd ../../..

View File

@ -20,7 +20,7 @@ mv "exiftool-$exifver" exiftool
rm -rf *.zip exiftool/t exiftool/html
chmod 755 exiftool/exiftool
govod="0.0.24"
govod="0.0.25"
echo "Getting go-vod $govod"
wget -q "https://github.com/pulsejet/go-vod/releases/download/$govod/go-vod-amd64"
wget -q "https://github.com/pulsejet/go-vod/releases/download/$govod/go-vod-aarch64"

View File

@ -23,17 +23,27 @@
</template>
<template #footer>
<NcAppNavigationSettings :title="t('memories', 'Settings')">
<Settings />
</NcAppNavigationSettings>
<NcAppNavigationItem
:title="t('memories', 'Settings')"
@click="showSettings"
>
<CogIcon slot="icon" :size="20" />
</NcAppNavigationItem>
</template>
</NcAppNavigation>
<NcAppContent>
<div class="outer">
<div
:class="{
outer: true,
'remove-gap': removeNavGap,
}"
>
<router-view />
</div>
</NcAppContent>
<Settings :open.sync="settingsOpen" />
</NcContent>
</template>
@ -45,8 +55,6 @@ import NcAppContent from "@nextcloud/vue/dist/Components/NcAppContent";
import NcAppNavigation from "@nextcloud/vue/dist/Components/NcAppNavigation";
const NcAppNavigationItem = () =>
import("@nextcloud/vue/dist/Components/NcAppNavigationItem");
const NcAppNavigationSettings = () =>
import("@nextcloud/vue/dist/Components/NcAppNavigationSettings");
import { generateUrl } from "@nextcloud/router";
import { translate as t } from "@nextcloud/l10n";
@ -64,8 +72,10 @@ import AlbumIcon from "vue-material-design-icons/ImageAlbum.vue";
import ArchiveIcon from "vue-material-design-icons/PackageDown.vue";
import CalendarIcon from "vue-material-design-icons/Calendar.vue";
import PeopleIcon from "vue-material-design-icons/AccountBoxMultiple.vue";
import MarkerIcon from "vue-material-design-icons/MapMarker.vue";
import TagsIcon from "vue-material-design-icons/Tag.vue";
import MapIcon from "vue-material-design-icons/Map.vue";
import CogIcon from "vue-material-design-icons/Cog.vue";
export default defineComponent({
name: "App",
@ -74,7 +84,6 @@ export default defineComponent({
NcAppContent,
NcAppNavigation,
NcAppNavigationItem,
NcAppNavigationSettings,
Timeline,
Settings,
@ -88,13 +97,16 @@ export default defineComponent({
ArchiveIcon,
CalendarIcon,
PeopleIcon,
MarkerIcon,
TagsIcon,
MapIcon,
CogIcon,
},
data: () => ({
navItems: [],
metadataComponent: null as any,
settingsOpen: false,
}),
computed: {
@ -142,6 +154,10 @@ export default defineComponent({
showNavigation(): boolean {
return !this.$route.name?.endsWith("-share");
},
removeNavGap(): boolean {
return this.$route.name === "map";
},
},
watch: {
@ -195,6 +211,25 @@ export default defineComponent({
}
},
async beforeMount() {
if ("serviceWorker" in navigator) {
// Use the window load event to keep the page load performant
window.addEventListener("load", async () => {
try {
const url = generateUrl("/apps/memories/service-worker.js");
const registration = await navigator.serviceWorker.register(url, {
scope: generateUrl("/apps/memories"),
});
console.log("SW registered: ", registration);
} catch (error) {
console.error("SW registration failed: ", error);
}
});
} else {
console.debug("Service Worker is not enabled on this browser.");
}
},
methods: {
navItemsAll() {
return [
@ -246,40 +281,26 @@ export default defineComponent({
icon: CalendarIcon,
title: t("memories", "On this day"),
},
{
name: "places",
icon: MarkerIcon,
title: t("memories", "Places"),
if: this.config_placesGis > 0,
},
{
name: "map",
icon: MapIcon,
title: t("memories", "Map"),
},
{
name: "tags",
icon: TagsIcon,
title: t("memories", "Tags"),
if: this.config_tagsEnabled,
},
{
name: "maps",
icon: MapIcon,
title: t("memories", "Maps"),
if: this.config_mapsEnabled,
},
];
},
async beforeMount() {
if ("serviceWorker" in navigator) {
// Use the window load event to keep the page load performant
window.addEventListener("load", async () => {
try {
const url = generateUrl("/apps/memories/service-worker.js");
const registration = await navigator.serviceWorker.register(url, {
scope: generateUrl("/apps/memories"),
});
console.log("SW registered: ", registration);
} catch (error) {
console.error("SW registration failed: ", error);
}
});
} else {
console.debug("Service Worker is not enabled on this browser.");
}
},
linkClick() {
const nav: any = this.$refs.nav;
if (globalThis.windowInnerWidth <= 1024) nav?.toggleNavigation(false);
@ -308,6 +329,10 @@ export default defineComponent({
tokenInput.value = token;
},
showSettings() {
this.settingsOpen = true;
},
},
});
</script>
@ -317,6 +342,10 @@ export default defineComponent({
padding: 0 0 0 44px;
height: 100%;
width: 100%;
&.remove-gap {
padding: 0;
}
}
@media (max-width: 768px) {

View File

@ -87,7 +87,6 @@ export default defineComponent({
fileInfo: null as IFileInfo,
exif: {} as { [prop: string]: any },
baseInfo: {} as any,
nominatim: null as any,
state: 0,
}),
@ -245,22 +244,7 @@ export default defineComponent({
},
address(): string | null {
if (!this.lat || !this.lon) return null;
if (!this.nominatim) return this.t("memories", "Loading …");
const n = this.nominatim;
const country = n.address.country_code?.toUpperCase();
if (n.address?.city && n.address.state) {
return `${n.address.city}, ${n.address.state}, ${country}`;
} else if (n.address?.state) {
return `${n.address.state}, ${country}`;
} else if (n.address?.country) {
return n.address.country;
} else {
return n.display_name;
}
return this.baseInfo.address;
},
lat(): number {
@ -293,7 +277,6 @@ export default defineComponent({
this.state = Math.random();
this.fileInfo = fileInfo;
this.exif = {};
this.nominatim = null;
const state = this.state;
const url = API.IMAGE_INFO(fileInfo.id);
@ -302,9 +285,6 @@ export default defineComponent({
this.baseInfo = res.data;
this.exif = res.data.exif || {};
// Lazy loading
this.getNominatim().catch();
},
handleFileUpdated({ fileid }) {
@ -312,19 +292,6 @@ export default defineComponent({
this.update(this.fileInfo);
}
},
async getNominatim() {
const lat = this.lat;
const lon = this.lon;
if (!lat || !lon) return null;
const state = this.state;
const n = await axios.get(
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=18`
);
if (state !== this.state) return;
this.nominatim = n.data;
},
},
});
</script>

View File

@ -868,11 +868,7 @@ export default defineComponent({
/** Open viewer with given photo */
openViewer(photo: IPhoto) {
this.$router.push({
path: this.$route.path,
query: this.$route.query,
hash: utils.getViewerHash(photo),
});
this.$router.push(utils.getViewerRoute(photo));
},
},
});
@ -882,10 +878,10 @@ export default defineComponent({
.top-bar {
position: absolute;
top: 10px;
right: 60px;
right: min(60px, 10%);
padding: 8px;
width: 400px;
max-width: 100vw;
max-width: 80%;
background-color: var(--color-main-background);
box-shadow: 0 0 2px gray;
border-radius: 10px;
@ -907,6 +903,7 @@ export default defineComponent({
right: unset;
position: fixed;
width: 100vw;
max-width: 100vw;
border-radius: 0px;
opacity: 1;
padding-top: 3px;

View File

@ -22,6 +22,16 @@
<template>
<div>
<NcAppSettingsDialog
:open="open"
:show-navigation="true"
:title="t('memories', 'Memories Settings')"
@update:open="onClose"
>
<NcAppSettingsSection
id="general-settings"
:title="t('memories', 'General')"
>
<label for="timeline-path">{{ t("memories", "Timeline Path") }}</label>
<input
id="timeline-path"
@ -30,6 +40,27 @@
type="text"
/>
<NcCheckboxRadioSwitch
:checked.sync="config_squareThumbs"
@update:checked="updateSquareThumbs"
type="switch"
>
{{ t("memories", "Square grid mode") }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch
:checked.sync="config_enableTopMemories"
@update:checked="updateEnableTopMemories"
type="switch"
>
{{ t("memories", "Show past photos on top of timeline") }}
</NcCheckboxRadioSwitch>
</NcAppSettingsSection>
<NcAppSettingsSection
id="folders-settings"
:title="t('memories', 'Folders')"
>
<label for="folders-path">{{ t("memories", "Folders Path") }}</label>
<input
id="folders-path"
@ -45,14 +76,8 @@
>
{{ t("memories", "Show hidden folders") }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch
:checked.sync="config_squareThumbs"
@update:checked="updateSquareThumbs"
type="switch"
>
{{ t("memories", "Square grid mode") }}
</NcCheckboxRadioSwitch>
</NcAppSettingsSection>
</NcAppSettingsDialog>
<MultiPathSelectionModal
ref="multiPathModal"
@ -72,6 +97,10 @@ input[type="text"] {
import { defineComponent } from "vue";
import { getFilePickerBuilder } from "@nextcloud/dialogs";
const NcAppSettingsDialog = () =>
import("@nextcloud/vue/dist/Components/NcAppSettingsDialog");
const NcAppSettingsSection = () =>
import("@nextcloud/vue/dist/Components/NcAppSettingsSection");
const NcCheckboxRadioSwitch = () =>
import("@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch");
@ -81,10 +110,19 @@ export default defineComponent({
name: "Settings",
components: {
NcAppSettingsDialog,
NcAppSettingsSection,
NcCheckboxRadioSwitch,
MultiPathSelectionModal,
},
props: {
open: {
type: Boolean,
required: true,
},
},
computed: {
pathSelTitle(): string {
return this.t("memories", "Choose Timeline Paths");
@ -92,6 +130,10 @@ export default defineComponent({
},
methods: {
onClose() {
this.$emit("update:open", false);
},
async chooseFolder(title: string, initial: string) {
const picker = getFilePickerBuilder(title)
.setMultiSelect(false)
@ -137,6 +179,10 @@ export default defineComponent({
await this.updateSetting("squareThumbs");
},
async updateEnableTopMemories() {
await this.updateSetting("enableTopMemories");
},
async updateShowHidden() {
await this.updateSetting("showHidden");
},

View File

@ -0,0 +1,68 @@
<template>
<div class="container">
<div class="primary">
<component :is="primary" />
</div>
<div class="timeline">
<Timeline />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import Timeline from "./Timeline.vue";
const MapSplitMatter = () => import("./top-matter/MapSplitMatter.vue");
export default defineComponent({
name: "SplitTimeline",
components: {
Timeline,
},
computed: {
primary() {
switch (this.$route.name) {
case "map":
return MapSplitMatter;
default:
return "None";
}
},
},
});
</script>
<style lang="scss" scoped>
.container {
width: 100%;
height: 100%;
display: flex;
overflow: hidden;
}
.primary {
width: 60%;
height: 100%;
}
.timeline {
flex: 1;
height: 100%;
padding-left: 8px;
}
@media (max-width: 768px) {
.container {
flex-direction: column;
}
.primary {
width: 100%;
height: 40%;
}
.timeline {
width: 100%;
height: 60%;
padding-left: 0;
}
}
</style>

View File

@ -290,6 +290,8 @@ export default defineComponent({
return this.t("memories", "On this day");
case "tags":
return this.t("memories", "Tags");
case "places":
return this.t("memories", "Places");
default:
return "";
}
@ -323,18 +325,21 @@ export default defineComponent({
methods: {
async routeChange(to: any, from?: any) {
if (
from?.path !== to.path ||
JSON.stringify(from.query) !== JSON.stringify(to.query)
) {
// Always do a hard refresh if the path changes
if (from?.path !== to.path) {
await this.refresh();
}
// Do a soft refresh if the query changes
else if (JSON.stringify(from.query) !== JSON.stringify(to.query)) {
await this.softRefresh();
}
// The viewer might change the route immediately again
await this.$nextTick();
// Check if hash has changed
const viewerIsOpen = (this.$refs.viewer as any).isOpen;
const viewerIsOpen = (this.$refs.viewer as any)?.isOpen;
if (
from?.hash !== to.hash &&
to.hash?.startsWith("#v") &&
@ -390,11 +395,11 @@ export default defineComponent({
},
isMobileLayout() {
return globalThis.windowInnerWidth <= 600;
return globalThis.windowInnerWidth <= 600 || this.$route.name === "map";
},
allowBreakout() {
return this.isMobileLayout() && !this.config_squareThumbs;
return globalThis.windowInnerWidth <= 600 && !this.config_squareThumbs;
},
/** Create new state */
@ -442,6 +447,7 @@ export default defineComponent({
/** Re-process days */
async softRefresh() {
this.selectionManager.clearSelection();
this.fetchDayQueue = []; // reset queue
await this.fetchDays(true);
},
@ -647,6 +653,13 @@ export default defineComponent({
query.set("archive", "1");
}
// Albums
if (this.$route.name === "albums" && this.$route.params.name) {
const user = <string>this.$route.params.user;
const name = <string>this.$route.params.name;
query.set("album", `${user}/${name}`);
}
// People
if (
this.routeIsPeople &&
@ -664,16 +677,20 @@ export default defineComponent({
}
}
// Places
if (this.$route.name === "places" && this.$route.params.name) {
const name = <string>this.$route.params.name;
query.set("place", <string>name.split("-", 1)[0]);
}
// Tags
if (this.$route.name === "tags" && this.$route.params.name) {
query.set("tag", <string>this.$route.params.name);
}
// Albums
if (this.$route.name === "albums" && this.$route.params.name) {
const user = <string>this.$route.params.user;
const name = <string>this.$route.params.name;
query.set("album", `${user}/${name}`);
// Map Bounds
if (this.$route.name === "map" && this.$route.query.b) {
query.set("mapbounds", <string>this.$route.query.b);
}
// Month view
@ -731,12 +748,14 @@ export default defineComponent({
let data: IDay[] = [];
if (this.$route.name === "thisday") {
data = await dav.getOnThisDayData();
} else if (this.$route.name === "tags" && !this.$route.params.name) {
data = await dav.getTagsData();
} else if (this.routeIsPeople && !this.$route.params.name) {
data = await dav.getPeopleData(this.$route.name as any);
} else if (this.$route.name === "albums" && !this.$route.params.name) {
data = await dav.getAlbumsData("3");
} else if (this.routeIsPeople && !this.$route.params.name) {
data = await dav.getPeopleData(this.$route.name as any);
} else if (this.$route.name === "places" && !this.$route.params.name) {
data = await dav.getPlacesData();
} else if (this.$route.name === "tags" && !this.$route.params.name) {
data = await dav.getTagsData();
} else {
// Try the cache
try {
@ -922,7 +941,8 @@ export default defineComponent({
if (this.fetchDayQueue.length === 0) return;
// Construct URL
const url = this.getDayUrl(this.fetchDayQueue.join(","));
const dayStr = this.fetchDayQueue.join(",");
const url = this.getDayUrl(dayStr);
this.fetchDayQueue = [];
try {
@ -930,7 +950,11 @@ export default defineComponent({
const res = await axios.get<IPhoto[]>(url);
if (res.status !== 200) throw res;
const data = res.data;
if (this.state !== startState) return;
// Check if the state has changed
if (this.state !== startState || this.getDayUrl(dayStr) !== url) {
return;
}
// Bin the data into separate days
// It is already sorted in dayid DESC
@ -954,7 +978,7 @@ export default defineComponent({
for (const [dayId, photos] of dayMap) {
// Check if the response has any delta
const head = this.heads[dayId];
if (head.day.detail?.length) {
if (head?.day?.detail?.length) {
if (
head.day.detail.length === photos.length &&
head.day.detail.every(
@ -1297,6 +1321,7 @@ export default defineComponent({
width: 100%;
overflow: hidden;
user-select: none;
position: relative;
* {
-webkit-tap-highlight-color: transparent;
@ -1308,7 +1333,7 @@ export default defineComponent({
will-change: scroll-position;
contain: strict;
height: 300px;
width: calc(100% + 20px);
width: 100%;
transition: opacity 0.2s ease-in-out;
:deep .vue-recycle-scroller__slot {
@ -1326,6 +1351,7 @@ export default defineComponent({
&.empty {
opacity: 0;
transition: none;
width: 0;
}
}

View File

@ -59,13 +59,17 @@ export default defineComponent({
computed: {
previewUrl() {
if (this.album) {
const mock = { fileid: this.album.last_added_photo, etag: "", flag: 0 };
return getPreviewUrl(mock, true, 512);
}
if (this.face) {
return API.FACE_PREVIEW(this.faceApp, this.face.fileid);
}
if (this.album) {
const mock = { fileid: this.album.last_added_photo, etag: "", flag: 0 };
return getPreviewUrl(mock, true, 512);
if (this.place) {
return API.PLACE_PREVIEW(this.place.fileid);
}
return API.TAG_PREVIEW(this.data.name);
@ -92,6 +96,10 @@ export default defineComponent({
: "recognize";
},
place() {
return this.data.flag & constants.c.FLAG_IS_PLACE ? this.data : null;
},
album() {
return this.data.flag & constants.c.FLAG_IS_ALBUM
? <IAlbum>this.data
@ -102,16 +110,23 @@ export default defineComponent({
target() {
if (this.noNavigate) return {};
if (this.album) {
const user = this.album.user;
const name = this.album.name;
return { name: "albums", params: { user, name } };
}
if (this.face) {
const name = this.face.name || this.face.fileid.toString();
const user = this.face.user_id;
return { name: this.faceApp, params: { name, user } };
}
if (this.album) {
const user = this.album.user;
const name = this.album.name;
return { name: "albums", params: { user, name } };
if (this.place) {
const id = this.place.fileid.toString();
const placeName = this.place.name || id;
const name = `${id}-${placeName}`;
return { name: "places", params: { name } };
}
return { name: "tags", params: { name: this.data.name } };
@ -166,7 +181,7 @@ img {
font-size: 1.1em;
word-wrap: break-word;
text-overflow: ellipsis;
line-height: 1em;
line-height: 1.2em;
> .subtitle {
font-size: 0.7em;

View File

@ -0,0 +1,236 @@
<template>
<div class="map-matter">
<LMap
class="map"
ref="map"
:crossOrigin="true"
:zoom="zoom"
:minZoom="2"
@moveend="refresh"
@zoomend="refresh"
>
<LTileLayer :url="tileurl" :attribution="attribution" />
<LMarker
v-for="cluster in clusters"
:key="cluster.id"
:lat-lng="cluster.center"
@click="zoomTo(cluster)"
>
<LIcon :icon-anchor="[24, 24]">
<div class="preview">
<div class="count" v-if="cluster.count > 1">
{{ cluster.count }}
</div>
<img
:src="clusterPreviewUrl(cluster)"
:class="[
'thumb-important',
`memories-thumb-${cluster.preview.fileid}`,
]"
/>
</div>
</LIcon>
</LMarker>
</LMap>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { LMap, LTileLayer, LMarker, LPopup, LIcon } from "vue2-leaflet";
import { IPhoto } from "../../types";
import { API } from "../../services/API";
import { getPreviewUrl } from "../../services/FileUtils";
import axios from "@nextcloud/axios";
import * as utils from "../../services/Utils";
import "leaflet/dist/leaflet.css";
const OSM_TILE_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
const OSM_ATTRIBUTION =
'&copy; <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors';
const STAMEN_URL = `https://stamen-tiles-{s}.a.ssl.fastly.net/terrain-background/{z}/{x}/{y}{r}.png`;
const STAMEN_ATTRIBUTION = `Map tiles by <a href="http://stamen.com">Stamen Design</a>, under <a href="http://creativecommons.org/licenses/by/3.0">CC BY 3.0</a>. Data by <a href="http://openstreetmap.org">OpenStreetMap</a>, under <a href="http://www.openstreetmap.org/copyright">ODbL</a>.`;
type IMarkerCluster = {
id?: number;
u?: any;
center: [number, number];
count: number;
preview?: IPhoto;
};
export default defineComponent({
name: "MapSplitMatter",
components: {
LMap,
LTileLayer,
LMarker,
LPopup,
LIcon,
},
data: () => ({
zoom: 2,
clusters: [] as IMarkerCluster[],
}),
mounted() {
const map = this.$refs.map as LMap;
// Make sure the zoom control doesn't overlap with the navbar
map.mapObject.zoomControl.setPosition("topright");
// Initialize
this.refresh();
},
computed: {
tileurl() {
return this.zoom >= 5 ? OSM_TILE_URL : STAMEN_URL;
},
attribution() {
return this.zoom >= 5 ? OSM_ATTRIBUTION : STAMEN_ATTRIBUTION;
},
},
methods: {
async refresh() {
const map = this.$refs.map as LMap;
// Get boundaries of the map
const boundary = map.mapObject.getBounds();
const minLat = boundary.getSouth();
const maxLat = boundary.getNorth();
const minLon = boundary.getWest();
const maxLon = boundary.getEast();
// Set query parameters to route if required
const s = (x: number) => x.toFixed(6);
const bounds = `${s(minLat)},${s(maxLat)},${s(minLon)},${s(maxLon)}`;
this.zoom = Math.round(map.mapObject.getZoom());
const zoom = this.zoom.toString();
if (this.$route.query.b === bounds && this.$route.query.z === zoom) {
return;
}
this.$router.replace({ query: { b: bounds, z: zoom } });
// Show clusters correctly while draging the map
const query = new URLSearchParams();
query.set("bounds", bounds);
query.set("zoom", zoom);
// Make API call
const url = API.Q(API.MAP_CLUSTERS(), query);
const res = await axios.get(url);
this.clusters = res.data;
},
clusterPreviewUrl(cluster: IMarkerCluster) {
let url = getPreviewUrl(cluster.preview, false, 256);
if (cluster.u) {
url += `?u=${cluster.u}`;
}
return url;
},
zoomTo(cluster: IMarkerCluster) {
// At high zoom levels, open the photo
if (this.zoom >= 12 && cluster.preview) {
cluster.preview.key = cluster.preview.fileid.toString();
this.$router.push(utils.getViewerRoute(cluster.preview));
return;
}
// Zoom in
const map = this.$refs.map as LMap;
const factor = globalThis.innerWidth >= 768 ? 2 : 1;
const zoom = map.mapObject.getZoom() + factor;
map.mapObject.setView(cluster.center, zoom, { animate: true });
},
},
});
</script>
<style lang="scss" scoped>
.map-matter {
height: 100%;
width: 100%;
}
.map {
height: 100%;
width: 100%;
margin: 0;
z-index: 0;
}
.preview {
width: 48px;
height: 48px;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 5px;
position: relative;
transition: transform 0.2s;
&:hover {
transform: scale(1.8);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 5px;
cursor: pointer;
}
.count {
position: absolute;
top: 0;
right: 0;
background-color: var(--color-primary-default);
color: var(--color-primary-text);
padding: 0 4px;
border-radius: 5px;
font-size: 0.8em;
}
}
</style>
<style lang="scss">
.leaflet-marker-icon {
animation: fade-in 0.2s;
}
// Show leaflet marker on top on hover
.leaflet-marker-icon:hover {
z-index: 100000 !important;
}
// Dark mode
$darkFilter: invert(1) grayscale(1) brightness(1.3) contrast(1.3);
.leaflet-tile-pane {
body[data-theme-dark] &,
body[data-theme-dark-highcontrast] & {
filter: $darkFilter;
}
@media (prefers-color-scheme: dark) {
body[data-theme-default] & {
filter: $darkFilter;
}
}
}
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
</style>

View File

@ -101,6 +101,9 @@ export default defineComponent({
},
async refresh() {
// Skip if disabled
if (!this.config_enableTopMemories) return;
// Look for cache
const dayIdToday = utils.dateToDayId(new Date());
const cacheUrl = `/onthisday/${dayIdToday}`;

View File

@ -43,10 +43,14 @@ export default defineComponent({
methods: {
createMatter() {
this.name = <string>this.$route.params.name || "";
if (this.$route.name === "places") {
this.name = this.name.split("-").slice(1).join("-");
}
},
back() {
this.$router.push({ name: "tags" });
this.$router.push({ name: this.$route.name });
},
},
});

View File

@ -58,6 +58,10 @@ export default defineComponent({
: TopMatterType.NONE;
case "albums":
return TopMatterType.ALBUM;
case "places":
return this.$route.params.name
? TopMatterType.TAG
: TopMatterType.NONE;
default:
return TopMatterType.NONE;
}

View File

@ -14,6 +14,10 @@ const config_noTranscode = loadState(
<string>"UNSET"
) as boolean | string;
const config_video_default_quality = Number(
loadState("memories", "video_default_quality", <string>"0") as string
);
/**
* Check if slide has video content
*
@ -271,26 +275,31 @@ class VideoContentSetup {
let qualityNums: number[];
if (qualityList && qualityList.length > 1) {
const s = new Set<number>();
let hasMax = false;
for (let i = 0; i < qualityList?.length; i++) {
const { width, height, label } = qualityList[i];
s.add(Math.min(width, height));
if (label?.includes("max.m3u8")) {
s.add(999999999);
hasMax = true;
}
}
qualityNums = Array.from(s).sort((a, b) => b - a);
qualityNums.unshift(0);
if (hasMax) {
qualityNums.unshift(-1);
}
qualityNums.unshift(-2);
}
// Create the plyr instance
const opts: Plyr.Options = {
i18n: {
qualityLabel: {
"-1": t("memories", "Direct"),
0: t("memories", "Auto"),
999999999: t("memories", "Original"),
"-2": t("memories", "Direct"),
"-1": t("memories", "Original"),
"0": t("memories", "Auto"),
},
},
fullscreen: {
@ -304,14 +313,14 @@ class VideoContentSetup {
if (qualityNums) {
opts.quality = {
default: 0,
default: config_video_default_quality,
options: qualityNums,
forced: true,
onChange: (quality: number) => {
qualityList = content.videojs?.qualityLevels();
if (!qualityList || !content.videojs) return;
if (quality === -1) {
if (quality === -2) {
// Direct playback
// Prevent any useless transcodes
for (let i = 0; i < qualityList.length; ++i) {
@ -340,7 +349,7 @@ class VideoContentSetup {
qualityList[i].enabled =
!quality || // auto
pixels === quality || // exact match
(label?.includes("max.m3u8") && quality === 999999999); // max
(label?.includes("max.m3u8") && quality === -1); // max
}
},
};

View File

@ -789,11 +789,19 @@ export default defineComponent({
/** Get element for thumbnail if it exists */
thumbElem(photo: IPhoto): HTMLImageElement | undefined {
if (!photo) return;
const elems = document.querySelectorAll(`.memories-thumb-${photo.key}`);
const elems = Array.from(
document.querySelectorAll(`.memories-thumb-${photo.key}`)
);
if (elems.length === 0) return;
if (elems.length === 1) return elems[0] as HTMLImageElement;
// Find if any element has the thumb-important class
const important = elems.filter((e) =>
e.classList.contains("thumb-important")
);
if (important.length > 0) return important[0] as HTMLImageElement;
// Find element within 500px of the screen top
let elem: HTMLImageElement;
elems.forEach((e) => {

View File

@ -87,3 +87,18 @@ aside.app-sidebar {
transform: scale(1.05);
}
}
// Hide scrollbar
.recycler::-webkit-scrollbar {
display: none;
width: 0 !important;
}
.recycler {
scrollbar-width: none;
-ms-overflow-style: none;
}
// Make sure empty content is full width
[role="note"].empty-content {
width: 100%;
}

View File

@ -21,8 +21,11 @@ export default defineComponent({
"foldersPath",
<string>"/"
) as string,
config_showHidden:
loadState("memories", "showHidden", <string>"false") === "true",
config_enableTopMemories:
loadState("memories", "enableTopMemories", <string>"false") === "true",
config_tagsEnabled: Boolean(
loadState("memories", "systemtags", <string>"")
@ -36,9 +39,10 @@ export default defineComponent({
config_facerecognitionEnabled: Boolean(
loadState("memories", "facerecognitionEnabled", <string>"")
),
config_mapsEnabled: Boolean(loadState("memories", "maps", <string>"")),
config_albumsEnabled: Boolean(loadState("memories", "albums", <string>"")),
config_placesGis: Number(loadState("memories", "places_gis", <string>"-1")),
config_squareThumbs: localStorage.getItem("memories_squareThumbs") === "1",
config_showFaceRect: localStorage.getItem("memories_showFaceRect") === "1",

View File

@ -3,6 +3,7 @@ import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import Router from "vue-router";
import Vue from "vue";
import Timeline from "./components/Timeline.vue";
import SplitTimeline from "./components/SplitTimeline.vue";
Vue.use(Router);
@ -94,6 +95,15 @@ export default new Router({
}),
},
{
path: "/places/:name*",
component: Timeline,
name: "places",
props: (route) => ({
rootTitle: t("memories", "Places"),
}),
},
{
path: "/tags/:name*",
component: Timeline,
@ -129,5 +139,14 @@ export default new Router({
rootTitle: t("memories", "Shared Album"),
}),
},
{
path: "/map",
component: SplitTimeline,
name: "map",
props: (route) => ({
rootTitle: t("memories", "Map"),
}),
},
],
});

View File

@ -47,6 +47,14 @@ export class API {
return gen(`${BASE}/albums/download?name={user}/{name}`, { user, name });
}
static PLACE_LIST() {
return gen(`${BASE}/places`);
}
static PLACE_PREVIEW(place: number | string) {
return gen(`${BASE}/places/preview/{place}`, { place });
}
static TAG_LIST() {
return gen(`${BASE}/tags`);
}
@ -114,4 +122,12 @@ export class API {
static CONFIG(setting: string) {
return gen(`${BASE}/config/{setting}`, { setting });
}
static MAP_CLUSTERS() {
return tok(gen(`${BASE}/map/clusters`));
}
static MAP_CLUSTER_PREVIEW(id: number) {
return tok(gen(`${BASE}/map/clusters/preview/{id}`, { id }));
}
}

View File

@ -8,3 +8,4 @@ export * from "./dav/folders";
export * from "./dav/onthisday";
export * from "./dav/tags";
export * from "./dav/other";
export * from "./dav/places";

View File

@ -220,6 +220,10 @@ export function convertFlags(photo: IPhoto) {
}
delete photo.isface;
}
if (photo.isplace) {
photo.flag |= constants.c.FLAG_IS_PLACE;
delete photo.isplace;
}
if (photo.istag) {
photo.flag |= constants.c.FLAG_IS_TAG;
delete photo.istag;
@ -283,6 +287,18 @@ export function getViewerHash(photo: IPhoto) {
return `#v/${photo.dayid}/${photo.key}`;
}
/**
* Get route for viewer for photo
*/
export function getViewerRoute(photo: IPhoto) {
const $route = globalThis.vueroute();
return {
path: $route.path,
query: $route.query,
hash: getViewerHash(photo),
};
}
/** Set a timer that renews if existing */
export function setRenewingTimeout(
ctx: any,
@ -314,12 +330,13 @@ export const constants = {
FLAG_IS_VIDEO: 1 << 2,
FLAG_IS_FAVORITE: 1 << 3,
FLAG_IS_FOLDER: 1 << 4,
FLAG_IS_TAG: 1 << 5,
FLAG_IS_ALBUM: 1 << 5,
FLAG_IS_FACE_RECOGNIZE: 1 << 6,
FLAG_IS_FACE_RECOGNITION: 1 << 7,
FLAG_IS_ALBUM: 1 << 8,
FLAG_SELECTED: 1 << 9,
FLAG_LEAVING: 1 << 10,
FLAG_IS_PLACE: 1 << 8,
FLAG_IS_TAG: 1 << 9,
FLAG_SELECTED: 1 << 10,
FLAG_LEAVING: 1 << 11,
},
TagDayID: TagDayID,

View File

@ -4,9 +4,9 @@ import { showError } from "@nextcloud/dialogs";
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import { IAlbum, IDay, IFileInfo, IPhoto, ITag } from "../../types";
import { constants } from "../Utils";
import { API } from "../API";
import axios from "@nextcloud/axios";
import client from "../DavClient";
import { API } from "../API";
/**
* Get DAV path for album

View File

@ -19,7 +19,6 @@ export async function getPeopleData(
id: number;
count: number;
name: string;
previews: IPhoto[];
}[] = [];
try {
const res = await axios.get<typeof data>(API.FACE_LIST(app));
@ -28,9 +27,6 @@ export async function getPeopleData(
throw e;
}
// Add flag to previews
data.forEach((t) => t.previews?.forEach((preview) => (preview.flag = 0)));
// Convert to days response
return [
{

View File

@ -0,0 +1,41 @@
import { IDay, IPhoto, ITag } from "../../types";
import { constants } from "../Utils";
import { API } from "../API";
import axios from "@nextcloud/axios";
/**
* Get list of tags and convert to Days response
*/
export async function getPlacesData(): Promise<IDay[]> {
// Query for photos
let data: {
osm_id: number;
count: number;
name: string;
}[] = [];
try {
const res = await axios.get<typeof data>(API.PLACE_LIST());
data = res.data;
} catch (e) {
throw e;
}
// Convert to days response
return [
{
dayid: constants.TagDayID.TAGS,
count: data.length,
detail: data.map(
(tag) =>
({
...tag,
id: tag.osm_id,
fileid: tag.osm_id,
flag: constants.c.FLAG_IS_TAG,
istag: true,
isplace: true,
} as ITag)
),
},
];
}

View File

@ -1,7 +1,7 @@
import { IDay, IPhoto, ITag } from "../../types";
import { constants, hashCode } from "../Utils";
import axios from "@nextcloud/axios";
import { API } from "../API";
import axios from "@nextcloud/axios";
/**
* Get list of tags and convert to Days response
@ -12,7 +12,6 @@ export async function getTagsData(): Promise<IDay[]> {
id: number;
count: number;
name: string;
previews: IPhoto[];
}[] = [];
try {
const res = await axios.get<typeof data>(API.TAG_LIST());
@ -21,9 +20,6 @@ export async function getTagsData(): Promise<IDay[]> {
throw e;
}
// Add flag to previews
data.forEach((t) => t.previews?.forEach((preview) => (preview.flag = 0)));
// Convert to days response
return [
{

View File

@ -76,6 +76,7 @@ export type IPhoto = {
h: number;
w: number;
datetaken: number;
address?: string;
exif?: {
Rotation?: number;
Orientation?: number;
@ -104,6 +105,8 @@ export type IPhoto = {
isalbum?: boolean;
/** Is this a face */
isface?: "recognize" | "facerecognition";
/** Is this a place */
isplace?: boolean;
/** Optional datetaken epoch */
datetaken?: number;
};

View File

@ -21,9 +21,10 @@ declare module "vue" {
config_recognizeEnabled: boolean;
config_facerecognitionInstalled: boolean;
config_facerecognitionEnabled: boolean;
config_mapsEnabled: boolean;
config_albumsEnabled: boolean;
config_placesGis: number;
config_squareThumbs: boolean;
config_enableTopMemories: boolean;
config_showFaceRect: boolean;
config_eventName: string;