Add public link logic

Signed-off-by: Louis Chemineau <louis@chmn.me>
This commit is contained in:
Louis Chemineau 2022-09-21 16:50:28 +02:00
parent d8ab1a24d1
commit ff01726c6b
30 changed files with 1593 additions and 244 deletions

View File

@ -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' => '.*',

View File

@ -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])) {

View File

@ -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;
}
}

View File

@ -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.');
}

View File

@ -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();
}
}

View File

@ -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;
});
}

View File

@ -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 [];
}
}

View File

@ -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);
}
}

View File

@ -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 [];
}
}

View File

@ -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;

View File

@ -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)];

View File

@ -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);

102
src/PhotosPublic.vue Normal file
View File

@ -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>

View File

@ -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 {

View File

@ -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: {

View File

@ -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
}
},
},
}

View File

@ -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(/&quot;/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
}

View File

@ -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(/&quot;/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
}

67
src/public.js Normal file
View File

@ -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),
})

View File

@ -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,

View File

@ -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(/&quot;/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
}
}

View File

@ -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 }
}

View File

@ -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',

213
src/store/publicAlbums.js Normal file
View File

@ -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 }

View File

@ -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)

View File

@ -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>

View File

@ -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>

View File

@ -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)

View File

@ -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>

View File

@ -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')