summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/files_versions/composer/composer/autoload_classmap.php3
-rw-r--r--apps/files_versions/composer/composer/autoload_static.php3
-rw-r--r--apps/files_versions/lib/AppInfo/Application.php4
-rw-r--r--apps/files_versions/lib/Capabilities.php19
-rw-r--r--apps/files_versions/lib/Db/VersionEntity.php11
-rw-r--r--apps/files_versions/lib/Hooks.php49
-rw-r--r--apps/files_versions/lib/Sabre/Plugin.php30
-rw-r--r--apps/files_versions/lib/Sabre/VersionFile.php20
-rw-r--r--apps/files_versions/lib/Versions/INameableVersion.php (renamed from dist/federatedfilesharing-vue-settings-admin.js.LICENSE.txt)22
-rw-r--r--apps/files_versions/lib/Versions/INameableVersionBackend.php36
-rw-r--r--apps/files_versions/lib/Versions/LegacyVersionsBackend.php102
-rw-r--r--apps/files_versions/lib/Versions/Version.php12
-rw-r--r--apps/files_versions/lib/Versions/VersionManager.php9
-rw-r--r--apps/files_versions/src/components/Version.vue302
-rw-r--r--apps/files_versions/src/files_versions_tab.js2
-rw-r--r--apps/files_versions/src/utils/davRequest.js1
-rw-r--r--apps/files_versions/src/utils/versions.js89
-rw-r--r--apps/files_versions/src/views/VersionTab.vue205
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>