old-stable24
Varun Patil 2022-10-19 10:10:36 -07:00
parent 37a725ce1b
commit 26cb158b2e
17 changed files with 1084 additions and 912 deletions

View File

@ -4,9 +4,7 @@ declare(strict_types=1);
/** /**
* @copyright Copyright (c) 2022, Varun Patil <radialapps@gmail.com> * @copyright Copyright (c) 2022, Varun Patil <radialapps@gmail.com>
*
* @author Varun Patil <radialapps@gmail.com> * @author Varun Patil <radialapps@gmail.com>
*
* @license AGPL-3.0-or-later * @license AGPL-3.0-or-later
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
@ -21,22 +19,22 @@ declare(strict_types=1);
* *
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/ */
namespace OCA\Memories\AppInfo; namespace OCA\Memories\AppInfo;
use OCA\Memories\Listeners\PostWriteListener;
use OCA\Memories\Listeners\PostDeleteListener; use OCA\Memories\Listeners\PostDeleteListener;
use OCA\Memories\Listeners\PostWriteListener;
use OCP\AppFramework\App; use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\Files\Events\Node\NodeWrittenEvent;
use OCP\Files\Events\Node\NodeDeletedEvent; use OCP\Files\Events\Node\NodeDeletedEvent;
use OCP\Files\Events\Node\NodeTouchedEvent; use OCP\Files\Events\Node\NodeTouchedEvent;
use OCP\Files\Events\Node\NodeWrittenEvent;
class Application extends App implements IBootstrap { class Application extends App implements IBootstrap
{
public const APPNAME = 'memories'; public const APPNAME = 'memories';
public const IMAGE_MIMES = [ public const IMAGE_MIMES = [
@ -61,16 +59,19 @@ class Application extends App implements IBootstrap {
'video/x-matroska', 'video/x-matroska',
]; ];
public function __construct() { public function __construct()
{
parent::__construct(self::APPNAME); parent::__construct(self::APPNAME);
} }
public function register(IRegistrationContext $context): void { public function register(IRegistrationContext $context): void
{
$context->registerEventListener(NodeWrittenEvent::class, PostWriteListener::class); $context->registerEventListener(NodeWrittenEvent::class, PostWriteListener::class);
$context->registerEventListener(NodeTouchedEvent::class, PostWriteListener::class); $context->registerEventListener(NodeTouchedEvent::class, PostWriteListener::class);
$context->registerEventListener(NodeDeletedEvent::class, PostDeleteListener::class); $context->registerEventListener(NodeDeletedEvent::class, PostDeleteListener::class);
} }
public function boot(IBootContext $context): void { public function boot(IBootContext $context): void
{
} }
} }

View File

@ -4,9 +4,7 @@ declare(strict_types=1);
/** /**
* @copyright Copyright (c) 2022, Varun Patil <radialapps@gmail.com> * @copyright Copyright (c) 2022, Varun Patil <radialapps@gmail.com>
*
* @author Varun Patil <radialapps@gmail.com> * @author Varun Patil <radialapps@gmail.com>
*
* @license AGPL-3.0-or-later * @license AGPL-3.0-or-later
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
@ -21,11 +19,12 @@ declare(strict_types=1);
* *
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/ */
namespace OCA\Memories\Command; namespace OCA\Memories\Command;
use OCA\Files_External\Service\GlobalStoragesService;
use OCA\Memories\Db\TimelineWrite;
use OCP\Encryption\IManager; use OCP\Encryption\IManager;
use OCP\Files\File; use OCP\Files\File;
use OCP\Files\Folder; use OCP\Files\Folder;
@ -36,8 +35,6 @@ use OCP\IDBConnection;
use OCP\IPreview; use OCP\IPreview;
use OCP\IUser; use OCP\IUser;
use OCP\IUserManager; use OCP\IUserManager;
use OCA\Files_External\Service\GlobalStoragesService;
use OCA\Memories\Db\TimelineWrite;
use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface; use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
@ -45,8 +42,8 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
class Index extends Command { class Index extends Command
{
/** @var ?GlobalStoragesService */ /** @var ?GlobalStoragesService */
protected $globalService; protected $globalService;
@ -67,13 +64,16 @@ class Index extends Command {
private int $nSkipped = 0; private int $nSkipped = 0;
private int $nInvalid = 0; private int $nInvalid = 0;
public function __construct(IRootFolder $rootFolder, public function __construct(
IUserManager $userManager, IRootFolder $rootFolder,
IPreview $previewGenerator, IUserManager $userManager,
IConfig $config, IPreview $previewGenerator,
IManager $encryptionManager, IConfig $config,
IDBConnection $connection, IManager $encryptionManager,
ContainerInterface $container) { IDBConnection $connection,
ContainerInterface $container
)
{
parent::__construct(); parent::__construct();
$this->userManager = $userManager; $this->userManager = $userManager;
@ -91,38 +91,8 @@ class Index extends Command {
} }
} }
/** Make sure exiftool is available */ protected function configure(): void
private function testExif() { {
$testfile = dirname(__FILE__). '/../../exiftest.jpg';
$stream = fopen($testfile, 'rb');
if (!$stream) {
error_log("Couldn't open Exif test file $testfile");
return false;
}
$exif = null;
try {
$exif = \OCA\Memories\Exif::getExifFromStream($stream);
} catch (\Exception $e) {
error_log("Couldn't read Exif data from test file: " . $e->getMessage());
return false;
} finally {
fclose($stream);
}
if (!$exif) {
error_log("Got blank Exif data from test file");
return false;
}
if ($exif["DateTimeOriginal"] !== "2004:08:31 19:52:58") {
error_log("Got unexpected Exif data from test file");
return false;
}
return true;
}
protected function configure(): void {
$this $this
->setName('memories:index') ->setName('memories:index')
->setDescription('Generate photo entries') ->setDescription('Generate photo entries')
@ -137,45 +107,52 @@ class Index extends Command {
null, null,
InputOption::VALUE_NONE, InputOption::VALUE_NONE,
'Clear existing index before creating a new one (SLOW)' 'Clear existing index before creating a new one (SLOW)'
); )
;
} }
protected function execute(InputInterface $input, OutputInterface $output): int { protected function execute(InputInterface $input, OutputInterface $output): int
{
// Get options and arguments // Get options and arguments
$refresh = $input->getOption('refresh') ? true : false; $refresh = $input->getOption('refresh') ? true : false;
$clear = $input->getOption('clear') ? true : false; $clear = $input->getOption('clear') ? true : false;
// Clear index if asked for this // Clear index if asked for this
if ($clear && $input->isInteractive()) { if ($clear && $input->isInteractive()) {
$output->write("Are you sure you want to clear the existing index? (y/N): "); $output->write('Are you sure you want to clear the existing index? (y/N): ');
$answer = trim(fgets(STDIN)); $answer = trim(fgets(STDIN));
if ($answer !== 'y') { if ('y' !== $answer) {
$output->writeln("Aborting"); $output->writeln('Aborting');
return 1; return 1;
} }
} }
if ($clear) { if ($clear) {
$this->timelineWrite->clear(); $this->timelineWrite->clear();
$output->writeln("Cleared existing index"); $output->writeln('Cleared existing index');
} }
// Run with the static process // Run with the static process
try { try {
\OCA\Memories\Exif::ensureStaticExiftoolProc(); \OCA\Memories\Exif::ensureStaticExiftoolProc();
return $this->executeWithOpts($output, $refresh); return $this->executeWithOpts($output, $refresh);
} catch (\Exception $e) { } catch (\Exception $e) {
error_log("FATAL: " . $e->getMessage()); error_log('FATAL: '.$e->getMessage());
return 1; return 1;
} finally { } finally {
\OCA\Memories\Exif::closeStaticExiftoolProc(); \OCA\Memories\Exif::closeStaticExiftoolProc();
} }
} }
protected function executeWithOpts(OutputInterface $output, bool &$refresh): int { protected function executeWithOpts(OutputInterface $output, bool &$refresh): int
{
// Refuse to run without exiftool // Refuse to run without exiftool
if (!$this->testExif()) { if (!$this->testExif()) {
error_log('FATAL: exiftool could not be found or test failed'); error_log('FATAL: exiftool could not be found or test failed');
error_log('Please install exiftool (at least v12) and make sure it is in the PATH'); error_log('Please install exiftool (at least v12) and make sure it is in the PATH');
return 1; return 1;
} }
@ -184,6 +161,7 @@ class Index extends Command {
if ($this->encryptionManager->isEnabled()) { if ($this->encryptionManager->isEnabled()) {
error_log('FATAL: Encryption is enabled. Aborted.'); error_log('FATAL: Encryption is enabled. Aborted.');
return 1; return 1;
} }
$this->output = $output; $this->output = $output;
@ -194,19 +172,58 @@ class Index extends Command {
// Show some stats // Show some stats
$endTime = microtime(true); $endTime = microtime(true);
$execTime = intval(($endTime - $startTime)*1000)/1000 ; $execTime = (int) (($endTime - $startTime) * 1000) / 1000;
$nTotal = $this->nInvalid + $this->nSkipped + $this->nProcessed; $nTotal = $this->nInvalid + $this->nSkipped + $this->nProcessed;
$this->output->writeln("=========================================="); $this->output->writeln('==========================================');
$this->output->writeln("Checked $nTotal files in $execTime sec"); $this->output->writeln("Checked {$nTotal} files in {$execTime} sec");
$this->output->writeln($this->nInvalid . " not valid media items"); $this->output->writeln($this->nInvalid.' not valid media items');
$this->output->writeln($this->nSkipped . " skipped because unmodified"); $this->output->writeln($this->nSkipped.' skipped because unmodified');
$this->output->writeln($this->nProcessed . " (re-)processed"); $this->output->writeln($this->nProcessed.' (re-)processed');
$this->output->writeln("=========================================="); $this->output->writeln('==========================================');
return 0; return 0;
} }
private function generateUserEntries(IUser &$user, bool &$refresh): void { /** Make sure exiftool is available */
private function testExif()
{
$testfile = __DIR__.'/../../exiftest.jpg';
$stream = fopen($testfile, 'r');
if (!$stream) {
error_log("Couldn't open Exif test file {$testfile}");
return false;
}
$exif = null;
try {
$exif = \OCA\Memories\Exif::getExifFromStream($stream);
} catch (\Exception $e) {
error_log("Couldn't read Exif data from test file: ".$e->getMessage());
return false;
} finally {
fclose($stream);
}
if (!$exif) {
error_log('Got blank Exif data from test file');
return false;
}
if ('2004:08:31 19:52:58' !== $exif['DateTimeOriginal']) {
error_log('Got unexpected Exif data from test file');
return false;
}
return true;
}
private function generateUserEntries(IUser &$user, bool &$refresh): void
{
\OC_Util::tearDownFS(); \OC_Util::tearDownFS();
\OC_Util::setupFS($user->getUID()); \OC_Util::setupFS($user->getUID());
@ -215,17 +232,19 @@ class Index extends Command {
$this->parseFolder($userFolder, $refresh); $this->parseFolder($userFolder, $refresh);
} }
private function parseFolder(Folder &$folder, bool &$refresh): void { private function parseFolder(Folder &$folder, bool &$refresh): void
{
try { try {
$folderPath = $folder->getPath(); $folderPath = $folder->getPath();
// Respect the '.nomedia' file. If present don't traverse the folder // Respect the '.nomedia' file. If present don't traverse the folder
if ($folder->nodeExists('.nomedia')) { if ($folder->nodeExists('.nomedia')) {
$this->output->writeln('Skipping folder ' . $folderPath); $this->output->writeln('Skipping folder '.$folderPath);
return; return;
} }
$this->output->writeln('Scanning folder ' . $folderPath); $this->output->writeln('Scanning folder '.$folderPath);
$nodes = $folder->getDirectoryListing(); $nodes = $folder->getDirectoryListing();
@ -237,21 +256,23 @@ class Index extends Command {
} }
} }
} catch (StorageNotAvailableException $e) { } catch (StorageNotAvailableException $e) {
$this->output->writeln(sprintf('<error>Storage for folder folder %s is not available: %s</error>', $this->output->writeln(sprintf(
'<error>Storage for folder folder %s is not available: %s</error>',
$folder->getPath(), $folder->getPath(),
$e->getHint() $e->getHint()
)); ));
} }
} }
private function parseFile(File &$file, bool &$refresh): void { private function parseFile(File &$file, bool &$refresh): void
{
$res = $this->timelineWrite->processFile($file, $refresh); $res = $this->timelineWrite->processFile($file, $refresh);
if ($res === 2) { if (2 === $res) {
$this->nProcessed++; ++$this->nProcessed;
} else if ($res === 1) { } elseif (1 === $res) {
$this->nSkipped++; ++$this->nSkipped;
} else { } else {
$this->nInvalid++; ++$this->nInvalid;
} }
} }
} }

View File

@ -1,11 +1,10 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
/** /**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com> * @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later * @license AGPL-3.0-or-later
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
@ -20,7 +19,6 @@ declare(strict_types=1);
* *
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/ */
namespace OCA\Memories\Controller; namespace OCA\Memories\Controller;
@ -32,21 +30,22 @@ use OCA\Memories\Exif;
use OCP\App\IAppManager; use OCP\App\IAppManager;
use OCP\AppFramework\Controller; use OCP\AppFramework\Controller;
use OCP\AppFramework\Http; use OCP\AppFramework\Http;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\DataDisplayResponse;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\StreamResponse; use OCP\AppFramework\Http\StreamResponse;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\DataDisplayResponse;
use OCP\Files\IRootFolder;
use OCP\Files\FileInfo; use OCP\Files\FileInfo;
use OCP\Files\Folder; use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\IConfig; use OCP\IConfig;
use OCP\IDBConnection; use OCP\IDBConnection;
use OCP\IPreview;
use OCP\IRequest; use OCP\IRequest;
use OCP\IUserSession; use OCP\IUserSession;
use OCP\IPreview;
class ApiController extends Controller { class ApiController extends Controller
{
private IConfig $config; private IConfig $config;
private IUserSession $userSession; private IUserSession $userSession;
private IDBConnection $connection; private IDBConnection $connection;
@ -63,8 +62,9 @@ class ApiController extends Controller {
IDBConnection $connection, IDBConnection $connection,
IRootFolder $rootFolder, IRootFolder $rootFolder,
IAppManager $appManager, IAppManager $appManager,
IPreview $previewManager) { IPreview $previewManager
)
{
parent::__construct(Application::APPNAME, $request); parent::__construct(Application::APPNAME, $request);
$this->config = $config; $this->config = $config;
@ -77,117 +77,23 @@ class ApiController extends Controller {
$this->timelineWrite = new TimelineWrite($connection); $this->timelineWrite = new TimelineWrite($connection);
} }
/**
* Get transformations depending on the request
*/
private function getTransformations() {
$transforms = array();
// Filter only favorites
if ($this->request->getParam('fav')) {
$transforms[] = array($this->timelineQuery, 'transformFavoriteFilter');
}
// Filter only videos
if ($this->request->getParam('vid')) {
$transforms[] = array($this->timelineQuery, 'transformVideoFilter');
}
// Filter only for one face
if ($this->recognizeIsEnabled()) {
$face = $this->request->getParam('face');
if ($face) {
$transforms[] = array($this->timelineQuery, 'transformFaceFilter', $face);
}
$faceRect = $this->request->getParam('facerect');
if ($faceRect) {
$transforms[] = array($this->timelineQuery, 'transformFaceRect', $face);
}
}
// Filter only for one tag
if ($this->tagsIsEnabled()) {
$tagName = $this->request->getParam('tag');
if ($tagName) {
$transforms[] = array($this->timelineQuery, 'transformTagFilter', $tagName);
}
}
// Limit number of responses for day query
$limit = $this->request->getParam('limit');
if ($limit) {
$transforms[] = array($this->timelineQuery, 'transformLimitDay', intval($limit));
}
return $transforms;
}
/** Preload a few "day" at the start of "days" response */
private function preloadDays(array &$days, Folder &$folder, bool $recursive, bool $archive) {
$uid = $this->userSession->getUser()->getUID();
$transforms = $this->getTransformations();
$preloaded = 0;
foreach ($days as &$day) {
$day["detail"] = $this->timelineQuery->getDay(
$folder,
$uid,
[$day["dayid"]],
$recursive,
$archive,
$transforms,
);
$day["count"] = count($day["detail"]); // make sure count is accurate
$preloaded += $day["count"];
if ($preloaded >= 50) { // should be enough
break;
}
}
}
/** Get the Folder object relevant to the request */
private function getRequestFolder() {
$uid = $this->userSession->getUser()->getUID();
try {
$folder = null;
$folderPath = $this->request->getParam('folder');
$userFolder = $this->rootFolder->getUserFolder($uid);
if (!is_null($folderPath)) {
$folder = $userFolder->get($folderPath);
} else {
$configPath = Exif::removeExtraSlash(Exif::getPhotosPath($this->config, $uid));
$folder = $userFolder->get($configPath);
}
if (!$folder instanceof Folder) {
throw new \Exception("Folder not found");
}
} catch (\Exception $e) {
return null;
}
return $folder;
}
/** /**
* @NoAdminRequired * @NoAdminRequired
*
* @return JSONResponse
*/ */
public function days(): JSONResponse { public function days(): JSONResponse
{
$user = $this->userSession->getUser(); $user = $this->userSession->getUser();
if (is_null($user)) { if (null === $user) {
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
} }
$uid = $user->getUID(); $uid = $user->getUID();
// Get the folder to show // Get the folder to show
$folder = $this->getRequestFolder(); $folder = $this->getRequestFolder();
$recursive = is_null($this->request->getParam('folder')); $recursive = null === $this->request->getParam('folder');
$archive = !is_null($this->request->getParam('archive')); $archive = null !== $this->request->getParam('archive');
if (is_null($folder)) { if (null === $folder) {
return new JSONResponse(["message" => "Folder not found"], Http::STATUS_NOT_FOUND); return new JSONResponse(['message' => 'Folder not found'], Http::STATUS_NOT_FOUND);
} }
// Run actual query // Run actual query
@ -210,56 +116,55 @@ class ApiController extends Controller {
return new JSONResponse($list, Http::STATUS_OK); return new JSONResponse($list, Http::STATUS_OK);
} catch (\Exception $e) { } catch (\Exception $e) {
return new JSONResponse(["message" => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
} }
} }
/** /**
* @NoAdminRequired * @NoAdminRequired
*
* @return JSONResponse
*/ */
public function dayPost(): JSONResponse { public function dayPost(): JSONResponse
{
$id = $this->request->getParam('body_ids'); $id = $this->request->getParam('body_ids');
if (is_null($id)) { if (null === $id) {
return new JSONResponse([], Http::STATUS_BAD_REQUEST); return new JSONResponse([], Http::STATUS_BAD_REQUEST);
} }
return $this->day($id); return $this->day($id);
} }
/** /**
* @NoAdminRequired * @NoAdminRequired
*
* @return JSONResponse
*/ */
public function day(string $id): JSONResponse { public function day(string $id): JSONResponse
{
$user = $this->userSession->getUser(); $user = $this->userSession->getUser();
if (is_null($user)) { if (null === $user) {
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
} }
$uid = $user->getUID(); $uid = $user->getUID();
// Check for wildcard // Check for wildcard
$day_ids = []; $day_ids = [];
if ($id === "*") { if ('*' === $id) {
$day_ids = null; $day_ids = null;
} else { } else {
// Split at commas and convert all parts to int // Split at commas and convert all parts to int
$day_ids = array_map(function ($part) { $day_ids = array_map(function ($part) {
return intval($part); return (int) $part;
}, explode(",", $id)); }, explode(',', $id));
} }
// Check if $day_ids is empty // Check if $day_ids is empty
if (!is_null($day_ids) && count($day_ids) === 0) { if (null !== $day_ids && 0 === \count($day_ids)) {
return new JSONResponse([], Http::STATUS_OK); return new JSONResponse([], Http::STATUS_OK);
} }
// Get the folder to show // Get the folder to show
$folder = $this->getRequestFolder(); $folder = $this->getRequestFolder();
$recursive = is_null($this->request->getParam('folder')); $recursive = null === $this->request->getParam('folder');
$archive = !is_null($this->request->getParam('archive')); $archive = null !== $this->request->getParam('archive');
if (is_null($folder)) { if (null === $folder) {
return new JSONResponse([], Http::STATUS_NOT_FOUND); return new JSONResponse([], Http::STATUS_NOT_FOUND);
} }
@ -273,16 +178,18 @@ class ApiController extends Controller {
$archive, $archive,
$this->getTransformations(), $this->getTransformations(),
); );
return new JSONResponse($list, Http::STATUS_OK); return new JSONResponse($list, Http::STATUS_OK);
} catch (\Exception $e) { } catch (\Exception $e) {
return new JSONResponse(["message" => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
} }
} }
/** /**
* Get subfolders entry for days response * Get subfolders entry for days response.
*/ */
public function getSubfoldersEntry(Folder &$folder) { public function getSubfoldersEntry(Folder &$folder)
{
// Ugly: get the view of the folder with reflection // Ugly: get the view of the folder with reflection
// This is unfortunately the only way to get the contents of a folder // This is unfortunately the only way to get the contents of a folder
// matching a MIME type without using SEARCH, which is deep // matching a MIME type without using SEARCH, which is deep
@ -294,20 +201,20 @@ class ApiController extends Controller {
$folders = $view->getDirectoryContent($folder->getPath(), FileInfo::MIMETYPE_FOLDER, $folder); $folders = $view->getDirectoryContent($folder->getPath(), FileInfo::MIMETYPE_FOLDER, $folder);
// Sort by name // Sort by name
usort($folders, function($a, $b) { usort($folders, function ($a, $b) {
return strnatcmp($a->getName(), $b->getName()); return strnatcmp($a->getName(), $b->getName());
}); });
// Process to response type // Process to response type
return [ return [
"dayid" => \OCA\Memories\Util::$TAG_DAYID_FOLDERS, 'dayid' => \OCA\Memories\Util::$TAG_DAYID_FOLDERS,
"count" => count($folders), 'count' => \count($folders),
"detail" => array_map(function ($node) { 'detail' => array_map(function ($node) {
return [ return [
"fileid" => $node->getId(), 'fileid' => $node->getId(),
"name" => $node->getName(), 'name' => $node->getName(),
"isfolder" => 1, 'isfolder' => 1,
"path" => $node->getPath(), 'path' => $node->getPath(),
]; ];
}, $folders, []), }, $folders, []),
]; ];
@ -317,22 +224,22 @@ class ApiController extends Controller {
* @NoAdminRequired * @NoAdminRequired
* *
* Get list of tags with counts of images * Get list of tags with counts of images
* @return JSONResponse
*/ */
public function tags(): JSONResponse { public function tags(): JSONResponse
{
$user = $this->userSession->getUser(); $user = $this->userSession->getUser();
if (is_null($user)) { if (null === $user) {
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
} }
// Check tags enabled for this user // Check tags enabled for this user
if (!$this->tagsIsEnabled()) { if (!$this->tagsIsEnabled()) {
return new JSONResponse(["message" => "Tags not enabled for user"], Http::STATUS_PRECONDITION_FAILED); return new JSONResponse(['message' => 'Tags not enabled for user'], Http::STATUS_PRECONDITION_FAILED);
} }
// If this isn't the timeline folder then things aren't going to work // If this isn't the timeline folder then things aren't going to work
$folder = $this->getRequestFolder(); $folder = $this->getRequestFolder();
if (is_null($folder)) { if (null === $folder) {
return new JSONResponse([], Http::STATUS_NOT_FOUND); return new JSONResponse([], Http::STATUS_NOT_FOUND);
} }
@ -347,21 +254,21 @@ class ApiController extends Controller {
// Convert to map with key as systemtagid // Convert to map with key as systemtagid
$previews_map = []; $previews_map = [];
foreach ($previews as &$preview) { foreach ($previews as &$preview) {
$key = $preview["systemtagid"]; $key = $preview['systemtagid'];
if (!array_key_exists($key, $previews_map)) { if (!\array_key_exists($key, $previews_map)) {
$previews_map[$key] = []; $previews_map[$key] = [];
} }
unset($preview["systemtagid"]); unset($preview['systemtagid']);
$previews_map[$key][] = $preview; $previews_map[$key][] = $preview;
} }
// Add previews to list // Add previews to list
foreach ($list as &$tag) { foreach ($list as &$tag) {
$key = $tag["id"]; $key = $tag['id'];
if (array_key_exists($key, $previews_map)) { if (\array_key_exists($key, $previews_map)) {
$tag["previews"] = $previews_map[$key]; $tag['previews'] = $previews_map[$key];
} else { } else {
$tag["previews"] = []; $tag['previews'] = [];
} }
} }
@ -372,22 +279,22 @@ class ApiController extends Controller {
* @NoAdminRequired * @NoAdminRequired
* *
* Get list of faces with counts of images * Get list of faces with counts of images
* @return JSONResponse
*/ */
public function faces(): JSONResponse { public function faces(): JSONResponse
{
$user = $this->userSession->getUser(); $user = $this->userSession->getUser();
if (is_null($user)) { if (null === $user) {
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
} }
// Check faces enabled for this user // Check faces enabled for this user
if (!$this->recognizeIsEnabled()) { if (!$this->recognizeIsEnabled()) {
return new JSONResponse(["message" => "Recognize app not enabled or not v3+"], Http::STATUS_PRECONDITION_FAILED); return new JSONResponse(['message' => 'Recognize app not enabled or not v3+'], Http::STATUS_PRECONDITION_FAILED);
} }
// If this isn't the timeline folder then things aren't going to work // If this isn't the timeline folder then things aren't going to work
$folder = $this->getRequestFolder(); $folder = $this->getRequestFolder();
if (is_null($folder)) { if (null === $folder) {
return new JSONResponse([], Http::STATUS_NOT_FOUND); return new JSONResponse([], Http::STATUS_NOT_FOUND);
} }
@ -401,14 +308,17 @@ class ApiController extends Controller {
/** /**
* @NoAdminRequired * @NoAdminRequired
*
* @NoCSRFRequired * @NoCSRFRequired
* *
* Get face preview image cropped with imagick * Get face preview image cropped with imagick
*
* @return DataResponse * @return DataResponse
*/ */
public function facePreview(string $id): Http\Response { public function facePreview(string $id): Http\Response
{
$user = $this->userSession->getUser(); $user = $this->userSession->getUser();
if (is_null($user)) { if (null === $user) {
return new DataResponse([], Http::STATUS_PRECONDITION_FAILED); return new DataResponse([], Http::STATUS_PRECONDITION_FAILED);
} }
@ -419,13 +329,13 @@ class ApiController extends Controller {
// Get folder to search for // Get folder to search for
$folder = $this->getRequestFolder(); $folder = $this->getRequestFolder();
if (is_null($folder)) { if (null === $folder) {
return new JSONResponse([], Http::STATUS_NOT_FOUND); return new JSONResponse([], Http::STATUS_NOT_FOUND);
} }
// Run actual query // Run actual query
$detections = $this->timelineQuery->getFacePreviewDetection($folder, intval($id)); $detections = $this->timelineQuery->getFacePreviewDetection($folder, (int) $id);
if (is_null($detections) || count($detections) == 0) { if (null === $detections || 0 === \count($detections)) {
return new DataResponse([], Http::STATUS_NOT_FOUND); return new DataResponse([], Http::STATUS_NOT_FOUND);
} }
@ -433,8 +343,8 @@ class ApiController extends Controller {
$preview = null; $preview = null;
foreach ($detections as &$detection) { foreach ($detections as &$detection) {
// Get the file (also checks permissions) // Get the file (also checks permissions)
$files = $folder->getById($detection["file_id"]); $files = $folder->getById($detection['file_id']);
if (count($files) == 0 || $files[0]->getType() != FileInfo::TYPE_FILE) { if (0 === \count($files) || FileInfo::TYPE_FILE !== $files[0]->getType()) {
continue; continue;
} }
@ -450,7 +360,7 @@ class ApiController extends Controller {
} }
// Make sure the preview is valid // Make sure the preview is valid
if (is_null($preview)) { if (null === $preview) {
return new DataResponse([], Http::STATUS_NOT_FOUND); return new DataResponse([], Http::STATUS_NOT_FOUND);
} }
@ -459,16 +369,16 @@ class ApiController extends Controller {
$image->readImageBlob($preview->getContent()); $image->readImageBlob($preview->getContent());
$iw = $image->getImageWidth(); $iw = $image->getImageWidth();
$ih = $image->getImageHeight(); $ih = $image->getImageHeight();
$dw = floatval($detection["width"]); $dw = (float) ($detection['width']);
$dh = floatval($detection["height"]); $dh = (float) ($detection['height']);
$dcx = floatval($detection["x"]) + floatval($detection["width"]) / 2; $dcx = (float) ($detection['x']) + (float) ($detection['width']) / 2;
$dcy = floatval($detection["y"]) + floatval($detection["height"]) / 2; $dcy = (float) ($detection['y']) + (float) ($detection['height']) / 2;
$faceDim = max($dw * $iw, $dh * $ih) * 1.5; $faceDim = max($dw * $iw, $dh * $ih) * 1.5;
$image->cropImage( $image->cropImage(
intval($faceDim), (int) $faceDim,
intval($faceDim), (int) $faceDim,
intval($dcx * $iw - $faceDim / 2), (int) ($dcx * $iw - $faceDim / 2),
intval($dcy * $ih - $faceDim / 2), (int) ($dcy * $ih - $faceDim / 2),
); );
$image->scaleImage(256, 256, true); $image->scaleImage(256, 256, true);
$blob = $image->getImageBlob(); $blob = $image->getImageBlob();
@ -478,6 +388,7 @@ class ApiController extends Controller {
'Content-Type' => $image->getImageMimeType(), 'Content-Type' => $image->getImageMimeType(),
]); ]);
$response->cacheFor(3600 * 24, false, false); $response->cacheFor(3600 * 24, false, false);
return $response; return $response;
} }
@ -485,18 +396,20 @@ class ApiController extends Controller {
* @NoAdminRequired * @NoAdminRequired
* *
* Get image info for one file * Get image info for one file
*
* @param string fileid * @param string fileid
*/ */
public function imageInfo(string $id): JSONResponse { public function imageInfo(string $id): JSONResponse
{
$user = $this->userSession->getUser(); $user = $this->userSession->getUser();
if (is_null($user)) { if (null === $user) {
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
} }
$userFolder = $this->rootFolder->getUserFolder($user->getUID()); $userFolder = $this->rootFolder->getUserFolder($user->getUID());
// Check for permissions and get numeric Id // Check for permissions and get numeric Id
$file = $userFolder->getById(intval($id)); $file = $userFolder->getById((int) $id);
if (count($file) === 0) { if (0 === \count($file)) {
return new JSONResponse([], Http::STATUS_NOT_FOUND); return new JSONResponse([], Http::STATUS_NOT_FOUND);
} }
$file = $file[0]; $file = $file[0];
@ -511,18 +424,20 @@ class ApiController extends Controller {
* @NoAdminRequired * @NoAdminRequired
* *
* Change exif data for one file * Change exif data for one file
*
* @param string fileid * @param string fileid
*/ */
public function imageEdit(string $id): JSONResponse { public function imageEdit(string $id): JSONResponse
{
$user = $this->userSession->getUser(); $user = $this->userSession->getUser();
if (is_null($user)) { if (null === $user) {
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
} }
$userFolder = $this->rootFolder->getUserFolder($user->getUID()); $userFolder = $this->rootFolder->getUserFolder($user->getUID());
// Check for permissions and get numeric Id // Check for permissions and get numeric Id
$file = $userFolder->getById(intval($id)); $file = $userFolder->getById((int) $id);
if (count($file) === 0) { if (0 === \count($file)) {
return new JSONResponse([], Http::STATUS_NOT_FOUND); return new JSONResponse([], Http::STATUS_NOT_FOUND);
} }
$file = $file[0]; $file = $file[0];
@ -535,24 +450,24 @@ class ApiController extends Controller {
// Get new date from body // Get new date from body
$body = $this->request->getParams(); $body = $this->request->getParams();
if (!isset($body['date'])) { if (!isset($body['date'])) {
return new JSONResponse(["message" => "Missing date"], Http::STATUS_BAD_REQUEST); return new JSONResponse(['message' => 'Missing date'], Http::STATUS_BAD_REQUEST);
} }
// Make sure the date is valid // Make sure the date is valid
try { try {
Exif::parseExifDate($body['date']); Exif::parseExifDate($body['date']);
} catch (\Exception $e) { } catch (\Exception $e) {
return new JSONResponse(["message" => $e->getMessage()], Http::STATUS_BAD_REQUEST); return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
} }
// Update date // Update date
try { try {
$res = Exif::updateExifDate($file, $body['date']); $res = Exif::updateExifDate($file, $body['date']);
if ($res === false) { if (false === $res) {
return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR);
} }
} catch (\Exception $e) { } catch (\Exception $e) {
return new JSONResponse(["message" => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
} }
// Reprocess the file // Reprocess the file
@ -565,66 +480,68 @@ class ApiController extends Controller {
* @NoAdminRequired * @NoAdminRequired
* *
* Move one file to the archive folder * Move one file to the archive folder
*
* @param string fileid * @param string fileid
*/ */
public function archive(string $id): JSONResponse { public function archive(string $id): JSONResponse
{
$user = $this->userSession->getUser(); $user = $this->userSession->getUser();
if (is_null($user)) { if (null === $user) {
return new JSONResponse(["message" => "Not logged in"], Http::STATUS_PRECONDITION_FAILED); return new JSONResponse(['message' => 'Not logged in'], Http::STATUS_PRECONDITION_FAILED);
} }
$uid = $user->getUID(); $uid = $user->getUID();
$userFolder = $this->rootFolder->getUserFolder($uid); $userFolder = $this->rootFolder->getUserFolder($uid);
// Check for permissions and get numeric Id // Check for permissions and get numeric Id
$file = $userFolder->getById(intval($id)); $file = $userFolder->getById((int) $id);
if (count($file) === 0) { if (0 === \count($file)) {
return new JSONResponse(["message" => "No such file"], Http::STATUS_NOT_FOUND); return new JSONResponse(['message' => 'No such file'], Http::STATUS_NOT_FOUND);
} }
$file = $file[0]; $file = $file[0];
// Check if user has permissions // Check if user has permissions
if (!$file->isUpdateable()) { if (!$file->isUpdateable()) {
return new JSONResponse(["message" => "Cannot update this file"], Http::STATUS_FORBIDDEN); return new JSONResponse(['message' => 'Cannot update this file'], Http::STATUS_FORBIDDEN);
} }
// Create archive folder in the root of the user's configured timeline // Create archive folder in the root of the user's configured timeline
$timelinePath = Exif::removeExtraSlash(Exif::getPhotosPath($this->config, $uid)); $timelinePath = Exif::removeExtraSlash(Exif::getPhotosPath($this->config, $uid));
$timelineFolder = $userFolder->get($timelinePath); $timelineFolder = $userFolder->get($timelinePath);
if (is_null($timelineFolder) || !$timelineFolder instanceof Folder) { if (null === $timelineFolder || !$timelineFolder instanceof Folder) {
return new JSONResponse(["message" => "Cannot get timeline"], Http::STATUS_INTERNAL_SERVER_ERROR); return new JSONResponse(['message' => 'Cannot get timeline'], Http::STATUS_INTERNAL_SERVER_ERROR);
} }
if (!$timelineFolder->isCreatable()) { if (!$timelineFolder->isCreatable()) {
return new JSONResponse(["message" => "Cannot create archive folder"], Http::STATUS_FORBIDDEN); return new JSONResponse(['message' => 'Cannot create archive folder'], Http::STATUS_FORBIDDEN);
} }
// Get path of current file relative to the timeline folder // Get path of current file relative to the timeline folder
// remove timelineFolder path from start of file path // remove timelineFolder path from start of file path
$timelinePath = $timelineFolder->getPath(); // no trailing slash $timelinePath = $timelineFolder->getPath(); // no trailing slash
if (substr($file->getPath(), 0, strlen($timelinePath)) !== $timelinePath) { if (substr($file->getPath(), 0, \strlen($timelinePath)) !== $timelinePath) {
return new JSONResponse(["message" => "Files outside timeline cannot be archived"], Http::STATUS_INTERNAL_SERVER_ERROR); return new JSONResponse(['message' => 'Files outside timeline cannot be archived'], Http::STATUS_INTERNAL_SERVER_ERROR);
} }
$relativePath = substr($file->getPath(), strlen($timelinePath)); // has a leading slash $relativePath = substr($file->getPath(), \strlen($timelinePath)); // has a leading slash
// Final path of the file including the file name // Final path of the file including the file name
$destinationPath = ''; $destinationPath = '';
// Check if we want to archive or unarchive // Check if we want to archive or unarchive
$body = $this->request->getParams(); $body = $this->request->getParams();
$unarchive = isset($body['archive']) && $body['archive'] === false; $unarchive = isset($body['archive']) && false === $body['archive'];
// Get if the file is already in the archive (relativePath starts with archive) // Get if the file is already in the archive (relativePath starts with archive)
$archiveFolderWithLeadingSlash = '/' . \OCA\Memories\Util::$ARCHIVE_FOLDER; $archiveFolderWithLeadingSlash = '/'.\OCA\Memories\Util::$ARCHIVE_FOLDER;
if (substr($relativePath, 0, strlen($archiveFolderWithLeadingSlash)) === $archiveFolderWithLeadingSlash) { if (substr($relativePath, 0, \strlen($archiveFolderWithLeadingSlash)) === $archiveFolderWithLeadingSlash) {
// file already in archive, remove it instead // file already in archive, remove it instead
$destinationPath = substr($relativePath, strlen($archiveFolderWithLeadingSlash)); $destinationPath = substr($relativePath, \strlen($archiveFolderWithLeadingSlash));
if (!$unarchive) { if (!$unarchive) {
return new JSONResponse(["message" => "File already archived"], Http::STATUS_BAD_REQUEST); return new JSONResponse(['message' => 'File already archived'], Http::STATUS_BAD_REQUEST);
} }
} else { } else {
// file not in archive, put it in there // file not in archive, put it in there
$destinationPath = Exif::removeExtraSlash(\OCA\Memories\Util::$ARCHIVE_FOLDER . $relativePath); $destinationPath = Exif::removeExtraSlash(\OCA\Memories\Util::$ARCHIVE_FOLDER.$relativePath);
if ($unarchive) { if ($unarchive) {
return new JSONResponse(["message" => "File not archived"], Http::STATUS_BAD_REQUEST); return new JSONResponse(['message' => 'File not archived'], Http::STATUS_BAD_REQUEST);
} }
} }
@ -635,11 +552,12 @@ class ApiController extends Controller {
// Create folder tree // Create folder tree
$folder = $timelineFolder; $folder = $timelineFolder;
foreach ($destinationFolders as $folderName) { foreach ($destinationFolders as $folderName) {
if ($folderName === '') { if ('' === $folderName) {
continue; continue;
} }
try { try {
$existingFolder = $folder->get($folderName . '/'); $existingFolder = $folder->get($folderName.'/');
if (!$existingFolder instanceof Folder) { if (!$existingFolder instanceof Folder) {
throw new \OCP\Files\NotFoundException('Not a folder'); throw new \OCP\Files\NotFoundException('Not a folder');
} }
@ -648,46 +566,27 @@ class ApiController extends Controller {
try { try {
$folder = $folder->newFolder($folderName); $folder = $folder->newFolder($folderName);
} catch (\OCP\Files\NotPermittedException $e) { } catch (\OCP\Files\NotPermittedException $e) {
return new JSONResponse(["message" => "Failed to create folder"], Http::STATUS_FORBIDDEN); return new JSONResponse(['message' => 'Failed to create folder'], Http::STATUS_FORBIDDEN);
} }
} }
} }
// Move file to archive folder // Move file to archive folder
try { try {
$file->move($folder->getPath() . '/' . $file->getName()); $file->move($folder->getPath().'/'.$file->getName());
} catch (\OCP\Files\NotPermittedException $e) { } catch (\OCP\Files\NotPermittedException $e) {
return new JSONResponse(["message" => "Failed to move file"], Http::STATUS_FORBIDDEN); return new JSONResponse(['message' => 'Failed to move file'], Http::STATUS_FORBIDDEN);
} catch (\OCP\Files\NotFoundException $e) { } catch (\OCP\Files\NotFoundException $e) {
return new JSONResponse(["message" => "File not found"], Http::STATUS_INTERNAL_SERVER_ERROR); return new JSONResponse(['message' => 'File not found'], Http::STATUS_INTERNAL_SERVER_ERROR);
} catch (\OCP\Files\InvalidPathException $e) { } catch (\OCP\Files\InvalidPathException $e) {
return new JSONResponse(["message" => "Invalid path"], Http::STATUS_INTERNAL_SERVER_ERROR); return new JSONResponse(['message' => 'Invalid path'], Http::STATUS_INTERNAL_SERVER_ERROR);
} catch (\OCP\Lock\LockedException $e) { } catch (\OCP\Lock\LockedException $e) {
return new JSONResponse(["message" => "File is locked"], Http::STATUS_INTERNAL_SERVER_ERROR); return new JSONResponse(['message' => 'File is locked'], Http::STATUS_INTERNAL_SERVER_ERROR);
} }
return new JSONResponse([], Http::STATUS_OK); return new JSONResponse([], Http::STATUS_OK);
} }
/**
* Check if tags is enabled for this user
*/
private function tagsIsEnabled(): bool {
return $this->appManager->isEnabledForUser('systemtags');
}
/**
* Check if recognize is enabled for this user
*/
private function recognizeIsEnabled(): bool {
if (!$this->appManager->isEnabledForUser('recognize')) {
return false;
}
$v = $this->appManager->getAppInfo('recognize')["version"];
return version_compare($v, "3.0.0-alpha", ">=");
}
/** /**
* @NoAdminRequired * @NoAdminRequired
* *
@ -698,32 +597,157 @@ class ApiController extends Controller {
* *
* @return JSONResponse an empty JSONResponse with respective http status code * @return JSONResponse an empty JSONResponse with respective http status code
*/ */
public function setUserConfig(string $key, string $value): JSONResponse { public function setUserConfig(string $key, string $value): JSONResponse
{
$user = $this->userSession->getUser(); $user = $this->userSession->getUser();
if (is_null($user)) { if (null === $user) {
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
} }
$userId = $user->getUid(); $userId = $user->getUid();
$this->config->setUserValue($userId, Application::APPNAME, $key, $value); $this->config->setUserValue($userId, Application::APPNAME, $key, $value);
return new JSONResponse([], Http::STATUS_OK); return new JSONResponse([], Http::STATUS_OK);
} }
/** /**
* @NoAdminRequired * @NoAdminRequired
* @NoCSRFRequired *
*/ * @NoCSRFRequired
public function serviceWorker(): StreamResponse { */
$response = new StreamResponse(__DIR__.'/../../js/memories-service-worker.js'); public function serviceWorker(): StreamResponse
$response->setHeaders([ {
'Content-Type' => 'application/javascript', $response = new StreamResponse(__DIR__.'/../../js/memories-service-worker.js');
'Service-Worker-Allowed' => '/' $response->setHeaders([
]); 'Content-Type' => 'application/javascript',
$policy = new ContentSecurityPolicy(); 'Service-Worker-Allowed' => '/',
$policy->addAllowedWorkerSrcDomain("'self'"); ]);
$policy->addAllowedScriptDomain("'self'"); $policy = new ContentSecurityPolicy();
$policy->addAllowedConnectDomain("'self'"); $policy->addAllowedWorkerSrcDomain("'self'");
$response->setContentSecurityPolicy($policy); $policy->addAllowedScriptDomain("'self'");
return $response; $policy->addAllowedConnectDomain("'self'");
} $response->setContentSecurityPolicy($policy);
}
return $response;
}
/**
* Get transformations depending on the request.
*/
private function getTransformations()
{
$transforms = [];
// Filter only favorites
if ($this->request->getParam('fav')) {
$transforms[] = [$this->timelineQuery, 'transformFavoriteFilter'];
}
// Filter only videos
if ($this->request->getParam('vid')) {
$transforms[] = [$this->timelineQuery, 'transformVideoFilter'];
}
// Filter only for one face
if ($this->recognizeIsEnabled()) {
$face = $this->request->getParam('face');
if ($face) {
$transforms[] = [$this->timelineQuery, 'transformFaceFilter', $face];
}
$faceRect = $this->request->getParam('facerect');
if ($faceRect) {
$transforms[] = [$this->timelineQuery, 'transformFaceRect', $face];
}
}
// Filter only for one tag
if ($this->tagsIsEnabled()) {
$tagName = $this->request->getParam('tag');
if ($tagName) {
$transforms[] = [$this->timelineQuery, 'transformTagFilter', $tagName];
}
}
// Limit number of responses for day query
$limit = $this->request->getParam('limit');
if ($limit) {
$transforms[] = [$this->timelineQuery, 'transformLimitDay', (int) $limit];
}
return $transforms;
}
/** Preload a few "day" at the start of "days" response */
private function preloadDays(array &$days, Folder &$folder, bool $recursive, bool $archive)
{
$uid = $this->userSession->getUser()->getUID();
$transforms = $this->getTransformations();
$preloaded = 0;
foreach ($days as &$day) {
$day['detail'] = $this->timelineQuery->getDay(
$folder,
$uid,
[$day['dayid']],
$recursive,
$archive,
$transforms,
);
$day['count'] = \count($day['detail']); // make sure count is accurate
$preloaded += $day['count'];
if ($preloaded >= 50) { // should be enough
break;
}
}
}
/** Get the Folder object relevant to the request */
private function getRequestFolder()
{
$uid = $this->userSession->getUser()->getUID();
try {
$folder = null;
$folderPath = $this->request->getParam('folder');
$userFolder = $this->rootFolder->getUserFolder($uid);
if (null !== $folderPath) {
$folder = $userFolder->get($folderPath);
} else {
$configPath = Exif::removeExtraSlash(Exif::getPhotosPath($this->config, $uid));
$folder = $userFolder->get($configPath);
}
if (!$folder instanceof Folder) {
throw new \Exception('Folder not found');
}
} catch (\Exception $e) {
return null;
}
return $folder;
}
/**
* Check if tags is enabled for this user.
*/
private function tagsIsEnabled(): bool
{
return $this->appManager->isEnabledForUser('systemtags');
}
/**
* Check if recognize is enabled for this user.
*/
private function recognizeIsEnabled(): bool
{
if (!$this->appManager->isEnabledForUser('recognize')) {
return false;
}
$v = $this->appManager->getAppInfo('recognize')['version'];
return version_compare($v, '3.0.0-alpha', '>=');
}
}

View File

@ -1,26 +1,27 @@
<?php <?php
namespace OCA\Memories\Controller; namespace OCA\Memories\Controller;
use OCP\IRequest;
use OCP\AppFramework\Services\IInitialState;
use OCP\AppFramework\Http\TemplateResponse;
use OCA\Viewer\Event\LoadViewer;
use OCA\Files\Event\LoadSidebar; use OCA\Files\Event\LoadSidebar;
use OCP\AppFramework\Controller; use OCA\Memories\AppInfo\Application;
use OCA\Viewer\Event\LoadViewer;
use OCP\App\IAppManager; use OCP\App\IAppManager;
use OCP\EventDispatcher\IEventDispatcher; use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\ContentSecurityPolicy; use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IConfig; use OCP\IConfig;
use OCP\IRequest;
use OCP\IUserSession; use OCP\IUserSession;
use OCP\Util; use OCP\Util;
use OCA\Memories\AppInfo\Application; class PageController extends Controller
{
class PageController extends Controller {
protected $userId; protected $userId;
protected $appName; protected $appName;
private IAppManager $appManager;
protected IEventDispatcher $eventDispatcher; protected IEventDispatcher $eventDispatcher;
private IAppManager $appManager;
private IInitialState $initialState; private IInitialState $initialState;
private IUserSession $userSession; private IUserSession $userSession;
private IConfig $config; private IConfig $config;
@ -33,8 +34,9 @@ class PageController extends Controller {
IEventDispatcher $eventDispatcher, IEventDispatcher $eventDispatcher,
IInitialState $initialState, IInitialState $initialState,
IUserSession $userSession, IUserSession $userSession,
IConfig $config) { IConfig $config
)
{
parent::__construct($AppName, $request); parent::__construct($AppName, $request);
$this->userId = $UserId; $this->userId = $UserId;
$this->appName = $AppName; $this->appName = $AppName;
@ -47,11 +49,13 @@ class PageController extends Controller {
/** /**
* @NoAdminRequired * @NoAdminRequired
*
* @NoCSRFRequired * @NoCSRFRequired
*/ */
public function main() { public function main()
{
$user = $this->userSession->getUser(); $user = $this->userSession->getUser();
if (is_null($user)) { if (null === $user) {
return null; return null;
} }
@ -64,76 +68,95 @@ class PageController extends Controller {
$uid = $user->getUid(); $uid = $user->getUid();
$timelinePath = \OCA\Memories\Util::getPhotosPath($this->config, $uid); $timelinePath = \OCA\Memories\Util::getPhotosPath($this->config, $uid);
$this->initialState->provideInitialState('timelinePath', $timelinePath); $this->initialState->provideInitialState('timelinePath', $timelinePath);
$this->initialState->provideInitialState('showHidden', $this->config->getUserValue( $this->initialState->provideInitialState('showHidden', $this->config->getUserValue(
$uid, Application::APPNAME, 'showHidden', false)); $uid,
Application::APPNAME,
'showHidden',
false
));
// Apps enabled // Apps enabled
$this->initialState->provideInitialState('systemtags', $this->appManager->isEnabledForUser('systemtags') === true); $this->initialState->provideInitialState('systemtags', true === $this->appManager->isEnabledForUser('systemtags'));
$this->initialState->provideInitialState('recognize', $this->appManager->isEnabledForUser('recognize') === true); $this->initialState->provideInitialState('recognize', true === $this->appManager->isEnabledForUser('recognize'));
$this->initialState->provideInitialState('version', $this->appManager->getAppInfo('memories')["version"]); $this->initialState->provideInitialState('version', $this->appManager->getAppInfo('memories')['version']);
$policy = new ContentSecurityPolicy(); $policy = new ContentSecurityPolicy();
$policy->addAllowedWorkerSrcDomain("'self'"); $policy->addAllowedWorkerSrcDomain("'self'");
$policy->addAllowedScriptDomain("'self'"); $policy->addAllowedScriptDomain("'self'");
$response = new TemplateResponse($this->appName, 'main'); $response = new TemplateResponse($this->appName, 'main');
$response->setContentSecurityPolicy($policy); $response->setContentSecurityPolicy($policy);
return $response; return $response;
} }
/** /**
* @NoAdminRequired * @NoAdminRequired
*
* @NoCSRFRequired * @NoCSRFRequired
*/ */
public function folder() { public function folder()
{
return $this->main(); return $this->main();
} }
/** /**
* @NoAdminRequired * @NoAdminRequired
*
* @NoCSRFRequired * @NoCSRFRequired
*/ */
public function favorites() { public function favorites()
{
return $this->main(); return $this->main();
} }
/** /**
* @NoAdminRequired * @NoAdminRequired
*
* @NoCSRFRequired * @NoCSRFRequired
*/ */
public function videos() { public function videos()
{
return $this->main(); return $this->main();
} }
/** /**
* @NoAdminRequired * @NoAdminRequired
*
* @NoCSRFRequired * @NoCSRFRequired
*/ */
public function archive() { public function archive()
{
return $this->main(); return $this->main();
} }
/** /**
* @NoAdminRequired * @NoAdminRequired
*
* @NoCSRFRequired * @NoCSRFRequired
*/ */
public function thisday() { public function thisday()
{
return $this->main(); return $this->main();
} }
/** /**
* @NoAdminRequired * @NoAdminRequired
*
* @NoCSRFRequired * @NoCSRFRequired
*/ */
public function people() { public function people()
{
return $this->main(); return $this->main();
} }
/** /**
* @NoAdminRequired * @NoAdminRequired
*
* @NoCSRFRequired * @NoCSRFRequired
*/ */
public function tags() { public function tags()
{
return $this->main(); return $this->main();
} }
} }

View File

@ -1,42 +1,49 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace OCA\Memories\Db; namespace OCA\Memories\Db;
use OCP\IDBConnection; use OCP\IDBConnection;
class TimelineQuery { class TimelineQuery
{
use TimelineQueryDays; use TimelineQueryDays;
use TimelineQueryFaces;
use TimelineQueryFilters; use TimelineQueryFilters;
use TimelineQueryTags; use TimelineQueryTags;
use TimelineQueryFaces;
protected IDBConnection $connection; protected IDBConnection $connection;
public function __construct(IDBConnection $connection) { public function __construct(IDBConnection $connection)
{
$this->connection = $connection; $this->connection = $connection;
} }
public function getInfoById(int $id): array { public function getInfoById(int $id): array
{
$qb = $this->connection->getQueryBuilder(); $qb = $this->connection->getQueryBuilder();
$qb->select('fileid', 'dayid', 'datetaken') $qb->select('fileid', 'dayid', 'datetaken')
->from('memories') ->from('memories')
->where($qb->expr()->eq('fileid', $qb->createNamedParameter($id, \PDO::PARAM_INT))); ->where($qb->expr()->eq('fileid', $qb->createNamedParameter($id, \PDO::PARAM_INT)))
;
$result = $qb->executeQuery(); $result = $qb->executeQuery();
$row = $result->fetch(); $row = $result->fetch();
$result->closeCursor(); $result->closeCursor();
$utcTs = 0; $utcTs = 0;
try { try {
$utcDate = new \DateTime($row['datetaken'], new \DateTimeZone('UTC')); $utcDate = new \DateTime($row['datetaken'], new \DateTimeZone('UTC'));
$utcTs = $utcDate->getTimestamp(); $utcTs = $utcDate->getTimestamp();
} catch (\Throwable $e) {} } catch (\Throwable $e) {
}
return [ return [
'fileid' => intval($row['fileid']), 'fileid' => (int) ($row['fileid']),
'dayid' => intval($row['dayid']), 'dayid' => (int) ($row['dayid']),
'datetaken' => $utcTs, 'datetaken' => $utcTs,
]; ];
} }
} }

View File

@ -1,117 +1,25 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace OCA\Memories\Db; namespace OCA\Memories\Db;
use OCP\IDBConnection;
use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\Folder; use OCP\Files\Folder;
use OCP\IDBConnection;
trait TimelineQueryDays { trait TimelineQueryDays
{
protected IDBConnection $connection; protected IDBConnection $connection;
/** /**
* Process the days response * Get the days response from the database for the timeline.
* @param array $days *
*/ * @param Folder $folder The folder to get the days from
private function processDays(&$days) { * @param bool $recursive Whether to get the days recursively
foreach($days as &$row) { * @param bool $archive Whether to get the days only from the archive folder
$row["dayid"] = intval($row["dayid"]); * @param array $queryTransforms An array of query transforms to apply to the query
$row["count"] = intval($row["count"]);
// All transform processing
$this->processFace($row, true);
}
return $days;
}
/**
* Process the single day response
* @param array $day
*/
private function processDay(&$day) {
foreach($day as &$row) {
// We don't need date taken (see query builder)
unset($row['datetaken']);
// Convert field types
$row["fileid"] = intval($row["fileid"]);
$row["isvideo"] = intval($row["isvideo"]);
$row["dayid"] = intval($row["dayid"]);
$row["w"] = intval($row["w"]);
$row["h"] = intval($row["h"]);
if (!$row["isvideo"]) {
unset($row["isvideo"]);
}
if ($row["categoryid"]) {
$row["isfavorite"] = 1;
}
unset($row["categoryid"]);
// All transform processing
$this->processFace($row);
}
return $day;
}
/** Get the query for oc_filecache join */
private function getFilecacheJoinQuery(
IQueryBuilder &$query,
Folder &$folder,
bool $recursive,
bool $archive
) {
// Subquery to get storage and path
$subQuery = $query->getConnection()->getQueryBuilder();
$cursor = $subQuery->select('path', 'storage')->from('filecache')->where(
$subQuery->expr()->eq('fileid', $subQuery->createNamedParameter($folder->getId())),
)->executeQuery();
$finfo = $cursor->fetch();
$cursor->closeCursor();
if (empty($finfo)) {
throw new \Exception("Folder not found");
}
$pathQuery = null;
if ($recursive) {
// Filter by path for recursive query
$likePath = $finfo["path"];
if (!empty($likePath)) {
$likePath .= '/';
}
$pathQuery = $query->expr()->like('f.path', $query->createNamedParameter($likePath . '%'));
// Exclude/show archive folder
$archiveLikePath = $likePath . \OCA\Memories\Util::$ARCHIVE_FOLDER . '/%';
if (!$archive) {
// Exclude archive folder
$pathQuery = $query->expr()->andX(
$pathQuery,
$query->expr()->notLike('f.path', $query->createNamedParameter($archiveLikePath))
);
} else {
// Show only archive folder
$pathQuery = $query->expr()->like('f.path', $query->createNamedParameter($archiveLikePath));
}
} else {
// If getting non-recursively folder only check for parent
$pathQuery = $query->expr()->eq('f.parent', $query->createNamedParameter($folder->getId(), IQueryBuilder::PARAM_INT));
}
return $query->expr()->andX(
$query->expr()->eq('f.fileid', 'm.fileid'),
$query->expr()->in('f.storage', $query->createNamedParameter($finfo["storage"])),
$pathQuery,
);
}
/**
* Get the days response from the database for the timeline
* *
* @param Folder $folder The folder to get the days from
* @param bool $recursive Whether to get the days recursively
* @param bool $archive Whether to get the days only from the archive folder
* @param array $queryTransforms An array of query transforms to apply to the query
* @return array The days response * @return array The days response
*/ */
public function getDays( public function getDays(
@ -127,11 +35,13 @@ trait TimelineQueryDays {
$count = $query->func()->count($query->createFunction('DISTINCT m.fileid'), 'count'); $count = $query->func()->count($query->createFunction('DISTINCT m.fileid'), 'count');
$query->select('m.dayid', $count) $query->select('m.dayid', $count)
->from('memories', 'm') ->from('memories', 'm')
->innerJoin('m', 'filecache', 'f', $this->getFilecacheJoinQuery($query, $folder, $recursive, $archive)); ->innerJoin('m', 'filecache', 'f', $this->getFilecacheJoinQuery($query, $folder, $recursive, $archive))
;
// Group and sort by dayid // Group and sort by dayid
$query->groupBy('m.dayid') $query->groupBy('m.dayid')
->orderBy('m.dayid', 'DESC'); ->orderBy('m.dayid', 'DESC')
;
// Apply all transformations // Apply all transformations
$this->applyAllTransforms($queryTransforms, $query, $uid); $this->applyAllTransforms($queryTransforms, $query, $uid);
@ -139,17 +49,21 @@ trait TimelineQueryDays {
$cursor = $query->executeQuery(); $cursor = $query->executeQuery();
$rows = $cursor->fetchAll(); $rows = $cursor->fetchAll();
$cursor->closeCursor(); $cursor->closeCursor();
return $this->processDays($rows); return $this->processDays($rows);
} }
/** /**
* Get the day response from the database for the timeline * Get the day response from the database for the timeline.
* @param Folder $folder The folder to get the day from *
* @param string $uid The user id * @param Folder $folder The folder to get the day from
* @param int[] $dayid The day id * @param string $uid The user id
* @param bool $recursive If the query should be recursive * @param int[] $dayid The day id
* @param bool $archive If the query should include only the archive folder * @param bool $recursive If the query should be recursive
* @param array $queryTransforms The query transformations to apply * @param bool $archive If the query should include only the archive folder
* @param array $queryTransforms The query transformations to apply
* @param mixed $day_ids
*
* @return array An array of day responses * @return array An array of day responses
*/ */
public function getDay( public function getDay(
@ -170,10 +84,11 @@ trait TimelineQueryDays {
// when using DISTINCT on selected fields // when using DISTINCT on selected fields
$query->select($fileid, 'f.etag', 'm.isvideo', 'vco.categoryid', 'm.datetaken', 'm.dayid', 'm.w', 'm.h') $query->select($fileid, 'f.etag', 'm.isvideo', 'vco.categoryid', 'm.datetaken', 'm.dayid', 'm.w', 'm.h')
->from('memories', 'm') ->from('memories', 'm')
->innerJoin('m', 'filecache', 'f', $this->getFilecacheJoinQuery($query, $folder, $recursive, $archive)); ->innerJoin('m', 'filecache', 'f', $this->getFilecacheJoinQuery($query, $folder, $recursive, $archive))
;
// Filter by dayid unless wildcard // Filter by dayid unless wildcard
if (!is_null($day_ids)) { if (null !== $day_ids) {
$query->andWhere($query->expr()->in('m.dayid', $query->createNamedParameter($day_ids, IQueryBuilder::PARAM_INT_ARRAY))); $query->andWhere($query->expr()->in('m.dayid', $query->createNamedParameter($day_ids, IQueryBuilder::PARAM_INT_ARRAY)));
} else { } else {
// Limit wildcard to 100 results // Limit wildcard to 100 results
@ -193,6 +108,108 @@ trait TimelineQueryDays {
$cursor = $query->executeQuery(); $cursor = $query->executeQuery();
$rows = $cursor->fetchAll(); $rows = $cursor->fetchAll();
$cursor->closeCursor(); $cursor->closeCursor();
return $this->processDay($rows); return $this->processDay($rows);
} }
/**
* Process the days response.
*
* @param array $days
*/
private function processDays(&$days)
{
foreach ($days as &$row) {
$row['dayid'] = (int) ($row['dayid']);
$row['count'] = (int) ($row['count']);
// All transform processing
$this->processFace($row, true);
}
return $days;
}
/**
* Process the single day response.
*
* @param array $day
*/
private function processDay(&$day)
{
foreach ($day as &$row) {
// We don't need date taken (see query builder)
unset($row['datetaken']);
// Convert field types
$row['fileid'] = (int) ($row['fileid']);
$row['isvideo'] = (int) ($row['isvideo']);
$row['dayid'] = (int) ($row['dayid']);
$row['w'] = (int) ($row['w']);
$row['h'] = (int) ($row['h']);
if (!$row['isvideo']) {
unset($row['isvideo']);
}
if ($row['categoryid']) {
$row['isfavorite'] = 1;
}
unset($row['categoryid']);
// All transform processing
$this->processFace($row);
}
return $day;
}
/** Get the query for oc_filecache join */
private function getFilecacheJoinQuery(
IQueryBuilder &$query,
Folder &$folder,
bool $recursive,
bool $archive
) {
// Subquery to get storage and path
$subQuery = $query->getConnection()->getQueryBuilder();
$cursor = $subQuery->select('path', 'storage')->from('filecache')->where(
$subQuery->expr()->eq('fileid', $subQuery->createNamedParameter($folder->getId())),
)->executeQuery();
$finfo = $cursor->fetch();
$cursor->closeCursor();
if (empty($finfo)) {
throw new \Exception('Folder not found');
}
$pathQuery = null;
if ($recursive) {
// Filter by path for recursive query
$likePath = $finfo['path'];
if (!empty($likePath)) {
$likePath .= '/';
}
$pathQuery = $query->expr()->like('f.path', $query->createNamedParameter($likePath.'%'));
// Exclude/show archive folder
$archiveLikePath = $likePath.\OCA\Memories\Util::$ARCHIVE_FOLDER.'/%';
if (!$archive) {
// Exclude archive folder
$pathQuery = $query->expr()->andX(
$pathQuery,
$query->expr()->notLike('f.path', $query->createNamedParameter($archiveLikePath))
);
} else {
// Show only archive folder
$pathQuery = $query->expr()->like('f.path', $query->createNamedParameter($archiveLikePath));
}
} else {
// If getting non-recursively folder only check for parent
$pathQuery = $query->expr()->eq('f.parent', $query->createNamedParameter($folder->getId(), IQueryBuilder::PARAM_INT));
}
return $query->expr()->andX(
$query->expr()->eq('f.fileid', 'm.fileid'),
$query->expr()->in('f.storage', $query->createNamedParameter($finfo['storage'])),
$pathQuery,
);
}
} }

View File

@ -1,19 +1,24 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace OCA\Memories\Db; namespace OCA\Memories\Db;
use OCP\IDBConnection;
use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\Folder; use OCP\Files\Folder;
use OCP\IDBConnection;
trait TimelineQueryFaces { trait TimelineQueryFaces
{
protected IDBConnection $connection; protected IDBConnection $connection;
public function transformFaceFilter(IQueryBuilder &$query, string $userId, string $faceStr) { public function transformFaceFilter(IQueryBuilder &$query, string $userId, string $faceStr)
{
// Get title and uid of face user // Get title and uid of face user
$faceNames = explode('/', $faceStr); $faceNames = explode('/', $faceStr);
if (count($faceNames) !== 2) throw new \Exception("Invalid face query"); if (2 !== \count($faceNames)) {
throw new \Exception('Invalid face query');
}
$faceUid = $faceNames[0]; $faceUid = $faceNames[0];
$faceName = $faceNames[1]; $faceName = $faceNames[1];
@ -31,7 +36,8 @@ trait TimelineQueryFaces {
)); ));
} }
public function transformFaceRect(IQueryBuilder &$query, string $userId) { public function transformFaceRect(IQueryBuilder &$query, string $userId)
{
// Include detection params in response // Include detection params in response
$query->addSelect( $query->addSelect(
'rfd.width AS face_w', 'rfd.width AS face_w',
@ -41,26 +47,8 @@ trait TimelineQueryFaces {
); );
} }
/** Convert face fields to object */ public function getFaces(Folder $folder)
private function processFace(&$row, $days=false) { {
if (!isset($row) || !isset($row['face_w'])) return;
if (!$days) {
$row["facerect"] = [
"w" => floatval($row["face_w"]),
"h" => floatval($row["face_h"]),
"x" => floatval($row["face_x"]),
"y" => floatval($row["face_y"]),
];
}
unset($row["face_w"]);
unset($row["face_h"]);
unset($row["face_x"]);
unset($row["face_y"]);
}
public function getFaces(Folder $folder) {
$query = $this->connection->getQueryBuilder(); $query = $this->connection->getQueryBuilder();
// SELECT all face clusters // SELECT all face clusters
@ -88,25 +76,31 @@ trait TimelineQueryFaces {
$faces = $query->executeQuery()->fetchAll(); $faces = $query->executeQuery()->fetchAll();
// Post process // Post process
foreach($faces as &$row) { foreach ($faces as &$row) {
$row['id'] = intval($row['id']); $row['id'] = (int) ($row['id']);
$row["name"] = $row["title"]; $row['name'] = $row['title'];
unset($row["title"]); unset($row['title']);
$row["count"] = intval($row["count"]); $row['count'] = (int) ($row['count']);
} }
return $faces; return $faces;
} }
public function getFacePreviewDetection(Folder &$folder, int $id) { public function getFacePreviewDetection(Folder &$folder, int $id)
{
$query = $this->connection->getQueryBuilder(); $query = $this->connection->getQueryBuilder();
// SELECT face detections for ID // SELECT face detections for ID
$query->select( $query->select(
'rfd.file_id', // Needed to get the actual file 'rfd.file_id', // Get actual file
'rfd.x', 'rfd.y', 'rfd.width', 'rfd.height', // Image cropping 'rfd.x', // Image cropping
'm.w as image_width', 'm.h as image_height', // Scoring 'rfd.y',
'm.fileid', 'm.datetaken', // Just in case, for postgres 'rfd.width',
'rfd.height',
'm.w as image_width', // Scoring
'm.h as image_height',
'm.fileid',
'm.datetaken', // Just in case, for postgres
)->from('recognize_face_detections', 'rfd'); )->from('recognize_face_detections', 'rfd');
$query->where($query->expr()->eq('rfd.cluster_id', $query->createNamedParameter($id))); $query->where($query->expr()->eq('rfd.cluster_id', $query->createNamedParameter($id)));
@ -132,31 +126,50 @@ trait TimelineQueryFaces {
// Score the face detections // Score the face detections
foreach ($previews as &$p) { foreach ($previews as &$p) {
// Get actual pixel size of face // Get actual pixel size of face
$iw = min(intval($p["image_width"] ?: 512), 2048); $iw = min((int) ($p['image_width'] ?: 512), 2048);
$ih = min(intval($p["image_height"] ?: 512), 2048); $ih = min((int) ($p['image_height'] ?: 512), 2048);
$w = floatval($p["width"]) * $iw; $w = (float) ($p['width']) * $iw;
$h = floatval($p["height"]) * $ih; $h = (float) ($p['height']) * $ih;
// Get center of face // Get center of face
$x = floatval($p["x"]) + floatval($p["width"]) / 2; $x = (float) ($p['x']) + (float) ($p['width']) / 2;
$y = floatval($p["y"]) + floatval($p["height"]) / 2; $y = (float) ($p['y']) + (float) ($p['height']) / 2;
// 3D normal distribution - if the face is closer to the center, it's better // 3D normal distribution - if the face is closer to the center, it's better
$positionScore = exp(-pow($x - 0.5, 2) * 4) * exp(-pow($y - 0.5, 2) * 4); $positionScore = exp(-($x - 0.5) ** 2 * 4) * exp(-($y - 0.5) ** 2 * 4);
// Root size distribution - if the face is bigger, it's better, // Root size distribution - if the face is bigger, it's better,
// but it doesn't matter beyond a certain point, especially 256px ;) // but it doesn't matter beyond a certain point, especially 256px ;)
$sizeScore = pow($w * 100, 1/4) * pow($h * 100, 1/4); $sizeScore = ($w * 100) ** (1 / 4) * ($h * 100) ** (1 / 4);
// Combine scores // Combine scores
$p["score"] = $positionScore * $sizeScore; $p['score'] = $positionScore * $sizeScore;
} }
// Sort previews by score descending // Sort previews by score descending
usort($previews, function($a, $b) { usort($previews, function ($a, $b) {
return $b["score"] <=> $a["score"]; return $b['score'] <=> $a['score'];
}); });
return $previews; return $previews;
} }
}
/** Convert face fields to object */
private function processFace(&$row, $days = false)
{
if (!isset($row) || !isset($row['face_w'])) {
return;
}
if (!$days) {
$row['facerect'] = [
'w' => (float) ($row['face_w']),
'h' => (float) ($row['face_h']),
'x' => (float) ($row['face_x']),
'y' => (float) ($row['face_y']),
];
}
unset($row['face_w'], $row['face_h'], $row['face_x'], $row['face_y']);
}
}

View File

@ -1,4 +1,5 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace OCA\Memories\Db; namespace OCA\Memories\Db;
@ -6,50 +7,59 @@ namespace OCA\Memories\Db;
use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\ITags; use OCP\ITags;
trait TimelineQueryFilters { trait TimelineQueryFilters
private function applyAllTransforms(array $transforms, IQueryBuilder &$query, string $uid): void { {
foreach ($transforms as &$transform) { public function transformFavoriteFilter(IQueryBuilder &$query, string $userId)
$fun = array_slice($transform, 0, 2); {
$params = array_slice($transform, 2);
array_unshift($params, $uid);
array_unshift($params, $query);
$fun(...$params);
}
}
public function transformFavoriteFilter(IQueryBuilder &$query, string $userId) {
$query->innerJoin('m', 'vcategory_to_object', 'vcoi', $query->expr()->andX( $query->innerJoin('m', 'vcategory_to_object', 'vcoi', $query->expr()->andX(
$query->expr()->eq('vcoi.objid', 'm.fileid'), $query->expr()->eq('vcoi.objid', 'm.fileid'),
$query->expr()->in('vcoi.categoryid', $this->getFavoriteVCategoryFun($query, $userId)), $query->expr()->in('vcoi.categoryid', $this->getFavoriteVCategoryFun($query, $userId)),
)); ));
} }
public function addFavoriteTag(IQueryBuilder &$query, string $userId) { public function addFavoriteTag(IQueryBuilder &$query, string $userId)
{
$query->leftJoin('m', 'vcategory_to_object', 'vco', $query->expr()->andX( $query->leftJoin('m', 'vcategory_to_object', 'vco', $query->expr()->andX(
$query->expr()->eq('vco.objid', 'm.fileid'), $query->expr()->eq('vco.objid', 'm.fileid'),
$query->expr()->in('vco.categoryid', $this->getFavoriteVCategoryFun($query, $userId)), $query->expr()->in('vco.categoryid', $this->getFavoriteVCategoryFun($query, $userId)),
)); ));
} }
private function getFavoriteVCategoryFun(IQueryBuilder &$query, string $userId) { public function transformVideoFilter(IQueryBuilder &$query, string $userId)
return $query->createFunction( {
$query->getConnection()->getQueryBuilder()->select('id')->from('vcategory', 'vc')->where(
$query->expr()->andX(
$query->expr()->eq('type', $query->createNamedParameter("files")),
$query->expr()->eq('uid', $query->createNamedParameter($userId)),
$query->expr()->eq('category', $query->createNamedParameter(ITags::TAG_FAVORITE)),
))->getSQL());
}
public function transformVideoFilter(IQueryBuilder &$query, string $userId) {
$query->andWhere($query->expr()->eq('m.isvideo', $query->createNamedParameter('1'))); $query->andWhere($query->expr()->eq('m.isvideo', $query->createNamedParameter('1')));
} }
public function transformLimitDay(IQueryBuilder &$query, string $userId, int $limit) { public function transformLimitDay(IQueryBuilder &$query, string $userId, int $limit)
{
// The valid range for limit is 1 - 100; otherwise abort // The valid range for limit is 1 - 100; otherwise abort
if ($limit < 1 || $limit > 100) { if ($limit < 1 || $limit > 100) {
return; return;
} }
$query->setMaxResults($limit); $query->setMaxResults($limit);
} }
}
private function applyAllTransforms(array $transforms, IQueryBuilder &$query, string $uid): void
{
foreach ($transforms as &$transform) {
$fun = \array_slice($transform, 0, 2);
$params = \array_slice($transform, 2);
array_unshift($params, $uid);
array_unshift($params, $query);
$fun(...$params);
}
}
private function getFavoriteVCategoryFun(IQueryBuilder &$query, string $userId)
{
return $query->createFunction(
$query->getConnection()->getQueryBuilder()->select('id')->from('vcategory', 'vc')->where(
$query->expr()->andX(
$query->expr()->eq('type', $query->createNamedParameter('files')),
$query->expr()->eq('uid', $query->createNamedParameter($userId)),
$query->expr()->eq('category', $query->createNamedParameter(ITags::TAG_FAVORITE)),
)
)->getSQL()
);
}
}

View File

@ -1,38 +1,45 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace OCA\Memories\Db; namespace OCA\Memories\Db;
use OCP\IDBConnection;
use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\Folder; use OCP\Files\Folder;
use OCP\IDBConnection;
trait TimelineQueryTags { trait TimelineQueryTags
{
protected IDBConnection $connection; protected IDBConnection $connection;
public function getSystemTagId(IQueryBuilder &$query, string $tagName) { public function getSystemTagId(IQueryBuilder &$query, string $tagName)
{
$sqb = $query->getConnection()->getQueryBuilder(); $sqb = $query->getConnection()->getQueryBuilder();
return $sqb->select('id')->from('systemtag')->where( return $sqb->select('id')->from('systemtag')->where(
$sqb->expr()->andX( $sqb->expr()->andX(
$sqb->expr()->eq('name', $sqb->createNamedParameter($tagName)), $sqb->expr()->eq('name', $sqb->createNamedParameter($tagName)),
$sqb->expr()->eq('visibility', $sqb->createNamedParameter(1)), $sqb->expr()->eq('visibility', $sqb->createNamedParameter(1)),
))->executeQuery()->fetchOne(); )
)->executeQuery()->fetchOne();
} }
public function transformTagFilter(IQueryBuilder &$query, string $userId, string $tagName) { public function transformTagFilter(IQueryBuilder &$query, string $userId, string $tagName)
{
$tagId = $this->getSystemTagId($query, $tagName); $tagId = $this->getSystemTagId($query, $tagName);
if ($tagId === FALSE) { if (false === $tagId) {
throw new \Exception("Tag $tagName not found"); throw new \Exception("Tag {$tagName} not found");
} }
$query->innerJoin('m', 'systemtag_object_mapping', 'stom', $query->expr()->andX( $query->innerJoin('m', 'systemtag_object_mapping', 'stom', $query->expr()->andX(
$query->expr()->eq('stom.objecttype', $query->createNamedParameter("files")), $query->expr()->eq('stom.objecttype', $query->createNamedParameter('files')),
$query->expr()->eq('stom.objectid', 'm.fileid'), $query->expr()->eq('stom.objectid', 'm.fileid'),
$query->expr()->eq('stom.systemtagid', $query->createNamedParameter($tagId)), $query->expr()->eq('stom.systemtagid', $query->createNamedParameter($tagId)),
)); ));
} }
public function getTags(Folder $folder) { public function getTags(Folder $folder)
{
$query = $this->connection->getQueryBuilder(); $query = $this->connection->getQueryBuilder();
// SELECT visible tag name and count of photos // SELECT visible tag name and count of photos
@ -44,7 +51,7 @@ trait TimelineQueryTags {
// WHERE there are items with this tag // WHERE there are items with this tag
$query->innerJoin('st', 'systemtag_object_mapping', 'stom', $query->expr()->andX( $query->innerJoin('st', 'systemtag_object_mapping', 'stom', $query->expr()->andX(
$query->expr()->eq('stom.systemtagid', 'st.id'), $query->expr()->eq('stom.systemtagid', 'st.id'),
$query->expr()->eq('stom.objecttype', $query->createNamedParameter("files")), $query->expr()->eq('stom.objecttype', $query->createNamedParameter('files')),
)); ));
// WHERE these items are memories indexed photos // WHERE these items are memories indexed photos
@ -62,15 +69,16 @@ trait TimelineQueryTags {
$tags = $query->executeQuery()->fetchAll(); $tags = $query->executeQuery()->fetchAll();
// Post process // Post process
foreach($tags as &$row) { foreach ($tags as &$row) {
$row["id"] = intval($row["id"]); $row['id'] = (int) ($row['id']);
$row["count"] = intval($row["count"]); $row['count'] = (int) ($row['count']);
} }
return $tags; return $tags;
} }
public function getTagPreviews(Folder $folder) { public function getTagPreviews(Folder $folder)
{
$query = $this->connection->getQueryBuilder(); $query = $this->connection->getQueryBuilder();
// Windowing // Windowing
@ -78,8 +86,10 @@ trait TimelineQueryTags {
// SELECT all photos with this tag // SELECT all photos with this tag
$query->select('f.fileid', 'f.etag', 'stom.systemtagid', $rowNumber)->from( $query->select('f.fileid', 'f.etag', 'stom.systemtagid', $rowNumber)->from(
'systemtag_object_mapping', 'stom')->where( 'systemtag_object_mapping',
$query->expr()->eq('stom.objecttype', $query->createNamedParameter("files")), 'stom'
)->where(
$query->expr()->eq('stom.objecttype', $query->createNamedParameter('files')),
); );
// WHERE these items are memories indexed photos // WHERE these items are memories indexed photos
@ -89,7 +99,7 @@ trait TimelineQueryTags {
$query->innerJoin('m', 'filecache', 'f', $this->getFilecacheJoinQuery($query, $folder, true, false)); $query->innerJoin('m', 'filecache', 'f', $this->getFilecacheJoinQuery($query, $folder, true, false));
// Make this a sub query // Make this a sub query
$fun = $query->createFunction('(' . $query->getSQL() . ')'); $fun = $query->createFunction('('.$query->getSQL().')');
// Create outer query // Create outer query
$outerQuery = $this->connection->getQueryBuilder(); $outerQuery = $this->connection->getQueryBuilder();
@ -102,12 +112,12 @@ trait TimelineQueryTags {
$previews = $outerQuery->executeQuery()->fetchAll(); $previews = $outerQuery->executeQuery()->fetchAll();
// Post-process // Post-process
foreach($previews as &$row) { foreach ($previews as &$row) {
$row["fileid"] = intval($row["fileid"]); $row['fileid'] = (int) ($row['fileid']);
$row["systemtagid"] = intval($row["systemtagid"]); $row['systemtagid'] = (int) ($row['systemtagid']);
unset($row["n"]); unset($row['n']);
} }
return $previews; return $previews;
} }
} }

View File

@ -1,44 +1,50 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace OCA\Memories\Db; namespace OCA\Memories\Db;
use OCA\Memories\AppInfo\Application; use OCA\Memories\AppInfo\Application;
use OCA\Memories\Exif; use OCA\Memories\Exif;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\File; use OCP\Files\File;
use OCP\IDBConnection; use OCP\IDBConnection;
use OCP\DB\QueryBuilder\IQueryBuilder;
class TimelineWrite { class TimelineWrite
{
protected IDBConnection $connection; protected IDBConnection $connection;
public function __construct(IDBConnection $connection) { public function __construct(IDBConnection $connection)
{
$this->connection = $connection; $this->connection = $connection;
} }
/** /**
* Check if a file has a valid mimetype for processing * Check if a file has a valid mimetype for processing.
* @param File $file *
* @return int 0 for invalid, 1 for image, 2 for video * @return int 0 for invalid, 1 for image, 2 for video
*/ */
public function getFileType(File $file): int { public function getFileType(File $file): int
{
$mime = $file->getMimeType(); $mime = $file->getMimeType();
if (in_array($mime, Application::IMAGE_MIMES)) { if (\in_array($mime, Application::IMAGE_MIMES, true)) {
return 1; return 1;
} elseif (in_array($mime, Application::VIDEO_MIMES)) { }
if (\in_array($mime, Application::VIDEO_MIMES, true)) {
return 2; return 2;
} }
return 0; return 0;
} }
/** /**
* Process a file to insert Exif data into the database * Process a file to insert Exif data into the database.
* @param File $file *
* @return int 2 if processed, 1 if skipped, 0 if not valid * @return int 2 if processed, 1 if skipped, 0 if not valid
*/ */
public function processFile( public function processFile(
File &$file, File &$file,
bool $force=false bool $force = false
): int { ): int {
// There is no easy way to UPSERT in a standard SQL way, so just // There is no easy way to UPSERT in a standard SQL way, so just
// do multiple calls. The worst that can happen is more updates, // do multiple calls. The worst that can happen is more updates,
@ -47,7 +53,7 @@ class TimelineWrite {
// Check if we want to process this file // Check if we want to process this file
$fileType = $this->getFileType($file); $fileType = $this->getFileType($file);
$isvideo = ($fileType === 2); $isvideo = (2 === $fileType);
if (!$fileType) { if (!$fileType) {
return 0; return 0;
} }
@ -60,19 +66,22 @@ class TimelineWrite {
$query = $this->connection->getQueryBuilder(); $query = $this->connection->getQueryBuilder();
$query->select('fileid', 'mtime') $query->select('fileid', 'mtime')
->from('memories') ->from('memories')
->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))); ->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)))
;
$cursor = $query->executeQuery(); $cursor = $query->executeQuery();
$prevRow = $cursor->fetch(); $prevRow = $cursor->fetch();
$cursor->closeCursor(); $cursor->closeCursor();
if ($prevRow && !$force && intval($prevRow['mtime']) === $mtime) { if ($prevRow && !$force && (int) ($prevRow['mtime']) === $mtime) {
return 1; return 1;
} }
// Get exif data // Get exif data
$exif = []; $exif = [];
try { try {
$exif = Exif::getExifFromFile($file); $exif = Exif::getExifFromFile($file);
} catch (\Exception $e) {} } catch (\Exception $e) {
}
// Get more parameters // Get more parameters
$dateTaken = Exif::getDateTaken($file, $exif); $dateTaken = Exif::getDateTaken($file, $exif);
@ -89,7 +98,8 @@ class TimelineWrite {
->set('isvideo', $query->createNamedParameter($isvideo, IQueryBuilder::PARAM_INT)) ->set('isvideo', $query->createNamedParameter($isvideo, IQueryBuilder::PARAM_INT))
->set('w', $query->createNamedParameter($w, IQueryBuilder::PARAM_INT)) ->set('w', $query->createNamedParameter($w, IQueryBuilder::PARAM_INT))
->set('h', $query->createNamedParameter($h, IQueryBuilder::PARAM_INT)) ->set('h', $query->createNamedParameter($h, IQueryBuilder::PARAM_INT))
->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))); ->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)))
;
$query->executeStatement(); $query->executeStatement();
} else { } else {
// Try to create new row // Try to create new row
@ -103,10 +113,11 @@ class TimelineWrite {
'isvideo' => $query->createNamedParameter($isvideo, IQueryBuilder::PARAM_INT), 'isvideo' => $query->createNamedParameter($isvideo, IQueryBuilder::PARAM_INT),
'w' => $query->createNamedParameter($w, IQueryBuilder::PARAM_INT), 'w' => $query->createNamedParameter($w, IQueryBuilder::PARAM_INT),
'h' => $query->createNamedParameter($h, IQueryBuilder::PARAM_INT), 'h' => $query->createNamedParameter($h, IQueryBuilder::PARAM_INT),
]); ])
;
$query->executeStatement(); $query->executeStatement();
} catch (\Exception $ex) { } catch (\Exception $ex) {
error_log("Failed to create memories record: " . $ex->getMessage()); error_log('Failed to create memories record: '.$ex->getMessage());
} }
} }
@ -114,23 +125,26 @@ class TimelineWrite {
} }
/** /**
* Remove a file from the exif database * Remove a file from the exif database.
* @param File $file
*/ */
public function deleteFile(File &$file) { public function deleteFile(File &$file)
{
$query = $this->connection->getQueryBuilder(); $query = $this->connection->getQueryBuilder();
$query->delete('memories') $query->delete('memories')
->where($query->expr()->eq('fileid', $query->createNamedParameter($file->getId(), IQueryBuilder::PARAM_INT))); ->where($query->expr()->eq('fileid', $query->createNamedParameter($file->getId(), IQueryBuilder::PARAM_INT)))
;
$query->executeStatement(); $query->executeStatement();
} }
/** /**
* Clear the entire index. Does not need confirmation! * Clear the entire index. Does not need confirmation!
*
* @param File $file * @param File $file
*/ */
public function clear() { public function clear()
{
$query = $this->connection->getQueryBuilder(); $query = $this->connection->getQueryBuilder();
$query->delete('memories'); $query->delete('memories');
$query->executeStatement(); $query->executeStatement();
} }
} }

View File

@ -1,4 +1,5 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace OCA\Memories; namespace OCA\Memories;
@ -7,24 +8,15 @@ use OCA\Memories\AppInfo\Application;
use OCP\Files\File; use OCP\Files\File;
use OCP\IConfig; use OCP\IConfig;
class Exif { class Exif
{
/** Opened instance of exiftool when running in command mode */ /** Opened instance of exiftool when running in command mode */
private static $staticProc = null; private static $staticProc;
private static $staticPipes = null; private static $staticPipes;
private static $noStaticProc = false; private static $noStaticProc = false;
/** Initialize static exiftool process for local reads */ public static function closeStaticExiftoolProc()
private static function initializeStaticExiftoolProc() { {
self::closeStaticExiftoolProc();
self::$staticProc = proc_open(['exiftool', '-stay_open', 'true', '-@', '-'], [
0 => array('pipe', 'r'),
1 => array('pipe', 'w'),
2 => array('pipe', 'w'),
], self::$staticPipes);
stream_set_blocking(self::$staticPipes[1], false);
}
public static function closeStaticExiftoolProc() {
try { try {
if (self::$staticProc) { if (self::$staticProc) {
fclose(self::$staticPipes[0]); fclose(self::$staticPipes[0]);
@ -34,15 +26,18 @@ class Exif {
self::$staticProc = null; self::$staticProc = null;
self::$staticPipes = null; self::$staticPipes = null;
} }
} catch (\Exception $ex) {} } catch (\Exception $ex) {
}
} }
public static function restartStaticExiftoolProc() { public static function restartStaticExiftoolProc()
{
self::closeStaticExiftoolProc(); self::closeStaticExiftoolProc();
self::ensureStaticExiftoolProc(); self::ensureStaticExiftoolProc();
} }
public static function ensureStaticExiftoolProc() { public static function ensureStaticExiftoolProc()
{
if (self::$noStaticProc) { if (self::$noStaticProc) {
return; return;
} }
@ -50,15 +45,16 @@ class Exif {
if (!self::$staticProc) { if (!self::$staticProc) {
self::initializeStaticExiftoolProc(); self::initializeStaticExiftoolProc();
usleep(500000); // wait if error usleep(500000); // wait if error
if (!proc_get_status(self::$staticProc)["running"]) { if (!proc_get_status(self::$staticProc)['running']) {
error_log("WARN: Failed to create stay_open exiftool process"); error_log('WARN: Failed to create stay_open exiftool process');
self::$noStaticProc = true; self::$noStaticProc = true;
self::$staticProc = null; self::$staticProc = null;
} }
return; return;
} }
if (!proc_get_status(self::$staticProc)["running"]) { if (!proc_get_status(self::$staticProc)['running']) {
self::$staticProc = null; self::$staticProc = null;
self::ensureStaticExiftoolProc(); self::ensureStaticExiftoolProc();
} }
@ -66,49 +62,51 @@ class Exif {
/** /**
* Get the path to the user's configured photos directory. * Get the path to the user's configured photos directory.
* @param IConfig $config
* @param string $userId
*/ */
public static function getPhotosPath(IConfig &$config, string &$userId) { public static function getPhotosPath(IConfig &$config, string &$userId)
{
$p = $config->getUserValue($userId, Application::APPNAME, 'timelinePath', ''); $p = $config->getUserValue($userId, Application::APPNAME, 'timelinePath', '');
if (empty($p)) { if (empty($p)) {
return 'Photos/'; return 'Photos/';
} }
return self::sanitizePath($p); return self::sanitizePath($p);
} }
/** /**
* Sanitize a path to keep only ASCII characters and special characters. * Sanitize a path to keep only ASCII characters and special characters.
* @param string $path
*/ */
public static function sanitizePath(string $path) { public static function sanitizePath(string $path)
return mb_ereg_replace("([^\w\s\d\-_~,;\[\]\(\).\/])", '', $path); {
return mb_ereg_replace('([^\\w\\s\\d\\-_~,;\\[\\]\\(\\).\\/])', '', $path);
} }
/** /**
* Keep only one slash if multiple repeating * Keep only one slash if multiple repeating.
*/ */
public static function removeExtraSlash(string $path) { public static function removeExtraSlash(string $path)
{
return mb_ereg_replace('\/\/+', '/', $path); return mb_ereg_replace('\/\/+', '/', $path);
} }
/** /**
* Remove any leading slash present on the path * Remove any leading slash present on the path.
*/ */
public static function removeLeadingSlash(string $path) { public static function removeLeadingSlash(string $path)
{
return mb_ereg_replace('~^/+~', '', $path); return mb_ereg_replace('~^/+~', '', $path);
} }
/** /**
* Get exif data as a JSON object from a Nextcloud file. * Get exif data as a JSON object from a Nextcloud file.
* @param File $file
*/ */
public static function getExifFromFile(File &$file) { public static function getExifFromFile(File &$file)
{
// Borrowed from previews // Borrowed from previews
// https://github.com/nextcloud/server/blob/19f68b3011a3c040899fb84975a28bd746bddb4b/lib/private/Preview/ProviderV2.php // https://github.com/nextcloud/server/blob/19f68b3011a3c040899fb84975a28bd746bddb4b/lib/private/Preview/ProviderV2.php
if (!$file->isEncrypted() && $file->getStorage()->isLocal()) { if (!$file->isEncrypted() && $file->getStorage()->isLocal()) {
$path = $file->getStorage()->getLocalFile($file->getInternalPath()); $path = $file->getStorage()->getLocalFile($file->getInternalPath());
if (is_string($path)) { if (\is_string($path)) {
return self::getExifFromLocalPath($path); return self::getExifFromLocalPath($path);
} }
} }
@ -121,96 +119,35 @@ class Exif {
$exif = self::getExifFromStream($handle); $exif = self::getExifFromStream($handle);
fclose($handle); fclose($handle);
return $exif; return $exif;
} }
/** Get exif data as a JSON object from a local file path */ /** Get exif data as a JSON object from a local file path */
public static function getExifFromLocalPath(string &$path) { public static function getExifFromLocalPath(string &$path)
if (!is_null(self::$staticProc)) { {
if (null !== self::$staticProc) {
self::ensureStaticExiftoolProc(); self::ensureStaticExiftoolProc();
return self::getExifFromLocalPathWithStaticProc($path); return self::getExifFromLocalPathWithStaticProc($path);
} else {
return self::getExifFromLocalPathWithSeparateProc($path);
}
}
/**
* Read from non blocking handle or throw timeout
* @param resource $handle
* @param int $timeout milliseconds
* @param string $delimiter null for eof
*/
private static function readOrTimeout($handle, $timeout, $delimiter=null) {
$buf = '';
$waitedMs = 0;
while ($waitedMs < $timeout && ($delimiter ? !str_ends_with($buf, $delimiter) : !feof($handle))) {
$r = stream_get_contents($handle);
if (empty($r)) {
$waitedMs++;
usleep(1000);
continue;
}
$buf .= $r;
} }
if ($waitedMs >= $timeout) { return self::getExifFromLocalPathWithSeparateProc($path);
throw new \Exception('Timeout');
}
return $buf;
}
private static function getExifFromLocalPathWithStaticProc(string &$path) {
fwrite(self::$staticPipes[0], "$path\n-json\n-api\nQuickTimeUTC=1\n-n\n-execute\n");
fflush(self::$staticPipes[0]);
$readyToken = "\n{ready}\n";
try {
$buf = self::readOrTimeout(self::$staticPipes[1], 5000, $readyToken);
$tokPos = strrpos($buf, $readyToken);
$buf = substr($buf, 0, $tokPos);
return self::processStdout($buf);
} catch (\Exception $ex) {
error_log("ERROR: Exiftool may have crashed, restarting process [$path]");
self::restartStaticExiftoolProc();
throw new \Exception("Nothing to read from Exiftool");
}
}
private static function getExifFromLocalPathWithSeparateProc(string &$path) {
$pipes = [];
$proc = proc_open(['exiftool', '-api', 'QuickTimeUTC=1', '-n', '-json', $path], [
1 => array('pipe', 'w'),
2 => array('pipe', 'w'),
], $pipes);
stream_set_blocking($pipes[1], false);
try {
$stdout = self::readOrTimeout($pipes[1], 5000);
return self::processStdout($stdout);
} catch (\Exception $ex) {
error_log("Exiftool timeout: [$path]");
throw new \Exception("Could not read from Exiftool");
} finally {
fclose($pipes[1]);
fclose($pipes[2]);
proc_terminate($proc);
}
} }
/** /**
* Get exif data as a JSON object from a stream. * Get exif data as a JSON object from a stream.
*
* @param resource $handle * @param resource $handle
*/ */
public static function getExifFromStream(&$handle) { public static function getExifFromStream(&$handle)
{
// Start exiftool and output to json // Start exiftool and output to json
$pipes = []; $pipes = [];
$proc = proc_open(['exiftool', '-api', 'QuickTimeUTC=1', '-n', '-json', '-fast', '-'], [ $proc = proc_open(['exiftool', '-api', 'QuickTimeUTC=1', '-n', '-json', '-fast', '-'], [
0 => array('pipe', 'rb'), 0 => ['pipe', 'rb'],
1 => array('pipe', 'w'), 1 => ['pipe', 'w'],
2 => array('pipe', 'w'), 2 => ['pipe', 'w'],
], $pipes); ], $pipes);
// Write the file to exiftool's stdin // Write the file to exiftool's stdin
@ -221,12 +158,15 @@ class Exif {
// Get output from exiftool // Get output from exiftool
stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[1], false);
try { try {
$stdout = self::readOrTimeout($pipes[1], 5000); $stdout = self::readOrTimeout($pipes[1], 5000);
return self::processStdout($stdout); return self::processStdout($stdout);
} catch (\Exception $ex) { } catch (\Exception $ex) {
error_log("Exiftool timeout for file stream: " . $ex->getMessage()); error_log('Exiftool timeout for file stream: '.$ex->getMessage());
throw new \Exception("Could not read from Exiftool");
throw new \Exception('Could not read from Exiftool');
} finally { } finally {
fclose($pipes[1]); fclose($pipes[1]);
fclose($pipes[2]); fclose($pipes[2]);
@ -234,36 +174,30 @@ class Exif {
} }
} }
/** Get json array from stdout of exiftool */
private static function processStdout(string &$stdout) {
$json = json_decode($stdout, true);
if (!$json) {
throw new \Exception('Could not read exif data');
}
return $json[0];
}
/** /**
* Parse date from exif format and throw error if invalid * Parse date from exif format and throw error if invalid.
* *
* @param string $dt * @param string $dt
* @param mixed $date
*
* @return int unix timestamp * @return int unix timestamp
*/ */
public static function parseExifDate($date) { public static function parseExifDate($date)
{
$dt = $date; $dt = $date;
if (isset($dt) && is_string($dt) && !empty($dt)) { if (isset($dt) && \is_string($dt) && !empty($dt)) {
$dt = explode('-', explode('+', $dt, 2)[0], 2)[0]; // get rid of timezone if present $dt = explode('-', explode('+', $dt, 2)[0], 2)[0]; // get rid of timezone if present
$dt = \DateTime::createFromFormat('Y:m:d H:i:s', $dt); $dt = \DateTime::createFromFormat('Y:m:d H:i:s', $dt);
if (!$dt) { if (!$dt) {
throw new \Exception("Invalid date: $date"); throw new \Exception("Invalid date: {$date}");
} }
if ($dt && $dt->getTimestamp() > -5364662400) { // 1800 A.D. if ($dt && $dt->getTimestamp() > -5364662400) { // 1800 A.D.
return $dt->getTimestamp(); return $dt->getTimestamp();
} else {
throw new \Exception("Date too old: $date");
} }
throw new \Exception("Date too old: {$date}");
} else { } else {
throw new \Exception("No date provided"); throw new \Exception('No date provided');
} }
} }
@ -273,7 +207,8 @@ class Exif {
* *
* @param int $epoch * @param int $epoch
*/ */
public static function forgetTimezone($epoch) { public static function forgetTimezone($epoch)
{
$dt = new \DateTime(); $dt = new \DateTime();
$dt->setTimestamp($epoch); $dt->setTimestamp($epoch);
$tz = getenv('TZ'); // at least works on debian ... $tz = getenv('TZ'); // at least works on debian ...
@ -281,16 +216,17 @@ class Exif {
$dt->setTimezone(new \DateTimeZone($tz)); $dt->setTimezone(new \DateTimeZone($tz));
} }
$utc = new \DateTime($dt->format('Y-m-d H:i:s'), new \DateTimeZone('UTC')); $utc = new \DateTime($dt->format('Y-m-d H:i:s'), new \DateTimeZone('UTC'));
return $utc->getTimestamp(); return $utc->getTimestamp();
} }
/** /**
* Get the date taken from either the file or exif data if available. * Get the date taken from either the file or exif data if available.
* @param File $file *
* @param array $exif
* @return int unix timestamp * @return int unix timestamp
*/ */
public static function getDateTaken(File &$file, array &$exif) { public static function getDateTaken(File &$file, array &$exif)
{
$dt = $exif['DateTimeOriginal'] ?? null; $dt = $exif['DateTimeOriginal'] ?? null;
if (!isset($dt) || empty($dt)) { if (!isset($dt) || empty($dt)) {
$dt = $exif['CreateDate'] ?? null; $dt = $exif['CreateDate'] ?? null;
@ -307,25 +243,27 @@ class Exif {
$dateTaken = $file->getCreationTime(); $dateTaken = $file->getCreationTime();
// Fall back to modification time // Fall back to modification time
if ($dateTaken == 0) { if (0 === $dateTaken) {
$dateTaken = $file->getMtime(); $dateTaken = $file->getMtime();
} }
return self::forgetTimezone($dateTaken); return self::forgetTimezone($dateTaken);
} }
/** /**
* Get image dimensions from Exif data * Get image dimensions from Exif data.
* @param array $exif *
* @return array [width, height] * @return array [width, height]
*/ */
public static function getDimensions(array &$exif) { public static function getDimensions(array &$exif)
{
$width = $exif['ImageWidth'] ?? 0; $width = $exif['ImageWidth'] ?? 0;
$height = $exif['ImageHeight'] ?? 0; $height = $exif['ImageHeight'] ?? 0;
// Check if image is rotated and we need to swap width and height // Check if image is rotated and we need to swap width and height
$rotation = $exif['Rotation'] ?? 0; $rotation = $exif['Rotation'] ?? 0;
$orientation = $exif['Orientation'] ?? 0; $orientation = $exif['Orientation'] ?? 0;
if (in_array($orientation, [5, 6, 7, 8]) || in_array($rotation, [90, 270])) { if (\in_array($orientation, [5, 6, 7, 8], true) || \in_array($rotation, [90, 270], true)) {
return [$height, $width]; return [$height, $width];
} }
@ -333,16 +271,16 @@ class Exif {
} }
/** /**
* Update exif date using exiftool * Update exif date using exiftool.
* *
* @param File $file
* @param string $newDate formatted in standard Exif format (YYYY:MM:DD HH:MM:SS) * @param string $newDate formatted in standard Exif format (YYYY:MM:DD HH:MM:SS)
*/ */
public static function updateExifDate(File &$file, string $newDate) { public static function updateExifDate(File &$file, string $newDate)
{
// Check for local files -- this is easier // Check for local files -- this is easier
if (!$file->isEncrypted() && $file->getStorage()->isLocal()) { if (!$file->isEncrypted() && $file->getStorage()->isLocal()) {
$path = $file->getStorage()->getLocalFile($file->getInternalPath()); $path = $file->getStorage()->getLocalFile($file->getInternalPath());
if (is_string($path)) { if (\is_string($path)) {
return self::updateExifDateForLocalFile($path, $newDate); return self::updateExifDateForLocalFile($path, $newDate);
} }
} }
@ -352,36 +290,12 @@ class Exif {
} }
/** /**
* Update exif date using exiftool for a local file * Update exif date for stream.
* *
* @param string $path
* @param string $newDate formatted in standard Exif format (YYYY:MM:DD HH:MM:SS)
* @return bool
*/
private static function updateExifDateForLocalFile(string $path, string $newDate) {
$cmd = ['exiftool', '-api', 'QuickTimeUTC=1', '-overwrite_original', '-DateTimeOriginal=' . $newDate, $path];
$proc = proc_open($cmd, [
1 => array('pipe', 'w'),
2 => array('pipe', 'w'),
], $pipes);
$stdout = self::readOrTimeout($pipes[1], 300000);
fclose($pipes[1]);
fclose($pipes[2]);
proc_terminate($proc);
if (strpos($stdout, 'error') !== false) {
error_log("Exiftool error: $stdout");
throw new \Exception("Could not update exif date: " . $stdout);
}
return true;
}
/**
* Update exif date for stream
*
* @param File $file
* @param string $newDate formatted in standard Exif format (YYYY:MM:DD HH:MM:SS) * @param string $newDate formatted in standard Exif format (YYYY:MM:DD HH:MM:SS)
*/ */
public static function updateExifDateForStreamFile(File &$file, string $newDate) { public static function updateExifDateForStreamFile(File &$file, string $newDate)
{
// Temp file for output, so we can compare sizes before writing to the actual file // Temp file for output, so we can compare sizes before writing to the actual file
$tmpfile = tmpfile(); $tmpfile = tmpfile();
@ -390,11 +304,11 @@ class Exif {
$pipes = []; $pipes = [];
$proc = proc_open([ $proc = proc_open([
'exiftool', '-api', 'QuickTimeUTC=1', 'exiftool', '-api', 'QuickTimeUTC=1',
'-overwrite_original', '-DateTimeOriginal=' . $newDate, '-' '-overwrite_original', '-DateTimeOriginal='.$newDate, '-',
], [ ], [
0 => array('pipe', 'rb'), 0 => ['pipe', 'rb'],
1 => array('pipe', 'w'), 1 => ['pipe', 'w'],
2 => array('pipe', 'w'), 2 => ['pipe', 'w'],
], $pipes); ], $pipes);
// Write the file to exiftool's stdin // Write the file to exiftool's stdin
@ -418,9 +332,10 @@ class Exif {
while ($waitedMs < $timeout && !feof($pipes[1])) { while ($waitedMs < $timeout && !feof($pipes[1])) {
$r = stream_copy_to_stream($pipes[1], $tmpfile, 1024 * 1024); $r = stream_copy_to_stream($pipes[1], $tmpfile, 1024 * 1024);
$newLen += $r; $newLen += $r;
if ($r === 0) { if (0 === $r) {
$waitedMs++; ++$waitedMs;
usleep(1000); usleep(1000);
continue; continue;
} }
} }
@ -428,8 +343,9 @@ class Exif {
throw new \Exception('Timeout'); throw new \Exception('Timeout');
} }
} catch (\Exception $ex) { } catch (\Exception $ex) {
error_log("Exiftool timeout for file stream: " . $ex->getMessage()); error_log('Exiftool timeout for file stream: '.$ex->getMessage());
throw new \Exception("Could not read from Exiftool");
throw new \Exception('Could not read from Exiftool');
} finally { } finally {
// Close the pipes // Close the pipes
fclose($pipes[1]); fclose($pipes[1]);
@ -440,8 +356,9 @@ class Exif {
// Check the new length of the file // Check the new length of the file
// If the new length and old length are more different than 1KB, abort // If the new length and old length are more different than 1KB, abort
if (abs($newLen - $origLen) > 1024) { if (abs($newLen - $origLen) > 1024) {
error_log("Exiftool error: new length $newLen, old length $origLen"); error_log("Exiftool error: new length {$newLen}, old length {$origLen}");
throw new \Exception("Exiftool error: new length $newLen, old length $origLen");
throw new \Exception("Exiftool error: new length {$newLen}, old length {$origLen}");
} }
// Write the temp file to the actual file // Write the temp file to the actual file
@ -451,14 +368,16 @@ class Exif {
throw new \Exception('Could not open file for writing'); throw new \Exception('Could not open file for writing');
} }
$wroteBytes = 0; $wroteBytes = 0;
try { try {
$wroteBytes = stream_copy_to_stream($tmpfile, $out); $wroteBytes = stream_copy_to_stream($tmpfile, $out);
} finally { } finally {
fclose($out); fclose($out);
} }
if ($wroteBytes !== $newLen) { if ($wroteBytes !== $newLen) {
error_log("Exiftool error: wrote $r bytes, expected $newLen"); error_log("Exiftool error: wrote {$r} bytes, expected {$newLen}");
throw new \Exception("Could not write to file");
throw new \Exception('Could not write to file');
} }
// All done at this point // All done at this point
@ -468,4 +387,129 @@ class Exif {
fclose($tmpfile); fclose($tmpfile);
} }
} }
}
/** Initialize static exiftool process for local reads */
private static function initializeStaticExiftoolProc()
{
self::closeStaticExiftoolProc();
self::$staticProc = proc_open(['exiftool', '-stay_open', 'true', '-@', '-'], [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], self::$staticPipes);
stream_set_blocking(self::$staticPipes[1], false);
}
/**
* Read from non blocking handle or throw timeout.
*
* @param resource $handle
* @param int $timeout milliseconds
* @param string $delimiter null for eof
*/
private static function readOrTimeout($handle, $timeout, $delimiter = null)
{
$buf = '';
$waitedMs = 0;
while ($waitedMs < $timeout && ($delimiter ? !str_ends_with($buf, $delimiter) : !feof($handle))) {
$r = stream_get_contents($handle);
if (empty($r)) {
++$waitedMs;
usleep(1000);
continue;
}
$buf .= $r;
}
if ($waitedMs >= $timeout) {
throw new \Exception('Timeout');
}
return $buf;
}
private static function getExifFromLocalPathWithStaticProc(string &$path)
{
fwrite(self::$staticPipes[0], "{$path}\n-json\n-api\nQuickTimeUTC=1\n-n\n-execute\n");
fflush(self::$staticPipes[0]);
$readyToken = "\n{ready}\n";
try {
$buf = self::readOrTimeout(self::$staticPipes[1], 5000, $readyToken);
$tokPos = strrpos($buf, $readyToken);
$buf = substr($buf, 0, $tokPos);
return self::processStdout($buf);
} catch (\Exception $ex) {
error_log("ERROR: Exiftool may have crashed, restarting process [{$path}]");
self::restartStaticExiftoolProc();
throw new \Exception('Nothing to read from Exiftool');
}
}
private static function getExifFromLocalPathWithSeparateProc(string &$path)
{
$pipes = [];
$proc = proc_open(['exiftool', '-api', 'QuickTimeUTC=1', '-n', '-json', $path], [
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes);
stream_set_blocking($pipes[1], false);
try {
$stdout = self::readOrTimeout($pipes[1], 5000);
return self::processStdout($stdout);
} catch (\Exception $ex) {
error_log("Exiftool timeout: [{$path}]");
throw new \Exception('Could not read from Exiftool');
} finally {
fclose($pipes[1]);
fclose($pipes[2]);
proc_terminate($proc);
}
}
/** Get json array from stdout of exiftool */
private static function processStdout(string &$stdout)
{
$json = json_decode($stdout, true);
if (!$json) {
throw new \Exception('Could not read exif data');
}
return $json[0];
}
/**
* Update exif date using exiftool for a local file.
*
* @param string $newDate formatted in standard Exif format (YYYY:MM:DD HH:MM:SS)
*
* @return bool
*/
private static function updateExifDateForLocalFile(string $path, string $newDate)
{
$cmd = ['exiftool', '-api', 'QuickTimeUTC=1', '-overwrite_original', '-DateTimeOriginal='.$newDate, $path];
$proc = proc_open($cmd, [
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes);
$stdout = self::readOrTimeout($pipes[1], 300000);
fclose($pipes[1]);
fclose($pipes[2]);
proc_terminate($proc);
if (false !== strpos($stdout, 'error')) {
error_log("Exiftool error: {$stdout}");
throw new \Exception('Could not update exif date: '.$stdout);
}
return true;
}
}

View File

@ -3,7 +3,6 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
*
* @license AGPL-3.0-or-later * @license AGPL-3.0-or-later
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
@ -18,28 +17,29 @@ declare(strict_types=1);
* *
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/ */
namespace OCA\Memories\Listeners; namespace OCA\Memories\Listeners;
use \OCA\Memories\Db\TimelineWrite; use OCA\Memories\Db\TimelineWrite;
use OCP\EventDispatcher\Event; use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener; use OCP\EventDispatcher\IEventListener;
use OCP\Files\Events\Node\NodeDeletedEvent; use OCP\Files\Events\Node\NodeDeletedEvent;
use OCP\Files\Folder; use OCP\Files\Folder;
use OCP\IDBConnection; use OCP\IDBConnection;
class PostDeleteListener implements IEventListener { class PostDeleteListener implements IEventListener
{
private TimelineWrite $util; private TimelineWrite $util;
public function __construct(IDBConnection $connection) { public function __construct(IDBConnection $connection)
{
$this->util = new TimelineWrite($connection); $this->util = new TimelineWrite($connection);
} }
public function handle(Event $event): void { public function handle(Event $event): void
if (!($event instanceof NodeDeletedEvent)) { {
if (!$event instanceof NodeDeletedEvent) {
return; return;
} }
@ -50,4 +50,4 @@ class PostDeleteListener implements IEventListener {
$this->util->deleteFile($node); $this->util->deleteFile($node);
} }
} }

View File

@ -3,7 +3,6 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
*
* @license AGPL-3.0-or-later * @license AGPL-3.0-or-later
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
@ -18,13 +17,11 @@ declare(strict_types=1);
* *
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/ */
namespace OCA\Memories\Listeners; namespace OCA\Memories\Listeners;
use \OCA\Memories\Db\TimelineWrite; use OCA\Memories\Db\TimelineWrite;
use OCP\EventDispatcher\Event; use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener; use OCP\EventDispatcher\IEventListener;
use OCP\Files\Events\Node\NodeTouchedEvent; use OCP\Files\Events\Node\NodeTouchedEvent;
@ -33,18 +30,23 @@ use OCP\Files\Folder;
use OCP\IDBConnection; use OCP\IDBConnection;
use OCP\IUserManager; use OCP\IUserManager;
class PostWriteListener implements IEventListener { class PostWriteListener implements IEventListener
{
private TimelineWrite $timelineWrite; private TimelineWrite $timelineWrite;
public function __construct(IDBConnection $connection, public function __construct(
IUserManager $userManager) { IDBConnection $connection,
IUserManager $userManager
)
{
$this->userManager = $userManager; $this->userManager = $userManager;
$this->timelineWrite = new TimelineWrite($connection); $this->timelineWrite = new TimelineWrite($connection);
} }
public function handle(Event $event): void { public function handle(Event $event): void
if (!($event instanceof NodeWrittenEvent) && {
!($event instanceof NodeTouchedEvent)) { if (!($event instanceof NodeWrittenEvent)
&& !($event instanceof NodeTouchedEvent)) {
return; return;
} }
@ -63,14 +65,14 @@ class PostWriteListener implements IEventListener {
// in reverse order from root to leaf. The rationale is that the // in reverse order from root to leaf. The rationale is that the
// .nomedia file is most likely to be in higher level directories. // .nomedia file is most likely to be in higher level directories.
$parents = []; $parents = [];
try { try {
$parent = $node->getParent(); $parent = $node->getParent();
while ($parent) { while ($parent) {
$parents[] = $parent; $parents[] = $parent;
$parent = $parent->getParent(); $parent = $parent->getParent();
} }
} } catch (\OCP\Files\NotFoundException $e) {
catch (\OCP\Files\NotFoundException $e) {
// This happens when the parent is in the root directory // This happens when the parent is in the root directory
// and getParent() is called on it. // and getParent() is called on it.
} }
@ -85,4 +87,4 @@ class PostWriteListener implements IEventListener {
$this->timelineWrite->processFile($node); $this->timelineWrite->processFile($node);
} }
} }

View File

@ -4,9 +4,7 @@ declare(strict_types=1);
/** /**
* @copyright Copyright (c) 2022 Varun Patil <radialapps@gmail.com> * @copyright Copyright (c) 2022 Varun Patil <radialapps@gmail.com>
*
* @author Varun Patil <radialapps@gmail.com> * @author Varun Patil <radialapps@gmail.com>
*
* @license GNU AGPL version 3 or any later version * @license GNU AGPL version 3 or any later version
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
@ -21,26 +19,25 @@ declare(strict_types=1);
* *
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/ */
namespace OCA\Memories\Migration; namespace OCA\Memories\Migration;
use Closure; use Closure;
use OCP\DB\Types;
use OCP\DB\ISchemaWrapper; use OCP\DB\ISchemaWrapper;
use OCP\Migration\SimpleMigrationStep; use OCP\DB\Types;
use OCP\Migration\IOutput; use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version000000Date20220812163631 extends SimpleMigrationStep { class Version000000Date20220812163631 extends SimpleMigrationStep
{
/** /**
* @param IOutput $output * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` *
* @param array $options * @return null|ISchemaWrapper
* @return null|ISchemaWrapper */
*/ public function changeSchema(IOutput $output, Closure $schemaClosure, array $options)
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) { {
/** @var ISchemaWrapper $schema */ /** @var ISchemaWrapper $schema */
$schema = $schemaClosure(); $schema = $schemaClosure();
@ -66,7 +63,7 @@ class Version000000Date20220812163631 extends SimpleMigrationStep {
]); ]);
$table->addColumn('isvideo', Types::BOOLEAN, [ $table->addColumn('isvideo', Types::BOOLEAN, [
'notnull' => false, 'notnull' => false,
'default' => false 'default' => false,
]); ]);
$table->addColumn('mtime', Types::INTEGER, [ $table->addColumn('mtime', Types::INTEGER, [
'notnull' => true, 'notnull' => true,
@ -85,4 +82,4 @@ class Version000000Date20220812163631 extends SimpleMigrationStep {
return $schema; return $schema;
} }
} }

View File

@ -4,9 +4,7 @@ declare(strict_types=1);
/** /**
* @copyright Copyright (c) 2022 Your name <your@email.com> * @copyright Copyright (c) 2022 Your name <your@email.com>
*
* @author Your name <your@email.com> * @author Your name <your@email.com>
*
* @license GNU AGPL version 3 or any later version * @license GNU AGPL version 3 or any later version
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
@ -21,79 +19,75 @@ declare(strict_types=1);
* *
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/ */
namespace OCA\Memories\Migration; namespace OCA\Memories\Migration;
use Closure; use Closure;
use OCP\DB\ISchemaWrapper; use OCP\DB\ISchemaWrapper;
use OCP\IDBConnection;
use OCP\Migration\IOutput; use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep; use OCP\Migration\SimpleMigrationStep;
use OCP\IDBConnection;
/** /**
* Auto-generated migration step: Please modify to your needs! * Auto-generated migration step: Please modify to your needs!
*/ */
class Version200000Date20220924015634 extends SimpleMigrationStep { class Version200000Date20220924015634 extends SimpleMigrationStep
{
/** @var IDBConnection */
private $dbc;
/** @var IDBConnection */ public function __construct(IDBConnection $dbc)
private $dbc; {
$this->dbc = $dbc;
}
public function __construct(IDBConnection $dbc) { /**
$this->dbc = $dbc; * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
} */
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void
{
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if ($schema->hasTable('memories')) {
$table = $schema->getTable('memories');
if ($table->hasColumn('uid')) {
$sql = $this->dbc->getDatabasePlatform()->getTruncateTableSQL('`*PREFIX*memories`', false);
$this->dbc->executeStatement($sql);
}
}
}
/** /**
* @param IOutput $output * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` */
* @param array $options public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper
*/ {
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { /** @var ISchemaWrapper $schema */
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if ($schema->hasTable('memories')) {
$table = $schema->getTable('memories');
if ($table->hasColumn('uid')) {
$sql = $this->dbc->getDatabasePlatform()->getTruncateTableSQL('`*PREFIX*memories`', false);
$this->dbc->executeStatement($sql);
}
}
}
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure(); $schema = $schemaClosure();
if (!$schema->hasTable('memories')) { if (!$schema->hasTable('memories')) {
throw new \Exception('Memories table does not exist'); throw new \Exception('Memories table does not exist');
} }
$table = $schema->getTable('memories'); $table = $schema->getTable('memories');
if ($table->hasIndex('memories_uid_index')) { if ($table->hasIndex('memories_uid_index')) {
$table->dropIndex('memories_uid_index'); $table->dropIndex('memories_uid_index');
$table->dropIndex('memories_ud_index'); $table->dropIndex('memories_ud_index');
$table->dropIndex('memories_day_uf_ui'); $table->dropIndex('memories_day_uf_ui');
$table->dropColumn('uid'); $table->dropColumn('uid');
$table->addIndex(['dayid'], 'memories_dayid_index'); $table->addIndex(['dayid'], 'memories_dayid_index');
$table->addUniqueIndex(['fileid'], 'memories_fileid_index'); $table->addUniqueIndex(['fileid'], 'memories_fileid_index');
} }
return $schema; return $schema;
} }
/** /**
* @param IOutput $output * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` */
* @param array $options public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void
*/ {
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { }
}
} }

View File

@ -4,9 +4,7 @@ declare(strict_types=1);
/** /**
* @copyright Copyright (c) 2022 Your name <your@email.com> * @copyright Copyright (c) 2022 Your name <your@email.com>
*
* @author Your name <your@email.com> * @author Your name <your@email.com>
*
* @license GNU AGPL version 3 or any later version * @license GNU AGPL version 3 or any later version
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
@ -21,45 +19,41 @@ declare(strict_types=1);
* *
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/ */
namespace OCA\Memories\Migration; namespace OCA\Memories\Migration;
use Closure; use Closure;
use OCP\DB\Types;
use OCP\DB\ISchemaWrapper; use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput; use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep; use OCP\Migration\SimpleMigrationStep;
/** /**
* Auto-generated migration step: Please modify to your needs! * Auto-generated migration step: Please modify to your needs!
*/ */
class Version400000Date20221015121115 extends SimpleMigrationStep { class Version400000Date20221015121115 extends SimpleMigrationStep
{
/**
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
*/
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void
{
}
/** /**
* @param IOutput $output * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` */
* @param array $options public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper
*/ {
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { /** @var ISchemaWrapper $schema */
}
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure(); $schema = $schemaClosure();
if (!$schema->hasTable('memories')) { if (!$schema->hasTable('memories')) {
throw new \Exception('Memories table does not exist'); throw new \Exception('Memories table does not exist');
} }
$table = $schema->getTable('memories'); $table = $schema->getTable('memories');
$table->addColumn('w', Types::INTEGER, [ $table->addColumn('w', Types::INTEGER, [
'notnull' => true, 'notnull' => true,
@ -70,14 +64,13 @@ class Version400000Date20221015121115 extends SimpleMigrationStep {
'default' => 0, 'default' => 0,
]); ]);
return $schema; return $schema;
} }
/** /**
* @param IOutput $output * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` */
* @param array $options public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void
*/ {
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { }
}
} }

View File

@ -1,4 +1,5 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace OCA\Memories; namespace OCA\Memories;
@ -6,7 +7,8 @@ namespace OCA\Memories;
use OCA\Memories\AppInfo\Application; use OCA\Memories\AppInfo\Application;
use OCP\IConfig; use OCP\IConfig;
class Util { class Util
{
public static $TAG_DAYID_START = -(1 << 30); // the world surely didn't exist public static $TAG_DAYID_START = -(1 << 30); // the world surely didn't exist
public static $TAG_DAYID_FOLDERS = -(1 << 30) + 1; public static $TAG_DAYID_FOLDERS = -(1 << 30) + 1;
@ -14,14 +16,14 @@ class Util {
/** /**
* Get the path to the user's configured photos directory. * Get the path to the user's configured photos directory.
* @param IConfig $config
* @param string $userId
*/ */
public static function getPhotosPath(IConfig &$config, string $userId) { public static function getPhotosPath(IConfig &$config, string $userId)
{
$p = $config->getUserValue($userId, Application::APPNAME, 'timelinePath', ''); $p = $config->getUserValue($userId, Application::APPNAME, 'timelinePath', '');
if (empty($p)) { if (empty($p)) {
return '/Photos/'; return '/Photos/';
} }
return $p; return $p;
} }
} }