Add listener and interfaces to allow versions migration across storagetags/v29.0.0beta6
@@ -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') | |||
} | |||
}) | |||
}) |