Merge branch 'master' into stable24
commit
407af61a3e
|
@ -24,6 +24,7 @@ jobs:
|
|||
- name: Build vue app
|
||||
run: |
|
||||
make dev-setup
|
||||
make patch-external
|
||||
make build-js-production
|
||||
zip -r vue.zip js/
|
||||
|
||||
|
@ -40,8 +41,8 @@ jobs:
|
|||
# do not stop on another job's failure
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php-versions: ['7.4']
|
||||
server-versions: ['stable25']
|
||||
php-versions: ["7.4"]
|
||||
server-versions: ["stable25"]
|
||||
|
||||
services:
|
||||
mysql:
|
||||
|
@ -100,7 +101,6 @@ jobs:
|
|||
name: report-mysql-${{ matrix.php-versions }}-${{ matrix.server-versions }}
|
||||
path: apps/${{ env.APP_NAME }}/playwright-report
|
||||
|
||||
|
||||
pgsql:
|
||||
runs-on: ubuntu-latest
|
||||
needs: vue
|
||||
|
@ -109,8 +109,8 @@ jobs:
|
|||
# do not stop on another job's failure
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php-versions: ['7.4']
|
||||
server-versions: ['stable25']
|
||||
php-versions: ["7.4"]
|
||||
server-versions: ["stable25"]
|
||||
|
||||
services:
|
||||
postgres:
|
||||
|
@ -181,8 +181,8 @@ jobs:
|
|||
# do not stop on another job's failure
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php-versions: ['7.4']
|
||||
server-versions: ['stable25']
|
||||
php-versions: ["7.4"]
|
||||
server-versions: ["stable25"]
|
||||
|
||||
steps:
|
||||
- name: Checkout server
|
||||
|
@ -231,4 +231,3 @@ jobs:
|
|||
with:
|
||||
name: report-sqlite-${{ matrix.php-versions }}-${{ matrix.server-versions }}
|
||||
path: apps/${{ env.APP_NAME }}/playwright-report
|
||||
|
||||
|
|
|
@ -25,4 +25,4 @@ jobs:
|
|||
- name: PHP-CS-Fixer
|
||||
uses: docker://oskarstark/php-cs-fixer-ga
|
||||
with:
|
||||
args: --dry-run --diff lib
|
||||
args: --dry-run --diff lib
|
||||
|
|
|
@ -13,34 +13,35 @@ jobs:
|
|||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.x
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.x
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
make dev-setup
|
||||
make build-js-production
|
||||
./scripts/bundle.sh
|
||||
- name: Build
|
||||
run: |
|
||||
make dev-setup
|
||||
make patch-external
|
||||
make build-js-production
|
||||
./scripts/bundle.sh
|
||||
|
||||
- name: Upload app tarball to release
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
id: attach_to_release
|
||||
with:
|
||||
file: memories.tar.gz
|
||||
asset_name: memories.tar.gz
|
||||
tag: ${{ github.ref }}
|
||||
overwrite: true
|
||||
- name: Upload app tarball to release
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
id: attach_to_release
|
||||
with:
|
||||
file: memories.tar.gz
|
||||
asset_name: memories.tar.gz
|
||||
tag: ${{ github.ref }}
|
||||
overwrite: true
|
||||
|
||||
- name: Upload app to Nextcloud appstore
|
||||
uses: R0Wi/nextcloud-appstore-push-action@v1
|
||||
with:
|
||||
app_name: ${{ env.APP_NAME }}
|
||||
appstore_token: ${{ secrets.APPSTORE_TOKEN }}
|
||||
download_url: ${{ steps.attach_to_release.outputs.browser_download_url }}
|
||||
app_private_key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
nightly: ${{ github.event.release.prerelease }}
|
||||
- name: Upload app to Nextcloud appstore
|
||||
uses: R0Wi/nextcloud-appstore-push-action@v1
|
||||
with:
|
||||
app_name: ${{ env.APP_NAME }}
|
||||
appstore_token: ${{ secrets.APPSTORE_TOKEN }}
|
||||
download_url: ${{ steps.attach_to_release.outputs.browser_download_url }}
|
||||
app_private_key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
nightly: ${{ github.event.release.prerelease }}
|
||||
|
|
3
Makefile
3
Makefile
|
@ -26,6 +26,9 @@ build-js:
|
|||
build-js-production:
|
||||
rm -f js/* && npm run build
|
||||
|
||||
patch-external:
|
||||
patch -p1 < patches/scroller.patch
|
||||
|
||||
watch-js:
|
||||
npm run watch
|
||||
|
||||
|
|
|
@ -1,54 +1,56 @@
|
|||
<?php
|
||||
|
||||
function getWildcard($param) {
|
||||
return [
|
||||
'requirements' => [ $param => '.*' ],
|
||||
'defaults' => [ $param => '' ]
|
||||
];
|
||||
}
|
||||
|
||||
function w($base, $param) {
|
||||
return array_merge($base, getWildcard($param));
|
||||
}
|
||||
|
||||
return [
|
||||
'routes' => [
|
||||
// Days and folder API
|
||||
// Vue routes for deep links
|
||||
['name' => 'page#main', 'url' => '/', 'verb' => 'GET'],
|
||||
['name' => 'page#folder', 'url' => '/folders/{path}', 'verb' => 'GET',
|
||||
'requirements' => [ 'path' => '.*' ],
|
||||
'defaults' => [ 'path' => '' ]
|
||||
],
|
||||
['name' => 'page#favorites', 'url' => '/favorites', 'verb' => 'GET'],
|
||||
['name' => 'page#videos', 'url' => '/videos', 'verb' => 'GET'],
|
||||
['name' => 'page#albums', 'url' => '/albums/{id}', 'verb' => 'GET',
|
||||
'requirements' => [ 'id' => '.*' ],
|
||||
'defaults' => [ 'id' => '' ]
|
||||
],
|
||||
['name' => 'page#archive', 'url' => '/archive', 'verb' => 'GET'],
|
||||
['name' => 'page#thisday', 'url' => '/thisday', 'verb' => 'GET'],
|
||||
['name' => 'page#people', 'url' => '/people/{name}', 'verb' => 'GET',
|
||||
'requirements' => [ 'name' => '.*' ],
|
||||
'defaults' => [ 'name' => '' ]
|
||||
],
|
||||
['name' => 'page#tags', 'url' => '/tags/{name}', 'verb' => 'GET',
|
||||
'requirements' => [ 'name' => '.*' ],
|
||||
'defaults' => [ 'name' => '' ]
|
||||
],
|
||||
|
||||
// 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#tags', 'url' => '/tags/{name}', 'verb' => 'GET'], 'name'),
|
||||
|
||||
// Public pages
|
||||
['name' => 'page#sharedFolder', 'url' => '/s/{token}', 'verb' => 'GET'],
|
||||
['name' => 'page#sharedfolder', 'url' => '/s/{token}', 'verb' => 'GET'],
|
||||
|
||||
// API
|
||||
['name' => 'api#days', 'url' => '/api/days', 'verb' => 'GET'],
|
||||
['name' => 'api#dayPost', 'url' => '/api/days', 'verb' => 'POST'],
|
||||
['name' => 'api#day', 'url' => '/api/days/{id}', 'verb' => 'GET'],
|
||||
// API Routes
|
||||
['name' => 'days#days', 'url' => '/api/days', 'verb' => 'GET'],
|
||||
['name' => 'days#day', 'url' => '/api/days/{id}', 'verb' => 'GET'],
|
||||
['name' => 'days#dayPost', 'url' => '/api/days', 'verb' => 'POST'],
|
||||
|
||||
['name' => 'api#tags', 'url' => '/api/tags', 'verb' => 'GET'],
|
||||
['name' => 'api#tagPreviews', 'url' => '/api/tag-previews', 'verb' => 'GET'],
|
||||
['name' => 'tags#tags', 'url' => '/api/tags', 'verb' => 'GET'],
|
||||
['name' => 'tags#previews', 'url' => '/api/tag-previews', 'verb' => 'GET'],
|
||||
|
||||
['name' => 'api#albums', 'url' => '/api/albums', 'verb' => 'GET'],
|
||||
['name' => 'albums#albums', 'url' => '/api/albums', 'verb' => 'GET'],
|
||||
|
||||
['name' => 'api#faces', 'url' => '/api/faces', 'verb' => 'GET'],
|
||||
['name' => 'api#facePreview', 'url' => '/api/faces/preview/{id}', 'verb' => 'GET'],
|
||||
['name' => 'faces#faces', 'url' => '/api/faces', 'verb' => 'GET'],
|
||||
['name' => 'faces#preview', 'url' => '/api/faces/preview/{id}', 'verb' => 'GET'],
|
||||
|
||||
['name' => 'api#imageInfo', 'url' => '/api/info/{id}', 'verb' => 'GET'],
|
||||
['name' => 'api#imageEdit', 'url' => '/api/edit/{id}', 'verb' => 'PATCH'],
|
||||
['name' => 'image#info', 'url' => '/api/info/{id}', 'verb' => 'GET'],
|
||||
['name' => 'image#edit', 'url' => '/api/edit/{id}', 'verb' => 'PATCH'],
|
||||
|
||||
['name' => 'api#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'],
|
||||
['name' => 'archive#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'],
|
||||
|
||||
// Config API
|
||||
['name' => 'api#setUserConfig', 'url' => '/api/config/{key}', 'verb' => 'PUT'],
|
||||
['name' => 'other#setUserConfig', 'url' => '/api/config/{key}', 'verb' => 'PUT'],
|
||||
|
||||
// Service worker
|
||||
['name' => 'api#serviceWorker', 'url' => '/service-worker.js', 'verb' => 'GET'],
|
||||
['name' => 'other#serviceWorker', 'url' => '/service-worker.js', 'verb' => 'GET'],
|
||||
]
|
||||
];
|
||||
|
|
|
@ -220,9 +220,8 @@ class Index extends Command
|
|||
private function testExif()
|
||||
{
|
||||
$testfile = __DIR__.'/../../exiftest.jpg';
|
||||
$stream = fopen($testfile, 'r');
|
||||
if (!$stream) {
|
||||
error_log("Couldn't open Exif test file {$testfile}");
|
||||
if (!file_exists($testfile)) {
|
||||
error_log("Couldn't find Exif test file {$testfile}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
@ -230,13 +229,11 @@ class Index extends Command
|
|||
$exif = null;
|
||||
|
||||
try {
|
||||
$exif = \OCA\Memories\Exif::getExifFromStream($stream);
|
||||
$exif = \OCA\Memories\Exif::getExifFromLocalPath($testfile);
|
||||
} catch (\Exception $e) {
|
||||
error_log("Couldn't read Exif data from test file: ".$e->getMessage());
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
fclose($stream);
|
||||
}
|
||||
|
||||
if (!$exif) {
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
<?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\JSONResponse;
|
||||
|
||||
class AlbumsController extends ApiBase
|
||||
{
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* Get list of albums with counts of images
|
||||
*/
|
||||
public function albums(): JSONResponse
|
||||
{
|
||||
$user = $this->userSession->getUser();
|
||||
if (null === $user) {
|
||||
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()));
|
||||
}
|
||||
if ($t & 2) { // shared
|
||||
$list = array_merge($list, $this->timelineQuery->getAlbums($user->getUID(), true));
|
||||
}
|
||||
|
||||
return new JSONResponse($list, Http::STATUS_OK);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,164 @@
|
|||
<?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 OCA\Memories\AppInfo\Application;
|
||||
use OCA\Memories\Db\TimelineQuery;
|
||||
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\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
|
||||
{
|
||||
protected IConfig $config;
|
||||
protected IUserSession $userSession;
|
||||
protected IRootFolder $rootFolder;
|
||||
protected IAppManager $appManager;
|
||||
protected TimelineQuery $timelineQuery;
|
||||
protected TimelineWrite $timelineWrite;
|
||||
protected IShareManager $shareManager;
|
||||
protected IPreview $previewManager;
|
||||
|
||||
public function __construct(
|
||||
IRequest $request,
|
||||
IConfig $config,
|
||||
IUserSession $userSession,
|
||||
IDBConnection $connection,
|
||||
IRootFolder $rootFolder,
|
||||
IAppManager $appManager,
|
||||
IShareManager $shareManager,
|
||||
IPreview $preview
|
||||
) {
|
||||
parent::__construct(Application::APPNAME, $request);
|
||||
|
||||
$this->config = $config;
|
||||
$this->userSession = $userSession;
|
||||
$this->connection = $connection;
|
||||
$this->rootFolder = $rootFolder;
|
||||
$this->appManager = $appManager;
|
||||
$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
|
||||
{
|
||||
$user = $this->userSession->getUser();
|
||||
if ($this->getShareToken()) {
|
||||
$user = null;
|
||||
} elseif (null === $user) {
|
||||
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
|
||||
return $user ? $user->getUID() : '';
|
||||
}
|
||||
|
||||
/** Get the Folder object relevant to the request */
|
||||
protected function getRequestFolder()
|
||||
{
|
||||
// Albums have no folder
|
||||
if ($this->request->getParam('album')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
return $share;
|
||||
}
|
||||
|
||||
// Anything else needs a user
|
||||
$user = $this->userSession->getUser();
|
||||
if (null === $user) {
|
||||
return null;
|
||||
}
|
||||
$uid = $user->getUID();
|
||||
|
||||
$folder = null;
|
||||
$folderPath = $this->request->getParam('folder');
|
||||
$forcedTimelinePath = $this->request->getParam('timelinePath');
|
||||
$userFolder = $this->rootFolder->getUserFolder($uid);
|
||||
|
||||
if (null !== $folderPath) {
|
||||
$folder = $userFolder->get($folderPath);
|
||||
} elseif (null !== $forcedTimelinePath) {
|
||||
$folder = $userFolder->get($forcedTimelinePath);
|
||||
} else {
|
||||
$configPath = Exif::removeExtraSlash(Exif::getPhotosPath($this->config, $uid));
|
||||
$folder = $userFolder->get($configPath);
|
||||
}
|
||||
|
||||
if (!$folder instanceof Folder) {
|
||||
throw new \Exception('Folder not found');
|
||||
}
|
||||
|
||||
return $folder;
|
||||
}
|
||||
|
||||
protected function getShareToken()
|
||||
{
|
||||
return $this->request->getParam('folder_share');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if albums are enabled for this user.
|
||||
*/
|
||||
protected function albumsIsEnabled(): bool
|
||||
{
|
||||
return \OCA\Memories\Util::albumsIsEnabled($this->appManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tags is enabled for this user.
|
||||
*/
|
||||
protected function tagsIsEnabled(): bool
|
||||
{
|
||||
return \OCA\Memories\Util::tagsIsEnabled($this->appManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if recognize is enabled for this user.
|
||||
*/
|
||||
protected function recognizeIsEnabled(): bool
|
||||
{
|
||||
return \OCA\Memories\Util::recognizeIsEnabled($this->appManager);
|
||||
}
|
||||
}
|
|
@ -1,901 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
|
||||
* @author John Molakvoæ <skjnldsv@protonmail.com>
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* 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 OCA\Memories\AppInfo\Application;
|
||||
use OCA\Memories\Db\TimelineQuery;
|
||||
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\ContentSecurityPolicy;
|
||||
use OCP\AppFramework\Http\DataDisplayResponse;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\AppFramework\Http\StreamResponse;
|
||||
use OCP\Files\FileInfo;
|
||||
use OCP\Files\Folder;
|
||||
use OCP\Files\IRootFolder;
|
||||
use OCP\IConfig;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\IPreview;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUserSession;
|
||||
use OCP\Share\IManager as IShareManager;
|
||||
|
||||
class ApiController extends Controller
|
||||
{
|
||||
private IConfig $config;
|
||||
private IUserSession $userSession;
|
||||
private IDBConnection $connection;
|
||||
private IRootFolder $rootFolder;
|
||||
private IAppManager $appManager;
|
||||
private TimelineQuery $timelineQuery;
|
||||
private TimelineWrite $timelineWrite;
|
||||
private IShareManager $shareManager;
|
||||
private IPreview $preview;
|
||||
|
||||
public function __construct(
|
||||
IRequest $request,
|
||||
IConfig $config,
|
||||
IUserSession $userSession,
|
||||
IDBConnection $connection,
|
||||
IRootFolder $rootFolder,
|
||||
IAppManager $appManager,
|
||||
IShareManager $shareManager,
|
||||
IPreview $preview
|
||||
) {
|
||||
parent::__construct(Application::APPNAME, $request);
|
||||
|
||||
$this->config = $config;
|
||||
$this->userSession = $userSession;
|
||||
$this->connection = $connection;
|
||||
$this->rootFolder = $rootFolder;
|
||||
$this->appManager = $appManager;
|
||||
$this->shareManager = $shareManager;
|
||||
$this->previewManager = $preview;
|
||||
$this->timelineQuery = new TimelineQuery($this->connection);
|
||||
$this->timelineWrite = new TimelineWrite($connection, $preview);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* @PublicPage
|
||||
*/
|
||||
public function days(): JSONResponse
|
||||
{
|
||||
// Get the folder to show
|
||||
$uid = $this->getUid();
|
||||
|
||||
// Get the folder to show
|
||||
$folder = null;
|
||||
|
||||
try {
|
||||
$folder = $this->getRequestFolder();
|
||||
} catch (\Exception $e) {
|
||||
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Params
|
||||
$recursive = null === $this->request->getParam('folder');
|
||||
$archive = null !== $this->request->getParam('archive');
|
||||
|
||||
// Run actual query
|
||||
try {
|
||||
$list = $this->timelineQuery->getDays(
|
||||
$folder,
|
||||
$uid,
|
||||
$recursive,
|
||||
$archive,
|
||||
$this->getTransformations(true),
|
||||
);
|
||||
|
||||
// Preload some day responses
|
||||
$this->preloadDays($list, $uid, $folder, $recursive, $archive);
|
||||
|
||||
// Add subfolder info if querying non-recursively
|
||||
if (!$recursive) {
|
||||
array_unshift($list, $this->getSubfoldersEntry($folder));
|
||||
}
|
||||
|
||||
return new JSONResponse($list, Http::STATUS_OK);
|
||||
} catch (\Exception $e) {
|
||||
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* @PublicPage
|
||||
*/
|
||||
public function dayPost(): JSONResponse
|
||||
{
|
||||
$id = $this->request->getParam('body_ids');
|
||||
if (null === $id) {
|
||||
return new JSONResponse([], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
return $this->day($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* @PublicPage
|
||||
*/
|
||||
public function day(string $id): JSONResponse
|
||||
{
|
||||
// Get user
|
||||
$uid = $this->getUid();
|
||||
|
||||
// Check for wildcard
|
||||
$day_ids = [];
|
||||
if ('*' === $id) {
|
||||
$day_ids = null;
|
||||
} else {
|
||||
// Split at commas and convert all parts to int
|
||||
$day_ids = array_map(function ($part) {
|
||||
return (int) $part;
|
||||
}, explode(',', $id));
|
||||
}
|
||||
|
||||
// Check if $day_ids is empty
|
||||
if (null !== $day_ids && 0 === \count($day_ids)) {
|
||||
return new JSONResponse([], Http::STATUS_OK);
|
||||
}
|
||||
|
||||
// Get the folder to show
|
||||
$folder = null;
|
||||
|
||||
try {
|
||||
$folder = $this->getRequestFolder();
|
||||
} catch (\Exception $e) {
|
||||
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Params
|
||||
$recursive = null === $this->request->getParam('folder');
|
||||
$archive = null !== $this->request->getParam('archive');
|
||||
|
||||
// Run actual query
|
||||
try {
|
||||
$list = $this->timelineQuery->getDay(
|
||||
$folder,
|
||||
$uid,
|
||||
$day_ids,
|
||||
$recursive,
|
||||
$archive,
|
||||
$this->getTransformations(false),
|
||||
);
|
||||
|
||||
return new JSONResponse($list, Http::STATUS_OK);
|
||||
} catch (\Exception $e) {
|
||||
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subfolders entry for days response.
|
||||
*/
|
||||
public function getSubfoldersEntry(Folder &$folder)
|
||||
{
|
||||
// Ugly: get the view of the folder with reflection
|
||||
// This is unfortunately the only way to get the contents of a folder
|
||||
// matching a MIME type without using SEARCH, which is deep
|
||||
$rp = new \ReflectionProperty('\OC\Files\Node\Node', 'view');
|
||||
$rp->setAccessible(true);
|
||||
$view = $rp->getValue($folder);
|
||||
|
||||
// Get the subfolders
|
||||
$folders = $view->getDirectoryContent($folder->getPath(), FileInfo::MIMETYPE_FOLDER, $folder);
|
||||
|
||||
// Sort by name
|
||||
usort($folders, function ($a, $b) {
|
||||
return strnatcmp($a->getName(), $b->getName());
|
||||
});
|
||||
|
||||
// Process to response type
|
||||
return [
|
||||
'dayid' => \OCA\Memories\Util::$TAG_DAYID_FOLDERS,
|
||||
'count' => \count($folders),
|
||||
'detail' => array_map(function ($node) {
|
||||
return [
|
||||
'fileid' => $node->getId(),
|
||||
'name' => $node->getName(),
|
||||
'isfolder' => 1,
|
||||
'path' => $node->getPath(),
|
||||
];
|
||||
}, $folders, []),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* Get list of tags with counts of images
|
||||
*/
|
||||
public function tags(): JSONResponse
|
||||
{
|
||||
$user = $this->userSession->getUser();
|
||||
if (null === $user) {
|
||||
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
|
||||
// Check tags enabled for this user
|
||||
if (!$this->tagsIsEnabled()) {
|
||||
return new JSONResponse(['message' => 'Tags not enabled for user'], Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
|
||||
// If this isn't the timeline folder then things aren't going to work
|
||||
$folder = $this->getRequestFolder();
|
||||
if (null === $folder) {
|
||||
return new JSONResponse([], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Run actual query
|
||||
$list = $this->timelineQuery->getTags(
|
||||
$folder,
|
||||
);
|
||||
|
||||
return new JSONResponse($list, Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* Get previews for a tag
|
||||
*/
|
||||
public function tagPreviews(): JSONResponse
|
||||
{
|
||||
$user = $this->userSession->getUser();
|
||||
if (null === $user) {
|
||||
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
|
||||
// Check tags enabled for this user
|
||||
if (!$this->tagsIsEnabled()) {
|
||||
return new JSONResponse(['message' => 'Tags not enabled for user'], Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
|
||||
// If this isn't the timeline folder then things aren't going to work
|
||||
$folder = $this->getRequestFolder();
|
||||
if (null === $folder) {
|
||||
return new JSONResponse([], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Get the tag
|
||||
$tagName = $this->request->getParam('tag');
|
||||
|
||||
// Run actual query
|
||||
$list = $this->timelineQuery->getTagPreviews(
|
||||
$tagName,
|
||||
$folder,
|
||||
);
|
||||
|
||||
return new JSONResponse($list, Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* Get list of albums with counts of images
|
||||
*/
|
||||
public function albums(): JSONResponse
|
||||
{
|
||||
$user = $this->userSession->getUser();
|
||||
if (null === $user) {
|
||||
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()));
|
||||
}
|
||||
if ($t & 2) { // shared
|
||||
$list = array_merge($list, $this->timelineQuery->getAlbums($user->getUID(), true));
|
||||
}
|
||||
|
||||
return new JSONResponse($list, Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
$folder = $this->getRequestFolder();
|
||||
if (null === $folder) {
|
||||
return new JSONResponse([], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Run actual query
|
||||
$list = $this->timelineQuery->getFaces(
|
||||
$folder,
|
||||
);
|
||||
|
||||
return new JSONResponse($list, Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* @NoCSRFRequired
|
||||
*
|
||||
* Get face preview image cropped with imagick
|
||||
*
|
||||
* @return DataResponse
|
||||
*/
|
||||
public function facePreview(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
|
||||
$folder = $this->getRequestFolder();
|
||||
if (null === $folder) {
|
||||
return new JSONResponse([], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Run actual query
|
||||
$detections = $this->timelineQuery->getFacePreviewDetection($folder, (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;
|
||||
foreach ($detections as &$detection) {
|
||||
// Get the file (also checks permissions)
|
||||
$files = $folder->getById($detection['file_id']);
|
||||
if (0 === \count($files) || FileInfo::TYPE_FILE !== $files[0]->getType()) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* Get image info for one file
|
||||
*
|
||||
* @param string fileid
|
||||
*/
|
||||
public function imageInfo(string $id): JSONResponse
|
||||
{
|
||||
$user = $this->userSession->getUser();
|
||||
if (null === $user) {
|
||||
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
|
||||
|
||||
// Check for permissions and get numeric Id
|
||||
$file = $userFolder->getById((int) $id);
|
||||
if (0 === \count($file)) {
|
||||
return new JSONResponse([], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
$file = $file[0];
|
||||
|
||||
// Get the image info
|
||||
$info = $this->timelineQuery->getInfoById($file->getId());
|
||||
|
||||
return new JSONResponse($info, Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* Change exif data for one file
|
||||
*
|
||||
* @param string fileid
|
||||
*/
|
||||
public function imageEdit(string $id): JSONResponse
|
||||
{
|
||||
$user = $this->userSession->getUser();
|
||||
if (null === $user) {
|
||||
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
|
||||
|
||||
// Check for permissions and get numeric Id
|
||||
$file = $userFolder->getById((int) $id);
|
||||
if (0 === \count($file)) {
|
||||
return new JSONResponse([], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
$file = $file[0];
|
||||
|
||||
// Check if user has permissions
|
||||
if (!$file->isUpdateable()) {
|
||||
return new JSONResponse([], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
// Get new date from body
|
||||
$body = $this->request->getParams();
|
||||
if (!isset($body['date'])) {
|
||||
return new JSONResponse(['message' => 'Missing date'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Make sure the date is valid
|
||||
try {
|
||||
Exif::parseExifDate($body['date']);
|
||||
} catch (\Exception $e) {
|
||||
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Update date
|
||||
try {
|
||||
$res = Exif::updateExifDate($file, $body['date']);
|
||||
if (false === $res) {
|
||||
return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
// Reprocess the file
|
||||
$this->timelineWrite->processFile($file, true);
|
||||
|
||||
return $this->imageInfo($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* Move one file to the archive folder
|
||||
*
|
||||
* @param string fileid
|
||||
*/
|
||||
public function archive(string $id): JSONResponse
|
||||
{
|
||||
$user = $this->userSession->getUser();
|
||||
if (null === $user) {
|
||||
return new JSONResponse(['message' => 'Not logged in'], Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
$uid = $user->getUID();
|
||||
$userFolder = $this->rootFolder->getUserFolder($uid);
|
||||
|
||||
// Check for permissions and get numeric Id
|
||||
$file = $userFolder->getById((int) $id);
|
||||
if (0 === \count($file)) {
|
||||
return new JSONResponse(['message' => 'No such file'], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
$file = $file[0];
|
||||
|
||||
// Check if user has permissions
|
||||
if (!$file->isUpdateable()) {
|
||||
return new JSONResponse(['message' => 'Cannot update this file'], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
// Create archive folder in the root of the user's configured timeline
|
||||
$timelinePath = Exif::removeExtraSlash(Exif::getPhotosPath($this->config, $uid));
|
||||
$timelineFolder = $userFolder->get($timelinePath);
|
||||
if (null === $timelineFolder || !$timelineFolder instanceof Folder) {
|
||||
return new JSONResponse(['message' => 'Cannot get timeline'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
if (!$timelineFolder->isCreatable()) {
|
||||
return new JSONResponse(['message' => 'Cannot create archive folder'], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
// Get path of current file relative to the timeline folder
|
||||
// remove timelineFolder path from start of file path
|
||||
$timelinePath = $timelineFolder->getPath(); // no trailing slash
|
||||
if (substr($file->getPath(), 0, \strlen($timelinePath)) !== $timelinePath) {
|
||||
return new JSONResponse(['message' => 'Files outside timeline cannot be archived'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
$relativePath = substr($file->getPath(), \strlen($timelinePath)); // has a leading slash
|
||||
|
||||
// Final path of the file including the file name
|
||||
$destinationPath = '';
|
||||
|
||||
// Check if we want to archive or unarchive
|
||||
$body = $this->request->getParams();
|
||||
$unarchive = isset($body['archive']) && false === $body['archive'];
|
||||
|
||||
// Get if the file is already in the archive (relativePath starts with archive)
|
||||
$archiveFolderWithLeadingSlash = '/'.\OCA\Memories\Util::$ARCHIVE_FOLDER;
|
||||
if (substr($relativePath, 0, \strlen($archiveFolderWithLeadingSlash)) === $archiveFolderWithLeadingSlash) {
|
||||
// file already in archive, remove it instead
|
||||
$destinationPath = substr($relativePath, \strlen($archiveFolderWithLeadingSlash));
|
||||
if (!$unarchive) {
|
||||
return new JSONResponse(['message' => 'File already archived'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
} else {
|
||||
// file not in archive, put it in there
|
||||
$destinationPath = Exif::removeExtraSlash(\OCA\Memories\Util::$ARCHIVE_FOLDER.$relativePath);
|
||||
if ($unarchive) {
|
||||
return new JSONResponse(['message' => 'File not archived'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the filename
|
||||
$destinationFolders = explode('/', $destinationPath);
|
||||
array_pop($destinationFolders);
|
||||
|
||||
// Create folder tree
|
||||
$folder = $timelineFolder;
|
||||
foreach ($destinationFolders as $folderName) {
|
||||
if ('' === $folderName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$existingFolder = $folder->get($folderName.'/');
|
||||
if (!$existingFolder instanceof Folder) {
|
||||
throw new \OCP\Files\NotFoundException('Not a folder');
|
||||
}
|
||||
$folder = $existingFolder;
|
||||
} catch (\OCP\Files\NotFoundException $e) {
|
||||
try {
|
||||
$folder = $folder->newFolder($folderName);
|
||||
} catch (\OCP\Files\NotPermittedException $e) {
|
||||
return new JSONResponse(['message' => 'Failed to create folder'], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Move file to archive folder
|
||||
try {
|
||||
$file->move($folder->getPath().'/'.$file->getName());
|
||||
} catch (\OCP\Files\NotPermittedException $e) {
|
||||
return new JSONResponse(['message' => 'Failed to move file'], Http::STATUS_FORBIDDEN);
|
||||
} catch (\OCP\Files\NotFoundException $e) {
|
||||
return new JSONResponse(['message' => 'File not found'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
} catch (\OCP\Files\InvalidPathException $e) {
|
||||
return new JSONResponse(['message' => 'Invalid path'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
} catch (\OCP\Lock\LockedException $e) {
|
||||
return new JSONResponse(['message' => 'File is locked'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return new JSONResponse([], Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* update preferences (user setting)
|
||||
*
|
||||
* @param string key the identifier to change
|
||||
* @param string value the value to set
|
||||
*
|
||||
* @return JSONResponse an empty JSONResponse with respective http status code
|
||||
*/
|
||||
public function setUserConfig(string $key, string $value): JSONResponse
|
||||
{
|
||||
$user = $this->userSession->getUser();
|
||||
if (null === $user) {
|
||||
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
|
||||
// Make sure not running in read-only mode
|
||||
if ($this->config->getSystemValue('memories.readonly', false)) {
|
||||
return new JSONResponse(['message' => 'Cannot change settings in readonly mode'], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
$userId = $user->getUid();
|
||||
$this->config->setUserValue($userId, Application::APPNAME, $key, $value);
|
||||
|
||||
return new JSONResponse([], Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* @PublicPage
|
||||
*
|
||||
* @NoCSRFRequired
|
||||
*/
|
||||
public function serviceWorker(): StreamResponse
|
||||
{
|
||||
$response = new StreamResponse(__DIR__.'/../../js/memories-service-worker.js');
|
||||
$response->setHeaders([
|
||||
'Content-Type' => 'application/javascript',
|
||||
'Service-Worker-Allowed' => '/',
|
||||
]);
|
||||
$policy = new ContentSecurityPolicy();
|
||||
$policy->addAllowedWorkerSrcDomain("'self'");
|
||||
$policy->addAllowedScriptDomain("'self'");
|
||||
$policy->addAllowedConnectDomain("'self'");
|
||||
$response->setContentSecurityPolicy($policy);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/** Get logged in user's UID or throw HTTP error */
|
||||
private function getUid(): string
|
||||
{
|
||||
$user = $this->userSession->getUser();
|
||||
if ($this->getShareToken()) {
|
||||
$user = null;
|
||||
} elseif (null === $user) {
|
||||
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
|
||||
return $user ? $user->getUID() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transformations depending on the request.
|
||||
*
|
||||
* @param bool $aggregateOnly Only apply transformations for aggregation (days call)
|
||||
*/
|
||||
private function getTransformations(bool $aggregateOnly)
|
||||
{
|
||||
$transforms = [];
|
||||
|
||||
// Add extra information, basename and mimetype
|
||||
if (!$aggregateOnly && ($fields = $this->request->getParam('fields'))) {
|
||||
$fields = explode(',', $fields);
|
||||
$transforms[] = [$this->timelineQuery, 'transformExtraFields', $fields];
|
||||
}
|
||||
|
||||
// Other transforms not allowed for public shares
|
||||
if (null === $this->userSession->getUser()) {
|
||||
return $transforms;
|
||||
}
|
||||
|
||||
// Filter only favorites
|
||||
if ($this->request->getParam('fav')) {
|
||||
$transforms[] = [$this->timelineQuery, 'transformFavoriteFilter'];
|
||||
}
|
||||
|
||||
// Filter only videos
|
||||
if ($this->request->getParam('vid')) {
|
||||
$transforms[] = [$this->timelineQuery, 'transformVideoFilter'];
|
||||
}
|
||||
|
||||
// Filter only for one face
|
||||
if ($this->recognizeIsEnabled()) {
|
||||
$face = $this->request->getParam('face');
|
||||
if ($face) {
|
||||
$transforms[] = [$this->timelineQuery, 'transformFaceFilter', $face];
|
||||
}
|
||||
|
||||
$faceRect = $this->request->getParam('facerect');
|
||||
if ($faceRect && !$aggregateOnly) {
|
||||
$transforms[] = [$this->timelineQuery, 'transformFaceRect', $face];
|
||||
}
|
||||
}
|
||||
|
||||
// Filter only for one tag
|
||||
if ($this->tagsIsEnabled()) {
|
||||
if ($tagName = $this->request->getParam('tag')) {
|
||||
$transforms[] = [$this->timelineQuery, 'transformTagFilter', $tagName];
|
||||
}
|
||||
}
|
||||
|
||||
// Filter for one album
|
||||
if ($this->albumsIsEnabled()) {
|
||||
if ($albumId = $this->request->getParam('album')) {
|
||||
$transforms[] = [$this->timelineQuery, 'transformAlbumFilter', $albumId];
|
||||
}
|
||||
}
|
||||
|
||||
// Limit number of responses for day query
|
||||
$limit = $this->request->getParam('limit');
|
||||
if ($limit) {
|
||||
$transforms[] = [$this->timelineQuery, 'transformLimitDay', (int) $limit];
|
||||
}
|
||||
|
||||
return $transforms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload a few "day" at the start of "days" response.
|
||||
*
|
||||
* @param array $days the days array
|
||||
* @param string $uid User ID or blank for public shares
|
||||
* @param null|Folder $folder the folder to search in
|
||||
* @param bool $recursive search in subfolders
|
||||
* @param bool $archive search in archive folder only
|
||||
*/
|
||||
private function preloadDays(array &$days, string $uid, &$folder, bool $recursive, bool $archive)
|
||||
{
|
||||
$transforms = $this->getTransformations(false);
|
||||
$preloaded = 0;
|
||||
$preloadDayIds = [];
|
||||
$preloadDays = [];
|
||||
foreach ($days as &$day) {
|
||||
if ($day['count'] <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$preloaded += $day['count'];
|
||||
$preloadDayIds[] = $day['dayid'];
|
||||
$preloadDays[] = &$day;
|
||||
|
||||
if ($preloaded >= 50 || \count($preloadDayIds) > 5) { // should be enough
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (\count($preloadDayIds) > 0) {
|
||||
$allDetails = $this->timelineQuery->getDay(
|
||||
$folder,
|
||||
$uid,
|
||||
$preloadDayIds,
|
||||
$recursive,
|
||||
$archive,
|
||||
$transforms,
|
||||
);
|
||||
|
||||
// Group into dayid
|
||||
$detailMap = [];
|
||||
foreach ($allDetails as &$detail) {
|
||||
$detailMap[$detail['dayid']][] = &$detail;
|
||||
}
|
||||
foreach ($preloadDays as &$day) {
|
||||
$m = $detailMap[$day['dayid']];
|
||||
if (isset($m) && null !== $m && \count($m) > 0) {
|
||||
$day['detail'] = $m;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the Folder object relevant to the request */
|
||||
private function getRequestFolder()
|
||||
{
|
||||
// Albums have no folder
|
||||
if ($this->request->getParam('album')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
return $share;
|
||||
}
|
||||
|
||||
// Anything else needs a user
|
||||
$user = $this->userSession->getUser();
|
||||
if (null === $user) {
|
||||
return null;
|
||||
}
|
||||
$uid = $user->getUID();
|
||||
|
||||
$folder = null;
|
||||
$folderPath = $this->request->getParam('folder');
|
||||
$forcedTimelinePath = $this->request->getParam('timelinePath');
|
||||
$userFolder = $this->rootFolder->getUserFolder($uid);
|
||||
|
||||
if (null !== $folderPath) {
|
||||
$folder = $userFolder->get($folderPath);
|
||||
} elseif (null !== $forcedTimelinePath) {
|
||||
$folder = $userFolder->get($forcedTimelinePath);
|
||||
} else {
|
||||
$configPath = Exif::removeExtraSlash(Exif::getPhotosPath($this->config, $uid));
|
||||
$folder = $userFolder->get($configPath);
|
||||
}
|
||||
|
||||
if (!$folder instanceof Folder) {
|
||||
throw new \Exception('Folder not found');
|
||||
}
|
||||
|
||||
return $folder;
|
||||
}
|
||||
|
||||
private function getShareToken()
|
||||
{
|
||||
return $this->request->getParam('folder_share');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if albums are enabled for this user.
|
||||
*/
|
||||
private function albumsIsEnabled(): bool
|
||||
{
|
||||
return \OCA\Memories\Util::albumsIsEnabled($this->appManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tags is enabled for this user.
|
||||
*/
|
||||
private function tagsIsEnabled(): bool
|
||||
{
|
||||
return \OCA\Memories\Util::tagsIsEnabled($this->appManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if recognize is enabled for this user.
|
||||
*/
|
||||
private function recognizeIsEnabled(): bool
|
||||
{
|
||||
return \OCA\Memories\Util::recognizeIsEnabled($this->appManager);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,143 @@
|
|||
<?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 OCA\Memories\Exif;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\Files\Folder;
|
||||
|
||||
class ArchiveController extends ApiBase
|
||||
{
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* Move one file to the archive folder
|
||||
*
|
||||
* @param string fileid
|
||||
*/
|
||||
public function archive(string $id): JSONResponse
|
||||
{
|
||||
$user = $this->userSession->getUser();
|
||||
if (null === $user) {
|
||||
return new JSONResponse(['message' => 'Not logged in'], Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
$uid = $user->getUID();
|
||||
$userFolder = $this->rootFolder->getUserFolder($uid);
|
||||
|
||||
// Check for permissions and get numeric Id
|
||||
$file = $userFolder->getById((int) $id);
|
||||
if (0 === \count($file)) {
|
||||
return new JSONResponse(['message' => 'No such file'], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
$file = $file[0];
|
||||
|
||||
// Check if user has permissions
|
||||
if (!$file->isUpdateable()) {
|
||||
return new JSONResponse(['message' => 'Cannot update this file'], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
// Create archive folder in the root of the user's configured timeline
|
||||
$timelinePath = Exif::removeExtraSlash(Exif::getPhotosPath($this->config, $uid));
|
||||
$timelineFolder = $userFolder->get($timelinePath);
|
||||
if (null === $timelineFolder || !$timelineFolder instanceof Folder) {
|
||||
return new JSONResponse(['message' => 'Cannot get timeline'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
if (!$timelineFolder->isCreatable()) {
|
||||
return new JSONResponse(['message' => 'Cannot create archive folder'], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
// Get path of current file relative to the timeline folder
|
||||
// remove timelineFolder path from start of file path
|
||||
$timelinePath = $timelineFolder->getPath(); // no trailing slash
|
||||
if (substr($file->getPath(), 0, \strlen($timelinePath)) !== $timelinePath) {
|
||||
return new JSONResponse(['message' => 'Files outside timeline cannot be archived'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
$relativePath = substr($file->getPath(), \strlen($timelinePath)); // has a leading slash
|
||||
|
||||
// Final path of the file including the file name
|
||||
$destinationPath = '';
|
||||
|
||||
// Check if we want to archive or unarchive
|
||||
$body = $this->request->getParams();
|
||||
$unarchive = isset($body['archive']) && false === $body['archive'];
|
||||
|
||||
// Get if the file is already in the archive (relativePath starts with archive)
|
||||
$archiveFolderWithLeadingSlash = '/'.\OCA\Memories\Util::$ARCHIVE_FOLDER;
|
||||
if (substr($relativePath, 0, \strlen($archiveFolderWithLeadingSlash)) === $archiveFolderWithLeadingSlash) {
|
||||
// file already in archive, remove it instead
|
||||
$destinationPath = substr($relativePath, \strlen($archiveFolderWithLeadingSlash));
|
||||
if (!$unarchive) {
|
||||
return new JSONResponse(['message' => 'File already archived'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
} else {
|
||||
// file not in archive, put it in there
|
||||
$destinationPath = Exif::removeExtraSlash(\OCA\Memories\Util::$ARCHIVE_FOLDER.$relativePath);
|
||||
if ($unarchive) {
|
||||
return new JSONResponse(['message' => 'File not archived'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the filename
|
||||
$destinationFolders = explode('/', $destinationPath);
|
||||
array_pop($destinationFolders);
|
||||
|
||||
// Create folder tree
|
||||
$folder = $timelineFolder;
|
||||
foreach ($destinationFolders as $folderName) {
|
||||
if ('' === $folderName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$existingFolder = $folder->get($folderName.'/');
|
||||
if (!$existingFolder instanceof Folder) {
|
||||
throw new \OCP\Files\NotFoundException('Not a folder');
|
||||
}
|
||||
$folder = $existingFolder;
|
||||
} catch (\OCP\Files\NotFoundException $e) {
|
||||
try {
|
||||
$folder = $folder->newFolder($folderName);
|
||||
} catch (\OCP\Files\NotPermittedException $e) {
|
||||
return new JSONResponse(['message' => 'Failed to create folder'], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Move file to archive folder
|
||||
try {
|
||||
$file->move($folder->getPath().'/'.$file->getName());
|
||||
} catch (\OCP\Files\NotPermittedException $e) {
|
||||
return new JSONResponse(['message' => 'Failed to move file'], Http::STATUS_FORBIDDEN);
|
||||
} catch (\OCP\Files\NotFoundException $e) {
|
||||
return new JSONResponse(['message' => 'File not found'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
} catch (\OCP\Files\InvalidPathException $e) {
|
||||
return new JSONResponse(['message' => 'Invalid path'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
} catch (\OCP\Lock\LockedException $e) {
|
||||
return new JSONResponse(['message' => 'File is locked'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return new JSONResponse([], Http::STATUS_OK);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,304 @@
|
|||
<?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\JSONResponse;
|
||||
use OCP\Files\FileInfo;
|
||||
use OCP\Files\Folder;
|
||||
|
||||
class DaysController extends ApiBase
|
||||
{
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* @PublicPage
|
||||
*/
|
||||
public function days(): JSONResponse
|
||||
{
|
||||
// Get the folder to show
|
||||
$uid = $this->getUid();
|
||||
|
||||
// Get the folder to show
|
||||
$folder = null;
|
||||
|
||||
try {
|
||||
$folder = $this->getRequestFolder();
|
||||
} catch (\Exception $e) {
|
||||
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Params
|
||||
$recursive = null === $this->request->getParam('folder');
|
||||
$archive = null !== $this->request->getParam('archive');
|
||||
|
||||
// Run actual query
|
||||
try {
|
||||
$list = $this->timelineQuery->getDays(
|
||||
$folder,
|
||||
$uid,
|
||||
$recursive,
|
||||
$archive,
|
||||
$this->getTransformations(true),
|
||||
);
|
||||
|
||||
// Preload some day responses
|
||||
$this->preloadDays($list, $uid, $folder, $recursive, $archive);
|
||||
|
||||
// Add subfolder info if querying non-recursively
|
||||
if (!$recursive) {
|
||||
array_unshift($list, $this->getSubfoldersEntry($folder));
|
||||
}
|
||||
|
||||
return new JSONResponse($list, Http::STATUS_OK);
|
||||
} catch (\Exception $e) {
|
||||
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* @PublicPage
|
||||
*/
|
||||
public function day(string $id): JSONResponse
|
||||
{
|
||||
// Get user
|
||||
$uid = $this->getUid();
|
||||
|
||||
// Check for wildcard
|
||||
$day_ids = [];
|
||||
if ('*' === $id) {
|
||||
$day_ids = null;
|
||||
} else {
|
||||
// Split at commas and convert all parts to int
|
||||
$day_ids = array_map(function ($part) {
|
||||
return (int) $part;
|
||||
}, explode(',', $id));
|
||||
}
|
||||
|
||||
// Check if $day_ids is empty
|
||||
if (null !== $day_ids && 0 === \count($day_ids)) {
|
||||
return new JSONResponse([], Http::STATUS_OK);
|
||||
}
|
||||
|
||||
// Get the folder to show
|
||||
$folder = null;
|
||||
|
||||
try {
|
||||
$folder = $this->getRequestFolder();
|
||||
} catch (\Exception $e) {
|
||||
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Params
|
||||
$recursive = null === $this->request->getParam('folder');
|
||||
$archive = null !== $this->request->getParam('archive');
|
||||
|
||||
// Run actual query
|
||||
try {
|
||||
$list = $this->timelineQuery->getDay(
|
||||
$folder,
|
||||
$uid,
|
||||
$day_ids,
|
||||
$recursive,
|
||||
$archive,
|
||||
$this->getTransformations(false),
|
||||
);
|
||||
|
||||
return new JSONResponse($list, Http::STATUS_OK);
|
||||
} catch (\Exception $e) {
|
||||
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* @PublicPage
|
||||
*/
|
||||
public function dayPost(): JSONResponse
|
||||
{
|
||||
$id = $this->request->getParam('body_ids');
|
||||
if (null === $id) {
|
||||
return new JSONResponse([], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
return $this->day($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subfolders entry for days response.
|
||||
*/
|
||||
public function getSubfoldersEntry(Folder &$folder)
|
||||
{
|
||||
// Ugly: get the view of the folder with reflection
|
||||
// This is unfortunately the only way to get the contents of a folder
|
||||
// matching a MIME type without using SEARCH, which is deep
|
||||
$rp = new \ReflectionProperty('\OC\Files\Node\Node', 'view');
|
||||
$rp->setAccessible(true);
|
||||
$view = $rp->getValue($folder);
|
||||
|
||||
// Get the subfolders
|
||||
$folders = $view->getDirectoryContent($folder->getPath(), FileInfo::MIMETYPE_FOLDER, $folder);
|
||||
|
||||
// Sort by name
|
||||
usort($folders, function ($a, $b) {
|
||||
return strnatcmp($a->getName(), $b->getName());
|
||||
});
|
||||
|
||||
// Process to response type
|
||||
return [
|
||||
'dayid' => \OCA\Memories\Util::$TAG_DAYID_FOLDERS,
|
||||
'count' => \count($folders),
|
||||
'detail' => array_map(function ($node) {
|
||||
return [
|
||||
'fileid' => $node->getId(),
|
||||
'name' => $node->getName(),
|
||||
'isfolder' => 1,
|
||||
'path' => $node->getPath(),
|
||||
];
|
||||
}, $folders, []),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transformations depending on the request.
|
||||
*
|
||||
* @param bool $aggregateOnly Only apply transformations for aggregation (days call)
|
||||
*/
|
||||
private function getTransformations(bool $aggregateOnly)
|
||||
{
|
||||
$transforms = [];
|
||||
|
||||
// Add extra information, basename and mimetype
|
||||
if (!$aggregateOnly && ($fields = $this->request->getParam('fields'))) {
|
||||
$fields = explode(',', $fields);
|
||||
$transforms[] = [$this->timelineQuery, 'transformExtraFields', $fields];
|
||||
}
|
||||
|
||||
// Other transforms not allowed for public shares
|
||||
if (null === $this->userSession->getUser()) {
|
||||
return $transforms;
|
||||
}
|
||||
|
||||
// Filter only favorites
|
||||
if ($this->request->getParam('fav')) {
|
||||
$transforms[] = [$this->timelineQuery, 'transformFavoriteFilter'];
|
||||
}
|
||||
|
||||
// Filter only videos
|
||||
if ($this->request->getParam('vid')) {
|
||||
$transforms[] = [$this->timelineQuery, 'transformVideoFilter'];
|
||||
}
|
||||
|
||||
// Filter only for one face
|
||||
if ($this->recognizeIsEnabled()) {
|
||||
$face = $this->request->getParam('face');
|
||||
if ($face) {
|
||||
$transforms[] = [$this->timelineQuery, 'transformFaceFilter', $face];
|
||||
}
|
||||
|
||||
$faceRect = $this->request->getParam('facerect');
|
||||
if ($faceRect && !$aggregateOnly) {
|
||||
$transforms[] = [$this->timelineQuery, 'transformFaceRect', $face];
|
||||
}
|
||||
}
|
||||
|
||||
// Filter only for one tag
|
||||
if ($this->tagsIsEnabled()) {
|
||||
if ($tagName = $this->request->getParam('tag')) {
|
||||
$transforms[] = [$this->timelineQuery, 'transformTagFilter', $tagName];
|
||||
}
|
||||
}
|
||||
|
||||
// Filter for one album
|
||||
if ($this->albumsIsEnabled()) {
|
||||
if ($albumId = $this->request->getParam('album')) {
|
||||
$transforms[] = [$this->timelineQuery, 'transformAlbumFilter', $albumId];
|
||||
}
|
||||
}
|
||||
|
||||
// Limit number of responses for day query
|
||||
$limit = $this->request->getParam('limit');
|
||||
if ($limit) {
|
||||
$transforms[] = [$this->timelineQuery, 'transformLimitDay', (int) $limit];
|
||||
}
|
||||
|
||||
return $transforms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload a few "day" at the start of "days" response.
|
||||
*
|
||||
* @param array $days the days array
|
||||
* @param string $uid User ID or blank for public shares
|
||||
* @param null|Folder $folder the folder to search in
|
||||
* @param bool $recursive search in subfolders
|
||||
* @param bool $archive search in archive folder only
|
||||
*/
|
||||
private function preloadDays(array &$days, string $uid, &$folder, bool $recursive, bool $archive)
|
||||
{
|
||||
$transforms = $this->getTransformations(false);
|
||||
$preloaded = 0;
|
||||
$preloadDayIds = [];
|
||||
$preloadDays = [];
|
||||
foreach ($days as &$day) {
|
||||
if ($day['count'] <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$preloaded += $day['count'];
|
||||
$preloadDayIds[] = $day['dayid'];
|
||||
$preloadDays[] = &$day;
|
||||
|
||||
if ($preloaded >= 50 || \count($preloadDayIds) > 5) { // should be enough
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (\count($preloadDayIds) > 0) {
|
||||
$allDetails = $this->timelineQuery->getDay(
|
||||
$folder,
|
||||
$uid,
|
||||
$preloadDayIds,
|
||||
$recursive,
|
||||
$archive,
|
||||
$transforms,
|
||||
);
|
||||
|
||||
// Group into dayid
|
||||
$detailMap = [];
|
||||
foreach ($allDetails as &$detail) {
|
||||
$detailMap[$detail['dayid']][] = &$detail;
|
||||
}
|
||||
foreach ($preloadDays as &$day) {
|
||||
$m = $detailMap[$day['dayid']];
|
||||
if (isset($m) && null !== $m && \count($m) > 0) {
|
||||
$day['detail'] = $m;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,150 @@
|
|||
<?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
|
||||
$folder = $this->getRequestFolder();
|
||||
if (null === $folder) {
|
||||
return new JSONResponse([], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Run actual query
|
||||
$list = $this->timelineQuery->getFaces(
|
||||
$folder,
|
||||
);
|
||||
|
||||
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
|
||||
$folder = $this->getRequestFolder();
|
||||
if (null === $folder) {
|
||||
return new JSONResponse([], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Run actual query
|
||||
$detections = $this->timelineQuery->getFacePreviewDetection($folder, (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;
|
||||
foreach ($detections as &$detection) {
|
||||
// Get the file (also checks permissions)
|
||||
$files = $folder->getById($detection['file_id']);
|
||||
if (0 === \count($files) || FileInfo::TYPE_FILE !== $files[0]->getType()) {
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,115 @@
|
|||
<?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 OCA\Memories\Exif;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
|
||||
class ImageController extends ApiBase
|
||||
{
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* Get image info for one file
|
||||
*
|
||||
* @param string fileid
|
||||
*/
|
||||
public function info(string $id): JSONResponse
|
||||
{
|
||||
$user = $this->userSession->getUser();
|
||||
if (null === $user) {
|
||||
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
|
||||
|
||||
// Check for permissions and get numeric Id
|
||||
$file = $userFolder->getById((int) $id);
|
||||
if (0 === \count($file)) {
|
||||
return new JSONResponse([], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
$file = $file[0];
|
||||
|
||||
// Get the image info
|
||||
$info = $this->timelineQuery->getInfoById($file->getId());
|
||||
|
||||
return new JSONResponse($info, Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* Change exif data for one file
|
||||
*
|
||||
* @param string fileid
|
||||
*/
|
||||
public function edit(string $id): JSONResponse
|
||||
{
|
||||
$user = $this->userSession->getUser();
|
||||
if (null === $user) {
|
||||
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
|
||||
|
||||
// Check for permissions and get numeric Id
|
||||
$file = $userFolder->getById((int) $id);
|
||||
if (0 === \count($file)) {
|
||||
return new JSONResponse([], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
$file = $file[0];
|
||||
|
||||
// Check if user has permissions
|
||||
if (!$file->isUpdateable()) {
|
||||
return new JSONResponse([], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
// Get new date from body
|
||||
$body = $this->request->getParams();
|
||||
if (!isset($body['date'])) {
|
||||
return new JSONResponse(['message' => 'Missing date'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Make sure the date is valid
|
||||
try {
|
||||
Exif::parseExifDate($body['date']);
|
||||
} catch (\Exception $e) {
|
||||
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Update date
|
||||
try {
|
||||
$res = Exif::updateExifDate($file, $body['date']);
|
||||
if (false === $res) {
|
||||
return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
// Reprocess the file
|
||||
$this->timelineWrite->processFile($file, true);
|
||||
|
||||
return $this->info($id);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
<?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 OCA\Memories\AppInfo\Application;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\ContentSecurityPolicy;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\AppFramework\Http\StreamResponse;
|
||||
|
||||
class OtherController extends ApiBase
|
||||
{
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* update preferences (user setting)
|
||||
*
|
||||
* @param string key the identifier to change
|
||||
* @param string value the value to set
|
||||
*
|
||||
* @return JSONResponse an empty JSONResponse with respective http status code
|
||||
*/
|
||||
public function setUserConfig(string $key, string $value): JSONResponse
|
||||
{
|
||||
$user = $this->userSession->getUser();
|
||||
if (null === $user) {
|
||||
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
|
||||
// Make sure not running in read-only mode
|
||||
if ($this->config->getSystemValue('memories.readonly', false)) {
|
||||
return new JSONResponse(['message' => 'Cannot change settings in readonly mode'], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
$userId = $user->getUid();
|
||||
$this->config->setUserValue($userId, Application::APPNAME, $key, $value);
|
||||
|
||||
return new JSONResponse([], Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* @PublicPage
|
||||
*
|
||||
* @NoCSRFRequired
|
||||
*/
|
||||
public function serviceWorker(): StreamResponse
|
||||
{
|
||||
$response = new StreamResponse(__DIR__.'/../../js/memories-service-worker.js');
|
||||
$response->setHeaders([
|
||||
'Content-Type' => 'application/javascript',
|
||||
'Service-Worker-Allowed' => '/',
|
||||
]);
|
||||
$policy = new ContentSecurityPolicy();
|
||||
$policy->addAllowedWorkerSrcDomain("'self'");
|
||||
$policy->addAllowedScriptDomain("'self'");
|
||||
$policy->addAllowedConnectDomain("'self'");
|
||||
$response->setContentSecurityPolicy($policy);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
|
@ -109,7 +109,7 @@ class PageController extends Controller
|
|||
*
|
||||
* @NoCSRFRequired
|
||||
*/
|
||||
public function sharedFolder(string $token)
|
||||
public function sharedfolder(string $token)
|
||||
{
|
||||
// Scripts
|
||||
Util::addScript($this->appName, 'memories-main');
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
<?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\JSONResponse;
|
||||
|
||||
class TagsController extends ApiBase
|
||||
{
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* Get list of tags with counts of images
|
||||
*/
|
||||
public function tags(): JSONResponse
|
||||
{
|
||||
$user = $this->userSession->getUser();
|
||||
if (null === $user) {
|
||||
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
|
||||
// Check tags enabled for this user
|
||||
if (!$this->tagsIsEnabled()) {
|
||||
return new JSONResponse(['message' => 'Tags not enabled for user'], Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
|
||||
// If this isn't the timeline folder then things aren't going to work
|
||||
$folder = $this->getRequestFolder();
|
||||
if (null === $folder) {
|
||||
return new JSONResponse([], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Run actual query
|
||||
$list = $this->timelineQuery->getTags(
|
||||
$folder,
|
||||
);
|
||||
|
||||
return new JSONResponse($list, Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* Get previews for a tag
|
||||
*/
|
||||
public function previews(): JSONResponse
|
||||
{
|
||||
$user = $this->userSession->getUser();
|
||||
if (null === $user) {
|
||||
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
|
||||
// Check tags enabled for this user
|
||||
if (!$this->tagsIsEnabled()) {
|
||||
return new JSONResponse(['message' => 'Tags not enabled for user'], Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
|
||||
// If this isn't the timeline folder then things aren't going to work
|
||||
$folder = $this->getRequestFolder();
|
||||
if (null === $folder) {
|
||||
return new JSONResponse([], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Get the tag
|
||||
$tagName = $this->request->getParam('tag');
|
||||
|
||||
// Run actual query
|
||||
$list = $this->timelineQuery->getTagPreviews(
|
||||
$tagName,
|
||||
$folder,
|
||||
);
|
||||
|
||||
return new JSONResponse($list, Http::STATUS_OK);
|
||||
}
|
||||
}
|
|
@ -151,8 +151,7 @@ class TimelineWrite
|
|||
*/
|
||||
public function clear()
|
||||
{
|
||||
$query = $this->connection->getQueryBuilder();
|
||||
$query->delete('memories');
|
||||
$query->executeStatement();
|
||||
$sql = $this->connection->getDatabasePlatform()->getTruncateTableSQL('`*PREFIX*memories`', false);
|
||||
$this->connection->executeStatement($sql);
|
||||
}
|
||||
}
|
||||
|
|
205
lib/Exif.php
205
lib/Exif.php
|
@ -104,25 +104,12 @@ class Exif
|
|||
*/
|
||||
public static function getExifFromFile(File &$file)
|
||||
{
|
||||
// Borrowed from previews
|
||||
// https://github.com/nextcloud/server/blob/19f68b3011a3c040899fb84975a28bd746bddb4b/lib/private/Preview/ProviderV2.php
|
||||
if (!$file->isEncrypted() && $file->getStorage()->isLocal()) {
|
||||
$path = $file->getStorage()->getLocalFile($file->getInternalPath());
|
||||
if (\is_string($path)) {
|
||||
return self::getExifFromLocalPath($path);
|
||||
}
|
||||
$path = $file->getStorage()->getLocalFile($file->getInternalPath());
|
||||
if (!\is_string($path)) {
|
||||
throw new \Exception('Failed to get local file path');
|
||||
}
|
||||
|
||||
// Fallback to reading as a stream
|
||||
$handle = $file->fopen('rb');
|
||||
if (!$handle) {
|
||||
throw new \Exception('Could not open file');
|
||||
}
|
||||
|
||||
$exif = self::getExifFromStream($handle);
|
||||
fclose($handle);
|
||||
|
||||
return $exif;
|
||||
return self::getExifFromLocalPath($path);
|
||||
}
|
||||
|
||||
/** Get exif data as a JSON object from a local file path */
|
||||
|
@ -137,45 +124,6 @@ class Exif
|
|||
return self::getExifFromLocalPathWithSeparateProc($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get exif data as a JSON object from a stream.
|
||||
*
|
||||
* @param resource $handle
|
||||
*/
|
||||
public static function getExifFromStream(&$handle)
|
||||
{
|
||||
// Start exiftool and output to json
|
||||
$pipes = [];
|
||||
$proc = proc_open([self::getExiftool(), '-api', 'QuickTimeUTC=1', '-n', '-json', '-fast', '-'], [
|
||||
0 => ['pipe', 'rb'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
], $pipes);
|
||||
|
||||
// Write the file to exiftool's stdin
|
||||
// Warning: this is slow for big files
|
||||
// Copy a maximum of 20MB; this may be $$$
|
||||
stream_copy_to_stream($handle, $pipes[0], 20 * 1024 * 1024);
|
||||
fclose($pipes[0]);
|
||||
|
||||
// Get output from exiftool
|
||||
stream_set_blocking($pipes[1], false);
|
||||
|
||||
try {
|
||||
$stdout = self::readOrTimeout($pipes[1], 5000);
|
||||
|
||||
return self::processStdout($stdout);
|
||||
} catch (\Exception $ex) {
|
||||
error_log('Exiftool timeout for file stream: '.$ex->getMessage());
|
||||
|
||||
throw new \Exception('Could not read from Exiftool');
|
||||
} finally {
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
proc_terminate($proc);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse date from exif format and throw error if invalid.
|
||||
*
|
||||
|
@ -283,114 +231,23 @@ class Exif
|
|||
*/
|
||||
public static function updateExifDate(File &$file, string $newDate)
|
||||
{
|
||||
// Check for local files -- this is easier
|
||||
if (!$file->isEncrypted() && $file->getStorage()->isLocal()) {
|
||||
$path = $file->getStorage()->getLocalFile($file->getInternalPath());
|
||||
if (\is_string($path)) {
|
||||
return self::updateExifDateForLocalFile($path, $newDate);
|
||||
}
|
||||
// Don't want to mess these up, definitely
|
||||
if ($file->isEncrypted()) {
|
||||
throw new \Exception('Cannot update exif date on encrypted files');
|
||||
}
|
||||
|
||||
// Use a stream
|
||||
return self::updateExifDateForStreamFile($file, $newDate);
|
||||
}
|
||||
// Get path to local (copy) of the file
|
||||
$path = $file->getStorage()->getLocalFile($file->getInternalPath());
|
||||
if (!\is_string($path)) {
|
||||
throw new \Exception('Failed to get local file path');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update exif date for stream.
|
||||
*
|
||||
* @param string $newDate formatted in standard Exif format (YYYY:MM:DD HH:MM:SS)
|
||||
*/
|
||||
public static function updateExifDateForStreamFile(File &$file, string $newDate)
|
||||
{
|
||||
// Temp file for output, so we can compare sizes before writing to the actual file
|
||||
$tmpfile = tmpfile();
|
||||
// Update exif data
|
||||
self::updateExifDateForLocalFile($path, $newDate);
|
||||
|
||||
try {
|
||||
// Start exiftool and output to json
|
||||
$pipes = [];
|
||||
$proc = proc_open([
|
||||
self::getExiftool(), '-api', 'QuickTimeUTC=1',
|
||||
'-overwrite_original', '-DateTimeOriginal='.$newDate, '-',
|
||||
], [
|
||||
0 => ['pipe', 'rb'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
], $pipes);
|
||||
|
||||
// Write the file to exiftool's stdin
|
||||
// Warning: this is slow for big files
|
||||
$in = $file->fopen('rb');
|
||||
if (!$in) {
|
||||
throw new \Exception('Could not open file');
|
||||
}
|
||||
$origLen = stream_copy_to_stream($in, $pipes[0]);
|
||||
fclose($in);
|
||||
fclose($pipes[0]);
|
||||
|
||||
// Get output from exiftool
|
||||
stream_set_blocking($pipes[1], false);
|
||||
$newLen = 0;
|
||||
|
||||
try {
|
||||
// Read and copy stdout of exiftool to the temp file
|
||||
$waitedMs = 0;
|
||||
$timeout = 300000;
|
||||
while ($waitedMs < $timeout && !feof($pipes[1])) {
|
||||
$r = stream_copy_to_stream($pipes[1], $tmpfile, 1024 * 1024);
|
||||
$newLen += $r;
|
||||
if (0 === $r) {
|
||||
++$waitedMs;
|
||||
usleep(1000);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ($waitedMs >= $timeout) {
|
||||
throw new \Exception('Timeout');
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
error_log('Exiftool timeout for file stream: '.$ex->getMessage());
|
||||
|
||||
throw new \Exception('Could not read from Exiftool');
|
||||
} finally {
|
||||
// Close the pipes
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
proc_terminate($proc);
|
||||
}
|
||||
|
||||
// Check the new length of the file
|
||||
// If the new length and old length are more different than 1KB, abort
|
||||
if (abs($newLen - $origLen) > 1024) {
|
||||
error_log("Exiftool error: new length {$newLen}, old length {$origLen}");
|
||||
|
||||
throw new \Exception("Exiftool error: new length {$newLen}, old length {$origLen}");
|
||||
}
|
||||
|
||||
// Write the temp file to the actual file
|
||||
fseek($tmpfile, 0);
|
||||
$out = $file->fopen('wb');
|
||||
if (!$out) {
|
||||
throw new \Exception('Could not open file for writing');
|
||||
}
|
||||
$wroteBytes = 0;
|
||||
|
||||
try {
|
||||
$wroteBytes = stream_copy_to_stream($tmpfile, $out);
|
||||
} finally {
|
||||
fclose($out);
|
||||
}
|
||||
if ($wroteBytes !== $newLen) {
|
||||
error_log("Exiftool error: wrote {$r} bytes, expected {$newLen}");
|
||||
|
||||
throw new \Exception('Could not write to file');
|
||||
}
|
||||
|
||||
// All done at this point
|
||||
return true;
|
||||
} finally {
|
||||
// Close the temp file
|
||||
fclose($tmpfile);
|
||||
// Update remote file if not local
|
||||
if (!$file->getStorage()->isLocal()) {
|
||||
$file->putContent(fopen($path, 'r')); // closes the handler
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -404,9 +261,11 @@ class Exif
|
|||
|
||||
// We know already where it is
|
||||
if (!empty($configPath) && file_exists($configPath)) {
|
||||
chmod($configPath, 0755);
|
||||
if (!is_executable($configPath)) {
|
||||
chmod($configPath, 0755);
|
||||
}
|
||||
|
||||
return $configPath;
|
||||
return explode(' ', $configPath);
|
||||
}
|
||||
|
||||
// Detect architecture
|
||||
|
@ -436,7 +295,9 @@ class Exif
|
|||
// check if file exists
|
||||
if (file_exists($path)) {
|
||||
// make executable before version check
|
||||
chmod($path, 0755);
|
||||
if (!is_executable($path)) {
|
||||
chmod($path, 0755);
|
||||
}
|
||||
|
||||
// check if the version prints correctly
|
||||
$ver = self::EXIFTOOL_VER;
|
||||
|
@ -446,7 +307,7 @@ class Exif
|
|||
echo "Exiftool binary version check passed {$out} <==> {$ver}\n";
|
||||
$config->setSystemValue($configKey, $path);
|
||||
|
||||
return $path;
|
||||
return [$path];
|
||||
}
|
||||
error_log("Exiftool version check failed {$vero} <==> {$ver}");
|
||||
$config->setSystemValue($configKey.'_no_local', true);
|
||||
|
@ -458,22 +319,20 @@ class Exif
|
|||
// Fallback to perl script
|
||||
$path = __DIR__.'/../exiftool-bin/exiftool/exiftool';
|
||||
if (file_exists($path)) {
|
||||
chmod($path, 0755);
|
||||
|
||||
return $path;
|
||||
return ['perl', $path];
|
||||
}
|
||||
|
||||
error_log("Exiftool not found: {$path}");
|
||||
|
||||
// Fallback to system binary
|
||||
return 'exiftool';
|
||||
return ['exiftool'];
|
||||
}
|
||||
|
||||
/** Initialize static exiftool process for local reads */
|
||||
private static function initializeStaticExiftoolProc()
|
||||
{
|
||||
self::closeStaticExiftoolProc();
|
||||
self::$staticProc = proc_open([self::getExiftool(), '-stay_open', 'true', '-@', '-'], [
|
||||
self::$staticProc = proc_open(array_merge(self::getExiftool(), ['-stay_open', 'true', '-@', '-']), [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
|
@ -535,7 +394,7 @@ class Exif
|
|||
private static function getExifFromLocalPathWithSeparateProc(string &$path)
|
||||
{
|
||||
$pipes = [];
|
||||
$proc = proc_open([self::getExiftool(), '-api', 'QuickTimeUTC=1', '-n', '-json', $path], [
|
||||
$proc = proc_open(array_merge(self::getExiftool(), ['-api', 'QuickTimeUTC=1', '-n', '-json', $path]), [
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
], $pipes);
|
||||
|
@ -572,11 +431,11 @@ class Exif
|
|||
*
|
||||
* @param string $newDate formatted in standard Exif format (YYYY:MM:DD HH:MM:SS)
|
||||
*
|
||||
* @return bool
|
||||
* @throws \Exception on failure
|
||||
*/
|
||||
private static function updateExifDateForLocalFile(string $path, string $newDate)
|
||||
{
|
||||
$cmd = [self::getExiftool(), '-api', 'QuickTimeUTC=1', '-overwrite_original', '-DateTimeOriginal='.$newDate, $path];
|
||||
$cmd = array_merge(self::getExiftool(), ['-api', 'QuickTimeUTC=1', '-overwrite_original', '-DateTimeOriginal='.$newDate, $path]);
|
||||
$proc = proc_open($cmd, [
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
|
@ -590,7 +449,5 @@ class Exif
|
|||
|
||||
throw new \Exception('Could not update exif date: '.$stdout);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
"vue-material-design-icons": "^5.1.2",
|
||||
"vue-property-decorator": "^9.1.2",
|
||||
"vue-router": "^3.5.4",
|
||||
"vue-virtual-scroller": "^1.1.2",
|
||||
"vue-virtual-scroller": "1.1.2",
|
||||
"webdav": "^4.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -43,7 +43,7 @@
|
|||
"vue-material-design-icons": "^5.1.2",
|
||||
"vue-property-decorator": "^9.1.2",
|
||||
"vue-router": "^3.5.4",
|
||||
"vue-virtual-scroller": "^1.1.2",
|
||||
"vue-virtual-scroller": "1.1.2",
|
||||
"webdav": "^4.11.0"
|
||||
},
|
||||
"browserslist": [
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
--- ./node_modules/vue-virtual-scroller/dist/vue-virtual-scroller.esm.js 2022-10-29 15:40:12.517184534 -0700
|
||||
+++ ./node_modules/vue-virtual-scroller/dist/vue-virtual-scroller.esm.js 2022-10-29 15:40:42.814432774 -0700
|
||||
@@ -99,6 +99,10 @@
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
+ updateInterval: {
|
||||
+ type: Number,
|
||||
+ default: 0,
|
||||
+ },
|
||||
skipHover: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
@@ -262,7 +266,9 @@
|
||||
handleScroll(event) {
|
||||
if (!this.$_scrollDirty) {
|
||||
this.$_scrollDirty = true;
|
||||
- requestAnimationFrame(() => {
|
||||
+ if (this.$_updateTimeout) return
|
||||
+
|
||||
+ const requestUpdate = () => requestAnimationFrame(() => {
|
||||
this.$_scrollDirty = false;
|
||||
const {
|
||||
continuous
|
||||
@@ -272,9 +278,19 @@
|
||||
// When non continous scrolling is ending, we force a refresh
|
||||
if (!continuous) {
|
||||
clearTimeout(this.$_refreshTimout);
|
||||
- this.$_refreshTimout = setTimeout(this.handleScroll, 100);
|
||||
+ this.$_refreshTimout = setTimeout(this.handleScroll, this.updateInterval + 100);
|
||||
}
|
||||
});
|
||||
+
|
||||
+ requestUpdate()
|
||||
+
|
||||
+ // Schedule the next update with throttling
|
||||
+ if (this.updateInterval) {
|
||||
+ this.$_updateTimeout = setTimeout(() => {
|
||||
+ this.$_updateTimeout = 0
|
||||
+ if (this.$_scrollDirty) requestUpdate();
|
||||
+ }, this.updateInterval)
|
||||
+ }
|
||||
}
|
||||
},
|
||||
handleVisibilityChange(isVisible, entry) {
|
||||
@@ -505,7 +521,7 @@
|
||||
// After the user has finished scrolling
|
||||
// Sort views so text selection is correct
|
||||
clearTimeout(this.$_sortTimer);
|
||||
- this.$_sortTimer = setTimeout(this.sortViews, 300);
|
||||
+ this.$_sortTimer = setTimeout(this.sortViews, this.updateInterval + 300);
|
||||
return {
|
||||
continuous
|
||||
};
|
|
@ -4,7 +4,7 @@
|
|||
cd apps/memories
|
||||
npm i
|
||||
cp ../../vue.zip .
|
||||
unzip vue.zip
|
||||
unzip -qq vue.zip
|
||||
cd ../..
|
||||
|
||||
# Speed up loads
|
||||
|
|
|
@ -5,14 +5,14 @@ exifver="12.49"
|
|||
rm -rf exiftool-bin
|
||||
mkdir -p exiftool-bin
|
||||
cd exiftool-bin
|
||||
wget "https://github.com/pulsejet/exiftool-bin/releases/download/$exifver/exiftool-amd64-musl"
|
||||
wget "https://github.com/pulsejet/exiftool-bin/releases/download/$exifver/exiftool-amd64-glibc"
|
||||
wget "https://github.com/pulsejet/exiftool-bin/releases/download/$exifver/exiftool-aarch64-musl"
|
||||
wget "https://github.com/pulsejet/exiftool-bin/releases/download/$exifver/exiftool-aarch64-glibc"
|
||||
wget -q "https://github.com/pulsejet/exiftool-bin/releases/download/$exifver/exiftool-amd64-musl"
|
||||
wget -q "https://github.com/pulsejet/exiftool-bin/releases/download/$exifver/exiftool-amd64-glibc"
|
||||
wget -q "https://github.com/pulsejet/exiftool-bin/releases/download/$exifver/exiftool-aarch64-musl"
|
||||
wget -q "https://github.com/pulsejet/exiftool-bin/releases/download/$exifver/exiftool-aarch64-glibc"
|
||||
chmod 755 *
|
||||
|
||||
wget "https://github.com/exiftool/exiftool/archive/refs/tags/$exifver.zip"
|
||||
unzip "$exifver.zip"
|
||||
wget -q "https://github.com/exiftool/exiftool/archive/refs/tags/$exifver.zip"
|
||||
unzip -qq "$exifver.zip"
|
||||
mv "exiftool-$exifver" exiftool
|
||||
rm -rf *.zip exiftool/t exiftool/html
|
||||
chmod 755 exiftool/exiftool
|
||||
|
|
|
@ -112,7 +112,7 @@ import UserConfig from "./mixins/UserConfig";
|
|||
import ImageMultiple from "vue-material-design-icons/ImageMultiple.vue";
|
||||
import FolderIcon from "vue-material-design-icons/Folder.vue";
|
||||
import Star from "vue-material-design-icons/Star.vue";
|
||||
import Video from "vue-material-design-icons/Video.vue";
|
||||
import Video from "vue-material-design-icons/PlayCircle.vue";
|
||||
import AlbumIcon from "vue-material-design-icons/ImageAlbum.vue";
|
||||
import ArchiveIcon from "vue-material-design-icons/PackageDown.vue";
|
||||
import CalendarIcon from "vue-material-design-icons/Calendar.vue";
|
||||
|
@ -179,6 +179,12 @@ export default class App extends Mixins(GlobalMixin, UserConfig) {
|
|||
|
||||
mounted() {
|
||||
this.doRouteChecks();
|
||||
|
||||
// Store CSS variables modified
|
||||
const root = document.documentElement;
|
||||
const colorPrimary =
|
||||
getComputedStyle(root).getPropertyValue("--color-primary");
|
||||
root.style.setProperty("--color-primary-select-light", `${colorPrimary}40`);
|
||||
}
|
||||
|
||||
async beforeMount() {
|
||||
|
|
|
@ -3,13 +3,15 @@
|
|||
class="scroller"
|
||||
ref="scroller"
|
||||
v-bind:class="{
|
||||
'scrolling-recycler': scrollingRecycler,
|
||||
scrolling: scrolling,
|
||||
'scrolling-recycler-now': scrollingRecyclerNowTimer,
|
||||
'scrolling-recycler': scrollingRecyclerTimer,
|
||||
'scrolling-now': scrollingNowTimer,
|
||||
scrolling: scrollingTimer,
|
||||
}"
|
||||
@mousemove="mousemove"
|
||||
@touchmove="touchmove"
|
||||
@mouseleave="mouseleave"
|
||||
@mousedown="mousedown"
|
||||
@mousemove.passive="mousemove"
|
||||
@touchmove.passive="touchmove"
|
||||
@mouseleave.passive="mouseleave"
|
||||
@mousedown.passive="mousedown"
|
||||
>
|
||||
<span
|
||||
class="cursor st"
|
||||
|
@ -21,7 +23,7 @@
|
|||
<span
|
||||
class="cursor hv"
|
||||
:style="{ transform: `translateY(${hoverCursorY}px)` }"
|
||||
@touchmove="touchmove"
|
||||
@touchmove.passive="touchmove"
|
||||
>
|
||||
<div class="text">{{ hoverCursorText }}</div>
|
||||
<div class="icon"><ScrollIcon :size="22" /></div>
|
||||
|
@ -40,7 +42,7 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Mixins, Prop } from "vue-property-decorator";
|
||||
import { Component, Mixins, Prop, Watch } from "vue-property-decorator";
|
||||
import { IRow, IRowType, ITick } from "../types";
|
||||
import GlobalMixin from "../mixins/GlobalMixin";
|
||||
import ScrollIcon from "vue-material-design-icons/UnfoldMoreHorizontal.vue";
|
||||
|
@ -66,6 +68,8 @@ export default class ScrollerManager extends Mixins(GlobalMixin) {
|
|||
private lastAdjustHeight = 0;
|
||||
/** Height of the entire photo view */
|
||||
private recyclerHeight: number = 100;
|
||||
/** Rect of scroller */
|
||||
private scrollerRect: DOMRect = null;
|
||||
/** Computed ticks */
|
||||
private ticks: ITick[] = [];
|
||||
/** Computed cursor top */
|
||||
|
@ -74,14 +78,16 @@ export default class ScrollerManager extends Mixins(GlobalMixin) {
|
|||
private hoverCursorY = -5;
|
||||
/** Hover cursor text */
|
||||
private hoverCursorText = "";
|
||||
/** Scrolling currently */
|
||||
private scrolling = false;
|
||||
/** Scrolling timer */
|
||||
private scrollingTimer = null as number | null;
|
||||
/** Scrolling recycler currently */
|
||||
private scrollingRecycler = false;
|
||||
/** Scrolling recycler timer */
|
||||
private scrollingRecyclerTimer = null as number | null;
|
||||
/** Scrolling using the scroller */
|
||||
private scrollingTimer = 0;
|
||||
/** Scrolling now using the scroller */
|
||||
private scrollingNowTimer = 0;
|
||||
/** Scrolling recycler */
|
||||
private scrollingRecyclerTimer = 0;
|
||||
/** Scrolling recycler now */
|
||||
private scrollingRecyclerNowTimer = 0;
|
||||
/** Recycler scrolling throttle */
|
||||
private scrollingRecyclerUpdateTimer = 0;
|
||||
/** View size reflow timer */
|
||||
private reflowRequest = false;
|
||||
/** Tick adjust timer */
|
||||
|
@ -108,13 +114,37 @@ export default class ScrollerManager extends Mixins(GlobalMixin) {
|
|||
this.cursorY = 0;
|
||||
this.hoverCursorY = -5;
|
||||
this.hoverCursorText = "";
|
||||
this.scrolling = false;
|
||||
this.scrollingTimer = null;
|
||||
this.reflowRequest = false;
|
||||
|
||||
// Clear all timers
|
||||
clearTimeout(this.scrollingTimer);
|
||||
clearTimeout(this.scrollingNowTimer);
|
||||
clearTimeout(this.scrollingRecyclerTimer);
|
||||
clearTimeout(this.scrollingRecyclerNowTimer);
|
||||
clearTimeout(this.scrollingRecyclerUpdateTimer);
|
||||
this.scrollingTimer = 0;
|
||||
this.scrollingNowTimer = 0;
|
||||
this.scrollingRecyclerTimer = 0;
|
||||
this.scrollingRecyclerNowTimer = 0;
|
||||
this.scrollingRecyclerUpdateTimer = 0;
|
||||
}
|
||||
|
||||
/** Recycler scroll event, must be called by timeline */
|
||||
public recyclerScrolled() {
|
||||
// This isn't a renewing timer, it's a scheduled task
|
||||
if (this.scrollingRecyclerUpdateTimer) return;
|
||||
this.scrollingRecyclerUpdateTimer = window.setTimeout(() => {
|
||||
this.scrollingRecyclerUpdateTimer = 0;
|
||||
this.updateFromRecyclerScroll();
|
||||
}, 100);
|
||||
|
||||
// Update that we're scrolling with the recycler
|
||||
utils.setRenewingTimeout(this, "scrollingRecyclerNowTimer", null, 200);
|
||||
utils.setRenewingTimeout(this, "scrollingRecyclerTimer", null, 1500);
|
||||
}
|
||||
|
||||
/** Update cursor position from recycler scroll position */
|
||||
public updateFromRecyclerScroll() {
|
||||
// Ignore if not initialized
|
||||
if (!this.ticks.length) return;
|
||||
|
||||
|
@ -136,15 +166,6 @@ export default class ScrollerManager extends Mixins(GlobalMixin) {
|
|||
} else {
|
||||
this.moveHoverCursor(rtop);
|
||||
}
|
||||
|
||||
// Show the scroller for some time
|
||||
if (this.scrollingRecyclerTimer)
|
||||
window.clearTimeout(this.scrollingRecyclerTimer);
|
||||
this.scrollingRecycler = true;
|
||||
this.scrollingRecyclerTimer = window.setTimeout(() => {
|
||||
this.scrollingRecycler = false;
|
||||
this.scrollingRecyclerTimer = null;
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
/** Re-create tick data in the next frame */
|
||||
|
@ -290,6 +311,11 @@ export default class ScrollerManager extends Mixins(GlobalMixin) {
|
|||
|
||||
/** Mark ticks as visible or invisible */
|
||||
private computeVisibleTicks() {
|
||||
// Kind of unrelated here, but refresh rect
|
||||
this.scrollerRect = (
|
||||
this.$refs.scroller as HTMLElement
|
||||
).getBoundingClientRect();
|
||||
|
||||
// Do another pass to figure out which points are visible
|
||||
// This is not as bad as it looks, it's actually 12*O(n)
|
||||
// because there are only 12 months in a year
|
||||
|
@ -427,6 +453,10 @@ export default class ScrollerManager extends Mixins(GlobalMixin) {
|
|||
|
||||
/** Move to given scroller Y */
|
||||
private moveto(y: number) {
|
||||
// Move cursor immediately to prevent jank
|
||||
this.cursorY = y;
|
||||
this.hoverCursorY = y;
|
||||
|
||||
const { top1, top2, y1, y2 } = this.getCoords(y, "topF");
|
||||
const yfrac = (y - top1) / (top2 - top1);
|
||||
const ry = y1 + (y2 - y1) * yfrac;
|
||||
|
@ -442,21 +472,15 @@ export default class ScrollerManager extends Mixins(GlobalMixin) {
|
|||
|
||||
/** Handle touch */
|
||||
private touchmove(event: any) {
|
||||
const rect = (this.$refs.scroller as HTMLElement).getBoundingClientRect();
|
||||
const y = event.targetTouches[0].pageY - rect.top;
|
||||
event.preventDefault();
|
||||
const y = event.targetTouches[0].pageY - this.scrollerRect.top;
|
||||
event.stopPropagation();
|
||||
this.moveto(y);
|
||||
}
|
||||
|
||||
/** Update this is being used to scroll recycler */
|
||||
/** Update scroller is being used to scroll recycler */
|
||||
private handleScroll() {
|
||||
if (this.scrollingTimer) window.clearTimeout(this.scrollingTimer);
|
||||
this.scrolling = true;
|
||||
this.scrollingTimer = window.setTimeout(() => {
|
||||
this.scrolling = false;
|
||||
this.scrollingTimer = null;
|
||||
}, 1500);
|
||||
utils.setRenewingTimeout(this, "scrollingNowTimer", null, 200);
|
||||
utils.setRenewingTimeout(this, "scrollingTimer", null, 1500);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -579,8 +603,14 @@ export default class ScrollerManager extends Mixins(GlobalMixin) {
|
|||
}
|
||||
}
|
||||
}
|
||||
&:hover > .cursor.st {
|
||||
opacity: 1;
|
||||
&.scrolling-recycler-now:not(.scrolling-now) > .cursor {
|
||||
transition: transform 0.1s linear;
|
||||
}
|
||||
&:hover > .cursor {
|
||||
transition: none !important;
|
||||
&.st {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -31,6 +31,7 @@
|
|||
key-field="id"
|
||||
size-field="size"
|
||||
type-field="type"
|
||||
:updateInterval="100"
|
||||
@update="scrollChange"
|
||||
@resize="handleResizeWithDelay"
|
||||
>
|
||||
|
@ -270,7 +271,7 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
|
|||
(this.$refs.recycler as any).$el.addEventListener(
|
||||
"scroll",
|
||||
this.scrollPositionChange,
|
||||
false
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
// Get data
|
||||
|
@ -1163,6 +1164,7 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
|
|||
}
|
||||
|
||||
.recycler {
|
||||
contain: strict;
|
||||
height: 300px;
|
||||
width: calc(100% + 20px);
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
|
@ -1174,6 +1176,7 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
|
|||
}
|
||||
|
||||
.recycler .photo {
|
||||
contain: strict;
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
@ -1185,6 +1188,7 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
|
|||
}
|
||||
|
||||
.head-row {
|
||||
contain: strict;
|
||||
padding-top: 10px;
|
||||
padding-left: 3px;
|
||||
font-size: 0.9em;
|
||||
|
@ -1231,6 +1235,7 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
|
|||
}
|
||||
&.selected .select {
|
||||
opacity: 1;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
@include phone {
|
||||
|
|
|
@ -8,24 +8,24 @@
|
|||
error: data.flag & c.FLAG_LOAD_FAIL,
|
||||
}"
|
||||
>
|
||||
<Check
|
||||
:size="15"
|
||||
<CheckCircle
|
||||
:size="18"
|
||||
class="select"
|
||||
v-if="!(data.flag & c.FLAG_PLACEHOLDER)"
|
||||
@click="toggleSelect"
|
||||
/>
|
||||
|
||||
<Video :size="20" v-if="data.flag & c.FLAG_IS_VIDEO" />
|
||||
<Star :size="20" v-if="data.flag & c.FLAG_IS_FAVORITE" />
|
||||
<Video :size="22" v-if="data.flag & c.FLAG_IS_VIDEO" />
|
||||
<Star :size="22" v-if="data.flag & c.FLAG_IS_FAVORITE" />
|
||||
|
||||
<div
|
||||
class="img-outer fill-block"
|
||||
@click="emitClick"
|
||||
@contextmenu="contextmenu"
|
||||
@touchstart="touchstart"
|
||||
@touchmove="touchend"
|
||||
@touchend="touchend"
|
||||
@touchcancel="touchend"
|
||||
@touchstart.passive="touchstart"
|
||||
@touchmove.passive="touchend"
|
||||
@touchend.passive="touchend"
|
||||
@touchcancel.passive="touchend"
|
||||
>
|
||||
<img
|
||||
ref="img"
|
||||
|
@ -35,14 +35,15 @@
|
|||
@load="load"
|
||||
@error="error"
|
||||
/>
|
||||
<div class="overlay" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Check from "vue-material-design-icons/Check.vue";
|
||||
import CheckCircle from "vue-material-design-icons/CheckCircle.vue";
|
||||
import Star from "vue-material-design-icons/Star.vue";
|
||||
import Video from "vue-material-design-icons/Video.vue";
|
||||
import Video from "vue-material-design-icons/PlayCircleOutline.vue";
|
||||
import { Component, Emit, Mixins, Prop, Watch } from "vue-property-decorator";
|
||||
import errorsvg from "../../assets/error.svg";
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
|
@ -51,7 +52,7 @@ import { IDay, IPhoto } from "../../types";
|
|||
|
||||
@Component({
|
||||
components: {
|
||||
Check,
|
||||
CheckCircle,
|
||||
Video,
|
||||
Star,
|
||||
},
|
||||
|
@ -60,6 +61,7 @@ export default class Photo extends Mixins(GlobalMixin) {
|
|||
private touchTimer = 0;
|
||||
private src = null;
|
||||
private hasFaceRect = false;
|
||||
private hasTouch = false;
|
||||
|
||||
@Prop() data: IPhoto;
|
||||
@Prop() day: IDay;
|
||||
|
@ -175,6 +177,7 @@ export default class Photo extends Mixins(GlobalMixin) {
|
|||
}
|
||||
|
||||
touchstart() {
|
||||
this.hasTouch = true;
|
||||
this.touchTimer = window.setTimeout(() => {
|
||||
this.toggleSelect();
|
||||
this.touchTimer = 0;
|
||||
|
@ -182,8 +185,11 @@ export default class Photo extends Mixins(GlobalMixin) {
|
|||
}
|
||||
|
||||
contextmenu(e: Event) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// on mobile only
|
||||
if (this.hasTouch) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
touchend() {
|
||||
|
@ -198,65 +204,104 @@ export default class Photo extends Mixins(GlobalMixin) {
|
|||
<style lang="scss" scoped>
|
||||
/* Container and selection */
|
||||
.p-outer {
|
||||
&.leaving {
|
||||
transition: all 0.2s ease-in;
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Distance of icon from border
|
||||
$icon-dist: min(10px, 6%);
|
||||
|
||||
/* Extra icons */
|
||||
.check-icon.select {
|
||||
position: absolute;
|
||||
top: $icon-dist;
|
||||
left: $icon-dist;
|
||||
z-index: 100;
|
||||
background-color: var(--color-main-background);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
|
||||
.p-outer:hover > & {
|
||||
display: flex;
|
||||
}
|
||||
.selected > & {
|
||||
display: flex;
|
||||
filter: invert(1);
|
||||
}
|
||||
}
|
||||
.video-icon,
|
||||
.star-icon {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
filter: invert(1) brightness(100);
|
||||
}
|
||||
.video-icon {
|
||||
top: $icon-dist;
|
||||
right: $icon-dist;
|
||||
}
|
||||
.star-icon {
|
||||
bottom: $icon-dist;
|
||||
left: $icon-dist;
|
||||
}
|
||||
|
||||
/* Actual image */
|
||||
div.img-outer {
|
||||
padding: 2px;
|
||||
box-sizing: border-box;
|
||||
@media (max-width: 768px) {
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
transition: padding 0.1s ease;
|
||||
background-clip: content-box, padding-box;
|
||||
background-color: var(--color-background-dark);
|
||||
transition: background-color 0.15s ease, opacity 0.2s ease-in,
|
||||
transform 0.2s ease-in;
|
||||
|
||||
.selected > & {
|
||||
padding: calc($icon-dist - 2px);
|
||||
&.leaving {
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: var(--color-primary-select-light);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
--icon-dist: 8px;
|
||||
@media (max-width: 768px) {
|
||||
--icon-dist: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// Distance of icon from border
|
||||
$icon-half-size: 6px;
|
||||
$icon-size: $icon-half-size * 2;
|
||||
|
||||
/* Extra icons */
|
||||
.check-circle-icon.select {
|
||||
position: absolute;
|
||||
top: calc(var(--icon-dist) + 2px);
|
||||
left: calc(var(--icon-dist) + 2px);
|
||||
z-index: 100;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
|
||||
display: none;
|
||||
.p-outer:hover > & {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
opacity: 0.7;
|
||||
&:hover,
|
||||
.p-outer.selected & {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// Extremely ugly way to fill up the space
|
||||
// If this isn't done, bg has a border
|
||||
:deep path {
|
||||
transform: scale(1.19) translate(-1.85px, -1.85px);
|
||||
}
|
||||
|
||||
filter: invert(1) brightness(100);
|
||||
.p-outer.selected > & {
|
||||
display: flex;
|
||||
filter: invert(0);
|
||||
background-color: white;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
.play-circle-outline-icon,
|
||||
.star-icon {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
transition: transform 0.15s ease;
|
||||
filter: invert(1) brightness(100);
|
||||
}
|
||||
.play-circle-outline-icon {
|
||||
top: var(--icon-dist);
|
||||
right: var(--icon-dist);
|
||||
.p-outer.selected > & {
|
||||
transform: translate(-$icon-size, $icon-size);
|
||||
}
|
||||
}
|
||||
.star-icon {
|
||||
bottom: var(--icon-dist);
|
||||
left: var(--icon-dist);
|
||||
.p-outer.selected > & {
|
||||
transform: translate($icon-size, -$icon-size);
|
||||
}
|
||||
}
|
||||
|
||||
/* Actual image */
|
||||
div.img-outer {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
|
||||
transition: padding 0.15s ease;
|
||||
.p-outer.selected > & {
|
||||
padding: calc(var(--icon-dist) + $icon-half-size);
|
||||
}
|
||||
|
||||
.p-outer.placeholder > & {
|
||||
background-color: var(--color-background-dark);
|
||||
background-clip: content-box, padding-box;
|
||||
}
|
||||
|
||||
> img {
|
||||
|
@ -264,15 +309,13 @@ div.img-outer {
|
|||
background-clip: content-box;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
background-color: var(--color-background-dark);
|
||||
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
user-select: none;
|
||||
transition: box-shadow 0.1s ease;
|
||||
transition: border-radius 0.1s ease-in;
|
||||
|
||||
.selected > & {
|
||||
box-shadow: 0 0 4px 2px var(--color-primary);
|
||||
}
|
||||
.p-outer.placeholder > & {
|
||||
display: none;
|
||||
}
|
||||
|
@ -280,5 +323,28 @@ div.img-outer {
|
|||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
& > .overlay {
|
||||
pointer-events: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: translateY(-100%); // very weird stuff
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.2) 0%, transparent 30%);
|
||||
|
||||
display: none;
|
||||
transition: border-radius 0.1s ease-in;
|
||||
.p-outer:not(.selected):hover > & {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
> * {
|
||||
@media (max-width: 768px) {
|
||||
.selected > & {
|
||||
border-radius: $icon-size;
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -132,7 +132,7 @@ export default class Tag extends Mixins(GlobalMixin) {
|
|||
const url = generateUrl(
|
||||
`/apps/memories/api/tag-previews?tag=${this.data.name}`
|
||||
);
|
||||
const cacheUrl = `${url}&today=${Math.floor(todayDayId / 10)}`;
|
||||
const cacheUrl = `${url}&today=${todayDayId}`;
|
||||
const cache = await utils.getCachedData(cacheUrl);
|
||||
if (cache) {
|
||||
this.data.previews = cache as any;
|
||||
|
|
|
@ -93,7 +93,9 @@ export default class OnThisDay extends Mixins(GlobalMixin) {
|
|||
|
||||
mounted() {
|
||||
const inner = this.$refs.inner as HTMLElement;
|
||||
inner.addEventListener("scroll", this.onScroll.bind(this));
|
||||
inner.addEventListener("scroll", this.onScroll.bind(this), {
|
||||
passive: true,
|
||||
});
|
||||
|
||||
this.refresh();
|
||||
}
|
||||
|
|
|
@ -198,6 +198,20 @@ export function getFolderRoutePath(basePath: string) {
|
|||
return path;
|
||||
}
|
||||
|
||||
/** Set a timer that renews if existing */
|
||||
export function setRenewingTimeout(
|
||||
ctx: any,
|
||||
name: string,
|
||||
callback: () => void | null,
|
||||
delay: number
|
||||
) {
|
||||
if (ctx[name]) window.clearTimeout(ctx[name]);
|
||||
ctx[name] = window.setTimeout(() => {
|
||||
ctx[name] = 0;
|
||||
callback?.();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// Outside for set
|
||||
const TagDayID = {
|
||||
START: -(1 << 30),
|
||||
|
|
Loading…
Reference in New Issue