Merge branch 'master' into stable24

old_stable24
Varun Patil 2022-12-08 13:22:40 -08:00
commit 0dedf3a369
94 changed files with 3328 additions and 1273 deletions

View File

@ -2,6 +2,19 @@
This file is manually updated. Please file an issue if something is missing.
## v4.9.0, v3.9.0 (2022-12-08)
- **Important**: v4.9.0 comes with an optimization that greatly reduces CPU usage for preview serving. However, for best experience, the preview generator app is now **required** to be configured properly. Please install it from the app store.
- **Feature**: Slideshow for photos and videos ([#217](https://github.com/pulsejet/memories/issues/217))
- **Feature**: Support for GPU transcoding ([#194](https://github.com/pulsejet/memories/issues/194))
- **Feature**: Allow downloading entire albums
- **Feature**: Allow editing more EXIF fields ([#169](https://github.com/pulsejet/memories/issues/169))
- **Feature**: Alpha integration with the face recognition app ([#146](https://github.com/pulsejet/memories/issues/146))
- Fix downloading from albums ([#259](https://github.com/pulsejet/memories/issues/259))
- Fix support for HEVC live photos ([#234](https://github.com/pulsejet/memories/issues/234))
- Fix native photo sharing ([#254](https://github.com/pulsejet/memories/issues/254), [#263](https://github.com/pulsejet/memories/issues/263))
- Use larger previews in viewer (please see [these docs](https://github.com/pulsejet/memories/wiki/Configuration#preview-storage-considerations)) ([#226](https://github.com/pulsejet/memories/issues/226))
## v4.8.0, v3.8.0 (2022-11-22)
- **Feature**: Support for Live Photos ([#124](https://github.com/pulsejet/memories/issues/124))

View File

@ -19,6 +19,8 @@ npm-init:
npm-update:
npm update
.PHONY: dev-setup exiftool php-cs-fixer php-lint npm-init npm-update
# Building
build-js:
npm run dev
@ -33,6 +35,8 @@ patch-external:
watch-js:
npm run watch
.PHONY: build-js patch-external watch-js
# Testing
test:
npm run test
@ -43,6 +47,8 @@ test-watch:
test-coverage:
npm run test:coverage
.PHONY: test test-watch test-coverage
# Linting
lint:
npm run lint
@ -50,6 +56,8 @@ lint:
lint-fix:
npm run lint:fix
.PHONY: lint lint-fix
# Cleaning
clean:
rm -f js/*
@ -57,3 +65,4 @@ clean:
clean-dev:
rm -rf node_modules
.PHONY: clean clean-dev

View File

@ -13,7 +13,7 @@ Memories is a _batteries-included_ photo management solution for Nextcloud with
- **📸 Timeline**: Sort photos and videos by date taken, parsed from Exif data.
- **⏪ Rewind**: Jump to any time in the past instantly and relive your memories.
- **🤖 AI Tagging**: Group photos by people and objects using AI, powered by [recognize](https://github.com/nextcloud/recognize).
- **🤖 AI Tagging**: Group photos by people and objects, powered by [recognize](https://github.com/nextcloud/recognize) and [facerecognition](https://github.com/matiasdelellis/facerecognition).
- **🖼️ Albums**: Create albums to group photos and videos together. Then share these albums with others.
- **🫱🏻‍🫲🏻 External Sharing**: Share photos and videos with people outside of your Nextcloud instance.
- **📱 Mobile Support**: Works on devices of any shape and size through the web app.

View File

@ -3,7 +3,7 @@
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<id>memories</id>
<name>Memories</name>
<summary>Yet another photo management app</summary>
<summary>Fast, modern and advanced photo management suite</summary>
<description><![CDATA[
# Memories
@ -11,7 +11,7 @@ Memories is a *batteries-included* photo management solution for Nextcloud with
- **📸 Timeline**: Sort photos and videos by date taken, parsed from Exif data.
- **⏪ Rewind**: Jump to any time in the past instantly and relive your memories.
- **🤖 AI Tagging**: Group photos by people and objects using AI, powered by [recognize](https://github.com/nextcloud/recognize).
- **🤖 AI Tagging**: Group photos by people and objects, powered by [recognize](https://github.com/nextcloud/recognize) and [facerecognition](https://github.com/matiasdelellis/facerecognition).
- **🖼️ Albums**: Create albums to group photos and videos together. Then share these albums with others.
- **🫱🏻‍🫲🏻 External Sharing**: Share photos and videos with people outside of your Nextcloud instance.
- **📱 Mobile Support**: Works on devices of any shape and size through the web app.

View File

@ -23,7 +23,8 @@ return [
// Routes with params
w(['name' => 'Page#folder', 'url' => '/folders/{path}', 'verb' => 'GET'], 'path'),
w(['name' => 'Page#albums', 'url' => '/albums/{id}', 'verb' => 'GET'], 'id'),
w(['name' => 'Page#people', 'url' => '/people/{name}', 'verb' => 'GET'], 'name'),
w(['name' => 'Page#recognize', 'url' => '/recognize/{name}', 'verb' => 'GET'], 'name'),
w(['name' => 'Page#facerecognition', 'url' => '/facerecognition/{name}', 'verb' => 'GET'], 'name'),
w(['name' => 'Page#tags', 'url' => '/tags/{name}', 'verb' => 'GET'], 'name'),
// Public folder share
@ -44,23 +45,31 @@ return [
['name' => 'Days#day', 'url' => '/api/days/{id}', 'verb' => 'GET'],
['name' => 'Days#dayPost', 'url' => '/api/days', 'verb' => 'POST'],
['name' => 'Tags#tags', 'url' => '/api/tags', 'verb' => 'GET'],
['name' => 'Tags#previews', 'url' => '/api/tag-previews', 'verb' => 'GET'],
['name' => 'Albums#albums', 'url' => '/api/albums', 'verb' => 'GET'],
['name' => 'Albums#download', 'url' => '/api/albums/download', 'verb' => 'POST'],
['name' => 'Faces#faces', 'url' => '/api/faces', 'verb' => 'GET'],
['name' => 'Faces#preview', 'url' => '/api/faces/preview/{id}', 'verb' => 'GET'],
['name' => 'Tags#tags', 'url' => '/api/tags', 'verb' => 'GET'],
['name' => 'Tags#preview', 'url' => '/api/tags/preview/{tag}', 'verb' => 'GET'],
['name' => 'People#recognizePeople', 'url' => '/api/recognize/people', 'verb' => 'GET'],
['name' => 'People#recognizePeoplePreview', 'url' => '/api/recognize/people/preview/{id}', 'verb' => 'GET'],
['name' => 'People#facerecognitionPeople', 'url' => '/api/facerecognition/people', 'verb' => 'GET'],
['name' => 'People#facerecognitionPeoplePreview', 'url' => '/api/facerecognition/people/preview/{id}', 'verb' => 'GET'],
['name' => 'Archive#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'],
['name' => 'Image#preview', 'url' => '/api/image/preview/{id}', 'verb' => 'GET'],
['name' => 'Image#multipreview', 'url' => '/api/image/multipreview', 'verb' => 'POST'],
['name' => 'Image#info', 'url' => '/api/image/info/{id}', 'verb' => 'GET'],
['name' => 'Image#setExif', 'url' => '/api/image/set-exif/{id}', 'verb' => 'PATCH'],
['name' => 'Image#jpeg', 'url' => '/api/image/jpeg/{id}', 'verb' => 'GET'],
['name' => 'Archive#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'],
['name' => 'Video#transcode', 'url' => '/api/video/transcode/{client}/{fileid}/{profile}', 'verb' => 'GET'],
['name' => 'Video#livephoto', 'url' => '/api/video/livephoto/{fileid}', 'verb' => 'GET'],
['name' => 'Download#request', 'url' => '/api/download', 'verb' => 'POST'],
['name' => 'Download#file', 'url' => '/api/download/{handle}', 'verb' => 'GET'],
// Config API
['name' => 'Other#setUserConfig', 'url' => '/api/config/{key}', 'verb' => 'PUT'],

View File

@ -12,6 +12,6 @@ test.describe("Open", () => {
test("Open folder", async ({ page }) => {
await page.locator("text=Local").click();
await page.waitForSelector('img[src*="core/preview"]');
await page.waitForSelector('img[src*="api/image/preview"]');
});
});

View File

@ -13,6 +13,6 @@ export function login(route: string) {
await expect(page).toHaveURL(
"http://localhost:8080/index.php/apps/memories" + route
);
await page.waitForSelector('img[src*="core/preview"]');
await page.waitForSelector('img[src*="api/image/preview"]');
};
}

View File

@ -6,7 +6,7 @@ test.beforeEach(login("/"));
test.describe("Open", () => {
test("Look for Images", async ({ page }) => {
expect(
await page.locator('img[src*="core/preview"]').count(),
await page.locator('img[src*="api/image/preview"]').count(),
"Number of previews"
).toBeGreaterThan(4);
await page.waitForTimeout(1000);
@ -50,7 +50,9 @@ test.describe("Open", () => {
// refresh page
await page.reload();
await page.waitForTimeout(4000); // cache
await page.waitForSelector('img[src*="core/preview"]');
await page.reload(); // prevent stale cache issues
await page.waitForTimeout(4000); // cache
await page.waitForSelector('img[src*="api/image/preview"]');
expect(await page.locator(`img[src="${src1}"]`).count()).toBe(0);
expect(await page.locator(`img[src="${src2}"]`).count()).toBe(0);
});

View File

@ -27,11 +27,9 @@ use OC\DB\Connection;
use OC\DB\SchemaWrapper;
use OCA\Memories\AppInfo\Application;
use OCA\Memories\Db\TimelineWrite;
use OCP\Encryption\IManager;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\StorageNotAvailableException;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IPreview;
@ -53,7 +51,6 @@ class Index extends Command
protected IPreview $preview;
protected IConfig $config;
protected OutputInterface $output;
protected IManager $encryptionManager;
protected IDBConnection $connection;
protected Connection $connectionForSchema;
protected TimelineWrite $timelineWrite;
@ -73,7 +70,6 @@ class Index extends Command
IUserManager $userManager,
IPreview $preview,
IConfig $config,
IManager $encryptionManager,
IDBConnection $connection,
Connection $connectionForSchema
) {
@ -83,10 +79,9 @@ class Index extends Command
$this->rootFolder = $rootFolder;
$this->preview = $preview;
$this->config = $config;
$this->encryptionManager = $encryptionManager;
$this->connection = $connection;
$this->connectionForSchema = $connectionForSchema;
$this->timelineWrite = new TimelineWrite($connection, $preview);
$this->timelineWrite = new TimelineWrite($connection);
}
protected function configure(): void
@ -181,7 +176,7 @@ class Index extends Command
// Time measurement
$startTime = microtime(true);
if (\OCA\Memories\Util::isEncryptionEnabled($this->encryptionManager)) {
if (\OCA\Memories\Util::isEncryptionEnabled()) {
// 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.');
@ -211,8 +206,9 @@ class Index extends Command
/** Make sure exiftool is available */
private function testExif()
{
$testfile = __DIR__.'/../../exiftest.jpg';
if (!file_exists($testfile)) {
$testfilepath = __DIR__.'/../../exiftest.jpg';
$testfile = realpath($testfilepath);
if (!$testfile) {
error_log("Couldn't find Exif test file {$testfile}");
return false;
@ -279,11 +275,11 @@ class Index extends Command
$this->parseFile($node, $refresh);
}
}
} catch (StorageNotAvailableException $e) {
} catch (\Exception $e) {
$this->output->writeln(sprintf(
'<error>Storage for folder folder %s is not available: %s</error>',
'<error>Could not scan folder %s: %s</error>',
$folder->getPath(),
$e->getHint()
$e->getMessage()
));
}
}
@ -291,7 +287,18 @@ class Index extends Command
private function parseFile(File &$file, bool &$refresh): void
{
// Process the file
$res = $this->timelineWrite->processFile($file, $refresh);
$res = 1;
try {
$res = $this->timelineWrite->processFile($file, $refresh);
} catch (\Exception $e) {
$this->output->writeln(sprintf(
'<error>Could not process file %s: %s</error>',
$file->getPath(),
$e->getMessage()
));
}
if (2 === $res) {
++$this->nProcessed;
} elseif (1 === $res) {

View File

@ -33,21 +33,15 @@ class AlbumsController extends ApiBase
*
* Get list of albums with counts of images
*/
public function albums(): JSONResponse
public function albums(int $t = 0): JSONResponse
{
$user = $this->userSession->getUser();
if (null === $user) {
if (null === $user || !$this->albumsIsEnabled()) {
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
}
// Check tags enabled for this user
if (!$this->albumsIsEnabled()) {
return new JSONResponse(['message' => 'Albums not enabled for user'], Http::STATUS_PRECONDITION_FAILED);
}
// Run actual query
$list = [];
$t = (int) $this->request->getParam('t');
if ($t & 1) { // personal
$list = array_merge($list, $this->timelineQuery->getAlbums($user->getUID()));
}
@ -57,4 +51,35 @@ class AlbumsController extends ApiBase
return new JSONResponse($list, Http::STATUS_OK);
}
/**
* @NoAdminRequired
*
* Download an album as a zip file
*/
public function download(string $name = ''): JSONResponse
{
$user = $this->userSession->getUser();
if (null === $user || !$this->albumsIsEnabled()) {
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
}
// Get album
$album = $this->timelineQuery->getAlbumIfAllowed($user->getUID(), $name);
if (null === $album) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
// Get files
$files = $this->timelineQuery->getAlbumFiles($album['album_id']);
if (empty($files)) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
// Get download handle
$albumName = explode('/', $name)[1];
$handle = \OCA\Memories\Controller\DownloadController::createHandle($albumName, $files);
return new JSONResponse(['handle' => $handle], Http::STATUS_OK);
}
}

View File

@ -26,22 +26,18 @@ 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;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IPreview;
use OCP\IRequest;
use OCP\IUserSession;
use OCP\Share\IManager as IShareManager;
class ApiBase extends Controller
{
@ -49,11 +45,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;
protected IPreview $previewManager;
public function __construct(
IRequest $request,
@ -61,10 +53,7 @@ class ApiBase extends Controller
IUserSession $userSession,
IDBConnection $connection,
IRootFolder $rootFolder,
IAppManager $appManager,
IManager $encryptionManager,
IShareManager $shareManager,
IPreview $preview
IAppManager $appManager
) {
parent::__construct(Application::APPNAME, $request);
@ -73,15 +62,11 @@ 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);
$this->timelineWrite = new TimelineWrite($connection, $preview);
}
/** Get logged in user's UID or throw HTTP error */
protected function getUid(): string
protected function getUID(): string
{
$user = $this->userSession->getUser();
if ($this->getShareToken()) {
@ -104,12 +89,7 @@ class ApiBase extends Controller
}
// Public shared folder
if ($token = $this->getShareToken()) {
$share = $this->shareManager->getShareByToken($token)->getNode(); // throws exception if not found
if (!$share instanceof Folder) {
throw new \Exception('Share not found or invalid');
}
if ($share = $this->getShareNode()) { // can throw
$root->addFolder($share);
return $root;
@ -152,13 +132,24 @@ class ApiBase extends Controller
}
/**
* Get a file with ID from user's folder.
*
* @param int $fileId
*
* @return null|File
* Get a file with ID for the current user.
*/
protected function getUserFile(int $id)
protected function getUserFile(int $fileId): ?File
{
// Don't check self for share token
if ($this->getShareToken()) {
return $this->getShareFile($fileId);
}
// Check both user folder and album
return $this->getUserFolderFile($fileId) ??
$this->getAlbumFile($fileId);
}
/**
* Get a file with ID from user's folder.
*/
protected function getUserFolderFile(int $id): ?File
{
$user = $this->userSession->getUser();
if (null === $user) {
@ -166,23 +157,45 @@ class ApiBase extends Controller
}
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
// Check for permissions and get numeric Id
$file = $userFolder->getById($id);
if (0 === \count($file)) {
return $this->getOneFileFromFolder($userFolder, $id);
}
/**
* Get a file with ID from an album.
*/
protected function getAlbumFile(int $id): ?File
{
$user = $this->userSession->getUser();
if (null === $user) {
return null;
}
$uid = $user->getUID();
$owner = $this->timelineQuery->albumHasUserFile($uid, $id);
if (!$owner) {
return null;
}
// Check if node is a file
if (!$file[0] instanceof File) {
return null;
$folder = $this->rootFolder->getUserFolder($owner);
return $this->getOneFileFromFolder($folder, $id);
}
/**
* Get a file with ID from a public share.
*
* @param int $fileId
*/
protected function getShareFile(int $id): ?File
{
try {
if ($share = $this->getShareNode()) {
return $this->getOneFileFromFolder($share, $id);
}
} catch (\Exception $e) {
}
// Check read permission
if (!($file[0]->getPermissions() & \OCP\Constants::PERMISSION_READ)) {
return null;
}
return $file[0];
return null;
}
protected function isRecursive()
@ -210,6 +223,50 @@ class ApiBase extends Controller
return $this->request->getParam('folder_share');
}
protected function getShareObject()
{
// Get token from request
$token = $this->getShareToken();
if (null === $token) {
return null;
}
// Get share by token
$share = \OC::$server->get(\OCP\Share\IManager::class)->getShareByToken($token);
if (!PublicController::validateShare($share)) {
return null;
}
// Check if share is password protected
if (($password = $share->getPassword()) !== null) {
$session = \OC::$server->get(\OCP\ISession::class);
// https://github.com/nextcloud/server/blob/0447b53bda9fe95ea0cbed765aa332584605d652/lib/public/AppFramework/PublicShareController.php#L119
if ($session->get('public_link_authenticated_token') !== $token
|| $session->get('public_link_authenticated_password_hash') !== $password) {
throw new \Exception('Share is password protected and user is not authenticated');
}
}
return $share;
}
protected function getShareNode()
{
$share = $this->getShareObject();
if (null === $share) {
return null;
}
// Get node from share
$node = $share->getNode(); // throws exception if not found
if (!$node instanceof Folder || !$node->isReadable() || !$node->isShareable()) {
throw new \Exception('Share not found or invalid');
}
return $node;
}
/**
* Check if albums are enabled for this user.
*/
@ -233,4 +290,42 @@ class ApiBase extends Controller
{
return \OCA\Memories\Util::recognizeIsEnabled($this->appManager);
}
// Check if facerecognition is installed and enabled for this user.
protected function facerecognitionIsInstalled(): bool
{
return \OCA\Memories\Util::facerecognitionIsInstalled($this->appManager);
}
/**
* Check if facerecognition is enabled for this user.
*/
protected function facerecognitionIsEnabled(): bool
{
return \OCA\Memories\Util::facerecognitionIsEnabled($this->config, $this->getUID());
}
/**
* Helper to get one file or null from a fiolder.
*/
private function getOneFileFromFolder(Folder $folder, int $id): ?File
{
// Check for permissions and get numeric Id
$file = $folder->getById($id);
if (0 === \count($file)) {
return null;
}
// Check if node is a file
if (!$file[0] instanceof File) {
return null;
}
// Check read permission
if (!($file[0]->getPermissions() & \OCP\Constants::PERMISSION_READ)) {
return null;
}
return $file[0];
}
}

View File

@ -39,7 +39,7 @@ class DaysController extends ApiBase
public function days(): JSONResponse
{
// Get the folder to show
$uid = $this->getUid();
$uid = $this->getUID();
// Get the folder to show
$root = null;
@ -92,7 +92,7 @@ class DaysController extends ApiBase
public function day(string $id): JSONResponse
{
// Get user
$uid = $this->getUid();
$uid = $this->getUID();
// Check for wildcard
$dayIds = [];
@ -121,7 +121,7 @@ class DaysController extends ApiBase
// Convert to actual dayIds if month view
if ($this->isMonthView()) {
$dayIds = $this->timelineQuery->monthIdToDayIds($dayIds[0]);
$dayIds = $this->timelineQuery->monthIdToDayIds((int) $dayIds[0]);
}
// Run actual query
@ -198,16 +198,24 @@ class DaysController extends ApiBase
$transforms[] = [$this->timelineQuery, 'transformVideoFilter'];
}
// Filter only for one face
if ($this->recognizeIsEnabled()) {
$face = $this->request->getParam('face');
if ($face) {
$transforms[] = [$this->timelineQuery, 'transformFaceFilter', $face];
}
// Filter only for one face on Recognize
if (($recognize = $this->request->getParam('recognize')) && $this->recognizeIsEnabled()) {
$transforms[] = [$this->timelineQuery, 'transformPeopleRecognitionFilter', $recognize];
$faceRect = $this->request->getParam('facerect');
if ($faceRect && !$aggregateOnly) {
$transforms[] = [$this->timelineQuery, 'transformFaceRect', $face];
$transforms[] = [$this->timelineQuery, 'transformPeopleRecognizeRect', $recognize];
}
}
// Filter only for one face on Face Recognition
if (($face = $this->request->getParam('facerecognition')) && $this->facerecognitionIsEnabled()) {
$currentModel = (int) $this->config->getAppValue('facerecognition', 'model', -1);
$transforms[] = [$this->timelineQuery, 'transformPeopleFaceRecognitionFilter', $currentModel, $face];
$faceRect = $this->request->getParam('facerect');
if ($faceRect && !$aggregateOnly) {
$transforms[] = [$this->timelineQuery, 'transformPeopleFaceRecognitionRect', $face];
}
}

View File

@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Varun Patil <radialapps@gmail.com>
* @author Varun Patil <radialapps@gmail.com>
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace OCA\Memories\Controller;
use bantu\IniGetWrapper\IniGetWrapper;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\ISession;
use OCP\Security\ISecureRandom;
class DownloadController extends ApiBase
{
/**
* @NoAdminRequired
*
* @PublicPage
*
* Request to download one or more files
*/
public function request(): JSONResponse
{
// Get ids from body
$files = $this->request->getParam('files');
if (null === $files || !\is_array($files)) {
return new JSONResponse([], Http::STATUS_BAD_REQUEST);
}
// Return id
$handle = self::createHandle('memories', $files);
return new JSONResponse(['handle' => $handle]);
}
/**
* Get a handle for downloading files.
*
* @param string $name Name of zip file
* @param int[] $files
*/
public static function createHandle(string $name, array $files): string
{
$handle = \OC::$server->get(ISecureRandom::class)->generate(16, ISecureRandom::CHAR_ALPHANUMERIC);
\OC::$server->get(ISession::class)->set("memories_download_{$handle}", [$name, $files]);
return $handle;
}
/**
* @NoAdminRequired
*
* @NoCSRFRequired
*
* @PublicPage
*
* Download one or more files
*/
public function file(string $handle): Http\Response
{
// Get ids from request
$session = \OC::$server->get(ISession::class);
$key = "memories_download_{$handle}";
$info = $session->get($key);
$session->remove($key);
if (null === $info) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
$name = $info[0].'-'.date('YmdHis');
$fileIds = $info[1];
/** @var int[] $fileIds */
$fileIds = array_filter(array_map('intval', $fileIds), function (int $id): bool {
return $id > 0;
});
// Check if we have any valid ids
if (0 === \count($fileIds)) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
// Download single file
if (1 === \count($fileIds)) {
return $this->one($fileIds[0]);
}
// Download multiple files
$this->multiple($name, $fileIds); // exits
}
/**
* Download a single file.
*/
private function one(int $fileid): Http\Response
{
$file = $this->getUserFile($fileid);
if (null === $file) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
$response = new Http\StreamResponse($file->fopen('rb'));
$response->addHeader('Content-Type', $file->getMimeType());
$response->addHeader('Content-Disposition', 'attachment; filename="'.$file->getName().'"');
$response->addHeader('Content-Length', $file->getSize());
return $response;
}
/**
* Download multiple files.
*
* @param string $name Name of zip file
* @param int[] $fileIds
*/
private function multiple(string $name, array &$fileIds)
{
// Disable time limit
$executionTime = (int) \OC::$server->get(IniGetWrapper::class)->getNumeric('max_execution_time');
@set_time_limit(0);
// Ensure we can abort the request if user stops it
ignore_user_abort(true);
// Pretend the size is huge so forced zip64
// Lookup the constructor of \OC\Streamer for more info
$size = \count($fileIds) * 1024 * 1024 * 1024 * 8;
$streamer = new \OC\Streamer($this->request, $size, \count($fileIds));
// Create a zip file
$streamer->sendHeaders($name);
// Multiple files might have the same name
// So we need to add a number to the end of the name
$nameCounts = [];
// Send each file
foreach ($fileIds as $fileId) {
if (connection_aborted()) {
break;
}
/** @var bool|resource */
$handle = false;
/** @var ?File */
$file = null;
/** @var ?string */
$name = (string) $fileId;
try {
// This checks permissions
$file = $this->getUserFile($fileId);
if (null === $file) {
throw new \Exception('File not found');
}
$name = $file->getName();
// Open file
$handle = $file->fopen('rb');
if (false === $handle) {
throw new \Exception('Failed to open file');
}
// Handle duplicate names
if (isset($nameCounts[$name])) {
++$nameCounts[$name];
// add count before extension
$extpos = strrpos($name, '.');
if (false === $extpos) {
$name .= " ({$nameCounts[$name]})";
} else {
$name = substr($name, 0, $extpos)." ({$nameCounts[$name]})".substr($name, $extpos);
}
} else {
$nameCounts[$name] = 0;
}
// Add file to zip
if (!$streamer->addFileFromStream(
$handle,
$name,
$file->getSize(),
$file->getMTime(),
)) {
throw new \Exception('Failed to add file to zip');
}
} catch (\Exception $e) {
// create a dummy memory file with the error message
$dummy = fopen('php://memory', 'rw+');
fwrite($dummy, $e->getMessage());
rewind($dummy);
$streamer->addFileFromStream(
$dummy,
"{$name}_error.txt",
\strlen($e->getMessage()),
time(),
);
// close the dummy file
fclose($dummy);
} finally {
if (false !== $handle) {
fclose($handle);
}
}
}
// Restore time limit
@set_time_limit($executionTime);
// Done
$streamer->finalize();
exit;
}
}

View File

@ -1,156 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Varun Patil <radialapps@gmail.com>
* @author Varun Patil <radialapps@gmail.com>
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace OCA\Memories\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataDisplayResponse;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Files\FileInfo;
class FacesController extends ApiBase
{
/**
* @NoAdminRequired
*
* Get list of faces with counts of images
*/
public function faces(): JSONResponse
{
$user = $this->userSession->getUser();
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);
}
// If this isn't the timeline folder then things aren't going to work
$root = $this->getRequestRoot();
if ($root->isEmpty()) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
// Run actual query
$list = $this->timelineQuery->getFaces(
$root,
);
return new JSONResponse($list, Http::STATUS_OK);
}
/**
* @NoAdminRequired
*
* @NoCSRFRequired
*
* Get face preview image cropped with imagick
*
* @return DataResponse
*/
public function preview(string $id): Http\Response
{
$user = $this->userSession->getUser();
if (null === $user) {
return new DataResponse([], Http::STATUS_PRECONDITION_FAILED);
}
// Check faces enabled for this user
if (!$this->recognizeIsEnabled()) {
return new DataResponse([], Http::STATUS_PRECONDITION_FAILED);
}
// Get folder to search for
$root = $this->getRequestRoot();
if ($root->isEmpty()) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
// Run actual query
$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 = $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);
} catch (\Exception $e) {
continue;
}
// Got the preview
break;
}
// Make sure the preview is valid
if (null === $preview) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
}
// Crop image
$image = new \Imagick();
$image->readImageBlob($preview->getContent());
$iw = $image->getImageWidth();
$ih = $image->getImageHeight();
$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(
(int) $faceDim,
(int) $faceDim,
(int) ($dcx * $iw - $faceDim / 2),
(int) ($dcy * $ih - $faceDim / 2),
);
$image->scaleImage(256, 256, true);
$blob = $image->getImageBlob();
// Create and send response
$response = new DataDisplayResponse($blob, Http::STATUS_OK, [
'Content-Type' => $image->getImageMimeType(),
]);
$response->cacheFor(3600 * 24, false, false);
return $response;
}
}

View File

@ -24,32 +24,169 @@ declare(strict_types=1);
namespace OCA\Memories\Controller;
use OCA\Memories\AppInfo\Application;
use OCA\Memories\Db\TimelineWrite;
use OCA\Memories\Exif;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\FileDisplayResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Files\IRootFolder;
class ImageController extends ApiBase
{
/**
* @NoAdminRequired
*
* Get image info for one file
* @NoCSRFRequired
*
* @PublicPage
*
* Get preview of image
*/
public function preview(
int $id,
int $x = 32,
int $y = 32,
bool $a = false,
string $mode = 'fill'
) {
if (-1 === $id || 0 === $x || 0 === $y) {
return new JSONResponse([
'message' => 'Invalid parameters',
], Http::STATUS_BAD_REQUEST);
}
$file = $this->getUserFile($id);
if (!$file) {
return new JSONResponse([
'message' => 'File not found',
], Http::STATUS_NOT_FOUND);
}
try {
$preview = \OC::$server->get(\OCP\IPreview::class)->getPreview($file, $x, $y, !$a, $mode);
$response = new FileDisplayResponse($preview, Http::STATUS_OK, [
'Content-Type' => $preview->getMimeType(),
]);
$response->cacheFor(3600 * 24, false, true);
return $response;
} catch (\OCP\Files\NotFoundException $e) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
} catch (\InvalidArgumentException $e) {
return new JSONResponse([], Http::STATUS_BAD_REQUEST);
}
}
/**
* @NoAdminRequired
*
* @NoCSRFRequired
*
* @PublicPage
*
* Get preview of many images
*/
public function multipreview()
{
// read body to array
try {
$body = file_get_contents('php://input');
$files = json_decode($body, true);
} catch (\Exception $e) {
return new JSONResponse([], Http::STATUS_BAD_REQUEST);
}
/** @var \OCP\IPreview $previewManager */
$previewManager = \OC::$server->get(\OCP\IPreview::class);
// For checking max previews
$previewRoot = new \OC\Preview\Storage\Root(
\OC::$server->get(IRootFolder::class),
\OC::$server->getSystemConfig(),
);
// stream the response
header('Content-Type: application/octet-stream');
header('Expires: '.gmdate('D, d M Y H:i:s \G\M\T', time() + 7 * 3600 * 24));
header('Cache-Control: max-age='. 7 * 3600 * 24 .', private');
foreach ($files as $bodyFile) {
$reqid = $bodyFile['reqid'];
$fileid = (int) $bodyFile['fileid'];
$x = (int) $bodyFile['x'];
$y = (int) $bodyFile['y'];
$a = '1' === $bodyFile['a'];
$file = $this->getUserFile($fileid);
if (!$file) {
continue;
}
// Make sure max preview exists
$fileId = (string) $file->getId();
$folder = $previewRoot->getFolder($fileId);
$hasMax = false;
foreach ($folder->getDirectoryListing() as $preview) {
$name = $preview->getName();
if (str_contains($name, '-max')) {
$hasMax = true;
break;
}
}
if (!$hasMax) {
continue;
}
// Add this preview to the response
try {
$preview = $previewManager->getPreview($file, $x, $y, !$a, 'fill');
$content = $preview->getContent();
if (empty($content)) {
continue;
}
echo json_encode([
'reqid' => $reqid,
'Content-Length' => \strlen($content),
'Content-Type' => $preview->getMimeType(),
]);
echo "\n";
echo $content;
flush();
} catch (\Exception $e) {
continue;
}
}
exit;
}
/**
* @NoAdminRequired
*
* @PublicPage
*
* Get EXIF info for an image with file id
*
* @param string fileid
*/
public function info(string $id): JSONResponse
{
public function info(
string $id,
bool $basic = false,
bool $current = false
): JSONResponse {
$file = $this->getUserFile((int) $id);
if (!$file) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
// Get the image info
$basic = false !== $this->request->getParam('basic', false);
$info = $this->timelineQuery->getInfoById($file->getId(), $basic);
// Get latest exif data if requested
if ($this->request->getParam('current', false)) {
// Allow this ony for logged in users
if ($current && null !== $this->userSession->getUser()) {
$info['current'] = Exif::getExifFromFile($file);
}
@ -76,7 +213,7 @@ class ImageController extends ApiBase
}
// Check for end-to-end encryption
if (\OCA\Memories\Util::isEncryptionEnabled($this->encryptionManager)) {
if (\OCA\Memories\Util::isEncryptionEnabled()) {
return new JSONResponse(['message' => 'Cannot change encrypted file'], Http::STATUS_PRECONDITION_FAILED);
}
@ -96,7 +233,8 @@ class ImageController extends ApiBase
}
// Reprocess the file
$this->timelineWrite->processFile($file, true);
$timelineWrite = new TimelineWrite($this->connection);
$timelineWrite->processFile($file, true);
return new JSONResponse([], Http::STATUS_OK);
}

View File

@ -53,7 +53,7 @@ class OtherController extends ApiBase
return new JSONResponse(['message' => 'Cannot change settings in readonly mode'], Http::STATUS_FORBIDDEN);
}
$userId = $user->getUid();
$userId = $user->getUID();
$this->config->setUserValue($userId, Application::APPNAME, $key, $value);
return new JSONResponse([], Http::STATUS_OK);

View File

@ -62,7 +62,7 @@ class PageController extends Controller
$this->eventDispatcher->dispatchTyped(new LoadSidebar());
// Configuration
$uid = $user->getUid();
$uid = $user->getUID();
$this->initialState->provideInitialState('timelinePath', $this->config->getUserValue(
$uid,
Application::APPNAME,
@ -86,6 +86,8 @@ class PageController extends Controller
$this->initialState->provideInitialState('systemtags', true === $this->appManager->isEnabledForUser('systemtags'));
$this->initialState->provideInitialState('maps', true === $this->appManager->isEnabledForUser('maps'));
$this->initialState->provideInitialState('recognize', \OCA\Memories\Util::recognizeIsEnabled($this->appManager));
$this->initialState->provideInitialState('facerecognitionInstalled', \OCA\Memories\Util::facerecognitionIsInstalled($this->appManager));
$this->initialState->provideInitialState('facerecognitionEnabled', \OCA\Memories\Util::facerecognitionIsEnabled($this->config, $uid));
$this->initialState->provideInitialState('albums', \OCA\Memories\Util::albumsIsEnabled($this->appManager));
// App version
@ -181,7 +183,17 @@ class PageController extends Controller
*
* @NoCSRFRequired
*/
public function people()
public function recognize()
{
return $this->main();
}
/**
* @NoAdminRequired
*
* @NoCSRFRequired
*/
public function facerecognition()
{
return $this->main();
}

View File

@ -0,0 +1,276 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Varun Patil <radialapps@gmail.com>
* @author Varun Patil <radialapps@gmail.com>
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace OCA\Memories\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataDisplayResponse;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Files\FileInfo;
class PeopleController extends ApiBase
{
/**
* @NoAdminRequired
*
* Get list of faces with counts of images
*/
public function recognizePeople(): JSONResponse
{
$user = $this->userSession->getUser();
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);
}
// If this isn't the timeline folder then things aren't going to work
$root = $this->getRequestRoot();
if ($root->isEmpty()) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
// Run actual query
$list = $this->timelineQuery->getPeopleRecognize(
$root,
);
return new JSONResponse($list, Http::STATUS_OK);
}
/**
* @NoAdminRequired
*
* @NoCSRFRequired
*
* Get face preview image cropped with imagick
*
* @return DataResponse
*/
public function recognizePeoplePreview(int $id): Http\Response
{
$user = $this->userSession->getUser();
if (null === $user) {
return new DataResponse([], Http::STATUS_PRECONDITION_FAILED);
}
// Check faces enabled for this user
if (!$this->recognizeIsEnabled()) {
return new DataResponse([], Http::STATUS_PRECONDITION_FAILED);
}
// Get folder to search for
$root = $this->getRequestRoot();
if ($root->isEmpty()) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
// Run actual query
$detections = $this->timelineQuery->getPeopleRecognizePreview($root, $id);
if (null === $detections || 0 === \count($detections)) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
}
return $this->getPreviewResponse($detections, $user, 1.5);
}
/**
* @NoAdminRequired
*
* Get list of faces with counts of images
*/
public function facerecognitionPeople(): JSONResponse
{
$user = $this->userSession->getUser();
if (null === $user) {
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
}
// Check if face recognition is installed and enabled for this user
if (!$this->facerecognitionIsInstalled()) {
return new DataResponse([], Http::STATUS_PRECONDITION_FAILED);
}
// If this isn't the timeline folder then things aren't going to work
$root = $this->getRequestRoot();
if ($root->isEmpty()) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
// If the user has recognition disabled, just returns an empty response.
if (!$this->facerecognitionIsEnabled()) {
return new JSONResponse([]);
}
// Run actual query
$currentModel = (int) $this->config->getAppValue('facerecognition', 'model', -1);
$list = $this->timelineQuery->getPeopleFaceRecognition(
$root,
$currentModel,
);
// Just append unnamed clusters to the end.
$list = array_merge($list, $this->timelineQuery->getPeopleFaceRecognition(
$root,
$currentModel,
true
));
return new JSONResponse($list, Http::STATUS_OK);
}
/**
* @NoAdminRequired
*
* @NoCSRFRequired
*
* Get face preview image cropped with imagick
*
* @return DataResponse
*/
public function facerecognitionPeoplePreview(string $id): Http\Response
{
$user = $this->userSession->getUser();
if (null === $user) {
return new DataResponse([], Http::STATUS_PRECONDITION_FAILED);
}
// Check if face recognition is installed and enabled for this user
if (!$this->facerecognitionIsInstalled()) {
return new DataResponse([], Http::STATUS_PRECONDITION_FAILED);
}
// Get folder to search for
$root = $this->getRequestRoot();
if ($root->isEmpty()) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
// If the user has facerecognition disabled, just returns an empty response.
if (!$this->facerecognitionIsEnabled()) {
return new JSONResponse([]);
}
// Run actual query
$currentModel = (int) $this->config->getAppValue('facerecognition', 'model', -1);
$detections = $this->timelineQuery->getFaceRecognitionPreview($root, $currentModel, $id);
if (null === $detections || 0 === \count($detections)) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
}
return $this->getPreviewResponse($detections, $user, 1.8);
}
/**
* Get face preview image cropped with imagick.
*
* @param array $detections Array of detections to search
* @param \OCP\IUser $user User to search for
* @param int $padding Padding to add to the face in preview
*/
private function getPreviewResponse(
array $detections,
\OCP\IUser $user,
float $padding
): Http\Response {
// Get preview manager
$previewManager = \OC::$server->get(\OCP\IPreview::class);
// Find the first detection that has a preview
/** @var \Imagick */
$image = null;
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
foreach ($detections as &$detection) {
// Get the file (also checks permissions)
$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 = $previewManager->getPreview($files[0], 2048, 2048, false);
$image = new \Imagick();
if (!$image->readImageBlob($preview->getContent())) {
throw new \Exception('Failed to read image blob');
}
$iw = $image->getImageWidth();
$ih = $image->getImageHeight();
if ($iw <= 0 || $ih <= 0) {
$image = null;
throw new \Exception('Invalid image size');
}
} catch (\Exception $e) {
continue;
}
// Got the preview
break;
}
// Make sure the preview is valid
if (null === $image) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
}
// Set quality and make progressive
$image->setImageCompressionQuality(80);
$image->setInterlaceScheme(\Imagick::INTERLACE_PLANE);
// Crop image
$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) * $padding;
$image->cropImage(
(int) $faceDim,
(int) $faceDim,
(int) ($dcx * $iw - $faceDim / 2),
(int) ($dcy * $ih - $faceDim / 2),
);
$image->scaleImage(512, 512, true);
$blob = $image->getImageBlob();
// Create and send response
$response = new DataDisplayResponse($blob, Http::STATUS_OK, [
'Content-Type' => $image->getImageMimeType(),
]);
$response->cacheFor(3600 * 24, false, false);
return $response;
}
}

View File

@ -95,7 +95,7 @@ class PublicController extends AuthPublicShareController
throw new NotFoundException();
}
if (!$this->validateShare($share)) {
if (!self::validateShare($share)) {
throw new NotFoundException();
}
@ -109,6 +109,9 @@ class PublicController extends AuthPublicShareController
// Video configuration
$this->initialState->provideInitialState('notranscode', $this->config->getSystemValue('memories.no_transcode', 'UNSET'));
// Share info
$this->initialState->provideInitialState('no_download', $share->getHideDownload());
$policy = new ContentSecurityPolicy();
$policy->addAllowedWorkerSrcDomain("'self'");
$policy->addAllowedScriptDomain("'self'");
@ -118,6 +121,9 @@ class PublicController extends AuthPublicShareController
$policy->addAllowedScriptDomain('blob:');
$policy->addAllowedMediaDomain('blob:');
// Image editor
$policy->addAllowedConnectDomain('data:');
// Allow nominatim for metadata
$policy->addAllowedConnectDomain('nominatim.openstreetmap.org');
$policy->addAllowedFrameDomain('www.openstreetmap.org');
@ -128,6 +134,38 @@ class PublicController extends AuthPublicShareController
return $response;
}
/**
* Validate the permissions of the share.
*/
public static function validateShare(?IShare $share): bool
{
if (null === $share) {
return false;
}
// Get user manager
$userManager = \OC::$server->get(IUserManager::class);
// Check if share read is allowed
if (!($share->getPermissions() & \OCP\Constants::PERMISSION_READ)) {
return false;
}
// If the owner is disabled no access to the linke is granted
$owner = $userManager->get($share->getShareOwner());
if (null === $owner || !$owner->isEnabled()) {
return false;
}
// If the initiator of the share is disabled no access is granted
$initiator = $userManager->get($share->getSharedBy());
if (null === $initiator || !$initiator->isEnabled()) {
return false;
}
return $share->getNode()->isReadable() && $share->getNode()->isShareable();
}
protected function showAuthFailed(): TemplateResponse
{
$templateParameters = ['share' => $this->share, 'wrongpw' => true];
@ -149,28 +187,4 @@ class PublicController extends AuthPublicShareController
{
return null !== $this->share->getPassword();
}
/**
* Validate the permissions of the share.
*
* @param Share\IShare $share
*
* @return bool
*/
private function validateShare(IShare $share)
{
// If the owner is disabled no access to the linke is granted
$owner = $this->userManager->get($share->getShareOwner());
if (null === $owner || !$owner->isEnabled()) {
return false;
}
// If the initiator of the share is disabled no access is granted
$initiator = $this->userManager->get($share->getSharedBy());
if (null === $initiator || !$initiator->isEnabled()) {
return false;
}
return $share->getNode()->isReadable() && $share->getNode()->isShareable();
}
}

View File

@ -24,6 +24,7 @@ declare(strict_types=1);
namespace OCA\Memories\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataDisplayResponse;
use OCP\AppFramework\Http\JSONResponse;
class TagsController extends ApiBase
@ -62,9 +63,11 @@ class TagsController extends ApiBase
/**
* @NoAdminRequired
*
* Get previews for a tag
* @NoCSRFRequired
*
* Get preview for a tag
*/
public function previews(): JSONResponse
public function preview(string $tag): Http\Response
{
$user = $this->userSession->getUser();
if (null === $user) {
@ -82,15 +85,43 @@ class TagsController extends ApiBase
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
// Get the tag
$tagName = $this->request->getParam('tag');
// Run actual query
$list = $this->timelineQuery->getTagPreviews(
$tagName,
$root,
);
$list = $this->timelineQuery->getTagPreviews($tag, $root);
if (null === $list || 0 === \count($list)) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
return new JSONResponse($list, Http::STATUS_OK);
// Get preview manager
$previewManager = \OC::$server->get(\OCP\IPreview::class);
// Try to get a preview
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
foreach ($list as &$img) {
// Get the file
$files = $userFolder->getById($img['fileid']);
if (0 === \count($files)) {
continue;
}
// Check read permission
if (!($files[0]->getPermissions() & \OCP\Constants::PERMISSION_READ)) {
continue;
}
// Get preview image
try {
$preview = $previewManager->getPreview($files[0], 512, 512, false);
$response = new DataDisplayResponse($preview->getContent(), Http::STATUS_OK, [
'Content-Type' => $preview->getMimeType(),
]);
$response->cacheFor(3600 * 24, false, false);
return $response;
} catch (\Exception $e) {
continue;
}
}
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
}

View File

@ -34,17 +34,14 @@ class VideoController extends ApiBase
/**
* @NoAdminRequired
*
* @PublicPage
*
* @NoCSRFRequired
*
* Transcode a video to HLS by proxy
*/
public function transcode(string $client, string $fileid, string $profile): Http\Response
public function transcode(string $client, int $fileid, string $profile): Http\Response
{
$user = $this->userSession->getUser();
if (null === $user) {
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
}
// Make sure not running in read-only mode
if (false !== $this->config->getSystemValue('memories.no_transcode', 'UNSET')) {
return new JSONResponse(['message' => 'Transcoding disabled'], Http::STATUS_FORBIDDEN);
@ -56,11 +53,10 @@ class VideoController extends ApiBase
}
// Get file
$files = $this->rootFolder->getUserFolder($user->getUID())->getById($fileid);
if (0 === \count($files)) {
$file = $this->getUserFile($fileid);
if (!$file) {
return new JSONResponse(['message' => 'File not found'], Http::STATUS_NOT_FOUND);
}
$file = $files[0];
if (!($file->getPermissions() & \OCP\Constants::PERMISSION_READ)) {
return new JSONResponse(['message' => 'File not readable'], Http::STATUS_FORBIDDEN);
@ -83,84 +79,36 @@ class VideoController extends ApiBase
return new JSONResponse(['message' => 'File is in temp dir!'], Http::STATUS_NOT_FOUND);
}
// Make upstream request
[$data, $contentType, $returnCode] = $this->getUpstream($client, $path, $profile);
// If status code was 0, it's likely the server is down
// Make one attempt to start if we can't find the process
if (0 === $returnCode) {
$transcoder = $this->config->getSystemValue('memories.transcoder', false);
if (!$transcoder) {
return new JSONResponse(['message' => 'Transcoder not configured'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
// Make transcoder executable
if (!is_executable($transcoder)) {
chmod($transcoder, 0755);
}
// Check for environment variables
$env = '';
// QSV with VAAPI
$vaapi = $this->config->getSystemValue('memories.qsv', false);
if ($vaapi) {
$env .= 'VAAPI=1 ';
}
// Paths
$ffmpegPath = $this->config->getSystemValue('memories.ffmpeg_path', 'ffmpeg');
$ffprobePath = $this->config->getSystemValue('memories.ffprobe_path', 'ffprobe');
$tmpPath = $this->config->getSystemValue('memories.tmp_path', sys_get_temp_dir());
$env .= "FFMPEG='{$ffmpegPath}' FFPROBE='{$ffprobePath}' GOVOD_TEMPDIR='{$tmpPath}/go-vod' ";
// Check if already running
exec("pkill {$transcoder}");
shell_exec("{$env} nohup {$transcoder} > {$tmpPath}/go-vod.log 2>&1 & > /dev/null");
// wait for 1s and try again
sleep(1);
[$data, $contentType, $returnCode] = $this->getUpstream($client, $path, $profile);
}
// Check data was received
if ($returnCode >= 400 || false === $data) {
// Request and check data was received
if (200 !== $this->getUpstream($client, $path, $profile)) {
return new JSONResponse(['message' => 'Transcode failed'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
// Create and send response
$response = new DataDisplayResponse($data, Http::STATUS_OK, [
'Content-Type' => $contentType,
]);
$response->cacheFor(0, false, false);
return $response;
// The response was already streamed, so we have nothing to do here
exit;
}
/**
* @NoAdminRequired
*
* @PublicPage
*
* @NoCSRFRequired
*
* Return the live video part of a live photo
*/
public function livephoto(string $fileid)
{
$fileid = (int) $fileid;
$files = $this->rootFolder->getById($fileid);
if (0 === \count($files)) {
public function livephoto(
int $fileid,
string $liveid = '',
string $format = '',
string $transcode = ''
) {
$file = $this->getUserFile($fileid);
if (null === $file) {
return new JSONResponse(['message' => 'File not found'], Http::STATUS_NOT_FOUND);
}
$file = $files[0];
// Check file etag
$etag = $file->getEtag();
if ($etag !== $this->request->getParam('etag')) {
return new JSONResponse(['message' => 'File changed'], Http::STATUS_PRECONDITION_FAILED);
}
// Check file liveid
$liveid = $this->request->getParam('liveid');
if (!$liveid) {
return new JSONResponse(['message' => 'Live ID not provided'], Http::STATUS_BAD_REQUEST);
}
@ -208,13 +156,22 @@ class VideoController extends ApiBase
if ($liveFile instanceof File) {
// Requested only JSON info
if ('json' === $this->request->getParam('format')) {
if ('json' === $format) {
return new JSONResponse($lp);
}
$name = $liveFile->getName();
$blob = $liveFile->getContent();
$mime = $liveFile->getMimeType();
if ($transcode && !$this->config->getSystemValue('memories.no_transcode', true)) {
// Only Apple uses HEVC for now, so pass this to the transcoder
// If this is H.264 it won't get transcoded anyway
$liveVideoPath = $liveFile->getStorage()->getLocalFile($liveFile->getInternalPath());
if ($this->getUpstream($transcode, $liveVideoPath, 'max.mov')) {
exit;
}
}
}
}
@ -235,17 +192,115 @@ class VideoController extends ApiBase
}
private function getUpstream($client, $path, $profile)
{
$returnCode = $this->getUpstreamInternal($client, $path, $profile);
// If status code was 0, it's likely the server is down
// Make one attempt to start after killing whatever is there
if (0 !== $returnCode) {
return $returnCode;
}
// Get transcoder path
$transcoder = $this->config->getSystemValue('memories.transcoder', false);
if (!$transcoder) {
return 0;
}
// Make transcoder executable
if (!is_executable($transcoder)) {
@chmod($transcoder, 0755);
}
// Check for environment variables
$env = '';
// QSV with VAAPI
$vaapi = $this->config->getSystemValue('memories.qsv', false);
if ($vaapi) {
$env .= 'VAAPI=1 ';
}
// NVENC
$nvenc = $this->config->getSystemValue('memories.nvenc', false);
if ($nvenc) {
$env .= 'NVENC=1 ';
}
// Paths
$ffmpegPath = $this->config->getSystemValue('memories.ffmpeg_path', 'ffmpeg');
$ffprobePath = $this->config->getSystemValue('memories.ffprobe_path', 'ffprobe');
$tmpPath = $this->config->getSystemValue('memories.tmp_path', sys_get_temp_dir());
$env .= "FFMPEG='{$ffmpegPath}' FFPROBE='{$ffprobePath}' GOVOD_TEMPDIR='{$tmpPath}/go-vod' ";
// Check if already running
exec("pkill {$transcoder}");
shell_exec("{$env} nohup {$transcoder} > {$tmpPath}/go-vod.log 2>&1 & > /dev/null");
// wait for 1s and try again
sleep(1);
return $this->getUpstreamInternal($client, $path, $profile);
}
private function getUpstreamInternal($client, $path, $profile)
{
$path = rawurlencode($path);
$ch = curl_init("http://127.0.0.1:47788/{$client}{$path}/{$profile}");
// Make sure query params are repeated
// For example, in folder sharing, we need the params on every request
$url = "http://127.0.0.1:47788/{$client}{$path}/{$profile}";
if ($params = $_SERVER['QUERY_STRING']) {
$url .= "?{$params}";
}
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
curl_setopt($ch, CURLOPT_HEADER, 0);
$data = curl_exec($ch);
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
// Catch connection abort here
ignore_user_abort(true);
// Stream the response to the browser without reading it into memory
$headersWritten = false;
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($curl, $data) use (&$headersWritten, $profile) {
$returnCode = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
if (200 === $returnCode) {
// Write headers if just got the first chunk of data
if (!$headersWritten) {
$headersWritten = true;
$contentType = curl_getinfo($curl, CURLINFO_CONTENT_TYPE);
header("Content-Type: {$contentType}");
if (str_ends_with($profile, 'mov')) {
// cache full video 24 hours
header('Cache-Control: max-age=86400, public');
} else {
// no caching of segments
header('Cache-Control: no-cache, no-store, must-revalidate');
}
http_response_code($returnCode);
}
echo $data;
flush();
if (connection_aborted()) {
return -1; // stop the transfer
}
}
return \strlen($data);
});
// Start the request
curl_exec($ch);
$returnCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [$data, $contentType, $returnCode];
return $returnCode;
}
}

View File

@ -20,7 +20,9 @@ class LivePhoto
/** Check if a given Exif data is the video part of a live photo */
public function isVideoPart(array &$exif)
{
return 'video/quicktime' === $exif['MIMEType'] && \array_key_exists('ContentIdentifier', $exif);
return \array_key_exists('MIMEType', $exif)
&& 'video/quicktime' === $exif['MIMEType']
&& \array_key_exists('ContentIdentifier', $exif);
}
/** Get liveid from photo part */

View File

@ -11,10 +11,11 @@ class TimelineQuery
{
use TimelineQueryAlbums;
use TimelineQueryDays;
use TimelineQueryFaces;
use TimelineQueryFilters;
use TimelineQueryFolders;
use TimelineQueryLivePhoto;
use TimelineQueryPeopleFaceRecognition;
use TimelineQueryPeopleRecognize;
use TimelineQueryTags;
protected IDBConnection $connection;

View File

@ -15,7 +15,7 @@ trait TimelineQueryAlbums
public function transformAlbumFilter(IQueryBuilder &$query, string $uid, string $albumId)
{
// Get album object
$album = $this->getAlbumIfAllowed($query->getConnection(), $uid, $albumId);
$album = $this->getAlbumIfAllowed($uid, $albumId);
// Check permission
if (null === $album) {
@ -30,7 +30,7 @@ trait TimelineQueryAlbums
}
/** Get list of albums */
public function getAlbums(string $uid, $shared = false)
public function getAlbums(string $uid, bool $shared = false)
{
$query = $this->connection->getQueryBuilder();
@ -39,7 +39,7 @@ trait TimelineQueryAlbums
$query->select('pa.*', $count)->from('photos_albums', 'pa');
if ($shared) {
$query->innerJoin('pa', 'photos_collaborators', 'pc', $query->expr()->andX(
$query->innerJoin('pa', $this->collaboratorsTable(), 'pc', $query->expr()->andX(
$query->expr()->eq('pa.album_id', 'pc.album_id'),
$query->expr()->eq('pc.collaborator_id', $query->createNamedParameter($uid)),
));
@ -116,14 +116,46 @@ trait TimelineQueryAlbums
return $dayIds;
}
/**
* Check if a file belongs to a user through an album.
*
* @return bool|string owner of file
*/
public function albumHasUserFile(string $uid, int $fileId)
{
$query = $this->connection->getQueryBuilder();
$query->select('paf.owner')->from('photos_albums_files', 'paf')->where(
$query->expr()->andX(
$query->expr()->eq('paf.file_id', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)),
$query->expr()->orX(
$query->expr()->eq('pa.album_id', 'paf.album_id'),
$query->expr()->eq('pc.album_id', 'paf.album_id'),
),
)
);
// Check if user-owned album or shared album
$query->leftJoin('paf', 'photos_albums', 'pa', $query->expr()->andX(
$query->expr()->eq('pa.album_id', 'paf.album_id'),
$query->expr()->eq('pa.user', $query->createNamedParameter($uid)),
));
// Join to shared album
$query->leftJoin('paf', $this->collaboratorsTable(), 'pc', $query->expr()->andX(
$query->expr()->eq('pc.album_id', 'paf.album_id'),
$query->expr()->eq('pc.collaborator_id', $query->createNamedParameter($uid)),
));
return $query->executeQuery()->fetchOne();
}
/**
* Get album if allowed. Also check if album is shared with user.
*
* @param IDBConnection $connection
* @param string $uid UID of CURRENT user
* @param string $albumId $user/$name where $user is the OWNER of the album
* @param string $uid UID of CURRENT user
* @param string $albumId $user/$name where $user is the OWNER of the album
*/
private function getAlbumIfAllowed(IDBConnection $conn, string $uid, string $albumId)
public function getAlbumIfAllowed(string $uid, string $albumId)
{
// Split name and uid
$parts = explode('/', $albumId);
@ -134,7 +166,7 @@ trait TimelineQueryAlbums
$albumName = $parts[1];
// Check if owner
$query = $conn->getQueryBuilder();
$query = $this->connection->getQueryBuilder();
$query->select('*')->from('photos_albums')->where(
$query->expr()->andX(
$query->expr()->eq('name', $query->createNamedParameter($albumName)),
@ -152,8 +184,8 @@ trait TimelineQueryAlbums
}
// Check in collaborators instead
$query = $conn->getQueryBuilder();
$query->select('album_id')->from('photos_collaborators')->where(
$query = $this->connection->getQueryBuilder();
$query->select('album_id')->from($this->collaboratorsTable())->where(
$query->expr()->andX(
$query->expr()->eq('album_id', $query->createNamedParameter($album['album_id'])),
$query->expr()->eq('collaborator_id', $query->createNamedParameter($uid)),
@ -164,4 +196,37 @@ trait TimelineQueryAlbums
return $album;
}
}
/**
* Get full list of fileIds in album.
*/
public function getAlbumFiles(int $albumId)
{
$query = $this->connection->getQueryBuilder();
$query->select('file_id')->from('photos_albums_files', 'paf')->where(
$query->expr()->eq('album_id', $query->createNamedParameter($albumId, IQueryBuilder::PARAM_INT))
);
$query->innerJoin('paf', 'filecache', 'fc', $query->expr()->eq('fc.fileid', 'paf.file_id'));
$fileIds = [];
$result = $query->executeQuery();
while ($row = $result->fetch()) {
$fileIds[] = (int) $row['file_id'];
}
return $fileIds;
}
/** Get the name of the collaborators table */
private function collaboratorsTable()
{
// https://github.com/nextcloud/photos/commit/20e3e61ad577014e5f092a292c90a8476f630355
$appManager = \OC::$server->get(\OCP\App\IAppManager::class);
$photosVersion = $appManager->getAppVersion('photos');
if (version_compare($photosVersion, '2.0.1', '>=')) {
return 'photos_albums_collabs';
}
return 'photos_collaborators';
}
}

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace OCA\Memories\Db;
use OCA\Memories\Exif;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
@ -139,7 +140,7 @@ trait TimelineQueryDays
public function getDay(
TimelineRoot &$root,
string $uid,
$day_ids,
?array $day_ids,
bool $recursive,
bool $archive,
array $queryTransforms = []
@ -258,16 +259,13 @@ trait TimelineQueryDays
$actualPath[1] = $actualPath[2];
$actualPath[2] = $tmp;
$davPath = implode('/', $actualPath);
$davPaths[$fileid] = \OCA\Memories\Exif::removeExtraSlash('/'.$davPath.'/');
$davPaths[$fileid] = Exif::removeExtraSlash('/'.$davPath.'/');
}
}
}
}
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'];
@ -290,17 +288,22 @@ trait TimelineQueryDays
if (isset($row['path']) && !empty($row['path'])) {
$rootId = \array_key_exists('rootid', $row) ? $row['rootid'] : $defaultRootId;
$basePath = $internalPaths[$rootId] ?? '#__#';
$davPath = $davPaths[$rootId] ?: '';
$davPath = (\array_key_exists($rootId, $davPaths) ? $davPaths[$rootId] : null) ?: '';
if (0 === strpos($row['path'], $basePath)) {
$row['filename'] = $davPath.substr($row['path'], \strlen($basePath));
$rpath = substr($row['path'], \strlen($basePath));
$row['filename'] = Exif::removeExtraSlash($davPath.$rpath);
}
unset($row['path']);
}
// All transform processing
$this->processFace($row);
$this->processPeopleRecognizeDetection($row);
$this->processFaceRecognitionDetection($row);
// We don't need these fields
unset($row['datetaken'], $row['rootid']);
}
return $day;

View File

@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
namespace OCA\Memories\Db;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\Folder;
use OCP\IDBConnection;
trait TimelineQueryPeopleFaceRecognition
{
protected IDBConnection $connection;
public function transformPeopleFaceRecognitionFilter(IQueryBuilder &$query, string $userId, int $currentModel, string $personStr)
{
// Get title and uid of face user
$personNames = explode('/', $personStr);
if (2 !== \count($personNames)) {
throw new \Exception('Invalid person query');
}
$personUid = $personNames[0];
$personName = $personNames[1];
// Join with images
$query->innerJoin('m', 'facerecog_images', 'fri', $query->expr()->andX(
$query->expr()->eq('fri.file', 'm.fileid'),
$query->expr()->eq('fri.model', $query->createNamedParameter($currentModel)),
));
// Join with faces
$query->innerJoin(
'fri',
'facerecog_faces',
'frf',
$query->expr()->eq('frf.image', 'fri.id')
);
// Join with persons
$nameField = is_numeric($personName) ? 'frp.id' : 'frp.name';
$query->innerJoin('frf', 'facerecog_persons', 'frp', $query->expr()->andX(
$query->expr()->eq('frf.person', 'frp.id'),
$query->expr()->eq('frp.user', $query->createNamedParameter($personUid)),
$query->expr()->eq($nameField, $query->createNamedParameter($personName)),
));
}
public function transformPeopleFaceRecognitionRect(IQueryBuilder &$query, string $userId)
{
// Include detection params in response
$query->addSelect(
'frf.x AS face_x',
'frf.y AS face_y',
'frf.width AS face_width',
'frf.height AS face_height',
'm.w AS image_width',
'm.h AS image_height',
);
}
public function getPeopleFaceRecognition(TimelineRoot &$root, int $currentModel, bool $show_clusters = false, bool $show_singles = false, bool $show_hidden = false)
{
$query = $this->connection->getQueryBuilder();
// SELECT all face clusters
$count = $query->func()->count($query->createFunction('DISTINCT m.fileid'), 'count');
$query->select('frp.id', 'frp.user as user_id', 'frp.name', $count)->from('facerecog_persons', 'frp');
// WHERE there are faces with this cluster
$query->innerJoin('frp', 'facerecog_faces', 'frf', $query->expr()->eq('frp.id', 'frf.person'));
// WHERE faces are from images.
$query->innerJoin('frf', 'facerecog_images', 'fri', $query->expr()->eq('fri.id', 'frf.image'));
// WHERE these items are memories indexed photos
$query->innerJoin('fri', 'memories', 'm', $query->expr()->andX(
$query->expr()->eq('fri.file', 'm.fileid'),
$query->expr()->eq('fri.model', $query->createNamedParameter($currentModel)),
));
// WHERE these photos are in the user's requested folder recursively
$query = $this->joinFilecache($query, $root, true, false);
if ($show_clusters) {
// GROUP by ID of face cluster
$query->groupBy('frp.id');
$query->where($query->expr()->isNull('frp.name'));
} else {
// GROUP by name of face clusters
$query->groupBy('frp.name');
$query->where($query->expr()->isNotNull('frp.name'));
}
// By default hides individual faces when they have no name.
if ($show_clusters && !$show_singles) {
$query->having($query->expr()->gt('count', $query->createNamedParameter(1)));
}
// By default it shows the people who were not hidden
if (!$show_hidden) {
$query->andWhere($query->expr()->eq('frp.is_visible', $query->createNamedParameter(true)));
}
// ORDER by number of faces in cluster
$query->orderBy('count', 'DESC');
$query->addOrderBy('name', 'ASC');
$query->addOrderBy('frp.id'); // tie-breaker
// FETCH all faces
$cursor = $this->executeQueryWithCTEs($query);
$faces = $cursor->fetchAll();
// Post process
foreach ($faces as &$row) {
$row['id'] = $row['name'] ?: (int) $row['id'];
$row['count'] = (int) $row['count'];
}
return $faces;
}
public function getFaceRecognitionPreview(TimelineRoot &$root, $currentModel, $previewId)
{
$query = $this->connection->getQueryBuilder();
// SELECT face detections
$query->select(
'fri.file as file_id', // Get actual file
'frf.x', // Image cropping
'frf.y',
'frf.width',
'frf.height',
'm.w as image_width', // Scoring
'm.h as image_height',
'frf.confidence',
'm.fileid',
'm.datetaken', // Just in case, for postgres
)->from('facerecog_faces', 'frf');
// WHERE faces are from images and current model.
$query->innerJoin('frf', 'facerecog_images', 'fri', $query->expr()->andX(
$query->expr()->eq('fri.id', 'frf.image'),
$query->expr()->eq('fri.model', $query->createNamedParameter($currentModel)),
));
// WHERE these photos are memories indexed
$query->innerJoin('fri', 'memories', 'm', $query->expr()->eq('m.fileid', 'fri.file'));
$query->innerJoin('frf', 'facerecog_persons', 'frp', $query->expr()->eq('frp.id', 'frf.person'));
if (is_numeric($previewId)) {
// WHERE faces are from id persons (a cluster).
$query->where($query->expr()->eq('frp.id', $query->createNamedParameter($previewId)));
} else {
// WHERE faces are from name on persons.
$query->where($query->expr()->eq('frp.name', $query->createNamedParameter($previewId)));
}
// WHERE these photos are in the user's requested folder recursively
$query = $this->joinFilecache($query, $root, true, false);
// LIMIT results
$query->setMaxResults(15);
// Sort by date taken so we get recent photos
$query->orderBy('m.datetaken', 'DESC');
$query->addOrderBy('m.fileid', 'DESC'); // tie-breaker
// FETCH face detections
$cursor = $this->executeQueryWithCTEs($query);
$previews = $cursor->fetchAll();
if (empty($previews)) {
return null;
}
// Score the face detections
foreach ($previews as &$p) {
// Get actual pixel size of face
$iw = min((int) ($p['image_width'] ?: 512), 2048);
$ih = min((int) ($p['image_height'] ?: 512), 2048);
// Get percentage position and size
$p['x'] = (float) $p['x'] / $p['image_width'];
$p['y'] = (float) $p['y'] / $p['image_height'];
$p['width'] = (float) $p['width'] / $p['image_width'];
$p['height'] = (float) $p['height'] / $p['image_height'];
$w = (float) $p['width'];
$h = (float) $p['height'];
// Get center of face
$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(-($x - 0.5) ** 2 * 4) * exp(-($y - 0.5) ** 2 * 4);
// Root size distribution - if the image is bigger, it's better,
// but it doesn't matter beyond a certain point
$imgSizeScore = ($iw * 100) ** (1 / 2) * ($ih * 100) ** (1 / 2);
// Faces occupying too much of the image don't look particularly good
$faceSizeScore = (-$w ** 2 + $w) * (-$h ** 2 + $h);
// Combine scores
$p['score'] = $positionScore * $imgSizeScore * $faceSizeScore * $p['confidence'];
}
// Sort previews by score descending
usort($previews, function ($a, $b) {
return $b['score'] <=> $a['score'];
});
return $previews;
}
/** Convert face fields to object */
private function processFaceRecognitionDetection(&$row, $days = false)
{
if (!isset($row)) {
return;
}
// Differentiate Recognize queries from Face Recognition
if (!isset($row['face_width']) || !isset($row['image_width'])) {
return;
}
if (!$days) {
$row['facerect'] = [
// Get percentage position and size
'w' => (float) $row['face_width'] / $row['image_width'],
'h' => (float) $row['face_height'] / $row['image_height'],
'x' => (float) $row['face_x'] / $row['image_width'],
'y' => (float) $row['face_y'] / $row['image_height'],
];
}
unset($row['face_x'], $row['face_y'], $row['face_w'], $row['face_h'], $row['image_height'], $row['image_width']);
}
}

View File

@ -5,16 +5,15 @@ declare(strict_types=1);
namespace OCA\Memories\Db;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\Folder;
use OCP\IDBConnection;
trait TimelineQueryFaces
trait TimelineQueryPeopleRecognize
{
protected IDBConnection $connection;
public function transformFaceFilter(IQueryBuilder &$query, string $userId, string $faceStr)
public function transformPeopleRecognitionFilter(IQueryBuilder &$query, string $userId, string $faceStr)
{
// Get title and uid of face user
// Get name and uid of face user
$faceNames = explode('/', $faceStr);
if (2 !== \count($faceNames)) {
throw new \Exception('Invalid face query');
@ -36,7 +35,7 @@ trait TimelineQueryFaces
));
}
public function transformFaceRect(IQueryBuilder &$query, string $userId)
public function transformPeopleRecognizeRect(IQueryBuilder &$query, string $userId)
{
// Include detection params in response
$query->addSelect(
@ -47,7 +46,7 @@ trait TimelineQueryFaces
);
}
public function getFaces(TimelineRoot &$root)
public function getPeopleRecognize(TimelineRoot &$root)
{
$query = $this->connection->getQueryBuilder();
@ -87,7 +86,7 @@ trait TimelineQueryFaces
return $faces;
}
public function getFacePreviewDetection(TimelineRoot &$root, int $id)
public function getPeopleRecognizePreview(TimelineRoot &$root, int $id)
{
$query = $this->connection->getQueryBuilder();
@ -160,8 +159,9 @@ trait TimelineQueryFaces
}
/** Convert face fields to object */
private function processFace(&$row, $days = false)
private function processPeopleRecognizeDetection(&$row, $days = false)
{
// Differentiate Recognize queries from Face Recognition
if (!isset($row) || !isset($row['face_w'])) {
return;
}

View File

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace OCA\Memories\Db;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\Folder;
use OCP\IDBConnection;
trait TimelineQueryTags
@ -62,7 +61,7 @@ trait TimelineQueryTags
// GROUP and ORDER by tag name
$query->groupBy('st.id');
$query->orderBy('st.name', 'ASC');
$query->orderBy($query->createFunction('LOWER(st.name)'), 'ASC');
$query->addOrderBy('st.id'); // tie-breaker
// FETCH all tags
@ -101,8 +100,8 @@ trait TimelineQueryTags
// WHERE these photos are in the user's requested folder recursively
$query = $this->joinFilecache($query, $root, true, false);
// MAX 4
$query->setMaxResults(4);
// MAX 8
$query->setMaxResults(8);
// FETCH tag previews
$cursor = $this->executeQueryWithCTEs($query);

View File

@ -17,10 +17,10 @@ class TimelineWrite
protected IPreview $preview;
protected LivePhoto $livePhoto;
public function __construct(IDBConnection $connection, IPreview &$preview)
public function __construct(IDBConnection $connection)
{
$this->connection = $connection;
$this->preview = $preview;
$this->preview = \OC::$server->get(IPreview::class);
$this->livePhoto = new LivePhoto($connection);
}

View File

@ -11,6 +11,7 @@ use OCP\IConfig;
class Exif
{
private const EXIFTOOL_VER = '12.49';
private const EXIFTOOL_TIMEOUT = 30000;
/** Opened instance of exiftool when running in command mode */
private static $staticProc;
@ -132,8 +133,7 @@ class Exif
/**
* Parse date from exif format and throw error if invalid.
*
* @param string $dt
* @param mixed $date
* @param mixed $date
*
* @return int unix timestamp
*/
@ -159,10 +159,8 @@ class Exif
/**
* Forget the timezone for an epoch timestamp and get the same
* time epoch for UTC.
*
* @param int $epoch
*/
public static function forgetTimezone($epoch)
public static function forgetTimezone(int $epoch)
{
$dt = new \DateTime();
$dt->setTimestamp($epoch);
@ -222,7 +220,7 @@ class Exif
return [$height, $width];
}
if ($width <= 0 || $height <= 0 || $width > 10000 || $height > 10000) {
if ($width <= 0 || $height <= 0 || $width > 100000 || $height > 100000) {
return [0, 0];
}
@ -251,7 +249,7 @@ class Exif
fwrite($pipes[0], $raw);
fclose($pipes[0]);
$stdout = self::readOrTimeout($pipes[1], 30000);
$stdout = self::readOrTimeout($pipes[1], self::EXIFTOOL_TIMEOUT);
fclose($pipes[1]);
fclose($pipes[2]);
proc_terminate($proc);
@ -272,7 +270,7 @@ class Exif
stream_set_blocking($pipes[1], false);
try {
return self::readOrTimeout($pipes[1], 5000);
return self::readOrTimeout($pipes[1], self::EXIFTOOL_TIMEOUT);
} catch (\Exception $ex) {
error_log("Exiftool timeout: [{$path}]");
@ -288,7 +286,7 @@ class Exif
private static function getExiftool()
{
$configKey = 'memories.exiftool';
$config = \OC::$server->getConfig();
$config = \OC::$server->get(IConfig::class);
$configPath = $config->getSystemValue($configKey);
$noLocal = $config->getSystemValue($configKey.'_no_local', false);
@ -365,7 +363,7 @@ class Exif
* @param int $timeout milliseconds
* @param string $delimiter null for eof
*/
private static function readOrTimeout($handle, $timeout, $delimiter = null)
private static function readOrTimeout($handle, int $timeout, ?string $delimiter = null)
{
$buf = '';
$waitedMs = 0;
@ -396,7 +394,7 @@ class Exif
$readyToken = "\n{ready}\n";
try {
$buf = self::readOrTimeout(self::$staticPipes[1], 5000, $readyToken);
$buf = self::readOrTimeout(self::$staticPipes[1], self::EXIFTOOL_TIMEOUT, $readyToken);
$tokPos = strrpos($buf, $readyToken);
$buf = substr($buf, 0, $tokPos);
@ -419,7 +417,7 @@ class Exif
stream_set_blocking($pipes[1], false);
try {
$stdout = self::readOrTimeout($pipes[1], 5000);
$stdout = self::readOrTimeout($pipes[1], self::EXIFTOOL_TIMEOUT);
return self::processStdout($stdout);
} catch (\Exception $ex) {

View File

@ -27,15 +27,14 @@ use OCP\EventDispatcher\IEventListener;
use OCP\Files\Events\Node\NodeDeletedEvent;
use OCP\Files\Folder;
use OCP\IDBConnection;
use OCP\IPreview;
class PostDeleteListener implements IEventListener
{
private TimelineWrite $util;
public function __construct(IDBConnection $connection, IPreview $preview)
public function __construct(IDBConnection $connection)
{
$this->util = new TimelineWrite($connection, $preview);
$this->util = new TimelineWrite($connection);
}
public function handle(Event $event): void

View File

@ -28,20 +28,14 @@ use OCP\Files\Events\Node\NodeTouchedEvent;
use OCP\Files\Events\Node\NodeWrittenEvent;
use OCP\Files\Folder;
use OCP\IDBConnection;
use OCP\IPreview;
use OCP\IUserManager;
class PostWriteListener implements IEventListener
{
private TimelineWrite $timelineWrite;
public function __construct(
IDBConnection $connection,
IUserManager $userManager,
IPreview $preview
) {
$this->userManager = $userManager;
$this->timelineWrite = new TimelineWrite($connection, $preview);
public function __construct(IDBConnection $connection)
{
$this->timelineWrite = new TimelineWrite($connection);
}
public function handle(Event $event): void

View File

@ -4,6 +4,9 @@ declare(strict_types=1);
namespace OCA\Memories;
use OCP\App\IAppManager;
use OCP\IConfig;
class Util
{
public static $TAG_DAYID_START = -(1 << 30); // the world surely didn't exist
@ -46,16 +49,14 @@ class Util
/**
* Check if albums are enabled for this user.
*
* @param mixed $appManager
*/
public static function albumsIsEnabled(&$appManager): bool
public static function albumsIsEnabled(IAppManager &$appManager): bool
{
if (!$appManager->isEnabledForUser('photos')) {
return false;
}
$v = $appManager->getAppInfo('photos')['version'];
$v = $appManager->getAppVersion('photos');
return version_compare($v, '1.7.0', '>=');
}
@ -72,26 +73,46 @@ class Util
/**
* Check if recognize is enabled for this user.
*
* @param mixed $appManager
*/
public static function recognizeIsEnabled(&$appManager): bool
public static function recognizeIsEnabled(IAppManager &$appManager): bool
{
if (!$appManager->isEnabledForUser('recognize')) {
return false;
}
$v = $appManager->getAppInfo('recognize')['version'];
$v = $appManager->getAppVersion('recognize');
return version_compare($v, '3.0.0-alpha', '>=');
}
/**
* Check if link sharing is allowed.
*
* @param mixed $config
* Check if Face Recognition is enabled by the user.
*/
public static function isLinkSharingEnabled(&$config): bool
public static function facerecognitionIsEnabled(IConfig &$config, string $userId): bool
{
$e = $config->getUserValue($userId, 'facerecognition', 'enabled', 'false');
return 'true' === $e;
}
/**
* Check if Face Recognition is installed and enabled for this user.
*/
public static function facerecognitionIsInstalled(IAppManager &$appManager): bool
{
if (!$appManager->isEnabledForUser('facerecognition')) {
return false;
}
$v = $appManager->getAppInfo('facerecognition')['version'];
return version_compare($v, '0.9.10-beta.2', '>=');
}
/**
* Check if link sharing is allowed.
*/
public static function isLinkSharingEnabled(IConfig &$config): bool
{
// Check if the shareAPI is enabled
if ('yes' !== $config->getAppValue('core', 'shareapi_enabled', 'yes')) {
@ -109,11 +130,10 @@ class Util
/**
* 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
public static function isEncryptionEnabled(): bool
{
$encryptionManager = \OC::$server->get(\OCP\Encryption\IManager::class);
if ($encryptionManager->isEnabled()) {
// Server-side encryption (OC_DEFAULT_MODULE) is okay, others like e2e are not
return 'OC_DEFAULT_MODULE' !== $encryptionManager->getDefaultEncryptionModuleId();

180
package-lock.json generated
View File

@ -37,6 +37,7 @@
"@nextcloud/webpack-vue-config": "^5.4.0",
"@playwright/test": "^1.28.0",
"@types/url-parse": "^1.4.8",
"@types/video.js": "^7.3.49",
"playwright": "^1.28.0",
"ts-loader": "^9.4.1",
"typescript": "^4.9.3",
@ -1784,18 +1785,21 @@
}
},
"node_modules/@nextcloud/browser-storage": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@nextcloud/browser-storage/-/browser-storage-0.1.1.tgz",
"integrity": "sha512-bWzs/A44rEK8b3CMOFw0ZhsenagrWdsB902LOEwmlMCcFysiFgWiOPbF4/0/ODlOYjvPrO02wf6RigWtb8P+gA==",
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@nextcloud/browser-storage/-/browser-storage-0.2.0.tgz",
"integrity": "sha512-qRetNoCMHzfJyuQ7uvlwUXNwXlm5eSy4h8hI0Oa9HKbej57WGBYxRqsHElFzipSPh7mBUdFnz5clGpzIQx8+HQ==",
"dependencies": {
"core-js": "3.6.1"
"core-js": "3.25.5"
},
"engines": {
"node": "^16.0.0",
"npm": "^7.0.0 || ^8.0.0"
}
},
"node_modules/@nextcloud/browser-storage/node_modules/core-js": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.1.tgz",
"integrity": "sha512-186WjSik2iTGfDjfdCZAxv2ormxtKgemjC3SI6PL31qOA0j5LhTDVjHChccoc7brwLvpvLPiMyRlcO88C4l1QQ==",
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
"version": "3.25.5",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.25.5.tgz",
"integrity": "sha512-nbm6eZSjm+ZuBQxCUPQKQCoUEfFOXjUZ8dTTyikyKaWrTYmAVbykQfwsKE5dBK88u3QCkCrzsx/PPlKfhsvgpw==",
"hasInstallScript": true,
"funding": {
"type": "opencollective",
@ -1965,7 +1969,7 @@
"dependencies": {
"@nextcloud/auth": "^2.0.0",
"@nextcloud/axios": "^2.0.0",
"@nextcloud/browser-storage": "^0.1.1",
"@nextcloud/browser-storage": "^0.2.0",
"@nextcloud/calendar-js": "^3.0.0",
"@nextcloud/capabilities": "^1.0.4",
"@nextcloud/dialogs": "^3.1.4",
@ -1974,11 +1978,12 @@
"@nextcloud/l10n": "^1.6.0",
"@nextcloud/logger": "^2.2.1",
"@nextcloud/router": "^2.0.0",
"@skjnldsv/sanitize-svg": "^1.0.2",
"debounce": "1.2.1",
"emoji-mart-vue-fast": "^11.1.1",
"emoji-mart-vue-fast": "^12.0.1",
"escape-html": "^1.0.3",
"floating-vue": "^1.0.0-beta.18",
"focus-trap": "^7.0.0",
"floating-vue": "^1.0.0-beta.19",
"focus-trap": "^7.1.0",
"hammerjs": "^2.0.8",
"linkify-string": "^3.0.4",
"md5": "^2.3.0",
@ -1991,6 +1996,7 @@
"vue-color": "^2.8.1",
"vue-material-design-icons": "^5.1.2",
"vue-multiselect": "^2.1.6",
"vue-select": "^3.20.0",
"vue2-datepicker": "^3.11.0"
},
"engines": {
@ -2164,6 +2170,18 @@
"styled-components": "^5.1.0"
}
},
"node_modules/@skjnldsv/sanitize-svg": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@skjnldsv/sanitize-svg/-/sanitize-svg-1.0.2.tgz",
"integrity": "sha512-blfdQZ9jr4K9IOhifF0FVhKf9LCFH0L8wWR/vEgdA53q8DGNEbjUGMNo4VU1QugglaoQdFy65O2abODRFflsSg==",
"dependencies": {
"is-svg": "^4.3.2"
},
"engines": {
"node": "^14.0.0",
"npm": "^7.0.0"
}
},
"node_modules/@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
@ -2380,6 +2398,12 @@
"integrity": "sha512-zqqcGKyNWgTLFBxmaexGUKQyWqeG7HjXj20EuQJSJWwXe54BjX0ihIo5cJB9yAQzH8dNugJ9GvkBYMjPXs/PJw==",
"dev": true
},
"node_modules/@types/video.js": {
"version": "7.3.49",
"resolved": "https://registry.npmjs.org/@types/video.js/-/video.js-7.3.49.tgz",
"integrity": "sha512-GtBMH+rm7yyw5DAK7ycQeEd35x/EYoLK/49op+CqDDoNUm9XJEVOfb+EARKKe4TwP5jkaikjWqf5RFjmw8yHoQ==",
"dev": true
},
"node_modules/@types/ws": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz",
@ -4233,13 +4257,12 @@
"peer": true
},
"node_modules/emoji-mart-vue-fast": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/emoji-mart-vue-fast/-/emoji-mart-vue-fast-11.2.0.tgz",
"integrity": "sha512-dEVAJAbQop+efR8Zn4bvPQtSREwsVZccQxEBHdi1GNPO0JC9H6l0FswuCli/TrZXAQr1KS7dGEUhS9A1gURFRA==",
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/emoji-mart-vue-fast/-/emoji-mart-vue-fast-12.0.1.tgz",
"integrity": "sha512-qO8F9aduHwPGEU2U1YobOH3lRXEMvrjej6KdhGMnSoMJ+OFSmNf+pUal/MbrEn0RUy+Uqc7U9sPopA+3ipK4+g==",
"dependencies": {
"@babel/runtime": "^7.18.6",
"core-js": "^3.23.5",
"vue-virtual-scroller": "^1.0.10"
"core-js": "^3.23.5"
},
"peerDependencies": {
"vue": ">2.0.0"
@ -4777,9 +4800,9 @@
}
},
"node_modules/floating-vue": {
"version": "1.0.0-beta.18",
"resolved": "https://registry.npmjs.org/floating-vue/-/floating-vue-1.0.0-beta.18.tgz",
"integrity": "sha512-mRFc78szc1BTbhlCa4okb7wAGPuH/IID+yqJ+yrTMQ038H8WIAsPV/WFgWCaXqe8d1Z12LkMqiHDVorCJy8M2A==",
"version": "1.0.0-beta.19",
"resolved": "https://registry.npmjs.org/floating-vue/-/floating-vue-1.0.0-beta.19.tgz",
"integrity": "sha512-OcM7z5Ua4XAykqolmvPj3l1s+KqUKj6Xz2t66eqjgaWfNBjtuifmxO5+4rRXakIch/Crt8IH+vKdKcR3jOUaoQ==",
"dependencies": {
"@floating-ui/dom": "^0.1.10",
"vue-resize": "^1.0.0"
@ -4801,11 +4824,11 @@
}
},
"node_modules/focus-trap": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.0.0.tgz",
"integrity": "sha512-uT4Bl8TwU+5vVAx/DHil/1eVS54k9unqhK/vGy2KSh7esPmqgC0koAB9J2sJ+vtj8+vmiFyGk2unLkhNLQaxoA==",
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.1.0.tgz",
"integrity": "sha512-CuJvwUBfJCWcU6fc4xr3UwMF5vWnox4isXAixCwrPzCsPKOQjP9T+nTlYT2t+vOmQL8MOQ16eim99XhjQHAuiQ==",
"dependencies": {
"tabbable": "^6.0.0"
"tabbable": "^6.0.1"
}
},
"node_modules/follow-redirects": {
@ -5845,6 +5868,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-svg": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/is-svg/-/is-svg-4.3.2.tgz",
"integrity": "sha512-mM90duy00JGMyjqIVHu9gNTjywdZV+8qNasX8cm/EEYZ53PHDgajvbBwNVvty5dwSAxLUD3p3bdo+7sR/UMrpw==",
"dependencies": {
"fast-xml-parser": "^3.19.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-symbol": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
@ -8711,9 +8748,9 @@
}
},
"node_modules/tabbable": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.0.0.tgz",
"integrity": "sha512-SxhZErfHc3Yozz/HLAl/iPOxuIj8AtUw13NRewVOjFW7vbsqT1f3PuiHrPQbUkRcLNEgAedAv2DnjLtzynJXiw=="
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.0.1.tgz",
"integrity": "sha512-SYJSIgeyXW7EuX1ytdneO5e8jip42oHWg9xl/o3oTYhmXusZVgiA+VlPvjIN+kHii9v90AmzTZEBcsEvuAY+TA=="
},
"node_modules/tapable": {
"version": "2.2.1",
@ -9545,6 +9582,14 @@
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.6.5.tgz",
"integrity": "sha512-VYXZQLtjuvKxxcshuRAwjHnciqZVoXAjTjcqBTz4rKc8qih9g9pI3hbDjmqXaHdgL3v8pV6P8Z335XvHzESxLQ=="
},
"node_modules/vue-select": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/vue-select/-/vue-select-3.20.0.tgz",
"integrity": "sha512-Qau4BzpgAC+/9jM5oTlOrfA81ONdtTFH6PqeSDKvIB56f1F6EbIR8qAotpUxrIiNVppyPFjvVDkyriMfHjWBQA==",
"peerDependencies": {
"vue": "2.x"
}
},
"node_modules/vue-style-loader": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.3.tgz",
@ -11669,17 +11714,17 @@
"requires": {}
},
"@nextcloud/browser-storage": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@nextcloud/browser-storage/-/browser-storage-0.1.1.tgz",
"integrity": "sha512-bWzs/A44rEK8b3CMOFw0ZhsenagrWdsB902LOEwmlMCcFysiFgWiOPbF4/0/ODlOYjvPrO02wf6RigWtb8P+gA==",
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@nextcloud/browser-storage/-/browser-storage-0.2.0.tgz",
"integrity": "sha512-qRetNoCMHzfJyuQ7uvlwUXNwXlm5eSy4h8hI0Oa9HKbej57WGBYxRqsHElFzipSPh7mBUdFnz5clGpzIQx8+HQ==",
"requires": {
"core-js": "3.6.1"
"core-js": "3.25.5"
},
"dependencies": {
"core-js": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.1.tgz",
"integrity": "sha512-186WjSik2iTGfDjfdCZAxv2ormxtKgemjC3SI6PL31qOA0j5LhTDVjHChccoc7brwLvpvLPiMyRlcO88C4l1QQ=="
"version": "3.25.5",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.25.5.tgz",
"integrity": "sha512-nbm6eZSjm+ZuBQxCUPQKQCoUEfFOXjUZ8dTTyikyKaWrTYmAVbykQfwsKE5dBK88u3QCkCrzsx/PPlKfhsvgpw=="
}
}
},
@ -11818,7 +11863,7 @@
"requires": {
"@nextcloud/auth": "^2.0.0",
"@nextcloud/axios": "^2.0.0",
"@nextcloud/browser-storage": "^0.1.1",
"@nextcloud/browser-storage": "^0.2.0",
"@nextcloud/calendar-js": "^3.0.0",
"@nextcloud/capabilities": "^1.0.4",
"@nextcloud/dialogs": "^3.1.4",
@ -11827,11 +11872,12 @@
"@nextcloud/l10n": "^1.6.0",
"@nextcloud/logger": "^2.2.1",
"@nextcloud/router": "^2.0.0",
"@skjnldsv/sanitize-svg": "^1.0.2",
"debounce": "1.2.1",
"emoji-mart-vue-fast": "^11.1.1",
"emoji-mart-vue-fast": "^12.0.1",
"escape-html": "^1.0.3",
"floating-vue": "^1.0.0-beta.18",
"focus-trap": "^7.0.0",
"floating-vue": "^1.0.0-beta.19",
"focus-trap": "^7.1.0",
"hammerjs": "^2.0.8",
"linkify-string": "^3.0.4",
"md5": "^2.3.0",
@ -11844,6 +11890,7 @@
"vue-color": "^2.8.1",
"vue-material-design-icons": "^5.1.2",
"vue-multiselect": "^2.1.6",
"vue-select": "^3.20.0",
"vue2-datepicker": "^3.11.0"
}
},
@ -11946,6 +11993,14 @@
"use-callback-ref": "^1.2.4"
}
},
"@skjnldsv/sanitize-svg": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@skjnldsv/sanitize-svg/-/sanitize-svg-1.0.2.tgz",
"integrity": "sha512-blfdQZ9jr4K9IOhifF0FVhKf9LCFH0L8wWR/vEgdA53q8DGNEbjUGMNo4VU1QugglaoQdFy65O2abODRFflsSg==",
"requires": {
"is-svg": "^4.3.2"
}
},
"@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
@ -12162,6 +12217,12 @@
"integrity": "sha512-zqqcGKyNWgTLFBxmaexGUKQyWqeG7HjXj20EuQJSJWwXe54BjX0ihIo5cJB9yAQzH8dNugJ9GvkBYMjPXs/PJw==",
"dev": true
},
"@types/video.js": {
"version": "7.3.49",
"resolved": "https://registry.npmjs.org/@types/video.js/-/video.js-7.3.49.tgz",
"integrity": "sha512-GtBMH+rm7yyw5DAK7ycQeEd35x/EYoLK/49op+CqDDoNUm9XJEVOfb+EARKKe4TwP5jkaikjWqf5RFjmw8yHoQ==",
"dev": true
},
"@types/ws": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz",
@ -13677,13 +13738,12 @@
}
},
"emoji-mart-vue-fast": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/emoji-mart-vue-fast/-/emoji-mart-vue-fast-11.2.0.tgz",
"integrity": "sha512-dEVAJAbQop+efR8Zn4bvPQtSREwsVZccQxEBHdi1GNPO0JC9H6l0FswuCli/TrZXAQr1KS7dGEUhS9A1gURFRA==",
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/emoji-mart-vue-fast/-/emoji-mart-vue-fast-12.0.1.tgz",
"integrity": "sha512-qO8F9aduHwPGEU2U1YobOH3lRXEMvrjej6KdhGMnSoMJ+OFSmNf+pUal/MbrEn0RUy+Uqc7U9sPopA+3ipK4+g==",
"requires": {
"@babel/runtime": "^7.18.6",
"core-js": "^3.23.5",
"vue-virtual-scroller": "^1.0.10"
"core-js": "^3.23.5"
}
},
"emojis-list": {
@ -14113,9 +14173,9 @@
}
},
"floating-vue": {
"version": "1.0.0-beta.18",
"resolved": "https://registry.npmjs.org/floating-vue/-/floating-vue-1.0.0-beta.18.tgz",
"integrity": "sha512-mRFc78szc1BTbhlCa4okb7wAGPuH/IID+yqJ+yrTMQ038H8WIAsPV/WFgWCaXqe8d1Z12LkMqiHDVorCJy8M2A==",
"version": "1.0.0-beta.19",
"resolved": "https://registry.npmjs.org/floating-vue/-/floating-vue-1.0.0-beta.19.tgz",
"integrity": "sha512-OcM7z5Ua4XAykqolmvPj3l1s+KqUKj6Xz2t66eqjgaWfNBjtuifmxO5+4rRXakIch/Crt8IH+vKdKcR3jOUaoQ==",
"requires": {
"@floating-ui/dom": "^0.1.10",
"vue-resize": "^1.0.0"
@ -14131,11 +14191,11 @@
}
},
"focus-trap": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.0.0.tgz",
"integrity": "sha512-uT4Bl8TwU+5vVAx/DHil/1eVS54k9unqhK/vGy2KSh7esPmqgC0koAB9J2sJ+vtj8+vmiFyGk2unLkhNLQaxoA==",
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.1.0.tgz",
"integrity": "sha512-CuJvwUBfJCWcU6fc4xr3UwMF5vWnox4isXAixCwrPzCsPKOQjP9T+nTlYT2t+vOmQL8MOQ16eim99XhjQHAuiQ==",
"requires": {
"tabbable": "^6.0.0"
"tabbable": "^6.0.1"
}
},
"follow-redirects": {
@ -14908,6 +14968,14 @@
"has-tostringtag": "^1.0.0"
}
},
"is-svg": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/is-svg/-/is-svg-4.3.2.tgz",
"integrity": "sha512-mM90duy00JGMyjqIVHu9gNTjywdZV+8qNasX8cm/EEYZ53PHDgajvbBwNVvty5dwSAxLUD3p3bdo+7sR/UMrpw==",
"requires": {
"fast-xml-parser": "^3.19.0"
}
},
"is-symbol": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
@ -17125,9 +17193,9 @@
"dev": true
},
"tabbable": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.0.0.tgz",
"integrity": "sha512-SxhZErfHc3Yozz/HLAl/iPOxuIj8AtUw13NRewVOjFW7vbsqT1f3PuiHrPQbUkRcLNEgAedAv2DnjLtzynJXiw=="
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.0.1.tgz",
"integrity": "sha512-SYJSIgeyXW7EuX1ytdneO5e8jip42oHWg9xl/o3oTYhmXusZVgiA+VlPvjIN+kHii9v90AmzTZEBcsEvuAY+TA=="
},
"tapable": {
"version": "2.2.1",
@ -17736,6 +17804,12 @@
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.6.5.tgz",
"integrity": "sha512-VYXZQLtjuvKxxcshuRAwjHnciqZVoXAjTjcqBTz4rKc8qih9g9pI3hbDjmqXaHdgL3v8pV6P8Z335XvHzESxLQ=="
},
"vue-select": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/vue-select/-/vue-select-3.20.0.tgz",
"integrity": "sha512-Qau4BzpgAC+/9jM5oTlOrfA81ONdtTFH6PqeSDKvIB56f1F6EbIR8qAotpUxrIiNVppyPFjvVDkyriMfHjWBQA==",
"requires": {}
},
"vue-style-loader": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.3.tgz",

View File

@ -64,6 +64,7 @@
"@nextcloud/webpack-vue-config": "^5.4.0",
"@playwright/test": "^1.28.0",
"@types/url-parse": "^1.4.8",
"@types/video.js": "^7.3.49",
"playwright": "^1.28.0",
"ts-loader": "^9.4.1",
"typescript": "^4.9.3",

View File

@ -4,7 +4,7 @@ od=`pwd`
rm -rf /tmp/memories
mkdir -p /tmp/memories
cp -R appinfo l10n img js lib templates COPYING README.md exiftest* composer* /tmp/memories
cp -R appinfo l10n img js lib templates COPYING README.md CHANGELOG.md exiftest* composer* /tmp/memories
cd /tmp
rm -f memories/appinfo/screencap* memories/js/*.map

View File

@ -2,7 +2,7 @@
set -e
exifver="12.49"
exifver="12.50"
rm -rf exiftool-bin
mkdir -p exiftool-bin
@ -20,7 +20,7 @@ mv "exiftool-$exifver" exiftool
rm -rf *.zip exiftool/t exiftool/html
chmod 755 exiftool/exiftool
govod="0.0.18"
govod="0.0.24"
echo "Getting go-vod $govod"
wget -q "https://github.com/pulsejet/go-vod/releases/download/$govod/go-vod-amd64"
wget -q "https://github.com/pulsejet/go-vod/releases/download/$govod/go-vod-aarch64"

View File

@ -39,13 +39,15 @@
<script lang="ts">
import { Component, Mixins, Watch } from "vue-property-decorator";
import {
NcContent,
NcAppContent,
NcAppNavigation,
NcAppNavigationItem,
NcAppNavigationSettings,
} from "@nextcloud/vue";
import NcContent from "@nextcloud/vue/dist/Components/NcContent";
import NcAppContent from "@nextcloud/vue/dist/Components/NcAppContent";
import NcAppNavigation from "@nextcloud/vue/dist/Components/NcAppNavigation";
const NcAppNavigationItem = () =>
import("@nextcloud/vue/dist/Components/NcAppNavigationItem");
const NcAppNavigationSettings = () =>
import("@nextcloud/vue/dist/Components/NcAppNavigationSettings");
import { generateUrl } from "@nextcloud/router";
import { getCurrentUser } from "@nextcloud/auth";
import { translate as t } from "@nextcloud/l10n";
@ -97,7 +99,7 @@ export default class App extends Mixins(GlobalMixin, UserConfig) {
private metadataComponent!: Metadata;
private readonly navItemsAll = [
private readonly navItemsAll = (self: typeof this) => [
{
name: "timeline",
icon: ImageMultiple,
@ -122,13 +124,19 @@ export default class App extends Mixins(GlobalMixin, UserConfig) {
name: "albums",
icon: AlbumIcon,
title: t("memories", "Albums"),
if: (self: any) => self.showAlbums,
if: self.showAlbums,
},
{
name: "people",
name: "recognize",
icon: PeopleIcon,
title: t("memories", "People"),
if: (self: any) => self.showPeople,
title: self.recognize,
if: self.recognize,
},
{
name: "facerecognition",
icon: PeopleIcon,
title: self.facerecognition,
if: self.facerecognition,
},
{
name: "archive",
@ -144,13 +152,13 @@ export default class App extends Mixins(GlobalMixin, UserConfig) {
name: "tags",
icon: TagsIcon,
title: t("memories", "Tags"),
if: (self: any) => self.config_tagsEnabled,
if: self.config_tagsEnabled,
},
{
name: "maps",
icon: MapIcon,
title: t("memories", "Maps"),
if: (self: any) => self.config_mapsEnabled,
if: self.config_mapsEnabled,
},
];
@ -161,8 +169,28 @@ export default class App extends Mixins(GlobalMixin, UserConfig) {
return Number(version[0]);
}
get showPeople() {
return this.config_recognizeEnabled || getCurrentUser()?.isAdmin;
get recognize() {
if (!this.config_recognizeEnabled) {
return false;
}
if (this.config_facerecognitionInstalled) {
return t("memories", "People (Recognize)");
}
return t("memories", "People");
}
get facerecognition() {
if (!this.config_facerecognitionInstalled) {
return false;
}
if (this.config_recognizeEnabled) {
return t("memories", "People (Face Recognition)");
}
return t("memories", "People");
}
get isFirstStart() {
@ -190,8 +218,8 @@ export default class App extends Mixins(GlobalMixin, UserConfig) {
this.doRouteChecks();
// Populate navigation
this.navItems = this.navItemsAll.filter(
(item) => !item.if || item.if(this)
this.navItems = this.navItemsAll(this).filter(
(item) => typeof item.if === "undefined" || Boolean(item.if)
);
// Store CSS variables modified

View File

@ -48,9 +48,12 @@
<script lang="ts">
import { Component, Mixins } from "vue-property-decorator";
import { NcContent, NcAppContent, NcButton } from "@nextcloud/vue";
import NcContent from "@nextcloud/vue/dist/Components/NcContent";
import NcAppContent from "@nextcloud/vue/dist/Components/NcAppContent";
import NcButton from "@nextcloud/vue/dist/Components/NcButton";
import { getFilePickerBuilder } from "@nextcloud/dialogs";
import { generateUrl } from "@nextcloud/router";
import { getCurrentUser } from "@nextcloud/auth";
import axios from "@nextcloud/axios";
@ -59,6 +62,7 @@ import UserConfig from "../mixins/UserConfig";
import banner from "../assets/banner.svg";
import { IDay } from "../types";
import { API } from "../services/API";
@Component({
components: {
@ -95,7 +99,7 @@ export default class FirstStart extends Mixins(GlobalMixin, UserConfig) {
this.info = "";
const query = new URLSearchParams();
query.set("timelinePath", path);
let url = generateUrl("/apps/memories/api/days?" + query.toString());
let url = API.Q(API.DAYS(), query);
const res = await axios.get<IDay[]>(url);
// Check response
@ -108,11 +112,17 @@ export default class FirstStart extends Mixins(GlobalMixin, UserConfig) {
}
// Count total photos
const total = res.data.reduce((acc, day) => acc + day.count, 0);
this.info = this.t("memories", "Found {total} photos in {path}", {
total,
path,
});
const n = res.data.reduce((acc, day) => acc + day.count, 0);
this.info = this.n(
"memories",
"Found {n} item in {path}",
"Found {n} items in {path}",
n,
{
n,
path,
}
);
this.chosenPath = path;
}

View File

@ -48,8 +48,9 @@
import { Component, Mixins } from "vue-property-decorator";
import GlobalMixin from "../mixins/GlobalMixin";
import { NcActions, NcActionButton } from "@nextcloud/vue";
import { generateUrl } from "@nextcloud/router";
import NcActions from "@nextcloud/vue/dist/Components/NcActions";
import NcActionButton from "@nextcloud/vue/dist/Components/NcActionButton";
import axios from "@nextcloud/axios";
import { subscribe, unsubscribe } from "@nextcloud/event-bus";
import { getCanonicalLocale } from "@nextcloud/l10n";
@ -63,7 +64,9 @@ import EditIcon from "vue-material-design-icons/Pencil.vue";
import CalendarIcon from "vue-material-design-icons/Calendar.vue";
import CameraIrisIcon from "vue-material-design-icons/CameraIris.vue";
import ImageIcon from "vue-material-design-icons/Image.vue";
import InfoIcon from "vue-material-design-icons/InformationOutline.vue";
import LocationIcon from "vue-material-design-icons/MapMarker.vue";
import { API } from "../services/API";
@Component({
components: {
@ -85,10 +88,9 @@ export default class Metadata extends Mixins(GlobalMixin) {
this.exif = {};
this.nominatim = null;
let state = this.state;
const res = await axios.get<any>(
generateUrl("/apps/memories/api/image/info/{id}", { id: fileInfo.id })
);
const state = this.state;
const url = API.IMAGE_INFO(fileInfo.id);
const res = await axios.get<any>(url);
if (state !== this.state) return;
this.baseInfo = res.data;
@ -146,6 +148,17 @@ export default class Metadata extends Mixins(GlobalMixin) {
});
}
const title = this.exif?.["Title"];
const desc = this.exif?.["Description"];
if (title || desc) {
list.push({
title: title || this.t("memories", "No title"),
subtitle: [desc || this.t("memories", "No description")],
icon: InfoIcon,
edit: () => globalThis.editExif(globalThis.currentViewerPhoto),
});
}
if (this.address) {
list.push({
title: this.address,
@ -330,6 +343,7 @@ export default class Metadata extends Mixins(GlobalMixin) {
}
.text {
display: inline-block;
word-break: break-word;
flex: 1;
.subtitle {

View File

@ -9,9 +9,13 @@
scrolling: scrollingTimer,
}"
@mousemove.passive="mousemove"
@touchmove.passive="touchmove"
@mouseleave.passive="mouseleave"
@mousedown.passive="mousedown"
@mouseup.passive="interactend"
@touchmove.prevent="touchmove"
@touchstart.passive="interactstart"
@touchend.passive="interactend"
@touchcancel.passive="interactend"
>
<span
class="cursor st"
@ -23,7 +27,10 @@
<span
class="cursor hv"
:style="{ transform: `translateY(${hoverCursorY}px)` }"
@touchmove.passive="touchmove"
@touchmove.prevent="touchmove"
@touchstart.passive="interactstart"
@touchend.passive="interactend"
@touchcancel.passive="interactend"
>
<div class="text">{{ hoverCursorText }}</div>
<div class="icon"><ScrollIcon :size="22" /></div>
@ -42,13 +49,16 @@
</template>
<script lang="ts">
import { Component, Mixins, Prop, Watch } from "vue-property-decorator";
import { Component, Mixins, Prop } from "vue-property-decorator";
import { IRow, IRowType, ITick } from "../types";
import GlobalMixin from "../mixins/GlobalMixin";
import ScrollIcon from "vue-material-design-icons/UnfoldMoreHorizontal.vue";
import * as utils from "../services/Utils";
// Pixels to snap at
const SNAP_OFFSET = -35;
@Component({
components: {
ScrollIcon,
@ -92,6 +102,10 @@ export default class ScrollerManager extends Mixins(GlobalMixin) {
private reflowRequest = false;
/** Tick adjust timer */
private adjustRequest = false;
/** Scroller is being moved with interaction */
private interacting = false;
/** Track the last requested y position when interacting */
private lastRequestedRecyclerY = 0;
/** Get the visible ticks */
get visibleTicks() {
@ -145,8 +159,8 @@ export default class ScrollerManager extends Mixins(GlobalMixin) {
/** Update cursor position from recycler scroll position */
public updateFromRecyclerScroll() {
// Ignore if not initialized
if (!this.ticks.length) return;
// Ignore if not initialized or moving
if (!this.ticks.length || this.interacting) return;
// Get the scroll position
const scroll = this.recycler?.$el?.scrollTop || 0;
@ -413,6 +427,7 @@ export default class ScrollerManager extends Mixins(GlobalMixin) {
/** Handle mouse leave */
private mouseleave() {
this.interactend();
this.moveHoverCursor(this.cursorY);
}
@ -450,7 +465,7 @@ export default class ScrollerManager extends Mixins(GlobalMixin) {
}
/** Move to given scroller Y */
private moveto(y: number) {
private moveto(y: number, snap: boolean) {
// Move cursor immediately to prevent jank
this.cursorY = y;
this.hoverCursorY = y;
@ -458,21 +473,36 @@ export default class ScrollerManager extends Mixins(GlobalMixin) {
const { top1, top2, y1, y2 } = this.getCoords(y, "topF");
const yfrac = (y - top1) / (top2 - top1);
const ry = y1 + (y2 - y1) * (yfrac || 0);
this.recycler.scrollToPosition(ry);
const targetY = snap ? y1 + SNAP_OFFSET : ry;
if (this.lastRequestedRecyclerY !== targetY) {
this.lastRequestedRecyclerY = targetY;
this.recycler.scrollToPosition(targetY);
}
this.handleScroll();
}
/** Handle mouse click */
private mousedown(event: MouseEvent) {
this.moveto(event.offsetY);
this.interactstart(); // end called on mouseup
this.moveto(event.offsetY, false);
}
/** Handle touch */
private touchmove(event: any) {
const y = event.targetTouches[0].pageY - this.scrollerRect.top;
event.stopPropagation();
this.moveto(y);
let y = event.targetTouches[0].pageY - this.scrollerRect.top;
y = Math.max(0, y - 20); // middle of touch finger
this.moveto(y, true);
}
private interactstart() {
this.interacting = true;
}
private interactend() {
this.interacting = false;
this.recyclerScrolled(); // make sure final position is correct
}
/** Update scroller is being used to scroll recycler */
@ -491,6 +521,7 @@ export default class ScrollerManager extends Mixins(GlobalMixin) {
}
.scroller {
contain: layout style;
overflow-y: clip;
position: absolute;
height: 100%;
@ -507,38 +538,6 @@ export default class ScrollerManager extends Mixins(GlobalMixin) {
opacity: 1;
}
// Hide ticks on mobile unless hovering
@include phone {
// Shift pointer events to hover cursor
pointer-events: none;
.cursor.hv {
pointer-events: all;
}
&:not(.scrolling) {
.cursor.hv {
left: 5px;
border: none;
box-shadow: 0 0 5px -3px #000;
height: 40px;
width: 70px;
border-radius: 20px;
> .text {
display: none;
}
> .icon {
display: block;
}
}
> .tick {
opacity: 0;
}
}
.cursor.st {
display: none;
}
}
> .tick {
pointer-events: none;
position: absolute;
@ -610,5 +609,42 @@ export default class ScrollerManager extends Mixins(GlobalMixin) {
opacity: 1;
}
}
// Hide ticks on mobile unless hovering
@include phone {
// Shift pointer events to hover cursor
pointer-events: none;
.cursor.hv {
pointer-events: all;
}
> .tick {
right: 40px;
}
&:not(.scrolling) {
> .tick {
display: none;
}
}
.cursor.hv {
left: 5px;
border: none;
box-shadow: 0 0 5px -3px #000;
height: 40px;
width: 70px;
border-radius: 20px;
> .text {
display: none;
}
> .icon {
display: block;
}
}
.cursor.st {
display: none;
}
}
}
</style>

View File

@ -37,6 +37,7 @@
<!-- Selection Modals -->
<EditDate ref="editDate" @refresh="refresh" />
<EditExif ref="editExif" @refresh="refresh" />
<FaceMoveModal
ref="faceMoveModal"
@moved="deletePhotos"
@ -52,7 +53,10 @@ import GlobalMixin from "../mixins/GlobalMixin";
import UserConfig from "../mixins/UserConfig";
import { showError } from "@nextcloud/dialogs";
import { NcActions, NcActionButton } from "@nextcloud/vue";
import NcActions from "@nextcloud/vue/dist/Components/NcActions";
import NcActionButton from "@nextcloud/vue/dist/Components/NcActionButton";
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import {
IDay,
@ -68,13 +72,15 @@ import * as dav from "../services/DavRequests";
import * as utils from "../services/Utils";
import EditDate from "./modal/EditDate.vue";
import EditExif from "./modal/EditExif.vue";
import FaceMoveModal from "./modal/FaceMoveModal.vue";
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/TrashCanOutline.vue";
import EditIcon from "vue-material-design-icons/ClockEdit.vue";
import EditFileIcon from "vue-material-design-icons/FileEdit.vue";
import EditClockIcon 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";
import OpenInNewIcon from "vue-material-design-icons/OpenInNew.vue";
@ -90,6 +96,7 @@ type Selection = Map<number, IPhoto>;
NcActions,
NcActionButton,
EditDate,
EditExif,
FaceMoveModal,
AddToAlbumModal,
@ -154,6 +161,7 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
icon: DownloadIcon,
callback: this.downloadSelection.bind(this),
allowPublic: true,
if: () => !this.allowDownload(),
},
{
name: t("memories", "Favorite"),
@ -175,9 +183,15 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
},
{
name: t("memories", "Edit Date/Time"),
icon: EditIcon,
icon: EditClockIcon,
callback: this.editDateSelection.bind(this),
},
{
name: t("memories", "Edit EXIF Data"),
icon: EditFileIcon,
callback: this.editExifSelection.bind(this),
if: () => this.selection.size === 1,
},
{
name: t("memories", "View in folder"),
icon: OpenInNewIcon,
@ -195,22 +209,31 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
name: t("memories", "Move to another person"),
icon: MoveIcon,
callback: this.moveSelectionToPerson.bind(this),
if: () => this.$route.name === "people",
if: () => this.$route.name === "recognize",
},
{
name: t("memories", "Remove from person"),
icon: CloseIcon,
callback: this.removeSelectionFromPerson.bind(this),
if: () => this.$route.name === "people",
if: () => this.$route.name === "recognize",
},
];
// Ugly: globally exposed functions
globalThis.editDate = (photo: IPhoto) => {
const getSel = (photo: IPhoto) => {
const sel = new Map<number, IPhoto>();
sel.set(photo.fileid, photo);
this.editDateSelection(sel);
return sel;
};
globalThis.editDate = (photo: IPhoto) =>
this.editDateSelection(getSel(photo));
globalThis.editExif = (photo: IPhoto) =>
this.editExifSelection(getSel(photo));
}
/** Download is not allowed on some public shares */
private allowDownload(): boolean {
return this.state_noDownload;
}
/** Archive is not allowed only on folder routes */
@ -369,9 +392,15 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
}
if (this.touchAnchor && !this.touchScrollInterval) {
let frameCount = 3;
const fun = () => {
this.recycler.$el.scrollTop += this.touchScrollDelta;
this.touchMoveSelect(this.prevTouch, rowIdx);
if (frameCount++ >= 3) {
this.touchMoveSelect(this.prevTouch, rowIdx);
frameCount = 0;
}
if (this.touchScrollInterval) {
this.touchScrollInterval = window.requestAnimationFrame(fun);
@ -712,6 +741,14 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
(<any>this.$refs.editDate).open(Array.from(selection.values()));
}
/**
* Open the edit date dialog
*/
private async editExifSelection(selection: Selection) {
if (selection.size !== 1) return;
(<any>this.$refs.editExif).open(selection.values().next().value);
}
/**
* Open the files app with the selected file (one)
* Opens a new window.
@ -809,7 +846,7 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
private async removeSelectionFromPerson(selection: Selection) {
// Make sure route is valid
const { user, name } = this.$route.params;
if (this.$route.name !== "people" || !user || !name) {
if (this.$route.name !== "recognize" || !user || !name) {
return;
}
@ -837,7 +874,8 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
/** Open viewer with given photo */
private openViewer(photo: IPhoto) {
this.$router.push({
...this.$route,
path: this.$route.path,
query: this.$route.query,
hash: utils.getViewerHash(photo),
});
}

View File

@ -74,7 +74,8 @@ import GlobalMixin from "../mixins/GlobalMixin";
import UserConfig from "../mixins/UserConfig";
import { getFilePickerBuilder } from "@nextcloud/dialogs";
import { NcCheckboxRadioSwitch } from "@nextcloud/vue";
const NcCheckboxRadioSwitch = () =>
import("@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch");
import MultiPathSelectionModal from "./modal/MultiPathSelectionModal.vue";

View File

@ -10,11 +10,12 @@
<!-- No content found and nothing is loading -->
<NcEmptyContent
title="Nothing to show here"
:description="emptyViewDescription"
v-if="loading === 0 && list.length === 0"
>
<template #icon>
<PeopleIcon v-if="$route.name === 'people'" />
<ArchiveIcon v-else-if="$route.name === 'archive'" />
<PeopleIcon v-if="routeIsPeople" />
<ArchiveIcon v-else-if="routeIsArchive" />
<ImageMultipleIcon v-else />
</template>
</NcEmptyContent>
@ -26,7 +27,7 @@
:class="{ empty: list.length === 0 }"
:items="list"
:emit-update="true"
:buffer="400"
:buffer="800"
:skipHover="true"
key-field="id"
size-field="size"
@ -43,7 +44,7 @@
</div>
<OnThisDay
v-if="$route.name === 'timeline'"
v-if="routeIsBase"
:key="config_timelinePath"
:viewer="$refs.viewer"
@load="scrollerManager.adjust()"
@ -147,8 +148,7 @@ import UserConfig from "../mixins/UserConfig";
import axios from "@nextcloud/axios";
import { showError } from "@nextcloud/dialogs";
import { subscribe, unsubscribe } from "@nextcloud/event-bus";
import { generateUrl } from "@nextcloud/router";
import { NcEmptyContent } from "@nextcloud/vue";
import NcEmptyContent from "@nextcloud/vue/dist/Components/NcEmptyContent";
import { getLayout } from "../services/Layout";
import { IDay, IFolder, IHeadRow, IPhoto, IRow, IRowType } from "../types";
@ -157,7 +157,7 @@ import Photo from "./frame/Photo.vue";
import Tag from "./frame/Tag.vue";
import ScrollerManager from "./ScrollerManager.vue";
import SelectionManager from "./SelectionManager.vue";
import Viewer from "./Viewer.vue";
import Viewer from "./viewer/Viewer.vue";
import OnThisDay from "./top-matter/OnThisDay.vue";
import TopMatter from "./top-matter/TopMatter.vue";
@ -168,6 +168,7 @@ import PeopleIcon from "vue-material-design-icons/AccountMultiple.vue";
import CheckCircle from "vue-material-design-icons/CheckCircle.vue";
import ImageMultipleIcon from "vue-material-design-icons/ImageMultiple.vue";
import ArchiveIcon from "vue-material-design-icons/PackageDown.vue";
import { API } from "../services/API";
const SCROLL_LOAD_DELAY = 100; // Delay in loading data when scrolling
const DESKTOP_ROW_HEIGHT = 200; // Height of row on desktop
@ -308,6 +309,18 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
window.removeEventListener("resize", this.handleResizeWithDelay);
}
get routeIsBase() {
return this.$route.name === "timeline";
}
get routeIsPeople() {
return ["recognize", "facerecognition"].includes(this.$route.name);
}
get routeIsArchive() {
return this.$route.name === "archive";
}
updateLoading(delta: number) {
this.loading += delta;
}
@ -549,7 +562,7 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
}
/** Get query string for API calls */
appendQuery(url: string) {
getQuery() {
const query = new URLSearchParams();
// Favorites
@ -574,12 +587,12 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
// People
if (
this.$route.name === "people" &&
this.routeIsPeople &&
this.$route.params.user &&
this.$route.params.name
) {
query.set(
"face",
this.$route.name, // "recognize" or "facerecognition"
`${this.$route.params.user}/${this.$route.params.name}`
);
@ -602,23 +615,13 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
);
}
// Favorites
if (this.$route.name === "folder-share") {
query.set("folder_share", this.$route.params.token);
}
// Month view
if (this.isMonthView) {
query.set("monthView", "1");
query.set("reverse", "1");
}
// Create query string and append to URL
const queryStr = query.toString();
if (queryStr) {
url += "?" + queryStr;
}
return url;
return query;
}
/** Get view name for dynamic top matter */
@ -628,7 +631,8 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
return this.t("memories", "Your Timeline");
case "favorites":
return this.t("memories", "Favorites");
case "people":
case "recognize":
case "facerecognition":
return this.t("memories", "People");
case "videos":
return this.t("memories", "Videos");
@ -673,10 +677,36 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
return head.name;
}
/* Get a friendly description of empty view */
get emptyViewDescription() {
switch (this.$route.name) {
case "facerecognition":
if (this.config_facerecognitionEnabled)
return this.t(
"memories",
"You will find your friends soon. Please, be patient."
);
else
return this.t(
"memories",
"Face Recognition is disabled. Enable in settings to find your friends."
);
case "timeline":
case "favorites":
case "recognize":
case "videos":
case "albums":
case "archive":
case "thisday":
case "tags":
default:
return "";
}
}
/** Fetch timeline main call */
async fetchDays(noCache = false) {
let params: any = {};
let url = generateUrl(this.appendQuery("/apps/memories/api/days"), params);
const url = API.Q(API.DAYS(), this.getQuery());
const cacheUrl = this.$route.name + url;
// Try cache first
@ -694,8 +724,8 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
data = await dav.getOnThisDayData();
} else if (this.$route.name === "tags" && !this.$route.params.name) {
data = await dav.getTagsData();
} else if (this.$route.name === "people" && !this.$route.params.name) {
data = await dav.getPeopleData();
} else if (this.routeIsPeople && !this.$route.params.name) {
data = await dav.getPeopleData(this.$route.name as any);
} else if (this.$route.name === "albums" && !this.$route.params.name) {
data = await dav.getAlbumsData("3");
} else {
@ -842,9 +872,7 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
/** API url for Day call */
private getDayUrl(dayId: number | string) {
let baseUrl = "/apps/memories/api/days/{dayId}";
const params: any = { dayId };
return generateUrl(this.appendQuery(baseUrl), params);
return API.Q(API.DAY(dayId), this.getQuery());
}
/** Fetch image data for one dayId */
@ -1266,11 +1294,24 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
}
.recycler {
will-change: scroll-position;
contain: strict;
height: 300px;
width: calc(100% + 20px);
transition: opacity 0.2s ease-in-out;
:deep .vue-recycle-scroller__slot {
contain: content;
}
:deep .vue-recycle-scroller__item-wrapper {
contain: strict;
}
:deep .vue-recycle-scroller__item-view {
contain: layout style;
}
&.empty {
opacity: 0;
transition: none;

View File

@ -15,12 +15,14 @@
</div>
<div class="previews fill-block">
<div class="img-outer" v-for="info of previews" :key="info.fileid">
<img
class="fill-block"
:src="getPreviewUrl(info, true, 256)"
@error="$event.target.classList.add('error')"
/>
<div class="preview-container fill-block">
<div class="img-outer" v-for="info of previews" :key="info.fileid">
<img
class="fill-block"
:src="getPreviewUrl(info, true, 256)"
@error="$event.target.classList.add('error')"
/>
</div>
</div>
</div>
</router-link>
@ -185,11 +187,13 @@ export default class Folder extends Mixins(GlobalMixin, UserConfig) {
position: absolute;
padding: 2px;
box-sizing: border-box;
@media (max-width: 768px) {
padding: 1px;
.preview-container {
border-radius: 10px;
overflow: hidden;
}
> .img-outer {
.img-outer {
background-color: var(--color-background-dark);
padding: 0;
margin: 0;

View File

@ -133,7 +133,7 @@ export default class Photo extends Mixins(GlobalMixin) {
get videoUrl() {
if (this.data.liveid) {
return utils.getLivePhotoVideoUrl(this.data);
return utils.getLivePhotoVideoUrl(this.data, true);
}
}

View File

@ -2,12 +2,7 @@
<router-link
draggable="false"
class="tag fill-block"
:class="{
hasPreview: previews.length > 0,
onePreview: previews.length === 1,
hasError: error,
isFace: isFace,
}"
:class="{ face, error }"
:to="target"
@click.native="openTag(data)"
>
@ -20,14 +15,13 @@
</div>
<div class="previews fill-block" ref="previews">
<div class="img-outer" v-for="info of previews" :key="info.fileid">
<div class="img-outer">
<img
draggable="false"
class="fill-block"
:class="{ error: info.flag & c.FLAG_LOAD_FAIL }"
:key="'fpreview-' + info.fileid"
:src="getPreviewUrl(info)"
@error="info.flag |= c.FLAG_LOAD_FAIL"
:class="{ error }"
:src="previewUrl"
@error="data.flag |= c.FLAG_LOAD_FAIL"
/>
</div>
</div>
@ -35,18 +29,16 @@
</template>
<script lang="ts">
import { Component, Prop, Watch, Mixins, Emit } from "vue-property-decorator";
import { IAlbum, IPhoto, ITag } from "../../types";
import { generateUrl } from "@nextcloud/router";
import { getPhotosPreviewUrl, getPreviewUrl } from "../../services/FileUtils";
import { Component, Prop, Mixins, Emit } from "vue-property-decorator";
import { IAlbum, ITag } from "../../types";
import { getPreviewUrl } from "../../services/FileUtils";
import { getCurrentUser } from "@nextcloud/auth";
import { NcCounterBubble } from "@nextcloud/vue";
import axios from "@nextcloud/axios";
import * as utils from "../../services/Utils";
import NcCounterBubble from "@nextcloud/vue/dist/Components/NcCounterBubble";
import GlobalMixin from "../../mixins/GlobalMixin";
import { constants } from "../../services/Utils";
import { API } from "../../services/API";
@Component({
components: {
@ -57,15 +49,6 @@ export default class Tag extends Mixins(GlobalMixin) {
@Prop() data: ITag;
@Prop() noNavigate: boolean;
// Separate property because the one on data isn't reactive
private previews: IPhoto[] = [];
// Error occured fetching thumbs
private error = false;
// Smaller subtitle
private subtitle = "";
/**
* Open tag event
* Unless noNavigate is set, the tag will be opened
@ -73,117 +56,71 @@ export default class Tag extends Mixins(GlobalMixin) {
@Emit("open")
openTag(tag: ITag) {}
mounted() {
this.refreshPreviews();
get previewUrl() {
if (this.face) {
return API.FACE_PREVIEW(this.faceApp, this.face.fileid);
}
if (this.album) {
const mock = { fileid: this.album.last_added_photo, etag: "", flag: 0 };
return getPreviewUrl(mock, true, 512);
}
return API.TAG_PREVIEW(this.data.name);
}
@Watch("data")
dataChanged() {
this.refreshPreviews();
get subtitle() {
if (this.album && this.album.user !== getCurrentUser()?.uid) {
return `(${this.album.user})`;
}
return "";
}
getPreviewUrl(photo: IPhoto) {
if (this.isFace) {
return generateUrl(
"/apps/memories/api/faces/preview/" + this.data.fileid
);
}
if (this.isAlbum) {
return getPhotosPreviewUrl(photo, true, 256);
}
return getPreviewUrl(photo, true, 256);
get face() {
return this.data.flag & constants.c.FLAG_IS_FACE_RECOGNIZE ||
this.data.flag & constants.c.FLAG_IS_FACE_RECOGNITION
? this.data
: null;
}
get isFace() {
return this.data.flag & constants.c.FLAG_IS_FACE;
get faceApp() {
return this.data.flag & constants.c.FLAG_IS_FACE_RECOGNITION
? "facerecognition"
: "recognize";
}
get isAlbum() {
return this.data.flag & constants.c.FLAG_IS_ALBUM;
}
async refreshPreviews() {
// Reset state
this.error = false;
this.subtitle = "";
// Add dummy preview if face
if (this.isFace) {
this.previews = [{ fileid: 0, etag: "", flag: 0 }];
return;
}
// Add preview from last photo if album
if (this.isAlbum) {
const album = this.data as IAlbum;
if (album.last_added_photo > 0) {
this.previews = [{ fileid: album.last_added_photo, etag: "", flag: 0 }];
}
if (album.user !== getCurrentUser()?.uid) {
this.subtitle = `(${album.user})`;
}
return;
}
// Look for previews
if (!this.data.previews) {
try {
const todayDayId = utils.dateToDayId(new Date());
const url = generateUrl(
`/apps/memories/api/tag-previews?tag=${this.data.name}`
);
const cacheUrl = `${url}&today=${todayDayId}`;
const cache = await utils.getCachedData(cacheUrl);
if (cache) {
this.data.previews = cache as any;
} else {
const res = await axios.get(url);
this.data.previews = res.data;
// Cache only if >= 4 previews
if (this.data.previews.length >= 4) {
utils.cacheData(cacheUrl, res.data);
}
}
} catch (e) {
this.error = true;
return;
}
}
// Reset flag
this.data.previews.forEach((p) => (p.flag = 0));
// Get 4 or 1 preview(s)
let data = this.data.previews;
if (data.length < 4) {
data = data.slice(0, 1);
}
this.previews = data;
this.error = this.previews.length === 0;
get album() {
return this.data.flag & constants.c.FLAG_IS_ALBUM
? <IAlbum>this.data
: null;
}
/** Target URL to navigate to */
get target() {
if (this.noNavigate) return {};
if (this.isFace) {
const name = this.data.name || this.data.fileid.toString();
const user = this.data.user_id;
return { name: "people", params: { name, user } };
if (this.face) {
const name = this.face.name || this.face.fileid.toString();
const user = this.face.user_id;
return { name: this.faceApp, params: { name, user } };
}
if (this.isAlbum) {
const user = (<IAlbum>this.data).user;
const name = this.data.name;
if (this.album) {
const user = this.album.user;
const name = this.album.name;
return { name: "albums", params: { user, name } };
}
return { name: "tags", params: { name: this.data.name } };
}
get error() {
return (
Boolean(this.data.flag & this.c.FLAG_LOAD_FAIL) ||
Boolean(this.album && this.album.last_added_photo <= 0)
);
}
}
</script>
@ -220,12 +157,16 @@ img {
display: block;
}
.isFace > & {
.tag.face > & {
top: unset;
bottom: 10%;
transform: unset;
}
.tag.error > & {
color: unset;
}
@media (max-width: 768px) {
font-size: 0.95em;
}
@ -235,7 +176,7 @@ img {
z-index: 100;
position: absolute;
top: 6px;
right: 5px;
right: 6px;
}
.previews {
@ -244,30 +185,18 @@ img {
position: absolute;
padding: 2px;
box-sizing: border-box;
@media (max-width: 768px) {
padding: 1px;
}
.tag:not(.hasPreview) & {
background-color: #444;
background-clip: content-box;
}
> .img-outer {
background-color: var(--color-background-dark);
border-radius: 10px;
padding: 0;
margin: 0;
width: 50%;
height: 50%;
width: 100%;
height: 100%;
overflow: hidden;
display: inline-block;
cursor: pointer;
.tag.onePreview > & {
width: 100%;
height: 100%;
}
> img {
object-fit: cover;
padding: 0;

View File

@ -69,8 +69,15 @@ export default class AddToAlbumModal extends Mixins(GlobalMixin) {
this.added(this.photos.filter((p) => fids.includes(p.fileid)));
}
const n = this.photosDone;
showInfo(
this.t("memories", "{n} photos added to album", { n: this.photosDone })
this.n(
"memories",
"{n} item added to album",
"{n} items added to album",
n,
{ n }
)
);
this.close();
}

View File

@ -1,24 +1,3 @@
<!--
- @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me>
-
- @author Louis Chemineau <louis@chmn.me>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div class="manage-collaborators">
<div class="manage-collaborators__subtitle">
@ -177,14 +156,15 @@ import * as dav from "../../services/DavRequests";
import { showError } from "@nextcloud/dialogs";
import { getCurrentUser } from "@nextcloud/auth";
import { generateOcsUrl, generateUrl } from "@nextcloud/router";
import {
NcButton,
NcListItemIcon,
NcLoadingIcon,
NcPopover,
NcTextField,
NcEmptyContent,
} from "@nextcloud/vue";
import NcButton from "@nextcloud/vue/dist/Components/NcButton";
import NcLoadingIcon from "@nextcloud/vue/dist/Components/NcLoadingIcon";
import NcPopover from "@nextcloud/vue/dist/Components/NcPopover";
import NcEmptyContent from "@nextcloud/vue/dist/Components/NcEmptyContent";
const NcTextField = () => import("@nextcloud/vue/dist/Components/NcTextField");
const NcListItemIcon = () =>
import("@nextcloud/vue/dist/Components/NcListItemIcon");
import { Type } from "@nextcloud/sharing";
type Collaborator = {
@ -372,6 +352,12 @@ export default class AddToAlbumModal extends Mixins(GlobalMixin) {
try {
this.loadingAlbum = true;
this.errorFetchingAlbum = null;
const album = await dav.getAlbum(
getCurrentUser()?.uid.toString(),
this.albumName
);
this.populateCollaborators(album.collaborators);
} catch (error) {
if (error.response?.status === 404) {
this.errorFetchingAlbum = 404;

View File

@ -24,7 +24,8 @@
<script lang="ts">
import { Component, Emit, Mixins, Watch } from "vue-property-decorator";
import { NcButton, NcTextField } from "@nextcloud/vue";
import NcButton from "@nextcloud/vue/dist/Components/NcButton";
const NcTextField = () => import("@nextcloud/vue/dist/Components/NcTextField");
import { showError } from "@nextcloud/dialogs";
import { getCurrentUser } from "@nextcloud/auth";
import Modal from "./Modal.vue";

View File

@ -1,24 +1,3 @@
<!--
- @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me>
-
- @author Louis Chemineau <louis@chmn.me>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<form
v-if="!showCollaboratorView"
@ -122,7 +101,9 @@ import { Component, Emit, Mixins, Prop } from "vue-property-decorator";
import GlobalMixin from "../../mixins/GlobalMixin";
import { getCurrentUser } from "@nextcloud/auth";
import { NcButton, NcLoadingIcon, NcTextField } from "@nextcloud/vue";
import NcButton from "@nextcloud/vue/dist/Components/NcButton";
import NcLoadingIcon from "@nextcloud/vue/dist/Components/NcLoadingIcon";
const NcTextField = () => import("@nextcloud/vue/dist/Components/NcTextField");
import moment from "moment";
import * as dav from "../../services/DavRequests";

View File

@ -1,24 +1,3 @@
<!--
- @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me>
-
- @author Louis Chemineau <louis@chmn.me>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div v-if="!showAlbumCreationForm" class="album-picker">
<NcLoadingIcon v-if="loadingAlbums" class="loading-icon" />
@ -86,11 +65,14 @@ import AlbumForm from "./AlbumForm.vue";
import Plus from "vue-material-design-icons/Plus.vue";
import ImageMultiple from "vue-material-design-icons/ImageMultiple.vue";
import { NcButton, NcListItem, NcLoadingIcon } from "@nextcloud/vue";
import { generateUrl } from "@nextcloud/router";
import { getPhotosPreviewUrl } from "../../services/FileUtils";
import NcButton from "@nextcloud/vue/dist/Components/NcButton";
import NcLoadingIcon from "@nextcloud/vue/dist/Components/NcLoadingIcon";
const NcListItem = () => import("@nextcloud/vue/dist/Components/NcListItem");
import { getPreviewUrl } from "../../services/FileUtils";
import { IAlbum, IPhoto } from "../../types";
import axios from "@nextcloud/axios";
import { API } from "../../services/API";
@Component({
components: {
@ -103,7 +85,7 @@ import axios from "@nextcloud/axios";
},
filters: {
toCoverUrl(fileId: string) {
return getPhotosPreviewUrl(
return getPreviewUrl(
{
fileid: Number(fileId),
} as IPhoto,
@ -136,9 +118,7 @@ export default class AlbumPicker extends Mixins(GlobalMixin) {
async loadAlbums() {
try {
const res = await axios.get<IAlbum[]>(
generateUrl("/apps/memories/api/albums?t=3")
);
const res = await axios.get<IAlbum[]>(API.ALBUM_LIST());
this.albums = res.data;
} catch (e) {
console.error(e);

View File

@ -31,7 +31,9 @@
import { Component, Emit, Mixins } from "vue-property-decorator";
import GlobalMixin from "../../mixins/GlobalMixin";
import { NcButton, NcLoadingIcon } from "@nextcloud/vue";
import NcButton from "@nextcloud/vue/dist/Components/NcButton";
import NcLoadingIcon from "@nextcloud/vue/dist/Components/NcLoadingIcon";
import * as dav from "../../services/DavRequests";
import Modal from "./Modal.vue";

View File

@ -106,16 +106,6 @@
})
}}
</div>
<div class="info-pad warn">
{{
t(
"memories",
"This feature modifies files in your storage to update Exif data."
)
}}
{{ t("memories", "Exercise caution and make sure you have backups.") }}
</div>
</div>
<div v-else>
@ -134,17 +124,16 @@ import { Component, Emit, Mixins } from "vue-property-decorator";
import GlobalMixin from "../../mixins/GlobalMixin";
import { IPhoto } from "../../types";
import { NcButton, NcTextField } from "@nextcloud/vue";
import NcButton from "@nextcloud/vue/dist/Components/NcButton";
const NcTextField = () => import("@nextcloud/vue/dist/Components/NcTextField");
import { showError } from "@nextcloud/dialogs";
import { generateUrl } from "@nextcloud/router";
import { emit } from "@nextcloud/event-bus";
import Modal from "./Modal.vue";
import axios from "@nextcloud/axios";
import * as utils from "../../services/Utils";
import * as dav from "../../services/DavRequests";
const INFO_API_URL = "/apps/memories/api/image/info/{id}";
const EDIT_API_URL = "/apps/memories/api/image/set-exif/{id}";
import { API } from "../../services/API";
@Component({
components: {
@ -187,7 +176,7 @@ export default class EditDate extends Mixins(GlobalMixin) {
const calls = photos.map((p) => async () => {
try {
const res = await axios.get<any>(
generateUrl(INFO_API_URL, { id: p.fileid }) + "?basic=1"
API.Q(API.IMAGE_INFO(p.fileid), "basic=1")
);
if (typeof res.data.datetaken !== "number") {
console.error("Invalid date for", p.fileid);
@ -269,7 +258,7 @@ export default class EditDate extends Mixins(GlobalMixin) {
try {
this.processing = true;
const fileid = this.photos[0].fileid;
await axios.patch<any>(generateUrl(EDIT_API_URL, { id: fileid }), {
await axios.patch<any>(API.IMAGE_SETEXIF(fileid), {
raw: {
DateTimeOriginal: this.getExifFormat(this.getDate()),
},
@ -335,7 +324,7 @@ 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);
await axios.patch<any>(generateUrl(EDIT_API_URL, { id: p.fileid }), {
await axios.patch<any>(API.IMAGE_SETEXIF(p.fileid), {
raw: {
DateTimeOriginal: this.getExifFormat(pDateNew),
},

View File

@ -0,0 +1,172 @@
<template>
<Modal v-if="show" @close="close">
<template #title>
{{ t("memories", "Edit EXIF Data") }}
</template>
<template #buttons>
<NcButton
@click="save"
class="button"
type="error"
v-if="exif"
:disabled="processing"
>
{{ t("memories", "Update Exif") }}
</NcButton>
</template>
<div v-if="exif">
<div class="fields">
<NcTextField
v-for="field of fields"
:key="field.field"
:value.sync="exif[field.field]"
class="field"
:label="field.label"
:label-visible="true"
:placeholder="field.label"
/>
</div>
</div>
</Modal>
</template>
<script lang="ts">
import { Component, Emit, Mixins } from "vue-property-decorator";
import GlobalMixin from "../../mixins/GlobalMixin";
import { IPhoto } from "../../types";
import NcButton from "@nextcloud/vue/dist/Components/NcButton";
const NcTextField = () => import("@nextcloud/vue/dist/Components/NcTextField");
import { showError } from "@nextcloud/dialogs";
import { emit } from "@nextcloud/event-bus";
import axios from "@nextcloud/axios";
import { translate as t } from "@nextcloud/l10n";
import Modal from "./Modal.vue";
import { API } from "../../services/API";
@Component({
components: {
NcButton,
NcTextField,
Modal,
},
})
export default class EditExif extends Mixins(GlobalMixin) {
@Emit("refresh") emitRefresh(val: boolean) {}
private photo = null as IPhoto;
private show = false;
private exif: any = null;
private processing = false;
private fields = [
{
field: "Title",
label: t("memories", "Title"),
},
{
field: "Description",
label: t("memories", "Description"),
},
{
field: "DateTimeOriginal",
label: t("memories", "Date Taken"),
},
{
field: "Label",
label: t("memories", "Label"),
},
{
field: "Make",
label: t("memories", "Camera Make"),
},
{
field: "Model",
label: t("memories", "Camera Model"),
},
{
field: "Lens",
label: t("memories", "Lens"),
},
{
field: "Copyright",
label: t("memories", "Copyright"),
},
];
public async open(photo: IPhoto) {
this.show = true;
const res = await axios.get(API.IMAGE_INFO(photo.fileid));
if (!res.data?.exif) return;
const exif: any = {};
for (const field of this.fields) {
exif[field.field] = res.data.exif[field.field] || "";
}
this.photo = photo;
this.exif = exif;
}
public close() {
this.exif = null;
this.photo = null;
this.show = false;
}
public async saveOne() {
try {
// remove all null values from this.exif
const exif = JSON.parse(JSON.stringify(this.exif));
for (const key in exif) {
if (!exif[key]) {
delete exif[key];
}
}
// Make PATCH request to update date
this.processing = true;
const fileid = this.photo.fileid;
await axios.patch<any>(API.IMAGE_SETEXIF(fileid), {
raw: exif,
});
emit("files:file:updated", { fileid });
this.emitRefresh(true);
this.close();
} catch (e) {
if (e.response?.data?.message) {
showError(e.response.data.message);
} else {
showError(e);
}
} finally {
this.processing = false;
}
}
public async save() {
if (!this.photo) {
return;
}
return await this.saveOne();
}
}
</script>
<style scoped lang="scss">
.fields {
.field {
margin-bottom: 8px;
}
:deep label {
font-size: 0.8em;
padding: 0 !important;
padding-left: 5px !important;
}
}
</style>

View File

@ -18,12 +18,16 @@
<script lang="ts">
import { Component, Emit, Mixins, Watch } from "vue-property-decorator";
import { NcButton, NcTextField } from "@nextcloud/vue";
import NcButton from "@nextcloud/vue/dist/Components/NcButton";
const NcTextField = () => import("@nextcloud/vue/dist/Components/NcTextField");
import { showError } from "@nextcloud/dialogs";
import { getCurrentUser } from "@nextcloud/auth";
import Modal from "./Modal.vue";
import GlobalMixin from "../../mixins/GlobalMixin";
import client from "../../services/DavClient";
import * as dav from "../../services/DavRequests";
@Component({
components: {
@ -71,8 +75,12 @@ export default class FaceDeleteModal extends Mixins(GlobalMixin) {
public async save() {
try {
await client.deleteFile(`/recognize/${this.user}/faces/${this.name}`);
this.$router.push({ name: "people" });
if (this.$route.name === "recognize") {
await client.deleteFile(`/recognize/${this.user}/faces/${this.name}`);
} else {
await dav.setVisibilityPeopleFaceRecognition(this.name, false);
}
this.$router.push({ name: this.$route.name });
this.close();
} catch (error) {
console.log(error);

View File

@ -25,12 +25,16 @@
<script lang="ts">
import { Component, Emit, Mixins, Watch } from "vue-property-decorator";
import { NcButton, NcTextField } from "@nextcloud/vue";
import NcButton from "@nextcloud/vue/dist/Components/NcButton";
const NcTextField = () => import("@nextcloud/vue/dist/Components/NcTextField");
import { showError } from "@nextcloud/dialogs";
import { getCurrentUser } from "@nextcloud/auth";
import Modal from "./Modal.vue";
import GlobalMixin from "../../mixins/GlobalMixin";
import client from "../../services/DavClient";
import * as dav from "../../services/DavRequests";
@Component({
components: {
@ -80,12 +84,16 @@ export default class FaceEditModal extends Mixins(GlobalMixin) {
public async save() {
try {
await client.moveFile(
`/recognize/${this.user}/faces/${this.oldName}`,
`/recognize/${this.user}/faces/${this.name}`
);
if (this.$route.name === "recognize") {
await client.moveFile(
`/recognize/${this.user}/faces/${this.oldName}`,
`/recognize/${this.user}/faces/${this.name}`
);
} else {
await dav.renamePeopleFaceRecognition(this.oldName, this.name);
}
this.$router.push({
name: "people",
name: this.$route.name,
params: { user: this.user, name: this.name },
});
this.close();

View File

@ -44,10 +44,18 @@ export default class FaceMergeModal extends Mixins(GlobalMixin) {
this.name = this.$route.params.name || "";
this.detail = null;
const data = await dav.getPeopleData();
let data = [];
let flags = this.c.FLAG_IS_TAG;
if (this.$route.name === "recognize") {
data = await dav.getPeopleData("recognize");
flags |= this.c.FLAG_IS_FACE_RECOGNIZE;
} else {
data = await dav.getPeopleData("facerecognition");
flags |= this.c.FLAG_IS_FACE_RECOGNITION;
}
let detail = data[0].detail;
detail.forEach((photo: IPhoto) => {
photo.flag = this.c.FLAG_IS_FACE | this.c.FLAG_IS_TAG;
photo.flag = flags;
});
detail = detail.filter((photo: ITag) => {
const pname = photo.name || photo.fileid.toString();

View File

@ -29,7 +29,10 @@
<script lang="ts">
import { Component, Emit, Mixins } from "vue-property-decorator";
import { NcButton, NcTextField } from "@nextcloud/vue";
import NcButton from "@nextcloud/vue/dist/Components/NcButton";
const NcTextField = () => import("@nextcloud/vue/dist/Components/NcTextField");
import { showError } from "@nextcloud/dialogs";
import { getCurrentUser } from "@nextcloud/auth";
import { IFileInfo, ITag } from "../../types";
@ -132,7 +135,7 @@ export default class FaceMergeModal extends Mixins(GlobalMixin) {
// Go to new face
if (failures === 0) {
this.$router.push({
name: "people",
name: "recognize",
params: { user: face.user_id, name: newName },
});
this.close();

View File

@ -20,7 +20,9 @@
import { Component, Emit, Mixins, Prop } from "vue-property-decorator";
import GlobalMixin from "../../mixins/GlobalMixin";
import { NcButton, NcTextField } from "@nextcloud/vue";
import NcButton from "@nextcloud/vue/dist/Components/NcButton";
const NcTextField = () => import("@nextcloud/vue/dist/Components/NcTextField");
import { showError } from "@nextcloud/dialogs";
import { getCurrentUser } from "@nextcloud/auth";
import { IPhoto, ITag } from "../../types";

View File

@ -47,7 +47,7 @@ import { Component, Emit, Mixins } from "vue-property-decorator";
import axios from "@nextcloud/axios";
import { generateOcsUrl, generateUrl } from "@nextcloud/router";
import { NcButton } from "@nextcloud/vue";
import NcButton from "@nextcloud/vue/dist/Components/NcButton";
import * as utils from "../../services/Utils";
import Modal from "./Modal.vue";

View File

@ -22,7 +22,7 @@
<script lang="ts">
import { Component, Emit, Prop, Vue } from "vue-property-decorator";
import { NcModal } from "@nextcloud/vue";
const NcModal = () => import("@nextcloud/vue/dist/Components/NcModal");
import { subscribe, unsubscribe } from "@nextcloud/event-bus";
@Component({

View File

@ -39,7 +39,9 @@ import UserConfig from "../../mixins/UserConfig";
import Modal from "./Modal.vue";
import { getFilePickerBuilder } from "@nextcloud/dialogs";
import { NcActions, NcActionButton, NcButton } from "@nextcloud/vue";
import NcActions from "@nextcloud/vue/dist/Components/NcActions";
import NcActionButton from "@nextcloud/vue/dist/Components/NcActionButton";
import NcButton from "@nextcloud/vue/dist/Components/NcButton";
import CloseIcon from "vue-material-design-icons/Close.vue";

View File

@ -29,6 +29,15 @@
{{ t("memories", "Share album") }}
<template #icon> <ShareIcon :size="20" /> </template>
</NcActionButton>
<NcActionButton
:aria-label="t('memories', 'Download album')"
@click="downloadAlbum()"
close-after-click
v-if="!isAlbumList"
>
{{ t("memories", "Download album") }}
<template #icon> <DownloadIcon :size="20" /> </template>
</NcActionButton>
<NcActionButton
:aria-label="t('memories', 'Edit album details')"
@click="$refs.createModal.open(true)"
@ -61,18 +70,25 @@ import { Component, Mixins, Watch } from "vue-property-decorator";
import GlobalMixin from "../../mixins/GlobalMixin";
import UserConfig from "../../mixins/UserConfig";
import { NcActions, NcActionButton, NcActionCheckbox } from "@nextcloud/vue";
import NcActions from "@nextcloud/vue/dist/Components/NcActions";
import NcActionButton from "@nextcloud/vue/dist/Components/NcActionButton";
import NcActionCheckbox from "@nextcloud/vue/dist/Components/NcActionCheckbox";
import { getCurrentUser } from "@nextcloud/auth";
import axios from "@nextcloud/axios";
import AlbumCreateModal from "../modal/AlbumCreateModal.vue";
import AlbumDeleteModal from "../modal/AlbumDeleteModal.vue";
import AlbumShareModal from "../modal/AlbumShareModal.vue";
import { downloadWithHandle } from "../../services/dav/download";
import BackIcon from "vue-material-design-icons/ArrowLeft.vue";
import DownloadIcon from "vue-material-design-icons/Download.vue";
import EditIcon from "vue-material-design-icons/Pencil.vue";
import DeleteIcon from "vue-material-design-icons/Close.vue";
import PlusIcon from "vue-material-design-icons/Plus.vue";
import ShareIcon from "vue-material-design-icons/ShareVariant.vue";
import { API } from "../../services/API";
@Component({
components: {
@ -85,6 +101,7 @@ import ShareIcon from "vue-material-design-icons/ShareVariant.vue";
AlbumShareModal,
BackIcon,
DownloadIcon,
EditIcon,
DeleteIcon,
PlusIcon,
@ -120,6 +137,15 @@ export default class AlbumTopMatter extends Mixins(GlobalMixin, UserConfig) {
back() {
this.$router.push({ name: "albums" });
}
async downloadAlbum() {
const res = await axios.post(
API.ALBUM_DOWNLOAD(this.$route.params.user, this.$route.params.name)
);
if (res.status === 200 && res.data.handle) {
downloadWithHandle(res.data.handle);
}
}
}
</script>

View File

@ -56,7 +56,10 @@ import { Component, Mixins, Watch } from "vue-property-decorator";
import GlobalMixin from "../../mixins/GlobalMixin";
import UserConfig from "../../mixins/UserConfig";
import { NcActions, NcActionButton, NcActionCheckbox } from "@nextcloud/vue";
import NcActions from "@nextcloud/vue/dist/Components/NcActions";
import NcActionButton from "@nextcloud/vue/dist/Components/NcActionButton";
import NcActionCheckbox from "@nextcloud/vue/dist/Components/NcActionCheckbox";
import FaceEditModal from "../modal/FaceEditModal.vue";
import FaceDeleteModal from "../modal/FaceDeleteModal.vue";
import FaceMergeModal from "../modal/FaceMergeModal.vue";
@ -96,7 +99,7 @@ export default class FaceTopMatter extends Mixins(GlobalMixin, UserConfig) {
}
back() {
this.$router.push({ name: "people" });
this.$router.push({ name: this.$route.name });
}
changeShowFaceRect() {

View File

@ -34,12 +34,14 @@
<script lang="ts">
import { Component, Mixins, Watch } from "vue-property-decorator";
import { TopMatterFolder, TopMatterType } from "../../types";
import {
NcBreadcrumbs,
NcBreadcrumb,
NcActions,
NcActionButton,
} from "@nextcloud/vue";
const NcBreadcrumbs = () =>
import("@nextcloud/vue/dist/Components/NcBreadcrumbs");
const NcBreadcrumb = () =>
import("@nextcloud/vue/dist/Components/NcBreadcrumb");
import NcActions from "@nextcloud/vue/dist/Components/NcActions";
import NcActionButton from "@nextcloud/vue/dist/Components/NcActionButton";
import GlobalMixin from "../../mixins/GlobalMixin";
import FolderShareModal from "../modal/FolderShareModal.vue";

View File

@ -43,7 +43,9 @@
<script lang="ts">
import { Component, Emit, Mixins, Prop } from "vue-property-decorator";
import GlobalMixin from "../../mixins/GlobalMixin";
import { NcActions, NcActionButton } from "@nextcloud/vue";
import NcActions from "@nextcloud/vue/dist/Components/NcActions";
import NcActionButton from "@nextcloud/vue/dist/Components/NcActionButton";
import * as utils from "../../services/Utils";
import * as dav from "../../services/DavRequests";

View File

@ -14,7 +14,9 @@
import { Component, Mixins, Watch } from "vue-property-decorator";
import GlobalMixin from "../../mixins/GlobalMixin";
import { NcActions, NcActionButton } from "@nextcloud/vue";
import NcActions from "@nextcloud/vue/dist/Components/NcActions";
import NcActionButton from "@nextcloud/vue/dist/Components/NcActionButton";
import BackIcon from "vue-material-design-icons/ArrowLeft.vue";
@Component({

View File

@ -47,7 +47,8 @@ export default class TopMatter extends Mixins(GlobalMixin) {
return this.$route.params.name
? TopMatterType.TAG
: TopMatterType.NONE;
case "people":
case "recognize":
case "facerecognition":
return this.$route.params.name
? TopMatterType.FACE
: TopMatterType.NONE;

View File

@ -1,23 +1,39 @@
<template>
<div ref="editor" class="viewer__image-editor" v-bind="themeDataAttr" />
<div
ref="editor"
class="viewer__image-editor"
:class="{ loading: !imageEditor }"
v-bind="themeDataAttr"
/>
</template>
<script lang="ts">
import { Component, Prop, Mixins } from "vue-property-decorator";
import GlobalMixin from "../mixins/GlobalMixin";
import GlobalMixin from "../../mixins/GlobalMixin";
import { basename, dirname, extname, join } from "path";
import { emit } from "@nextcloud/event-bus";
import { showError, showSuccess } from "@nextcloud/dialogs";
import { generateUrl } from "@nextcloud/router";
import axios from "@nextcloud/axios";
import FilerobotImageEditor from "filerobot-image-editor";
import { FilerobotImageEditorConfig } from "react-filerobot-image-editor";
import translations from "./ImageEditorTranslations";
const { TABS, TOOLS } = FilerobotImageEditor as any;
import { API } from "../../services/API";
let TABS, TOOLS: any;
type FilerobotImageEditor = import("filerobot-image-editor").default;
let FilerobotImageEditor: typeof import("filerobot-image-editor").default;
async function loadFilerobot() {
if (!FilerobotImageEditor) {
FilerobotImageEditor = (await import("filerobot-image-editor")).default;
TABS = (<any>FilerobotImageEditor).TABS;
TOOLS = (<any>FilerobotImageEditor).TOOLS;
}
return FilerobotImageEditor;
}
@Component({
components: {},
@ -36,9 +52,7 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
if (["image/png", "image/jpeg", "image/webp"].includes(this.mime)) {
src = this.src;
} else {
src = generateUrl("/apps/memories/api/image/jpeg/{fileid}", {
fileid: this.fileid,
});
src = API.IMAGE_JPEG(this.fileid);
}
return {
@ -131,6 +145,7 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
}
async mounted() {
await loadFilerobot();
this.imageEditor = new FilerobotImageEditor(
<any>this.$refs.editor,
<any>this.config
@ -141,9 +156,7 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
// Get latest exif data
try {
const res = await axios.get(
generateUrl("/apps/memories/api/image/info/{id}?basic=1&current=1", {
id: this.fileid,
})
API.Q(API.IMAGE_INFO(this.fileid), "basic=1&current=1")
);
this.exif = res.data?.current;
@ -227,14 +240,9 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
delete exif.MajorBrand;
// Update exif data
await axios.patch(
generateUrl("/apps/memories/api/image/set-exif/{id}", {
id: fileid,
}),
{
raw: exif,
}
);
await axios.patch(API.IMAGE_SETEXIF(fileid), {
raw: exif,
});
showSuccess(this.t("memories", "Image saved successfully"));
if (fileid !== this.fileid) {
@ -313,6 +321,7 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
left: 0;
width: 100%;
height: 100vh;
background-color: black;
}
</style>

View File

@ -1,5 +1,5 @@
import PhotoSwipe from "photoswipe";
import * as utils from "../services/Utils";
import * as utils from "../../services/Utils";
function isLiveContent(content): boolean {
return Boolean(content?.data?.photo?.liveid);
@ -31,7 +31,7 @@ class LivePhotoContentSetup {
video.autoplay = false;
video.playsInline = true;
video.preload = "none";
video.src = utils.getLivePhotoVideoUrl(photo);
video.src = utils.getLivePhotoVideoUrl(photo, true);
const div = document.createElement("div");
div.className = "memories-livephoto";
@ -41,6 +41,7 @@ class LivePhotoContentSetup {
utils.setupLivePhotoHooks(video);
const img = document.createElement("img");
img.classList.add("pswp__img");
img.src = content.data.src;
img.onload = () => content.onLoaded();
div.appendChild(img);

View File

@ -1,18 +1,12 @@
import PhotoSwipe from "photoswipe";
import { generateUrl } from "@nextcloud/router";
import { loadState } from "@nextcloud/initial-state";
import axios from "@nextcloud/axios";
import { showError } from "@nextcloud/dialogs";
import { translate as t } from "@nextcloud/l10n";
import { getCurrentUser } from "@nextcloud/auth";
import Plyr from "plyr";
import "plyr/dist/plyr.css";
import plyrsvg from "../assets/plyr.svg";
import videojs from "video.js";
import "video.js/dist/video-js.min.css";
import "videojs-contrib-quality-levels";
import { API } from "../../services/API";
import { IPhoto } from "../../types";
const config_noTranscode = loadState(
"memories",
@ -20,10 +14,6 @@ const config_noTranscode = loadState(
<string>"UNSET"
) as boolean | string;
// Generate client id for this instance
// Does not need to be cryptographically secure
const clientId = Math.random().toString(36).substring(2, 15).padEnd(12, "0");
/**
* Check if slide has video content
*
@ -111,26 +101,43 @@ class VideoContentSetup {
pswp.on("close", () => {
if (isVideoContent(pswp.currSlide.content)) {
// Switch from zoom to fade closing transition,
// as zoom transition is choppy for videos
if (
!pswp.options.showHideAnimationType ||
pswp.options.showHideAnimationType === "zoom"
) {
pswp.options.showHideAnimationType = "fade";
}
// prevent more requests
this.destroyVideo(pswp.currSlide.content);
}
});
// Prevent closing when video fullscreen is active
pswp.on("pointerMove", (e) => {
const plyr: Plyr = (<any>pswp.currSlide.content)?.plyr;
if (plyr?.fullscreen.active) {
e.preventDefault();
}
});
}
initVideo(content: any) {
getHLSsrc(content: any) {
// Get base URL
const fileid = content.data.photo.fileid;
return {
src: API.VIDEO_TRANSCODE(fileid),
type: "application/x-mpegURL",
};
}
async initVideo(content: any) {
if (!isVideoContent(content) || content.videojs) {
return;
}
// Prevent double loading
content.videojs = {};
// Load videojs scripts
if (!globalThis.vidjs) {
await import("../../services/videojs");
}
// Create video element
content.videoElement = document.createElement("video");
content.videoElement.className = "video-js";
content.videoElement.setAttribute("poster", content.data.msrc);
@ -146,20 +153,11 @@ class VideoContentSetup {
// Add the video element to the actual container
content.element.appendChild(content.videoElement);
// Get file id
const fileid = content.data.photo.fileid;
// Create hls sources if enabled
let sources: any[] = [];
const baseUrl = generateUrl(
`/apps/memories/api/video/transcode/${clientId}/${fileid}`
);
if (!config_noTranscode) {
sources.push({
src: `${baseUrl}/index.m3u8`,
type: "application/x-mpegURL",
});
sources.push(this.getHLSsrc(content));
}
sources.push({
@ -167,8 +165,8 @@ class VideoContentSetup {
type: "video/mp4",
});
const overrideNative = !videojs.browser.IS_SAFARI;
content.videojs = videojs(content.videoElement, {
const overrideNative = !vidjs.browser.IS_SAFARI;
content.videojs = vidjs(content.videoElement, {
fill: true,
autoplay: true,
controls: false,
@ -210,32 +208,36 @@ class VideoContentSetup {
}, 200);
let canPlay = false;
content.videojs.on("loadedmetadata", () => {
content.videojs.on("canplay", () => {
canPlay = true;
this.updateRotation(content); // also gets the correct video elem as a side effect
// Wait (also below) for the transition to end
window.setTimeout(() => this.initPlyr(content), 250);
window.setTimeout(() => this.initPlyr(content), 0);
});
content.videojs.qualityLevels()?.on("addqualitylevel", (e) => {
window.setTimeout(() => this.initPlyr(content), 250);
if (e.qualityLevel?.label?.includes("max.m3u8")) {
// This is the highest quality level
// and guaranteed to be the last one
this.initPlyr(content);
}
// Fallback
window.setTimeout(() => this.initPlyr(content), 0);
});
// Get correct orientation
axios
.get<any>(
generateUrl("/apps/memories/api/image/info/{id}", {
id: content.data.photo.fileid,
})
)
.then((response) => {
content.data.exif = response.data?.exif;
if (!content.data.photo.imageInfo) {
const url = API.IMAGE_INFO(content.data.photo.fileid);
axios.get<any>(url).then((response) => {
content.data.photo.imageInfo = response.data;
// Update only after video is ready
// Otherwise the poster image is rotated
if (canPlay) this.updateRotation(content);
});
} else {
if (canPlay) this.updateRotation(content);
}
}
destroyVideo(content: any) {
@ -265,30 +267,38 @@ class VideoContentSetup {
const origParent = content.videoElement.parentElement;
// Populate quality list
const qualityList = content.videojs?.qualityLevels();
let qualityList = content.videojs?.qualityLevels();
let qualityNums: number[];
if (qualityList && qualityList.length > 1) {
const s = new Set<number>();
for (let i = 0; i < qualityList?.length; i++) {
const { width, height } = qualityList[i];
const { width, height, label } = qualityList[i];
s.add(Math.min(width, height));
if (label?.includes("max.m3u8")) {
s.add(999999999);
}
}
qualityNums = Array.from(s).sort((a, b) => b - a);
qualityNums.unshift(0);
qualityNums.unshift(-1);
}
// Create the plyr instance
const opts: Plyr.Options = {
iconUrl: <any>plyrsvg,
blankVideo: "",
i18n: {
qualityLabel: {
"-1": t("memories", "Direct"),
0: t("memories", "Auto"),
999999999: t("memories", "Original"),
},
},
fullscreen: {
enabled: true,
container: ".pswp__container",
// container: we need to set this after Plyr is loaded
// since we don't initialize Plyr inside the container,
// and this container is computed during construction
// https://github.com/sampotts/plyr/blob/20bf5a883306e9303b325e72c9102d76cc733c47/src/js/fullscreen.js#L30
},
};
@ -298,16 +308,45 @@ class VideoContentSetup {
options: qualityNums,
forced: true,
onChange: (quality: number) => {
qualityList = content.videojs?.qualityLevels();
if (!qualityList || !content.videojs) return;
if (quality === -1) {
// Direct playback
// Prevent any useless transcodes
for (let i = 0; i < qualityList.length; ++i) {
qualityList[i].enabled = false;
}
// Set the source to the original video
if (content.videojs.src().includes("m3u8")) {
content.videojs.src({
src: content.data.src,
type: "video/mp4",
});
}
return;
} else {
// Set source to HLS
if (!content.videojs.src().includes("m3u8")) {
content.videojs.src(this.getHLSsrc(content));
}
}
// Enable only the selected quality
for (let i = 0; i < qualityList.length; ++i) {
const { width, height } = qualityList[i];
const { width, height, label } = qualityList[i];
const pixels = Math.min(width, height);
qualityList[i].enabled = pixels === quality || !quality;
qualityList[i].enabled =
!quality || // auto
pixels === quality || // exact match
(label?.includes("max.m3u8") && quality === 999999999); // max
}
},
};
}
// Initialize Plyr and custom CSS
const plyr = new Plyr(content.videoElement, opts);
plyr.elements.container.style.height = "100%";
plyr.elements.container.style.width = "100%";
@ -320,27 +359,61 @@ class VideoContentSetup {
plyr.elements.container.style.backgroundColor = "transparent";
plyr.elements.wrapper.style.backgroundColor = "transparent";
// Set the fullscreen element to the container
plyr.elements.fullscreen = content.slide.holderElement;
// Done with init
content.plyr = plyr;
// Wait for animation to end before showing Plyr
plyr.elements.container.style.opacity = "0";
setTimeout(() => {
plyr.elements.container.style.opacity = "1";
}, 250);
// Restore original parent of video element
origParent.appendChild(content.videoElement);
// Move plyr to the slide container
content.slide.holderElement.appendChild(plyr.elements.container);
// Add fullscreen orientation hooks
if (screen.orientation?.lock) {
plyr.on("enterfullscreen", (event) => {
const rotation = this.updateRotation(content);
const exif = content.data.photo.imageInfo?.exif;
const h = Number(exif?.ImageHeight || 0);
const w = Number(exif?.ImageWidth || 1);
if (h && w) {
if (h < w && !rotation) {
screen.orientation.lock("landscape");
} else {
screen.orientation.lock("portrait");
}
}
});
plyr.on("exitfullscreen", (event) => {
screen.orientation.unlock();
});
}
}
updateRotation(content, val?: number) {
updateRotation(content: any, val?: number): boolean {
if (!content.videojs) return;
content.videoElement = content.videojs.el()?.querySelector("video");
if (!content.videoElement) return;
const rotation = val ?? Number(content.data.exif?.Rotation);
const photo: IPhoto = content.data.photo;
const exif = photo.imageInfo?.exif;
const rotation = val ?? Number(exif?.Rotation || 0);
const shouldRotate = content.videojs?.src().includes("m3u8");
if (rotation && shouldRotate) {
let transform = `rotate(${rotation}deg)`;
const hasRotation = rotation === 90 || rotation === 270;
if (rotation === 90 || rotation === 270) {
if (hasRotation) {
content.videoElement.style.width = content.element.style.height;
content.videoElement.style.height = content.element.style.width;
@ -349,11 +422,15 @@ class VideoContentSetup {
}
content.videoElement.style.transform = transform;
return hasRotation;
} else {
content.videoElement.style.transform = "none";
content.videoElement.style.width = "100%";
content.videoElement.style.height = "100%";
}
return false;
}
onContentDestroy({ content }) {

View File

@ -2,8 +2,9 @@
<div
class="memories_viewer outer"
v-if="show"
:class="{ fullyOpened }"
:class="{ fullyOpened, slideshowTimer }"
:style="{ width: outerWidth }"
@fullscreenchange="fullscreenChange"
>
<ImageEditor
v-if="editorOpen"
@ -81,6 +82,7 @@
:aria-label="t('memories', 'Download')"
@click="downloadCurrent"
:close-after-click="true"
v-if="!this.state_noDownload"
>
{{ t("memories", "Download") }}
<template #icon>
@ -88,7 +90,7 @@
</template>
</NcActionButton>
<NcActionButton
v-if="currentPhoto?.liveid"
v-if="!this.state_noDownload && currentPhoto?.liveid"
:aria-label="t('memories', 'Download Video')"
@click="downloadCurrentLiveVideo"
:close-after-click="true"
@ -109,33 +111,72 @@
<OpenInNewIcon :size="24" />
</template>
</NcActionButton>
<NcActionButton
:aria-label="t('memories', 'Slideshow')"
@click="startSlideshow"
:close-after-click="true"
>
{{ t("memories", "Slideshow") }}
<template #icon>
<SlideshowIcon :size="24" />
</template>
</NcActionButton>
<NcActionButton
:aria-label="t('memories', 'Edit EXIF Data')"
v-if="!routeIsPublic"
@click="editExif"
:close-after-click="true"
>
{{ t("memories", "Edit EXIF Data") }}
<template #icon>
<EditFileIcon :size="24" />
</template>
</NcActionButton>
</NcActions>
</div>
<div
class="bottom-bar"
v-if="photoswipe"
:class="{ showControls, showBottomBar }"
>
<div class="exif title" v-if="currentPhoto?.imageInfo?.exif?.Title">
{{ currentPhoto.imageInfo.exif.Title }}
</div>
<div
class="exif description"
v-if="currentPhoto?.imageInfo?.exif?.Description"
>
{{ currentPhoto.imageInfo.exif.Description }}
</div>
<div class="exif date" v-if="currentDateTaken">
{{ currentDateTaken }}
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Emit, Mixins } from "vue-property-decorator";
import GlobalMixin from "../mixins/GlobalMixin";
import GlobalMixin from "../../mixins/GlobalMixin";
import { IDay, IPhoto, IRow, IRowType } from "../../types";
import { IDay, IPhoto, IRow, IRowType } from "../types";
import { NcActions, NcActionButton } from "@nextcloud/vue";
import NcActions from "@nextcloud/vue/dist/Components/NcActions";
import NcActionButton from "@nextcloud/vue/dist/Components/NcActionButton";
import axios from "@nextcloud/axios";
import { subscribe, unsubscribe } from "@nextcloud/event-bus";
import { generateUrl } from "@nextcloud/router";
import { showError } from "@nextcloud/dialogs";
import { getPreviewUrl } from "../../services/FileUtils";
import { getDownloadLink } from "../../services/DavRequests";
import { API } from "../../services/API";
import * as dav from "../../services/DavRequests";
import * as utils from "../../services/Utils";
import ImageEditor from "./ImageEditor.vue";
import * as dav from "../services/DavRequests";
import * as utils from "../services/Utils";
import { getPreviewUrl } from "../services/FileUtils";
import { getDownloadLink } from "../services/DavRequests";
import PhotoSwipe, { PhotoSwipeOptions } from "photoswipe";
import "photoswipe/style.css";
import PsVideo from "./PsVideo";
import PsLivePhoto from "./PsLivePhoto";
@ -147,6 +188,10 @@ import DownloadIcon from "vue-material-design-icons/Download.vue";
import InfoIcon from "vue-material-design-icons/InformationOutline.vue";
import OpenInNewIcon from "vue-material-design-icons/OpenInNew.vue";
import TuneIcon from "vue-material-design-icons/Tune.vue";
import SlideshowIcon from "vue-material-design-icons/PlayBox.vue";
import EditFileIcon from "vue-material-design-icons/FileEdit.vue";
const SLIDESHOW_MS = 5000;
@Component({
components: {
@ -161,6 +206,8 @@ import TuneIcon from "vue-material-design-icons/Tune.vue";
InfoIcon,
OpenInNewIcon,
TuneIcon,
SlideshowIcon,
EditFileIcon,
},
})
export default class Viewer extends Mixins(GlobalMixin) {
@ -193,6 +240,9 @@ export default class Viewer extends Mixins(GlobalMixin) {
private globalAnchor = -1;
private currIndex = -1;
/** Timer to move to next photo */
private slideshowTimer = 0;
mounted() {
subscribe("files:sidebar:opened", this.handleAppSidebarOpen);
subscribe("files:sidebar:closed", this.handleAppSidebarClose);
@ -250,6 +300,23 @@ export default class Viewer extends Mixins(GlobalMixin) {
return this.list[idx];
}
/** Is the current slide a video */
get isVideo() {
return this.currentPhoto?.flag & this.c.FLAG_IS_VIDEO;
}
/** Show bottom bar info such as date taken */
get showBottomBar() {
return !this.isVideo && this.fullyOpened && this.currentPhoto?.imageInfo;
}
/** Get date taken string */
get currentDateTaken() {
const date = this.currentPhoto?.imageInfo?.datetaken;
if (!date) return null;
return utils.getLongDateStr(new Date(date * 1000), false, true);
}
/** Get download link for current photo */
get currentDownloadLink() {
return this.currentPhoto
@ -261,6 +328,7 @@ export default class Viewer extends Mixins(GlobalMixin) {
private handleFileUpdated({ fileid }: { fileid: number }) {
if (this.currentPhoto && this.currentPhoto.fileid === fileid) {
this.currentPhoto.etag += "_";
this.currentPhoto.imageInfo = null;
this.photoswipe.refreshSlideContent(this.currIndex);
}
}
@ -305,6 +373,8 @@ export default class Viewer extends Mixins(GlobalMixin) {
bgOpacity: 1,
appendToEl: this.$refs.inner as HTMLElement,
preload: [2, 2],
clickToCloseNonZoomable: false,
bgClickAction: "toggle-controls",
easing: "cubic-bezier(.49,.85,.55,1)",
showHideAnimationType: "zoom",
@ -403,6 +473,8 @@ export default class Viewer extends Mixins(GlobalMixin) {
this.dayIds = [];
this.globalCount = 0;
this.globalAnchor = -1;
clearTimeout(this.slideshowTimer);
this.slideshowTimer = 0;
});
// Update vue route for deep linking
@ -440,6 +512,28 @@ export default class Viewer extends Mixins(GlobalMixin) {
// Live photo support
new PsLivePhoto(this.photoswipe, {});
// Patch the close button to stop the slideshow
const _close = this.photoswipe.close.bind(this.photoswipe);
this.photoswipe.close = () => {
if (this.slideshowTimer) {
this.stopSlideshow();
} else {
_close();
}
};
// Patch the next/prev buttons to reset slideshow timer
const _next = this.photoswipe.next.bind(this.photoswipe);
const _prev = this.photoswipe.prev.bind(this.photoswipe);
this.photoswipe.next = () => {
this.resetSlideshowTimer();
_next();
};
this.photoswipe.prev = () => {
this.resetSlideshowTimer();
_prev();
};
return this.photoswipe;
}
@ -552,8 +646,8 @@ export default class Viewer extends Mixins(GlobalMixin) {
return this.thumbElem(photo) || thumbEl;
});
// Scroll to keep the thumbnail in view
this.photoswipe.on("slideActivate", (e) => {
// Scroll to keep the thumbnail in view
const thumb = this.thumbElem(e.slide.data?.photo);
if (thumb && this.fullyOpened) {
const rect = thumb.getBoundingClientRect();
@ -563,6 +657,12 @@ export default class Viewer extends Mixins(GlobalMixin) {
});
}
}
// Remove active class from others and add to this one
document
.querySelectorAll(".pswp__item")
.forEach((el) => el.classList.remove("active"));
e.slide.holderElement?.classList.add("active");
});
this.photoswipe.init();
@ -594,14 +694,15 @@ export default class Viewer extends Mixins(GlobalMixin) {
/** Get base data object */
private getItemData(photo: IPhoto) {
let previewUrl = getPreviewUrl(photo, false, 1024);
const sw = Math.floor(screen.width * devicePixelRatio);
const sh = Math.floor(screen.height * devicePixelRatio);
let previewUrl = getPreviewUrl(photo, false, [sw, sh]);
const isvideo = photo.flag & this.c.FLAG_IS_VIDEO;
// Preview aren't animated
if (photo.mimetype === "image/gif") {
if (isvideo || photo.mimetype === "image/gif") {
previewUrl = getDownloadLink(photo);
} else if (isvideo) {
previewUrl = generateUrl(getDownloadLink(photo));
}
// Get height and width
@ -615,6 +716,14 @@ export default class Viewer extends Mixins(GlobalMixin) {
h *= 4;
}
// Lazy load the rest of EXIF data
if (!photo.imageInfo) {
axios.get(API.IMAGE_INFO(photo.fileid)).then((res) => {
photo.imageInfo = res.data;
this.$forceUpdate();
});
}
return {
src: previewUrl,
width: w || undefined,
@ -662,7 +771,8 @@ export default class Viewer extends Mixins(GlobalMixin) {
}
const hash = photo ? utils.getViewerHash(photo) : "";
const route = {
...this.$route,
path: this.$route.path,
query: this.$route.query,
hash,
};
if (hash !== this.$route.hash) {
@ -689,11 +799,7 @@ export default class Viewer extends Mixins(GlobalMixin) {
/** Does the browser support native share API */
get canShare() {
return (
"share" in navigator &&
this.currentPhoto &&
!(this.currentPhoto.flag & this.c.FLAG_IS_VIDEO)
);
return "share" in navigator && this.currentPhoto && !this.isVideo;
}
/** Share the current photo externally */
@ -702,8 +808,10 @@ export default class Viewer extends Mixins(GlobalMixin) {
// Check navigator support
if (!this.canShare) throw new Error("Share not supported");
// Get image data from "img.pswp__img"
const img = document.querySelector("img.pswp__img") as HTMLImageElement;
// Get image data from active slide
const img = document.querySelector(
".pswp__item.active img.pswp__img"
) as HTMLImageElement;
if (!img?.src) return;
// Shre image data using navigator api
@ -711,7 +819,7 @@ export default class Viewer extends Mixins(GlobalMixin) {
if (!photo) return;
// No videos yet
if (photo.flag & this.c.FLAG_IS_VIDEO)
if (this.isVideo)
throw new Error(this.t("memories", "Video sharing not supported yet"));
// Get image blob
@ -760,24 +868,42 @@ export default class Viewer extends Mixins(GlobalMixin) {
/** Delete this photo and refresh */
private async deleteCurrent() {
const idx = this.photoswipe.currIndex - this.globalAnchor;
let idx = this.photoswipe.currIndex - this.globalAnchor;
const photo = this.list[idx];
if (!photo) return;
// Delete with WebDAV
try {
this.updateLoading(1);
for await (const p of dav.deletePhotos([this.list[idx]])) {
for await (const p of dav.deletePhotos([photo])) {
if (!p[0]) return;
}
} finally {
this.updateLoading(-1);
}
const spliced = this.list.splice(idx, 1);
// Remove from main view
this.deleted([photo]);
// If this is the only photo, close viewer
if (this.list.length === 1) {
return this.close();
}
// If this is the last photo, move to the previous photo first
// https://github.com/pulsejet/memories/issues/269
if (idx === this.list.length - 1) {
this.photoswipe.prev();
// Some photos might lazy load, so recompute idx for the next element
idx = this.photoswipe.currIndex + 1 - this.globalAnchor;
}
this.list.splice(idx, 1);
this.globalCount--;
for (let i = idx - 3; i <= idx + 3; i++) {
this.photoswipe.refreshSlideContent(i + this.globalAnchor);
}
this.deleted(spliced);
}
/** Is the current photo a favorite */
@ -820,7 +946,7 @@ export default class Viewer extends Mixins(GlobalMixin) {
private async downloadCurrentLiveVideo() {
const photo = this.currentPhoto;
if (!photo) return;
window.location.href = utils.getLivePhotoVideoUrl(photo);
window.location.href = utils.getLivePhotoVideoUrl(photo, false);
}
/** Open the sidebar */
@ -886,6 +1012,96 @@ export default class Viewer extends Mixins(GlobalMixin) {
private async viewInFolder() {
if (this.currentPhoto) dav.viewInFolder(this.currentPhoto);
}
/**
* Start a slideshow
*/
private async startSlideshow() {
// Full screen the pswp element
const pswp = this.photoswipe?.element;
if (!pswp) return;
pswp.requestFullscreen();
// Hide controls
this.setUiVisible(false);
// Start slideshow
this.slideshowTimer = window.setTimeout(
this.slideshowTimerFired,
SLIDESHOW_MS
);
}
/**
* Event of slideshow timer fire
*/
private slideshowTimerFired() {
// Cancel if timer doesn't exist anymore
// This can happen e.g. due to videos
if (!this.slideshowTimer) return;
// If this is a video, wait for it to finish
if (this.isVideo) {
// Get active video element
const video: HTMLVideoElement = this.photoswipe?.element?.querySelector(
".pswp__item.active video"
);
// If no video tag is found by now, something likely went wrong. Just skip ahead.
// Otherwise check if video is not ended yet
if (video && video.currentTime < video.duration - 0.1) {
// Wait for video to finish
video.addEventListener("ended", this.slideshowTimerFired);
return;
}
}
this.photoswipe.next();
// no need to set the timer again, since next
// calls resetSlideshowTimer anyway
}
/**
* Restart the slideshow timer
*/
private resetSlideshowTimer() {
if (this.slideshowTimer) {
window.clearTimeout(this.slideshowTimer);
this.slideshowTimer = window.setTimeout(
this.slideshowTimerFired,
SLIDESHOW_MS
);
}
}
/**
* Stop the slideshow
*/
private stopSlideshow() {
window.clearTimeout(this.slideshowTimer);
this.slideshowTimer = 0;
// exit full screen
if (document.fullscreenElement) {
document.exitFullscreen();
}
}
/**
* Detect change in fullscreen
*/
private fullscreenChange() {
if (!document.fullscreenElement) {
this.stopSlideshow();
}
}
/**
* Edit EXIF data for current photo
*/
private editExif() {
globalThis.editExif(globalThis.currentViewerPhoto);
}
}
</script>
@ -919,12 +1135,41 @@ export default class Viewer extends Mixins(GlobalMixin) {
}
}
.fullyOpened :deep .pswp__container {
@media (min-width: 1024px) {
// Animate transitions
// Disabled because this makes you sick if moving fast
// transition: transform var(--pswp-transition-duration) ease !important;
.bottom-bar {
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.3));
width: 100%;
padding: 10px;
z-index: 100001;
position: fixed;
bottom: 0;
left: 0;
transition: opacity 0.2s ease-in-out;
opacity: 0;
&.showControls.showBottomBar {
opacity: 1;
}
.exif {
&.title {
font-weight: bold;
font-size: 0.9em;
}
&.description {
margin-top: -2px;
margin-bottom: 2px;
font-size: 0.9em;
max-width: 70vw;
word-break: break-word;
line-height: 1.2em;
}
}
}
.fullyOpened.slideshowTimer :deep .pswp__container {
// Animate transitions
// Disabled normally because this makes you sick if moving fast
transition: transform 0.75s ease !important;
}
.inner,
@ -952,10 +1197,12 @@ export default class Viewer extends Mixins(GlobalMixin) {
.pswp__zoom-wrap {
width: 100%;
will-change: transform;
}
img.pswp__img {
object-fit: contain;
will-change: width, height;
}
.pswp__button {
@ -970,6 +1217,12 @@ export default class Viewer extends Mixins(GlobalMixin) {
display: none;
}
// the only popper is the action menu
// it needs to be moved a bit to the left
.v-popper__inner {
transform: translateX(-20px);
}
// Hide arrows on mobile
@media (max-width: 768px) {
.pswp__button--arrow {

View File

@ -1,25 +1,3 @@
/**
* @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/// <reference types="@nextcloud/typings" />
import "reflect-metadata";
@ -29,6 +7,8 @@ import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import App from "./App.vue";
import router from "./router";
import { generateFilePath } from "@nextcloud/router";
import { getRequestToken } from "@nextcloud/auth";
import { IPhoto } from "./types";
// Global exposed variables
@ -42,13 +22,48 @@ declare global {
var windowInnerWidth: number; // cache
var windowInnerHeight: number; // cache
var __webpack_nonce__: string;
var __webpack_public_path__: string;
var vidjs: typeof import("video.js").default;
var Plyr: typeof import("plyr");
var videoClientId: string;
var videoClientIdPersistent: string;
}
// Allow global access to the router
globalThis.vuerouter = router;
// Cache these for better performance
globalThis.windowInnerWidth = window.innerWidth;
globalThis.windowInnerHeight = window.innerHeight;
// CSP config for webpack dynamic chunk loading
__webpack_nonce__ = window.btoa(getRequestToken());
// Correct the root of the app for chunk loading
// OC.linkTo matches the apps folders
// OC.generateUrl ensure the index.php (or not)
// We do not want the index.php since we're loading files
__webpack_public_path__ = generateFilePath("memories", "", "js/");
// Generate client id for this instance
// Does not need to be cryptographically secure
const getClientId = () =>
Math.random().toString(36).substring(2, 15).padEnd(12, "0");
globalThis.videoClientId = getClientId();
globalThis.videoClientIdPersistent = localStorage.getItem(
"videoClientIdPersistent"
);
if (!globalThis.videoClientIdPersistent) {
globalThis.videoClientIdPersistent = getClientId();
localStorage.setItem(
"videoClientIdPersistent",
globalThis.videoClientIdPersistent
);
}
Vue.use(VueVirtualScroller);
// https://github.com/nextcloud/photos/blob/156f280c0476c483cb9ce81769ccb0c1c6500a4e/src/main.js

View File

@ -1,6 +1,7 @@
import { Component, Vue } from "vue-property-decorator";
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import { constants } from "../services/Utils";
import { loadState } from "@nextcloud/initial-state";
@Component
export default class GlobalMixin extends Vue {
@ -10,4 +11,7 @@ export default class GlobalMixin extends Vue {
public readonly c = constants.c;
public readonly TagDayID = constants.TagDayID;
public readonly TagDayIDValueSet = constants.TagDayIDValueSet;
public readonly state_noDownload =
loadState("memories", "no_download", false) !== false;
}

View File

@ -1,30 +1,8 @@
/**
* @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
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { Component, Vue } from "vue-property-decorator";
import { emit, subscribe, unsubscribe } from "@nextcloud/event-bus";
import { generateUrl } from "@nextcloud/router";
import { loadState } from "@nextcloud/initial-state";
import axios from "@nextcloud/axios";
import { API } from "../services/API";
const eventName = "memories:user-config-changed";
const localSettings = ["squareThumbs", "showFaceRect"];
@ -48,6 +26,12 @@ export default class UserConfig extends Vue {
config_recognizeEnabled = Boolean(
loadState("memories", "recognize", <string>"")
);
config_facerecognitionInstalled = Boolean(
loadState("memories", "facerecognitionInstalled", <string>"")
);
config_facerecognitionEnabled = Boolean(
loadState("memories", "facerecognitionEnabled", <string>"")
);
config_mapsEnabled = Boolean(loadState("memories", "maps", <string>""));
config_albumsEnabled = Boolean(loadState("memories", "albums", <string>""));
@ -79,7 +63,7 @@ export default class UserConfig extends Vue {
}
} else {
// Long time save setting
await axios.put(generateUrl("apps/memories/api/config/" + setting), {
await axios.put(API.CONFIG(setting), {
value: value.toString(),
});
}

View File

@ -1,25 +1,3 @@
/**
* @copyright Copyright (c) 2018 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
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { generateUrl } from "@nextcloud/router";
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import Router from "vue-router";
@ -28,17 +6,6 @@ import Timeline from "./components/Timeline.vue";
Vue.use(Router);
/**
* Parse the path of a route : join the elements of the array and return a single string with slashes
* + always lead current path with a slash
*
* @param {string | Array} path path arguments to parse
* @return {string}
*/
const parsePathParams = (path) => {
return `/${Array.isArray(path) ? path.join("/") : path || ""}`;
};
export default new Router({
mode: "history",
// if index.php is in the url AND we got this far, then it's working:
@ -110,9 +77,18 @@ export default new Router({
},
{
path: "/people/:user?/:name?",
path: "/recognize/:user?/:name?",
component: Timeline,
name: "people",
name: "recognize",
props: (route) => ({
rootTitle: t("memories", "People"),
}),
},
{
path: "/facerecognition/:user?/:name?",
component: Timeline,
name: "facerecognition",
props: (route) => ({
rootTitle: t("memories", "People"),
}),

View File

@ -0,0 +1,164 @@
import { registerRoute } from "workbox-routing";
import { CacheExpiration } from "workbox-expiration";
// Queue of requests to fetch preview images
interface FetchPreviewObject {
url: URL;
fileid: number;
reqid: number;
callback: (blob: Response) => void;
}
let fetchPreviewQueue: FetchPreviewObject[] = [];
// Cache for preview images
const cacheName = "images";
let imageCache: Cache;
(async () => {
imageCache = await caches.open(cacheName);
})();
// Expiration for cache
const expirationManager = new CacheExpiration(cacheName, {
maxAgeSeconds: 3600 * 24 * 7, // days
maxEntries: 20000, // 20k images
});
// Start fetching with multipreview
let fetchPreviewTimer: any;
async function flushPreviewQueue() {
if (fetchPreviewQueue.length === 0) return;
fetchPreviewTimer = 0;
const fetchPreviewQueueCopy = fetchPreviewQueue;
fetchPreviewQueue = [];
// Check if only one request
if (fetchPreviewQueueCopy.length === 1) {
const p = fetchPreviewQueueCopy[0];
return p.callback(await fetch(p.url));
}
// Create aggregated request body
const files = fetchPreviewQueueCopy.map((p) => ({
fileid: p.fileid,
x: Number(p.url.searchParams.get("x")),
y: Number(p.url.searchParams.get("y")),
a: p.url.searchParams.get("a"),
reqid: p.reqid,
}));
try {
// infer the url from the first file
const firstUrl = fetchPreviewQueueCopy[0].url;
const url = new URL(firstUrl.toString());
const path = url.pathname.split("/");
const previewIndex = path.indexOf("preview");
url.pathname = path.slice(0, previewIndex).join("/") + "/multipreview";
url.searchParams.delete("x");
url.searchParams.delete("y");
url.searchParams.delete("a");
url.searchParams.delete("c");
// Fetch multipreview
const res = await fetch(url, {
method: "POST",
body: JSON.stringify(files),
});
// Get blob
if (res.status !== 200) throw new Error("Error fetching multi-preview");
const blob = await res.blob();
let idx = 0;
while (idx < blob.size) {
// Read a line of JSON from blob
const line = await blob.slice(idx, idx + 256).text();
const newlineIndex = line?.indexOf("\n");
const jsonParsed = JSON.parse(line?.slice(0, newlineIndex));
const imgLen = jsonParsed["Content-Length"];
const imgType = jsonParsed["Content-Type"];
const reqid = jsonParsed["reqid"];
idx += newlineIndex + 1;
console.debug("multi-preview", jsonParsed);
// Read the image data
const imgBlob = blob.slice(idx, idx + imgLen);
idx += imgLen;
// Initiate callbacks
fetchPreviewQueueCopy
.filter((p) => p.reqid === reqid)
.forEach((p) => {
p.callback(
new Response(imgBlob, {
status: 200,
headers: {
"Content-Type": imgType,
"Content-Length": imgLen,
Expires: res.headers.get("Expires"),
"Cache-Control": res.headers.get("Cache-Control"),
},
})
);
p.callback = null;
});
}
} catch (e) {
console.error("Multipreview error", e);
}
// Initiate callbacks for failed requests
fetchPreviewQueueCopy.forEach((fetchPreviewObject) => {
fetchPreviewObject.callback?.(
new Response("Image not found", {
status: 404,
statusText: "Image not found",
})
);
});
}
// Intercept preview requests
registerRoute(
/^.*\/apps\/memories\/api\/image\/preview\/.*/,
async ({ url, request }) => {
// Check if in cache
const cache = await imageCache?.match(url);
if (cache) return cache;
// Get file id from URL
const fileid = Number(url.pathname.split("/").pop());
// Aggregate requests
let res: Response = await new Promise((callback) => {
fetchPreviewQueue.push({
url,
fileid,
reqid: Math.random(),
callback,
});
if (!fetchPreviewTimer) {
fetchPreviewTimer = setTimeout(flushPreviewQueue, 50);
}
});
// Fallback to single request
if (res.status !== 200) {
res = await fetch(url);
}
// Cache response
if (res.status === 200) {
imageCache?.put(request, res.clone());
expirationManager.updateTimestamp(request.url);
}
// Run expiration once in every 20 requests
if (Math.random() < 0.05) {
expirationManager.expireEntries();
}
return res;
}
);

View File

@ -0,0 +1,46 @@
import { precacheAndRoute } from 'workbox-precaching';
import { NetworkFirst, CacheFirst, NetworkOnly } from 'workbox-strategies';
import { registerRoute } from 'workbox-routing';
import { ExpirationPlugin } from 'workbox-expiration';
precacheAndRoute(self.__WB_MANIFEST);
import './service-worker-custom';
registerRoute(/^.*\/apps\/memories\/api\/video\/transcode\/.*/, new NetworkOnly());
registerRoute(/^.*\/apps\/memories\/api\/image\/jpeg\/.*/, new NetworkOnly());
registerRoute(/^.*\/remote.php\/.*/, new NetworkOnly());
registerRoute(/^.*\/apps\/files\/ajax\/download.php?.*/, new NetworkOnly());
const imageCache = new CacheFirst({
cacheName: 'images',
plugins: [
new ExpirationPlugin({
maxAgeSeconds: 3600 * 24 * 7, // days
maxEntries: 20000, // 20k images
}),
],
});
registerRoute(/^.*\/apps\/memories\/api\/image\/preview\/.*/, imageCache);
registerRoute(/^.*\/apps\/memories\/api\/video\/livephoto\/.*/, imageCache);
registerRoute(/^.*\/apps\/memories\/api\/faces\/preview\/.*/, imageCache);
registerRoute(/^.*\/apps\/memories\/api\/tags\/preview\/.*/, imageCache);
registerRoute(/^.*\/apps\/memories\/api\/.*/, new NetworkOnly());
registerRoute(/^.*\/.*$/, new NetworkFirst({
cacheName: 'pages',
plugins: [
new ExpirationPlugin({
maxAgeSeconds: 3600 * 24 * 7, // days
maxEntries: 2000, // assets
}),
],
}));
self.addEventListener('activate', event => {
// Take control of all pages under this SW's scope immediately,
// instead of waiting for reload/navigation.
event.waitUntil(self.clients.claim());
});

108
src/services/API.ts 100644
View File

@ -0,0 +1,108 @@
import { generateUrl } from "@nextcloud/router";
const BASE = "/apps/memories/api";
const gen = generateUrl;
/** Add auth token to this URL */
function tok(url: string) {
if (vuerouter.currentRoute.name === "folder-share") {
url = API.Q(url, `folder_share=${vuerouter.currentRoute.params.token}`);
}
return url;
}
export class API {
static Q(url: string, query: string | URLSearchParams | undefined | null) {
if (!query) return url;
let queryStr = typeof query === "string" ? query : query.toString();
if (!queryStr) return url;
if (url.indexOf("?") > -1) {
return `${url}&${queryStr}`;
} else {
return `${url}?${queryStr}`;
}
}
static DAYS() {
return tok(gen(`${BASE}/days`));
}
static DAY(id: number | string) {
return tok(gen(`${BASE}/days/{id}`, { id }));
}
static ALBUM_LIST(t: "1" | "2" | "3" = "3") {
return gen(`${BASE}/albums?t=${t}`);
}
static ALBUM_DOWNLOAD(user: string, name: string) {
return gen(`${BASE}/albums/download?name={user}/{name}`, { user, name });
}
static TAG_LIST() {
return gen(`${BASE}/tags`);
}
static TAG_PREVIEW(tag: string) {
return gen(`${BASE}/tags/preview/{tag}`, { tag });
}
static FACE_LIST(app: "recognize" | "facerecognition") {
return gen(`${BASE}/${app}/people`);
}
static FACE_PREVIEW(
app: "recognize" | "facerecognition",
face: string | number
) {
return gen(`${BASE}/${app}/people/preview/{face}`, { face });
}
static ARCHIVE(fileid: number) {
return gen(`${BASE}/archive/{fileid}`, { fileid });
}
static IMAGE_PREVIEW(fileid: number) {
return tok(gen(`${BASE}/image/preview/{fileid}`, { fileid }));
}
static IMAGE_INFO(id: number) {
return tok(gen(`${BASE}/image/info/{id}`, { id }));
}
static IMAGE_SETEXIF(id: number) {
return gen(`${BASE}/image/set-exif/{id}`, { id });
}
static IMAGE_JPEG(id: number) {
return gen(`${BASE}/image/jpeg/{id}`, { id });
}
static VIDEO_TRANSCODE(fileid: number) {
return tok(
gen(`${BASE}/video/transcode/{videoClientId}/{fileid}/index.m3u8`, {
videoClientId,
fileid,
})
);
}
static VIDEO_LIVEPHOTO(fileid: number) {
return tok(gen(`${BASE}/video/livephoto/{fileid}`, { fileid }));
}
static DOWNLOAD_REQUEST() {
return tok(gen(`${BASE}/download`));
}
static DOWNLOAD_FILE(handle: string) {
return tok(gen(`${BASE}/download/{handle}`, { handle }));
}
static CONFIG(setting: string) {
return gen(`${BASE}/config/{setting}`, { setting });
}
}

View File

@ -19,9 +19,9 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { generateUrl } from "@nextcloud/router";
import camelcase from "camelcase";
import { IFileInfo, IPhoto } from "../types";
import { API } from "./API";
import { isNumber } from "./NumberUtils";
/**
@ -138,38 +138,18 @@ const genFileInfo = function (obj) {
const getPreviewUrl = function (
photo: IPhoto | IFileInfo,
square: boolean,
size: number
size: number | [number, number]
) {
const a = square ? "0" : "1";
const [x, y] = typeof size === "number" ? [size, size] : size;
// Public preview
if (vuerouter.currentRoute.name === "folder-share") {
const token = vuerouter.currentRoute.params.token;
return generateUrl(
`/apps/files_sharing/publicpreview/${token}?file=${photo.filename}&fileId=${photo.fileid}&x=${size}&y=${size}&a=${a}`
);
}
// Build query
const query = new URLSearchParams();
query.set("c", photo.etag);
query.set("x", x.toString());
query.set("y", y.toString());
query.set("a", square ? "0" : "1");
// Albums from Photos
if (vuerouter.currentRoute.name === "albums") {
return getPhotosPreviewUrl(photo, square, size);
}
return generateUrl(
`/core/preview?fileId=${photo.fileid}&c=${photo.etag}&x=${size}&y=${size}&forceIcon=0&a=${a}`
);
};
/** Get the preview URL from the photos app */
const getPhotosPreviewUrl = function (
photo: IPhoto | IFileInfo,
square: boolean,
size: number
): string {
const a = square ? "0" : "1";
return generateUrl(
`/apps/photos/api/v1/preview/${photo.fileid}?c=${photo.etag}&x=${size}&y=${size}&forceIcon=0&a=${a}`
);
return API.Q(API.IMAGE_PREVIEW(photo.fileid), query);
};
export {
@ -178,5 +158,4 @@ export {
sortCompare,
genFileInfo,
getPreviewUrl,
getPhotosPreviewUrl,
};

View File

@ -1,9 +1,9 @@
import { getCanonicalLocale } from "@nextcloud/l10n";
import { getCurrentUser } from "@nextcloud/auth";
import { generateUrl } from "@nextcloud/router";
import { loadState } from "@nextcloud/initial-state";
import { IPhoto } from "../types";
import moment from "moment";
import { API } from "./API";
// Memoize the result of short date conversions
// These operations are surprisingly expensive
@ -212,7 +212,12 @@ export function convertFlags(photo: IPhoto) {
delete photo.isfolder;
}
if (photo.isface) {
photo.flag |= constants.c.FLAG_IS_FACE;
const app = photo.isface;
if (app === "recognize") {
photo.flag |= constants.c.FLAG_IS_FACE_RECOGNIZE;
} else if (app === "facerecognition") {
photo.flag |= constants.c.FLAG_IS_FACE_RECOGNITION;
}
delete photo.isface;
}
if (photo.istag) {
@ -240,10 +245,18 @@ export function getFolderRoutePath(basePath: string) {
/**
* Get URL to live photo video part
*/
export function getLivePhotoVideoUrl(p: IPhoto) {
return generateUrl(
`/apps/memories/api/video/livephoto/${p.fileid}?etag=${p.etag}&liveid=${p.liveid}`
);
export function getLivePhotoVideoUrl(p: IPhoto, transcode: boolean) {
// Build query string
const query = new URLSearchParams();
query.set("etag", p.etag);
query.set("liveid", p.liveid);
// Transcode if allowed
if (transcode) {
query.set("transcode", videoClientIdPersistent);
}
return API.Q(API.VIDEO_LIVEPHOTO(p.fileid), query);
}
/**
@ -302,10 +315,11 @@ export const constants = {
FLAG_IS_FAVORITE: 1 << 3,
FLAG_IS_FOLDER: 1 << 4,
FLAG_IS_TAG: 1 << 5,
FLAG_IS_FACE: 1 << 6,
FLAG_IS_ALBUM: 1 << 7,
FLAG_SELECTED: 1 << 8,
FLAG_LEAVING: 1 << 9,
FLAG_IS_FACE_RECOGNIZE: 1 << 6,
FLAG_IS_FACE_RECOGNITION: 1 << 7,
FLAG_IS_ALBUM: 1 << 8,
FLAG_SELECTED: 1 << 9,
FLAG_LEAVING: 1 << 10,
},
TagDayID: TagDayID,

View File

@ -1,12 +1,12 @@
import * as base from "./base";
import { getCurrentUser } from "@nextcloud/auth";
import { generateUrl } from "@nextcloud/router";
import { showError } from "@nextcloud/dialogs";
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import { IAlbum, IDay, IFileInfo, IPhoto, ITag } from "../../types";
import { constants } from "../Utils";
import axios from "@nextcloud/axios";
import client from "../DavClient";
import { API } from "../API";
/**
* Get DAV path for album
@ -28,9 +28,7 @@ export function getAlbumPath(user: string, name: string) {
export async function getAlbumsData(type: "1" | "2" | "3"): Promise<IDay[]> {
let data: IAlbum[] = [];
try {
const res = await axios.get<typeof data>(
generateUrl(`/apps/memories/api/albums?t=${type}`)
);
const res = await axios.get<typeof data>(API.ALBUM_LIST(type));
data = res.data;
} catch (e) {
throw e;

View File

@ -1,8 +1,8 @@
import * as base from "./base";
import { generateUrl } from "@nextcloud/router";
import { showError } from "@nextcloud/dialogs";
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import axios from "@nextcloud/axios";
import { API } from "../API";
/**
* Archive or unarchive a single file
@ -11,10 +11,7 @@ import axios from "@nextcloud/axios";
* @param archive Archive or unarchive
*/
export async function archiveFile(fileid: number, archive: boolean) {
return await axios.patch(
generateUrl("/apps/memories/api/archive/{fileid}", { fileid }),
{ archive }
);
return await axios.patch(API.ARCHIVE(fileid), { archive });
}
/**

View File

@ -204,7 +204,7 @@ export async function* deletePhotos(photos: IPhoto[]) {
photos
.filter((p) => p.liveid && !p.liveid.startsWith("self__"))
.map(async (p) => {
const url = utils.getLivePhotoVideoUrl(p) + "&format=json";
const url = utils.getLivePhotoVideoUrl(p, false) + "&format=json";
try {
const response = await axios.get(url);
const data = response.data;

View File

@ -1,42 +1,32 @@
import * as base from "./base";
import { generateUrl } from "@nextcloud/router";
import axios from "@nextcloud/axios";
import { showError } from "@nextcloud/dialogs";
import { translate as t } from "@nextcloud/l10n";
import { IPhoto } from "../../types";
import { getAlbumFileInfos } from "./albums";
import { API } from "../API";
/**
* Download a file
*
* @param fileNames - The file's names
* Download files
*/
export async function downloadFiles(fileNames: string[]): Promise<boolean> {
const randomToken = Math.random().toString(36).substring(2);
export async function downloadFiles(fileIds: number[]) {
if (!fileIds.length) return;
const params = new URLSearchParams();
params.append("files", JSON.stringify(fileNames));
params.append("downloadStartSecret", randomToken);
const res = await axios.post(API.DOWNLOAD_REQUEST(), { files: fileIds });
if (res.status !== 200 || !res.data.handle) {
showError(t("memories", "Failed to download files"));
return;
}
let downloadURL = generateUrl(`/apps/files/ajax/download.php?${params}`);
downloadWithHandle(res.data.handle);
}
window.location.href = `${downloadURL}downloadStartSecret=${randomToken}`;
return new Promise((resolve) => {
const waitForCookieInterval = setInterval(() => {
const cookieIsSet = document.cookie
.split(";")
.map((cookie) => cookie.split("="))
.findIndex(
([cookieName, cookieValue]) =>
cookieName === "ocDownloadStarted" && cookieValue === randomToken
);
if (cookieIsSet) {
clearInterval(waitForCookieInterval);
resolve(true);
}
}, 50);
});
/**
* Download files with a download handle
* @param handle Download handle
*/
export function downloadWithHandle(handle: string) {
window.location.href = API.DOWNLOAD_FILE(handle);
}
/**
@ -52,28 +42,7 @@ export async function downloadPublicPhoto(photo: IPhoto) {
* @param photos list of photos
*/
export async function downloadFilesByPhotos(photos: IPhoto[]) {
if (photos.length === 0) {
return;
}
// Public files
if (vuerouter.currentRoute.name === "folder-share") {
for (const photo of photos) {
await downloadPublicPhoto(photo);
}
return;
}
// Get files to download
const fileInfos = await base.getFiles(photos);
if (fileInfos.length !== photos.length) {
showError(t("memories", "Failed to download some files."));
}
if (fileInfos.length === 0) {
return;
}
await downloadFiles(fileInfos.map((f) => f.filename));
await downloadFiles(photos.map((f) => f.fileid));
}
/** Get URL to download one file (e.g. for video streaming) */
@ -98,9 +67,9 @@ export function getDownloadLink(photo: IPhoto) {
route.params.name
);
if (fInfos.length) {
return `/remote.php/dav${fInfos[0].originalFilename}`;
return generateUrl(`/remote.php/dav${fInfos[0].originalFilename}`);
}
}
return `/remote.php/dav${photo.filename}`;
return generateUrl(`/remote.php/dav${photo.filename}`);
}

View File

@ -3,14 +3,17 @@ import { showError } from "@nextcloud/dialogs";
import { translate as t } from "@nextcloud/l10n";
import { generateUrl } from "@nextcloud/router";
import { IDay, IPhoto } from "../../types";
import client from "../DavClient";
import { API } from "../API";
import { constants } from "../Utils";
import client from "../DavClient";
import * as base from "./base";
/**
* Get list of tags and convert to Days response
*/
export async function getPeopleData(): Promise<IDay[]> {
export async function getPeopleData(
app: "recognize" | "facerecognition"
): Promise<IDay[]> {
// Query for photos
let data: {
id: number;
@ -19,9 +22,7 @@ export async function getPeopleData(): Promise<IDay[]> {
previews: IPhoto[];
}[] = [];
try {
const res = await axios.get<typeof data>(
generateUrl("/apps/memories/api/faces")
);
const res = await axios.get<typeof data>(API.FACE_LIST(app));
data = res.data;
} catch (e) {
throw e;
@ -41,13 +42,48 @@ export async function getPeopleData(): Promise<IDay[]> {
...face,
fileid: face.id,
istag: true,
isface: true,
isface: app,
} as any)
),
},
];
}
export async function updatePeopleFaceRecognition(
name: string,
params: object
) {
if (Number.isInteger(Number(name))) {
return await axios.put(
generateUrl(`/apps/facerecognition/api/2.0/cluster/${name}`),
params
);
} else {
return await axios.put(
generateUrl(`/apps/facerecognition/api/2.0/person/${name}`),
params
);
}
}
export async function renamePeopleFaceRecognition(
name: string,
newName: string
) {
return await updatePeopleFaceRecognition(name, {
name: newName,
});
}
export async function setVisibilityPeopleFaceRecognition(
name: string,
visibility: boolean
) {
return await updatePeopleFaceRecognition(name, {
visible: visibility,
});
}
/**
* Remove images from a face.
*

View File

@ -1,6 +1,6 @@
import { generateUrl } from "@nextcloud/router";
import { IDay, IPhoto } from "../../types";
import axios from "@nextcloud/axios";
import { API } from "../API";
/**
* Get original onThisDay response.
@ -23,7 +23,7 @@ export async function getOnThisDayRaw() {
}
return (
await axios.post<IPhoto[]>(generateUrl("/apps/memories/api/days"), {
await axios.post<IPhoto[]>(API.DAYS(), {
body_ids: dayIds.join(","),
})
).data;

View File

@ -1,7 +1,7 @@
import { generateUrl } from "@nextcloud/router";
import { IDay, IPhoto, ITag } from "../../types";
import { constants, hashCode } from "../Utils";
import axios from "@nextcloud/axios";
import { API } from "../API";
/**
* Get list of tags and convert to Days response
@ -15,9 +15,7 @@ export async function getTagsData(): Promise<IDay[]> {
previews: IPhoto[];
}[] = [];
try {
const res = await axios.get<typeof data>(
generateUrl("/apps/memories/api/tags")
);
const res = await axios.get<typeof data>(API.TAG_LIST());
data = res.data;
} catch (e) {
throw e;

View File

@ -0,0 +1,13 @@
import videojs from "video.js";
globalThis.vidjs = videojs;
import "videojs-contrib-quality-levels";
import "video.js/dist/video-js.min.css";
import Plyr from "plyr";
(<any>globalThis).Plyr = Plyr;
import "plyr/dist/plyr.css";
import plyrsvg from "../assets/plyr.svg";
(<any>Plyr).defaults.iconUrl = plyrsvg;
(<any>Plyr).defaults.blankVideo = "";

View File

@ -73,6 +73,21 @@ export type IPhoto = {
/** Reference to day object */
d?: IDay;
/** Reference to exif object */
imageInfo?: {
h: number;
w: number;
datetaken: number;
exif?: {
Rotation?: number;
Orientation?: number;
ImageWidth?: number;
ImageHeight?: number;
Title?: string;
Description?: string;
[other: string]: unknown;
};
};
/** Face dimensions */
facerect?: IFaceRect;
@ -90,7 +105,7 @@ export type IPhoto = {
/** Is this an album */
isalbum?: boolean;
/** Is this a face */
isface?: boolean;
isface?: "recognize" | "facerecognition";
/** Optional datetaken epoch */
datetaken?: number;
};

View File

@ -1,13 +1,13 @@
{
"compilerOptions": {
"lib": ["dom", "es2017"],
"target": "ES2017",
"module": "es2015",
"moduleResolution": "node",
"sourceMap": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"jsx": "preserve"
}
}
"compilerOptions": {
"lib": ["dom", "es2017"],
"target": "ES2017",
"module": "es2020",
"moduleResolution": "node",
"sourceMap": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"jsx": "preserve"
}
}

View File

@ -18,78 +18,17 @@ webpackConfig.resolve.alias = {
'vue$': 'vue/dist/vue.esm.js',
}
webpackConfig.entry.main = path.resolve(path.join('src', 'main'));
delete webpackConfig.optimization.splitChunks;
webpackConfig.watchOptions = {
ignored: /node_modules/,
aggregateTimeout: 300,
};
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',
clientsClaim: true,
skipWaiting: true,
exclude: [new RegExp('.*')], // don't do precaching
inlineWorkboxRuntime: true,
sourcemap: false,
// Define runtime caching rules.
runtimeCaching: [{
// 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=.*/,
...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: /^.*\/.*$/,
handler: 'NetworkFirst',
options: {
cacheName: 'pages',
expiration: {
maxAgeSeconds: 3600 * 24 * 7, // one week
maxEntries: 2000, // assets
},
},
}],
})
);
}
webpackConfig.plugins.push(
new WorkboxPlugin.InjectManifest({
swSrc: path.resolve(path.join('src', 'service-worker.js')),
swDest: 'memories-service-worker.js',
})
);
module.exports = webpackConfig