diff options
18 files changed, 738 insertions, 181 deletions
diff --git a/apps/files_versions/composer/composer/autoload_classmap.php b/apps/files_versions/composer/composer/autoload_classmap.php index b4c8bb4413b..07dc290b604 100644 --- a/apps/files_versions/composer/composer/autoload_classmap.php +++ b/apps/files_versions/composer/composer/autoload_classmap.php @@ -31,6 +31,9 @@ return array( 'OCA\\Files_Versions\\Sabre\\VersionRoot' => $baseDir . '/../lib/Sabre/VersionRoot.php', 'OCA\\Files_Versions\\Storage' => $baseDir . '/../lib/Storage.php', 'OCA\\Files_Versions\\Versions\\BackendNotFoundException' => $baseDir . '/../lib/Versions/BackendNotFoundException.php', + 'OCA\\Files_Versions\\Versions\\IDeletableVersionBackend' => $baseDir . '/../lib/Versions/IDeletableVersionBackend.php', + 'OCA\\Files_Versions\\Versions\\INameableVersion' => $baseDir . '/../lib/Versions/INameableVersion.php', + 'OCA\\Files_Versions\\Versions\\INameableVersionBackend' => $baseDir . '/../lib/Versions/INameableVersionBackend.php', 'OCA\\Files_Versions\\Versions\\IVersion' => $baseDir . '/../lib/Versions/IVersion.php', 'OCA\\Files_Versions\\Versions\\IVersionBackend' => $baseDir . '/../lib/Versions/IVersionBackend.php', 'OCA\\Files_Versions\\Versions\\IVersionManager' => $baseDir . '/../lib/Versions/IVersionManager.php', diff --git a/apps/files_versions/composer/composer/autoload_static.php b/apps/files_versions/composer/composer/autoload_static.php index b65d62de59b..59fa10b4aa0 100644 --- a/apps/files_versions/composer/composer/autoload_static.php +++ b/apps/files_versions/composer/composer/autoload_static.php @@ -46,6 +46,9 @@ class ComposerStaticInitFiles_Versions 'OCA\\Files_Versions\\Sabre\\VersionRoot' => __DIR__ . '/..' . '/../lib/Sabre/VersionRoot.php', 'OCA\\Files_Versions\\Storage' => __DIR__ . '/..' . '/../lib/Storage.php', 'OCA\\Files_Versions\\Versions\\BackendNotFoundException' => __DIR__ . '/..' . '/../lib/Versions/BackendNotFoundException.php', + 'OCA\\Files_Versions\\Versions\\IDeletableVersionBackend' => __DIR__ . '/..' . '/../lib/Versions/IDeletableVersionBackend.php', + 'OCA\\Files_Versions\\Versions\\INameableVersion' => __DIR__ . '/..' . '/../lib/Versions/INameableVersion.php', + 'OCA\\Files_Versions\\Versions\\INameableVersionBackend' => __DIR__ . '/..' . '/../lib/Versions/INameableVersionBackend.php', 'OCA\\Files_Versions\\Versions\\IVersion' => __DIR__ . '/..' . '/../lib/Versions/IVersion.php', 'OCA\\Files_Versions\\Versions\\IVersionBackend' => __DIR__ . '/..' . '/../lib/Versions/IVersionBackend.php', 'OCA\\Files_Versions\\Versions\\IVersionManager' => __DIR__ . '/..' . '/../lib/Versions/IVersionManager.php', diff --git a/apps/files_versions/lib/AppInfo/Application.php b/apps/files_versions/lib/AppInfo/Application.php index a6eab420742..08c49f280af 100644 --- a/apps/files_versions/lib/AppInfo/Application.php +++ b/apps/files_versions/lib/AppInfo/Application.php @@ -47,10 +47,11 @@ use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\Files\Events\Node\BeforeNodeCopiedEvent; use OCP\Files\Events\Node\BeforeNodeDeletedEvent; use OCP\Files\Events\Node\BeforeNodeRenamedEvent; -use OCP\Files\Events\Node\BeforeNodeWrittenEvent; use OCP\Files\Events\Node\NodeCopiedEvent; use OCP\Files\Events\Node\NodeDeletedEvent; use OCP\Files\Events\Node\NodeRenamedEvent; +use OCP\Files\Events\Node\BeforeNodeWrittenEvent; +use OCP\Files\Events\Node\NodeWrittenEvent; use OCP\IConfig; use OCP\IGroupManager; use OCP\IServerContainer; @@ -105,6 +106,7 @@ class Application extends App implements IBootstrap { $context->registerEventListener(LoadSidebar::class, LoadSidebarListener::class); $context->registerEventListener(BeforeNodeWrittenEvent::class, Hooks::class); + $context->registerEventListener(NodeWrittenEvent::class, Hooks::class); $context->registerEventListener(BeforeNodeDeletedEvent::class, Hooks::class); $context->registerEventListener(NodeDeletedEvent::class, Hooks::class); $context->registerEventListener(NodeRenamedEvent::class, Hooks::class); diff --git a/apps/files_versions/lib/Capabilities.php b/apps/files_versions/lib/Capabilities.php index b8602540ec8..031cabb83ec 100644 --- a/apps/files_versions/lib/Capabilities.php +++ b/apps/files_versions/lib/Capabilities.php @@ -24,19 +24,34 @@ */ namespace OCA\Files_Versions; +use OCP\App\IAppManager; use OCP\Capabilities\ICapability; +use OCP\IConfig; class Capabilities implements ICapability { - + private IConfig $config; + private IAppManager $appManager; + + public function __construct( + IConfig $config, + IAppManager $appManager + ) { + $this->config = $config; + $this->appManager = $appManager; + } + /** * Return this classes capabilities * * @return array */ public function getCapabilities() { + $groupFolderOrS3VersioningInstalled = $this->appManager->isInstalled('groupfolders') || !$this->appManager->isInstalled('groupfolders'); + return [ 'files' => [ - 'versioning' => true + 'versioning' => true, + 'version_labeling' => !$groupFolderOrS3VersioningInstalled && $this->config->getSystemValueBool('enable_version_labeling', true), ] ]; } diff --git a/apps/files_versions/lib/Db/VersionEntity.php b/apps/files_versions/lib/Db/VersionEntity.php index 5ef94215dd2..d5adbcfa104 100644 --- a/apps/files_versions/lib/Db/VersionEntity.php +++ b/apps/files_versions/lib/Db/VersionEntity.php @@ -59,7 +59,7 @@ class VersionEntity extends Entity implements JsonSerializable { $this->addType('metadata', Types::JSON); } - public function jsonSerialize() { + public function jsonSerialize(): array { return [ 'id' => $this->id, 'file_id' => $this->fileId, @@ -69,4 +69,13 @@ class VersionEntity extends Entity implements JsonSerializable { 'metadata' => $this->metadata, ]; } + + public function getLabel(): string { + return $this->metadata['label'] ?? ''; + } + + public function setLabel(string $label): void { + $this->metadata['label'] = $label; + $this->markFieldUpdated('metadata'); + } }
\ No newline at end of file diff --git a/apps/files_versions/lib/Hooks.php b/apps/files_versions/lib/Hooks.php index d4408190ea3..ebb0974c4ed 100644 --- a/apps/files_versions/lib/Hooks.php +++ b/apps/files_versions/lib/Hooks.php @@ -32,6 +32,8 @@ namespace OCA\Files_Versions; use OC\Files\Filesystem; use OC\Files\Mount\MoveableMount; use OC\Files\View; +use OCA\Files_Versions\Db\VersionEntity; +use OCA\Files_Versions\Db\VersionsMapper; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\Files\Events\Node\BeforeNodeCopiedEvent; @@ -41,16 +43,28 @@ use OCP\Files\Events\Node\BeforeNodeWrittenEvent; use OCP\Files\Events\Node\NodeCopiedEvent; use OCP\Files\Events\Node\NodeDeletedEvent; use OCP\Files\Events\Node\NodeRenamedEvent; +use OCP\Files\Events\Node\NodeWrittenEvent; use OCP\Files\Folder; +use OCP\Files\IMimeTypeLoader; use OCP\Files\Node; class Hooks implements IEventListener { - public Folder $userFolder; + private Folder $userFolder; + private VersionsMapper $versionsMapper; + /** + * @var array<int, bool> + */ + private array $versionsCreated = []; + private IMimeTypeLoader $mimeTypeLoader; public function __construct( - Folder $userFolder + Folder $userFolder, + VersionsMapper $versionsMapper, + IMimeTypeLoader $mimeTypeLoader ) { $this->userFolder = $userFolder; + $this->versionsMapper = $versionsMapper; + $this->mimeTypeLoader = $mimeTypeLoader; } public function handle(Event $event): void { @@ -58,6 +72,10 @@ class Hooks implements IEventListener { $this->write_hook($event->getNode()); } + if ($event instanceof NodeWrittenEvent) { + $this->post_write_hook($event->getNode()); + } + if ($event instanceof BeforeNodeDeletedEvent) { $this->pre_remove_hook($event->getNode()); } @@ -88,9 +106,34 @@ class Hooks implements IEventListener { */ public function write_hook(Node $node): void { $path = $this->userFolder->getRelativePath($node->getPath()); - Storage::store($path); + $result = Storage::store($path); + + if ($result === false) { + return; + } + + // Store the result of the version creation so it can be used in post_write_hook. + $this->versionsCreated[$node->getId()] = true; } + /** + * listen to post_write event. + */ + public function post_write_hook(Node $node): void { + if (!array_key_exists($node->getId(), $this->versionsCreated)) { + return; + } + + unset($this->versionsCreated[$node->getId()]); + + $versionEntity = new VersionEntity(); + $versionEntity->setFileId($node->getId()); + $versionEntity->setTimestamp($node->getMTime()); + $versionEntity->setSize($node->getSize()); + $versionEntity->setMimetype($this->mimeTypeLoader->getId($node->getMimetype())); + $versionEntity->setMetadata([]); + $this->versionsMapper->insert($versionEntity); + } /** * Erase versions of deleted file diff --git a/apps/files_versions/lib/Sabre/Plugin.php b/apps/files_versions/lib/Sabre/Plugin.php index 5a127b4251d..4fd17194ba6 100644 --- a/apps/files_versions/lib/Sabre/Plugin.php +++ b/apps/files_versions/lib/Sabre/Plugin.php @@ -29,19 +29,23 @@ namespace OCA\Files_Versions\Sabre; use OC\AppFramework\Http\Request; use OCP\IRequest; use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\INode; +use Sabre\DAV\PropFind; +use Sabre\DAV\PropPatch; use Sabre\DAV\Server; use Sabre\DAV\ServerPlugin; use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; class Plugin extends ServerPlugin { + private Server $server; + private IRequest $request; - /** @var Server */ - private $server; - /** @var IRequest */ - private $request; + public const VERSION_LABEL = '{http://nextcloud.org/ns}version-label'; - public function __construct(IRequest $request) { + public function __construct( + IRequest $request + ) { $this->request = $request; } @@ -49,6 +53,8 @@ class Plugin extends ServerPlugin { $this->server = $server; $server->on('afterMethod:GET', [$this, 'afterGet']); + $server->on('propFind', [$this, 'propFind']); + $server->on('propPatch', [$this, 'propPatch']); } public function afterGet(RequestInterface $request, ResponseInterface $response) { @@ -81,4 +87,18 @@ class Plugin extends ServerPlugin { . '; filename="' . rawurlencode($filename) . '"'); } } + + public function propFind(PropFind $propFind, INode $node): void { + if ($node instanceof VersionFile) { + $propFind->handle(self::VERSION_LABEL, fn() => $node->getLabel()); + } + } + + public function propPatch($path, PropPatch $propPatch): void { + $node = $this->server->tree->getNodeForPath($path); + + if ($node instanceof VersionFile) { + $propPatch->handle(self::VERSION_LABEL, fn ($label) => $node->setLabel($label)); + } + } } diff --git a/apps/files_versions/lib/Sabre/VersionFile.php b/apps/files_versions/lib/Sabre/VersionFile.php index b7c7e6db1a6..9018f75703d 100644 --- a/apps/files_versions/lib/Sabre/VersionFile.php +++ b/apps/files_versions/lib/Sabre/VersionFile.php @@ -26,6 +26,8 @@ declare(strict_types=1); */ namespace OCA\Files_Versions\Sabre; +use OCA\Files_Versions\Versions\INameableVersion; +use OCA\Files_Versions\Versions\INameableVersionBackend; use OCA\Files_Versions\Versions\IVersion; use OCA\Files_Versions\Versions\IVersionManager; use OCP\Files\NotFoundException; @@ -70,6 +72,7 @@ class VersionFile implements IFile { } public function delete() { + // TODO: implement version deletion throw new Forbidden(); } @@ -81,6 +84,23 @@ class VersionFile implements IFile { throw new Forbidden(); } + public function getLabel(): ?string { + if ($this->version instanceof INameableVersion) { + return $this->version->getLabel(); + } else { + return null; + } + } + + public function setLabel($label): bool { + if ($this->versionManager instanceof INameableVersionBackend) { + $this->versionManager->setVersionLabel($this->version, $label); + return true; + } else { + return false; + } + } + public function getLastModified(): int { return $this->version->getTimestamp(); } diff --git a/dist/federatedfilesharing-vue-settings-admin.js.LICENSE.txt b/apps/files_versions/lib/Versions/INameableVersion.php index 2053f8e9ec7..b6ddb951e25 100644 --- a/dist/federatedfilesharing-vue-settings-admin.js.LICENSE.txt +++ b/apps/files_versions/lib/Versions/INameableVersion.php @@ -1,7 +1,9 @@ +<?php + +declare(strict_types=1); + /** - * @copyright 2022 Carl Schwan <carl@carlschwan.eu> - * - * @author Carl Schwan <carl@carlschwan.eu> + * @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me> * * @license GNU AGPL version 3 or any later version * @@ -19,3 +21,17 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ +namespace OCA\Files_Versions\Versions; + +/** + * @since 26.0.0 + */ +interface INameableVersion { + /** + * Get the user created label + * + * @return string + * @since 26.0.0 + */ + public function getLabel(): string; +} diff --git a/apps/files_versions/lib/Versions/INameableVersionBackend.php b/apps/files_versions/lib/Versions/INameableVersionBackend.php new file mode 100644 index 00000000000..4a8c094cf18 --- /dev/null +++ b/apps/files_versions/lib/Versions/INameableVersionBackend.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me> + * + * @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\Files_Versions\Versions; + +/** + * @since 26.0.0 + */ +interface INameableVersionBackend { + /** + * Set the label for a version. + * + * @since 26.0.0 + */ + public function setVersionLabel(IVersion $version, string $label): void; +} diff --git a/apps/files_versions/lib/Versions/LegacyVersionsBackend.php b/apps/files_versions/lib/Versions/LegacyVersionsBackend.php index 4ea0985e113..90c7cb930d5 100644 --- a/apps/files_versions/lib/Versions/LegacyVersionsBackend.php +++ b/apps/files_versions/lib/Versions/LegacyVersionsBackend.php @@ -28,25 +28,36 @@ namespace OCA\Files_Versions\Versions; use OC\Files\View; use OCA\Files_Sharing\SharedStorage; +use OCA\Files_Versions\Db\VersionEntity; +use OCA\Files_Versions\Db\VersionsMapper; use OCA\Files_Versions\Storage; use OCP\Files\File; use OCP\Files\FileInfo; use OCP\Files\Folder; +use OCP\Files\IMimeTypeLoader; use OCP\Files\IRootFolder; +use OCP\Files\Node; use OCP\Files\NotFoundException; use OCP\Files\Storage\IStorage; use OCP\IUser; use OCP\IUserManager; -class LegacyVersionsBackend implements IVersionBackend { - /** @var IRootFolder */ - private $rootFolder; - /** @var IUserManager */ - private $userManager; - - public function __construct(IRootFolder $rootFolder, IUserManager $userManager) { +class LegacyVersionsBackend implements IVersionBackend, INameableVersionBackend, IDeletableVersionBackend { + private IRootFolder $rootFolder; + private IUserManager $userManager; + private VersionsMapper $versionsMapper; + private IMimeTypeLoader $mimeTypeLoader; + + public function __construct( + IRootFolder $rootFolder, + IUserManager $userManager, + VersionsMapper $versionsMapper, + IMimeTypeLoader $mimeTypeLoader + ) { $this->rootFolder = $rootFolder; $this->userManager = $userManager; + $this->versionsMapper = $versionsMapper; + $this->mimeTypeLoader = $mimeTypeLoader; } public function useBackendForStorage(IStorage $storage): bool { @@ -63,21 +74,60 @@ class LegacyVersionsBackend implements IVersionBackend { $userFolder = $this->rootFolder->getUserFolder($user->getUID()); $nodes = $userFolder->getById($file->getId()); $file2 = array_pop($nodes); - $versions = Storage::getVersions($user->getUID(), $userFolder->getRelativePath($file2->getPath())); - - return array_map(function (array $data) use ($file, $user) { - return new Version( - (int)$data['version'], - (int)$data['version'], - $data['name'], - (int)$data['size'], - $data['mimetype'], - $data['path'], + + $versions = $this->getVersionsForFileFromDB($file2, $user); + + if (count($versions) > 0) { + return $versions; + } + + // Insert the entry in the DB for the current version. + if ($file2->getSize() > 0) { + $versionEntity = new VersionEntity(); + $versionEntity->setFileId($file2->getId()); + $versionEntity->setTimestamp($file2->getMTime()); + $versionEntity->setSize($file2->getSize()); + $versionEntity->setMimetype($this->mimeTypeLoader->getId($file2->getMimetype())); + $versionEntity->setMetadata([]); + $this->versionsMapper->insert($versionEntity); + } + + // Insert entries in the DB for existing versions. + $versionsOnFS = Storage::getVersions($user->getUID(), $userFolder->getRelativePath($file2->getPath())); + foreach ($versionsOnFS as $version) { + $versionEntity = new VersionEntity(); + $versionEntity->setFileId($file2->getId()); + $versionEntity->setTimestamp((int)$version['version']); + $versionEntity->setSize((int)$version['size']); + $versionEntity->setMimetype($this->mimeTypeLoader->getId($version['mimetype'])); + $versionEntity->setMetadata([]); + $this->versionsMapper->insert($versionEntity); + } + + return $this->getVersionsForFileFromDB($file2, $user); + } + + /** + * @return IVersion[] + */ + private function getVersionsForFileFromDB(Node $file, IUser $user): array { + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + + return array_map( + fn (VersionEntity $versionEntity) => new Version( + $versionEntity->getTimestamp(), + $versionEntity->getTimestamp(), + $file->getName(), + $versionEntity->getSize(), + $this->mimeTypeLoader->getMimetypeById($versionEntity->getMimetype()), + $userFolder->getRelativePath($file->getPath()), $file, $this, - $user - ); - }, $versions); + $user, + $versionEntity->getLabel(), + ), + $this->versionsMapper->findAllVersionsForFileId($file->getId()) + ); } public function createVersion(IUser $user, FileInfo $file) { @@ -125,4 +175,16 @@ class LegacyVersionsBackend implements IVersionBackend { $file = $versionFolder->get($userFolder->getRelativePath($sourceFile->getPath()) . '.v' . $revision); return $file; } + + public function setVersionLabel(IVersion $version, string $label): void { + $versionEntity = $this->versionsMapper->findVersionForFileId( + $version->getSourceFile()->getId(), + $version->getTimestamp(), + ); + if (trim($label) === '') { + $label = null; + } + $versionEntity->setLabel($label ?? ''); + $this->versionsMapper->update($versionEntity); + } } diff --git a/apps/files_versions/lib/Versions/Version.php b/apps/files_versions/lib/Versions/Version.php index 9979933ebc5..e87c2a593d7 100644 --- a/apps/files_versions/lib/Versions/Version.php +++ b/apps/files_versions/lib/Versions/Version.php @@ -28,7 +28,7 @@ namespace OCA\Files_Versions\Versions; use OCP\Files\FileInfo; use OCP\IUser; -class Version implements IVersion { +class Version implements IVersion, INameableVersion { /** @var int */ private $timestamp; @@ -38,6 +38,8 @@ class Version implements IVersion { /** @var string */ private $name; + private string $label; + /** @var int */ private $size; @@ -65,11 +67,13 @@ class Version implements IVersion { string $path, FileInfo $sourceFileInfo, IVersionBackend $backend, - IUser $user + IUser $user, + string $label = '' ) { $this->timestamp = $timestamp; $this->revisionId = $revisionId; $this->name = $name; + $this->label = $label; $this->size = $size; $this->mimetype = $mimetype; $this->path = $path; @@ -102,6 +106,10 @@ class Version implements IVersion { return $this->name; } + public function getLabel(): string { + return $this->label; + } + public function getMimeType(): string { return $this->mimetype; } diff --git a/apps/files_versions/lib/Versions/VersionManager.php b/apps/files_versions/lib/Versions/VersionManager.php index 4700f1b208b..4787de7fdac 100644 --- a/apps/files_versions/lib/Versions/VersionManager.php +++ b/apps/files_versions/lib/Versions/VersionManager.php @@ -30,7 +30,7 @@ use OCP\Files\FileInfo; use OCP\Files\Storage\IStorage; use OCP\IUser; -class VersionManager implements IVersionManager { +class VersionManager implements IVersionManager, INameableVersionBackend { /** @var (IVersionBackend[])[] */ private $backends = []; @@ -110,4 +110,11 @@ class VersionManager implements IVersionManager { public function useBackendForStorage(IStorage $storage): bool { return false; } + + public function setVersionLabel(IVersion $version, string $label): void { + $backend = $this->getBackendForStorage($version->getSourceFile()->getStorage()); + if ($backend instanceof INameableVersionBackend) { + $backend->setVersionLabel($version, $label); + } + } } diff --git a/apps/files_versions/src/components/Version.vue b/apps/files_versions/src/components/Version.vue new file mode 100644 index 00000000000..a06d6a0ba8e --- /dev/null +++ b/apps/files_versions/src/components/Version.vue @@ -0,0 +1,302 @@ +<!-- + - @copyright 2022 Carl Schwan <carl@carlschwan.eu> + - @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> + <NcListItem class="version" + :title="versionLabel" + :href="downloadURL" + :force-display-actions="true"> + <template #icon> + <img lazy="true" + :src="previewURL" + alt="" + height="256" + width="256" + class="version__image"> + </template> + <template #subtitle> + <div class="version__info"> + <span v-tooltip="formattedDate">{{ version.mtime | humanDateFromNow }}</span> + <!-- Separate dot to improve alignement --> + <span class="version__info__size">•</span> + <span class="version__info__size">{{ version.size | humanReadableSize }}</span> + </div> + </template> + <template #actions> + <NcActionButton v-if="capabilities.files.version_labeling === true" + :close-after-click="true" + @click="openVersionLabelModal"> + <template #icon> + <Pencil :size="22" /> + </template> + {{ version.label === '' ? t('files_versions', 'Name this version') : t('files_versions', 'Edit version name') }} + </NcActionButton> + <NcActionButton v-if="!isCurrent" + :close-after-click="true" + @click="restoreVersion"> + <template #icon> + <BackupRestore :size="22" /> + </template> + {{ t('files_versions', 'Restore version') }} + </NcActionButton> + <NcActionLink :href="downloadURL" + :close-after-click="true" + :download="downloadURL"> + <template #icon> + <Download :size="22" /> + </template> + {{ t('files_versions', 'Download version') }} + </NcActionLink> + <NcActionButton v-if="!isCurrent" + :close-after-click="true" + @click="deleteVersion"> + <template #icon> + <Delete :size="22" /> + </template> + {{ t('files_versions', 'Delete version') }} + </NcActionButton> + </template> + </NcListItem> + <NcModal v-if="showVersionLabelForm" + :title="t('files_versions', 'Name this version')" + @close="showVersionLabelForm = false"> + <form class="version-label-modal" + @submit.prevent="setVersionLabel(formVersionLabelValue)"> + <label> + <div class="version-label-modal__title">{{ t('photos', 'Version name') }}</div> + <NcTextField ref="labelInput" + :value.sync="formVersionLabelValue" + :placeholder="t('photos', 'Version name')" + :label-outside="true" /> + </label> + + <div class="version-label-modal__info"> + {{ t('photos', 'Named versions are persisted, and excluded from automatic cleanups when your storage quota is full.') }} + </div> + + <div class="version-label-modal__actions"> + <NcButton :disabled="formVersionLabelValue.trim().length === 0" @click="setVersionLabel('')"> + {{ t('files_versions', 'Remove version name') }} + </NcButton> + <NcButton type="primary" native-type="submit"> + <template #icon> + <Check /> + </template> + {{ t('files_versions', 'Save version name') }} + </NcButton> + </div> + </form> + </NcModal> + </div> +</template> + +<script> +import BackupRestore from 'vue-material-design-icons/BackupRestore.vue' +import Download from 'vue-material-design-icons/Download.vue' +import Pencil from 'vue-material-design-icons/Pencil.vue' +import Check from 'vue-material-design-icons/Check.vue' +import Delete from 'vue-material-design-icons/Delete' +import { NcActionButton, NcActionLink, NcListItem, NcModal, NcButton, NcTextField, Tooltip } from '@nextcloud/vue' +import moment from '@nextcloud/moment' +import { translate } from '@nextcloud/l10n' +import { joinPaths } from '@nextcloud/paths' +import { generateUrl } from '@nextcloud/router' +import { loadState } from '@nextcloud/initial-state' + +export default { + name: 'Version', + components: { + NcActionLink, + NcActionButton, + NcListItem, + NcModal, + NcButton, + NcTextField, + BackupRestore, + Download, + Pencil, + Check, + Delete, + }, + directives: { + tooltip: Tooltip, + }, + filters: { + /** + * @param {number} bytes + * @return {string} + */ + humanReadableSize(bytes) { + return OC.Util.humanFileSize(bytes) + }, + /** + * @param {number} timestamp + * @return {string} + */ + humanDateFromNow(timestamp) { + return moment(timestamp).fromNow() + }, + }, + props: { + /** @type {Vue.PropOptions<import('../utils/versions.js').Version>} */ + version: { + type: Object, + required: true, + }, + fileInfo: { + type: Object, + required: true, + }, + isCurrent: { + type: Boolean, + default: false, + }, + isFirstVersion: { + type: Boolean, + default: false, + }, + }, + data() { + return { + showVersionLabelForm: false, + formVersionLabelValue: this.version.label, + capabilities: loadState('core', 'capabilities', { files: { version_labeling: false } }), + } + }, + computed: { + /** + * @return {string} + */ + versionLabel() { + if (this.isCurrent) { + if (this.version.label === '') { + return translate('files_versions', 'Current version') + } else { + return `${this.version.label} (${translate('files_versions', 'Current version')})` + } + } + + if (this.isFirstVersion && this.version.label === '') { + return translate('files_versions', 'Initial version') + } + + return this.version.label + }, + + /** + * @return {string} + */ + downloadURL() { + if (this.isCurrent) { + return joinPaths('/remote.php/webdav', this.fileInfo.path, this.fileInfo.name) + } else { + return this.version.url + } + }, + + /** + * @return {string} + */ + previewURL() { + if (this.isCurrent) { + return generateUrl('/core/preview?fileId={fileId}&c={fileEtag}&x=250&y=250&forceIcon=0&a=0', { + fileId: this.fileInfo.id, + fileEtag: this.fileInfo.etag, + }) + } else { + return this.version.preview + } + }, + }, + methods: { + openVersionLabelModal() { + this.showVersionLabelForm = true + this.$nextTick(() => { + this.$refs.labelInput.$el.getElementsByTagName('input')[0].focus() + }) + }, + + restoreVersion() { + this.$emit('restore', this.version) + }, + + setVersionLabel(label) { + this.formVersionLabelValue = label + this.showVersionLabelForm = false + this.$emit('label-update', this.version, label) + }, + + deleteVersion() { + this.$emit('delete', this.version) + }, + + formattedDate() { + return moment(this.version.mtime) + }, + }, +} +</script> + +<style scoped lang="scss"> +.version { + display: flex; + flex-direction: row; + + &__info { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.5rem; + + &__size { + color: var(--color-text-lighter); + } + } + + &__image { + width: 3rem; + height: 3rem; + border: 1px solid var(--color-border); + border-radius: var(--border-radius-large); + } +} + +.version-label-modal { + display: flex; + justify-content: space-between; + flex-direction: column; + height: 250px; + padding: 16px; + + &__title { + margin-bottom: 12px; + font-weight: 600; + } + + &__info { + margin-top: 12px; + color: var(--color-text-maxcontrast); + } + + &__actions { + display: flex; + justify-content: space-between; + margin-top: 64px; + } +} +</style> diff --git a/apps/files_versions/src/files_versions_tab.js b/apps/files_versions/src/files_versions_tab.js index 8482247e672..e67199436fa 100644 --- a/apps/files_versions/src/files_versions_tab.js +++ b/apps/files_versions/src/files_versions_tab.js @@ -41,7 +41,7 @@ window.addEventListener('DOMContentLoaded', function() { OCA.Files.Sidebar.registerTab(new OCA.Files.Sidebar.Tab({ id: 'version_vue', - name: t('files_versions', 'Version'), + name: t('files_versions', 'Versions'), iconSvg: BackupRestore, async mount(el, fileInfo, context) { diff --git a/apps/files_versions/src/utils/davRequest.js b/apps/files_versions/src/utils/davRequest.js index b77cd150643..fb2126d98bf 100644 --- a/apps/files_versions/src/utils/davRequest.js +++ b/apps/files_versions/src/utils/davRequest.js @@ -29,5 +29,6 @@ export default `<?xml version="1.0"?> <d:getcontentlength /> <d:getcontenttype /> <d:getlastmodified /> + <nc:version-label /> </d:prop> </d:propfind>` diff --git a/apps/files_versions/src/utils/versions.js b/apps/files_versions/src/utils/versions.js index 8fe258119f7..1a5dde10824 100644 --- a/apps/files_versions/src/utils/versions.js +++ b/apps/files_versions/src/utils/versions.js @@ -23,14 +23,14 @@ import { getCurrentUser } from '@nextcloud/auth' import client from '../utils/davClient.js' import davRequest from '../utils/davRequest.js' import logger from '../utils/logger.js' -import { basename, joinPaths } from '@nextcloud/paths' +import { joinPaths } from '@nextcloud/paths' import { generateUrl } from '@nextcloud/router' -import { translate } from '@nextcloud/l10n' import moment from '@nextcloud/moment' /** * @typedef {object} Version - * @property {string} title - 'Current version' or '' + * @property {string} fileId - The id of the file associated to the version. + * @property {string} label - 'Current version' or '' * @property {string} fileName - File name relative to the version DAV endpoint * @property {string} mimeType - Empty for the current version, else the actual mime type of the version * @property {string} size - Human readable size @@ -39,7 +39,6 @@ import moment from '@nextcloud/moment' * @property {string} preview - Preview URL of the version * @property {string} url - Download URL of the version * @property {string|null} fileVersion - The version id, null for the current version - * @property {boolean} isCurrent - Whether this is the current version of the file */ /** @@ -50,11 +49,15 @@ export async function fetchVersions(fileInfo) { const path = `/versions/${getCurrentUser()?.uid}/versions/${fileInfo.id}` try { - /** @type {import('webdav').FileStat[]} */ + /** @type {import('webdav').ResponseDataDetailed<import('webdav').FileStat[]>} */ const response = await client.getDirectoryContents(path, { data: davRequest, + details: true, }) - return response.map(version => formatVersion(version, fileInfo)) + return response.data + // Filter out root + .filter(({ mime }) => mime !== '') + .map(version => formatVersion(version, fileInfo)) } catch (exception) { logger.error('Could not fetch version', { exception }) throw exception @@ -65,13 +68,12 @@ export async function fetchVersions(fileInfo) { * Restore the given version * * @param {Version} version - * @param {object} fileInfo */ -export async function restoreVersion(version, fileInfo) { +export async function restoreVersion(version) { try { logger.debug('Restoring version', { url: version.url }) await client.moveFile( - `/versions/${getCurrentUser()?.uid}/versions/${fileInfo.id}/${version.fileVersion}`, + `/versions/${getCurrentUser()?.uid}/versions/${version.fileId}/${version.fileVersion}`, `/versions/${getCurrentUser()?.uid}/restore/target` ) } catch (exception) { @@ -88,37 +90,50 @@ export async function restoreVersion(version, fileInfo) { * @return {Version} */ function formatVersion(version, fileInfo) { - const isCurrent = version.mime === '' - const fileVersion = isCurrent ? null : basename(version.filename) - - let url = null - let preview = null - - if (isCurrent) { - // https://nextcloud_server2.test/remote.php/webdav/welcome.txt?downloadStartSecret=hl5awd7tbzg - url = joinPaths('/remote.php/webdav', fileInfo.path, fileInfo.name) - preview = generateUrl('/core/preview?fileId={fileId}&c={fileEtag}&x=250&y=250&forceIcon=0&a=0', { - fileId: fileInfo.id, - fileEtag: fileInfo.etag, - }) - } else { - url = joinPaths('/remote.php/dav', version.filename) - preview = generateUrl('/apps/files_versions/preview?file={file}&version={fileVersion}', { - file: joinPaths(fileInfo.path, fileInfo.name), - fileVersion, - }) - } - return { - title: isCurrent ? translate('files_versions', 'Current version') : '', + fileId: fileInfo.id, + label: version.props['version-label'], fileName: version.filename, mimeType: version.mime, - size: isCurrent ? fileInfo.size : version.size, + size: version.size, type: version.type, - mtime: moment(isCurrent ? fileInfo.mtime : version.lastmod).unix(), - preview, - url, - fileVersion, - isCurrent, + mtime: moment(version.lastmod).unix() * 1000, + preview: generateUrl('/apps/files_versions/preview?file={file}&version={fileVersion}', { + file: joinPaths(fileInfo.path, fileInfo.name), + fileVersion: version.basename, + }), + url: joinPaths('/remote.php/dav', version.filename), + fileVersion: version.basename, } } + +/** + * @param {Version} version + * @param {string} newLabel + */ +export async function setVersionLabel(version, newLabel) { + return await client.customRequest( + version.fileName, + { + method: 'PROPPATCH', + data: `<?xml version="1.0"?> + <d:propertyupdate xmlns:d="DAV:" + xmlns:oc="http://owncloud.org/ns" + xmlns:nc="http://nextcloud.org/ns" + xmlns:ocs="http://open-collaboration-services.org/ns"> + <d:set> + <d:prop> + <nc:version-label>${newLabel}</nc:version-label> + </d:prop> + </d:set> + </d:propertyupdate>`, + } + ) +} + +/** + * @param {Version} version + */ +export async function deleteVersion(version) { + await client.deleteFile(version.fileName) +} diff --git a/apps/files_versions/src/views/VersionTab.vue b/apps/files_versions/src/views/VersionTab.vue index 8159415dfc7..5c078ea405b 100644 --- a/apps/files_versions/src/views/VersionTab.vue +++ b/apps/files_versions/src/views/VersionTab.vue @@ -16,84 +16,28 @@ - along with this program. If not, see <http://www.gnu.org/licenses/>. --> <template> - <div> - <ul> - <NcListItem v-for="version in versions" - :key="version.mtime" - class="version" - :title="version.title" - :href="version.url"> - <template #icon> - <img lazy="true" - :src="version.preview" - alt="" - height="256" - width="256" - class="version__image"> - </template> - <template #subtitle> - <div class="version__info"> - <span>{{ version.mtime | humanDateFromNow }}</span> - <!-- Separate dot to improve alignement --> - <span class="version__info__size">•</span> - <span class="version__info__size">{{ version.size | humanReadableSize }}</span> - </div> - </template> - <template v-if="!version.isCurrent" #actions> - <NcActionLink :href="version.url" - :download="version.url"> - <template #icon> - <Download :size="22" /> - </template> - {{ t('files_versions', 'Download version') }} - </NcActionLink> - <NcActionButton @click="restoreVersion(version)"> - <template #icon> - <BackupRestore :size="22" /> - </template> - {{ t('files_versions', 'Restore version') }} - </NcActionButton> - </template> - </NcListItem> - <NcEmptyContent v-if="!loading && versions.length === 1" - :title="t('files_version', 'No versions yet')"> - <!-- length === 1, since we don't want to show versions if there is only the current file --> - <template #icon> - <BackupRestore /> - </template> - </NcEmptyContent> - </ul> - </div> + <ul> + <Version v-for="version in orderedVersions" + :key="version.mtime" + :version="version" + :file-info="fileInfo" + :is-current="version.mtime === fileInfo.mtime" + :is-first-version="version.mtime === initialVersionMtime" + @restore="handleRestore" + @label-update="handleLabelUpdate" + @delete="handleDelete" /> + </ul> </template> <script> -import BackupRestore from 'vue-material-design-icons/BackupRestore.vue' -import Download from 'vue-material-design-icons/Download.vue' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink.js' -import NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' import { showError, showSuccess } from '@nextcloud/dialogs' -import { fetchVersions, restoreVersion } from '../utils/versions.js' -import moment from '@nextcloud/moment' +import { fetchVersions, deleteVersion, restoreVersion, setVersionLabel } from '../utils/versions.js' +import Version from '../components/Version.vue' export default { name: 'VersionTab', components: { - NcEmptyContent, - NcActionLink, - NcActionButton, - NcListItem, - BackupRestore, - Download, - }, - filters: { - humanReadableSize(bytes) { - return OC.Util.humanFileSize(bytes) - }, - humanDateFromNow(timestamp) { - return moment(timestamp * 1000).fromNow() - }, + Version, }, data() { return { @@ -103,6 +47,35 @@ export default { loading: false, } }, + computed: { + /** + * Order versions by mtime. + * Put the current version at the top. + * + * @return {import('../utils/versions.js').Version[]} + */ + orderedVersions() { + return [...this.versions].sort((a, b) => { + if (a.mtime === this.fileInfo.mtime) { + return -1 + } else if (b.mtime === this.fileInfo.mtime) { + return 1 + } else { + return b.mtime - a.mtime + } + }) + }, + + /** + * Return the mtime of the first version to display "Initial version" label + * @return {number} + */ + initialVersionMtime() { + return this.versions + .map(version => version.mtime) + .reduce((a, b) => Math.min(a, b)) + }, + }, methods: { /** * Update current fileInfo and fetch new data @@ -128,55 +101,77 @@ export default { }, /** - * Restore the given version + * Handle restored event from Version.vue * - * @param version + * @param {import('../utils/versions.js').Version} version */ - async restoreVersion(version) { + async handleRestore(version) { + // Update local copy of fileInfo as rendering depends on it. + const oldFileInfo = this.fileInfo + this.fileInfo = { + ...this.fileInfo, + size: version.size, + mtime: version.mtime, + } + try { - await restoreVersion(version, this.fileInfo) - // File info is not updated so we manually update its size and mtime if the restoration went fine. - this.fileInfo.size = version.size - this.fileInfo.mtime = version.lastmod - showSuccess(t('files_versions', 'Version restored')) + await restoreVersion(version) + if (version.label !== '') { + showSuccess(t('files_versions', `${version.label} restored`)) + } else if (version.mtime === this.initialVersionMtime) { + showSuccess(t('files_versions', 'Initial version restored')) + } else { + showSuccess(t('files_versions', 'Version restored')) + } await this.fetchVersions() } catch (exception) { + this.fileInfo = oldFileInfo showError(t('files_versions', 'Could not restore version')) } }, /** + * Handle label-updated event from Version.vue + * + * @param {import('../utils/versions.js').Version} version + * @param {string} newName + */ + async handleLabelUpdate(version, newName) { + const oldLabel = version.label + version.label = newName + + try { + await setVersionLabel(version, newName) + } catch (exception) { + version.label = oldLabel + showError(t('files_versions', 'Could not set version name')) + } + }, + + /** + * Handle deleted event from Version.vue + * + * @param {import('../utils/versions.js').Version} version + * @param {string} newName + */ + async handleDelete(version) { + const index = this.versions.indexOf(version) + this.versions.splice(index, 1) + + try { + await deleteVersion(version) + } catch (exception) { + this.versions.push(version) + showError(t('files_versions', 'Could not delete version')) + } + }, + + /** * Reset the current view to its default state */ resetState() { - this.versions = [] + this.$set(this, 'versions', []) }, }, } </script> - -<style scopped lang="scss"> -.version { - display: flex; - flex-direction: row; - - &__info { - display: flex; - flex-direction: row; - align-items: center; - gap: 0.5rem; - - &__size { - color: var(--color-text-lighter); - } - } - - &__image { - width: 3rem; - height: 3rem; - border: 1px solid var(--color-border); - margin-right: 1rem; - border-radius: var(--border-radius-large); - } -} -</style> |