Merge branch 'master' into stable24

old_stable24
Varun Patil 2022-11-22 00:21:29 -08:00
commit abb8dcfc90
35 changed files with 740 additions and 329 deletions

View File

@ -2,6 +2,15 @@
This file is manually updated. Please file an issue if something is missing.
## v4.8.0, v3.8.0
- **Feature**: Timeline path now scans recursively for mounted volumes / shares inside it
- **Feature**: Multiple timeline paths can be specified ([#178](https://github.com/pulsejet/memories/issues/178))
- Support for server-side encrypted storage ([#99](https://github.com/pulsejet/memories/issues/99))
- Mouse wheel now zooms on desktop
- Improved caching performance
- Due to incorrect caching in previous versions, your browser cache may have become very large. You can clear it to save some space.
## v4.7.0, v3.7.0 (2022-11-14)
- **Note**: you must run `occ memories:index -f` to take advantage of new features.

View File

@ -39,7 +39,7 @@ Memories is a _batteries-included_ photo management solution for Nextcloud with
1. ☁ Clone this into your `apps` folder of your Nextcloud.
1. 👩‍💻 In a terminal, run the command `make dev-setup` to install the dependencies.
1. 🏗 To build the Typescript, run `make build-js`. Watch changes with: `make watch-js`.
1. 🏗 To build the Typescript, run `make build-js`. Watch changes with: `make watch-js`. Lint-fix PHP with `make php-lint`.
1. ✅ Enable the app through the app management of your Nextcloud.
1. ⚒️ (Strongly recommended) use VS Code and install Vetur and Prettier.

View File

@ -40,6 +40,7 @@ use OCP\IUserManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\ConsoleSectionOutput;
use Symfony\Component\Console\Output\OutputInterface;
class Index extends Command
@ -58,12 +59,14 @@ class Index extends Command
protected TimelineWrite $timelineWrite;
// Stats
private int $nUser = 0;
private int $nProcessed = 0;
private int $nSkipped = 0;
private int $nInvalid = 0;
private int $nNoMedia = 0;
// Helper for the progress bar
private int $previousLineLength = 0;
private ConsoleSectionOutput $outputSection;
public function __construct(
IRootFolder $rootFolder,
@ -178,8 +181,9 @@ class Index extends Command
// Time measurement
$startTime = microtime(true);
if ($this->encryptionManager->isEnabled()) {
error_log('FATAL: Encryption is enabled. Aborted.');
if (\OCA\Memories\Util::isEncryptionEnabled($this->encryptionManager)) {
// Can work with server-side but not with e2e encryption, see https://github.com/pulsejet/memories/issues/99
error_log('FATAL: Only server-side encryption (OC_DEFAULT_MODULE) is supported, but another encryption module is enabled. Aborted.');
return 1;
}
@ -192,10 +196,11 @@ class Index extends Command
// Show some stats
$endTime = microtime(true);
$execTime = (int) (($endTime - $startTime) * 1000) / 1000;
$nTotal = $this->nInvalid + $this->nSkipped + $this->nProcessed;
$nTotal = $this->nInvalid + $this->nSkipped + $this->nProcessed + $this->nNoMedia;
$this->output->writeln('==========================================');
$this->output->writeln("Checked {$nTotal} files in {$execTime} sec");
$this->output->writeln("Checked {$nTotal} files of {$this->nUser} users in {$execTime} sec");
$this->output->writeln($this->nInvalid.' not valid media items');
$this->output->writeln($this->nNoMedia.' .nomedia folders ignored');
$this->output->writeln($this->nSkipped.' skipped because unmodified');
$this->output->writeln($this->nProcessed.' (re-)processed');
$this->output->writeln('==========================================');
@ -245,31 +250,31 @@ class Index extends Command
$uid = $user->getUID();
$userFolder = $this->rootFolder->getUserFolder($uid);
$this->parseFolder($userFolder, $refresh);
if ($this->previousLineLength) {
$this->output->write("\r".str_repeat(' ', $this->previousLineLength)."\r");
}
$this->outputSection = $this->output->section();
$this->parseFolder($userFolder, $refresh, $this->nUser, $this->userManager->countSeenUsers());
$this->outputSection->overwrite('Scanned '.$userFolder->getPath());
++$this->nUser;
}
private function parseFolder(Folder &$folder, bool &$refresh): void
private function parseFolder(Folder &$folder, bool &$refresh, int $progress_i, int $progress_n): 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.' because of .nomedia file');
$this->previousLineLength = 0;
++$this->nNoMedia;
return;
}
$nodes = $folder->getDirectoryListing();
foreach ($nodes as &$node) {
foreach ($nodes as $i => &$node) {
if ($node instanceof Folder) {
$this->parseFolder($node, $refresh);
$this->parseFolder($node, $refresh, $progress_i * \count($nodes) + $i, $progress_n * \count($nodes));
} elseif ($node instanceof File) {
$this->outputSection->overwrite(sprintf('%.2f%%', $progress_i / $progress_n * 100).' scanning '.$node->getPath());
$this->parseFile($node, $refresh);
}
}
@ -284,14 +289,6 @@ class Index extends Command
private function parseFile(File &$file, bool &$refresh): void
{
// Clear previous line and write new one
$line = 'Scanning file '.$file->getPath();
if ($this->previousLineLength) {
$this->output->write("\r".str_repeat(' ', $this->previousLineLength)."\r");
}
$this->output->write($line."\r");
$this->previousLineLength = \strlen($line);
// Process the file
$res = $this->timelineWrite->processFile($file, $refresh);
if (2 === $res) {

View File

@ -52,7 +52,15 @@ class VideoSetup extends Command
{
// Preset executables
$ffmpegPath = $this->config->getSystemValue('memories.ffmpeg_path', 'ffmpeg');
if ('ffmpeg' === $ffmpegPath) {
$ffmpegPath = trim(shell_exec('which ffmpeg') ?: 'ffmpeg');
$this->config->setSystemValue('memories.ffmpeg_path', $ffmpegPath);
}
$ffprobePath = $this->config->getSystemValue('memories.ffprobe_path', 'ffprobe');
if ('ffprobe' === $ffprobePath) {
$ffprobePath = trim(shell_exec('which ffprobe') ?: 'ffprobe');
$this->config->setSystemValue('memories.ffprobe_path', $ffprobePath);
}
// Get ffmpeg version
$ffmpeg = shell_exec("{$ffmpegPath} -version");
@ -80,7 +88,9 @@ class VideoSetup extends Command
// Check go-vod binary
$output->writeln('Checking for go-vod binary');
$goVodPath = $this->config->getSystemValue('memories.transcoder', false);
if (false === $goVodPath) {
// Detect architecture
$arch = \OCA\Memories\Util::getArch();
@ -92,6 +102,8 @@ class VideoSetup extends Command
}
$goVodPath = realpath(__DIR__."/../../exiftool-bin/go-vod-{$arch}");
}
$output->writeln("Trying go-vod from {$goVodPath}");
chmod($goVodPath, 0755);

View File

@ -25,12 +25,14 @@ namespace OCA\Memories\Controller;
use OCA\Memories\AppInfo\Application;
use OCA\Memories\Db\TimelineQuery;
use OCA\Memories\Db\TimelineRoot;
use OCA\Memories\Db\TimelineWrite;
use OCA\Memories\Exif;
use OCP\App\IAppManager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Encryption\IManager;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
@ -47,6 +49,7 @@ class ApiBase extends Controller
protected IUserSession $userSession;
protected IRootFolder $rootFolder;
protected IAppManager $appManager;
protected IManager $encryptionManager;
protected TimelineQuery $timelineQuery;
protected TimelineWrite $timelineWrite;
protected IShareManager $shareManager;
@ -59,6 +62,7 @@ class ApiBase extends Controller
IDBConnection $connection,
IRootFolder $rootFolder,
IAppManager $appManager,
IManager $encryptionManager,
IShareManager $shareManager,
IPreview $preview
) {
@ -69,6 +73,7 @@ class ApiBase extends Controller
$this->connection = $connection;
$this->rootFolder = $rootFolder;
$this->appManager = $appManager;
$this->encryptionManager = $encryptionManager;
$this->shareManager = $shareManager;
$this->previewManager = $preview;
$this->timelineQuery = new TimelineQuery($connection);
@ -88,12 +93,14 @@ class ApiBase extends Controller
return $user ? $user->getUID() : '';
}
/** Get the Folder object relevant to the request */
protected function getRequestFolder()
/** Get the TimelineRoot object relevant to the request */
protected function getRequestRoot()
{
$root = new TimelineRoot();
// Albums have no folder
if ($this->request->getParam('album')) {
return null;
return $root;
}
// Public shared folder
@ -103,35 +110,45 @@ class ApiBase extends Controller
throw new \Exception('Share not found or invalid');
}
return $share;
$root->addFolder($share);
return $root;
}
// Anything else needs a user
$user = $this->userSession->getUser();
if (null === $user) {
return null;
throw new \Exception('User not logged in');
}
$uid = $user->getUID();
$folder = null;
$folderPath = $this->request->getParam('folder');
$forcedTimelinePath = $this->request->getParam('timelinePath');
$userFolder = $this->rootFolder->getUserFolder($uid);
try {
if (null !== $folderPath) {
$folder = $userFolder->get($folderPath);
} elseif (null !== $forcedTimelinePath) {
$folder = $userFolder->get($forcedTimelinePath);
$folder = $userFolder->get(Exif::removeExtraSlash($folderPath));
$root->addFolder($folder);
} else {
$configPath = Exif::removeExtraSlash(Exif::getPhotosPath($this->config, $uid));
$folder = $userFolder->get($configPath);
$timelinePath = $this->request->getParam('timelinePath', Exif::getPhotosPath($this->config, $uid));
$timelinePath = Exif::removeExtraSlash($timelinePath);
// Multiple timeline path support
$paths = explode(';', $timelinePath);
foreach ($paths as &$path) {
$folder = $userFolder->get(trim($path));
$root->addFolder($folder);
}
$root->addMountPoints();
}
} catch (\OCP\Files\NotFoundException $e) {
$msg = $e->getMessage();
throw new \Exception("Folder not found: {$msg}");
}
if (!$folder instanceof Folder) {
throw new \Exception('Folder not found');
}
return $folder;
return $root;
}
/**
@ -160,6 +177,11 @@ class ApiBase extends Controller
return null;
}
// Check read permission
if (!($file[0]->getPermissions() & \OCP\Constants::PERMISSION_READ)) {
return null;
}
return $file[0];
}

View File

@ -54,62 +54,94 @@ class ArchiveController extends ApiBase
$file = $file[0];
// Check if user has permissions
if (!$file->isUpdateable()) {
if (!$file->isUpdateable() || !($file->getPermissions() & \OCP\Constants::PERMISSION_UPDATE)) {
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 (null === $timelineFolder || !$timelineFolder instanceof Folder) {
return new JSONResponse(['message' => 'Cannot get timeline'], Http::STATUS_INTERNAL_SERVER_ERROR);
$configPath = Exif::removeExtraSlash(Exif::getPhotosPath($this->config, $uid));
$configPaths = explode(';', $configPath);
$timelineFolders = [];
$timelinePaths = [];
// Get all timeline paths
foreach ($configPaths as $path) {
try {
$f = $userFolder->get($path);
$timelineFolders[] = $f;
$timelinePaths[] = $f->getPath();
} catch (\OCP\Files\NotFoundException $e) {
return new JSONResponse(['message' => 'Timeline folder not found'], Http::STATUS_NOT_FOUND);
}
if (!$timelineFolder->isCreatable()) {
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);
// Bubble up from file until we reach the correct folder
$fileStorageId = $file->getStorage()->getId();
$parent = $file->getParent();
$isArchived = false;
while (true) {
if (null === $parent) {
throw new \Exception('Cannot get correct parent of file');
}
$relativePath = substr($file->getPath(), \strlen($timelinePath)); // has a leading slash
// Final path of the file including the file name
$destinationPath = '';
// Hit a timeline folder
if (\in_array($parent->getPath(), $timelinePaths, true)) {
break;
}
// Hit a storage root
try {
if ($parent->getParent()->getStorage()->getId() !== $fileStorageId) {
break;
}
} catch (\OCP\Files\NotFoundException $e) {
break;
}
// Hit an archive folder root
if ($parent->getName() === \OCA\Memories\Util::$ARCHIVE_FOLDER) {
$isArchived = true;
break;
}
$parent = $parent->getParent();
}
// Get path of current file relative to the parent folder
$relativeFilePath = $parent->getRelativePath($file->getPath());
// Check if we want to archive or unarchive
$body = $this->request->getParams();
$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) {
// file already in archive, remove it instead
$destinationPath = substr($relativePath, \strlen($archiveFolderWithLeadingSlash));
if (!$unarchive) {
if ($isArchived && !$unarchive) {
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);
if ($unarchive) {
if (!$isArchived && $unarchive) {
return new JSONResponse(['message' => 'File not archived'], Http::STATUS_BAD_REQUEST);
}
// Final path of the file including the file name
$destinationPath = '';
// Get if the file is already in the archive (relativePath starts with archive)
if ($isArchived) {
// file already in archive, remove it
$destinationPath = $relativeFilePath;
$parent = $parent->getParent();
} else {
// file not in archive, put it in there
$af = \OCA\Memories\Util::$ARCHIVE_FOLDER;
$destinationPath = Exif::removeExtraSlash($af.$relativeFilePath);
}
// Remove the filename
$destinationFolders = explode('/', $destinationPath);
$destinationFolders = array_filter(explode('/', $destinationPath));
array_pop($destinationFolders);
// Create folder tree
$folder = $timelineFolder;
$folder = $parent;
foreach ($destinationFolders as $folderName) {
if ('' === $folderName) {
continue;
}
try {
$existingFolder = $folder->get($folderName.'/');
if (!$existingFolder instanceof Folder) {

View File

@ -23,9 +23,9 @@ declare(strict_types=1);
namespace OCA\Memories\Controller;
use OCA\Memories\Db\TimelineRoot;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Files\Folder;
class DaysController extends ApiBase
{
@ -42,10 +42,10 @@ class DaysController extends ApiBase
$uid = $this->getUid();
// Get the folder to show
$folder = null;
$root = null;
try {
$folder = $this->getRequestFolder();
$root = $this->getRequestRoot();
} catch (\Exception $e) {
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_NOT_FOUND);
}
@ -53,7 +53,7 @@ class DaysController extends ApiBase
// Run actual query
try {
$list = $this->timelineQuery->getDays(
$folder,
$root,
$uid,
$this->isRecursive(),
$this->isArchive(),
@ -65,7 +65,7 @@ class DaysController extends ApiBase
$list = $this->timelineQuery->daysToMonths($list);
} else {
// Preload some day responses
$this->preloadDays($list, $uid, $folder);
$this->preloadDays($list, $uid, $root);
}
// Reverse response if requested. Folders still stay at top.
@ -75,7 +75,7 @@ class DaysController extends ApiBase
// Add subfolder info if querying non-recursively
if (!$this->isRecursive()) {
array_unshift($list, $this->getSubfoldersEntry($folder));
array_unshift($list, $this->getSubfoldersEntry($root->getFolder($root->getOneId())));
}
return new JSONResponse($list, Http::STATUS_OK);
@ -111,10 +111,10 @@ class DaysController extends ApiBase
}
// Get the folder to show
$folder = null;
$root = null;
try {
$folder = $this->getRequestFolder();
$root = $this->getRequestRoot();
} catch (\Exception $e) {
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_NOT_FOUND);
}
@ -127,7 +127,7 @@ class DaysController extends ApiBase
// Run actual query
try {
$list = $this->timelineQuery->getDay(
$folder,
$root,
$uid,
$dayIds,
$this->isRecursive(),
@ -239,9 +239,9 @@ class DaysController extends ApiBase
*
* @param array $days the days array
* @param string $uid User ID or blank for public shares
* @param null|Folder $folder the folder to search in
* @param TimelineRoot $root the root folder
*/
private function preloadDays(array &$days, string $uid, &$folder)
private function preloadDays(array &$days, string $uid, TimelineRoot &$root)
{
$transforms = $this->getTransformations(false);
$preloaded = 0;
@ -263,7 +263,7 @@ class DaysController extends ApiBase
if (\count($preloadDayIds) > 0) {
$allDetails = $this->timelineQuery->getDay(
$folder,
$root,
$uid,
$preloadDayIds,
$this->isRecursive(),

View File

@ -49,14 +49,14 @@ class FacesController extends ApiBase
}
// If this isn't the timeline folder then things aren't going to work
$folder = $this->getRequestFolder();
if (null === $folder) {
$root = $this->getRequestRoot();
if ($root->isEmpty()) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
// Run actual query
$list = $this->timelineQuery->getFaces(
$folder,
$root,
);
return new JSONResponse($list, Http::STATUS_OK);
@ -84,26 +84,32 @@ class FacesController extends ApiBase
}
// Get folder to search for
$folder = $this->getRequestFolder();
if (null === $folder) {
$root = $this->getRequestRoot();
if ($root->isEmpty()) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
// Run actual query
$detections = $this->timelineQuery->getFacePreviewDetection($folder, (int) $id);
$detections = $this->timelineQuery->getFacePreviewDetection($root, (int) $id);
if (null === $detections || 0 === \count($detections)) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
}
// Find the first detection that has a preview
$preview = null;
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
foreach ($detections as &$detection) {
// Get the file (also checks permissions)
$files = $folder->getById($detection['file_id']);
$files = $userFolder->getById($detection['file_id']);
if (0 === \count($files) || FileInfo::TYPE_FILE !== $files[0]->getType()) {
continue;
}
// Check read permission
if (!($files[0]->getPermissions() & \OCP\Constants::PERMISSION_READ)) {
continue;
}
// Get (hopefully cached) preview image
try {
$preview = $this->previewManager->getPreview($files[0], 2048, 2048, false);

View File

@ -13,7 +13,7 @@ trait FoldersTrait
/**
* 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
@ -34,7 +34,7 @@ trait FoldersTrait
return [
'dayid' => \OCA\Memories\Util::$TAG_DAYID_FOLDERS,
'count' => \count($folders),
'detail' => array_map(function (&$node) use (&$folder) {
'detail' => array_map(function ($node) use (&$folder) {
return [
'fileid' => $node->getId(),
'name' => $node->getName(),

View File

@ -71,10 +71,15 @@ class ImageController extends ApiBase
}
// Check if user has permissions
if (!$file->isUpdateable()) {
if (!$file->isUpdateable() || !($file->getPermissions() & \OCP\Constants::PERMISSION_UPDATE)) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
// Check for end-to-end encryption
if (\OCA\Memories\Util::isEncryptionEnabled($this->encryptionManager)) {
return new JSONResponse(['message' => 'Cannot change encrypted file'], Http::STATUS_PRECONDITION_FAILED);
}
// Get original file from body
$exif = $this->request->getParam('raw');
$path = $file->getStorage()->getLocalFile($file->getInternalPath());

View File

@ -46,14 +46,14 @@ class TagsController extends ApiBase
}
// If this isn't the timeline folder then things aren't going to work
$folder = $this->getRequestFolder();
if (null === $folder) {
$root = $this->getRequestRoot();
if ($root->isEmpty()) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
// Run actual query
$list = $this->timelineQuery->getTags(
$folder,
$root,
);
return new JSONResponse($list, Http::STATUS_OK);
@ -77,8 +77,8 @@ class TagsController extends ApiBase
}
// If this isn't the timeline folder then things aren't going to work
$folder = $this->getRequestFolder();
if (null === $folder) {
$root = $this->getRequestRoot();
if ($root->isEmpty()) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
@ -88,7 +88,7 @@ class TagsController extends ApiBase
// Run actual query
$list = $this->timelineQuery->getTagPreviews(
$tagName,
$folder,
$root,
);
return new JSONResponse($list, Http::STATUS_OK);

View File

@ -62,6 +62,10 @@ class VideoController extends ApiBase
}
$file = $files[0];
if (!($file->getPermissions() & \OCP\Constants::PERMISSION_READ)) {
return new JSONResponse(['message' => 'File not readable'], Http::STATUS_FORBIDDEN);
}
// Local files only for now
if (!$file->getStorage()->isLocal()) {
return new JSONResponse(['message' => 'External storage not supported'], Http::STATUS_FORBIDDEN);

View File

@ -5,27 +5,77 @@ declare(strict_types=1);
namespace OCA\Memories\Db;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\Folder;
use OCP\IDBConnection;
const CTE_FOLDERS = // CTE to get all folders recursively in the given top folder
'WITH RECURSIVE *PREFIX*cte_folders(fileid) AS (
const CTE_FOLDERS = // CTE to get all folders recursively in the given top folders excluding archive
'WITH RECURSIVE *PREFIX*cte_folders_all(fileid, rootid) AS (
SELECT
f.fileid
f.fileid,
f.fileid AS rootid
FROM
*PREFIX*filecache f
WHERE
f.fileid = :topFolderId
f.fileid IN (:topFolderIds)
UNION ALL
SELECT
f.fileid
f.fileid,
c.rootid
FROM
*PREFIX*filecache f
INNER JOIN *PREFIX*cte_folders_all c
ON (f.parent = c.fileid
AND f.mimetype = (SELECT `id` FROM `*PREFIX*mimetypes` WHERE `mimetype` = \'httpd/unix-directory\')
AND f.name <> \'.archive\'
)
), *PREFIX*cte_folders AS (
SELECT
fileid,
MIN(rootid) AS rootid
FROM
*PREFIX*cte_folders_all
GROUP BY
fileid
)';
const CTE_FOLDERS_ARCHIVE = // CTE to get all archive folders recursively in the given top folders
'WITH RECURSIVE *PREFIX*cte_folders_all(fileid, name, rootid) AS (
SELECT
f.fileid,
f.name,
f.fileid AS rootid
FROM
*PREFIX*filecache f
WHERE
f.fileid IN (:topFolderIds)
UNION ALL
SELECT
f.fileid,
f.name,
c.rootid
FROM
*PREFIX*filecache f
INNER JOIN *PREFIX*cte_folders_all c
ON (f.parent = c.fileid
AND f.mimetype = (SELECT `id` FROM `*PREFIX*mimetypes` WHERE `mimetype` = \'httpd/unix-directory\')
)
), *PREFIX*cte_folders(fileid, rootid) AS (
SELECT
cfa.fileid,
MIN(cfa.rootid) AS rootid
FROM
*PREFIX*cte_folders_all cfa
WHERE
cfa.name = \'.archive\'
GROUP BY
cfa.fileid
UNION ALL
SELECT
f.fileid,
c.rootid
FROM
*PREFIX*filecache f
INNER JOIN *PREFIX*cte_folders c
ON (f.parent = c.fileid
AND f.mimetype = (SELECT `id` FROM `*PREFIX*mimetypes` WHERE `mimetype` = \'httpd/unix-directory\')
AND f.fileid <> :excludedFolderId
)
ON (f.parent = c.fileid)
)';
trait TimelineQueryDays
@ -35,7 +85,7 @@ trait TimelineQueryDays
/**
* Get the days response from the database for the timeline.
*
* @param null|Folder $folder The folder to get the days from
* @param TimelineRoot $root The root 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
@ -43,7 +93,7 @@ trait TimelineQueryDays
* @return array The days response
*/
public function getDays(
&$folder,
TimelineRoot &$root,
string $uid,
bool $recursive,
bool $archive,
@ -56,7 +106,7 @@ trait TimelineQueryDays
$query->select('m.dayid', $count)
->from('memories', 'm')
;
$query = $this->joinFilecache($query, $folder, $recursive, $archive);
$query = $this->joinFilecache($query, $root, $recursive, $archive);
// Group and sort by dayid
$query->groupBy('m.dayid')
@ -76,7 +126,7 @@ trait TimelineQueryDays
/**
* Get the day response from the database for the timeline.
*
* @param null|Folder $folder The folder to get the day from
* @param TimelineRoot $root The root to get the day from
* @param string $uid The user id
* @param int[] $day_ids The day ids to fetch
* @param bool $recursive If the query should be recursive
@ -87,7 +137,7 @@ trait TimelineQueryDays
* @return array An array of day responses
*/
public function getDay(
&$folder,
TimelineRoot &$root,
string $uid,
$day_ids,
bool $recursive,
@ -107,9 +157,14 @@ trait TimelineQueryDays
;
// JOIN with filecache for existing files
$query = $this->joinFilecache($query, $folder, $recursive, $archive);
$query = $this->joinFilecache($query, $root, $recursive, $archive);
$query->addSelect('f.etag', 'f.path', 'f.name AS basename');
// SELECT rootid if not a single folder
if ($recursive && !$root->isEmpty()) {
$query->addSelect('cte_f.rootid');
}
// JOIN with mimetypes to get the mimetype
$query->join('f', 'mimetypes', 'mimetypes', $query->expr()->eq('f.mimetype', 'mimetypes.id'));
$query->addSelect('mimetypes.mimetype');
@ -136,7 +191,7 @@ trait TimelineQueryDays
$rows = $cursor->fetchAll();
$cursor->closeCursor();
return $this->processDay($rows, $uid, $folder);
return $this->processDay($rows, $uid, $root);
}
/**
@ -156,37 +211,55 @@ trait TimelineQueryDays
/**
* Process the single day response.
*
* @param array $day
* @param string $uid User or blank if not logged in
* @param null|Folder $folder
*/
private function processDay(&$day, $uid, $folder)
private function processDay(array &$day, string $uid, TimelineRoot &$root)
{
$basePath = '#__#__#';
$davPath = '';
if (null !== $folder) {
/**
* Path entry in database for folder.
* We need to splice this from the start of the file path.
*/
$internalPaths = [];
/**
* DAV paths for the folders.
* We need to prefix this to the start of the file path.
*/
$davPaths = [];
/**
* The root folder id for the folder.
* We fallback to this if rootid is not found.
*/
$defaultRootId = 0;
if (!$root->isEmpty()) {
// Get root id of the top folder
$defaultRootId = $root->getOneId();
// No way to get the internal path from the folder
$query = $this->connection->getQueryBuilder();
$query->select('path')
$query->select('fileid', 'path')
->from('filecache')
->where($query->expr()->eq('fileid', $query->createNamedParameter($folder->getId(), IQueryBuilder::PARAM_INT)))
->where($query->expr()->in('fileid', $query->createNamedParameter($root->getIds(), IQueryBuilder::PARAM_INT_ARRAY)))
;
$path = $query->executeQuery()->fetchOne();
$basePath = $path ?: $basePath;
$paths = $query->executeQuery()->fetchAll();
foreach ($paths as &$path) {
$fileid = (int) $path['fileid'];
$internalPaths[$fileid] = $path['path'];
// Get user facing path
// Get DAV path.
// getPath looks like /user/files/... but we want /files/user/...
// Split at / and swap these
// For public shares, we just give the relative path
if (!empty($uid)) {
$actualPath = $folder->getPath();
if (!empty($uid) && ($actualPath = $root->getFolderPath($fileid))) {
$actualPath = explode('/', $actualPath);
if (\count($actualPath) >= 3) {
$tmp = $actualPath[1];
$actualPath[1] = $actualPath[2];
$actualPath[2] = $tmp;
$davPath = implode('/', $actualPath);
$davPaths[$fileid] = \OCA\Memories\Exif::removeExtraSlash('/'.$davPath.'/');
}
}
}
}
@ -212,9 +285,14 @@ trait TimelineQueryDays
// Check if path exists and starts with basePath and remove
if (isset($row['path']) && !empty($row['path'])) {
$rootId = \array_key_exists('rootid', $row) ? $row['rootid'] : $defaultRootId;
$basePath = $internalPaths[$rootId] ?? '#__#';
$davPath = $davPaths[$rootId] ?: '';
if (0 === strpos($row['path'], $basePath)) {
$row['filename'] = $davPath.substr($row['path'], \strlen($basePath));
}
unset($row['path']);
}
@ -231,9 +309,14 @@ trait TimelineQueryDays
$params = $query->getParameters();
$types = $query->getParameterTypes();
// Get SQL
$CTE_SQL = \array_key_exists('cteFoldersArchive', $params) && $params['cteFoldersArchive']
? CTE_FOLDERS_ARCHIVE
: CTE_FOLDERS;
// Add WITH clause if needed
if (false !== strpos($sql, 'cte_folders')) {
$sql = CTE_FOLDERS.' '.$sql;
$sql = $CTE_SQL.' '.$sql;
}
return $this->connection->executeQuery($sql, $params, $types);
@ -244,53 +327,31 @@ trait TimelineQueryDays
*/
private function addSubfolderJoinParams(
IQueryBuilder &$query,
Folder &$folder,
TimelineRoot &$root,
bool $archive
) {
// Query parameters, set at the end
$topFolderId = $folder->getId();
$excludedFolderId = -1;
/** @var Folder Archive folder if it exists */
$archiveFolder = null;
try {
$archiveFolder = $folder->get('.archive/');
} catch (\OCP\Files\NotFoundException $e) {
}
if (!$archive) {
// Exclude archive folder
if ($archiveFolder) {
$excludedFolderId = $archiveFolder->getId();
}
} else {
// Only include archive folder
$topFolderId = $archiveFolder ? $archiveFolder->getId() : -1;
}
// Add query parameters
$query->setParameter('topFolderId', $topFolderId, IQueryBuilder::PARAM_INT);
$query->setParameter('excludedFolderId', $excludedFolderId, IQueryBuilder::PARAM_INT);
$query->setParameter('topFolderIds', $root->getIds(), IQueryBuilder::PARAM_INT_ARRAY);
$query->setParameter('cteFoldersArchive', $archive, IQueryBuilder::PARAM_BOOL);
}
/**
* Inner join with oc_filecache.
*
* @param IQueryBuilder $query Query builder
* @param null|Folder $folder Either the top folder or null for all
* @param TimelineRoot $root Either the top folder or null for all
* @param bool $recursive Whether to get the days recursively
* @param bool $archive Whether to get the days only from the archive folder
*/
private function joinFilecache(
IQueryBuilder &$query,
&$folder,
TimelineRoot &$root,
bool $recursive,
bool $archive
) {
// Join with memories
$baseOp = $query->expr()->eq('f.fileid', 'm.fileid');
if (null === $folder) {
if ($root->isEmpty()) {
return $query->innerJoin('m', 'filecache', 'f', $baseOp);
}
@ -298,11 +359,11 @@ trait TimelineQueryDays
$pathOp = null;
if ($recursive) {
// Join with folders CTE
$this->addSubfolderJoinParams($query, $folder, $archive);
$this->addSubfolderJoinParams($query, $root, $archive);
$query->innerJoin('f', 'cte_folders', 'cte_f', $query->expr()->eq('f.parent', 'cte_f.fileid'));
} else {
// If getting non-recursively folder only check for parent
$pathOp = $query->expr()->eq('f.parent', $query->createNamedParameter($folder->getId(), IQueryBuilder::PARAM_INT));
$pathOp = $query->expr()->eq('f.parent', $query->createNamedParameter($root->getOneId(), IQueryBuilder::PARAM_INT));
}
return $query->innerJoin('m', 'filecache', 'f', $query->expr()->andX(

View File

@ -47,7 +47,7 @@ trait TimelineQueryFaces
);
}
public function getFaces(Folder $folder)
public function getFaces(TimelineRoot &$root)
{
$query = $this->connection->getQueryBuilder();
@ -62,7 +62,7 @@ trait TimelineQueryFaces
$query->innerJoin('rfd', 'memories', 'm', $query->expr()->eq('m.fileid', 'rfd.file_id'));
// WHERE these photos are in the user's requested folder recursively
$query = $this->joinFilecache($query, $folder, true, false);
$query = $this->joinFilecache($query, $root, true, false);
// GROUP by ID of face cluster
$query->groupBy('rfc.id');
@ -87,7 +87,7 @@ trait TimelineQueryFaces
return $faces;
}
public function getFacePreviewDetection(Folder &$folder, int $id)
public function getFacePreviewDetection(TimelineRoot &$root, int $id)
{
$query = $this->connection->getQueryBuilder();
@ -109,7 +109,7 @@ trait TimelineQueryFaces
$query->innerJoin('rfd', 'memories', 'm', $query->expr()->eq('m.fileid', 'rfd.file_id'));
// WHERE these photos are in the user's requested folder recursively
$query = $this->joinFilecache($query, $folder, true, false);
$query = $this->joinFilecache($query, $root, true, false);
// LIMIT results
$query->setMaxResults(15);

View File

@ -19,7 +19,9 @@ trait TimelineQueryFolders
$query->select('f.fileid', 'f.etag')->from('memories', 'm');
// WHERE these photos are in the user's requested folder recursively
$query = $this->joinFilecache($query, $folder, true, false);
$root = new TimelineRoot();
$root->addFolder($folder);
$query = $this->joinFilecache($query, $root, true, false);
// ORDER descending by fileid
$query->orderBy('f.fileid', 'DESC');

View File

@ -38,7 +38,7 @@ trait TimelineQueryTags
));
}
public function getTags(Folder $folder)
public function getTags(TimelineRoot &$root)
{
$query = $this->connection->getQueryBuilder();
@ -58,7 +58,7 @@ trait TimelineQueryTags
$query->innerJoin('stom', 'memories', 'm', $query->expr()->eq('m.objectid', 'stom.objectid'));
// WHERE these photos are in the user's requested folder recursively
$query = $this->joinFilecache($query, $folder, true, false);
$query = $this->joinFilecache($query, $root, true, false);
// GROUP and ORDER by tag name
$query->groupBy('st.id');
@ -78,7 +78,7 @@ trait TimelineQueryTags
return $tags;
}
public function getTagPreviews(string $tagName, Folder &$folder)
public function getTagPreviews(string $tagName, TimelineRoot &$root)
{
$query = $this->connection->getQueryBuilder();
$tagId = $this->getSystemTagId($query, $tagName);
@ -99,7 +99,7 @@ trait TimelineQueryTags
$query->innerJoin('stom', 'memories', 'm', $query->expr()->eq('m.objectid', 'stom.objectid'));
// WHERE these photos are in the user's requested folder recursively
$query = $this->joinFilecache($query, $folder, true, false);
$query = $this->joinFilecache($query, $root, true, false);
// MAX 4
$query->setMaxResults(4);

View File

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace OCA\Memories\Db;
use OCP\Files\Folder;
use OCP\Files\Node;
class TimelineRoot
{
protected array $folders;
protected array $folderPaths;
/** Initialize */
public function __construct()
{
}
/**
* Add a folder to the root.
*
* @param Node $folder Node to add
*
* @throws \Exception if node is not valid readable folder
*/
public function addFolder(Node &$folder)
{
$folderPath = $folder->getPath();
if (!$folder instanceof Folder) {
throw new \Exception("Not a folder: {$folderPath}");
}
if (!($folder->getPermissions() & \OCP\Constants::PERMISSION_READ)) {
throw new \Exception("Folder not readable: {$folderPath}");
}
// Add top level folder
$id = $folder->getId();
$this->folders[$id] = $folder;
$this->folderPaths[$id] = $folderPath;
}
// Add mountpoints recursively
public function addMountPoints()
{
$mp = [];
foreach ($this->folderPaths as $id => $folderPath) {
$mounts = \OC\Files\Filesystem::getMountManager()->findIn($folderPath);
foreach ($mounts as &$mount) {
$id = $mount->getStorageRootId();
$path = $mount->getMountPoint();
$mp[$id] = $path;
}
}
$this->folderPaths += $mp;
}
public function getFolderPath(int $id)
{
return $this->folderPaths[$id];
}
public function getIds()
{
return array_keys($this->folderPaths);
}
public function getOneId()
{
return array_key_first($this->folders);
}
public function getFolder(int $id)
{
return $this->folders[$id];
}
public function isEmpty()
{
return empty($this->folderPaths);
}
}

View File

@ -114,9 +114,13 @@ class TimelineWrite
$exifJson = json_encode($exif);
// Store error if data > 64kb
if (\is_string($exifJson)) {
if (\strlen($exifJson) > 65535) {
$exifJson = json_encode(['error' => 'Exif data too large']);
}
} else {
$exifJson = json_encode(['error' => 'Exif data encoding error']);
}
if ($prevRow) {
// Update existing row

View File

@ -368,7 +368,7 @@ class Exif
private static function getExifFromLocalPathWithStaticProc(string &$path)
{
fwrite(self::$staticPipes[0], "{$path}\n-json\n-b\n-api\nQuickTimeUTC=1\n-n\n-execute\n");
fwrite(self::$staticPipes[0], "{$path}\n-U\n-json\n--b\n-api\nQuickTimeUTC=1\n-n\n-execute\n");
fflush(self::$staticPipes[0]);
$readyToken = "\n{ready}\n";
@ -390,7 +390,7 @@ class Exif
private static function getExifFromLocalPathWithSeparateProc(string &$path)
{
$pipes = [];
$proc = proc_open(array_merge(self::getExiftool(), ['-api', 'QuickTimeUTC=1', '-n', '-json', '-b', $path]), [
$proc = proc_open(array_merge(self::getExiftool(), ['-api', 'QuickTimeUTC=1', '-n', '-U', '-json', '--b', $path]), [
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes);

View File

@ -4,9 +4,6 @@ declare(strict_types=1);
namespace OCA\Memories;
use OCA\Memories\AppInfo\Application;
use OCP\IConfig;
class Util
{
public static $TAG_DAYID_START = -(1 << 30); // the world surely didn't exist
@ -47,19 +44,6 @@ class Util
return null;
}
/**
* Get the path to the user's configured photos directory.
*/
public static function getPhotosPath(IConfig &$config, string $userId)
{
$p = $config->getUserValue($userId, Application::APPNAME, 'timelinePath', '');
if (empty($p)) {
return '/Photos/';
}
return $p;
}
/**
* Check if albums are enabled for this user.
*
@ -121,4 +105,20 @@ class Util
return true;
}
/**
* Check if any encryption is enabled that we can not cope with
* such as end-to-end encryption.
*
* @param mixed $encryptionManager
*/
public static function isEncryptionEnabled(&$encryptionManager): bool
{
if ($encryptionManager->isEnabled()) {
// Server-side encryption (OC_DEFAULT_MODULE) is okay, others like e2e are not
return 'OC_DEFAULT_MODULE' !== $encryptionManager->getDefaultEncryptionModuleId();
}
return false;
}
}

138
package-lock.json generated
View File

@ -18,8 +18,8 @@
"justified-layout": "^4.1.0",
"moment": "^2.29.4",
"path-posix": "^1.0.0",
"photoswipe": "^5.3.3",
"plyr": "^3.7.2",
"photoswipe": "^5.3.4",
"plyr": "^3.7.3",
"reflect-metadata": "^0.1.13",
"video.js": "^7.20.3",
"videojs-contrib-quality-levels": "^2.2.0",
@ -29,17 +29,17 @@
"vue-property-decorator": "^9.1.2",
"vue-router": "^3.5.4",
"vue-virtual-scroller": "1.1.2",
"webdav": "^4.11.0"
"webdav": "^4.11.2"
},
"devDependencies": {
"@nextcloud/babel-config": "^1.0.0",
"@nextcloud/browserslist-config": "^2.3.0",
"@nextcloud/webpack-vue-config": "^5.3.0",
"@playwright/test": "^1.27.1",
"@nextcloud/webpack-vue-config": "^5.4.0",
"@playwright/test": "^1.28.0",
"@types/url-parse": "^1.4.8",
"playwright": "^1.27.1",
"playwright": "^1.28.0",
"ts-loader": "^9.4.1",
"typescript": "^4.8.4",
"typescript": "^4.9.3",
"workbox-webpack-plugin": "^6.5.4"
},
"engines": {
@ -2023,13 +2023,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.27.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.27.1.tgz",
"integrity": "sha512-mrL2q0an/7tVqniQQF6RBL2saskjljXzqNcCOVMUjRIgE6Y38nCNaP+Dc2FBW06bcpD3tqIws/HT9qiMHbNU0A==",
"version": "1.28.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.28.0.tgz",
"integrity": "sha512-vrHs5DFTPwYox5SGKq/7TDn/S4q6RA1zArd7uhO6EyP9hj3XgZBBM12ktMbnDQNxh/fL1IUKsTNLxihmsU38lQ==",
"dev": true,
"dependencies": {
"@types/node": "*",
"playwright-core": "1.27.1"
"playwright-core": "1.28.0"
},
"bin": {
"playwright": "cli.js"
@ -3755,9 +3755,9 @@
"peer": true
},
"node_modules/core-js": {
"version": "3.26.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.26.0.tgz",
"integrity": "sha512-+DkDrhoR4Y0PxDz6rurahuB+I45OsEUv8E1maPTB6OuHRohMMcznBq9TMpdpDMm/hUPob/mJJS3PqgbHpMTQgw==",
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.26.1.tgz",
"integrity": "sha512-21491RRQVzUn0GGM9Z1Jrpr6PNPxPi+Za8OM9q4tksTSnlbXXGKK1nXNg/QvwFYettXvSX6zWKCtHHfjN4puyA==",
"hasInstallScript": true,
"funding": {
"type": "opencollective",
@ -5239,9 +5239,9 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/hot-patcher": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/hot-patcher/-/hot-patcher-0.5.0.tgz",
"integrity": "sha512-2Uu2W0s8+dnqXzdlg0MRsRzPoDCs1wVjOGSyMRRaMzLDX4bgHw6xDYKccsWafXPPxQpkQfEjgW6+17pwcg60bw=="
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hot-patcher/-/hot-patcher-1.0.0.tgz",
"integrity": "sha512-3H8VH0PreeNsKMZw16nTHbUp4YoHCnPlawpsPXGJUR4qENDynl79b6Xk9CIFvLcH1qungBsCuzKcWyzoPPalTw=="
},
"node_modules/hpack.js": {
"version": "2.1.6",
@ -7037,9 +7037,9 @@
}
},
"node_modules/photoswipe": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/photoswipe/-/photoswipe-5.3.3.tgz",
"integrity": "sha512-BUuulwZwkYFKADSe5xf0dd+wf6dws34ZvqP8R3oYHepRauOXoQHvw600sw1HlWd8K0S3LRCS4jxyR5fTuI383Q==",
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/photoswipe/-/photoswipe-5.3.4.tgz",
"integrity": "sha512-SN+RWHqxJvdwzXJsh8KrG+ajjPpdTX5HpKglEd0k9o6o5fW+QHPkW8//Bo11MB+NQwTa/hFw8BDv2EdxiDXjNw==",
"engines": {
"node": ">= 0.12.0"
}
@ -7085,13 +7085,13 @@
}
},
"node_modules/playwright": {
"version": "1.27.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.27.1.tgz",
"integrity": "sha512-xXYZ7m36yTtC+oFgqH0eTgullGztKSRMb4yuwLPl8IYSmgBM88QiB+3IWb1mRIC9/NNwcgbG0RwtFlg+EAFQHQ==",
"version": "1.28.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.28.0.tgz",
"integrity": "sha512-kyOXGc5y1mgi+hgEcCIyE1P1+JumLrxS09nFHo5sdJNzrucxPRAGwM4A2X3u3SDOfdgJqx61yIoR6Av+5plJPg==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"playwright-core": "1.27.1"
"playwright-core": "1.28.0"
},
"bin": {
"playwright": "cli.js"
@ -7101,9 +7101,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.27.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.27.1.tgz",
"integrity": "sha512-9EmeXDncC2Pmp/z+teoVYlvmPWUC6ejSSYZUln7YaP89Z6lpAaiaAnqroUt/BoLo8tn7WYShcfaCh+xofZa44Q==",
"version": "1.28.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.28.0.tgz",
"integrity": "sha512-nJLknd28kPBiCNTbqpu6Wmkrh63OEqJSFw9xOfL9qxfNwody7h6/L3O2dZoWQ6Oxcm0VOHjWmGiCUGkc0X3VZA==",
"dev": true,
"bin": {
"playwright": "cli.js"
@ -7113,11 +7113,11 @@
}
},
"node_modules/plyr": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/plyr/-/plyr-3.7.2.tgz",
"integrity": "sha512-I0ZC/OI4oJ0iWG9s2rrnO0YFO6aLyrPiQBq9kum0FqITYljwTPBbYL3TZZu8UJQJUq7tUWN18Q7ACwNCkGKABQ==",
"version": "3.7.3",
"resolved": "https://registry.npmjs.org/plyr/-/plyr-3.7.3.tgz",
"integrity": "sha512-ORULENBvEvvzMYXRQBALDmEi8P+wZt1Hr/NvHqchu/t7E2xJKNkRYWx0qCA1HETIGZ6zobrOVgqeAUqWimS7fQ==",
"dependencies": {
"core-js": "^3.22.0",
"core-js": "^3.26.1",
"custom-event-polyfill": "^1.0.7",
"loadjs": "^4.2.0",
"rangetouch": "^2.0.1",
@ -9074,9 +9074,9 @@
}
},
"node_modules/typescript": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz",
"integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==",
"version": "4.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.3.tgz",
"integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
@ -9659,16 +9659,16 @@
}
},
"node_modules/webdav": {
"version": "4.11.0",
"resolved": "https://registry.npmjs.org/webdav/-/webdav-4.11.0.tgz",
"integrity": "sha512-vQ2EFL8cef9F/Nvua1NPcw3z9CWAnnc22mn+sym72W2WFW4Q7doTIhItRzxpgU+tUCc3V10VB0I+eBdgU5wKTQ==",
"version": "4.11.2",
"resolved": "https://registry.npmjs.org/webdav/-/webdav-4.11.2.tgz",
"integrity": "sha512-Ht9TPD5EB7gYW0YmhRcE5NW0/dn/HQfyLSPQY1Rw1coQ5MQTUooAQ9Bpqt4EU7QLw0b95tX4cU59R+SIojs9KQ==",
"dependencies": {
"axios": "^0.27.2",
"base-64": "^1.0.0",
"byte-length": "^1.0.2",
"fast-xml-parser": "^3.19.0",
"he": "^1.2.0",
"hot-patcher": "^0.5.0",
"hot-patcher": "^1.0.0",
"layerr": "^0.1.2",
"md5": "^2.3.0",
"minimatch": "^5.1.0",
@ -11855,13 +11855,13 @@
"requires": {}
},
"@playwright/test": {
"version": "1.27.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.27.1.tgz",
"integrity": "sha512-mrL2q0an/7tVqniQQF6RBL2saskjljXzqNcCOVMUjRIgE6Y38nCNaP+Dc2FBW06bcpD3tqIws/HT9qiMHbNU0A==",
"version": "1.28.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.28.0.tgz",
"integrity": "sha512-vrHs5DFTPwYox5SGKq/7TDn/S4q6RA1zArd7uhO6EyP9hj3XgZBBM12ktMbnDQNxh/fL1IUKsTNLxihmsU38lQ==",
"dev": true,
"requires": {
"@types/node": "*",
"playwright-core": "1.27.1"
"playwright-core": "1.28.0"
}
},
"@popperjs/core": {
@ -13288,9 +13288,9 @@
"peer": true
},
"core-js": {
"version": "3.26.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.26.0.tgz",
"integrity": "sha512-+DkDrhoR4Y0PxDz6rurahuB+I45OsEUv8E1maPTB6OuHRohMMcznBq9TMpdpDMm/hUPob/mJJS3PqgbHpMTQgw=="
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.26.1.tgz",
"integrity": "sha512-21491RRQVzUn0GGM9Z1Jrpr6PNPxPi+Za8OM9q4tksTSnlbXXGKK1nXNg/QvwFYettXvSX6zWKCtHHfjN4puyA=="
},
"core-js-compat": {
"version": "3.26.0",
@ -14471,9 +14471,9 @@
}
},
"hot-patcher": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/hot-patcher/-/hot-patcher-0.5.0.tgz",
"integrity": "sha512-2Uu2W0s8+dnqXzdlg0MRsRzPoDCs1wVjOGSyMRRaMzLDX4bgHw6xDYKccsWafXPPxQpkQfEjgW6+17pwcg60bw=="
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hot-patcher/-/hot-patcher-1.0.0.tgz",
"integrity": "sha512-3H8VH0PreeNsKMZw16nTHbUp4YoHCnPlawpsPXGJUR4qENDynl79b6Xk9CIFvLcH1qungBsCuzKcWyzoPPalTw=="
},
"hpack.js": {
"version": "2.1.6",
@ -15838,9 +15838,9 @@
}
},
"photoswipe": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/photoswipe/-/photoswipe-5.3.3.tgz",
"integrity": "sha512-BUuulwZwkYFKADSe5xf0dd+wf6dws34ZvqP8R3oYHepRauOXoQHvw600sw1HlWd8K0S3LRCS4jxyR5fTuI383Q=="
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/photoswipe/-/photoswipe-5.3.4.tgz",
"integrity": "sha512-SN+RWHqxJvdwzXJsh8KrG+ajjPpdTX5HpKglEd0k9o6o5fW+QHPkW8//Bo11MB+NQwTa/hFw8BDv2EdxiDXjNw=="
},
"picocolors": {
"version": "1.0.0",
@ -15871,26 +15871,26 @@
}
},
"playwright": {
"version": "1.27.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.27.1.tgz",
"integrity": "sha512-xXYZ7m36yTtC+oFgqH0eTgullGztKSRMb4yuwLPl8IYSmgBM88QiB+3IWb1mRIC9/NNwcgbG0RwtFlg+EAFQHQ==",
"version": "1.28.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.28.0.tgz",
"integrity": "sha512-kyOXGc5y1mgi+hgEcCIyE1P1+JumLrxS09nFHo5sdJNzrucxPRAGwM4A2X3u3SDOfdgJqx61yIoR6Av+5plJPg==",
"dev": true,
"requires": {
"playwright-core": "1.27.1"
"playwright-core": "1.28.0"
}
},
"playwright-core": {
"version": "1.27.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.27.1.tgz",
"integrity": "sha512-9EmeXDncC2Pmp/z+teoVYlvmPWUC6ejSSYZUln7YaP89Z6lpAaiaAnqroUt/BoLo8tn7WYShcfaCh+xofZa44Q==",
"version": "1.28.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.28.0.tgz",
"integrity": "sha512-nJLknd28kPBiCNTbqpu6Wmkrh63OEqJSFw9xOfL9qxfNwody7h6/L3O2dZoWQ6Oxcm0VOHjWmGiCUGkc0X3VZA==",
"dev": true
},
"plyr": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/plyr/-/plyr-3.7.2.tgz",
"integrity": "sha512-I0ZC/OI4oJ0iWG9s2rrnO0YFO6aLyrPiQBq9kum0FqITYljwTPBbYL3TZZu8UJQJUq7tUWN18Q7ACwNCkGKABQ==",
"version": "3.7.3",
"resolved": "https://registry.npmjs.org/plyr/-/plyr-3.7.3.tgz",
"integrity": "sha512-ORULENBvEvvzMYXRQBALDmEi8P+wZt1Hr/NvHqchu/t7E2xJKNkRYWx0qCA1HETIGZ6zobrOVgqeAUqWimS7fQ==",
"requires": {
"core-js": "^3.22.0",
"core-js": "^3.26.1",
"custom-event-polyfill": "^1.0.7",
"loadjs": "^4.2.0",
"rangetouch": "^2.0.1",
@ -17381,9 +17381,9 @@
}
},
"typescript": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz",
"integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==",
"version": "4.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.3.tgz",
"integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==",
"dev": true
},
"unbox-primitive": {
@ -17837,16 +17837,16 @@
}
},
"webdav": {
"version": "4.11.0",
"resolved": "https://registry.npmjs.org/webdav/-/webdav-4.11.0.tgz",
"integrity": "sha512-vQ2EFL8cef9F/Nvua1NPcw3z9CWAnnc22mn+sym72W2WFW4Q7doTIhItRzxpgU+tUCc3V10VB0I+eBdgU5wKTQ==",
"version": "4.11.2",
"resolved": "https://registry.npmjs.org/webdav/-/webdav-4.11.2.tgz",
"integrity": "sha512-Ht9TPD5EB7gYW0YmhRcE5NW0/dn/HQfyLSPQY1Rw1coQ5MQTUooAQ9Bpqt4EU7QLw0b95tX4cU59R+SIojs9KQ==",
"requires": {
"axios": "^0.27.2",
"base-64": "^1.0.0",
"byte-length": "^1.0.2",
"fast-xml-parser": "^3.19.0",
"he": "^1.2.0",
"hot-patcher": "^0.5.0",
"hot-patcher": "^1.0.0",
"layerr": "^0.1.2",
"md5": "^2.3.0",
"minimatch": "^5.1.0",

View File

@ -38,8 +38,8 @@
"justified-layout": "^4.1.0",
"moment": "^2.29.4",
"path-posix": "^1.0.0",
"photoswipe": "^5.3.3",
"plyr": "^3.7.2",
"photoswipe": "^5.3.4",
"plyr": "^3.7.3",
"reflect-metadata": "^0.1.13",
"video.js": "^7.20.3",
"videojs-contrib-quality-levels": "^2.2.0",
@ -49,7 +49,7 @@
"vue-property-decorator": "^9.1.2",
"vue-router": "^3.5.4",
"vue-virtual-scroller": "1.1.2",
"webdav": "^4.11.0"
"webdav": "^4.11.2"
},
"browserslist": [
"extends @nextcloud/browserslist-config"
@ -61,12 +61,12 @@
"devDependencies": {
"@nextcloud/babel-config": "^1.0.0",
"@nextcloud/browserslist-config": "^2.3.0",
"@nextcloud/webpack-vue-config": "^5.3.0",
"@playwright/test": "^1.27.1",
"@nextcloud/webpack-vue-config": "^5.4.0",
"@playwright/test": "^1.28.0",
"@types/url-parse": "^1.4.8",
"playwright": "^1.27.1",
"playwright": "^1.28.0",
"ts-loader": "^9.4.1",
"typescript": "^4.8.4",
"typescript": "^4.9.3",
"workbox-webpack-plugin": "^6.5.4"
}
}

View File

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

View File

@ -325,6 +325,7 @@ body {
#app-navigation-vue {
border-top-left-radius: var(--body-container-radius);
border-bottom-left-radius: var(--body-container-radius);
max-width: 250px;
}
}

View File

@ -220,6 +220,11 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
delete exif.ExifImageHeight;
delete exif.ExifImageWidth;
delete exif.ExifImageSize;
delete exif.CompatibleBrands;
delete exif.FileType;
delete exif.FileTypeExtension;
delete exif.MIMEType;
delete exif.MajorBrand;
// Update exif data
await axios.patch(

View File

@ -164,6 +164,7 @@ class VideoContentSetup {
sources.push({
src: content.data.src,
type: "video/mp4",
});
const overrideNative = !videojs.browser.IS_SAFARI;
@ -287,7 +288,7 @@ class VideoContentSetup {
},
fullscreen: {
enabled: true,
container: ".pswp__item",
container: ".pswp__container",
},
};

View File

@ -73,7 +73,7 @@ import AddToAlbumModal from "./modal/AddToAlbumModal.vue";
import StarIcon from "vue-material-design-icons/Star.vue";
import DownloadIcon from "vue-material-design-icons/Download.vue";
import DeleteIcon from "vue-material-design-icons/Delete.vue";
import DeleteIcon from "vue-material-design-icons/TrashCanOutline.vue";
import EditIcon from "vue-material-design-icons/ClockEdit.vue";
import ArchiveIcon from "vue-material-design-icons/PackageDown.vue";
import UnarchiveIcon from "vue-material-design-icons/PackageUp.vue";

View File

@ -53,6 +53,12 @@
>
{{ t("memories", "Square grid mode") }}
</NcCheckboxRadioSwitch>
<MultiPathSelectionModal
ref="multiPathModal"
:title="pathSelTitle"
@close="saveTimelinePath"
/>
</div>
</template>
@ -65,18 +71,24 @@ input[type="text"] {
<script lang="ts">
import { Component, Mixins } from "vue-property-decorator";
import GlobalMixin from "../mixins/GlobalMixin";
import { getFilePickerBuilder } from "@nextcloud/dialogs";
import UserConfig from "../mixins/UserConfig";
import { getFilePickerBuilder } from "@nextcloud/dialogs";
import { NcCheckboxRadioSwitch } from "@nextcloud/vue";
import MultiPathSelectionModal from "./modal/MultiPathSelectionModal.vue";
@Component({
components: {
NcCheckboxRadioSwitch,
MultiPathSelectionModal,
},
})
export default class Settings extends Mixins(UserConfig, GlobalMixin) {
get pathSelTitle() {
return this.t("memories", "Choose Timeline Paths");
}
async chooseFolder(title: string, initial: string) {
const picker = getFilePickerBuilder(title)
.setMultiSelect(false)
@ -91,10 +103,13 @@ export default class Settings extends Mixins(UserConfig, GlobalMixin) {
}
async chooseTimelinePath() {
const newPath = await this.chooseFolder(
this.t("memories", "Choose the root of your timeline"),
this.config_timelinePath
);
(<any>this.$refs.multiPathModal).open(this.config_timelinePath.split(";"));
}
async saveTimelinePath(paths: string[]) {
if (!paths || !paths.length) return;
const newPath = paths.join(";");
if (newPath !== this.config_timelinePath) {
this.config_timelinePath = newPath;
await this.updateSetting("timelinePath");
@ -102,10 +117,11 @@ export default class Settings extends Mixins(UserConfig, GlobalMixin) {
}
async chooseFoldersPath() {
const newPath = await this.chooseFolder(
let newPath = await this.chooseFolder(
this.t("memories", "Choose the root for the folders view"),
this.config_foldersPath
);
if (newPath === "") newPath = "/";
if (newPath !== this.config_foldersPath) {
this.config_foldersPath = newPath;
await this.updateSetting("foldersPath");

View File

@ -64,7 +64,7 @@
{{ item.super }}
</div>
<div class="main" @click="selectionManager.selectHead(item)">
<CheckCircle :size="18" class="select" v-if="item.name" />
<CheckCircle :size="20" class="select" v-if="item.name" />
<span class="name"> {{ item.name || getHeadName(item) }} </span>
</div>
</div>
@ -917,7 +917,9 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
head.day.detail.length === photos.length &&
head.day.detail.every(
(p, i) =>
p.fileid === photos[i].fileid && p.etag === photos[i].etag
p.fileid === photos[i].fileid &&
p.etag === photos[i].etag &&
p.filename === photos[i].filename
)
) {
continue;
@ -1316,6 +1318,7 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
display: block;
transition: transform 0.2s ease;
cursor: pointer;
font-size: 1.075em;
}
:hover,
@ -1325,7 +1328,7 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
opacity: 0.7;
}
.name {
transform: translateX(22px);
transform: translateX(24px);
}
}
&.selected .select {

View File

@ -122,7 +122,7 @@ import "photoswipe/style.css";
import PsVideo from "./PsVideo";
import ShareIcon from "vue-material-design-icons/ShareVariant.vue";
import DeleteIcon from "vue-material-design-icons/Delete.vue";
import DeleteIcon from "vue-material-design-icons/TrashCanOutline.vue";
import StarIcon from "vue-material-design-icons/Star.vue";
import StarOutlineIcon from "vue-material-design-icons/StarOutline.vue";
import DownloadIcon from "vue-material-design-icons/Download.vue";
@ -253,9 +253,13 @@ export default class Viewer extends Mixins(GlobalMixin) {
counter: true,
zoom: false,
loop: false,
wheelToZoom: true,
bgOpacity: 1,
appendToEl: this.$refs.inner as HTMLElement,
preload: [2, 2],
closeTitle: this.t("memories", "Close"),
arrowPrevTitle: this.t("memories", "Previous"),
arrowNextTitle: this.t("memories", "Next"),
getViewportSizeFn: () => {
const sidebarWidth = this.sidebarOpen ? this.sidebarWidth : 0;
this.outerWidth = `calc(100vw - ${sidebarWidth}px)`;

View File

@ -261,7 +261,7 @@ $icon-size: $icon-half-size * 2;
// Extremely ugly way to fill up the space
// If this isn't done, bg has a border
:deep path {
transform: scale(1.19) translate(-1.85px, -1.85px);
transform: scale(1.2) translate(-2px, -2px);
}
filter: invert(1) brightness(100);

View File

@ -335,12 +335,11 @@ export default class EditDate extends Mixins(GlobalMixin) {
const offset = date.getTime() - pDate.getTime();
const scale = diff > 0 ? diffNew / diff : 0;
const pDateNew = new Date(dateNew.getTime() - offset * scale);
const res = await axios.patch<any>(
generateUrl(EDIT_API_URL, { id: p.fileid }),
{
date: this.getExifFormat(pDateNew),
}
);
await axios.patch<any>(generateUrl(EDIT_API_URL, { id: p.fileid }), {
raw: {
DateTimeOriginal: this.getExifFormat(pDateNew),
},
});
emit("files:file:updated", { fileid: p.fileid });
} catch (e) {
if (e.response?.data?.message) {

View File

@ -0,0 +1,112 @@
<template>
<Modal @close="close" v-if="show" size="small">
<template #title>
{{ title }}
</template>
<ul>
<li v-for="(path, index) in paths" :key="index" class="path">
{{ path }}
<NcActions :inline="1">
<NcActionButton
:aria-label="t('memories', 'Remove')"
@click="remove(index)"
>
{{ t("memories", "Remove") }}
<template #icon> <CloseIcon :size="20" /> </template>
</NcActionButton>
</NcActions>
</li>
</ul>
<template #buttons>
<NcButton @click="add" class="button" type="secondary">
{{ t("memories", "Add Path") }}
</NcButton>
<NcButton @click="save" class="button" type="primary">
{{ t("memories", "Save") }}
</NcButton>
</template>
</Modal>
</template>
<script lang="ts">
import { Component, Emit, Mixins, Prop } from "vue-property-decorator";
import GlobalMixin from "../../mixins/GlobalMixin";
import UserConfig from "../../mixins/UserConfig";
import Modal from "./Modal.vue";
import { getFilePickerBuilder } from "@nextcloud/dialogs";
import { NcActions, NcActionButton, NcButton } from "@nextcloud/vue";
import CloseIcon from "vue-material-design-icons/Close.vue";
@Component({
components: {
Modal,
NcActions,
NcActionButton,
NcButton,
CloseIcon,
},
})
export default class Settings extends Mixins(UserConfig, GlobalMixin) {
@Prop({ required: true }) title: string;
private show = false;
private paths: string[] = [];
@Emit("close")
public close(list: string[]) {
this.show = false;
}
public open(paths: string[]) {
this.paths = paths;
this.show = true;
}
public save() {
this.close(this.paths);
}
async chooseFolder(title: string, initial: string) {
const picker = getFilePickerBuilder(title)
.setMultiSelect(false)
.setModal(true)
.setType(1)
.addMimeTypeFilter("httpd/unix-directory")
.allowDirectories()
.startAt(initial)
.build();
return await picker.pick();
}
public async add() {
let newPath = await this.chooseFolder(
this.t("memories", "Add a root to your timeline"),
"/"
);
if (newPath === "") newPath = "/";
this.paths.push(newPath);
}
public remove(index: number) {
this.paths.splice(index, 1);
}
}
</script>
<style lang="scss" scoped>
.path {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.1rem;
padding-left: 10px;
word-wrap: break-all;
}
</style>

View File

@ -334,7 +334,11 @@ export function cacheData(url: string, data: Object) {
if (!cache) return;
const response = new Response(str);
const encoded = new TextEncoder().encode(str);
response.headers.set("Content-Type", "application/json");
response.headers.set("Content-Length", encoded.length.toString());
response.headers.set("Cache-Control", "max-age=604800"); // 1 week
response.headers.set("Vary", "Accept-Encoding");
await cache.put(url, response);
})();
}

View File

@ -26,6 +26,18 @@ webpackConfig.watchOptions = {
};
if (!isDev) {
const imageCacheOpts = (expiryDays) => ({
handler: 'CacheFirst',
options: {
cacheName: 'images',
expiration: {
maxAgeSeconds: 3600 * 24 * expiryDays, // days
maxEntries: 20000, // 20k images
},
},
});
webpackConfig.plugins.push(
new WorkboxPlugin.GenerateSW({
swDest: 'memories-service-worker.js',
@ -37,17 +49,33 @@ if (!isDev) {
// Define runtime caching rules.
runtimeCaching: [{
// Match any preview file request
// Do not cache video related files
urlPattern: /^.*\/apps\/memories\/api\/video\/.*/,
handler: 'NetworkOnly',
}, {
// Do not cache raw editing files
urlPattern: /^.*\/apps\/memories\/api\/image\/jpeg\/.*/,
handler: 'NetworkOnly',
}, {
// Do not cache webdav
urlPattern: /^.*\/remote.php\/.*/,
handler: 'NetworkOnly',
}, {
// Do not cache downloads
urlPattern: /^.*\/apps\/files\/ajax\/download.php?.*/,
handler: 'NetworkOnly',
}, {
// Preview file request from core
urlPattern: /^.*\/core\/preview\?fileId=.*/,
handler: 'CacheFirst',
options: {
cacheName: 'images',
expiration: {
maxAgeSeconds: 3600 * 24 * 7, // one week
maxEntries: 20000, // 20k images
},
},
...imageCacheOpts(7),
}, {
// Albums from Photos
urlPattern: /^.*\/apps\/photos\/api\/v1\/preview\/.*/,
...imageCacheOpts(7),
}, {
// Face previews from Memories
urlPattern: /^.*\/apps\/memories\/api\/faces\/preview\/.*/,
...imageCacheOpts(1),
}, {
// Match page requests
urlPattern: /^.*\/.*$/,