Merge branch 'master' into stable24
commit
0dedf3a369
13
CHANGELOG.md
13
CHANGELOG.md
|
@ -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))
|
||||
|
|
9
Makefile
9
Makefile
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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'],
|
||||
|
||||
|
|
|
@ -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"]');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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"]');
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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']);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
22
lib/Exif.php
22
lib/Exif.php
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
50
lib/Util.php
50
lib/Util.php
|
@ -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();
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
64
src/App.vue
64
src/App.vue
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
|
|
|
@ -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>
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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¤t=1", {
|
||||
id: this.fileid,
|
||||
})
|
||||
API.Q(API.IMAGE_INFO(this.fileid), "basic=1¤t=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>
|
||||
|
|
@ -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);
|
|
@ -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 }) {
|
|
@ -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 {
|
59
src/main.ts
59
src/main.ts
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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"),
|
||||
}),
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
);
|
|
@ -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());
|
||||
});
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 = "";
|
17
src/types.ts
17
src/types.ts
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
73
webpack.js
73
webpack.js
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue