diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 4bf3a45e..9e066a17 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -4,9 +4,7 @@ declare(strict_types=1); /** * @copyright Copyright (c) 2022, Varun Patil - * * @author Varun Patil - * * @license AGPL-3.0-or-later * * 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 * along with this program. If not, see . - * */ namespace OCA\Memories\AppInfo; -use OCA\Memories\Listeners\PostWriteListener; use OCA\Memories\Listeners\PostDeleteListener; +use OCA\Memories\Listeners\PostWriteListener; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; -use OCP\Files\Events\Node\NodeWrittenEvent; use OCP\Files\Events\Node\NodeDeletedEvent; 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 IMAGE_MIMES = [ @@ -61,16 +59,19 @@ class Application extends App implements IBootstrap { 'video/x-matroska', ]; - public function __construct() { + public function __construct() + { parent::__construct(self::APPNAME); } - public function register(IRegistrationContext $context): void { + public function register(IRegistrationContext $context): void + { $context->registerEventListener(NodeWrittenEvent::class, PostWriteListener::class); $context->registerEventListener(NodeTouchedEvent::class, PostWriteListener::class); $context->registerEventListener(NodeDeletedEvent::class, PostDeleteListener::class); } - public function boot(IBootContext $context): void { + public function boot(IBootContext $context): void + { } -} \ No newline at end of file +} diff --git a/lib/Command/Index.php b/lib/Command/Index.php index 1c87ef68..2b033dec 100644 --- a/lib/Command/Index.php +++ b/lib/Command/Index.php @@ -4,9 +4,7 @@ declare(strict_types=1); /** * @copyright Copyright (c) 2022, Varun Patil - * * @author Varun Patil - * * @license AGPL-3.0-or-later * * 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 * along with this program. If not, see . - * */ namespace OCA\Memories\Command; +use OCA\Files_External\Service\GlobalStoragesService; +use OCA\Memories\Db\TimelineWrite; use OCP\Encryption\IManager; use OCP\Files\File; use OCP\Files\Folder; @@ -36,8 +35,6 @@ use OCP\IDBConnection; use OCP\IPreview; use OCP\IUser; use OCP\IUserManager; -use OCA\Files_External\Service\GlobalStoragesService; -use OCA\Memories\Db\TimelineWrite; use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; 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\Output\OutputInterface; -class Index extends Command { - +class Index extends Command +{ /** @var ?GlobalStoragesService */ protected $globalService; @@ -67,13 +64,16 @@ class Index extends Command { private int $nSkipped = 0; private int $nInvalid = 0; - public function __construct(IRootFolder $rootFolder, - IUserManager $userManager, - IPreview $previewGenerator, - IConfig $config, - IManager $encryptionManager, - IDBConnection $connection, - ContainerInterface $container) { + public function __construct( + IRootFolder $rootFolder, + IUserManager $userManager, + IPreview $previewGenerator, + IConfig $config, + IManager $encryptionManager, + IDBConnection $connection, + ContainerInterface $container + ) + { parent::__construct(); $this->userManager = $userManager; @@ -91,38 +91,8 @@ class Index extends Command { } } - /** Make sure exiftool is available */ - 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 { + protected function configure(): void + { $this ->setName('memories:index') ->setDescription('Generate photo entries') @@ -137,45 +107,52 @@ class Index extends Command { null, InputOption::VALUE_NONE, '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 $refresh = $input->getOption('refresh') ? true : false; $clear = $input->getOption('clear') ? true : false; // Clear index if asked for this 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)); - if ($answer !== 'y') { - $output->writeln("Aborting"); + if ('y' !== $answer) { + $output->writeln('Aborting'); + return 1; } } if ($clear) { $this->timelineWrite->clear(); - $output->writeln("Cleared existing index"); + $output->writeln('Cleared existing index'); } // Run with the static process try { \OCA\Memories\Exif::ensureStaticExiftoolProc(); + return $this->executeWithOpts($output, $refresh); } catch (\Exception $e) { - error_log("FATAL: " . $e->getMessage()); + error_log('FATAL: '.$e->getMessage()); + return 1; } finally { \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 if (!$this->testExif()) { 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'); + return 1; } @@ -184,6 +161,7 @@ class Index extends Command { if ($this->encryptionManager->isEnabled()) { error_log('FATAL: Encryption is enabled. Aborted.'); + return 1; } $this->output = $output; @@ -194,19 +172,58 @@ class Index extends Command { // Show some stats $endTime = microtime(true); - $execTime = intval(($endTime - $startTime)*1000)/1000 ; + $execTime = (int) (($endTime - $startTime) * 1000) / 1000; $nTotal = $this->nInvalid + $this->nSkipped + $this->nProcessed; - $this->output->writeln("=========================================="); - $this->output->writeln("Checked $nTotal files in $execTime sec"); - $this->output->writeln($this->nInvalid . " not valid media items"); - $this->output->writeln($this->nSkipped . " skipped because unmodified"); - $this->output->writeln($this->nProcessed . " (re-)processed"); - $this->output->writeln("=========================================="); + $this->output->writeln('=========================================='); + $this->output->writeln("Checked {$nTotal} files in {$execTime} sec"); + $this->output->writeln($this->nInvalid.' not valid media items'); + $this->output->writeln($this->nSkipped.' skipped because unmodified'); + $this->output->writeln($this->nProcessed.' (re-)processed'); + $this->output->writeln('=========================================='); 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::setupFS($user->getUID()); @@ -215,17 +232,19 @@ class Index extends Command { $this->parseFolder($userFolder, $refresh); } - private function parseFolder(Folder &$folder, bool &$refresh): void { + private function parseFolder(Folder &$folder, bool &$refresh): void + { try { $folderPath = $folder->getPath(); // Respect the '.nomedia' file. If present don't traverse the folder if ($folder->nodeExists('.nomedia')) { - $this->output->writeln('Skipping folder ' . $folderPath); + $this->output->writeln('Skipping folder '.$folderPath); + return; } - $this->output->writeln('Scanning folder ' . $folderPath); + $this->output->writeln('Scanning folder '.$folderPath); $nodes = $folder->getDirectoryListing(); @@ -237,21 +256,23 @@ class Index extends Command { } } } catch (StorageNotAvailableException $e) { - $this->output->writeln(sprintf('Storage for folder folder %s is not available: %s', + $this->output->writeln(sprintf( + 'Storage for folder folder %s is not available: %s', $folder->getPath(), $e->getHint() )); } } - private function parseFile(File &$file, bool &$refresh): void { + private function parseFile(File &$file, bool &$refresh): void + { $res = $this->timelineWrite->processFile($file, $refresh); - if ($res === 2) { - $this->nProcessed++; - } else if ($res === 1) { - $this->nSkipped++; + if (2 === $res) { + ++$this->nProcessed; + } elseif (1 === $res) { + ++$this->nSkipped; } else { - $this->nInvalid++; + ++$this->nInvalid; } } -} \ No newline at end of file +} diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index d418c104..eab6dd05 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -1,11 +1,10 @@ - * * @author John Molakvoæ - * * @license AGPL-3.0-or-later * * 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 * along with this program. If not, see . - * */ namespace OCA\Memories\Controller; @@ -32,21 +30,22 @@ use OCA\Memories\Exif; use OCP\App\IAppManager; use OCP\AppFramework\Controller; 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\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\Folder; +use OCP\Files\IRootFolder; use OCP\IConfig; use OCP\IDBConnection; +use OCP\IPreview; use OCP\IRequest; use OCP\IUserSession; -use OCP\IPreview; -class ApiController extends Controller { +class ApiController extends Controller +{ private IConfig $config; private IUserSession $userSession; private IDBConnection $connection; @@ -63,8 +62,9 @@ class ApiController extends Controller { IDBConnection $connection, IRootFolder $rootFolder, IAppManager $appManager, - IPreview $previewManager) { - + IPreview $previewManager + ) + { parent::__construct(Application::APPNAME, $request); $this->config = $config; @@ -77,117 +77,23 @@ class ApiController extends Controller { $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 - * - * @return JSONResponse */ - public function days(): JSONResponse { + public function days(): JSONResponse + { $user = $this->userSession->getUser(); - if (is_null($user)) { + if (null === $user) { return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); } $uid = $user->getUID(); // Get the folder to show $folder = $this->getRequestFolder(); - $recursive = is_null($this->request->getParam('folder')); - $archive = !is_null($this->request->getParam('archive')); - if (is_null($folder)) { - return new JSONResponse(["message" => "Folder not found"], Http::STATUS_NOT_FOUND); + $recursive = null === $this->request->getParam('folder'); + $archive = null !== $this->request->getParam('archive'); + if (null === $folder) { + return new JSONResponse(['message' => 'Folder not found'], Http::STATUS_NOT_FOUND); } // Run actual query @@ -210,56 +116,55 @@ class ApiController extends Controller { return new JSONResponse($list, Http::STATUS_OK); } 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 - * - * @return JSONResponse */ - public function dayPost(): JSONResponse { + public function dayPost(): JSONResponse + { $id = $this->request->getParam('body_ids'); - if (is_null($id)) { + if (null === $id) { return new JSONResponse([], Http::STATUS_BAD_REQUEST); } + return $this->day($id); } /** * @NoAdminRequired - * - * @return JSONResponse */ - public function day(string $id): JSONResponse { + public function day(string $id): JSONResponse + { $user = $this->userSession->getUser(); - if (is_null($user)) { + if (null === $user) { return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); } $uid = $user->getUID(); // Check for wildcard $day_ids = []; - if ($id === "*") { + if ('*' === $id) { $day_ids = null; } else { // Split at commas and convert all parts to int $day_ids = array_map(function ($part) { - return intval($part); - }, explode(",", $id)); + return (int) $part; + }, explode(',', $id)); } // 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); } // Get the folder to show $folder = $this->getRequestFolder(); - $recursive = is_null($this->request->getParam('folder')); - $archive = !is_null($this->request->getParam('archive')); - if (is_null($folder)) { + $recursive = null === $this->request->getParam('folder'); + $archive = null !== $this->request->getParam('archive'); + if (null === $folder) { return new JSONResponse([], Http::STATUS_NOT_FOUND); } @@ -273,16 +178,18 @@ class ApiController extends Controller { $archive, $this->getTransformations(), ); + return new JSONResponse($list, Http::STATUS_OK); } 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 // This is unfortunately the only way to get the contents of a folder // 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); // Sort by name - usort($folders, function($a, $b) { + usort($folders, function ($a, $b) { return strnatcmp($a->getName(), $b->getName()); }); // Process to response type return [ - "dayid" => \OCA\Memories\Util::$TAG_DAYID_FOLDERS, - "count" => count($folders), - "detail" => array_map(function ($node) { + 'dayid' => \OCA\Memories\Util::$TAG_DAYID_FOLDERS, + 'count' => \count($folders), + 'detail' => array_map(function ($node) { return [ - "fileid" => $node->getId(), - "name" => $node->getName(), - "isfolder" => 1, - "path" => $node->getPath(), + 'fileid' => $node->getId(), + 'name' => $node->getName(), + 'isfolder' => 1, + 'path' => $node->getPath(), ]; }, $folders, []), ]; @@ -317,22 +224,22 @@ class ApiController extends Controller { * @NoAdminRequired * * Get list of tags with counts of images - * @return JSONResponse */ - public function tags(): JSONResponse { + public function tags(): JSONResponse + { $user = $this->userSession->getUser(); - if (is_null($user)) { + if (null === $user) { return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); } // Check tags enabled for this user 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 $folder = $this->getRequestFolder(); - if (is_null($folder)) { + if (null === $folder) { return new JSONResponse([], Http::STATUS_NOT_FOUND); } @@ -347,21 +254,21 @@ class ApiController extends Controller { // Convert to map with key as systemtagid $previews_map = []; foreach ($previews as &$preview) { - $key = $preview["systemtagid"]; - if (!array_key_exists($key, $previews_map)) { + $key = $preview['systemtagid']; + if (!\array_key_exists($key, $previews_map)) { $previews_map[$key] = []; } - unset($preview["systemtagid"]); + unset($preview['systemtagid']); $previews_map[$key][] = $preview; } // Add previews to list foreach ($list as &$tag) { - $key = $tag["id"]; - if (array_key_exists($key, $previews_map)) { - $tag["previews"] = $previews_map[$key]; + $key = $tag['id']; + if (\array_key_exists($key, $previews_map)) { + $tag['previews'] = $previews_map[$key]; } else { - $tag["previews"] = []; + $tag['previews'] = []; } } @@ -372,22 +279,22 @@ class ApiController extends Controller { * @NoAdminRequired * * Get list of faces with counts of images - * @return JSONResponse */ - public function faces(): JSONResponse { + public function faces(): JSONResponse + { $user = $this->userSession->getUser(); - if (is_null($user)) { + if (null === $user) { return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); } // Check faces enabled for this user 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 $folder = $this->getRequestFolder(); - if (is_null($folder)) { + if (null === $folder) { return new JSONResponse([], Http::STATUS_NOT_FOUND); } @@ -401,14 +308,17 @@ class ApiController extends Controller { /** * @NoAdminRequired + * * @NoCSRFRequired * * Get face preview image cropped with imagick + * * @return DataResponse */ - public function facePreview(string $id): Http\Response { + public function facePreview(string $id): Http\Response + { $user = $this->userSession->getUser(); - if (is_null($user)) { + if (null === $user) { return new DataResponse([], Http::STATUS_PRECONDITION_FAILED); } @@ -419,13 +329,13 @@ class ApiController extends Controller { // Get folder to search for $folder = $this->getRequestFolder(); - if (is_null($folder)) { + if (null === $folder) { return new JSONResponse([], Http::STATUS_NOT_FOUND); } // Run actual query - $detections = $this->timelineQuery->getFacePreviewDetection($folder, intval($id)); - if (is_null($detections) || count($detections) == 0) { + $detections = $this->timelineQuery->getFacePreviewDetection($folder, (int) $id); + if (null === $detections || 0 === \count($detections)) { return new DataResponse([], Http::STATUS_NOT_FOUND); } @@ -433,8 +343,8 @@ class ApiController extends Controller { $preview = null; foreach ($detections as &$detection) { // Get the file (also checks permissions) - $files = $folder->getById($detection["file_id"]); - if (count($files) == 0 || $files[0]->getType() != FileInfo::TYPE_FILE) { + $files = $folder->getById($detection['file_id']); + if (0 === \count($files) || FileInfo::TYPE_FILE !== $files[0]->getType()) { continue; } @@ -450,7 +360,7 @@ class ApiController extends Controller { } // Make sure the preview is valid - if (is_null($preview)) { + if (null === $preview) { return new DataResponse([], Http::STATUS_NOT_FOUND); } @@ -459,16 +369,16 @@ class ApiController extends Controller { $image->readImageBlob($preview->getContent()); $iw = $image->getImageWidth(); $ih = $image->getImageHeight(); - $dw = floatval($detection["width"]); - $dh = floatval($detection["height"]); - $dcx = floatval($detection["x"]) + floatval($detection["width"]) / 2; - $dcy = floatval($detection["y"]) + floatval($detection["height"]) / 2; + $dw = (float) ($detection['width']); + $dh = (float) ($detection['height']); + $dcx = (float) ($detection['x']) + (float) ($detection['width']) / 2; + $dcy = (float) ($detection['y']) + (float) ($detection['height']) / 2; $faceDim = max($dw * $iw, $dh * $ih) * 1.5; $image->cropImage( - intval($faceDim), - intval($faceDim), - intval($dcx * $iw - $faceDim / 2), - intval($dcy * $ih - $faceDim / 2), + (int) $faceDim, + (int) $faceDim, + (int) ($dcx * $iw - $faceDim / 2), + (int) ($dcy * $ih - $faceDim / 2), ); $image->scaleImage(256, 256, true); $blob = $image->getImageBlob(); @@ -478,6 +388,7 @@ class ApiController extends Controller { 'Content-Type' => $image->getImageMimeType(), ]); $response->cacheFor(3600 * 24, false, false); + return $response; } @@ -485,18 +396,20 @@ class ApiController extends Controller { * @NoAdminRequired * * Get image info for one file + * * @param string fileid */ - public function imageInfo(string $id): JSONResponse { + public function imageInfo(string $id): JSONResponse + { $user = $this->userSession->getUser(); - if (is_null($user)) { + if (null === $user) { return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); } $userFolder = $this->rootFolder->getUserFolder($user->getUID()); // Check for permissions and get numeric Id - $file = $userFolder->getById(intval($id)); - if (count($file) === 0) { + $file = $userFolder->getById((int) $id); + if (0 === \count($file)) { return new JSONResponse([], Http::STATUS_NOT_FOUND); } $file = $file[0]; @@ -511,18 +424,20 @@ class ApiController extends Controller { * @NoAdminRequired * * Change exif data for one file + * * @param string fileid */ - public function imageEdit(string $id): JSONResponse { + public function imageEdit(string $id): JSONResponse + { $user = $this->userSession->getUser(); - if (is_null($user)) { + if (null === $user) { return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); } $userFolder = $this->rootFolder->getUserFolder($user->getUID()); // Check for permissions and get numeric Id - $file = $userFolder->getById(intval($id)); - if (count($file) === 0) { + $file = $userFolder->getById((int) $id); + if (0 === \count($file)) { return new JSONResponse([], Http::STATUS_NOT_FOUND); } $file = $file[0]; @@ -535,24 +450,24 @@ class ApiController extends Controller { // Get new date from body $body = $this->request->getParams(); 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 try { Exif::parseExifDate($body['date']); } 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 try { $res = Exif::updateExifDate($file, $body['date']); - if ($res === false) { + if (false === $res) { return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); } } 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 @@ -565,66 +480,68 @@ class ApiController extends Controller { * @NoAdminRequired * * Move one file to the archive folder + * * @param string fileid */ - public function archive(string $id): JSONResponse { + public function archive(string $id): JSONResponse + { $user = $this->userSession->getUser(); - if (is_null($user)) { - return new JSONResponse(["message" => "Not logged in"], Http::STATUS_PRECONDITION_FAILED); + if (null === $user) { + return new JSONResponse(['message' => 'Not logged in'], Http::STATUS_PRECONDITION_FAILED); } $uid = $user->getUID(); $userFolder = $this->rootFolder->getUserFolder($uid); // Check for permissions and get numeric Id - $file = $userFolder->getById(intval($id)); - if (count($file) === 0) { - return new JSONResponse(["message" => "No such file"], Http::STATUS_NOT_FOUND); + $file = $userFolder->getById((int) $id); + if (0 === \count($file)) { + return new JSONResponse(['message' => 'No such file'], Http::STATUS_NOT_FOUND); } $file = $file[0]; // Check if user has permissions 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 $timelinePath = Exif::removeExtraSlash(Exif::getPhotosPath($this->config, $uid)); $timelineFolder = $userFolder->get($timelinePath); - if (is_null($timelineFolder) || !$timelineFolder instanceof Folder) { - return new JSONResponse(["message" => "Cannot get timeline"], Http::STATUS_INTERNAL_SERVER_ERROR); + if (null === $timelineFolder || !$timelineFolder instanceof Folder) { + return new JSONResponse(['message' => 'Cannot get timeline'], Http::STATUS_INTERNAL_SERVER_ERROR); } 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 // remove timelineFolder path from start of file path $timelinePath = $timelineFolder->getPath(); // no trailing slash - if (substr($file->getPath(), 0, strlen($timelinePath)) !== $timelinePath) { - return new JSONResponse(["message" => "Files outside timeline cannot be archived"], Http::STATUS_INTERNAL_SERVER_ERROR); + if (substr($file->getPath(), 0, \strlen($timelinePath)) !== $timelinePath) { + 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 $destinationPath = ''; // Check if we want to archive or unarchive $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) - $archiveFolderWithLeadingSlash = '/' . \OCA\Memories\Util::$ARCHIVE_FOLDER; - if (substr($relativePath, 0, strlen($archiveFolderWithLeadingSlash)) === $archiveFolderWithLeadingSlash) { + $archiveFolderWithLeadingSlash = '/'.\OCA\Memories\Util::$ARCHIVE_FOLDER; + if (substr($relativePath, 0, \strlen($archiveFolderWithLeadingSlash)) === $archiveFolderWithLeadingSlash) { // file already in archive, remove it instead - $destinationPath = substr($relativePath, strlen($archiveFolderWithLeadingSlash)); + $destinationPath = substr($relativePath, \strlen($archiveFolderWithLeadingSlash)); 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 { // 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) { - 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 $folder = $timelineFolder; foreach ($destinationFolders as $folderName) { - if ($folderName === '') { + if ('' === $folderName) { continue; } + try { - $existingFolder = $folder->get($folderName . '/'); + $existingFolder = $folder->get($folderName.'/'); if (!$existingFolder instanceof Folder) { throw new \OCP\Files\NotFoundException('Not a folder'); } @@ -648,46 +566,27 @@ class ApiController extends Controller { try { $folder = $folder->newFolder($folderName); } 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 try { - $file->move($folder->getPath() . '/' . $file->getName()); + $file->move($folder->getPath().'/'.$file->getName()); } 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) { - 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) { - 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) { - 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); } - /** - * 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 * @@ -698,32 +597,157 @@ class ApiController extends Controller { * * @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(); - if (is_null($user)) { + if (null === $user) { return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); } $userId = $user->getUid(); $this->config->setUserValue($userId, Application::APPNAME, $key, $value); + return new JSONResponse([], Http::STATUS_OK); } /** - * @NoAdminRequired - * @NoCSRFRequired - */ - public function serviceWorker(): StreamResponse { - $response = new StreamResponse(__DIR__.'/../../js/memories-service-worker.js'); - $response->setHeaders([ - 'Content-Type' => 'application/javascript', - 'Service-Worker-Allowed' => '/' - ]); - $policy = new ContentSecurityPolicy(); - $policy->addAllowedWorkerSrcDomain("'self'"); - $policy->addAllowedScriptDomain("'self'"); - $policy->addAllowedConnectDomain("'self'"); - $response->setContentSecurityPolicy($policy); - return $response; - } -} \ No newline at end of file + * @NoAdminRequired + * + * @NoCSRFRequired + */ + public function serviceWorker(): StreamResponse + { + $response = new StreamResponse(__DIR__.'/../../js/memories-service-worker.js'); + $response->setHeaders([ + 'Content-Type' => 'application/javascript', + 'Service-Worker-Allowed' => '/', + ]); + $policy = new ContentSecurityPolicy(); + $policy->addAllowedWorkerSrcDomain("'self'"); + $policy->addAllowedScriptDomain("'self'"); + $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', '>='); + } +} diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index b703122f..2a3adfdf 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -1,26 +1,27 @@ userId = $UserId; $this->appName = $AppName; @@ -47,11 +49,13 @@ class PageController extends Controller { /** * @NoAdminRequired + * * @NoCSRFRequired */ - public function main() { + public function main() + { $user = $this->userSession->getUser(); - if (is_null($user)) { + if (null === $user) { return null; } @@ -64,76 +68,95 @@ class PageController extends Controller { $uid = $user->getUid(); $timelinePath = \OCA\Memories\Util::getPhotosPath($this->config, $uid); $this->initialState->provideInitialState('timelinePath', $timelinePath); - $this->initialState->provideInitialState('showHidden', $this->config->getUserValue( - $uid, Application::APPNAME, 'showHidden', false)); + $this->initialState->provideInitialState('showHidden', $this->config->getUserValue( + $uid, + Application::APPNAME, + 'showHidden', + false + )); // Apps enabled - $this->initialState->provideInitialState('systemtags', $this->appManager->isEnabledForUser('systemtags') === true); - $this->initialState->provideInitialState('recognize', $this->appManager->isEnabledForUser('recognize') === true); - $this->initialState->provideInitialState('version', $this->appManager->getAppInfo('memories')["version"]); + $this->initialState->provideInitialState('systemtags', true === $this->appManager->isEnabledForUser('systemtags')); + $this->initialState->provideInitialState('recognize', true === $this->appManager->isEnabledForUser('recognize')); + $this->initialState->provideInitialState('version', $this->appManager->getAppInfo('memories')['version']); $policy = new ContentSecurityPolicy(); - $policy->addAllowedWorkerSrcDomain("'self'"); - $policy->addAllowedScriptDomain("'self'"); + $policy->addAllowedWorkerSrcDomain("'self'"); + $policy->addAllowedScriptDomain("'self'"); $response = new TemplateResponse($this->appName, 'main'); $response->setContentSecurityPolicy($policy); + return $response; } /** * @NoAdminRequired + * * @NoCSRFRequired */ - public function folder() { + public function folder() + { return $this->main(); } /** * @NoAdminRequired + * * @NoCSRFRequired */ - public function favorites() { + public function favorites() + { return $this->main(); } /** * @NoAdminRequired + * * @NoCSRFRequired */ - public function videos() { + public function videos() + { return $this->main(); } /** * @NoAdminRequired + * * @NoCSRFRequired */ - public function archive() { + public function archive() + { return $this->main(); } /** * @NoAdminRequired + * * @NoCSRFRequired */ - public function thisday() { + public function thisday() + { return $this->main(); } /** * @NoAdminRequired + * * @NoCSRFRequired */ - public function people() { + public function people() + { return $this->main(); } /** * @NoAdminRequired + * * @NoCSRFRequired */ - public function tags() { + public function tags() + { return $this->main(); } } diff --git a/lib/Db/TimelineQuery.php b/lib/Db/TimelineQuery.php index c31895d1..62a19fed 100644 --- a/lib/Db/TimelineQuery.php +++ b/lib/Db/TimelineQuery.php @@ -1,42 +1,49 @@ connection = $connection; } - public function getInfoById(int $id): array { + public function getInfoById(int $id): array + { $qb = $this->connection->getQueryBuilder(); $qb->select('fileid', 'dayid', 'datetaken') ->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(); $row = $result->fetch(); $result->closeCursor(); $utcTs = 0; + try { $utcDate = new \DateTime($row['datetaken'], new \DateTimeZone('UTC')); $utcTs = $utcDate->getTimestamp(); - } catch (\Throwable $e) {} + } catch (\Throwable $e) { + } return [ - 'fileid' => intval($row['fileid']), - 'dayid' => intval($row['dayid']), + 'fileid' => (int) ($row['fileid']), + 'dayid' => (int) ($row['dayid']), 'datetaken' => $utcTs, ]; } -} \ No newline at end of file +} diff --git a/lib/Db/TimelineQueryDays.php b/lib/Db/TimelineQueryDays.php index c758a9f5..e8e0e31d 100644 --- a/lib/Db/TimelineQueryDays.php +++ b/lib/Db/TimelineQueryDays.php @@ -1,117 +1,25 @@ 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 + * 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 * - * @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 */ public function getDays( @@ -127,11 +35,13 @@ trait TimelineQueryDays { $count = $query->func()->count($query->createFunction('DISTINCT m.fileid'), 'count'); $query->select('m.dayid', $count) ->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 $query->groupBy('m.dayid') - ->orderBy('m.dayid', 'DESC'); + ->orderBy('m.dayid', 'DESC') + ; // Apply all transformations $this->applyAllTransforms($queryTransforms, $query, $uid); @@ -139,17 +49,21 @@ trait TimelineQueryDays { $cursor = $query->executeQuery(); $rows = $cursor->fetchAll(); $cursor->closeCursor(); + return $this->processDays($rows); } /** - * 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 int[] $dayid The day id - * @param bool $recursive If the query should be recursive - * @param bool $archive If the query should include only the archive folder - * @param array $queryTransforms The query transformations to apply + * 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 int[] $dayid The day id + * @param bool $recursive If the query should be recursive + * @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 */ public function getDay( @@ -170,10 +84,11 @@ trait TimelineQueryDays { // when using DISTINCT on selected fields $query->select($fileid, 'f.etag', 'm.isvideo', 'vco.categoryid', 'm.datetaken', 'm.dayid', 'm.w', 'm.h') ->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 - if (!is_null($day_ids)) { + if (null !== $day_ids) { $query->andWhere($query->expr()->in('m.dayid', $query->createNamedParameter($day_ids, IQueryBuilder::PARAM_INT_ARRAY))); } else { // Limit wildcard to 100 results @@ -193,6 +108,108 @@ trait TimelineQueryDays { $cursor = $query->executeQuery(); $rows = $cursor->fetchAll(); $cursor->closeCursor(); + 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, + ); + } } diff --git a/lib/Db/TimelineQueryFaces.php b/lib/Db/TimelineQueryFaces.php index 7c0ec211..ca5af177 100644 --- a/lib/Db/TimelineQueryFaces.php +++ b/lib/Db/TimelineQueryFaces.php @@ -1,19 +1,24 @@ addSelect( 'rfd.width AS face_w', @@ -41,26 +47,8 @@ trait TimelineQueryFaces { ); } - /** Convert face fields to object */ - 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) { + public function getFaces(Folder $folder) + { $query = $this->connection->getQueryBuilder(); // SELECT all face clusters @@ -88,25 +76,31 @@ trait TimelineQueryFaces { $faces = $query->executeQuery()->fetchAll(); // Post process - foreach($faces as &$row) { - $row['id'] = intval($row['id']); - $row["name"] = $row["title"]; - unset($row["title"]); - $row["count"] = intval($row["count"]); + foreach ($faces as &$row) { + $row['id'] = (int) ($row['id']); + $row['name'] = $row['title']; + unset($row['title']); + $row['count'] = (int) ($row['count']); } return $faces; } - public function getFacePreviewDetection(Folder &$folder, int $id) { + public function getFacePreviewDetection(Folder &$folder, int $id) + { $query = $this->connection->getQueryBuilder(); // SELECT face detections for ID $query->select( - 'rfd.file_id', // Needed to get the actual file - 'rfd.x', 'rfd.y', 'rfd.width', 'rfd.height', // Image cropping - 'm.w as image_width', 'm.h as image_height', // Scoring - 'm.fileid', 'm.datetaken', // Just in case, for postgres + 'rfd.file_id', // Get actual file + 'rfd.x', // Image cropping + 'rfd.y', + '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'); $query->where($query->expr()->eq('rfd.cluster_id', $query->createNamedParameter($id))); @@ -132,31 +126,50 @@ trait TimelineQueryFaces { // Score the face detections foreach ($previews as &$p) { // Get actual pixel size of face - $iw = min(intval($p["image_width"] ?: 512), 2048); - $ih = min(intval($p["image_height"] ?: 512), 2048); - $w = floatval($p["width"]) * $iw; - $h = floatval($p["height"]) * $ih; + $iw = min((int) ($p['image_width'] ?: 512), 2048); + $ih = min((int) ($p['image_height'] ?: 512), 2048); + $w = (float) ($p['width']) * $iw; + $h = (float) ($p['height']) * $ih; // Get center of face - $x = floatval($p["x"]) + floatval($p["width"]) / 2; - $y = floatval($p["y"]) + floatval($p["height"]) / 2; + $x = (float) ($p['x']) + (float) ($p['width']) / 2; + $y = (float) ($p['y']) + (float) ($p['height']) / 2; // 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, // 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 - $p["score"] = $positionScore * $sizeScore; + $p['score'] = $positionScore * $sizeScore; } // Sort previews by score descending - usort($previews, function($a, $b) { - return $b["score"] <=> $a["score"]; + usort($previews, function ($a, $b) { + return $b['score'] <=> $a['score']; }); return $previews; } -} \ No newline at end of file + + /** 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']); + } +} diff --git a/lib/Db/TimelineQueryFilters.php b/lib/Db/TimelineQueryFilters.php index bb8328e6..c5e1b596 100644 --- a/lib/Db/TimelineQueryFilters.php +++ b/lib/Db/TimelineQueryFilters.php @@ -1,4 +1,5 @@ innerJoin('m', 'vcategory_to_object', 'vcoi', $query->expr()->andX( $query->expr()->eq('vcoi.objid', 'm.fileid'), $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->expr()->eq('vco.objid', 'm.fileid'), $query->expr()->in('vco.categoryid', $this->getFavoriteVCategoryFun($query, $userId)), )); } - 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()); - } - - public function transformVideoFilter(IQueryBuilder &$query, string $userId) { + public function transformVideoFilter(IQueryBuilder &$query, string $userId) + { $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 if ($limit < 1 || $limit > 100) { return; } $query->setMaxResults($limit); } -} \ No newline at end of file + + 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() + ); + } +} diff --git a/lib/Db/TimelineQueryTags.php b/lib/Db/TimelineQueryTags.php index c60a9024..6b026068 100644 --- a/lib/Db/TimelineQueryTags.php +++ b/lib/Db/TimelineQueryTags.php @@ -1,38 +1,45 @@ getConnection()->getQueryBuilder(); + return $sqb->select('id')->from('systemtag')->where( $sqb->expr()->andX( $sqb->expr()->eq('name', $sqb->createNamedParameter($tagName)), $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); - if ($tagId === FALSE) { - throw new \Exception("Tag $tagName not found"); + if (false === $tagId) { + throw new \Exception("Tag {$tagName} not found"); } $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.systemtagid', $query->createNamedParameter($tagId)), )); } - public function getTags(Folder $folder) { + public function getTags(Folder $folder) + { $query = $this->connection->getQueryBuilder(); // SELECT visible tag name and count of photos @@ -44,7 +51,7 @@ trait TimelineQueryTags { // WHERE there are items with this tag $query->innerJoin('st', 'systemtag_object_mapping', 'stom', $query->expr()->andX( $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 @@ -62,15 +69,16 @@ trait TimelineQueryTags { $tags = $query->executeQuery()->fetchAll(); // Post process - foreach($tags as &$row) { - $row["id"] = intval($row["id"]); - $row["count"] = intval($row["count"]); + foreach ($tags as &$row) { + $row['id'] = (int) ($row['id']); + $row['count'] = (int) ($row['count']); } return $tags; } - public function getTagPreviews(Folder $folder) { + public function getTagPreviews(Folder $folder) + { $query = $this->connection->getQueryBuilder(); // Windowing @@ -78,8 +86,10 @@ trait TimelineQueryTags { // SELECT all photos with this tag $query->select('f.fileid', 'f.etag', 'stom.systemtagid', $rowNumber)->from( - 'systemtag_object_mapping', 'stom')->where( - $query->expr()->eq('stom.objecttype', $query->createNamedParameter("files")), + 'systemtag_object_mapping', + 'stom' + )->where( + $query->expr()->eq('stom.objecttype', $query->createNamedParameter('files')), ); // WHERE these items are memories indexed photos @@ -89,7 +99,7 @@ trait TimelineQueryTags { $query->innerJoin('m', 'filecache', 'f', $this->getFilecacheJoinQuery($query, $folder, true, false)); // Make this a sub query - $fun = $query->createFunction('(' . $query->getSQL() . ')'); + $fun = $query->createFunction('('.$query->getSQL().')'); // Create outer query $outerQuery = $this->connection->getQueryBuilder(); @@ -102,12 +112,12 @@ trait TimelineQueryTags { $previews = $outerQuery->executeQuery()->fetchAll(); // Post-process - foreach($previews as &$row) { - $row["fileid"] = intval($row["fileid"]); - $row["systemtagid"] = intval($row["systemtagid"]); - unset($row["n"]); + foreach ($previews as &$row) { + $row['fileid'] = (int) ($row['fileid']); + $row['systemtagid'] = (int) ($row['systemtagid']); + unset($row['n']); } return $previews; } -} \ No newline at end of file +} diff --git a/lib/Db/TimelineWrite.php b/lib/Db/TimelineWrite.php index 27b14508..87945074 100644 --- a/lib/Db/TimelineWrite.php +++ b/lib/Db/TimelineWrite.php @@ -1,44 +1,50 @@ connection = $connection; } /** - * Check if a file has a valid mimetype for processing - * @param File $file + * Check if a file has a valid mimetype for processing. + * * @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(); - if (in_array($mime, Application::IMAGE_MIMES)) { + if (\in_array($mime, Application::IMAGE_MIMES, true)) { return 1; - } elseif (in_array($mime, Application::VIDEO_MIMES)) { + } + if (\in_array($mime, Application::VIDEO_MIMES, true)) { return 2; } + return 0; } /** - * Process a file to insert Exif data into the database - * @param File $file + * Process a file to insert Exif data into the database. + * * @return int 2 if processed, 1 if skipped, 0 if not valid */ public function processFile( File &$file, - bool $force=false + bool $force = false ): int { // 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, @@ -47,7 +53,7 @@ class TimelineWrite { // Check if we want to process this file $fileType = $this->getFileType($file); - $isvideo = ($fileType === 2); + $isvideo = (2 === $fileType); if (!$fileType) { return 0; } @@ -60,19 +66,22 @@ class TimelineWrite { $query = $this->connection->getQueryBuilder(); $query->select('fileid', 'mtime') ->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(); $prevRow = $cursor->fetch(); $cursor->closeCursor(); - if ($prevRow && !$force && intval($prevRow['mtime']) === $mtime) { + if ($prevRow && !$force && (int) ($prevRow['mtime']) === $mtime) { return 1; } // Get exif data $exif = []; + try { $exif = Exif::getExifFromFile($file); - } catch (\Exception $e) {} + } catch (\Exception $e) { + } // Get more parameters $dateTaken = Exif::getDateTaken($file, $exif); @@ -89,7 +98,8 @@ class TimelineWrite { ->set('isvideo', $query->createNamedParameter($isvideo, IQueryBuilder::PARAM_INT)) ->set('w', $query->createNamedParameter($w, 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(); } else { // Try to create new row @@ -103,10 +113,11 @@ class TimelineWrite { 'isvideo' => $query->createNamedParameter($isvideo, IQueryBuilder::PARAM_INT), 'w' => $query->createNamedParameter($w, IQueryBuilder::PARAM_INT), 'h' => $query->createNamedParameter($h, IQueryBuilder::PARAM_INT), - ]); + ]) + ; $query->executeStatement(); } 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 - * @param File $file + * Remove a file from the exif database. */ - public function deleteFile(File &$file) { + public function deleteFile(File &$file) + { $query = $this->connection->getQueryBuilder(); $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(); } /** * Clear the entire index. Does not need confirmation! + * * @param File $file */ - public function clear() { + public function clear() + { $query = $this->connection->getQueryBuilder(); $query->delete('memories'); $query->executeStatement(); } -} \ No newline at end of file +} diff --git a/lib/Exif.php b/lib/Exif.php index 599d1d36..b283476b 100644 --- a/lib/Exif.php +++ b/lib/Exif.php @@ -1,4 +1,5 @@ array('pipe', 'r'), - 1 => array('pipe', 'w'), - 2 => array('pipe', 'w'), - ], self::$staticPipes); - stream_set_blocking(self::$staticPipes[1], false); - } - - public static function closeStaticExiftoolProc() { + public static function closeStaticExiftoolProc() + { try { if (self::$staticProc) { fclose(self::$staticPipes[0]); @@ -34,15 +26,18 @@ class Exif { self::$staticProc = null; self::$staticPipes = null; } - } catch (\Exception $ex) {} + } catch (\Exception $ex) { + } } - public static function restartStaticExiftoolProc() { + public static function restartStaticExiftoolProc() + { self::closeStaticExiftoolProc(); self::ensureStaticExiftoolProc(); } - public static function ensureStaticExiftoolProc() { + public static function ensureStaticExiftoolProc() + { if (self::$noStaticProc) { return; } @@ -50,15 +45,16 @@ class Exif { if (!self::$staticProc) { self::initializeStaticExiftoolProc(); usleep(500000); // wait if error - if (!proc_get_status(self::$staticProc)["running"]) { - error_log("WARN: Failed to create stay_open exiftool process"); + if (!proc_get_status(self::$staticProc)['running']) { + error_log('WARN: Failed to create stay_open exiftool process'); self::$noStaticProc = true; self::$staticProc = null; } + return; } - if (!proc_get_status(self::$staticProc)["running"]) { + if (!proc_get_status(self::$staticProc)['running']) { self::$staticProc = null; self::ensureStaticExiftoolProc(); } @@ -66,49 +62,51 @@ class Exif { /** * 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', ''); if (empty($p)) { return 'Photos/'; } + return self::sanitizePath($p); } /** * Sanitize a path to keep only ASCII characters and special characters. - * @param string $path */ - public static function sanitizePath(string $path) { - return mb_ereg_replace("([^\w\s\d\-_~,;\[\]\(\).\/])", '', $path); + public static function sanitizePath(string $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); } /** - * 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); } /** * 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 // https://github.com/nextcloud/server/blob/19f68b3011a3c040899fb84975a28bd746bddb4b/lib/private/Preview/ProviderV2.php if (!$file->isEncrypted() && $file->getStorage()->isLocal()) { $path = $file->getStorage()->getLocalFile($file->getInternalPath()); - if (is_string($path)) { + if (\is_string($path)) { return self::getExifFromLocalPath($path); } } @@ -121,96 +119,35 @@ class Exif { $exif = self::getExifFromStream($handle); fclose($handle); + return $exif; } /** Get exif data as a JSON object from a local file path */ - public static function getExifFromLocalPath(string &$path) { - if (!is_null(self::$staticProc)) { + public static function getExifFromLocalPath(string &$path) + { + if (null !== self::$staticProc) { self::ensureStaticExiftoolProc(); + 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) { - 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); - } + return self::getExifFromLocalPathWithSeparateProc($path); } /** * Get exif data as a JSON object from a stream. + * * @param resource $handle */ - public static function getExifFromStream(&$handle) { + public static function getExifFromStream(&$handle) + { // Start exiftool and output to json $pipes = []; $proc = proc_open(['exiftool', '-api', 'QuickTimeUTC=1', '-n', '-json', '-fast', '-'], [ - 0 => array('pipe', 'rb'), - 1 => array('pipe', 'w'), - 2 => array('pipe', 'w'), + 0 => ['pipe', 'rb'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], ], $pipes); // Write the file to exiftool's stdin @@ -221,12 +158,15 @@ class Exif { // Get output from exiftool stream_set_blocking($pipes[1], false); + try { $stdout = self::readOrTimeout($pipes[1], 5000); + return self::processStdout($stdout); } catch (\Exception $ex) { - error_log("Exiftool timeout for file stream: " . $ex->getMessage()); - throw new \Exception("Could not read from Exiftool"); + error_log('Exiftool timeout for file stream: '.$ex->getMessage()); + + throw new \Exception('Could not read from Exiftool'); } finally { fclose($pipes[1]); 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 mixed $date + * * @return int unix timestamp */ - public static function parseExifDate($date) { + public static function parseExifDate($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 = \DateTime::createFromFormat('Y:m:d H:i:s', $dt); if (!$dt) { - throw new \Exception("Invalid date: $date"); + throw new \Exception("Invalid date: {$date}"); } if ($dt && $dt->getTimestamp() > -5364662400) { // 1800 A.D. return $dt->getTimestamp(); - } else { - throw new \Exception("Date too old: $date"); } + + throw new \Exception("Date too old: {$date}"); } else { - throw new \Exception("No date provided"); + throw new \Exception('No date provided'); } } @@ -273,7 +207,8 @@ class Exif { * * @param int $epoch */ - public static function forgetTimezone($epoch) { + public static function forgetTimezone($epoch) + { $dt = new \DateTime(); $dt->setTimestamp($epoch); $tz = getenv('TZ'); // at least works on debian ... @@ -281,16 +216,17 @@ class Exif { $dt->setTimezone(new \DateTimeZone($tz)); } $utc = new \DateTime($dt->format('Y-m-d H:i:s'), new \DateTimeZone('UTC')); + return $utc->getTimestamp(); } /** * Get the date taken from either the file or exif data if available. - * @param File $file - * @param array $exif + * * @return int unix timestamp */ - public static function getDateTaken(File &$file, array &$exif) { + public static function getDateTaken(File &$file, array &$exif) + { $dt = $exif['DateTimeOriginal'] ?? null; if (!isset($dt) || empty($dt)) { $dt = $exif['CreateDate'] ?? null; @@ -307,25 +243,27 @@ class Exif { $dateTaken = $file->getCreationTime(); // Fall back to modification time - if ($dateTaken == 0) { + if (0 === $dateTaken) { $dateTaken = $file->getMtime(); } + return self::forgetTimezone($dateTaken); } /** - * Get image dimensions from Exif data - * @param array $exif + * Get image dimensions from Exif data. + * * @return array [width, height] */ - public static function getDimensions(array &$exif) { + public static function getDimensions(array &$exif) + { $width = $exif['ImageWidth'] ?? 0; $height = $exif['ImageHeight'] ?? 0; // Check if image is rotated and we need to swap width and height $rotation = $exif['Rotation'] ?? 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]; } @@ -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) */ - public static function updateExifDate(File &$file, string $newDate) { + public static function updateExifDate(File &$file, string $newDate) + { // Check for local files -- this is easier if (!$file->isEncrypted() && $file->getStorage()->isLocal()) { $path = $file->getStorage()->getLocalFile($file->getInternalPath()); - if (is_string($path)) { + if (\is_string($path)) { 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) */ - 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 $tmpfile = tmpfile(); @@ -390,11 +304,11 @@ class Exif { $pipes = []; $proc = proc_open([ 'exiftool', '-api', 'QuickTimeUTC=1', - '-overwrite_original', '-DateTimeOriginal=' . $newDate, '-' + '-overwrite_original', '-DateTimeOriginal='.$newDate, '-', ], [ - 0 => array('pipe', 'rb'), - 1 => array('pipe', 'w'), - 2 => array('pipe', 'w'), + 0 => ['pipe', 'rb'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], ], $pipes); // Write the file to exiftool's stdin @@ -418,9 +332,10 @@ class Exif { while ($waitedMs < $timeout && !feof($pipes[1])) { $r = stream_copy_to_stream($pipes[1], $tmpfile, 1024 * 1024); $newLen += $r; - if ($r === 0) { - $waitedMs++; + if (0 === $r) { + ++$waitedMs; usleep(1000); + continue; } } @@ -428,8 +343,9 @@ class Exif { throw new \Exception('Timeout'); } } catch (\Exception $ex) { - error_log("Exiftool timeout for file stream: " . $ex->getMessage()); - throw new \Exception("Could not read from Exiftool"); + error_log('Exiftool timeout for file stream: '.$ex->getMessage()); + + throw new \Exception('Could not read from Exiftool'); } finally { // Close the pipes fclose($pipes[1]); @@ -440,8 +356,9 @@ class Exif { // Check the new length of the file // If the new length and old length are more different than 1KB, abort if (abs($newLen - $origLen) > 1024) { - error_log("Exiftool error: new length $newLen, old length $origLen"); - throw new \Exception("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}"); } // Write the temp file to the actual file @@ -451,14 +368,16 @@ class Exif { throw new \Exception('Could not open file for writing'); } $wroteBytes = 0; + try { $wroteBytes = stream_copy_to_stream($tmpfile, $out); } finally { fclose($out); } if ($wroteBytes !== $newLen) { - error_log("Exiftool error: wrote $r bytes, expected $newLen"); - throw new \Exception("Could not write to file"); + error_log("Exiftool error: wrote {$r} bytes, expected {$newLen}"); + + throw new \Exception('Could not write to file'); } // All done at this point @@ -468,4 +387,129 @@ class Exif { fclose($tmpfile); } } -} \ No newline at end of file + + /** 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; + } +} diff --git a/lib/Listeners/PostDeleteListener.php b/lib/Listeners/PostDeleteListener.php index e4667476..065682da 100644 --- a/lib/Listeners/PostDeleteListener.php +++ b/lib/Listeners/PostDeleteListener.php @@ -3,7 +3,6 @@ declare(strict_types=1); /** - * * @license AGPL-3.0-or-later * * 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 * along with this program. If not, see . - * */ namespace OCA\Memories\Listeners; -use \OCA\Memories\Db\TimelineWrite; - +use OCA\Memories\Db\TimelineWrite; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\Files\Events\Node\NodeDeletedEvent; use OCP\Files\Folder; use OCP\IDBConnection; -class PostDeleteListener implements IEventListener { +class PostDeleteListener implements IEventListener +{ private TimelineWrite $util; - public function __construct(IDBConnection $connection) { + public function __construct(IDBConnection $connection) + { $this->util = new TimelineWrite($connection); } - public function handle(Event $event): void { - if (!($event instanceof NodeDeletedEvent)) { + public function handle(Event $event): void + { + if (!$event instanceof NodeDeletedEvent) { return; } @@ -50,4 +50,4 @@ class PostDeleteListener implements IEventListener { $this->util->deleteFile($node); } -} \ No newline at end of file +} diff --git a/lib/Listeners/PostWriteListener.php b/lib/Listeners/PostWriteListener.php index 1e7c2280..131bd8fe 100644 --- a/lib/Listeners/PostWriteListener.php +++ b/lib/Listeners/PostWriteListener.php @@ -3,7 +3,6 @@ declare(strict_types=1); /** - * * @license AGPL-3.0-or-later * * 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 * along with this program. If not, see . - * */ namespace OCA\Memories\Listeners; -use \OCA\Memories\Db\TimelineWrite; - +use OCA\Memories\Db\TimelineWrite; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\Files\Events\Node\NodeTouchedEvent; @@ -33,18 +30,23 @@ use OCP\Files\Folder; use OCP\IDBConnection; use OCP\IUserManager; -class PostWriteListener implements IEventListener { +class PostWriteListener implements IEventListener +{ private TimelineWrite $timelineWrite; - public function __construct(IDBConnection $connection, - IUserManager $userManager) { + public function __construct( + IDBConnection $connection, + IUserManager $userManager + ) + { $this->userManager = $userManager; $this->timelineWrite = new TimelineWrite($connection); } - public function handle(Event $event): void { - if (!($event instanceof NodeWrittenEvent) && - !($event instanceof NodeTouchedEvent)) { + public function handle(Event $event): void + { + if (!($event instanceof NodeWrittenEvent) + && !($event instanceof NodeTouchedEvent)) { return; } @@ -63,14 +65,14 @@ class PostWriteListener implements IEventListener { // in reverse order from root to leaf. The rationale is that the // .nomedia file is most likely to be in higher level directories. $parents = []; + try { $parent = $node->getParent(); while ($parent) { $parents[] = $parent; $parent = $parent->getParent(); } - } - catch (\OCP\Files\NotFoundException $e) { + } catch (\OCP\Files\NotFoundException $e) { // This happens when the parent is in the root directory // and getParent() is called on it. } @@ -85,4 +87,4 @@ class PostWriteListener implements IEventListener { $this->timelineWrite->processFile($node); } -} \ No newline at end of file +} diff --git a/lib/Migration/Version000000Date20220812163631.php b/lib/Migration/Version000000Date20220812163631.php index b1a86511..63c27cda 100644 --- a/lib/Migration/Version000000Date20220812163631.php +++ b/lib/Migration/Version000000Date20220812163631.php @@ -4,9 +4,7 @@ declare(strict_types=1); /** * @copyright Copyright (c) 2022 Varun Patil - * * @author Varun Patil - * * @license GNU AGPL version 3 or any later version * * 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 * along with this program. If not, see . - * */ namespace OCA\Memories\Migration; use Closure; -use OCP\DB\Types; use OCP\DB\ISchemaWrapper; -use OCP\Migration\SimpleMigrationStep; +use OCP\DB\Types; 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 array $options - * @return null|ISchemaWrapper - */ - public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) { + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) + { /** @var ISchemaWrapper $schema */ $schema = $schemaClosure(); @@ -66,7 +63,7 @@ class Version000000Date20220812163631 extends SimpleMigrationStep { ]); $table->addColumn('isvideo', Types::BOOLEAN, [ 'notnull' => false, - 'default' => false + 'default' => false, ]); $table->addColumn('mtime', Types::INTEGER, [ 'notnull' => true, @@ -85,4 +82,4 @@ class Version000000Date20220812163631 extends SimpleMigrationStep { return $schema; } -} \ No newline at end of file +} diff --git a/lib/Migration/Version200000Date20220924015634.php b/lib/Migration/Version200000Date20220924015634.php index edc6a37c..689088da 100644 --- a/lib/Migration/Version200000Date20220924015634.php +++ b/lib/Migration/Version200000Date20220924015634.php @@ -4,9 +4,7 @@ declare(strict_types=1); /** * @copyright Copyright (c) 2022 Your name - * * @author Your name - * * @license GNU AGPL version 3 or any later version * * 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 * along with this program. If not, see . - * */ namespace OCA\Memories\Migration; use Closure; use OCP\DB\ISchemaWrapper; +use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; -use OCP\IDBConnection; /** * Auto-generated migration step: Please modify to your needs! */ -class Version200000Date20220924015634 extends SimpleMigrationStep { +class Version200000Date20220924015634 extends SimpleMigrationStep +{ + /** @var IDBConnection */ + private $dbc; - /** @var IDBConnection */ - private $dbc; + public function __construct(IDBConnection $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 array $options - */ - 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 array $options - * @return null|ISchemaWrapper - */ - public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { - /** @var ISchemaWrapper $schema */ + /** + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper + { + /** @var ISchemaWrapper $schema */ $schema = $schemaClosure(); - if (!$schema->hasTable('memories')) { - throw new \Exception('Memories table does not exist'); - } + if (!$schema->hasTable('memories')) { + throw new \Exception('Memories table does not exist'); + } - $table = $schema->getTable('memories'); - if ($table->hasIndex('memories_uid_index')) { - $table->dropIndex('memories_uid_index'); - $table->dropIndex('memories_ud_index'); - $table->dropIndex('memories_day_uf_ui'); - $table->dropColumn('uid'); + $table = $schema->getTable('memories'); + if ($table->hasIndex('memories_uid_index')) { + $table->dropIndex('memories_uid_index'); + $table->dropIndex('memories_ud_index'); + $table->dropIndex('memories_day_uf_ui'); + $table->dropColumn('uid'); - $table->addIndex(['dayid'], 'memories_dayid_index'); - $table->addUniqueIndex(['fileid'], 'memories_fileid_index'); - } + $table->addIndex(['dayid'], 'memories_dayid_index'); + $table->addUniqueIndex(['fileid'], 'memories_fileid_index'); + } - return $schema; - } + return $schema; + } - /** - * @param IOutput $output - * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` - * @param array $options - */ - public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { - } + /** + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void + { + } } diff --git a/lib/Migration/Version400000Date20221015121115.php b/lib/Migration/Version400000Date20221015121115.php index 8cf3f731..e4fcf4ea 100644 --- a/lib/Migration/Version400000Date20221015121115.php +++ b/lib/Migration/Version400000Date20221015121115.php @@ -4,9 +4,7 @@ declare(strict_types=1); /** * @copyright Copyright (c) 2022 Your name - * * @author Your name - * * @license GNU AGPL version 3 or any later version * * 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 * along with this program. If not, see . - * */ namespace OCA\Memories\Migration; use Closure; -use OCP\DB\Types; use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; /** * Auto-generated migration step: Please modify to your needs! */ -class 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 array $options - */ - public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { - } - - /** - * @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 */ + /** + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper + { + /** @var ISchemaWrapper $schema */ $schema = $schemaClosure(); - if (!$schema->hasTable('memories')) { - throw new \Exception('Memories table does not exist'); - } + if (!$schema->hasTable('memories')) { + throw new \Exception('Memories table does not exist'); + } - $table = $schema->getTable('memories'); + $table = $schema->getTable('memories'); $table->addColumn('w', Types::INTEGER, [ 'notnull' => true, @@ -70,14 +64,13 @@ class Version400000Date20221015121115 extends SimpleMigrationStep { 'default' => 0, ]); - return $schema; - } + return $schema; + } - /** - * @param IOutput $output - * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` - * @param array $options - */ - public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { - } + /** + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void + { + } } diff --git a/lib/Util.php b/lib/Util.php index 414d40be..d1195011 100644 --- a/lib/Util.php +++ b/lib/Util.php @@ -1,4 +1,5 @@ getUserValue($userId, Application::APPNAME, 'timelinePath', ''); if (empty($p)) { return '/Photos/'; } + return $p; } -} \ No newline at end of file +}