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

View File

@ -4,9 +4,7 @@ declare(strict_types=1);
/**
* @copyright Copyright (c) 2022, Varun Patil <radialapps@gmail.com>
*
* @author Varun Patil <radialapps@gmail.com>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
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
{
}
}

View File

@ -4,9 +4,7 @@ declare(strict_types=1);
/**
* @copyright Copyright (c) 2022, Varun Patil <radialapps@gmail.com>
*
* @author Varun Patil <radialapps@gmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
@ -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 <http://www.gnu.org/licenses/>.
*
*/
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,
public function __construct(
IRootFolder $rootFolder,
IUserManager $userManager,
IPreview $previewGenerator,
IConfig $config,
IManager $encryptionManager,
IDBConnection $connection,
ContainerInterface $container) {
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('<error>Storage for folder folder %s is not available: %s</error>',
$this->output->writeln(sprintf(
'<error>Storage for folder folder %s is not available: %s</error>',
$folder->getPath(),
$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;
}
}
}

View File

@ -1,11 +1,10 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
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 {
public function serviceWorker(): StreamResponse
{
$response = new StreamResponse(__DIR__.'/../../js/memories-service-worker.js');
$response->setHeaders([
'Content-Type' => 'application/javascript',
'Service-Worker-Allowed' => '/'
'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', '>=');
}
}

View File

@ -1,26 +1,27 @@
<?php
namespace OCA\Memories\Controller;
use OCP\IRequest;
use OCP\AppFramework\Services\IInitialState;
use OCP\AppFramework\Http\TemplateResponse;
use OCA\Viewer\Event\LoadViewer;
use OCA\Files\Event\LoadSidebar;
use OCP\AppFramework\Controller;
use OCA\Memories\AppInfo\Application;
use OCA\Viewer\Event\LoadViewer;
use OCP\App\IAppManager;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IConfig;
use OCP\IRequest;
use OCP\IUserSession;
use OCP\Util;
use OCA\Memories\AppInfo\Application;
class PageController extends Controller {
class PageController extends Controller
{
protected $userId;
protected $appName;
private IAppManager $appManager;
protected IEventDispatcher $eventDispatcher;
private IAppManager $appManager;
private IInitialState $initialState;
private IUserSession $userSession;
private IConfig $config;
@ -33,8 +34,9 @@ class PageController extends Controller {
IEventDispatcher $eventDispatcher,
IInitialState $initialState,
IUserSession $userSession,
IConfig $config) {
IConfig $config
)
{
parent::__construct($AppName, $request);
$this->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;
}
@ -65,12 +69,16 @@ class PageController extends Controller {
$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));
$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'");
@ -78,62 +86,77 @@ class PageController extends Controller {
$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();
}
}

View File

@ -1,41 +1,48 @@
<?php
declare(strict_types=1);
namespace OCA\Memories\Db;
use OCP\IDBConnection;
class TimelineQuery {
class TimelineQuery
{
use TimelineQueryDays;
use TimelineQueryFaces;
use TimelineQueryFilters;
use TimelineQueryTags;
use TimelineQueryFaces;
protected IDBConnection $connection;
public function __construct(IDBConnection $connection) {
public function __construct(IDBConnection $connection)
{
$this->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,
];
}

View File

@ -1,117 +1,25 @@
<?php
declare(strict_types=1);
namespace OCA\Memories\Db;
use OCP\IDBConnection;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\Folder;
use OCP\IDBConnection;
trait TimelineQueryDays {
trait TimelineQueryDays
{
protected IDBConnection $connection;
/**
* Process the days response
* @param array $days
*/
private function processDays(&$days) {
foreach($days as &$row) {
$row["dayid"] = intval($row["dayid"]);
$row["count"] = intval($row["count"]);
// All transform processing
$this->processFace($row, true);
}
return $days;
}
/**
* Process the single day response
* @param array $day
*/
private function processDay(&$day) {
foreach($day as &$row) {
// We don't need date taken (see query builder)
unset($row['datetaken']);
// Convert field types
$row["fileid"] = intval($row["fileid"]);
$row["isvideo"] = intval($row["isvideo"]);
$row["dayid"] = intval($row["dayid"]);
$row["w"] = intval($row["w"]);
$row["h"] = intval($row["h"]);
if (!$row["isvideo"]) {
unset($row["isvideo"]);
}
if ($row["categoryid"]) {
$row["isfavorite"] = 1;
}
unset($row["categoryid"]);
// All transform processing
$this->processFace($row);
}
return $day;
}
/** Get the query for oc_filecache join */
private function getFilecacheJoinQuery(
IQueryBuilder &$query,
Folder &$folder,
bool $recursive,
bool $archive
) {
// Subquery to get storage and path
$subQuery = $query->getConnection()->getQueryBuilder();
$cursor = $subQuery->select('path', 'storage')->from('filecache')->where(
$subQuery->expr()->eq('fileid', $subQuery->createNamedParameter($folder->getId())),
)->executeQuery();
$finfo = $cursor->fetch();
$cursor->closeCursor();
if (empty($finfo)) {
throw new \Exception("Folder not found");
}
$pathQuery = null;
if ($recursive) {
// Filter by path for recursive query
$likePath = $finfo["path"];
if (!empty($likePath)) {
$likePath .= '/';
}
$pathQuery = $query->expr()->like('f.path', $query->createNamedParameter($likePath . '%'));
// Exclude/show archive folder
$archiveLikePath = $likePath . \OCA\Memories\Util::$ARCHIVE_FOLDER . '/%';
if (!$archive) {
// Exclude archive folder
$pathQuery = $query->expr()->andX(
$pathQuery,
$query->expr()->notLike('f.path', $query->createNamedParameter($archiveLikePath))
);
} else {
// Show only archive folder
$pathQuery = $query->expr()->like('f.path', $query->createNamedParameter($archiveLikePath));
}
} else {
// If getting non-recursively folder only check for parent
$pathQuery = $query->expr()->eq('f.parent', $query->createNamedParameter($folder->getId(), IQueryBuilder::PARAM_INT));
}
return $query->expr()->andX(
$query->expr()->eq('f.fileid', 'm.fileid'),
$query->expr()->in('f.storage', $query->createNamedParameter($finfo["storage"])),
$pathQuery,
);
}
/**
* Get the days response from the database for the timeline
* Get the days response from the database for the timeline.
*
* @param Folder $folder The folder to get the days from
* @param bool $recursive Whether to get the days recursively
* @param bool $archive Whether to get the days only from the archive folder
* @param array $queryTransforms An array of query transforms to apply to the query
*
* @return array The days response
*/
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
* 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,
);
}
}

View File

@ -1,19 +1,24 @@
<?php
declare(strict_types=1);
namespace OCA\Memories\Db;
use OCP\IDBConnection;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\Folder;
use OCP\IDBConnection;
trait TimelineQueryFaces {
trait TimelineQueryFaces
{
protected IDBConnection $connection;
public function transformFaceFilter(IQueryBuilder &$query, string $userId, string $faceStr) {
public function transformFaceFilter(IQueryBuilder &$query, string $userId, string $faceStr)
{
// Get title and uid of face user
$faceNames = explode('/', $faceStr);
if (count($faceNames) !== 2) throw new \Exception("Invalid face query");
if (2 !== \count($faceNames)) {
throw new \Exception('Invalid face query');
}
$faceUid = $faceNames[0];
$faceName = $faceNames[1];
@ -31,7 +36,8 @@ trait TimelineQueryFaces {
));
}
public function transformFaceRect(IQueryBuilder &$query, string $userId) {
public function transformFaceRect(IQueryBuilder &$query, string $userId)
{
// Include detection params in response
$query->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;
}
/** Convert face fields to object */
private function processFace(&$row, $days = false)
{
if (!isset($row) || !isset($row['face_w'])) {
return;
}
if (!$days) {
$row['facerect'] = [
'w' => (float) ($row['face_w']),
'h' => (float) ($row['face_h']),
'x' => (float) ($row['face_x']),
'y' => (float) ($row['face_y']),
];
}
unset($row['face_w'], $row['face_h'], $row['face_x'], $row['face_y']);
}
}

View File

@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace OCA\Memories\Db;
@ -6,50 +7,59 @@ namespace OCA\Memories\Db;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\ITags;
trait TimelineQueryFilters {
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);
}
}
public function transformFavoriteFilter(IQueryBuilder &$query, string $userId) {
trait TimelineQueryFilters
{
public function transformFavoriteFilter(IQueryBuilder &$query, string $userId)
{
$query->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);
}
private function applyAllTransforms(array $transforms, IQueryBuilder &$query, string $uid): void
{
foreach ($transforms as &$transform) {
$fun = \array_slice($transform, 0, 2);
$params = \array_slice($transform, 2);
array_unshift($params, $uid);
array_unshift($params, $query);
$fun(...$params);
}
}
private function getFavoriteVCategoryFun(IQueryBuilder &$query, string $userId)
{
return $query->createFunction(
$query->getConnection()->getQueryBuilder()->select('id')->from('vcategory', 'vc')->where(
$query->expr()->andX(
$query->expr()->eq('type', $query->createNamedParameter('files')),
$query->expr()->eq('uid', $query->createNamedParameter($userId)),
$query->expr()->eq('category', $query->createNamedParameter(ITags::TAG_FAVORITE)),
)
)->getSQL()
);
}
}

View File

@ -1,38 +1,45 @@
<?php
declare(strict_types=1);
namespace OCA\Memories\Db;
use OCP\IDBConnection;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\Folder;
use OCP\IDBConnection;
trait TimelineQueryTags {
trait TimelineQueryTags
{
protected IDBConnection $connection;
public function getSystemTagId(IQueryBuilder &$query, string $tagName) {
public function getSystemTagId(IQueryBuilder &$query, string $tagName)
{
$sqb = $query->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,10 +112,10 @@ 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;

View File

@ -1,44 +1,50 @@
<?php
declare(strict_types=1);
namespace OCA\Memories\Db;
use OCA\Memories\AppInfo\Application;
use OCA\Memories\Exif;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\File;
use OCP\IDBConnection;
use OCP\DB\QueryBuilder\IQueryBuilder;
class TimelineWrite {
class TimelineWrite
{
protected IDBConnection $connection;
public function __construct(IDBConnection $connection) {
public function __construct(IDBConnection $connection)
{
$this->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,21 +125,24 @@ 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();

View File

@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace OCA\Memories;
@ -7,24 +8,15 @@ use OCA\Memories\AppInfo\Application;
use OCP\Files\File;
use OCP\IConfig;
class Exif {
class Exif
{
/** Opened instance of exiftool when running in command mode */
private static $staticProc = null;
private static $staticPipes = null;
private static $staticProc;
private static $staticPipes;
private static $noStaticProc = false;
/** Initialize static exiftool process for local reads */
private static function initializeStaticExiftoolProc() {
self::closeStaticExiftoolProc();
self::$staticProc = proc_open(['exiftool', '-stay_open', 'true', '-@', '-'], [
0 => array('pipe', 'r'),
1 => array('pipe', 'w'),
2 => array('pipe', 'w'),
], self::$staticPipes);
stream_set_blocking(self::$staticPipes[1], false);
}
public static function closeStaticExiftoolProc() {
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);
}
}
/**
* 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);
}
}
/** Initialize static exiftool process for local reads */
private static function initializeStaticExiftoolProc()
{
self::closeStaticExiftoolProc();
self::$staticProc = proc_open(['exiftool', '-stay_open', 'true', '-@', '-'], [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], self::$staticPipes);
stream_set_blocking(self::$staticPipes[1], false);
}
/**
* Read from non blocking handle or throw timeout.
*
* @param resource $handle
* @param int $timeout milliseconds
* @param string $delimiter null for eof
*/
private static function readOrTimeout($handle, $timeout, $delimiter = null)
{
$buf = '';
$waitedMs = 0;
while ($waitedMs < $timeout && ($delimiter ? !str_ends_with($buf, $delimiter) : !feof($handle))) {
$r = stream_get_contents($handle);
if (empty($r)) {
++$waitedMs;
usleep(1000);
continue;
}
$buf .= $r;
}
if ($waitedMs >= $timeout) {
throw new \Exception('Timeout');
}
return $buf;
}
private static function getExifFromLocalPathWithStaticProc(string &$path)
{
fwrite(self::$staticPipes[0], "{$path}\n-json\n-api\nQuickTimeUTC=1\n-n\n-execute\n");
fflush(self::$staticPipes[0]);
$readyToken = "\n{ready}\n";
try {
$buf = self::readOrTimeout(self::$staticPipes[1], 5000, $readyToken);
$tokPos = strrpos($buf, $readyToken);
$buf = substr($buf, 0, $tokPos);
return self::processStdout($buf);
} catch (\Exception $ex) {
error_log("ERROR: Exiftool may have crashed, restarting process [{$path}]");
self::restartStaticExiftoolProc();
throw new \Exception('Nothing to read from Exiftool');
}
}
private static function getExifFromLocalPathWithSeparateProc(string &$path)
{
$pipes = [];
$proc = proc_open(['exiftool', '-api', 'QuickTimeUTC=1', '-n', '-json', $path], [
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes);
stream_set_blocking($pipes[1], false);
try {
$stdout = self::readOrTimeout($pipes[1], 5000);
return self::processStdout($stdout);
} catch (\Exception $ex) {
error_log("Exiftool timeout: [{$path}]");
throw new \Exception('Could not read from Exiftool');
} finally {
fclose($pipes[1]);
fclose($pipes[2]);
proc_terminate($proc);
}
}
/** Get json array from stdout of exiftool */
private static function processStdout(string &$stdout)
{
$json = json_decode($stdout, true);
if (!$json) {
throw new \Exception('Could not read exif data');
}
return $json[0];
}
/**
* Update exif date using exiftool for a local file.
*
* @param string $newDate formatted in standard Exif format (YYYY:MM:DD HH:MM:SS)
*
* @return bool
*/
private static function updateExifDateForLocalFile(string $path, string $newDate)
{
$cmd = ['exiftool', '-api', 'QuickTimeUTC=1', '-overwrite_original', '-DateTimeOriginal='.$newDate, $path];
$proc = proc_open($cmd, [
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes);
$stdout = self::readOrTimeout($pipes[1], 300000);
fclose($pipes[1]);
fclose($pipes[2]);
proc_terminate($proc);
if (false !== strpos($stdout, 'error')) {
error_log("Exiftool error: {$stdout}");
throw new \Exception('Could not update exif date: '.$stdout);
}
return true;
}
}

View File

@ -3,7 +3,6 @@
declare(strict_types=1);
/**
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
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;
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*
*/
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.
}

View File

@ -4,9 +4,7 @@ declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Varun Patil <radialapps@gmail.com>
*
* @author Varun Patil <radialapps@gmail.com>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
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) {
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,

View File

@ -4,9 +4,7 @@ declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Your name <your@email.com>
*
* @author Your name <your@email.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
@ -21,35 +19,34 @@ declare(strict_types=1);
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Memories\Migration;
use 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;
public function __construct(IDBConnection $dbc) {
public function __construct(IDBConnection $dbc)
{
$this->dbc = $dbc;
}
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
*/
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void
{
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if ($schema->hasTable('memories')) {
@ -62,12 +59,10 @@ class Version200000Date20220924015634 extends SimpleMigrationStep {
}
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper
{
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
@ -90,10 +85,9 @@ class Version200000Date20220924015634 extends SimpleMigrationStep {
}
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
*/
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void
{
}
}

View File

@ -4,9 +4,7 @@ declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Your name <your@email.com>
*
* @author Your name <your@email.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
@ -21,37 +19,33 @@ declare(strict_types=1);
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Memories\Migration;
use 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 IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
*/
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
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 {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper
{
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
@ -74,10 +68,9 @@ class Version400000Date20221015121115 extends SimpleMigrationStep {
}
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
*/
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void
{
}
}

View File

@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace OCA\Memories;
@ -6,7 +7,8 @@ namespace OCA\Memories;
use OCA\Memories\AppInfo\Application;
use OCP\IConfig;
class Util {
class Util
{
public static $TAG_DAYID_START = -(1 << 30); // the world surely didn't exist
public static $TAG_DAYID_FOLDERS = -(1 << 30) + 1;
@ -14,14 +16,14 @@ class Util {
/**
* 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 $p;
}
}