mirror of https://github.com/nextcloud/photos
Add public link logic
Signed-off-by: Louis Chemineau <louis@chmn.me>
This commit is contained in:
parent
d8ab1a24d1
commit
ff01726c6b
|
@ -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' => '.*',
|
||||
|
|
|
@ -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])) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
@ -212,7 +212,7 @@ class AlbumRoot implements ICollection, ICopyTarget {
|
|||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
* @return array{'id': string, 'label': string, 'type': int}
|
||||
*/
|
||||
public function getCollaborators() {
|
||||
return array_map(
|
||||
|
@ -220,4 +220,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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
@ -90,9 +90,7 @@ class PropFindPlugin extends ServerPlugin {
|
|||
$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::HAS_PREVIEW_PROPERTYNAME, fn () => json_encode($this->previewManager->isAvailable($fileInfo)));
|
||||
|
||||
if ($this->metadataEnabled) {
|
||||
$propFind->handle(FilesPlugin::FILE_METADATA_SIZE, function () use ($node) {
|
||||
|
@ -111,7 +109,7 @@ class PropFindPlugin extends ServerPlugin {
|
|||
}
|
||||
}
|
||||
|
||||
if ($node instanceof AlbumRoot || $node instanceof SharedAlbumRoot) {
|
||||
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());
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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 [];
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)];
|
||||
|
|
|
@ -52,16 +52,20 @@ 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);
|
||||
|
||||
// If the config is a path, make sure it exists
|
||||
if (str_starts_with($default, '/')) {
|
||||
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
|
||||
$userFolder = $this->rootFolder->getUserFolder($userId);
|
||||
// If the folder does not exists, create it
|
||||
if (!$userFolder->nodeExists($value)) {
|
||||
$userFolder->newFolder($value);
|
||||
|
|
|
@ -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>
|
|
@ -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 {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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')
|
||||
if (navigationToggle !== null) {
|
||||
navigationToggle.style.display = hide ? 'none' : null
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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 = await fetchAlbums(`/photos/${getCurrentUser()?.uid}/albums`, 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) }
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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 = await fetchAlbums(`/photos/${getCurrentUser()?.uid}/sharedalbums`, 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) }
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
})
|
|
@ -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')
|
||||
|
@ -118,6 +119,15 @@ const router = new Router({
|
|||
albumName: route.params.albumName,
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: '/public/:userId/:token',
|
||||
component: PublicAlbumContent,
|
||||
name: 'publicAlbums',
|
||||
props: route => ({
|
||||
userId: route.params.userId,
|
||||
token: route.params.token,
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: '/folders/:path*',
|
||||
component: Folders,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 }
|
||||
}
|
|
@ -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',
|
||||
|
|
|
@ -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 }
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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)
|
||||
|
|
|
@ -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')
|
||||
|
|
Loading…
Reference in New Issue