diff options
author | Louis <6653109+artonge@users.noreply.github.com> | 2023-01-30 10:40:49 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-01-30 10:40:49 +0100 |
commit | 2f3007205d11732e931615ab1bf71620321ed64a (patch) | |
tree | 6061c638f220f46c62f859689e6e4977eecb32be /apps | |
parent | de415fbb15d6c5cceca7b05261947d573a7a512c (diff) | |
parent | 552f5b0171ab071e9696414e05105791875f51fb (diff) | |
download | nextcloud-server-2f3007205d11732e931615ab1bf71620321ed64a.tar.gz nextcloud-server-2f3007205d11732e931615ab1bf71620321ed64a.zip |
Merge pull request #35160 from nextcloud/artonge/feat/version_naming_backend
Allow to name a version
Diffstat (limited to 'apps')
27 files changed, 1457 insertions, 340 deletions
diff --git a/apps/files_trashbin/tests/StorageTest.php b/apps/files_trashbin/tests/StorageTest.php index 0a7a129ca28..b4892d2deb0 100644 --- a/apps/files_trashbin/tests/StorageTest.php +++ b/apps/files_trashbin/tests/StorageTest.php @@ -92,6 +92,8 @@ class StorageTest extends \Test\TestCase { parent::setUp(); \OC_Hook::clear(); + \OC::$server->boot(); + // register trashbin hooks $trashbinApp = new Application(); $trashbinApp->boot($this->createMock(IBootContext::class)); @@ -224,8 +226,6 @@ class StorageTest extends \Test\TestCase { * Test that deleted versions properly land in the trashbin. */ public function testDeleteVersionsOfFile() { - \OCA\Files_Versions\Hooks::connectHooks(); - // trigger a version (multiple would not work because of the expire logic) $this->userView->file_put_contents('test.txt', 'v1'); @@ -253,8 +253,6 @@ class StorageTest extends \Test\TestCase { * Test that deleted versions properly land in the trashbin. */ public function testDeleteVersionsOfFolder() { - \OCA\Files_Versions\Hooks::connectHooks(); - // trigger a version (multiple would not work because of the expire logic) $this->userView->file_put_contents('folder/inside.txt', 'v1'); @@ -288,8 +286,6 @@ class StorageTest extends \Test\TestCase { * Test that deleted versions properly land in the trashbin when deleting as share recipient. */ public function testDeleteVersionsOfFileAsRecipient() { - \OCA\Files_Versions\Hooks::connectHooks(); - $this->userView->mkdir('share'); // trigger a version (multiple would not work because of the expire logic) $this->userView->file_put_contents('share/test.txt', 'v1'); @@ -341,8 +337,6 @@ class StorageTest extends \Test\TestCase { * Test that deleted versions properly land in the trashbin when deleting as share recipient. */ public function testDeleteVersionsOfFolderAsRecipient() { - \OCA\Files_Versions\Hooks::connectHooks(); - $this->userView->mkdir('share'); $this->userView->mkdir('share/folder'); // trigger a version (multiple would not work because of the expire logic) @@ -410,8 +404,6 @@ class StorageTest extends \Test\TestCase { * unlink() which should NOT trigger the version deletion logic. */ public function testKeepFileAndVersionsWhenMovingFileBetweenStorages() { - \OCA\Files_Versions\Hooks::connectHooks(); - $storage2 = new Temporary([]); \OC\Files\Filesystem::mount($storage2, [], $this->user . '/files/substorage'); @@ -451,8 +443,6 @@ class StorageTest extends \Test\TestCase { * unlink() which should NOT trigger the version deletion logic. */ public function testKeepFileAndVersionsWhenMovingFolderBetweenStorages() { - \OCA\Files_Versions\Hooks::connectHooks(); - $storage2 = new Temporary([]); \OC\Files\Filesystem::mount($storage2, [], $this->user . '/files/substorage'); diff --git a/apps/files_versions/appinfo/info.xml b/apps/files_versions/appinfo/info.xml index d40a16c023b..23a24a34d7c 100644 --- a/apps/files_versions/appinfo/info.xml +++ b/apps/files_versions/appinfo/info.xml @@ -8,7 +8,7 @@ This application automatically maintains older versions of files that are changed. When enabled, a hidden versions folder is provisioned in every user's directory and is used to store old file versions. A user can revert to an older version through the web interface at any time, with the replaced file becoming a version. The app automatically manages the versions folder to ensure the user does not run out of Quota because of versions. In addition to the expiry of versions, the versions app makes certain never to use more than 50% of the user's currently available free space. If stored versions exceed this limit, the app will delete the oldest versions first until it meets this limit. More information is available in the Versions documentation. </description> - <version>1.19.0</version> + <version>1.19.1</version> <licence>agpl</licence> <author>Frank Karlitschek</author> <author>Bjoern Schiessle</author> diff --git a/apps/files_versions/composer/composer/autoload_classmap.php b/apps/files_versions/composer/composer/autoload_classmap.php index 43b678ef39c..25a1e1c4913 100644 --- a/apps/files_versions/composer/composer/autoload_classmap.php +++ b/apps/files_versions/composer/composer/autoload_classmap.php @@ -14,11 +14,14 @@ return array( 'OCA\\Files_Versions\\Command\\Expire' => $baseDir . '/../lib/Command/Expire.php', 'OCA\\Files_Versions\\Command\\ExpireVersions' => $baseDir . '/../lib/Command/ExpireVersions.php', 'OCA\\Files_Versions\\Controller\\PreviewController' => $baseDir . '/../lib/Controller/PreviewController.php', + 'OCA\\Files_Versions\\Db\\VersionEntity' => $baseDir . '/../lib/Db/VersionEntity.php', + 'OCA\\Files_Versions\\Db\\VersionsMapper' => $baseDir . '/../lib/Db/VersionsMapper.php', 'OCA\\Files_Versions\\Events\\CreateVersionEvent' => $baseDir . '/../lib/Events/CreateVersionEvent.php', 'OCA\\Files_Versions\\Expiration' => $baseDir . '/../lib/Expiration.php', - 'OCA\\Files_Versions\\Hooks' => $baseDir . '/../lib/Hooks.php', + 'OCA\\Files_Versions\\Listener\\FileEventsListener' => $baseDir . '/../lib/Listener/FileEventsListener.php', 'OCA\\Files_Versions\\Listener\\LoadAdditionalListener' => $baseDir . '/../lib/Listener/LoadAdditionalListener.php', 'OCA\\Files_Versions\\Listener\\LoadSidebarListener' => $baseDir . '/../lib/Listener/LoadSidebarListener.php', + 'OCA\\Files_Versions\\Migration\\Version1020Date20221114144058' => $baseDir . '/../lib/Migration/Version1020Date20221114144058.php', 'OCA\\Files_Versions\\Sabre\\Plugin' => $baseDir . '/../lib/Sabre/Plugin.php', 'OCA\\Files_Versions\\Sabre\\RestoreFolder' => $baseDir . '/../lib/Sabre/RestoreFolder.php', 'OCA\\Files_Versions\\Sabre\\RootCollection' => $baseDir . '/../lib/Sabre/RootCollection.php', @@ -28,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 1cbb96bf841..ee445fe9636 100644 --- a/apps/files_versions/composer/composer/autoload_static.php +++ b/apps/files_versions/composer/composer/autoload_static.php @@ -29,11 +29,14 @@ class ComposerStaticInitFiles_Versions 'OCA\\Files_Versions\\Command\\Expire' => __DIR__ . '/..' . '/../lib/Command/Expire.php', 'OCA\\Files_Versions\\Command\\ExpireVersions' => __DIR__ . '/..' . '/../lib/Command/ExpireVersions.php', 'OCA\\Files_Versions\\Controller\\PreviewController' => __DIR__ . '/..' . '/../lib/Controller/PreviewController.php', + 'OCA\\Files_Versions\\Db\\VersionEntity' => __DIR__ . '/..' . '/../lib/Db/VersionEntity.php', + 'OCA\\Files_Versions\\Db\\VersionsMapper' => __DIR__ . '/..' . '/../lib/Db/VersionsMapper.php', 'OCA\\Files_Versions\\Events\\CreateVersionEvent' => __DIR__ . '/..' . '/../lib/Events/CreateVersionEvent.php', 'OCA\\Files_Versions\\Expiration' => __DIR__ . '/..' . '/../lib/Expiration.php', - 'OCA\\Files_Versions\\Hooks' => __DIR__ . '/..' . '/../lib/Hooks.php', + 'OCA\\Files_Versions\\Listener\\FileEventsListener' => __DIR__ . '/..' . '/../lib/Listener/FileEventsListener.php', 'OCA\\Files_Versions\\Listener\\LoadAdditionalListener' => __DIR__ . '/..' . '/../lib/Listener/LoadAdditionalListener.php', 'OCA\\Files_Versions\\Listener\\LoadSidebarListener' => __DIR__ . '/..' . '/../lib/Listener/LoadSidebarListener.php', + 'OCA\\Files_Versions\\Migration\\Version1020Date20221114144058' => __DIR__ . '/..' . '/../lib/Migration/Version1020Date20221114144058.php', 'OCA\\Files_Versions\\Sabre\\Plugin' => __DIR__ . '/..' . '/../lib/Sabre/Plugin.php', 'OCA\\Files_Versions\\Sabre\\RestoreFolder' => __DIR__ . '/..' . '/../lib/Sabre/RestoreFolder.php', 'OCA\\Files_Versions\\Sabre\\RootCollection' => __DIR__ . '/..' . '/../lib/Sabre/RootCollection.php', @@ -43,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 357a4179666..a07058d04a7 100644 --- a/apps/files_versions/lib/AppInfo/Application.php +++ b/apps/files_versions/lib/AppInfo/Application.php @@ -33,7 +33,7 @@ use OCA\DAV\Connector\Sabre\Principal; use OCA\Files\Event\LoadAdditionalScriptsEvent; use OCA\Files\Event\LoadSidebar; use OCA\Files_Versions\Capabilities; -use OCA\Files_Versions\Hooks; +use OCA\Files_Versions\Listener\FileEventsListener; use OCA\Files_Versions\Listener\LoadAdditionalListener; use OCA\Files_Versions\Listener\LoadSidebarListener; use OCA\Files_Versions\Versions\IVersionManager; @@ -44,6 +44,17 @@ use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; 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\BeforeNodeTouchedEvent; +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\NodeCreatedEvent; +use OCP\Files\Events\Node\NodeTouchedEvent; +use OCP\Files\Events\Node\NodeWrittenEvent; use OCP\IConfig; use OCP\IGroupManager; use OCP\IServerContainer; @@ -96,15 +107,22 @@ class Application extends App implements IBootstrap { */ $context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadAdditionalListener::class); $context->registerEventListener(LoadSidebar::class, LoadSidebarListener::class); + + $context->registerEventListener(NodeCreatedEvent::class, FileEventsListener::class); + $context->registerEventListener(BeforeNodeTouchedEvent::class, FileEventsListener::class); + $context->registerEventListener(NodeTouchedEvent::class, FileEventsListener::class); + $context->registerEventListener(BeforeNodeWrittenEvent::class, FileEventsListener::class); + $context->registerEventListener(NodeWrittenEvent::class, FileEventsListener::class); + $context->registerEventListener(BeforeNodeDeletedEvent::class, FileEventsListener::class); + $context->registerEventListener(NodeDeletedEvent::class, FileEventsListener::class); + $context->registerEventListener(NodeRenamedEvent::class, FileEventsListener::class); + $context->registerEventListener(NodeCopiedEvent::class, FileEventsListener::class); + $context->registerEventListener(BeforeNodeRenamedEvent::class, FileEventsListener::class); + $context->registerEventListener(BeforeNodeCopiedEvent::class, FileEventsListener::class); } public function boot(IBootContext $context): void { $context->injectFn(\Closure::fromCallable([$this, 'registerVersionBackends'])); - - /** - * Register hooks - */ - Hooks::connectHooks(); } public function registerVersionBackends(ContainerInterface $container, IAppManager $appManager, LoggerInterface $logger): void { diff --git a/apps/files_versions/lib/Capabilities.php b/apps/files_versions/lib/Capabilities.php index b8602540ec8..6524943690a 100644 --- a/apps/files_versions/lib/Capabilities.php +++ b/apps/files_versions/lib/Capabilities.php @@ -24,19 +24,35 @@ */ 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), + 'version_deletion' => !$groupFolderOrS3VersioningInstalled && $this->config->getSystemValueBool('enable_version_deletion', true), ] ]; } diff --git a/apps/files_versions/lib/Db/VersionEntity.php b/apps/files_versions/lib/Db/VersionEntity.php new file mode 100644 index 00000000000..d5adbcfa104 --- /dev/null +++ b/apps/files_versions/lib/Db/VersionEntity.php @@ -0,0 +1,81 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me> + * + * @author 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\Db; + +use JsonSerializable; + +use OCP\AppFramework\Db\Entity; +use OCP\DB\Types; + +/** + * @method int getFileId() + * @method void setFileId(int $fileId) + * @method int getTimestamp() + * @method void setTimestamp(int $timestamp) + * @method int getSize() + * @method void setSize(int $size) + * @method int getMimetype() + * @method void setMimetype(int $mimetype) + * @method array|null getMetadata() + * @method void setMetadata(array $metadata) + */ +class VersionEntity extends Entity implements JsonSerializable { + protected ?int $fileId = null; + protected ?int $timestamp = null; + protected ?int $size = null; + protected ?int $mimetype = null; + protected ?array $metadata = null; + + public function __construct() { + $this->addType('id', Types::INTEGER); + $this->addType('file_id', Types::INTEGER); + $this->addType('timestamp', Types::INTEGER); + $this->addType('size', Types::INTEGER); + $this->addType('mimetype', Types::INTEGER); + $this->addType('metadata', Types::JSON); + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'file_id' => $this->fileId, + 'timestamp' => $this->timestamp, + 'size' => $this->size, + 'mimetype' => $this->mimetype, + '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/Db/VersionsMapper.php b/apps/files_versions/lib/Db/VersionsMapper.php new file mode 100644 index 00000000000..86a0be82668 --- /dev/null +++ b/apps/files_versions/lib/Db/VersionsMapper.php @@ -0,0 +1,88 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me> + * + * @author 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\Db; + +use OCA\Files_Versions\Db\VersionEntity; +use OCP\IDBConnection; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\IResult; + +/** + * @extends QBMapper<VersionEntity> + */ +class VersionsMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'files_versions', VersionEntity::class); + } + + /** + * @return VersionEntity[] + */ + public function findAllVersionsForFileId(int $fileId): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId))); + + return $this->findEntities($qb); + } + + /** + * @return VersionEntity + */ + public function findCurrentVersionForFileId(int $fileId): VersionEntity { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId))) + ->orderBy('timestamp', 'DESC') + ->setMaxResults(1); + + return $this->findEntity($qb); + } + + public function findVersionForFileId(int $fileId, int $timestamp): VersionEntity { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId))) + ->andWhere($qb->expr()->eq('timestamp', $qb->createNamedParameter($timestamp))); + + return $this->findEntity($qb); + } + + public function deleteAllVersionsForFileId(int $fileId): int { + $qb = $this->db->getQueryBuilder(); + + return $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId))) + ->executeStatement(); + } +} diff --git a/apps/files_versions/lib/Hooks.php b/apps/files_versions/lib/Hooks.php deleted file mode 100644 index 02f562b1874..00000000000 --- a/apps/files_versions/lib/Hooks.php +++ /dev/null @@ -1,142 +0,0 @@ -<?php -/** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bart Visscher <bartv@thisnet.nl> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Sam Tuke <mail@samtuke.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ -namespace OCA\Files_Versions; - -use OC\Files\Filesystem; -use OC\Files\Mount\MoveableMount; -use OC\Files\View; -use OCP\Util; - -class Hooks { - public static function connectHooks() { - // Listen to write signals - Util::connectHook('OC_Filesystem', 'write', Hooks::class, 'write_hook'); - // Listen to delete and rename signals - Util::connectHook('OC_Filesystem', 'post_delete', Hooks::class, 'remove_hook'); - Util::connectHook('OC_Filesystem', 'delete', Hooks::class, 'pre_remove_hook'); - Util::connectHook('OC_Filesystem', 'post_rename', Hooks::class, 'rename_hook'); - Util::connectHook('OC_Filesystem', 'post_copy', Hooks::class, 'copy_hook'); - Util::connectHook('OC_Filesystem', 'rename', Hooks::class, 'pre_renameOrCopy_hook'); - Util::connectHook('OC_Filesystem', 'copy', Hooks::class, 'pre_renameOrCopy_hook'); - } - - /** - * listen to write event. - */ - public static function write_hook(array $params): void { - $path = $params[Filesystem::signal_param_path]; - if ($path !== '') { - Storage::store($path); - } - } - - - /** - * Erase versions of deleted file - * @param array $params - * - * This function is connected to the delete signal of OC_Filesystem - * cleanup the versions directory if the actual file gets deleted - */ - public static function remove_hook(array $params): void { - $path = $params[Filesystem::signal_param_path]; - if ($path !== '') { - Storage::delete($path); - } - } - - /** - * mark file as "deleted" so that we can clean up the versions if the file is gone - * @param array $params - */ - public static function pre_remove_hook(array $params): void { - $path = $params[Filesystem::signal_param_path]; - if ($path !== '') { - Storage::markDeletedFile($path); - } - } - - /** - * rename/move versions of renamed/moved files - * @param array $params array with oldpath and newpath - * - * This function is connected to the rename signal of OC_Filesystem and adjust the name and location - * of the stored versions along the actual file - */ - public static function rename_hook(array $params): void { - $oldpath = $params['oldpath']; - $newpath = $params['newpath']; - if ($oldpath !== '' && $newpath !== '') { - Storage::renameOrCopy($oldpath, $newpath, 'rename'); - } - } - - /** - * copy versions of copied files - * @param array $params array with oldpath and newpath - * - * This function is connected to the copy signal of OC_Filesystem and copies the - * the stored versions to the new location - */ - public static function copy_hook(array $params): void { - $oldpath = $params['oldpath']; - $newpath = $params['newpath']; - if ($oldpath !== '' && $newpath !== '') { - Storage::renameOrCopy($oldpath, $newpath, 'copy'); - } - } - - /** - * Remember owner and the owner path of the source file. - * If the file already exists, then it was a upload of a existing file - * over the web interface and we call Storage::store() directly - * - * @param array $params array with oldpath and newpath - * - */ - public static function pre_renameOrCopy_hook(array $params): void { - // if we rename a movable mount point, then the versions don't have - // to be renamed - $absOldPath = Filesystem::normalizePath('/' . \OC_User::getUser() . '/files' . $params['oldpath']); - $manager = Filesystem::getMountManager(); - $mount = $manager->find($absOldPath); - $internalPath = $mount->getInternalPath($absOldPath); - if ($internalPath === '' and $mount instanceof MoveableMount) { - return; - } - - $view = new View(\OC_User::getUser() . '/files'); - if ($view->file_exists($params['newpath'])) { - Storage::store($params['newpath']); - } else { - Storage::setSourcePathAndUser($params['oldpath']); - } - } -} diff --git a/apps/files_versions/lib/Listener/FileEventsListener.php b/apps/files_versions/lib/Listener/FileEventsListener.php new file mode 100644 index 00000000000..76590733e26 --- /dev/null +++ b/apps/files_versions/lib/Listener/FileEventsListener.php @@ -0,0 +1,347 @@ +<?php +/** + * @copyright Copyright (c) 2016, ownCloud, Inc. + * + * @author Bart Visscher <bartv@thisnet.nl> + * @author Björn Schießle <bjoern@schiessle.org> + * @author Christoph Wurst <christoph@winzerhof-wurst.at> + * @author John Molakvoæ <skjnldsv@protonmail.com> + * @author Jörn Friedrich Dreyer <jfd@butonic.de> + * @author Morris Jobke <hey@morrisjobke.de> + * @author Robin Appelman <robin@icewind.nl> + * @author Robin McCorkell <robin@mccorkell.me.uk> + * @author Sam Tuke <mail@samtuke.com> + * @author Louis Chmn <louis@chmn.me> + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * 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, version 3, + * along with this program. If not, see <http://www.gnu.org/licenses/> + * + */ +namespace OCA\Files_Versions\Listener; + +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; +use OC\DB\Exceptions\DbalException; +use OC\Files\Filesystem; +use OC\Files\Mount\MoveableMount; +use OC\Files\Node\NonExistingFile; +use OC\Files\View; +use OCA\Files_Versions\Db\VersionEntity; +use OCA\Files_Versions\Db\VersionsMapper; +use OCA\Files_Versions\Storage; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\Events\Node\BeforeNodeCopiedEvent; +use OCP\Files\Events\Node\BeforeNodeDeletedEvent; +use OCP\Files\Events\Node\BeforeNodeRenamedEvent; +use OCP\Files\Events\Node\BeforeNodeTouchedEvent; +use OCP\Files\Events\Node\BeforeNodeWrittenEvent; +use OCP\Files\Events\Node\NodeCopiedEvent; +use OCP\Files\Events\Node\NodeCreatedEvent; +use OCP\Files\Events\Node\NodeDeletedEvent; +use OCP\Files\Events\Node\NodeRenamedEvent; +use OCP\Files\Events\Node\NodeTouchedEvent; +use OCP\Files\Events\Node\NodeWrittenEvent; +use OCP\Files\Folder; +use OCP\Files\IMimeTypeLoader; +use OCP\Files\IRootFolder; +use OCP\Files\Node; + +class FileEventsListener implements IEventListener { + private IRootFolder $rootFolder; + private VersionsMapper $versionsMapper; + /** + * @var array<int, array> + */ + private array $writeHookInfo = []; + /** + * @var array<int, Node> + */ + private array $nodesTouched = []; + /** + * @var array<string, Node> + */ + private array $versionsDeleted = []; + private IMimeTypeLoader $mimeTypeLoader; + + public function __construct( + IRootFolder $rootFolder, + VersionsMapper $versionsMapper, + IMimeTypeLoader $mimeTypeLoader + ) { + $this->rootFolder = $rootFolder; + $this->versionsMapper = $versionsMapper; + $this->mimeTypeLoader = $mimeTypeLoader; + } + + public function handle(Event $event): void { + if ($event instanceof NodeCreatedEvent) { + $this->created($event->getNode()); + } + + if ($event instanceof BeforeNodeTouchedEvent) { + $this->pre_touch_hook($event->getNode()); + } + + if ($event instanceof NodeTouchedEvent) { + $this->touch_hook($event->getNode()); + } + + if ($event instanceof BeforeNodeWrittenEvent) { + $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()); + } + + if ($event instanceof NodeDeletedEvent) { + $this->remove_hook($event->getNode()); + } + + if ($event instanceof NodeRenamedEvent) { + $this->rename_hook($event->getSource(), $event->getTarget()); + } + + if ($event instanceof NodeCopiedEvent) { + $this->copy_hook($event->getSource(), $event->getTarget()); + } + + if ($event instanceof BeforeNodeRenamedEvent) { + $this->pre_renameOrCopy_hook($event->getSource(), $event->getTarget()); + } + + if ($event instanceof BeforeNodeCopiedEvent) { + $this->pre_renameOrCopy_hook($event->getSource(), $event->getTarget()); + } + } + + public function pre_touch_hook(Node $node): void { + // Do not handle folders. + if ($node instanceof Folder) { + return; + } + + // $node is a non-existing on file creation. + if ($node instanceof NonExistingFile) { + return; + } + + $this->nodesTouched[$node->getId()] = $node; + } + + public function touch_hook(Node $node): void { + $previousNode = $this->nodesTouched[$node->getId()] ?? null; + + if ($previousNode === null) { + return; + } + + unset($this->nodesTouched[$node->getId()]); + + try { + // We update the timestamp of the version entity associated with the previousNode. + $versionEntity = $this->versionsMapper->findVersionForFileId($previousNode->getId(), $previousNode->getMTime()); + // Create a version in the DB for the current content. + $versionEntity->setTimestamp($node->getMTime()); + $this->versionsMapper->update($versionEntity); + } catch (DbalException $ex) { + // Ignore UniqueConstraintViolationException, as we are probably in the middle of a rollback + // Where the previous node would temporary have the mtime of the old version, so the rollback touches it to fix it. + if (!($ex->getPrevious() instanceof UniqueConstraintViolationException)) { + throw $ex; + } + } catch (DoesNotExistException $ex) { + // Ignore DoesNotExistException, as we are probably in the middle of a rollback + // Where the previous node would temporary have a wrong mtime, so the rollback touches it to fix it. + } + } + + public function created(Node $node): void { + // Do not handle folders. + if ($node instanceof Folder) { + return; + } + + $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); + } + + /** + * listen to write event. + */ + public function write_hook(Node $node): void { + // Do not handle folders. + if ($node instanceof Folder) { + return; + } + + // $node is a non-existing on file creation. + if ($node instanceof NonExistingFile) { + return; + } + + $path = $this->getPathForNode($node); + $result = Storage::store($path); + + // Store the result of the version creation so it can be used in post_write_hook. + $this->writeHookInfo[$node->getId()] = [ + 'previousNode' => $node, + 'versionCreated' => $result !== false + ]; + } + + /** + * listen to post_write event. + */ + public function post_write_hook(Node $node): void { + // Do not handle folders. + if ($node instanceof Folder) { + return; + } + + $writeHookInfo = $this->writeHookInfo[$node->getId()] ?? null; + + if ($writeHookInfo === null) { + return; + } + + if ($writeHookInfo['versionCreated'] && $node->getMTime() !== $writeHookInfo['previousNode']->getMTime()) { + // If a new version was created, insert a version in the DB for the current content. + // Unless both versions have the same mtime. + $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); + } else { + // If no new version was stored in the FS, no new version should be added in the DB. + // So we simply update the associated version. + $currentVersionEntity = $this->versionsMapper->findVersionForFileId($node->getId(), $writeHookInfo['previousNode']->getMtime()); + $currentVersionEntity->setTimestamp($node->getMTime()); + $currentVersionEntity->setSize($node->getSize()); + $currentVersionEntity->setMimetype($this->mimeTypeLoader->getId($node->getMimetype())); + $this->versionsMapper->update($currentVersionEntity); + } + + unset($this->writeHookInfo[$node->getId()]); + } + + /** + * Erase versions of deleted file + * + * This function is connected to the delete signal of OC_Filesystem + * cleanup the versions directory if the actual file gets deleted + */ + public function remove_hook(Node $node): void { + // Need to normalize the path as there is an issue with path concatenation in View.php::getAbsolutePath. + $path = Filesystem::normalizePath($node->getPath()); + if (!array_key_exists($path, $this->versionsDeleted)) { + return; + } + $node = $this->versionsDeleted[$path]; + $relativePath = $this->getPathForNode($node); + unset($this->versionsDeleted[$path]); + Storage::delete($relativePath); + $this->versionsMapper->deleteAllVersionsForFileId($node->getId()); + } + + /** + * mark file as "deleted" so that we can clean up the versions if the file is gone + */ + public function pre_remove_hook(Node $node): void { + $path = $this->getPathForNode($node); + Storage::markDeletedFile($path); + $this->versionsDeleted[$node->getPath()] = $node; + } + + /** + * rename/move versions of renamed/moved files + * + * This function is connected to the rename signal of OC_Filesystem and adjust the name and location + * of the stored versions along the actual file + */ + public function rename_hook(Node $source, Node $target): void { + $oldPath = $this->getPathForNode($source); + $newPath = $this->getPathForNode($target); + Storage::renameOrCopy($oldPath, $newPath, 'rename'); + } + + /** + * copy versions of copied files + * + * This function is connected to the copy signal of OC_Filesystem and copies the + * the stored versions to the new location + */ + public function copy_hook(Node $source, Node $target): void { + $oldPath = $this->getPathForNode($source); + $newPath = $this->getPathForNode($target); + Storage::renameOrCopy($oldPath, $newPath, 'copy'); + } + + /** + * Remember owner and the owner path of the source file. + * If the file already exists, then it was a upload of a existing file + * over the web interface and we call Storage::store() directly + * + * + */ + public function pre_renameOrCopy_hook(Node $source, Node $target): void { + // if we rename a movable mount point, then the versions don't have + // to be renamed + $oldPath = $this->getPathForNode($source); + $newPath = $this->getPathForNode($target); + $absOldPath = Filesystem::normalizePath('/' . \OC_User::getUser() . '/files' . $oldPath); + $manager = Filesystem::getMountManager(); + $mount = $manager->find($absOldPath); + $internalPath = $mount->getInternalPath($absOldPath); + if ($internalPath === '' and $mount instanceof MoveableMount) { + return; + } + + $view = new View(\OC_User::getUser() . '/files'); + if ($view->file_exists($newPath)) { + Storage::store($newPath); + } else { + Storage::setSourcePathAndUser($oldPath); + } + } + + /** + * Retrieve the path relative to the current user root folder. + * If no user is connected, use the node's owner. + */ + private function getPathForNode(Node $node): ?string { + try { + return $this->rootFolder + ->getUserFolder(\OC_User::getUser()) + ->getRelativePath($node->getPath()); + } catch (\Throwable $ex) { + return $this->rootFolder + ->getUserFolder($node->getOwner()->getUid()) + ->getRelativePath($node->getPath()); + } + } +} diff --git a/apps/files_versions/lib/Migration/Version1020Date20221114144058.php b/apps/files_versions/lib/Migration/Version1020Date20221114144058.php new file mode 100644 index 00000000000..f0d58284c9f --- /dev/null +++ b/apps/files_versions/lib/Migration/Version1020Date20221114144058.php @@ -0,0 +1,84 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me> + * + * @author 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\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Auto-generated migration step: Please modify to your needs! + */ +class Version1020Date20221114144058 extends SimpleMigrationStep { + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if ($schema->hasTable("files_versions")) { + return null; + } + + $table = $schema->createTable("files_versions"); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('file_id', Types::BIGINT, [ + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('timestamp', Types::BIGINT, [ + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('size', Types::BIGINT, [ + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('mimetype', Types::BIGINT, [ + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('metadata', Types::JSON, [ + 'notnull' => true, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['file_id', 'timestamp'], 'files_versions_uniq_index'); + + return $schema; + } +} 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..20ae25a7623 100644 --- a/apps/files_versions/lib/Sabre/VersionFile.php +++ b/apps/files_versions/lib/Sabre/VersionFile.php @@ -26,6 +26,9 @@ declare(strict_types=1); */ namespace OCA\Files_Versions\Sabre; +use OCA\Files_Versions\Versions\IDeletableVersionBackend; +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,7 +73,11 @@ class VersionFile implements IFile { } public function delete() { - throw new Forbidden(); + if ($this->versionManager instanceof IDeletableVersionBackend) { + $this->versionManager->deleteVersion($this->version); + } else { + throw new Forbidden(); + } } public function getName(): string { @@ -81,6 +88,23 @@ class VersionFile implements IFile { throw new Forbidden(); } + public function getLabel(): ?string { + if ($this->version instanceof INameableVersion && $this->version->getSourceFile()->getSize() > 0) { + 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/apps/files_versions/lib/Storage.php b/apps/files_versions/lib/Storage.php index 024e1debc92..2fd208cd364 100644 --- a/apps/files_versions/lib/Storage.php +++ b/apps/files_versions/lib/Storage.php @@ -49,6 +49,7 @@ use OC\Files\View; use OCA\Files_Sharing\SharedMount; use OCA\Files_Versions\AppInfo\Application; use OCA\Files_Versions\Command\Expire; +use OCA\Files_Versions\Db\VersionsMapper; use OCA\Files_Versions\Events\CreateVersionEvent; use OCA\Files_Versions\Versions\IVersionManager; use OCP\Files\FileInfo; @@ -291,6 +292,17 @@ class Storage { } /** + * Delete a version of a file + */ + public static function deleteRevision(string $path, int $revision): void { + [$uid, $filename] = self::getUidAndFilename($path); + $view = new View('/' . $uid . '/files_versions'); + \OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $path . $revision, 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED]); + self::deleteVersion($view, $filename . '.v' . $revision); + \OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $path . $revision, 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED]); + } + + /** * Rename or copy versions of a file of the given paths * * @param string $sourcePath source path of the file to move, relative to @@ -562,19 +574,39 @@ class Storage { [] )); + /** @var VersionsMapper $versionsMapper */ + $versionsMapper = \OC::$server->get(VersionsMapper::class); + $userFolder = $root->getUserFolder($uid); + $versionEntities = []; + /** @var Node[] $versions */ - $versions = array_filter($allVersions, function (Node $info) use ($threshold) { + $versions = array_filter($allVersions, function (Node $info) use ($threshold, $userFolder, $versionsMapper, $versionsRoot, &$versionEntities) { + // Check that the file match '*.v*' $versionsBegin = strrpos($info->getName(), '.v'); if ($versionsBegin === false) { return false; } + $version = (int)substr($info->getName(), $versionsBegin + 2); + + // Check that the version does not have a label. + $path = $versionsRoot->getRelativePath($info->getPath()); + $node = $userFolder->get(substr($path, 0, -strlen('.v'.$version))); + $versionEntity = $versionsMapper->findVersionForFileId($node->getId(), $version); + $versionEntities[$info->getId()] = $versionEntity; + + if ($versionEntity->getLabel() !== '') { + return false; + } + + // Check that the version's timestamp is lower than $threshold return $version < $threshold; }); foreach ($versions as $version) { $internalPath = $version->getInternalPath(); \OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $internalPath, 'trigger' => self::DELETE_TRIGGER_RETENTION_CONSTRAINT]); + $versionsMapper->delete($versionEntities[$version->getId()]); $version->delete(); \OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $internalPath, 'trigger' => self::DELETE_TRIGGER_RETENTION_CONSTRAINT]); } diff --git a/apps/files_versions/lib/Versions/IDeletableVersionBackend.php b/apps/files_versions/lib/Versions/IDeletableVersionBackend.php new file mode 100644 index 00000000000..abb43d09d90 --- /dev/null +++ b/apps/files_versions/lib/Versions/IDeletableVersionBackend.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 IDeletableVersionBackend { + /** + * Delete a version. + * + * @since 26.0.0 + */ + public function deleteVersion(IVersion $version): void; +} diff --git a/apps/files_versions/lib/Versions/INameableVersion.php b/apps/files_versions/lib/Versions/INameableVersion.php new file mode 100644 index 00000000000..b6ddb951e25 --- /dev/null +++ b/apps/files_versions/lib/Versions/INameableVersion.php @@ -0,0 +1,37 @@ +<?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 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..cbfbc001e0c 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,58 @@ 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. + $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 +173,25 @@ 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); + } + + public function deleteVersion(IVersion $version): void { + Storage::deleteRevision($version->getVersionPath(), $version->getRevisionId()); + $versionEntity = $this->versionsMapper->findVersionForFileId( + $version->getSourceFile()->getId(), + $version->getTimestamp(), + ); + $this->versionsMapper->delete($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..bfae0937df8 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, IDeletableVersionBackend { /** @var (IVersionBackend[])[] */ private $backends = []; @@ -110,4 +110,18 @@ 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); + } + } + + public function deleteVersion(IVersion $version): void { + $backend = $this->getBackendForStorage($version->getSourceFile()->getStorage()); + if ($backend instanceof IDeletableVersionBackend) { + $backend->deleteVersion($version); + } + } } diff --git a/apps/files_versions/src/components/Version.vue b/apps/files_versions/src/components/Version.vue new file mode 100644 index 00000000000..6a9bac977a8 --- /dev/null +++ b/apps/files_versions/src/components/Version.vue @@ -0,0 +1,303 @@ +<!-- + - @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" + data-files-versions-version> + <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 && capabilities.files.version_deletion === true" + :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, version_deletion: false } }), + } + }, + computed: { + /** + * @return {string} + */ + versionLabel() { + if (this.isCurrent) { + if (this.version.label === undefined || 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..f2e9576abd0 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 data-files-versions-versions-list> + <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> diff --git a/apps/files_versions/tests/StorageTest.php b/apps/files_versions/tests/StorageTest.php index d16b9ecdfd8..b319cc2c9c7 100644 --- a/apps/files_versions/tests/StorageTest.php +++ b/apps/files_versions/tests/StorageTest.php @@ -51,7 +51,7 @@ class StorageTest extends TestCase { }); $this->overwriteService(Expiration::class, $expiration); - Hooks::connectHooks(); + \OC::$server->boot(); $this->createUser('version_test', ''); $this->loginAsUser('version_test'); diff --git a/apps/files_versions/tests/VersioningTest.php b/apps/files_versions/tests/VersioningTest.php index b8f58ecff2b..4f171031ab3 100644 --- a/apps/files_versions/tests/VersioningTest.php +++ b/apps/files_versions/tests/VersioningTest.php @@ -35,6 +35,9 @@ namespace OCA\Files_Versions\Tests; use OC\Files\Storage\Temporary; +use OCA\Files_Versions\Db\VersionEntity; +use OCA\Files_Versions\Db\VersionsMapper; +use OCP\Files\IMimeTypeLoader; use OCP\IConfig; use OCP\IUser; use OCP\Share\IShare; @@ -54,6 +57,14 @@ class VersioningTest extends \Test\TestCase { * @var \OC\Files\View */ private $rootView; + /** + * @var VersionsMapper + */ + private $versionsMapper; + /** + * @var IMimeTypeLoader + */ + private $mimeTypeLoader; private $user1; private $user2; @@ -100,7 +111,7 @@ class VersioningTest extends \Test\TestCase { // clear hooks \OC_Hook::clear(); \OC::registerShareHooks(\OC::$server->getSystemConfig()); - \OCA\Files_Versions\Hooks::connectHooks(); + \OC::$server->boot(); self::loginHelper(self::TEST_VERSIONS_USER); $this->rootView = new \OC\Files\View(); @@ -108,6 +119,9 @@ class VersioningTest extends \Test\TestCase { $this->rootView->mkdir(self::USERS_VERSIONS_ROOT); } + $this->versionsMapper = \OCP\Server::get(VersionsMapper::class); + $this->mimeTypeLoader = \OCP\Server::get(IMimeTypeLoader::class); + $this->user1 = $this->createMock(IUser::class); $this->user1->method('getUID') ->willReturn(self::TEST_VERSIONS_USER); @@ -762,6 +776,7 @@ class VersioningTest extends \Test\TestCase { $filePath = self::TEST_VERSIONS_USER . '/files/sub/test.txt'; $this->rootView->file_put_contents($filePath, 'test file'); + $fileInfo = $this->rootView->getFileInfo($filePath); $t0 = $this->rootView->filemtime($filePath); // not exactly the same timestamp as the file @@ -774,8 +789,26 @@ class VersioningTest extends \Test\TestCase { $v2 = self::USERS_VERSIONS_ROOT . '/sub/test.txt.v' . $t2; $this->rootView->mkdir(self::USERS_VERSIONS_ROOT . '/sub'); + $this->rootView->file_put_contents($v1, 'version1'); + $fileInfoV1 = $this->rootView->getFileInfo($v1); + $versionEntity = new VersionEntity(); + $versionEntity->setFileId($fileInfo->getId()); + $versionEntity->setTimestamp($t1); + $versionEntity->setSize($fileInfoV1->getSize()); + $versionEntity->setMimetype($this->mimeTypeLoader->getId($fileInfoV1->getMimetype())); + $versionEntity->setMetadata([]); + $this->versionsMapper->insert($versionEntity); + $this->rootView->file_put_contents($v2, 'version2'); + $fileInfoV2 = $this->rootView->getFileInfo($v2); + $versionEntity = new VersionEntity(); + $versionEntity->setFileId($fileInfo->getId()); + $versionEntity->setTimestamp($t2); + $versionEntity->setSize($fileInfoV2->getSize()); + $versionEntity->setMimetype($this->mimeTypeLoader->getId($fileInfoV2->getMimetype())); + $versionEntity->setMetadata([]); + $this->versionsMapper->insert($versionEntity); $oldVersions = \OCA\Files_Versions\Storage::getVersions( self::TEST_VERSIONS_USER, '/sub/test.txt' |