diff options
author | Louis Chemineau <louis@chmn.me> | 2022-09-21 17:50:28 +0300 |
---|---|---|
committer | Louis Chemineau <louis@chmn.me> | 2022-10-20 12:54:08 +0300 |
commit | 3d024beb7ad95e2d8d8641c5e5b4586c57f3625d (patch) | |
tree | 33f1184a97f0b905f7893d268646d9cbc9dccd95 | |
parent | 77674859dcb98a2809eafd785f7b8641960cafe8 (diff) |
Add public link logic
Signed-off-by: Louis Chemineau <louis@chmn.me>
30 files changed, 1592 insertions, 244 deletions
diff --git a/appinfo/routes.php b/appinfo/routes.php index d290fdb0..2389fc2d 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -46,6 +46,14 @@ return [ 'path' => '', ] ], + [ 'name' => 'publicAlbum#get', 'url' => '/public/{ownerId}/{token}', 'verb' => 'GET', + 'requirements' => [ + 'ownerId' => '.*', + ], + 'requirements' => [ + 'token' => '.*', + ], + ], ['name' => 'page#index', 'url' => '/folders/{path}', 'verb' => 'GET', 'postfix' => 'folders', 'requirements' => [ 'path' => '.*', @@ -74,10 +82,8 @@ return [ 'requirements' => [ 'path' => '.*', ], - 'defaults' => [ - 'path' => '', - ] ], + [ 'name' => 'public#get', 'url' => '/display/{token}', 'verb' => 'GET' ], ['name' => 'page#index', 'url' => '/tags/{path}', 'verb' => 'GET', 'postfix' => 'tags', 'requirements' => [ 'path' => '.*', diff --git a/lib/Album/AlbumMapper.php b/lib/Album/AlbumMapper.php index a38ee2a5..577e0064 100644 --- a/lib/Album/AlbumMapper.php +++ b/lib/Album/AlbumMapper.php @@ -33,6 +33,7 @@ use OCP\IGroup; use OCP\IUser; use OCP\IUserManager; use OCP\IGroupManager; +use OCP\IL10N; class AlbumMapper { private IDBConnection $connection; @@ -40,6 +41,7 @@ class AlbumMapper { private ITimeFactory $timeFactory; private IUserManager $userManager; private IGroupManager $groupManager; + protected IL10N $l; // Same mapping as IShare. public const TYPE_USER = 0; @@ -51,13 +53,15 @@ class AlbumMapper { IMimeTypeLoader $mimeTypeLoader, ITimeFactory $timeFactory, IUserManager $userManager, - IGroupManager $groupManager + IGroupManager $groupManager, + IL10N $l ) { $this->connection = $connection; $this->mimeTypeLoader = $mimeTypeLoader; $this->timeFactory = $timeFactory; $this->userManager = $userManager; $this->groupManager = $groupManager; + $this->l = $l; } public function create(string $userId, string $name, string $location = ""): AlbumInfo { @@ -147,12 +151,13 @@ class AlbumMapper { $query->executeStatement(); } - public function setLocation(int $id, string $newLocation): void { + public function setLocation(int $id, string $newLocation): string { $query = $this->connection->getQueryBuilder(); $query->update("photos_albums") ->set("location", $query->createNamedParameter($newLocation)) ->where($query->expr()->eq('album_id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT))); $query->executeStatement(); + return $newLocation; } public function delete(int $id): void { @@ -294,26 +299,29 @@ class AlbumMapper { $collaborators = array_map(function (array $row) { /** @var IUser|IGroup|null */ - $collaborator = null; + $displayName = null; switch ($row['collaborator_type']) { case self::TYPE_USER: - $collaborator = $this->userManager->get($row['collaborator_id']); + $displayName = $this->userManager->get($row['collaborator_id'])->getDisplayName(); break; case self::TYPE_GROUP: - $collaborator = $this->groupManager->get($row['collaborator_id']); + $displayName = $this->groupManager->get($row['collaborator_id'])->getDisplayName(); + break; + case self::TYPE_LINK: + $displayName = $this->l->t('Public link');; break; default: throw new \Exception('Invalid collaborator type: ' . $row['collaborator_type']); } - if (is_null($collaborator)) { + if (is_null($displayName)) { return null; } return [ 'id' => $row['collaborator_id'], - 'label' => $collaborator->getDisplayName(), + 'label' => $displayName, 'type' => $row['collaborator_type'], ]; }, $rows); @@ -345,6 +353,8 @@ class AlbumMapper { throw new \Exception('Unknown collaborator: ' . $collaborator['id']); } break; + case self::TYPE_LINK: + break; default: throw new \Exception('Invalid collaborator type: ' . $collaborator['type']); } @@ -397,7 +407,7 @@ class AlbumMapper { if ($row['fileid']) { $mimeId = $row['mimetype']; $mimeType = $this->mimeTypeLoader->getMimetypeById($mimeId); - $filesByAlbum[$albumId][] = new AlbumFile((int)$row['fileid'], $row['album_name'].' ('.$row['album_user'].')', $mimeType, (int)$row['size'], (int)$row['mtime'], $row['etag'], (int)$row['added'], $row['owner']); + $filesByAlbum[$albumId][] = new AlbumFile((int)$row['fileid'], $row['file_name'], $mimeType, (int)$row['size'], (int)$row['mtime'], $row['etag'], (int)$row['added'], $row['owner']); } if (!isset($albumsById[$albumId])) { diff --git a/lib/Controller/PublicAlbumController.php b/lib/Controller/PublicAlbumController.php new file mode 100644 index 00000000..b9a9364e --- /dev/null +++ b/lib/Controller/PublicAlbumController.php @@ -0,0 +1,123 @@ +<?php + +/** + * @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me> + * + * @author Louis Chemineau <louis@chmn.me> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OCA\Photos\Controller; + +use OCP\AppFramework\PublicShareController; +use OCA\Files\Event\LoadSidebar; +use OCA\Photos\AppInfo\Application; +use OCA\Photos\Service\UserConfigService; +use OCA\Viewer\Event\LoadViewer; +use OCP\App\IAppManager; +use OCP\AppFramework\Http\ContentSecurityPolicy; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\AppFramework\Services\IInitialState; +use OCP\IRequest; +use OCP\ISession; +use OCP\Util; + + +class PublicAlbumController extends PublicShareController { + private IAppManager $appManager; + private IEventDispatcher $eventDispatcher; + private UserConfigService $userConfig; + private IInitialState $initialState; + + public function __construct( + IRequest $request, + ISession $session, + IAppManager $appManager, + IEventDispatcher $eventDispatcher, + UserConfigService $userConfig, + IInitialState $initialState, + ) { + parent::__construct(Application::APP_ID, $request, $session); + + $this->appManager = $appManager; + $this->eventDispatcher = $eventDispatcher; + $this->userConfig = $userConfig; + $this->initialState = $initialState; + } + + /** + * Validate the token of this share. If the token is invalid this controller + * will return a 404. + */ + public function isValidToken(): bool { + // TODO: uncomment + // $album = $this->albumMapper->getAlbumForToken($this->getToken); + // return $album !== null; + return true; + } + + public function getPasswordHash(): string { + return ''; + } + + /** + * Allows you to specify if this share is password protected + */ + protected function isPasswordProtected(): bool { + return false; + } + + /** + * Your normal controller function. The following annotation will allow guests + * to open the page as well + * + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + * + * @return TemplateResponse + */ + public function get(): TemplateResponse { + $this->eventDispatcher->dispatch(LoadSidebar::class, new LoadSidebar()); + $this->eventDispatcher->dispatch(LoadViewer::class, new LoadViewer()); + + $this->initialState->provideInitialState('image-mimes', Application::IMAGE_MIMES); + $this->initialState->provideInitialState('video-mimes', Application::VIDEO_MIMES); + $this->initialState->provideInitialState('maps', $this->appManager->isEnabledForUser('maps') === true); + $this->initialState->provideInitialState('recognize', $this->appManager->isEnabledForUser('recognize') === true); + $this->initialState->provideInitialState('systemtags', $this->appManager->isEnabledForUser('systemtags') === true); + + // Provide user config + foreach (array_keys(UserConfigService::DEFAULT_CONFIGS) as $key) { + $this->initialState->provideInitialState($key, $this->userConfig->getUserConfig($key)); + } + + Util::addScript(Application::APP_ID, 'photos-public'); + Util::addStyle(Application::APP_ID, 'icons'); + + $response = new TemplateResponse(Application::APP_ID, 'main'); + + $policy = new ContentSecurityPolicy(); + $policy->addAllowedWorkerSrcDomain("'self'"); + $policy->addAllowedScriptDomain("'self'"); + $response->setContentSecurityPolicy($policy); + + return $response; + } +}
\ No newline at end of file diff --git a/lib/Sabre/Album/AlbumPhoto.php b/lib/Sabre/Album/AlbumPhoto.php index a5e51bff..24ab0c41 100644 --- a/lib/Sabre/Album/AlbumPhoto.php +++ b/lib/Sabre/Album/AlbumPhoto.php @@ -152,9 +152,11 @@ class AlbumPhoto implements IFile { switch ($favoriteState) { case "0": - return $tagger->removeFromFavorites($this->albumFile->getFileId()); + $tagger->removeFromFavorites($this->albumFile->getFileId()); + return "0"; case "1": - return $tagger->addToFavorites($this->albumFile->getFileId()); + $tagger->addToFavorites($this->albumFile->getFileId()); + return "1"; default: new \Exception('Favorite state is invalide, should be 0 or 1.'); } diff --git a/lib/Sabre/Album/AlbumRoot.php b/lib/Sabre/Album/AlbumRoot.php index 4c42f799..e2fe6c9f 100644 --- a/lib/Sabre/Album/AlbumRoot.php +++ b/lib/Sabre/Album/AlbumRoot.php @@ -218,7 +218,7 @@ class AlbumRoot implements ICollection, ICopyTarget { } /** - * @return array + * @return array{'id': string, 'label': string, 'type': int} */ public function getCollaborators() { return array_map( @@ -226,4 +226,13 @@ class AlbumRoot implements ICollection, ICopyTarget { $this->albumMapper->getCollaborators($this->album->getAlbum()->getId()), ); } + + /** + * @param array{'id': string, 'type': int} $collaborators + * @return array{'id': string, 'label': string, 'type': int} + */ + public function setCollaborators($collaborators) { + $this->albumMapper->setCollaborators($this->getAlbum()->getAlbum()->getId(), $collaborators); + return $this->getCollaborators(); + } } diff --git a/lib/Sabre/Album/PropFindPlugin.php b/lib/Sabre/Album/PropFindPlugin.php index 46d1c225..00fb8017 100644 --- a/lib/Sabre/Album/PropFindPlugin.php +++ b/lib/Sabre/Album/PropFindPlugin.php @@ -78,7 +78,7 @@ class PropFindPlugin extends ServerPlugin { public function propFind(PropFind $propFind, INode $node): void { if ($node instanceof AlbumPhoto) { - // Checking if the node is trulely available and ignoring if not + // Checking if the node is truly available and ignoring if not // Should be pre-emptively handled by the NodeDeletedEvent try { $fileInfo = $node->getFileInfo(); @@ -87,12 +87,10 @@ class PropFindPlugin extends ServerPlugin { } $propFind->handle(FilesPlugin::INTERNAL_FILEID_PROPERTYNAME, fn () => $node->getFile()->getFileId()); - $propFind->handle(FilesPlugin::GETETAG_PROPERTYNAME, fn () => $node->getETag()); - $propFind->handle(self::FILE_NAME_PROPERTYNAME, fn () => $node->getFile()->getName()); - $propFind->handle(self::FAVORITE_PROPERTYNAME, fn () => $node->isFavorite() ? 1 : 0); - $propFind->handle(FilesPlugin::HAS_PREVIEW_PROPERTYNAME, function () use ($fileInfo) { - return json_encode($this->previewManager->isAvailable($fileInfo)); - }); + $propFind->handle(FilesPlugin::GETETAG_PROPERTYNAME, fn () => $node->getETag()); + $propFind->handle(self::FILE_NAME_PROPERTYNAME, fn () => $node->getFile()->getName()); + $propFind->handle(self::FAVORITE_PROPERTYNAME, fn () => $node->isFavorite() ? 1 : 0); + $propFind->handle(FilesPlugin::HAS_PREVIEW_PROPERTYNAME, fn () => json_encode($this->previewManager->isAvailable($fileInfo))); if ($this->metadataEnabled) { $propFind->handle(FilesPlugin::FILE_METADATA_SIZE, function () use ($node) { @@ -111,11 +109,11 @@ class PropFindPlugin extends ServerPlugin { } } - if ($node instanceof AlbumRoot || $node instanceof SharedAlbumRoot) { - $propFind->handle(self::LAST_PHOTO_PROPERTYNAME, fn () => $node->getAlbum()->getAlbum()->getLastAddedPhoto()); - $propFind->handle(self::NBITEMS_PROPERTYNAME, fn () => count($node->getChildren())); - $propFind->handle(self::LOCATION_PROPERTYNAME, fn () => $node->getAlbum()->getAlbum()->getLocation()); - $propFind->handle(self::DATE_RANGE_PROPERTYNAME, fn () => json_encode($node->getDateRange())); + if ($node instanceof AlbumRoot) { + $propFind->handle(self::LAST_PHOTO_PROPERTYNAME, fn () => $node->getAlbum()->getAlbum()->getLastAddedPhoto()); + $propFind->handle(self::NBITEMS_PROPERTYNAME, fn () => count($node->getChildren())); + $propFind->handle(self::LOCATION_PROPERTYNAME, fn () => $node->getAlbum()->getAlbum()->getLocation()); + $propFind->handle(self::DATE_RANGE_PROPERTYNAME, fn () => json_encode($node->getDateRange())); $propFind->handle(self::COLLABORATORS_PROPERTYNAME, fn () => $node->getCollaborators()); // TODO detect dynamically which metadata groups are requested and @@ -141,7 +139,7 @@ class PropFindPlugin extends ServerPlugin { return true; }); $propPatch->handle(self::COLLABORATORS_PROPERTYNAME, function ($collaborators) use ($node) { - $this->albumMapper->setCollaborators($node->getAlbum()->getAlbum()->getId(), json_decode($collaborators, true)); + $collaborators = $node->setCollaborators(json_decode($collaborators, true)); return true; }); } diff --git a/lib/Sabre/Album/PublicAlbumRoot.php b/lib/Sabre/Album/PublicAlbumRoot.php new file mode 100644 index 00000000..8ca2e1e6 --- /dev/null +++ b/lib/Sabre/Album/PublicAlbumRoot.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); +/** + * @copyright Copyright (c) 2022 Robin Appelman <robin@icewind.nl> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OCA\Photos\Sabre\Album; + +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\Conflict; +use OCP\Files\Folder; + +class PublicAlbumRoot extends AlbumRoot { + /** + * @return void + */ + public function delete() { + throw new Forbidden('Not allowed to delete a public album'); + } + + /** + * @return void + */ + public function setName($name) { + throw new Forbidden('Not allowed to rename a public album'); + } + + // TODO: uncomment else it is a security hole. + // public function copyInto($targetName, $sourcePath, INode $sourceNode): bool { + // throw new Forbidden('Not allowed to copy into a public album'); + // } + + /** + * We cannot create files in an Album + * We add the file to the default Photos folder and then link it there. + * + * @param [type] $name + * @param [type] $data + * @return void + */ + public function createFile($name, $data = null) { + try { + $albumOwner = $this->album->getAlbum()->getUserId(); + $photosLocation = $this->userConfigService->getConfigForUser($albumOwner, 'photosLocation'); + $photosFolder = $this->rootFolder->getUserFolder($albumOwner)->get($photosLocation); + if (!($photosFolder instanceof Folder)) { + throw new Conflict('The destination exists and is not a folder'); + } + + // Check for conflict and rename the file accordingly + $newName = \basename(\OC_Helper::buildNotExistingFileName($photosLocation, $name)); + + $node = $photosFolder->newFile($newName, $data); + $this->addFile($node->getId(), $node->getOwner()->getUID()); + // Cheating with header because we are using fileID-fileName + // https://github.com/nextcloud/server/blob/af29b978078ffd9169a9bd9146feccbb7974c900/apps/dav/lib/Connector/Sabre/FilesPlugin.php#L564-L585 + \header('OC-FileId: ' . $node->getId()); + return '"' . $node->getEtag() . '"'; + } catch (\Exception $e) { + throw new Forbidden('Could not create file'); + } + } + + + protected function addFile(int $sourceId, string $ownerUID): bool { + if (in_array($sourceId, $this->album->getFileIds())) { + throw new Conflict("File $sourceId is already in the folder"); + } + + $this->albumMapper->addFile($this->album->getAlbum()->getId(), $sourceId, $ownerUID); + return true; + } + + // Do not reveal collaborators for public albums. + public function getCollaborators() { + return []; + } +} diff --git a/lib/Sabre/Album/PublicAlbumsHome.php b/lib/Sabre/Album/PublicAlbumsHome.php new file mode 100644 index 00000000..9d7d7362 --- /dev/null +++ b/lib/Sabre/Album/PublicAlbumsHome.php @@ -0,0 +1,73 @@ +<?php + +declare(strict_types=1); +/** + * @copyright Copyright (c) 2022 Robin Appelman <robin@icewind.nl> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OCA\Photos\Sabre\Album; + +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use OCP\Files\IRootFolder; +use OCP\IUser; +use OCA\Photos\Sabre\Album\PublicAlbumRoot; +use OCA\Photos\Service\UserConfigService; +use OCA\Photos\Album\AlbumMapper; + +class PublicAlbumsHome extends AlbumsHome { + public function __construct( + array $principalInfo, + AlbumMapper $albumMapper, + IUser $user, + IRootFolder $rootFolder, + UserConfigService $userConfigService, + ) { + parent::__construct( + $principalInfo, + $albumMapper, + $user, + $rootFolder, + $userConfigService, + ); + } + + public function getName(): string { + return 'public'; + } + + /** + * @return never + */ + public function createDirectory($name) { + throw new Forbidden('Not allowed to create folders in this folder'); + } + + public function getChild($name) { + $albums = $this->albumMapper->getSharedAlbumsForCollaboratorWithFiles($name, AlbumMapper::TYPE_LINK); + + array_filter($albums, fn ($album) => $album->getAlbum()->getUserId() === $this->user->getUid()); + + if (count($albums) !== 1) { + throw new NotFound(); + } + + return new PublicAlbumRoot($this->albumMapper, $albums[0], $this->rootFolder, $this->userFolder, $this->user, $this->userConfigService); + } +} diff --git a/lib/Sabre/Album/SharedAlbumRoot.php b/lib/Sabre/Album/SharedAlbumRoot.php index 26a2272e..607a009f 100644 --- a/lib/Sabre/Album/SharedAlbumRoot.php +++ b/lib/Sabre/Album/SharedAlbumRoot.php @@ -59,4 +59,9 @@ class SharedAlbumRoot extends AlbumRoot { $this->albumMapper->addFile($this->album->getAlbum()->getId(), $sourceId, $ownerUID); return true; } + + // Do not reveal collaborators for shared albums. + public function getCollaborators() { + return []; + } } diff --git a/lib/Sabre/Album/SharedAlbumsHome.php b/lib/Sabre/Album/SharedAlbumsHome.php index 89a0dd51..799ea15d 100644 --- a/lib/Sabre/Album/SharedAlbumsHome.php +++ b/lib/Sabre/Album/SharedAlbumsHome.php @@ -41,7 +41,6 @@ class SharedAlbumsHome extends AlbumsHome { IRootFolder $rootFolder, IGroupManager $groupManager, UserConfigService $userConfigService - ) { parent::__construct( $principalInfo, @@ -66,7 +65,7 @@ class SharedAlbumsHome extends AlbumsHome { } /** - * @return AlbumRoot[] + * @return SharedAlbumRoot[] */ public function getChildren(): array { if ($this->children === null) { @@ -82,7 +81,6 @@ class SharedAlbumsHome extends AlbumsHome { $this->children = array_map(function (AlbumWithFiles $folder) { return new SharedAlbumRoot($this->albumMapper, $folder, $this->rootFolder, $this->userFolder, $this->user, $this->userConfigService); }, $albums); - ; } return $this->children; diff --git a/lib/Sabre/PhotosHome.php b/lib/Sabre/PhotosHome.php index 668983fb..ba4d266e 100644 --- a/lib/Sabre/PhotosHome.php +++ b/lib/Sabre/PhotosHome.php @@ -26,6 +26,7 @@ namespace OCA\Photos\Sabre; use OCA\Photos\Album\AlbumMapper; use OCA\Photos\Sabre\Album\AlbumsHome; use OCA\Photos\Sabre\Album\SharedAlbumsHome; +use OCA\Photos\Sabre\Album\PublicAlbumsHome; use OCA\Photos\Service\UserConfigService; use OCP\Files\IRootFolder; use OCP\IUser; @@ -93,13 +94,15 @@ class PhotosHome implements ICollection { return new AlbumsHome($this->principalInfo, $this->albumMapper, $this->user, $this->rootFolder, $this->userConfigService); } elseif ($name === 'sharedalbums') { return new SharedAlbumsHome($this->principalInfo, $this->albumMapper, $this->user, $this->rootFolder, $this->groupManager, $this->userConfigService); + } elseif ($name === 'public') { + return new PublicAlbumsHome($this->principalInfo, $this->albumMapper, $this->user, $this->rootFolder, $this->userConfigService); } throw new NotFound(); } /** - * @return (AlbumsHome|SharedAlbumsHome)[] + * @return (AlbumsHome|SharedAlbumsHome|PublicAlbumHome)[] */ public function getChildren(): array { return [new AlbumsHome($this->principalInfo, $this->albumMapper, $this->user, $this->rootFolder, $this->userConfigService)]; diff --git a/lib/Service/UserConfigService.php b/lib/Service/UserConfigService.php index 58292081..cf717d83 100644 --- a/lib/Service/UserConfigService.php +++ b/lib/Service/UserConfigService.php @@ -48,13 +48,16 @@ class UserConfigService { } public function getUserConfig(string $key) { + $user = $this->userSession->getUser(); + return $this->getConfigForUser($user->getUid(), $key); + } + + public function getConfigForUser(string $userId, string $key) { if (!in_array($key, array_keys(self::DEFAULT_CONFIGS))) { throw new Exception('Unknown user config key'); } - - $user = $this->userSession->getUser(); $default = self::DEFAULT_CONFIGS[$key]; - $value = $this->config->getUserValue($user->getUid(), Application::APP_ID, $key, $default); + $value = $this->config->getUserValue($userId, Application::APP_ID, $key, $default); return $value; } diff --git a/src/PhotosPublic.vue b/src/PhotosPublic.vue new file mode 100644 index 00000000..18d008ef --- /dev/null +++ b/src/PhotosPublic.vue @@ -0,0 +1,102 @@ +<!-- + - @copyright Copyright (c) 2019 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/>. + - + --> + +<template> + <NcContent app-name="photos"> + <NcAppContent> + <router-view /> + + <!-- svg img loading placeholder (linked to the File component) --> + <!-- eslint-disable-next-line vue/no-v-html (because it's an SVG file) --> + <span class="hidden-visually" role="none" v-html="svgplaceholder" /> + <!-- eslint-disable-next-line vue/no-v-html (because it's an SVG file) --> + <span class="hidden-visually" role="none" v-html="imgplaceholder" /> + <!-- eslint-disable-next-line vue/no-v-html (because it's an SVG file) --> + <span class="hidden-visually" role="none" v-html="videoplaceholder" /> + </NcAppContent> + </NcContent> +</template> + +<script> +import { generateUrl } from '@nextcloud/router' + +import { NcContent, NcAppContent } from '@nextcloud/vue' + +import svgplaceholder from './assets/file-placeholder.svg' +import imgplaceholder from './assets/image.svg' +import videoplaceholder from './assets/video.svg' + +export default { + name: 'PhotosPublic', + components: { + NcAppContent, + NcContent, + }, + data() { + return { + svgplaceholder, + imgplaceholder, + videoplaceholder, + } + }, + + async beforeMount() { + if ('serviceWorker' in navigator) { + // Use the window load event to keep the page load performant + window.addEventListener('load', () => { + navigator.serviceWorker.register(generateUrl('/apps/photos/service-worker.js', {}, { + noRewrite: true, + }), { + scope: '/', + }).then(registration => { + console.debug('SW registered: ', registration) + }).catch(registrationError => { + console.error('SW registration failed: ', registrationError) + }) + + }) + } else { + console.debug('Service Worker is not enabled on this browser.') + } + }, + + beforeDestroy() { + window.removeEventListener('load', () => { + navigator.serviceWorker.register(generateUrl('/apps/photos/service-worker.js', {}, { + noRewrite: true, + })) + }) + }, +} +</script> +<style lang="scss"> +.app-content { + display: flex; + flex-grow: 1; + flex-direction: column; + align-content: space-between; +} + +.app-navigation__photos::v-deep .app-navigation-entry-icon.icon-photos { + background-size: 20px; +} +</style> diff --git a/src/components/Albums/CollaboratorsSelectionForm.vue b/src/components/Albums/CollaboratorsSelectionForm.vue index 03ef7ddc..d55a3a83 100644 --- a/src/components/Albums/CollaboratorsSelectionForm.vue +++ b/src/components/Albums/CollaboratorsSelectionForm.vue @@ -46,16 +46,16 @@ </label> <ul v-if="searchResults.length !== 0" :id="`manage-collaborators__form__list-${randomId}`" class="manage-collaborators__form__list"> - <li v-for="result of searchResults" :key="result.key"> + <li v-for="collaboratorKey of searchResults" :key="collaboratorKey"> <a> - <NcListItemIcon :id="availableCollaborators[result.key].id" + <NcListItemIcon :id="availableCollaborators[collaboratorKey].id" class="manage-collaborators__form__list__result" - :title="availableCollaborators[result.key].id" + :title="availableCollaborators[collaboratorKey].id" :search="searchText" - :user="availableCollaborators[result.key].id" - :display-name="availableCollaborators[result.key].label" - :aria-label="t('photos', 'Add {collaboratorLabel} to the collaborators list', {collaboratorLabel: availableCollaborators[result.key].label})" - @click="selectEntity(result.key)" /> + :user="availableCollaborators[collaboratorKey].id" + :display-name="availableCollaborators[collaboratorKey].label" + :aria-label="t('photos', 'Add {collaboratorLabel} to the collaborators list', {collaboratorLabel: availableCollaborators[collaboratorKey].label})" + @click="selectEntity(collaboratorKey)" /> </a> </li> </ul> @@ -69,7 +69,7 @@ </form> <ul class="manage-collaborators__selection"> - <li v-for="collaboratorKey of selectedCollaboratorsKeys" + <li v-for="collaboratorKey of listableSelectedCollaboratorsKeys" :key="collaboratorKey" class="manage-collaborators__selection__item"> <NcListItemIcon :id="availableCollaborators[collaboratorKey].id" @@ -87,8 +87,9 @@ <div class="actions"> <div v-if="allowPublicLink" class="actions__public-link"> - <template v-if="publicLink"> + <template v-if="isPublicLinkSelected"> <NcButton class="manage-collaborators__public-link-button" + :aria-label="t('photos', 'Copy the public link')" @click="copyPublicLink"> <template v-if="publicLinkCopied"> {{ t('photos', 'Public link copied!') }} @@ -101,7 +102,7 @@ <ContentCopy v-else /> </template> </NcButton> - <NcButton @click="deletePublicLink"> + <NcButton type="tertiary" :aria-label="t('photos', 'Delete the public link')" @click="deletePublicLink"> <Close slot="icon" /> </NcButton> </template> @@ -119,28 +120,30 @@ </div> </div> </template> - <script> +import { mapActions } from 'vuex' + import Magnify from 'vue-material-design-icons/Magnify' import Close from 'vue-material-design-icons/Close' +import Check from 'vue-material-design-icons/Check' +import ContentCopy from 'vue-material-design-icons/ContentCopy' import AccountGroup from 'vue-material-design-icons/AccountGroup' import Earth from 'vue-material-design-icons/Earth' import axios from '@nextcloud/axios' import { showError } from '@nextcloud/dialogs' import { getCurrentUser } from '@nextcloud/auth' -import { generateOcsUrl } from '@nextcloud/router' +import { generateOcsUrl, generateUrl } from '@nextcloud/router' import { NcButton, NcListItemIcon, NcLoadingIcon, NcPopover, NcTextField, NcEmptyContent } from '@nextcloud/vue' import logger from '../../services/logger.js' -const SHARE = { - TYPE: { - USER: 0, - GROUP: 1, - // LINK: 3, - }, -} +/** + * @typedef {object} Collaborator + * @property {string} id - The id of the collaborator. + * @property {string} label - The label of the collaborator for display. + * @property {0|1|3} type - The type of the collaborator. + */ export default { name: 'CollaboratorsSelectionForm', @@ -149,6 +152,8 @@ export default { Magnify, Close, AccountGroup, + ContentCopy, + Check, Earth, NcLoadingIcon, NcButton, @@ -164,16 +169,12 @@ export default { required: true, }, + /** @type {import('vue').PropType<Collaborator[]>} */ collaborators: { type: Array, default: () => [], }, - publicLink: { - type: String, - default: '', - }, - allowPublicLink: { type: Boolean, default: true, @@ -183,8 +184,11 @@ export default { data() { return { searchText: '', + /** @type {Object<string, Collaborator>} */ availableCollaborators: {}, + /** @type {string[]} */ selectedCollaboratorsKeys: [], + /** @type {Collaborator[]} */ currentSearchResults: [], loadingCollaborators: false, randomId: Math.random().toString().substring(2, 10), @@ -192,6 +196,12 @@ export default { config: { minSearchStringLength: parseInt(OC.config['sharing.minSearchStringLength'], 10) || 0, }, + /** @type {Collaborator} */ + publicLink: { + id: (Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2)).substring(0, 15), + label: t('photos', 'Public link'), + type: OC.Share.SHARE_TYPE_LINK, + }, } }, @@ -203,29 +213,56 @@ export default { return this.currentSearchResults .filter(({ id }) => id !== getCurrentUser().uid) .map(({ type, id }) => `${type}:${id}`) - .filter(key => !this.selectedCollaboratorsKeys.includes(key)) - .map((key) => ({ key, height: 48 })) + .filter(collaboratorKey => !this.selectedCollaboratorsKeys.includes(collaboratorKey)) }, /** - * @return {object[]} + * @return {string[]} + */ + listableSelectedCollaboratorsKeys() { + return this.selectedCollaboratorsKeys + .filter(collaboratorKey => this.availableCollaborators[collaboratorKey].type !== OC.Share.SHARE_TYPE_LINK) + }, + + /** + * @return {Collaborator[]} */ selectedCollaborators() { - return this.selectedCollaboratorsKeys.map((collaboratorKey) => this.availableCollaborators[collaboratorKey]) + return this.selectedCollaboratorsKeys + .map((collaboratorKey) => this.availableCollaborators[collaboratorKey]) + }, + + /** + * @return {boolean} + */ + isPublicLinkSelected() { + return this.selectedCollaborators + .some(collaborator => collaborator.type === OC.Share.SHARE_TYPE_LINK) + }, }, mounted() { this.searchCollaborators() - this.selectedCollaboratorsKeys = this.collaborators.map(({ type, id }) => `${type}:${id}`) + + const initialCollaborators = this.collaborators.reduce(this.indexCollaborators, {}) + const publicLink = this.collaborators.find(collaborator => collaborator.type === OC.Share.SHARE_TYPE_LINK) + + if (publicLink !== undefined) { + this.publicLink = publicLink + } + + this.selectedCollaboratorsKeys = Object.keys(initialCollaborators) this.availableCollaborators = { + [`${this.publicLink.type}:${this.publicLink.id}`]: this.publicLink, ...this.availableCollaborators, - ...this.collaborators - .reduce((collaborators, collaborator) => ({ ...collaborators, [`${collaborator.type}:${collaborator.id}`]: collaborator }), {}), + ...initialCollaborators, } }, methods: { + ...mapActions(['updateAlbum']), + /** * Fetch possible collaborators. */ @@ -241,30 +278,27 @@ export default { search: this.searchText, itemType: 'share-recipients', shareTypes: [ - SHARE.TYPE.USER, - SHARE.TYPE.GROUP, + OC.Share.SHARE_TYPE_USER, + OC.Share.SHARE_TYPE_GROUP, ], }, }) this.currentSearchResults = response.data.ocs.data .map(collaborator => { - let type = -1 switch (collaborator.source) { case 'users': - type = OC.Share.SHARE_TYPE_USER - break + return { id: collaborator.id, label: collaborator.label, type: OC.Share.SHARE_TYPE_USER } case 'groups': - type = OC.Share.SHARE_TYPE_GROUP - break + return { id: collaborator.id, label: collaborator.label, type: OC.Share.SHARE_TYPE_GROUP } + default: + throw new Error(`Invalid collaborator source ${collaborator.source}`) } - - return { ...collaborator, type } }) + this.availableCollaborators = { ...this.availableCollaborators, - ...this.currentSearchResults - .reduce((collaborators, collaborator) => ({ ...collaborators, [`${collaborator.type}:${collaborator.id}`]: collaborator }), {}), + ...this.currentSearchResults.reduce(this.indexCollaborators, {}), } } catch (error) { this.errorFetchingCollaborators = error @@ -275,17 +309,46 @@ export default { } }, - // TODO: implement public sharing + /** + * @param {Object<string, Collaborator>} collaborators - Index of collaborators + * @param {Collaborator} collaborator - A collaborator + */ + indexCollaborators(collaborators, collaborator) { + return { ...collaborators, [`${collaborator.type}:${collaborator.id}`]: collaborator } + }, + async createPublicLinkForAlbum() { - return axios.put(generateOcsUrl(`apps/photos/createPublicLink/${this.albumName}`)) + this.selectEntity(`${this.publicLink.type}:${this.publicLink.id}`) + await this.updateAlbumCollaborators() }, async deletePublicLink() { - return axios.delete(generateOcsUrl(`apps/photos/createPublicLink/${this.albumName}`)) + this.unselectEntity(`${this.publicLink.type}:${this.publicLink.id}`) + + this.publicLinkCopied = false + + delete this.availableCollaborators[`${this.publicLink.type}:${this.publicLink.id}`] + this.publicLink = { + id: (Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2)).substring(0, 15), + label: t('photos', 'Public link'), + type: OC.Share.SHARE_TYPE_LINK, + } + this.availableCollaborators[`${this.publicLink.type}:${this.publicLink.id}`] = this.publicLink + + await this.updateAlbumCollaborators() + }, + + async updateAlbumCollaborators() { + await this.updateAlbum({ + albumName: this.albumName, + properties: { + collaborators: this.selectedCollaborators, + }, + }) }, async copyPublicLink() { - await navigator.clipboard.writeText(this.publicLink) + await navigator.clipboard.writeText(`${window.location.protocol}//${window.location.host}${generateUrl(`apps/photos/public/${getCurrentUser().uid}/${this.publicLink.id}`)}`) this.publicLinkCopied = true setTimeout(() => { this.publicLinkCopied = false @@ -308,7 +371,6 @@ export default { }, } </script> - <style lang="scss" scoped> .manage-collaborators { display: flex; @@ -396,6 +458,10 @@ export default { &__public-link { display: flex; align-items: center; + + button { + margin-left: 8px; + } } &__slot { diff --git a/src/components/Collection/CollectionContent.vue b/src/components/Collection/CollectionContent.vue index 5e483317..f61341cb 100644 --- a/src/components/Collection/CollectionContent.vue +++ b/src/components/Collection/CollectionContent.vue @@ -24,7 +24,7 @@ <NcEmptyContent v-if="collection === undefined && !loading" class="empty-content-with-illustration" :title="t('photos', 'This collection does not exist')"> - <FolderMultipleImage /> + <FolderMultipleImage slot="icon" /> </NcEmptyContent> <NcEmptyContent v-else-if="error" :title="t('photos', 'An error occurred')"> <AlertCircle slot="icon" /> @@ -102,8 +102,8 @@ export default { }, error: { - type: [Error], - default: '', + type: [Error, Number], + default: null, }, semaphore: { diff --git a/src/components/HeaderNavigation.vue b/src/components/HeaderNavigation.vue index 5933c088..7fc65bfe 100644 --- a/src/components/HeaderNavigation.vue +++ b/src/components/HeaderNavigation.vue @@ -170,7 +170,9 @@ export default { toggleNavigationButton(hide) { // Hide the navigation toggle if the back button is shown const navigationToggle = document.querySelector('button.app-navigation-toggle') - navigationToggle.style.display = hide ? 'none' : null + if (navigationToggle !== null) { + navigationToggle.style.display = hide ? 'none' : null + } }, }, } diff --git a/src/mixins/FetchAlbumsMixin.js b/src/mixins/FetchAlbumsMixin.js index 131898f0..90e39a10 100644 --- a/src/mixins/FetchAlbumsMixin.js +++ b/src/mixins/FetchAlbumsMixin.js @@ -20,16 +20,12 @@ * */ -import { mapGetters } from 'vuex' +import { mapGetters, mapActions } from 'vuex' -import moment from '@nextcloud/moment' -import { showError } from '@nextcloud/dialogs' import { getCurrentUser } from '@nextcloud/auth' -import client from '../services/DavClient.js' -import logger from '../services/logger.js' -import { genFileInfo } from '../utils/fileUtils.js' import AbortControllerMixin from './AbortControllerMixin.js' +import { fetchAlbums } from '../services/Albums.js' export default { name: 'FetchAlbumsMixin', @@ -56,6 +52,10 @@ export default { }, methods: { + ...mapActions([ + 'addAlbums', + ]), + async fetchAlbums() { if (this.loadingAlbums) { return @@ -65,74 +65,15 @@ export default { this.loadingAlbums = true this.errorFetchingAlbums = null - const response = await client.getDirectoryContents(`/photos/${getCurrentUser()?.uid}/albums`, { - data: `<?xml version="1.0"?> - <d:propfind xmlns:d="DAV:" - xmlns:oc="http://owncloud.org/ns" - xmlns:nc="http://nextcloud.org/ns" - xmlns:ocs="http://open-collaboration-services.org/ns"> - <d:prop> - <nc:last-photo /> - <nc:nbItems /> - <nc:location /> - <nc:dateRange /> - <nc:collaborators /> - <nc:publicLink /> - </d:prop> - </d:propfind>`, - details: true, - signal: this.abortController.signal, - }) - - const albums = response.data - .filter(album => album.filename !== `/photos/${getCurrentUser()?.uid}/albums`) - // Ensure that we have a proper collaborators array. - .map(album => { - if (album.props.collaborators === '') { - album.props.collaborators = [] - } else if (typeof album.props.collaborators.collaborator === 'object') { - if (Array.isArray(album.props.collaborators.collaborator)) { - album.props.collaborators = album.props.collaborators.collaborator - } else { - album.props.collaborators = [album.props.collaborators.collaborator] - } - } - - return album - }) - .map(album => genFileInfo(album)) - .map(album => { - const dateRange = JSON.parse(album.dateRange?.replace(/"/g, '"') ?? '{}') - - if (dateRange.start === null) { - dateRange.start = moment().unix() - dateRange.end = moment().unix() - } - - const dateRangeFormated = { - startDate: moment.unix(dateRange.start).format('MMMM YYYY'), - endDate: moment.unix(dateRange.end).format('MMMM YYYY'), - } - - if (dateRangeFormated.startDate === dateRangeFormated.endDate) { - return { ...album, date: dateRangeFormated.startDate } - } else { - return { ...album, date: this.t('photos', '{startDate} to {endDate}', dateRangeFormated) } - } - }) + const albums = await fetchAlbums(`/photos/${getCurrentUser()?.uid}/albums`, this.abortController.signal) - this.$store.dispatch('addAlbums', { albums }) - logger.debug(`[FetchAlbumsMixin] Fetched ${albums.length} new files: `, albums) + this.addAlbums({ albums }) } catch (error) { if (error.response?.status === 404) { this.errorFetchingAlbums = 404 - } else if (error.code === 'ERR_CANCELED') { - return } else { this.errorFetchingAlbums = error } - logger.error(t('photos', 'Failed to fetch albums list.'), error) - showError(t('photos', 'Failed to fetch albums list.')) } finally { this.loadingAlbums = false } diff --git a/src/mixins/FetchSharedAlbumsMixin.js b/src/mixins/FetchSharedAlbumsMixin.js index c4a57b18..3298f857 100644 --- a/src/mixins/FetchSharedAlbumsMixin.js +++ b/src/mixins/FetchSharedAlbumsMixin.js @@ -20,16 +20,12 @@ * */ -import { mapGetters } from 'vuex' +import { mapGetters, mapActions } from 'vuex' -import moment from '@nextcloud/moment' -import { showError } from '@nextcloud/dialogs' import { getCurrentUser } from '@nextcloud/auth' -import client from '../services/DavClient.js' -import logger from '../services/logger.js' -import { genFileInfo } from '../utils/fileUtils.js' import AbortControllerMixin from './AbortControllerMixin.js' +import { fetchAlbums } from '../services/Albums.js' export default { name: 'FetchSharedAlbumsMixin', @@ -56,6 +52,10 @@ export default { }, methods: { + ...mapActions([ + 'addSharedAlbums', + ]), + async fetchAlbums() { if (this.loadingAlbums) { return @@ -65,62 +65,15 @@ export default { this.loadingAlbums = true this.errorFetchingAlbums = null - const response = await client.getDirectoryContents(`/photos/${getCurrentUser()?.uid}/sharedalbums`, { - data: `<?xml version="1.0"?> - <d:propfind xmlns:d="DAV:" - xmlns:oc="http://owncloud.org/ns" - xmlns:nc="http://nextcloud.org/ns" - xmlns:ocs="http://open-collaboration-services.org/ns"> - <d:prop> - <nc:last-photo /> - <nc:nbItems /> - <nc:location /> - <nc:dateRange /> - <nc:collaborators /> - <nc:publicLink /> - </d:prop> - </d:propfind>`, - details: true, - signal: this.abortController.signal, - }) - - const albums = response.data - .filter(album => album.filename !== `/photos/${getCurrentUser()?.uid}/sharedalbums`) - .map(album => genFileInfo(album)) - .map(album => { - album.collaborators = album.collaborators === '' ? [] : album.collaborators - - const dateRange = JSON.parse(album.dateRange?.replace(/"/g, '"') ?? '{}') - - if (dateRange.start === null) { - dateRange.start = moment().unix() - dateRange.end = moment().unix() - } - - const dateRangeFormated = { - startDate: moment.unix(dateRange.start).format('MMMM YYYY'), - endDate: moment.unix(dateRange.end).format('MMMM YYYY'), - } - - if (dateRangeFormated.startDate === dateRangeFormated.endDate) { - return { ...album, date: dateRangeFormated.startDate } - } else { - return { ...album, date: this.t('photos', '{startDate} to {endDate}', dateRangeFormated) } - } - }) + const albums = await fetchAlbums(`/photos/${getCurrentUser()?.uid}/sharedalbums`, this.abortController.signal) - this.$store.dispatch('addSharedAlbums', { albums }) - logger.debug(`[FetchSharedAlbumsMixin] Fetched ${albums.length} new files: `, albums) + this.addSharedAlbums({ albums }) } catch (error) { if (error.response?.status === 404) { this.errorFetchingAlbums = 404 - } else if (error.code === 'ERR_CANCELED') { - return } else { this.errorFetchingAlbums = error } - logger.error(t('photos', 'Failed to fetch albums list.'), error) - showError(t('photos', 'Failed to fetch albums list.')) } finally { this.loadingAlbums = false } diff --git a/src/public.js b/src/public.js new file mode 100644 index 00000000..f67536d1 --- /dev/null +++ b/src/public.js @@ -0,0 +1,67 @@ +/** + * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +import { generateFilePath } from '@nextcloud/router' +import { getRequestToken } from '@nextcloud/auth' +import { sync } from 'vuex-router-sync' +import { translate, translatePlural } from '@nextcloud/l10n' +import Vue from 'vue' + +import PhotosPublic from './PhotosPublic.vue' +import router from './router/index.js' +import store from './store/index.js' + +// CSP config for webpack dynamic chunk loading +// eslint-disable-next-line +__webpack_nonce__ = btoa(getRequestToken()) + +// Correct the root of the app for chunk loading +// OC.linkTo matches the apps folders +// OC.generateUrl ensure the index.php (or not) +// We do not want the index.php since we're loading files +// eslint-disable-next-line +__webpack_public_path__ = generateFilePath('photos', '', 'js/') + +sync(store, router) + +Vue.prototype.t = translate +Vue.prototype.n = translatePlural + +// TODO: remove when we have a proper fileinfo standalone library +// original scripts are loaded from +// https://github.com/nextcloud/server/blob/5bf3d1bb384da56adbf205752be8f840aac3b0c5/lib/private/legacy/template.php#L120-L122 +window.addEventListener('DOMContentLoaded', () => { + if (!window.OCA.Files) { + window.OCA.Files = {} + } + // register unused client for the sidebar to have access to its parser methods + Object.assign(window.OCA.Files, { App: { fileList: { filesClient: OC.Files.getClient() } } }, window.OCA.Files) +}) + +export default new Vue({ + el: '#content', + // eslint-disable-next-line vue/match-component-file-name + name: 'PhotosRoot', + router, + store, + render: h => h(PhotosPublic), +}) diff --git a/src/router/index.js b/src/router/index.js index 574a3ffa..b9a96bc9 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -35,6 +35,7 @@ const Albums = () => import('../views/Albums') const AlbumContent = () => import('../views/AlbumContent') const SharedAlbums = () => import('../views/SharedAlbums') const SharedAlbumContent = () => import('../views/SharedAlbumContent') +const PublicAlbumContent = () => import('../views/PublicAlbumContent') const Tags = () => import('../views/Tags') const TagContent = () => import('../views/TagContent') const Timeline = () => import('../views/Timeline') @@ -119,6 +120,15 @@ const router = new Router({ }), }, { + path: '/public/:userId/:token', + component: PublicAlbumContent, + name: 'publicAlbums', + props: route => ({ + userId: route.params.userId, + token: route.params.token, + }), + }, + { path: '/folders/:path*', component: Folders, name: 'folders', diff --git a/src/services/Albums.js b/src/services/Albums.js index 1ad2b7a7..3dc33297 100644 --- a/src/services/Albums.js +++ b/src/services/Albums.js @@ -20,8 +20,14 @@ * */ -import axios from '@nextcloud/axios' -import { generateUrl } from '@nextcloud/router' +import moment from '@nextcloud/moment' +import { showError } from '@nextcloud/dialogs' +import { translate } from '@nextcloud/l10n' + +import client from '../services/DavClient.js' +import logger from '../services/logger.js' +import DavRequest from '../services/DavRequest.js' +import { genFileInfo } from '../utils/fileUtils.js' /** * @typedef {object} Album @@ -35,19 +41,151 @@ import { generateUrl } from '@nextcloud/router' */ /** - * List albums. * - * @return {Promise<Album[]>} the album list + * @param {string} path - Albums' root path. + * @param {AbortSignal} signal - Abort signal to cancel the request. + * @return {Promise<Album|null>} */ -export default async function() { - const response = await axios.get(generateUrl('/apps/photos/api/v1/albums')) - return response.data.map(album => ({ - id: `${album.fileid}`, - name: album.basename, - location: 'Paris', - creationDate: album.lastmod, - state: 0, - itemCount: 100, - cover: '13397', - })) +export async function fetchAlbum(path, signal) { + try { + const response = await client.stat(path, { + data: `<?xml version="1.0"?> + <d:propfind xmlns:d="DAV:" + xmlns:oc="http://owncloud.org/ns" + xmlns:nc="http://nextcloud.org/ns" + xmlns:ocs="http://open-collaboration-services.org/ns"> + <d:prop> + <nc:last-photo /> + <nc:nbItems /> + <nc:location /> + <nc:dateRange /> + <nc:collaborators /> + </d:prop> + </d:propfind>`, + signal, + details: true, + }) + + logger.debug('[Albums] Fetched an album: ', response.data) + + return formatAlbum(response.data) + } catch (error) { + if (error.code === 'ERR_CANCELED') { + return null + } + + throw error + } } + +/** + * + * @param {string} path - Albums' root path. + * @param {AbortSignal} signal - Abort signal to cancel the request. + * @return {Promise<Album[]>} + */ +export async function fetchAlbums(path, signal) { + try { + const response = await client.getDirectoryContents(path, { + data: `<?xml version="1.0"?> + <d:propfind xmlns:d="DAV:" + xmlns:oc="http://owncloud.org/ns" + xmlns:nc="http://nextcloud.org/ns" + xmlns:ocs="http://open-collaboration-services.org/ns"> + <d:prop> + <nc:last-photo /> + <nc:nbItems /> + <nc:location /> + <nc:dateRange /> + <nc:collaborators /> + </d:prop> + </d:propfind>`, + details: true, + signal, + }) + + logger.debug(`[Albums] Fetched ${response.data.length} albums: `, response.data) + + return response.data + .filter(album => album.filename !== path) + .map(formatAlbum) + } catch (error) { + if (error.code === 'ERR_CANCELED') { + return [] + } + + throw error + } +} + +/** + * + * @param {object} album - An album received from a webdav request. + * @return {Album} + */ +function formatAlbum(album) { + // Ensure that we have a proper collaborators array. + if (album.props.collaborators === '') { + album.props.collaborators = [] + } else if (typeof album.props.collaborators.collaborator === 'object') { + if (Array.isArray(album.props.collaborators.collaborator)) { + album.props.collaborators = album.props.collaborators.collaborator + } else { + album.props.collaborators = [album.props.collaborators.collaborator] + } + } + + // Extract custom props. + album = genFileInfo(album) + + // Compute date range label. + const dateRange = JSON.parse(album.dateRange?.replace(/"/g, '"') ?? '{}') + if (dateRange.start === null) { + dateRange.start = moment().unix() + dateRange.end = moment().unix() + } + const dateRangeFormated = { + startDate: moment.unix(dateRange.start).format('MMMM YYYY'), + endDate: moment.unix(dateRange.end).format('MMMM YYYY'), + } + if (dateRangeFormated.startDate === dateRangeFormated.endDate) { + album.date = dateRangeFormated.startDate + } else { + album.date = translate('photos', '{startDate} to {endDate}', dateRangeFormated) + } + + return album +} + +/** + * + * @param {string} path - Albums' root path. + * @param {AbortSignal} signal - Abort signal to cancel the request. + * @return {Promise<[]>} + */ +export async function fetchAlbumContent(path, signal) { + try { + const response = await client.getDirectoryContents(path, { + data: DavRequest, + details: true, + signal, + }) + + const fetchedFiles = response.data + .map(file => genFileInfo(file)) + .filter(file => file.fileid) + + logger.debug(`[Albums] Fetched ${fetchedFiles.length} new files: `, fetchedFiles) + + return fetchedFiles + } catch (error) { + if (error.code === 'ERR_CANCELED') { + return [] + } + + logger.error('Error fetching album files', error) + console.error(error) + + throw error + } +}
\ No newline at end of file diff --git a/src/store/collectionStoreFactory.js b/src/store/collectionStoreFactory.js new file mode 100644 index 00000000..aa09b915 --- /dev/null +++ b/src/store/collectionStoreFactory.js @@ -0,0 +1,214 @@ +/** + * @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me> + * + * @author Louis Chemineau <louis@chmn.me> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +import { showError } from '@nextcloud/dialogs' + +import client from '../services/DavClient.js' +import logger from '../services/logger.js' +import Semaphore from '../utils/semaphoreWithPriority.js' +import { translate } from '@nextcloud/l10n' + +/** + * @param {string} collectionName - The name of the collection/ + */ +export default function collectionStoreFactory(collectionName) { + const capitalizedCollectionName = collectionName[0].toUpperCase() + collectionName.substr(1) + + const state = { + [`${collectionName}s`]: {}, + [`${collectionName}sFiles`]: {}, + } + + const mutations = { + /** + * Add a list of collections. + * + * @param {object} state vuex state + * @param {object} data destructuring object + * @param {Array} data.collections list of collections + */ + [`add${capitalizedCollectionName}s`](state, { collections }) { + state[`${collectionName}s`] = { + ...state[`${collectionName}s`], + ...collections.reduce((collections, collection) => ({ ...collections, [collection.basename]: collection }), {}), + } + }, + + /** + * Remove a list of collections. + * + * @param {object} state vuex state + * @param {object} data destructuring object + * @param {Array} data.collectionIds list of collection ids + */ + [`remove${capitalizedCollectionName}s`](state, { collectionIds }) { + collectionIds.forEach(collectionId => delete state[`${collectionName}s`][collectionId]) + collectionIds.forEach(collectionId => delete state[`${collectionName}sFiles`][collectionId]) + }, + + /** + * Add files to a collection. + * + * @param {object} state vuex state + * @param {object} data destructuring object + * @param {string} data.collectionId the collection id + * @param {string[]} data.fileIdsToAdd list of files + */ + [`addFilesTo${capitalizedCollectionName}`](state, { collectionId, fileIdsToAdd }) { + const collectionFiles = state[`${collectionName}sFiles`][collectionId] || [] + state[`${collectionName}sFiles`] = { + ...state[`${collectionName}sFiles`], + [collectionId]: [ + ...collectionFiles, + ...fileIdsToAdd.filter(fileId => !collectionFiles.includes(fileId)), // Filter to prevent duplicate fileId. + ], + } + state[`${collectionName}s`][collectionId].nbItems += fileIdsToAdd.length + }, + + /** + * Remove files to an collection. + * + * @param {object} state vuex state + * @param {object} data destructuring object + * @param {string} data.collectionId the collection id + * @param {string[]} data.fileIdsToRemove list of files + */ + [`removeFilesFrom${capitalizedCollectionName}`](state, { collectionId, fileIdsToRemove }) { + state[`${collectionName}sFiles`] = { + ...state[`${collectionName}sFiles`], + [collectionId]: state[`${collectionName}sFiles`][collectionId].filter(fileId => !fileIdsToRemove.includes(fileId)), + } + state[`${collectionName}s`][collectionId].nbItems -= fileIdsToRemove.length + }, + } + + const getters = { + [`${collectionName}s`]: state => state[`${collectionName}s`], + [`${collectionName}sFiles`]: state => state[`${collectionName}sFiles`], + } + + const actions = { + /** + * Update files and collections + * + * @param {object} context vuex context + * @param {object} data destructuring object + * @param {Array} data.collections list of collections + */ + [`add${capitalizedCollectionName}s`](context, { collections }) { + context.commit(`add${capitalizedCollectionName}s`, { collections }) + }, + + /** + * Add files to an collection. + * + * @param {object} context vuex context + * @param {object} data destructuring object + * @param {string} data.collectionId the collection name + * @param {string[]} data.fileIdsToAdd list of files ids to add + */ + async [`addFilesTo${capitalizedCollectionName}`](context, { collectionId, fileIdsToAdd }) { + const semaphore = new Semaphore(5) + + context.commit(`addFilesTo${capitalizedCollectionName}`, { collectionId, fileIdsToAdd }) + + const promises = fileIdsToAdd + .map(async (fileId) => { + const file = context.getters.files[fileId] + const collection = context.getters[`${collectionName}s`][collectionId] + const symbol = await semaphore.acquire() + + try { + await client.copyFile( + file.filename, + `${collection.filename}/${file.basename}`, + ) + } catch (error) { + if (error.response.status !== 409) { // Already in the collection. + context.commit(`removeFilesFrom${capitalizedCollectionName}`, { collectionId, fileIdsToRemove: [fileId] }) + + logger.error(translate('photos', 'Failed to add {fileBaseName} to {collectionId}.', { fileBaseName: file.basename, collectionId }), error) + showError(translate('photos', 'Failed to add {fileBaseName} to {collectionId}.', { fileBaseName: file.basename, collectionId })) + } + } finally { + semaphore.release(symbol) + } + }) + + return Promise.all(promises) + }, + + /** + * Remove files to an collection. + * + * @param {object} context vuex context + * @param {object} data destructuring object + * @param {string} data.collectionId the collection name + * @param {string[]} data.fileIdsToRemove list of files ids to remove + */ + async [`removeFilesFrom${capitalizedCollectionName}`](context, { collectionId, fileIdsToRemove }) { + const semaphore = new Semaphore(5) + + context.commit(`removeFilesFrom${capitalizedCollectionName}`, { collectionId, fileIdsToRemove }) + + const promises = fileIdsToRemove + .map(async (fileId) => { + const file = context.getters.files[fileId] + const symbol = await semaphore.acquire() + + try { + await client.deleteFile(file.filename) + } catch (error) { + context.commit(`addFilesTo${capitalizedCollectionName}`, { collectionId, fileIdsToAdd: [fileId] }) + + logger.error(translate('photos', 'Failed to delete {fileBaseName}.', { fileBaseName: file.basename }), error) + showError(translate('photos', 'Failed to delete {fileBaseName}.', { fileBaseName: file.basename })) + } finally { + semaphore.release(symbol) + } + }) + + return Promise.all(promises) + }, + + /** + * Delete a collection. + * + * @param {object} context vuex context + * @param {object} data destructuring object + * @param {string} data.collectionId the id of the collection + */ + async [`delete${capitalizedCollectionName}`](context, { collectionId }) { + try { + const collection = context.getters[`${collectionName}s`][collectionId] + await client.deleteFile(collection.filename) + context.commit(`remove${capitalizedCollectionName}s`, { collectionIds: [collectionId] }) + } catch (error) { + logger.error(translate('photos', 'Failed to delete {collectionId}.', { collectionId }), error) + showError(translate('photos', 'Failed to delete {collectionId}.', { collectionId })) + } + }, + } + + return { state, mutations, getters, actions } +} diff --git a/src/store/index.js b/src/store/index.js index 65fab70c..f77eaccf 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -29,6 +29,7 @@ import sharedAlbums from './sharedAlbums.js' import faces from './faces.js' import folders from './folders.js' import systemtags from './systemtags.js' +import collectionStoreFactory from './collectionStoreFactory.js' Vue.use(Vuex) export default new Store({ @@ -39,6 +40,7 @@ export default new Store({ sharedAlbums, faces, systemtags, + publicAlbums: collectionStoreFactory('publicAlbum'), }, strict: process.env.NODE_ENV !== 'production', diff --git a/src/store/publicAlbums.js b/src/store/publicAlbums.js new file mode 100644 index 00000000..03a84757 --- /dev/null +++ b/src/store/publicAlbums.js @@ -0,0 +1,213 @@ +/** + * @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me> + * + * @author Louis Chemineau <louis@chmn.me> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +import { showError } from '@nextcloud/dialogs' + +import client from '../services/DavClient.js' +import logger from '../services/logger.js' +import Semaphore from '../utils/semaphoreWithPriority.js' + +/** + * @typedef {object} Album + * @property {string} basename - The name of the album. + * @property {number} lastmod - The creation date of the album. + * @property {string} size - The number of items in the album. + */ + +const state = { + sharedAlbums: {}, + sharedAlbumsFiles: {}, +} + +const mutations = { + /** + * Add albums to the album collection. + * + * @param {object} state vuex state + * @param {object} data destructuring object + * @param {Array} data.albums list of albums + */ + addSharedAlbums(state, { albums }) { + state.sharedAlbums = { + ...state.sharedAlbums, + ...albums.reduce((albums, album) => ({ ...albums, [album.basename]: album }), {}), + } + }, + + /** + * Remove albums from the album collection. + * + * @param {object} state vuex state + * @param {object} data destructuring object + * @param {Array} data.albumNames list of albums ids + */ + removeSharedAlbums(state, { albumNames }) { + albumNames.forEach(albumName => delete state.sharedAlbums[albumName]) + albumNames.forEach(albumName => delete state.sharedAlbumsFiles[albumName]) + }, + + /** + * Add files to an album. + * + * @param {object} state vuex state + * @param {object} data destructuring object + * @param {string} data.albumName the album id + * @param {string[]} data.fileIdsToAdd list of files + */ + addFilesToSharedAlbum(state, { albumName, fileIdsToAdd }) { + const albumFiles = state.sharedAlbumsFiles[albumName] || [] + state.sharedAlbumsFiles = { + ...state.sharedAlbumsFiles, + [albumName]: [ + ...albumFiles, + ...fileIdsToAdd.filter(fileId => !albumFiles.includes(fileId)), // Filter to prevent duplicate fileId. + ], + } + state.sharedAlbums[albumName].nbItems += fileIdsToAdd.length + }, + + /** + * Remove files to an album. + * + * @param {object} state vuex state + * @param {object} data destructuring object + * @param {string} data.albumName the album id + * @param {string[]} data.fileIdsToRemove list of files + */ + removeFilesFromSharedAlbum(state, { albumName, fileIdsToRemove }) { + state.sharedAlbumsFiles = { + ...state.sharedAlbumsFiles, + [albumName]: state.sharedAlbumsFiles[albumName].filter(fileId => !fileIdsToRemove.includes(fileId)), + } + state.sharedAlbums[albumName].nbItems -= fileIdsToRemove.length + }, +} + +const getters = { + sharedAlbums: state => state.sharedAlbums, + sharedAlbumsFiles: state => state.sharedAlbumsFiles, +} + +const actions = { + /** + * Update files and albums + * + * @param {object} context vuex context + * @param {object} data destructuring object + * @param {Album[]} data.albums list of albums + */ + addSharedAlbums(context, { albums }) { + context.commit('addSharedAlbums', { albums }) + }, + + /** + * Add files to an album. + * + * @param {object} context vuex context + * @param {object} data destructuring object + * @param {string} data.albumName the album name + * @param {string[]} data.fileIdsToAdd list of files ids to add + */ + async addFilesToSharedAlbum(context, { albumName, fileIdsToAdd }) { + const semaphore = new Semaphore(5) + + context.commit('addFilesToSharedAlbum', { albumName, fileIdsToAdd }) + + const promises = fileIdsToAdd + .map(async (fileId) => { + const file = context.getters.files[fileId] + const album = context.getters.sharedAlbums[albumName] + const symbol = await semaphore.acquire() + + try { + await client.copyFile( + file.filename, + `${album.filename}/${file.basename}`, + ) + } catch (error) { + if (error.response.status !== 409) { // Already in the album. + context.commit('removeFilesFromSharedAlbum', { albumName, fileIdsToRemove: [fileId] }) + + logger.error(t('photos', 'Failed to add {fileBaseName} to shared album {albumName}.', { fileBaseName: file.basename, albumName }), error) + showError(t('photos', 'Failed to add {fileBaseName} to shared album {albumName}.', { fileBaseName: file.basename, albumName })) + } + } finally { + semaphore.release(symbol) + } + }) + + return Promise.all(promises) + }, + + /** + * Remove files to an album. + * + * @param {object} context vuex context + * @param {object} data destructuring object + * @param {string} data.albumName the album name + * @param {string[]} data.fileIdsToRemove list of files ids to remove + */ + async removeFilesFromSharedAlbum(context, { albumName, fileIdsToRemove }) { + const semaphore = new Semaphore(5) + + context.commit('removeFilesFromSharedAlbum', { albumName, fileIdsToRemove }) + + const promises = fileIdsToRemove + .map(async (fileId) => { + const file = context.getters.files[fileId] + const symbol = await semaphore.acquire() + + try { + await client.deleteFile(file.filename) + } catch (error) { + context.commit('addFilesToSharedAlbum', { albumName, fileIdsToAdd: [fileId] }) + + logger.error(t('photos', 'Failed to delete {fileBaseName}.', { fileBaseName: file.basename }), error) + showError(t('photos', 'Failed to delete {fileBaseName}.', { fileBaseName: file.basename })) + } finally { + semaphore.release(symbol) + } + }) + + return Promise.all(promises) + }, + + /** + * Delete an album. + * + * @param {object} context vuex context + * @param {object} data destructuring object + * @param {string} data.albumName the id of the album + */ + async deleteSharedAlbum(context, { albumName }) { + try { + const album = context.getters.sharedAlbums[albumName] + await client.deleteFile(album.filename) + context.commit('removeSharedAlbums', { albumNames: [albumName] }) + } catch (error) { + logger.error(t('photos', 'Failed to delete {albumName}.', { albumName }), error) + showError(t('photos', 'Failed to delete {albumName}.', { albumName })) + } + }, +} + +export default { state, mutations, getters, actions } diff --git a/src/views/AlbumContent.vue b/src/views/AlbumContent.vue index b4e3413f..a481bc9b 100644 --- a/src/views/AlbumContent.vue +++ b/src/views/AlbumContent.vue @@ -361,9 +361,7 @@ export default { this.errorFetchingFiles = error } - // cancelled request, moving on... - logger.error('Error fetching album files') - console.error(error) + logger.error('[AlbumContent] Error fetching album files', error) } finally { this.loadingFiles = false this.semaphore.release(semaphoreSymbol) diff --git a/src/views/Albums.vue b/src/views/Albums.vue index 34fbd4c1..035d5c62 100644 --- a/src/views/Albums.vue +++ b/src/views/Albums.vue @@ -30,7 +30,7 @@ :loading="loadingAlbums" :title="t('photos', 'Albums')" :root-title="t('photos', 'Albums')" - @refresh="onRefresh"> + @refresh="fetchAlbums"> <NcButton :aria-label="t('photos', 'Create a new album.')" @click="showAlbumCreationForm = true"> <template #icon> @@ -122,10 +122,6 @@ export default { this.showAlbumCreationForm = false this.$router.push(`albums/${album.basename}`) }, - - onRefresh() { - this.fetchAlbums() - }, }, } </script> diff --git a/src/views/PublicAlbumContent.vue b/src/views/PublicAlbumContent.vue new file mode 100644 index 00000000..6d3b5885 --- /dev/null +++ b/src/views/PublicAlbumContent.vue @@ -0,0 +1,324 @@ +<!-- + - @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me> + - + - @author Louis Chemineau <louis@chmn.me> + - + - @license AGPL-3.0-or-later + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> +<template> + <div> + <CollectionContent v-if="true" + ref="collectionContent" + :collection="album" + :collection-file-ids="albumFileIds" + :semaphore="semaphore" + :loading="loadingAlbum || loadingFiles" + :error="errorFetchingAlbum || errorFetchingFiles"> + <!-- Header --> + <HeaderNavigation key="navigation" + slot="header" + slot-scope="{selectedFileIds}" + :loading="loadingAlbum || loadingFiles" + :params="{ albumName }" + :path="'/' + albumName" + :title="albumName" + @refresh="fetchAlbumContent"> + <!-- TODO: enable upload on public albums --> + <!-- <UploadPicker :accept="allowedMimes" + :destination="folder.filename" + :multiple="true" + @uploaded="onUpload" /> --> + <div v-if="album.location !== ''" slot="subtitle" class="album__location"> + <MapMarker />{{ album.location }} + </div> + <template v-if="album !== undefined" slot="right"> + <NcButton v-if="album.nbItems !== 0" + type="tertiary" + :aria-label="t('photos', 'Add photos to this album')" + @click="showAddPhotosModal = true"> + <Plus slot="icon" /> + </NcButton> + + <NcActions :force-menu="true" :aria-label="t('photos', 'Open actions menu')"> + <!-- TODO: enable download on public albums --> + <!-- <ActionDownload v-if="albumFileIds.length > 0" + :selected-file-ids="albumFileIds" + :title="t('photos', 'Download all files in album')"> + <DownloadMultiple slot="icon" /> + </ActionDownload> --> + + <template v-if="selectedFileIds.length > 0"> + <NcActionSeparator /> + + <!-- TODO: enable download on public albums --> + <!-- <ActionDownload :selected-file-ids="selectedFileIds" :title="t('photos', 'Download selected files')"> + <Download slot="icon" /> + </ActionDownload> --> + + <NcActionButton :close-after-click="true" + @click="handleRemoveFilesFromAlbum(selectedFileIds)"> + {{ t('photos', 'Remove selection from album') }} + <Close slot="icon" /> + </NcActionButton> + </template> + </NcActions> + </template> + </HeaderNavigation> + + <!-- No content --> + <NcEmptyContent slot="empty-content" + :title="t('photos', 'This album does not have any photos or videos yet!')" + class="album__empty"> + <ImagePlus slot="icon" /> + + <NcButton slot="action" + type="primary" + :aria-label="t('photos', 'Add photos to this album')" + @click="showAddPhotosModal = true"> + <Plus slot="icon" /> + {{ t('photos', "Add") }} + </NcButton> + </NcEmptyContent> + </CollectionContent> + + <!-- Modals --> + <NcModal v-if="showAddPhotosModal" + size="large" + :title="t('photos', 'Add photos to the album')" + @close="showAddPhotosModal = false"> + <FilesPicker :destination="album.basename" + :blacklist-ids="albumFileIds" + :loading="loadingAddFilesToAlbum" + @files-picked="handleFilesPicked" /> + </NcModal> + </div> +</template> + +<script> +import { mapActions, mapGetters } from 'vuex' +import MapMarker from 'vue-material-design-icons/MapMarker' +import Plus from 'vue-material-design-icons/Plus' +import ImagePlus from 'vue-material-design-icons/ImagePlus' +import Close from 'vue-material-design-icons/Close' +// import Download from 'vue-material-design-icons/Download' +// import DownloadMultiple from 'vue-material-design-icons/DownloadMultiple' + +import { NcActions, NcActionButton, NcButton, NcModal, NcEmptyContent, NcActionSeparator, isMobile } from '@nextcloud/vue' +import { showError } from '@nextcloud/dialogs' + +import FetchFilesMixin from '../mixins/FetchFilesMixin.js' +import AbortControllerMixin from '../mixins/AbortControllerMixin.js' +import CollectionContent from '../components/Collection/CollectionContent.vue' +import HeaderNavigation from '../components/HeaderNavigation.vue' +// import ActionDownload from '../components/Actions/ActionDownload.vue' +import FilesPicker from '../components/FilesPicker.vue' +import { fetchAlbum, fetchAlbumContent } from '../services/Albums.js' +import logger from '../services/logger.js' + +export default { + name: 'PublicAlbumContent', + components: { + MapMarker, + Plus, + Close, + // Download, + // DownloadMultiple, + ImagePlus, + NcEmptyContent, + NcActions, + NcActionButton, + NcActionSeparator, + NcButton, + NcModal, + CollectionContent, + // ActionDownload, + FilesPicker, + HeaderNavigation, + }, + + mixins: [ + FetchFilesMixin, + AbortControllerMixin, + isMobile, + ], + + props: { + userId: { + type: String, + required: true, + }, + token: { + type: String, + required: true, + }, + }, + + data() { + return { + showAddPhotosModal: false, + loadingAlbum: false, + errorFetchingAlbum: null, + loadingCount: 0, + loadingAddFilesToAlbum: false, + albumName: '', + } + }, + + computed: { + ...mapGetters([ + 'files', + 'publicAlbums', + 'publicAlbumsFiles', + ]), + + /** + * @return {object} The album information for the current albumName. + */ + album() { + return this.publicAlbums[this.albumName] || {} + }, + + /** + * @return {string[]} The list of files for the current albumName. + */ + albumFileIds() { + return this.publicAlbumsFiles[this.albumName] || [] + }, + }, + + beforeMount() { + this.fetchAlbumInfo() + this.fetchAlbumContent() + }, + + methods: { + ...mapActions([ + 'appendFiles', + 'addPublicAlbums', + 'addFilesToPublicAlbum', + 'removeFilesFromPublicAlbum', + ]), + + async fetchAlbumInfo() { + if (this.loadingAlbum) { + return + } + + try { + this.loadingAlbum = true + this.errorFetchingAlbum = null + + const album = await fetchAlbum(`/photos/${this.userId}/public/${this.token}`, this.abortController.signal) + this.addPublicAlbums({ collections: [album] }) + this.albumName = album.basename + } catch (error) { + if (error.response?.status === 404) { + this.errorFetchingAlbum = 404 + } else { + this.errorFetchingAlbum = error + } + + logger.error('[PublicAlbumContent] Error fetching album', error) + showError(this.t('photos', 'Failed to fetch albums list.')) + } finally { + this.loadingAlbum = false + } + }, + + async fetchAlbumContent() { + if (this.loadingFiles || this.showEditAlbumForm) { + return [] + } + + const semaphoreSymbol = await this.semaphore.acquire(() => 0, 'fetchFiles') + const fetchSemaphoreSymbol = await this.fetchSemaphore.acquire() + + try { + this.errorFetchingFiles = null + this.loadingFiles = true + this.semaphoreSymbol = semaphoreSymbol + + const fetchedFiles = await fetchAlbumContent( + `/photos/${this.userId}/public/${this.token}`, + this.abortController.signal, + ) + + const fileIds = fetchedFiles + .map(file => file.fileid.toString()) + + this.appendFiles(fetchedFiles) + + if (fetchedFiles.length > 0) { + await this.$store.commit('addFilesToPublicAlbum', { collectionId: this.albumName, fileIdsToAdd: fileIds }) + } + + return fetchedFiles + } catch (error) { + if (error.response?.status === 404) { + this.errorFetchingFiles = 404 + return [] + } + + this.errorFetchingFiles = error + + showError(this.t('photos', 'Failed to fetch albums list.')) + logger.error('[PublicAlbumContent] Error fetching album files', error) + } finally { + this.loadingFiles = false + this.semaphore.release(semaphoreSymbol) + this.fetchSemaphore.release(fetchSemaphoreSymbol) + } + + return [] + }, + + async handleFilesPicked(fileIds) { + this.showAddPhotosModal = false + await this.addFilesToPublicAlbum({ collectionName: this.albumName, fileIdsToAdd: fileIds }) + // Re-fetch album content to have the proper filenames. + await this.fetchAlbumContent() + }, + + async handleRemoveFilesFromAlbum(fileIds) { + this.$refs.collectionContent.onUncheckFiles(fileIds) + await this.removeFilesFromPublicAlbum({ collectionName: this.albumName, fileIdsToRemove: fileIds }) + }, + }, +} +</script> +<style lang="scss" scoped> +.album { + display: flex; + flex-direction: column; + + &__title { + width: 100%; + } + + &__name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + &__location { + margin-left: -4px; + display: flex; + color: var(--color-text-lighter); + } +} +</style> diff --git a/src/views/SharedAlbumContent.vue b/src/views/SharedAlbumContent.vue index dde0656e..ab3848e2 100644 --- a/src/views/SharedAlbumContent.vue +++ b/src/views/SharedAlbumContent.vue @@ -49,12 +49,11 @@ type="tertiary" :aria-label="t('photos', 'Add photos to this album')" @click="showAddPhotosModal = true"> - <template #icon> - <Plus /> - </template> + <Plus slot="icon" /> </NcButton> <NcActions :force-menu="true" :aria-label="t('photos', 'Open actions menu')"> + <!-- TODO: enable download on shared albums --> <!-- <ActionDownload v-if="albumFileIds.length > 0" :selected-file-ids="albumFileIds" :title="t('photos', 'Download all files in album')"> @@ -70,6 +69,7 @@ <template v-if="selectedFileIds.length > 0"> <NcActionSeparator /> + <!-- TODO: enable download on shared albums --> <!-- <ActionDownload :selected-file-ids="selectedFileIds" :title="t('photos', 'Download selected files')"> <Download slot="icon" /> </ActionDownload> --> @@ -263,7 +263,7 @@ export default { } // cancelled request, moving on... - logger.error('Error fetching shared album files', error) + logger.error('[SharedAlbumContent] Error fetching album files', error) } finally { this.loadingFiles = false this.semaphore.release(semaphoreSymbol) diff --git a/src/views/SharedAlbums.vue b/src/views/SharedAlbums.vue index 09250bb5..c2541cff 100644 --- a/src/views/SharedAlbums.vue +++ b/src/views/SharedAlbums.vue @@ -22,8 +22,6 @@ <template> <CollectionsList :collections="sharedAlbums" :loading="loadingAlbums" - :collection-title="t('photos', 'Shared albums')" - :collection-root="t('photos', 'Shared albums')" :error="errorFetchingAlbums" class="albums-list"> <HeaderNavigation key="navigation" @@ -31,7 +29,7 @@ :loading="loadingAlbums" :title="t('photos', 'Shared albums')" :root-title="t('photos', 'Shared albums')" - @refresh="onRefresh" /> + @refresh="fetchAlbums" /> <CollectionCover :key="collection.basename" slot-scope="{collection}" @@ -90,12 +88,6 @@ export default { mixins: [ FetchSharedAlbumsMixin, ], - - methods: { - onRefresh() { - this.fetchAlbums() - }, - }, } </script> <style lang="scss" scoped> @@ -9,6 +9,11 @@ const BabelLoaderExcludeNodeModulesExcept = require('babel-loader-exclude-node-m const WorkboxPlugin = require('workbox-webpack-plugin') const { basename } = require('path') +webpackConfig.entry = { + main: path.join(__dirname, 'src', 'main.js'), + public: path.join(__dirname, 'src', 'public.js'), +} + webpackRules.RULE_JS.exclude = BabelLoaderExcludeNodeModulesExcept([ '@essentials/request-timeout', '@nextcloud/event-bus', @@ -43,7 +48,7 @@ webpackConfig.plugins.push( // patch webdav/dist/request.js new webpack.NormalModuleReplacementPlugin( /request(\.js)?/, - function(resource) { + function (resource) { if (resource.context.indexOf('webdav') > -1) { console.debug('Patched request for webdav', basename(resource.contextInfo.issuer)) resource.request = path.join(__dirname, 'src/patchedRequest.js') |