Merge branch 'master' into stable24
commit
cac12462d0
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -63,28 +63,23 @@ class PageController extends Controller
|
|||
|
||||
// Configuration
|
||||
$uid = $user->getUID();
|
||||
$this->initialState->provideInitialState('timelinePath', $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
|
||||
));
|
||||
$pi = function ($key, $default) use ($uid) {
|
||||
$this->initialState->provideInitialState($key, $this->config->getUserValue(
|
||||
$uid,
|
||||
Application::APPNAME,
|
||||
$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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}";
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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,40 +161,64 @@ class TimelineWrite
|
|||
$exifJson = json_encode(['error' => 'Exif data encoding error']);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
$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),
|
||||
'datetaken' => $query->createNamedParameter($dateTaken, IQueryBuilder::PARAM_STR),
|
||||
'mtime' => $query->createNamedParameter($mtime, IQueryBuilder::PARAM_INT),
|
||||
'isvideo' => $query->createNamedParameter($isvideo, IQueryBuilder::PARAM_INT),
|
||||
'video_duration' => $query->createNamedParameter($videoDuration, IQueryBuilder::PARAM_INT),
|
||||
'w' => $query->createNamedParameter($w, IQueryBuilder::PARAM_INT),
|
||||
'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')
|
||||
->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)))
|
||||
;
|
||||
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([
|
||||
'fileid' => $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT),
|
||||
'objectid' => $query->createNamedParameter((string) $fileId, IQueryBuilder::PARAM_STR),
|
||||
'dayid' => $query->createNamedParameter($dayId, IQueryBuilder::PARAM_INT),
|
||||
'datetaken' => $query->createNamedParameter($dateTaken, IQueryBuilder::PARAM_STR),
|
||||
'mtime' => $query->createNamedParameter($mtime, IQueryBuilder::PARAM_INT),
|
||||
'isvideo' => $query->createNamedParameter($isvideo, IQueryBuilder::PARAM_INT),
|
||||
'video_duration' => $query->createNamedParameter($videoDuration, IQueryBuilder::PARAM_INT),
|
||||
'w' => $query->createNamedParameter($w, IQueryBuilder::PARAM_INT),
|
||||
'h' => $query->createNamedParameter($h, IQueryBuilder::PARAM_INT),
|
||||
'exif' => $query->createNamedParameter($exifJson, IQueryBuilder::PARAM_STR),
|
||||
'liveid' => $query->createNamedParameter($liveid, IQueryBuilder::PARAM_STR),
|
||||
])
|
||||
;
|
||||
$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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
12
lib/Exif.php
12
lib/Exif.php
|
@ -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();
|
||||
}
|
||||
$dateTaken = $file->getMtime();
|
||||
|
||||
return self::forgetTimezone($dateTaken);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
{
|
||||
}
|
||||
}
|
|
@ -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
|
||||
{
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": [
|
||||
|
|
|
@ -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 ../../..
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
93
src/App.vue
93
src/App.vue
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
|
@ -368,4 +335,4 @@ export default defineComponent({
|
|||
min-height: 200px;
|
||||
max-height: 250px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -22,37 +22,62 @@
|
|||
|
||||
<template>
|
||||
<div>
|
||||
<label for="timeline-path">{{ t("memories", "Timeline Path") }}</label>
|
||||
<input
|
||||
id="timeline-path"
|
||||
@click="chooseTimelinePath"
|
||||
v-model="config_timelinePath"
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<label for="folders-path">{{ t("memories", "Folders Path") }}</label>
|
||||
<input
|
||||
id="folders-path"
|
||||
@click="chooseFoldersPath"
|
||||
v-model="config_foldersPath"
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<NcCheckboxRadioSwitch
|
||||
:checked.sync="config_showHidden"
|
||||
@update:checked="updateShowHidden"
|
||||
type="switch"
|
||||
<NcAppSettingsDialog
|
||||
:open="open"
|
||||
:show-navigation="true"
|
||||
:title="t('memories', 'Memories Settings')"
|
||||
@update:open="onClose"
|
||||
>
|
||||
{{ t("memories", "Show hidden folders") }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<NcAppSettingsSection
|
||||
id="general-settings"
|
||||
:title="t('memories', 'General')"
|
||||
>
|
||||
<label for="timeline-path">{{ t("memories", "Timeline Path") }}</label>
|
||||
<input
|
||||
id="timeline-path"
|
||||
@click="chooseTimelinePath"
|
||||
v-model="config_timelinePath"
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<NcCheckboxRadioSwitch
|
||||
:checked.sync="config_squareThumbs"
|
||||
@update:checked="updateSquareThumbs"
|
||||
type="switch"
|
||||
>
|
||||
{{ t("memories", "Square grid mode") }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<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"
|
||||
@click="chooseFoldersPath"
|
||||
v-model="config_foldersPath"
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<NcCheckboxRadioSwitch
|
||||
:checked.sync="config_showHidden"
|
||||
@update:checked="updateShowHidden"
|
||||
type="switch"
|
||||
>
|
||||
{{ t("memories", "Show hidden folders") }}
|
||||
</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,9 +179,13 @@ export default defineComponent({
|
|||
await this.updateSetting("squareThumbs");
|
||||
},
|
||||
|
||||
async updateEnableTopMemories() {
|
||||
await this.updateSetting("enableTopMemories");
|
||||
},
|
||||
|
||||
async updateShowHidden() {
|
||||
await this.updateSetting("showHidden");
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 =
|
||||
'© <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>
|
|
@ -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}`;
|
||||
|
|
|
@ -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 });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -66,4 +70,4 @@ export default defineComponent({
|
|||
display: inline-block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
@ -65,4 +69,4 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
@ -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);
|
||||
qualityNums.unshift(-1);
|
||||
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
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
||||
|
|
|
@ -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"),
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
@ -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 }));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,3 +8,4 @@ export * from "./dav/folders";
|
|||
export * from "./dav/onthisday";
|
||||
export * from "./dav/tags";
|
||||
export * from "./dav/other";
|
||||
export * from "./dav/places";
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 [
|
||||
{
|
||||
|
|
|
@ -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)
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
|
@ -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 [
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
Loading…
Reference in New Issue