mirror of https://github.com/nextcloud/server
feat(files_versions): Add listener and interfaces to allow versions migration across storages
Signed-off-by: Louis Chemineau <louis@chmn.me>
This commit is contained in:
parent
1a55084930
commit
369274c9ee
|
@ -22,6 +22,7 @@ return array(
|
|||
'OCA\\Files_Versions\\Listener\\LoadAdditionalListener' => $baseDir . '/../lib/Listener/LoadAdditionalListener.php',
|
||||
'OCA\\Files_Versions\\Listener\\LoadSidebarListener' => $baseDir . '/../lib/Listener/LoadSidebarListener.php',
|
||||
'OCA\\Files_Versions\\Listener\\VersionAuthorListener' => $baseDir . '/../lib/Listener/VersionAuthorListener.php',
|
||||
'OCA\\Files_Versions\\Listener\\VersionStorageMoveListener' => $baseDir . '/../lib/Listener/VersionStorageMoveListener.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',
|
||||
|
@ -41,6 +42,7 @@ return array(
|
|||
'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',
|
||||
'OCA\\Files_Versions\\Versions\\IVersionsImporterBackend' => $baseDir . '/../lib/Versions/IVersionsImporterBackend.php',
|
||||
'OCA\\Files_Versions\\Versions\\LegacyVersionsBackend' => $baseDir . '/../lib/Versions/LegacyVersionsBackend.php',
|
||||
'OCA\\Files_Versions\\Versions\\Version' => $baseDir . '/../lib/Versions/Version.php',
|
||||
'OCA\\Files_Versions\\Versions\\VersionManager' => $baseDir . '/../lib/Versions/VersionManager.php',
|
||||
|
|
|
@ -37,6 +37,7 @@ class ComposerStaticInitFiles_Versions
|
|||
'OCA\\Files_Versions\\Listener\\LoadAdditionalListener' => __DIR__ . '/..' . '/../lib/Listener/LoadAdditionalListener.php',
|
||||
'OCA\\Files_Versions\\Listener\\LoadSidebarListener' => __DIR__ . '/..' . '/../lib/Listener/LoadSidebarListener.php',
|
||||
'OCA\\Files_Versions\\Listener\\VersionAuthorListener' => __DIR__ . '/..' . '/../lib/Listener/VersionAuthorListener.php',
|
||||
'OCA\\Files_Versions\\Listener\\VersionStorageMoveListener' => __DIR__ . '/..' . '/../lib/Listener/VersionStorageMoveListener.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',
|
||||
|
@ -56,6 +57,7 @@ class ComposerStaticInitFiles_Versions
|
|||
'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',
|
||||
'OCA\\Files_Versions\\Versions\\IVersionsImporterBackend' => __DIR__ . '/..' . '/../lib/Versions/IVersionsImporterBackend.php',
|
||||
'OCA\\Files_Versions\\Versions\\LegacyVersionsBackend' => __DIR__ . '/..' . '/../lib/Versions/LegacyVersionsBackend.php',
|
||||
'OCA\\Files_Versions\\Versions\\Version' => __DIR__ . '/..' . '/../lib/Versions/Version.php',
|
||||
'OCA\\Files_Versions\\Versions\\VersionManager' => __DIR__ . '/..' . '/../lib/Versions/VersionManager.php',
|
||||
|
|
|
@ -37,6 +37,7 @@ use OCA\Files_Versions\Listener\FileEventsListener;
|
|||
use OCA\Files_Versions\Listener\LoadAdditionalListener;
|
||||
use OCA\Files_Versions\Listener\LoadSidebarListener;
|
||||
use OCA\Files_Versions\Listener\VersionAuthorListener;
|
||||
use OCA\Files_Versions\Listener\VersionStorageMoveListener;
|
||||
use OCA\Files_Versions\Versions\IVersionManager;
|
||||
use OCA\Files_Versions\Versions\VersionManager;
|
||||
use OCP\Accounts\IAccountManager;
|
||||
|
@ -109,6 +110,11 @@ class Application extends App implements IBootstrap {
|
|||
$context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadAdditionalListener::class);
|
||||
$context->registerEventListener(LoadSidebar::class, LoadSidebarListener::class);
|
||||
|
||||
$context->registerEventListener(BeforeNodeRenamedEvent::class, VersionStorageMoveListener::class);
|
||||
$context->registerEventListener(NodeRenamedEvent::class, VersionStorageMoveListener::class);
|
||||
$context->registerEventListener(BeforeNodeCopiedEvent::class, VersionStorageMoveListener::class);
|
||||
$context->registerEventListener(NodeCopiedEvent::class, VersionStorageMoveListener::class);
|
||||
|
||||
$context->registerEventListener(NodeCreatedEvent::class, FileEventsListener::class);
|
||||
$context->registerEventListener(BeforeNodeTouchedEvent::class, FileEventsListener::class);
|
||||
$context->registerEventListener(NodeTouchedEvent::class, FileEventsListener::class);
|
||||
|
|
|
@ -300,6 +300,13 @@ class FileEventsListener implements IEventListener {
|
|||
* of the stored versions along the actual file
|
||||
*/
|
||||
public function rename_hook(Node $source, Node $target): void {
|
||||
$sourceBackend = $this->versionManager->getBackendForStorage($source->getParent()->getStorage());
|
||||
$targetBackend = $this->versionManager->getBackendForStorage($target->getStorage());
|
||||
// If different backends, do nothing.
|
||||
if ($sourceBackend !== $targetBackend) {
|
||||
return;
|
||||
}
|
||||
|
||||
$oldPath = $this->getPathForNode($source);
|
||||
$newPath = $this->getPathForNode($target);
|
||||
Storage::renameOrCopy($oldPath, $newPath, 'rename');
|
||||
|
@ -312,6 +319,13 @@ class FileEventsListener implements IEventListener {
|
|||
* the stored versions to the new location
|
||||
*/
|
||||
public function copy_hook(Node $source, Node $target): void {
|
||||
$sourceBackend = $this->versionManager->getBackendForStorage($source->getParent()->getStorage());
|
||||
$targetBackend = $this->versionManager->getBackendForStorage($target->getStorage());
|
||||
// If different backends, do nothing.
|
||||
if ($sourceBackend !== $targetBackend) {
|
||||
return;
|
||||
}
|
||||
|
||||
$oldPath = $this->getPathForNode($source);
|
||||
$newPath = $this->getPathForNode($target);
|
||||
Storage::renameOrCopy($oldPath, $newPath, 'copy');
|
||||
|
@ -325,6 +339,13 @@ class FileEventsListener implements IEventListener {
|
|||
*
|
||||
*/
|
||||
public function pre_renameOrCopy_hook(Node $source, Node $target): void {
|
||||
$sourceBackend = $this->versionManager->getBackendForStorage($source->getStorage());
|
||||
$targetBackend = $this->versionManager->getBackendForStorage($target->getParent()->getStorage());
|
||||
// If different backends, do nothing.
|
||||
if ($sourceBackend !== $targetBackend) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if we rename a movable mount point, then the versions don't have
|
||||
// to be renamed
|
||||
$oldPath = $this->getPathForNode($source);
|
||||
|
|
|
@ -0,0 +1,154 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @copyright Copyright (c) 2024 Louis Chmn <louis@chmn.me>
|
||||
*
|
||||
* @author Louis Chmn <louis@chmn.me>
|
||||
*
|
||||
* @license GNU AGPL-3.0-or-later
|
||||
*
|
||||
* 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 Exception;
|
||||
use OC\Files\Node\NonExistingFile;
|
||||
use OCA\Files_Versions\Versions\IVersionBackend;
|
||||
use OCA\Files_Versions\Versions\IVersionManager;
|
||||
use OCA\Files_Versions\Versions\IVersionsImporterBackend;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
use OCP\Files\Events\Node\AbstractNodesEvent;
|
||||
use OCP\Files\Events\Node\BeforeNodeRenamedEvent;
|
||||
use OCP\Files\Events\Node\NodeCopiedEvent;
|
||||
use OCP\Files\Events\Node\NodeRenamedEvent;
|
||||
use OCP\Files\File;
|
||||
use OCP\Files\Folder;
|
||||
use OCP\Files\Node;
|
||||
use OCP\Files\Storage\IStorage;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserSession;
|
||||
|
||||
/** @template-implements IEventListener<Event> */
|
||||
class VersionStorageMoveListener implements IEventListener {
|
||||
/** @var File[] */
|
||||
private array $movedNodes = [];
|
||||
|
||||
public function __construct(
|
||||
private IVersionManager $versionManager,
|
||||
private IUserSession $userSession,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @abstract Moves version across storages if necessary.
|
||||
* @throws Exception No user in session
|
||||
*/
|
||||
public function handle(Event $event): void {
|
||||
if (!($event instanceof AbstractNodesEvent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$source = $event->getSource();
|
||||
$target = $event->getTarget();
|
||||
|
||||
$sourceStorage = $this->getNodeStorage($source);
|
||||
$targetStorage = $this->getNodeStorage($target);
|
||||
|
||||
$sourceBackend = $this->versionManager->getBackendForStorage($sourceStorage);
|
||||
$targetBackend = $this->versionManager->getBackendForStorage($targetStorage);
|
||||
|
||||
// If same backend, nothing to do.
|
||||
if ($sourceBackend === $targetBackend) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->userSession->getUser() ?? $source->getOwner();
|
||||
|
||||
if ($user === null) {
|
||||
throw new Exception("Cannot move versions across storages without a user.");
|
||||
}
|
||||
|
||||
if ($event instanceof BeforeNodeRenamedEvent) {
|
||||
$this->recursivelyPrepareMove($source);
|
||||
} elseif ($event instanceof NodeRenamedEvent || $event instanceof NodeCopiedEvent) {
|
||||
$this->recursivelyHandleMoveOrCopy($event, $user, $source, $target, $sourceBackend, $targetBackend);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store all sub files in this->movedNodes so their info can be used after the operation.
|
||||
*/
|
||||
private function recursivelyPrepareMove(Node $source): void {
|
||||
if ($source instanceof File) {
|
||||
$this->movedNodes[$source->getId()] = $source;
|
||||
} elseif ($source instanceof Folder) {
|
||||
foreach ($source->getDirectoryListing() as $child) {
|
||||
$this->recursivelyPrepareMove($child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call handleMoveOrCopy on each sub files
|
||||
* @param NodeRenamedEvent|NodeCopiedEvent $event
|
||||
*/
|
||||
private function recursivelyHandleMoveOrCopy(Event $event, IUser $user, ?Node $source, Node $target, IVersionBackend $sourceBackend, IVersionBackend $targetBackend): void {
|
||||
if ($target instanceof File) {
|
||||
if ($event instanceof NodeRenamedEvent) {
|
||||
$source = $this->movedNodes[$target->getId()];
|
||||
}
|
||||
|
||||
/** @var File $source */
|
||||
$this->handleMoveOrCopy($event, $user, $source, $target, $sourceBackend, $targetBackend);
|
||||
} elseif ($target instanceof Folder) {
|
||||
/** @var Folder $source */
|
||||
foreach ($target->getDirectoryListing() as $targetChild) {
|
||||
if ($event instanceof NodeCopiedEvent) {
|
||||
$sourceChild = $source->get($targetChild->getName());
|
||||
} else {
|
||||
$sourceChild = null;
|
||||
}
|
||||
|
||||
$this->recursivelyHandleMoveOrCopy($event, $user, $sourceChild, $targetChild, $sourceBackend, $targetBackend);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called only during NodeRenamedEvent or NodeCopiedEvent
|
||||
* Will send the source node versions to the new backend, and then delete them from the old backend.
|
||||
* @param NodeRenamedEvent|NodeCopiedEvent $event
|
||||
*/
|
||||
private function handleMoveOrCopy(Event $event, IUser $user, File $source, File $target, IVersionBackend $sourceBackend, IVersionBackend $targetBackend): void {
|
||||
if ($targetBackend instanceof IVersionsImporterBackend) {
|
||||
$versions = $sourceBackend->getVersionsForFile($user, $source);
|
||||
$targetBackend->importVersionsForFile($user, $source, $target, $versions);
|
||||
}
|
||||
|
||||
if ($event instanceof NodeRenamedEvent && $sourceBackend instanceof IVersionsImporterBackend) {
|
||||
$sourceBackend->clearVersionsForFile($user, $source, $target);
|
||||
}
|
||||
}
|
||||
|
||||
private function getNodeStorage(Node $node): IStorage {
|
||||
if ($node instanceof NonExistingFile) {
|
||||
return $node->getParent()->getStorage();
|
||||
} else {
|
||||
return $node->getStorage();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -28,6 +28,14 @@ namespace OCA\Files_Versions\Versions;
|
|||
* @since 29.0.0
|
||||
*/
|
||||
interface IMetadataVersion {
|
||||
/**
|
||||
* retrieves the all the metadata
|
||||
*
|
||||
* @return string[]
|
||||
* @since 29.0.0
|
||||
*/
|
||||
public function getMetadata(): array;
|
||||
|
||||
/**
|
||||
* retrieves the metadata value from our $key param
|
||||
*
|
||||
|
|
|
@ -25,6 +25,8 @@ declare(strict_types=1);
|
|||
*/
|
||||
namespace OCA\Files_Versions\Versions;
|
||||
|
||||
use OCP\Files\Storage\IStorage;
|
||||
|
||||
/**
|
||||
* @since 15.0.0
|
||||
*/
|
||||
|
@ -37,4 +39,10 @@ interface IVersionManager extends IVersionBackend {
|
|||
* @since 15.0.0
|
||||
*/
|
||||
public function registerBackend(string $storageType, IVersionBackend $backend);
|
||||
|
||||
/**
|
||||
* @throws BackendNotFoundException
|
||||
* @since 29.0.0
|
||||
*/
|
||||
public function getBackendForStorage(IStorage $storage): IVersionBackend;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @copyright Copyright (c) 2024 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\Versions;
|
||||
|
||||
use OCP\Files\Node;
|
||||
use OCP\IUser;
|
||||
|
||||
/**
|
||||
* @since 29.0.0
|
||||
*/
|
||||
interface IVersionsImporterBackend {
|
||||
/**
|
||||
* Import the given versions for the target file.
|
||||
*
|
||||
* @param Node $source - The source might not exist anymore.
|
||||
* @param IVersion[] $versions
|
||||
* @since 29.0.0
|
||||
*/
|
||||
public function importVersionsForFile(IUser $user, Node $source, Node $target, array $versions): void;
|
||||
|
||||
/**
|
||||
* Clear all versions for a file
|
||||
*
|
||||
* @since 29.0.0
|
||||
*/
|
||||
public function clearVersionsForFile(IUser $user, Node $source, Node $target): void;
|
||||
}
|
|
@ -27,6 +27,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace OCA\Files_Versions\Versions;
|
||||
|
||||
use Exception;
|
||||
use OC\Files\View;
|
||||
use OCA\DAV\Connector\Sabre\Exception\Forbidden;
|
||||
use OCA\Files_Sharing\ISharedStorage;
|
||||
|
@ -45,14 +46,16 @@ use OCP\Files\Storage\IStorage;
|
|||
use OCP\IUser;
|
||||
use OCP\IUserManager;
|
||||
use OCP\IUserSession;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class LegacyVersionsBackend implements IVersionBackend, IDeletableVersionBackend, INeedSyncVersionBackend, IMetadataVersionBackend {
|
||||
class LegacyVersionsBackend implements IVersionBackend, IDeletableVersionBackend, INeedSyncVersionBackend, IMetadataVersionBackend, IVersionsImporterBackend {
|
||||
public function __construct(
|
||||
private IRootFolder $rootFolder,
|
||||
private IUserManager $userManager,
|
||||
private VersionsMapper $versionsMapper,
|
||||
private IMimeTypeLoader $mimeTypeLoader,
|
||||
private IUserSession $userSession,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
|
@ -304,4 +307,74 @@ class LegacyVersionsBackend implements IVersionBackend, IDeletableVersionBackend
|
|||
$versionEntity->setMetadataValue($key, $value);
|
||||
$this->versionsMapper->update($versionEntity);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function importVersionsForFile(IUser $user, Node $source, Node $target, array $versions): void {
|
||||
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
|
||||
$relativePath = $userFolder->getRelativePath($target->getPath());
|
||||
|
||||
if ($relativePath === null) {
|
||||
throw new \Exception('Target does not have a relative path' . $target->getPath());
|
||||
}
|
||||
|
||||
$userView = new View('/' . $user->getUID());
|
||||
// create all parent folders
|
||||
Storage::createMissingDirectories($relativePath, $userView);
|
||||
Storage::scheduleExpire($user->getUID(), $relativePath);
|
||||
|
||||
foreach ($versions as $version) {
|
||||
// 1. Import the file in its new location.
|
||||
// Nothing to do for the current version.
|
||||
if ($version->getTimestamp() !== $source->getMTime()) {
|
||||
$backend = $version->getBackend();
|
||||
$versionFile = $backend->getVersionFile($user, $source, $version->getRevisionId());
|
||||
$newVersionPath = 'files_versions/' . $relativePath . '.v' . $version->getTimestamp();
|
||||
|
||||
$versionContent = $versionFile->fopen('r');
|
||||
if ($versionContent === false) {
|
||||
$this->logger->warning('Fail to open version file.', ['source' => $source, 'version' => $version, 'versionFile' => $versionFile]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$userView->file_put_contents($newVersionPath, $versionContent);
|
||||
// ensure the file is scanned
|
||||
$userView->getFileInfo($newVersionPath);
|
||||
}
|
||||
|
||||
// 2. Create the entity in the database
|
||||
$versionEntity = new VersionEntity();
|
||||
$versionEntity->setFileId($target->getId());
|
||||
$versionEntity->setTimestamp($version->getTimestamp());
|
||||
$versionEntity->setSize($version->getSize());
|
||||
$versionEntity->setMimetype($this->mimeTypeLoader->getId($version->getMimetype()));
|
||||
if ($version instanceof IMetadataVersion) {
|
||||
$versionEntity->setMetadata($version->getMetadata());
|
||||
}
|
||||
$this->versionsMapper->insert($versionEntity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function clearVersionsForFile(IUser $user, Node $source, Node $target): void {
|
||||
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
|
||||
|
||||
$relativePath = $userFolder->getRelativePath($source->getPath());
|
||||
if ($relativePath === null) {
|
||||
throw new Exception("Relative path not found for node with path: " . $source->getPath());
|
||||
}
|
||||
|
||||
$versions = Storage::getVersions($user->getUID(), $relativePath);
|
||||
/** @var Folder versionFolder */
|
||||
$versionFolder = $this->rootFolder->get('admin/files_versions');
|
||||
foreach ($versions as $version) {
|
||||
$versionFolder->get($version['path'] . '.v' . (int)$version['version'])->delete();
|
||||
}
|
||||
|
||||
$this->versionsMapper->deleteAllVersionsForFileId($target->getId());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -79,6 +79,10 @@ class Version implements IVersion, IMetadataVersion {
|
|||
return $this->user;
|
||||
}
|
||||
|
||||
public function getMetadata(): array {
|
||||
return $this->metadata;
|
||||
}
|
||||
|
||||
public function getMetadataValue(string $key): ?string {
|
||||
return $this->metadata[$key] ?? null;
|
||||
}
|
||||
|
|
|
@ -203,5 +203,4 @@ class VersionManager implements IVersionManager, IDeletableVersionBackend, INeed
|
|||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -45,7 +45,7 @@ export const triggerInlineActionForFile = (filename: string, actionId: string) =
|
|||
getActionsForFile(filename).get(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`).should('exist').click()
|
||||
}
|
||||
|
||||
export const moveFile = (fileName: string, dirName: string) => {
|
||||
export const moveFile = (fileName: string, dirPath: string) => {
|
||||
getRowForFile(fileName).should('be.visible')
|
||||
triggerActionForFile(fileName, 'move-copy')
|
||||
|
||||
|
@ -53,26 +53,30 @@ export const moveFile = (fileName: string, dirName: string) => {
|
|||
// intercept the copy so we can wait for it
|
||||
cy.intercept('MOVE', /\/remote.php\/dav\/files\//).as('moveFile')
|
||||
|
||||
if (dirName === '/') {
|
||||
if (dirPath === '/') {
|
||||
// select home folder
|
||||
cy.get('button[title="Home"]').should('be.visible').click()
|
||||
// click move
|
||||
cy.contains('button', 'Move').should('be.visible').click()
|
||||
} else if (dirName === '.') {
|
||||
} else if (dirPath === '.') {
|
||||
// click move
|
||||
cy.contains('button', 'Copy').should('be.visible').click()
|
||||
} else {
|
||||
// select the folder
|
||||
cy.get(`[data-filename="${dirName}"]`).should('be.visible').click()
|
||||
const directories = dirPath.split('/')
|
||||
directories.forEach((directory) => {
|
||||
// select the folder
|
||||
cy.get(`[data-filename="${directory}"]`).should('be.visible').click()
|
||||
})
|
||||
|
||||
// click move
|
||||
cy.contains('button', `Move to ${dirName}`).should('be.visible').click()
|
||||
cy.contains('button', `Move to ${directories.at(-1)}`).should('be.visible').click()
|
||||
}
|
||||
|
||||
cy.wait('@moveFile')
|
||||
})
|
||||
}
|
||||
|
||||
export const copyFile = (fileName: string, dirName: string) => {
|
||||
export const copyFile = (fileName: string, dirPath: string) => {
|
||||
getRowForFile(fileName).should('be.visible')
|
||||
triggerActionForFile(fileName, 'move-copy')
|
||||
|
||||
|
@ -80,19 +84,23 @@ export const copyFile = (fileName: string, dirName: string) => {
|
|||
// intercept the copy so we can wait for it
|
||||
cy.intercept('COPY', /\/remote.php\/dav\/files\//).as('copyFile')
|
||||
|
||||
if (dirName === '/') {
|
||||
if (dirPath === '/') {
|
||||
// select home folder
|
||||
cy.get('button[title="Home"]').should('be.visible').click()
|
||||
// click copy
|
||||
cy.contains('button', 'Copy').should('be.visible').click()
|
||||
} else if (dirName === '.') {
|
||||
} else if (dirPath === '.') {
|
||||
// click copy
|
||||
cy.contains('button', 'Copy').should('be.visible').click()
|
||||
} else {
|
||||
// select folder
|
||||
cy.get(`[data-filename="${CSS.escape(dirName)}"]`).should('be.visible').click()
|
||||
const directories = dirPath.split('/')
|
||||
directories.forEach((directory) => {
|
||||
// select the folder
|
||||
cy.get(`[data-filename="${CSS.escape(directory)}"]`).should('be.visible').click()
|
||||
})
|
||||
|
||||
// click copy
|
||||
cy.contains('button', `Copy to ${dirName}`).should('be.visible').click()
|
||||
cy.contains('button', `Copy to ${directories.at(-1)}`).should('be.visible').click()
|
||||
}
|
||||
|
||||
cy.wait('@copyFile')
|
||||
|
@ -112,10 +120,21 @@ export const renameFile = (fileName: string, newFileName: string) => {
|
|||
cy.wait('@moveFile')
|
||||
}
|
||||
|
||||
export const navigateToFolder = (folderName: string) => {
|
||||
getRowForFile(folderName).should('be.visible').find('[data-cy-files-list-row-name-link]').click()
|
||||
export const navigateToFolder = (dirPath: string) => {
|
||||
const directories = dirPath.split('/')
|
||||
directories.forEach((directory) => {
|
||||
getRowForFile(directory).should('be.visible').find('[data-cy-files-list-row-name-link]').click()
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
export const closeSidebar = () => {
|
||||
cy.get('[cy-data-sidebar] .app-sidebar__close').click()
|
||||
// {force: true} as it might be hidden behind toasts
|
||||
cy.get('[cy-data-sidebar] .app-sidebar__close').click({ force: true })
|
||||
}
|
||||
|
||||
export const clickOnBreadcumbs = (label: string) => {
|
||||
cy.intercept('PROPFIND', /\/remote.php\/dav\//).as('propfind')
|
||||
cy.get('[data-cy-files-content-breadcrumbs]').contains(label).click()
|
||||
cy.wait('@propfind')
|
||||
}
|
||||
|
|
|
@ -21,17 +21,7 @@
|
|||
*/
|
||||
|
||||
import type { User } from '@nextcloud/cypress'
|
||||
import { closeSidebar, copyFile, getRowForFile, getRowForFileId, renameFile, triggerActionForFile, triggerInlineActionForFileId } from './FilesUtils'
|
||||
|
||||
/**
|
||||
*
|
||||
* @param label
|
||||
*/
|
||||
function refreshView(label: string) {
|
||||
cy.intercept('PROPFIND', /\/remote.php\/dav\//).as('propfind')
|
||||
cy.get('[data-cy-files-content-breadcrumbs]').contains(label).click()
|
||||
cy.wait('@propfind')
|
||||
}
|
||||
import { clickOnBreadcumbs, closeSidebar, copyFile, getRowForFile, getRowForFileId, renameFile, triggerActionForFile, triggerInlineActionForFileId } from './FilesUtils'
|
||||
|
||||
/**
|
||||
*
|
||||
|
@ -123,7 +113,7 @@ describe('Files: Live photos', { testIsolation: true }, () => {
|
|||
|
||||
it('Copies both files when copying the .jpg', () => {
|
||||
copyFile(`${randomFileName}.jpg`, '.')
|
||||
refreshView('All files')
|
||||
clickOnBreadcumbs('All files')
|
||||
|
||||
getRowForFile(`${randomFileName}.jpg`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
|
||||
|
@ -133,7 +123,7 @@ describe('Files: Live photos', { testIsolation: true }, () => {
|
|||
|
||||
it('Copies both files when copying the .mov', () => {
|
||||
copyFile(`${randomFileName}.mov`, '.')
|
||||
refreshView('All files')
|
||||
clickOnBreadcumbs('All files')
|
||||
|
||||
getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName} (copy).jpg`).should('have.length', 1)
|
||||
|
@ -142,7 +132,7 @@ describe('Files: Live photos', { testIsolation: true }, () => {
|
|||
|
||||
it('Moves files when moving the .jpg', () => {
|
||||
renameFile(`${randomFileName}.jpg`, `${randomFileName}_moved.jpg`)
|
||||
refreshView('All files')
|
||||
clickOnBreadcumbs('All files')
|
||||
|
||||
getRowForFileId(jpgFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.jpg`)
|
||||
getRowForFileId(movFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.mov`)
|
||||
|
@ -150,7 +140,7 @@ describe('Files: Live photos', { testIsolation: true }, () => {
|
|||
|
||||
it('Moves files when moving the .mov', () => {
|
||||
renameFile(`${randomFileName}.mov`, `${randomFileName}_moved.mov`)
|
||||
refreshView('All files')
|
||||
clickOnBreadcumbs('All files')
|
||||
|
||||
getRowForFileId(jpgFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.jpg`)
|
||||
getRowForFileId(movFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.mov`)
|
||||
|
@ -158,7 +148,7 @@ describe('Files: Live photos', { testIsolation: true }, () => {
|
|||
|
||||
it('Deletes files when deleting the .jpg', () => {
|
||||
triggerActionForFile(`${randomFileName}.jpg`, 'delete')
|
||||
refreshView('All files')
|
||||
clickOnBreadcumbs('All files')
|
||||
|
||||
getRowForFile(`${randomFileName}.jpg`).should('have.length', 0)
|
||||
getRowForFile(`${randomFileName}.mov`).should('have.length', 0)
|
||||
|
@ -171,7 +161,7 @@ describe('Files: Live photos', { testIsolation: true }, () => {
|
|||
|
||||
it('Block deletion when deleting the .mov', () => {
|
||||
triggerActionForFile(`${randomFileName}.mov`, 'delete')
|
||||
refreshView('All files')
|
||||
clickOnBreadcumbs('All files')
|
||||
|
||||
getRowForFile(`${randomFileName}.jpg`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
|
||||
|
@ -186,7 +176,7 @@ describe('Files: Live photos', { testIsolation: true }, () => {
|
|||
triggerActionForFile(`${randomFileName}.jpg`, 'delete')
|
||||
cy.visit('/apps/files/trashbin')
|
||||
triggerInlineActionForFileId(jpgFileId, 'restore')
|
||||
refreshView('Deleted files')
|
||||
clickOnBreadcumbs('Deleted files')
|
||||
|
||||
getRowForFile(`${randomFileName}.jpg`).should('have.length', 0)
|
||||
getRowForFile(`${randomFileName}.mov`).should('have.length', 0)
|
||||
|
@ -201,7 +191,7 @@ describe('Files: Live photos', { testIsolation: true }, () => {
|
|||
triggerActionForFile(`${randomFileName}.jpg`, 'delete')
|
||||
cy.visit('/apps/files/trashbin')
|
||||
triggerInlineActionForFileId(movFileId, 'restore')
|
||||
refreshView('Deleted files')
|
||||
clickOnBreadcumbs('Deleted files')
|
||||
|
||||
getRowForFileId(jpgFileId).should('have.length', 1)
|
||||
getRowForFileId(movFileId).should('have.length', 1)
|
||||
|
|
|
@ -89,14 +89,11 @@ export function doesNotHaveAction(index: number, actionName: string) {
|
|||
toggleVersionMenu(index)
|
||||
}
|
||||
|
||||
export function assertVersionContent(filename: string, index: number, expectedContent: string) {
|
||||
const downloadsFolder = Cypress.config('downloadsFolder')
|
||||
|
||||
export function assertVersionContent(index: number, expectedContent: string) {
|
||||
cy.intercept({ method: 'GET', times: 1, url: 'remote.php/**' }).as('downloadVersion')
|
||||
triggerVersionAction(index, 'download')
|
||||
|
||||
return cy.readFile(path.join(downloadsFolder, filename))
|
||||
.then((versionContent) => expect(versionContent).to.equal(expectedContent))
|
||||
.then(() => cy.exec(`rm ${downloadsFolder}/${filename}`))
|
||||
cy.wait('@downloadVersion')
|
||||
.then(({ response }) => expect(response?.body).to.equal(expectedContent))
|
||||
}
|
||||
|
||||
export function setupTestSharedFileFromUser(owner: User, randomFileName: string, shareOptions: Partial<ShareSetting>) {
|
||||
|
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* @copyright Copyright (c) 2024 Louis Chmn <louis@chmn.me>
|
||||
*
|
||||
* @author Louis Chmn <louis@chmn.me>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
import { assertVersionContent, openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions, nameVersion } from './filesVersionsUtils'
|
||||
import { clickOnBreadcumbs, closeSidebar, copyFile, moveFile, navigateToFolder } from '../files/FilesUtils'
|
||||
import type { User } from '@nextcloud/cypress'
|
||||
|
||||
/**
|
||||
*
|
||||
* @param filePath
|
||||
*/
|
||||
function assertVersionsContent(filePath: string) {
|
||||
const path = filePath.split('/').slice(0, -1).join('/')
|
||||
|
||||
clickOnBreadcumbs('All files')
|
||||
|
||||
if (path !== '') {
|
||||
navigateToFolder(path)
|
||||
}
|
||||
|
||||
openVersionsPanel(filePath)
|
||||
|
||||
cy.get('[data-files-versions-version]').should('have.length', 3)
|
||||
assertVersionContent(0, 'v3')
|
||||
assertVersionContent(1, 'v2')
|
||||
assertVersionContent(2, 'v1')
|
||||
}
|
||||
|
||||
describe('Versions cross share move and copy', () => {
|
||||
let randomSharedFolderName = ''
|
||||
let randomFileName = ''
|
||||
let randomFilePath = ''
|
||||
let alice: User
|
||||
let bob: User
|
||||
|
||||
before(() => {
|
||||
randomSharedFolderName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10)
|
||||
|
||||
cy.createRandomUser()
|
||||
.then((user) => {
|
||||
alice = user
|
||||
cy.mkdir(alice, `/${randomSharedFolderName}`)
|
||||
setupTestSharedFileFromUser(alice, randomSharedFolderName, {})
|
||||
})
|
||||
.then((user) => { bob = user })
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
randomFileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt'
|
||||
randomFilePath = `${randomSharedFolderName}/${randomFileName}`
|
||||
uploadThreeVersions(alice, randomFilePath)
|
||||
|
||||
cy.login(bob)
|
||||
cy.visit('/apps/files')
|
||||
navigateToFolder(randomSharedFolderName)
|
||||
openVersionsPanel(randomFilePath)
|
||||
nameVersion(2, 'v1')
|
||||
closeSidebar()
|
||||
})
|
||||
|
||||
it('Also moves versions when bob moves the file out of a received share', () => {
|
||||
moveFile(randomFileName, '/')
|
||||
assertVersionsContent(randomFileName)
|
||||
// TODO: move that in assertVersionsContent when copying files keeps the versions' metadata
|
||||
cy.get('[data-files-versions-version]').eq(2).contains('v1')
|
||||
})
|
||||
|
||||
it('Also copies versions when bob copies the file out of a received share', () => {
|
||||
copyFile(randomFileName, '/')
|
||||
assertVersionsContent(randomFileName)
|
||||
})
|
||||
|
||||
context('When a file is in a subfolder', () => {
|
||||
let randomSubFolderName
|
||||
let randomSubSubFolderName
|
||||
|
||||
beforeEach(() => {
|
||||
randomSubFolderName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10)
|
||||
randomSubSubFolderName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10)
|
||||
clickOnBreadcumbs('All files')
|
||||
cy.mkdir(bob, `/${randomSharedFolderName}/${randomSubFolderName}`)
|
||||
cy.mkdir(bob, `/${randomSharedFolderName}/${randomSubFolderName}/${randomSubSubFolderName}`)
|
||||
cy.login(bob)
|
||||
navigateToFolder(randomSharedFolderName)
|
||||
moveFile(randomFileName, `${randomSubFolderName}/${randomSubSubFolderName}`)
|
||||
})
|
||||
|
||||
it('Also moves versions when bob moves the containing folder out of a received share', () => {
|
||||
moveFile(randomSubFolderName, '/')
|
||||
assertVersionsContent(`${randomSubFolderName}/${randomSubSubFolderName}/${randomFileName}`)
|
||||
// TODO: move that in assertVersionsContent when copying files keeps the versions' metadata
|
||||
cy.get('[data-files-versions-version]').eq(2).contains('v1')
|
||||
})
|
||||
|
||||
it('Also copies versions when bob copies the containing folder out of a received share', () => {
|
||||
copyFile(randomSubFolderName, '/')
|
||||
assertVersionsContent(`${randomSubFolderName}/${randomSubSubFolderName}/${randomFileName}`)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -42,9 +42,9 @@ describe('Versions download', () => {
|
|||
})
|
||||
|
||||
it('Download versions and assert their content', () => {
|
||||
assertVersionContent(randomFileName, 0, 'v3')
|
||||
assertVersionContent(randomFileName, 1, 'v2')
|
||||
assertVersionContent(randomFileName, 2, 'v1')
|
||||
assertVersionContent(0, 'v3')
|
||||
assertVersionContent(1, 'v2')
|
||||
assertVersionContent(2, 'v1')
|
||||
})
|
||||
|
||||
context('Download versions of shared file', () => {
|
||||
|
@ -52,9 +52,9 @@ describe('Versions download', () => {
|
|||
setupTestSharedFileFromUser(user, randomFileName, { download: true })
|
||||
openVersionsPanel(randomFileName)
|
||||
|
||||
assertVersionContent(randomFileName, 0, 'v3')
|
||||
assertVersionContent(randomFileName, 1, 'v2')
|
||||
assertVersionContent(randomFileName, 2, 'v1')
|
||||
assertVersionContent(0, 'v3')
|
||||
assertVersionContent(1, 'v2')
|
||||
assertVersionContent(2, 'v1')
|
||||
})
|
||||
|
||||
it('Does not show action without download permission', () => {
|
||||
|
|
|
@ -49,7 +49,7 @@ describe('Versions expiration', () => {
|
|||
cy.get('[data-files-versions-version]').eq(0).contains('Current version')
|
||||
})
|
||||
|
||||
assertVersionContent(randomFileName, 0, 'v3')
|
||||
assertVersionContent(0, 'v3')
|
||||
})
|
||||
|
||||
it('Expire versions v2', () => {
|
||||
|
@ -67,7 +67,7 @@ describe('Versions expiration', () => {
|
|||
cy.get('[data-files-versions-version]').eq(1).contains('v1')
|
||||
})
|
||||
|
||||
assertVersionContent(randomFileName, 0, 'v3')
|
||||
assertVersionContent(randomFileName, 1, 'v1')
|
||||
assertVersionContent(0, 'v3')
|
||||
assertVersionContent(1, 'v1')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -56,9 +56,9 @@ describe('Versions restoration', () => {
|
|||
})
|
||||
|
||||
it('Downloads versions and assert there content', () => {
|
||||
assertVersionContent(randomFileName, 0, 'v1')
|
||||
assertVersionContent(randomFileName, 1, 'v3')
|
||||
assertVersionContent(randomFileName, 2, 'v2')
|
||||
assertVersionContent(0, 'v1')
|
||||
assertVersionContent(1, 'v3')
|
||||
assertVersionContent(2, 'v2')
|
||||
})
|
||||
|
||||
context('Restore versions of shared file', () => {
|
||||
|
@ -76,9 +76,9 @@ describe('Versions restoration', () => {
|
|||
})
|
||||
|
||||
it('Downloads versions and assert there content', () => {
|
||||
assertVersionContent(randomFileName, 0, 'v1')
|
||||
assertVersionContent(randomFileName, 1, 'v3')
|
||||
assertVersionContent(randomFileName, 2, 'v2')
|
||||
assertVersionContent(0, 'v1')
|
||||
assertVersionContent(1, 'v3')
|
||||
assertVersionContent(2, 'v2')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -184,7 +184,7 @@ Cypress.Commands.add('mkdir', (user: User, target: string) => {
|
|||
cy.log(`Created directory ${target}`, response)
|
||||
} catch (error) {
|
||||
cy.log('error', error)
|
||||
throw new Error('Unable to process fixture')
|
||||
throw new Error('Unable to create directory')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in New Issue