diff --git a/appinfo/routes.php b/appinfo/routes.php index e9a4126ac7..c0e8ddd5d4 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -33,6 +33,7 @@ ['name' => 'web_view#index', 'url' => '/duplicated', 'verb' => 'GET', 'postfix' => 'duplicated'], ['name' => 'web_view#index', 'url' => '/shared', 'verb' => 'GET', 'postfix' => 'shared'], ['name' => 'web_view#index', 'url' => '/bookmarklet', 'verb' => 'GET', 'postfix' => 'bookmarklet'], + ['name' => 'web_view#index', 'url' => '/trashbin', 'verb' => 'GET', 'postfix' => 'trashbin'], ['name' => 'web_view#service_worker', 'url' => '/service-worker.js', 'verb' => 'GET'], ['name' => 'web_view#manifest', 'url' => '/manifest.webmanifest', 'verb' => 'GET'], @@ -45,6 +46,7 @@ ['name' => 'internal_bookmark#count_unavailable', 'url' => '/bookmark/unavailable', 'verb' => 'GET'], ['name' => 'internal_bookmark#count_archived', 'url' => '/bookmark/archived', 'verb' => 'GET'], ['name' => 'internal_bookmark#count_duplicated', 'url' => '/bookmark/duplicated', 'verb' => 'GET'], + ['name' => 'internal_bookmark#get_deleted_bookmarks', 'url' => '/bookmark/deleted', 'verb' => 'GET'], ['name' => 'internal_bookmark#edit_bookmark', 'url' => '/bookmark/{id}', 'verb' => 'PUT'], ['name' => 'internal_bookmark#get_single_bookmark', 'url' => '/bookmark/{id}', 'verb' => 'GET'], ['name' => 'internal_bookmark#delete_bookmark', 'url' => '/bookmark/{id}', 'verb' => 'DELETE'], @@ -62,17 +64,20 @@ ['name' => 'internal_tags#delete_tag', 'url' => '/tag/{old_name}', 'verb' => 'DELETE'], ['name' => 'internal_folders#get_folders', 'url' => '/folder', 'verb' => 'GET'], ['name' => 'internal_folders#find_shared_folders', 'url' => '/folder/shared', 'verb' => 'GET'], + ['name' => 'internal_folders#get_deleted_folders', 'url' => '/folder/deleted', 'verb' => 'GET'], ['name' => 'internal_folders#get_folder', 'url' => '/folder/{folderId}', 'verb' => 'GET'], ['name' => 'internal_folders#add_folder', 'url' => '/folder', 'verb' => 'POST'], ['name' => 'internal_folders#edit_folder', 'url' => '/folder/{folderId}', 'verb' => 'PUT'], ['name' => 'internal_folders#delete_folder', 'url' => '/folder/{folderId}', 'verb' => 'DELETE'], ['name' => 'internal_folders#hash_folder', 'url' => '/folder/{folderId}/hash', 'verb' => 'GET'], ['name' => 'internal_bookmark#import_bookmark', 'url' => '/folder/{folder}/import', 'verb' => 'POST'], + ['name' => 'internal_folders#undelete_folder', 'url' => '/folder/{folderId}/undelete', 'verb' => 'POST'], ['name' => 'internal_folders#get_folder_children', 'url' => '/folder/{folderId}/children', 'verb' => 'GET'], ['name' => 'internal_folders#get_folder_children_order', 'url' => '/folder/{folderId}/childorder', 'verb' => 'GET'], ['name' => 'internal_folders#set_folder_children_order', 'url' => '/folder/{folderId}/childorder', 'verb' => 'PATCH'], ['name' => 'internal_folders#add_to_folder', 'url' => '/folder/{folderId}/bookmarks/{bookmarkId}', 'verb' => 'POST'], ['name' => 'internal_folders#remove_from_folder', 'url' => '/folder/{folderId}/bookmarks/{bookmarkId}', 'verb' => 'DELETE'], + ['name' => 'internal_folders#undelete_from_folder', 'url' => '/folder/{folderId}/bookmarks/{bookmarkId}/undelete', 'verb' => 'POST'], ['name' => 'internal_folders#get_folder_public_token', 'url' => '/folder/{folderId}/publictoken', 'verb' => 'GET'], ['name' => 'internal_folders#create_folder_public_token', 'url' => '/folder/{folderId}/publictoken', 'verb' => 'POST'], ['name' => 'internal_folders#delete_folder_public_token', 'url' => '/folder/{folderId}/publictoken', 'verb' => 'DELETE'], @@ -110,12 +115,14 @@ ['name' => 'folders#edit_folder', 'url' => '/public/rest/v2/folder/{folderId}', 'verb' => 'PUT'], ['name' => 'folders#delete_folder', 'url' => '/public/rest/v2/folder/{folderId}', 'verb' => 'DELETE'], ['name' => 'folders#hash_folder', 'url' => '/public/rest/v2/folder/{folderId}/hash', 'verb' => 'GET'], + ['name' => 'folders#undelete_folder', 'url' => '/public/rest/v2/folder/{folderId}/undelete', 'verb' => 'POST'], ['name' => 'bookmark#import_bookmark', 'url' => '/public/rest/v2/folder/{folder}/import', 'verb' => 'POST'], ['name' => 'folders#get_folder_children', 'url' => '/public/rest/v2/folder/{folderId}/children', 'verb' => 'GET'], ['name' => 'folders#get_folder_children_order', 'url' => '/public/rest/v2/folder/{folderId}/childorder', 'verb' => 'GET'], ['name' => 'folders#set_folder_children_order', 'url' => '/public/rest/v2/folder/{folderId}/childorder', 'verb' => 'PATCH'], ['name' => 'folders#add_to_folder', 'url' => '/public/rest/v2/folder/{folderId}/bookmarks/{bookmarkId}', 'verb' => 'POST'], ['name' => 'folders#remove_from_folder', 'url' => '/public/rest/v2/folder/{folderId}/bookmarks/{bookmarkId}', 'verb' => 'DELETE'], + ['name' => 'folders#undelete_from_folder', 'url' => '/public/rest/v2/folder/{folderId}/bookmarks/{bookmarkId}/undelete', 'verb' => 'POST'], ['name' => 'bookmark#preflighted_cors', 'url' => '/public/rest/v2/{path}', 'verb' => 'OPTIONS', 'requirements' => ['path' => '.+']], ['name' => 'folders#get_folder_public_token', 'url' => '/public/rest/v2/folder/{folderId}/publictoken', 'verb' => 'GET'], diff --git a/composer.json b/composer.json index f95d9b9101..8a660a131d 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ "require-dev": { "phpunit/phpunit": "^9.5.26", "nextcloud/coding-standard": "^1.0.0", - "vimeo/psalm": "^4", + "vimeo/psalm": "5.x", "nextcloud/ocp": "dev-master" }, "config": { diff --git a/lib/Activity/ActivityPublisher.php b/lib/Activity/ActivityPublisher.php index 7bc69fc4bf..c4a344261c 100644 --- a/lib/Activity/ActivityPublisher.php +++ b/lib/Activity/ActivityPublisher.php @@ -14,7 +14,7 @@ use OCA\Bookmarks\Db\SharedFolder; use OCA\Bookmarks\Db\SharedFolderMapper; use OCA\Bookmarks\Db\TreeMapper; -use OCA\Bookmarks\Events\BeforeDeleteEvent; +use OCA\Bookmarks\Events\BeforeSoftDeleteEvent; use OCA\Bookmarks\Events\ChangeEvent; use OCA\Bookmarks\Events\CreateEvent; use OCA\Bookmarks\Events\MoveEvent; @@ -26,6 +26,9 @@ use OCP\EventDispatcher\IEventListener; use OCP\IL10N; +/** + * @psalm-implements IEventListener + */ class ActivityPublisher implements IEventListener { /** * @var IManager @@ -117,7 +120,7 @@ public function publishShare(ChangeEvent $event): void { if ($event instanceof CreateEvent) { $activity->setSubject('share_created', ['folder' => $sharedFolder->getTitle(), 'sharee' => $sharedFolder->getUserId()]); - } elseif ($event instanceof BeforeDeleteEvent) { + } elseif ($event instanceof BeforeSoftDeleteEvent) { $activity->setSubject('share_deleted', ['folder' => $sharedFolder->getTitle(), 'sharee' => $sharedFolder->getUserId()]); } else { return; @@ -151,7 +154,7 @@ public function publishFolder(ChangeEvent $event): void { $activity->setObject(TreeMapper::TYPE_FOLDER, $folder->getId()); if ($event instanceof CreateEvent) { $activity->setSubject('folder_created', ['folder' => $folder->getTitle()]); - } elseif ($event instanceof BeforeDeleteEvent) { + } elseif ($event instanceof BeforeSoftDeleteEvent) { $activity->setSubject('folder_deleted', ['folder' => $folder->getTitle()]); } elseif ($event instanceof MoveEvent) { $activity->setSubject('folder_moved', ['folder' => $folder->getTitle()]); @@ -203,7 +206,7 @@ public function publishBookmark(ChangeEvent $event): void { if ($event instanceof CreateEvent) { $activity->setSubject('bookmark_created', ['bookmark' => $bookmark->getTitle()]); - } elseif ($event instanceof BeforeDeleteEvent) { + } elseif ($event instanceof BeforeSoftDeleteEvent) { $activity->setSubject('bookmark_deleted', ['bookmark' => $bookmark->getTitle()]); } else { return; diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index bf69957d41..3250d142df 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -12,6 +12,8 @@ use OCA\Bookmarks\Dashboard\Frequent; use OCA\Bookmarks\Dashboard\Recent; use OCA\Bookmarks\Events\BeforeDeleteEvent; +use OCA\Bookmarks\Events\BeforeSoftDeleteEvent; +use OCA\Bookmarks\Events\BeforeSoftUndeleteEvent; use OCA\Bookmarks\Events\CreateEvent; use OCA\Bookmarks\Events\MoveEvent; use OCA\Bookmarks\Events\UpdateEvent; @@ -79,6 +81,8 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(UpdateEvent::class, TreeCacheManager::class); $context->registerEventListener(BeforeDeleteEvent::class, TreeCacheManager::class); $context->registerEventListener(MoveEvent::class, TreeCacheManager::class); + $context->registerEventListener(BeforeSoftDeleteEvent::class, TreeCacheManager::class); + $context->registerEventListener(BeforeSoftUndeleteEvent::class, TreeCacheManager::class); $context->registerEventListener(CreateEvent::class, ActivityPublisher::class); $context->registerEventListener(UpdateEvent::class, ActivityPublisher::class); diff --git a/lib/AugmentedTemplateResponse.php b/lib/AugmentedTemplateResponse.php index 440bfd9087..41c677a412 100644 --- a/lib/AugmentedTemplateResponse.php +++ b/lib/AugmentedTemplateResponse.php @@ -11,10 +11,15 @@ use OC; use OCP\AppFramework\Http\TemplateResponse; +/** + * @psalm-template S of int + * @psalm-template H of array + * @psalm-implements TemplateResponse + */ class AugmentedTemplateResponse extends TemplateResponse { public function render() { $return = parent::render(); - $return = preg_replace('//i', '', $return); + preg_replace('//i', '', $return); return $return; } } diff --git a/lib/BackgroundJobs/EmptyTrashbinJob.php b/lib/BackgroundJobs/EmptyTrashbinJob.php new file mode 100644 index 0000000000..fb96e2da80 --- /dev/null +++ b/lib/BackgroundJobs/EmptyTrashbinJob.php @@ -0,0 +1,33 @@ +setInterval(self::INTERVAL); + $this->setTimeSensitivity(self::TIME_INSENSITIVE); + } + + protected function run($argument) { + $this->treeMapper->deleteOldTrashbinItems(self::BATCH_SIZE, self::TRASHBIN_TTL); + } +} diff --git a/lib/Controller/BookmarkController.php b/lib/Controller/BookmarkController.php index fc7df321c7..eb0cd35cb1 100644 --- a/lib/Controller/BookmarkController.php +++ b/lib/Controller/BookmarkController.php @@ -153,7 +153,7 @@ public function __construct( * * @return ((int|mixed)[]|mixed)[] * - * @psalm-return array{folders: array, tags: array|mixed} + * @psalm-return array{folders: array, tags: array|mixed, archivedFilePath?: mixed|string, archivedFileType?: mixed|string, ...} */ private function _returnBookmarkAsArray(Bookmark $bookmark): array { $array = $bookmark->toArray(); @@ -265,6 +265,8 @@ public function getSingleBookmark($id): JSONResponse { * @param bool|null $unavailable * @param bool|null $archived * @param bool|null $duplicated + * @param bool|null $recursive + * @param bool|null $deleted * @return DataResponse * * @NoAdminRequired @@ -286,6 +288,7 @@ public function getBookmarks( ?bool $archived = null, ?bool $duplicated = null, bool $recursive = false, + bool $deleted = false, ): DataResponse { $this->registerResponder('rss', function (DataResponse $res) { if ($res->getData()['status'] === 'success') { @@ -302,7 +305,9 @@ public function getBookmarks( 'description' => $description, 'bookmarks' => $bookmarks, ], ''); - $response->setHeaders($res->getHeaders()); + /** @var array $headers */ + $headers = $res->getHeaders(); + $response->setHeaders($headers); $response->setStatus($res->getStatus()); if (stripos($this->request->getHeader('accept'), 'application/rss+xml') !== false) { $response->addHeader('Content-Type', 'application/rss+xml'); @@ -343,6 +348,10 @@ public function getBookmarks( if ($duplicated !== null) { $params->setDuplicated($duplicated); } + // search soft deleted bookmarks + $params->setSoftDeleted($deleted); + // search bookmarks only in soft-deleted folders + $params->setSoftDeletedFolders($deleted); $params->setTags($filterTag); $params->setSearch($search); $params->setConjunction($conjunction); @@ -852,4 +861,25 @@ public function releaseLock(): JSONResponse { } return new JSONResponse(['status' => 'success']); } + + /** + * @return Http\DataResponse + * @NoAdminRequired + * @NoCSRFRequired + * + * @PublicPage + */ + public function getDeletedBookmarks(): DataResponse { + $this->authorizer->setCredentials($this->request); + if ($this->authorizer->getUserId() === null) { + return new Http\DataResponse(['status' => 'error', 'data' => 'Unauthorized'], Http::STATUS_FORBIDDEN); + } + try { + $bookmarks = $this->treeMapper->getSoftDeletedRootItems($this->authorizer->getUserId(), TreeMapper::TYPE_BOOKMARK); + } catch (UrlParseError|\OCP\DB\Exception $e) { + return new Http\DataResponse(['status' => 'error', 'data' => 'Internal error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + return new Http\DataResponse(['status' => 'success', 'data' => array_map(fn ($bookmark) => $this->_returnBookmarkAsArray($bookmark), $bookmarks)]); + } } diff --git a/lib/Controller/FoldersController.php b/lib/Controller/FoldersController.php index 5ec9a8da8f..fe5337026a 100644 --- a/lib/Controller/FoldersController.php +++ b/lib/Controller/FoldersController.php @@ -29,26 +29,12 @@ use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\JSONResponse; +use OCP\DB\Exception; use OCP\IUserManager; use Psr\Log\LoggerInterface; class FoldersController extends ApiController { - private string $userId; - - private FolderMapper $folderMapper; - private PublicFolderMapper $publicFolderMapper; - - private ShareMapper $shareMapper; - - private Authorizer $authorizer; - - private TreeMapper $treeMapper; private ?int $rootFolderId = null; - private TreeCacheManager $hashManager; - private FolderService $folders; - private BookmarkService $bookmarks; - private LoggerInterface $logger; - private IUserManager $userManager; /** * FoldersController constructor. @@ -66,20 +52,22 @@ class FoldersController extends ApiController { * @param LoggerInterface $logger * @param IUserManager $userManager */ - public function __construct($appName, $request, FolderMapper $folderMapper, PublicFolderMapper $publicFolderMapper, ShareMapper $shareMapper, TreeMapper $treeMapper, Authorizer $authorizer, TreeCacheManager $hashManager, FolderService $folders, BookmarkService $bookmarks, LoggerInterface $logger, IUserManager $userManager) { + public function __construct( + $appName, + $request, + private FolderMapper $folderMapper, + private PublicFolderMapper $publicFolderMapper, + private ShareMapper $shareMapper, + private TreeMapper $treeMapper, + private Authorizer $authorizer, + private TreeCacheManager $hashManager, + private FolderService $folders, + private BookmarkService $bookmarks, + private LoggerInterface $logger, + private IUserManager $userManager, + ) { parent::__construct($appName, $request); - $this->folderMapper = $folderMapper; - $this->publicFolderMapper = $publicFolderMapper; - $this->shareMapper = $shareMapper; - $this->treeMapper = $treeMapper; - $this->authorizer = $authorizer; - $this->hashManager = $hashManager; - $this->folders = $folders; - $this->bookmarks = $bookmarks; - $this->logger = $logger; - $this->authorizer->setCORS(true); - $this->userManager = $userManager; } /** @@ -246,28 +234,63 @@ public function addToFolder($folderId, $bookmarkId): JSONResponse { * * @PublicPage */ - public function removeFromFolder($folderId, $bookmarkId): JSONResponse { + public function removeFromFolder($folderId, $bookmarkId, bool $hardDelete = false): JSONResponse { if (!Authorizer::hasPermission(Authorizer::PERM_WRITE, $this->authorizer->getPermissionsForFolder($folderId, $this->request)) || !Authorizer::hasPermission(Authorizer::PERM_EDIT, $this->authorizer->getPermissionsForBookmark($bookmarkId, $this->request))) { return new JSONResponse(['status' => 'error', 'data' => 'Unauthorized'], Http::STATUS_FORBIDDEN); } try { $folderId = $this->toInternalFolderId($folderId); - $this->bookmarks->removeFromFolder($folderId, $bookmarkId); + $this->bookmarks->removeFromFolder($folderId, $bookmarkId, $hardDelete); } catch (DoesNotExistException $e) { return new JSONResponse(['status' => 'error', 'data' => 'Could not find folder'], Http::STATUS_BAD_REQUEST); } catch (MultipleObjectsReturnedException $e) { return new JSONResponse(['status' => 'error', 'data' => 'Multiple objects found'], Http::STATUS_INTERNAL_SERVER_ERROR); } catch (UnsupportedOperation $e) { return new JSONResponse(['status' => 'error', 'data' => 'Unsupported operation'], Http::STATUS_BAD_REQUEST); + } catch (Exception $e) { + return new JSONResponse(['status' => 'error', 'data' => 'Internal error'], Http::STATUS_INTERNAL_SERVER_ERROR); } return new JSONResponse(['status' => 'success']); } + /** + * @param int $folderId + * @param int $bookmarkId + * @return JSONResponse + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @PublicPage + */ + public function undeleteFromFolder(int $folderId, int $bookmarkId): JSONResponse { + if (!Authorizer::hasPermission(Authorizer::PERM_WRITE, $this->authorizer->getPermissionsForFolder($folderId, $this->request)) || + !Authorizer::hasPermission(Authorizer::PERM_EDIT, $this->authorizer->getPermissionsForBookmark($bookmarkId, $this->request))) { + return new JSONResponse(['status' => 'error', 'data' => 'Unauthorized'], Http::STATUS_FORBIDDEN); + } + try { + $folderId = $this->toInternalFolderId($folderId); + $this->bookmarks->undeleteInFolder($folderId, $bookmarkId); + return new JSONResponse(['status' => 'success']); + } catch (DoesNotExistException $e) { + return new JSONResponse(['status' => 'error', 'data' => 'Could not find folder'], Http::STATUS_NOT_FOUND); + } catch (MultipleObjectsReturnedException $e) { + return new JSONResponse(['status' => 'error', 'data' => 'Multiple objects found'], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (UnsupportedOperation $e) { + return new JSONResponse(['status' => 'error', 'data' => 'Unsupported operation'], Http::STATUS_BAD_REQUEST); + } catch (Exception $e) { + return new JSONResponse(['status' => 'error', 'data' => 'Internal error'], Http::STATUS_BAD_REQUEST); + } + } + + + /** * @param int $folderId + * @param bool $hardDelete * @return JSONResponse * * @NoAdminRequired @@ -275,7 +298,7 @@ public function removeFromFolder($folderId, $bookmarkId): JSONResponse { * * @PublicPage */ - public function deleteFolder($folderId): JSONResponse { + public function deleteFolder(int $folderId, bool $hardDelete = false): JSONResponse { if (!Authorizer::hasPermission(Authorizer::PERM_EDIT, $this->authorizer->getPermissionsForFolder($folderId, $this->request))) { return new JSONResponse(['status' => 'error', 'data' => 'Unauthorized'], Http::STATUS_FORBIDDEN); } @@ -285,7 +308,7 @@ public function deleteFolder($folderId): JSONResponse { return new JSONResponse(['status' => 'success']); } try { - $this->folders->deleteSharedFolderOrFolder($this->authorizer->getUserId(), $folderId); + $this->folders->deleteSharedFolderOrFolder($this->authorizer->getUserId(), $folderId, $hardDelete); return new JSONResponse(['status' => 'success']); } catch (UnsupportedOperation $e) { return new JSONResponse(['status' => 'error', 'data' => 'Unsupported operation'], Http::STATUS_INTERNAL_SERVER_ERROR); @@ -296,6 +319,35 @@ public function deleteFolder($folderId): JSONResponse { } } + /** + * @param int $folderId + * @return JSONResponse + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @PublicPage + */ + public function undeleteFolder(int $folderId): JSONResponse { + if (!Authorizer::hasPermission(Authorizer::PERM_EDIT, $this->authorizer->getPermissionsForFolder($folderId, $this->request))) { + return new JSONResponse(['status' => 'error', 'data' => 'Unauthorized'], Http::STATUS_FORBIDDEN); + } + + $folderId = $this->toInternalFolderId($folderId); + try { + $this->folders->undelete($this->authorizer->getUserId(), $folderId); + return new JSONResponse(['status' => 'success']); + } catch (UnsupportedOperation $e) { + return new JSONResponse(['status' => 'error', 'data' => 'Unsupported operation'], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (DoesNotExistException $e) { + return new JSONResponse(['status' => 'error', 'data' => 'Could not find folder'], Http::STATUS_NOT_FOUND); + } catch (MultipleObjectsReturnedException $e) { + return new JSONResponse(['status' => 'error', 'data' => 'Multiple objects found'], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (Exception $e) { + return new JSONResponse(['status' => 'error', 'data' => 'Internal error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + /** * @param int $folderId * @param string|null $title @@ -442,7 +494,7 @@ public function getFolders($root = -1, $layers = -1): JSONResponse { return new JSONResponse(['status' => 'error', 'data' => 'Unauthorized'], Http::STATUS_FORBIDDEN); } $internalRoot = $this->toInternalFolderId($root); - $folders = $this->treeMapper->getSubFolders($internalRoot, $layers); + $folders = $this->treeMapper->getSubFolders($internalRoot, $layers, $root === -1 ? false : null); if ($root === -1 || $root === '-1') { foreach ($folders as &$folder) { $folder['parent_folder'] = -1; @@ -710,4 +762,30 @@ public function deleteShare($shareId): DataResponse { } return new Http\DataResponse(['status' => 'success']); } + + /** + * @return Http\DataResponse + * @NoAdminRequired + * @NoCSRFRequired + * + * @PublicPage + */ + public function getDeletedFolders(): DataResponse { + $this->authorizer->setCredentials($this->request); + if ($this->authorizer->getUserId() === null) { + return new Http\DataResponse(['status' => 'error', 'data' => 'Unauthorized'], Http::STATUS_FORBIDDEN); + } + try { + $folders = $this->treeMapper->getSoftDeletedRootItems($this->authorizer->getUserId(), TreeMapper::TYPE_FOLDER); + $folderItems = array_map(function ($folder) { + $array = $folder->toArray(); + $array['children'] = $this->treeMapper->getSubFolders($folder->getId(), -1, true); + return $array; + }, $folders); + } catch (UrlParseError|Exception $e) { + return new Http\DataResponse(['status' => 'error', 'data' => 'Internal error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + return new Http\DataResponse(['status' => 'success', 'data' => $folderItems]); + } } diff --git a/lib/Controller/InternalBookmarkController.php b/lib/Controller/InternalBookmarkController.php index 67efe639fe..f16cee018d 100644 --- a/lib/Controller/InternalBookmarkController.php +++ b/lib/Controller/InternalBookmarkController.php @@ -58,6 +58,7 @@ public function __construct( * @param bool|null $unavailable * @param bool|null $archived * @param bool|null $duplicated + * @param bool|null $deleted * @return DataResponse * * @NoAdminRequired @@ -76,8 +77,9 @@ public function getBookmarks( $archived = null, $duplicated = null, bool $recursive = false, + bool $deleted = false, ): DataResponse { - return $this->publicController->getBookmarks($page, $tags, $conjunction, $sortby, $search, $limit, $untagged, $folder, $url, $unavailable, $archived, $duplicated, $recursive); + return $this->publicController->getBookmarks($page, $tags, $conjunction, $sortby, $search, $limit, $untagged, $folder, $url, $unavailable, $archived, $duplicated, $recursive, $deleted); } /** @@ -265,4 +267,13 @@ public function acquireLock(): JSONResponse { public function releaseLock(): JSONResponse { return $this->publicController->releaseLock(); } + + /** + * @return Http\DataResponse + * @NoAdminRequired + * @NoCSRFRequired + */ + public function getDeletedBookmarks(): DataResponse { + return $this->publicController->getDeletedBookmarks(); + } } diff --git a/lib/Controller/InternalFoldersController.php b/lib/Controller/InternalFoldersController.php index dfdaed8fa4..8dadc43ede 100644 --- a/lib/Controller/InternalFoldersController.php +++ b/lib/Controller/InternalFoldersController.php @@ -69,10 +69,20 @@ public function setFolderChildrenOrder($folderId, $data = []): JSONResponse { * * @NoAdminRequired */ - public function deleteFolder($folderId): JSONResponse { + public function deleteFolder(int $folderId): JSONResponse { return $this->controller->deleteFolder($folderId); } + /** + * @param int $folderId + * @return JSONResponse + * + * @NoAdminRequired + */ + public function undeleteFolder(int $folderId): JSONResponse { + return $this->controller->undeleteFolder($folderId); + } + /** * @param int $folderId * @param int $bookmarkId @@ -95,6 +105,17 @@ public function removeFromFolder($folderId, $bookmarkId): JSONResponse { return $this->controller->removeFromFolder($folderId, $bookmarkId); } + /** + * @param int $folderId + * @param int $bookmarkId + * @return JSONResponse + * + * @NoAdminRequired + */ + public function undeleteFromFolder(int $folderId, int $bookmarkId): JSONResponse { + return $this->controller->undeleteFromFolder($folderId, $bookmarkId); + } + /** * @param int $folderId * @param string|null $title @@ -221,4 +242,13 @@ public function editShare($shareId, $canWrite = false, $canShare = false): DataR public function deleteShare($shareId): DataResponse { return $this->controller->deleteShare($shareId); } + + /** + * @return DataResponse + * @NoAdminRequired + * @NoCSRFRequired + */ + public function getDeletedFolders(): DataResponse { + return $this->controller->getDeletedFolders(); + } } diff --git a/lib/Controller/WebViewController.php b/lib/Controller/WebViewController.php index a6716598d3..aff69cc65c 100644 --- a/lib/Controller/WebViewController.php +++ b/lib/Controller/WebViewController.php @@ -45,6 +45,7 @@ class WebViewController extends Controller { * @param IURLGenerator $urlGenerator * @param IInitialStateService $initialState * @param InternalFoldersController $folderController + * @param InternalBookmarkController $bookmarkController * @param UserSettingsService $userSettingsService */ public function __construct( @@ -58,6 +59,8 @@ public function __construct( private IURLGenerator $urlGenerator, private \OCP\IInitialStateService $initialState, private \OCA\Bookmarks\Controller\InternalFoldersController $folderController, + private \OCA\Bookmarks\Controller\InternalBookmarkController $bookmarkController, + private \OCA\Bookmarks\Controller\InternalTagsController $tagsController, private UserSettingsService $userSettingsService) { parent::__construct($appName, $request); $this->userId = $userId; @@ -80,8 +83,13 @@ public function index(): AugmentedTemplateResponse { $policy->addAllowedFrameDomain("'self'"); $res->setContentSecurityPolicy($policy); - // Provide complete folder hierarchy $this->initialState->provideInitialState($this->appName, 'folders', $this->folderController->getFolders()->getData()['data']); + $this->initialState->provideInitialState($this->appName, 'deletedFolders', $this->folderController->getDeletedFolders()->getData()['data']); + $this->initialState->provideInitialState($this->appName, 'duplicatedCount', $this->bookmarkController->countArchived()->getData()['item']); + $this->initialState->provideInitialState($this->appName, 'archivedCount', $this->bookmarkController->countDuplicated()->getData()['item']); + $this->initialState->provideInitialState($this->appName, 'unavailableCount', $this->bookmarkController->countUnavailable()->getData()['item']); + $this->initialState->provideInitialState($this->appName, 'allCount', $this->bookmarkController->countBookmarks(-1)->getData()['item']); + $this->initialState->provideInitialState($this->appName, 'tags', $this->tagsController->fullTags(true)->getData()); $settings = $this->userSettingsService->toArray(); $this->initialState->provideInitialState($this->appName, 'settings', $settings); diff --git a/lib/Db/BookmarkMapper.php b/lib/Db/BookmarkMapper.php index 8fac2b911e..fe6aba9724 100644 --- a/lib/Db/BookmarkMapper.php +++ b/lib/Db/BookmarkMapper.php @@ -90,7 +90,7 @@ public function __construct(IDBConnection $db, IEventDispatcher $eventDispatcher $this->eventDispatcher = $eventDispatcher; $this->urlNormalizer = $urlNormalizer; $this->config = $config; - $this->limit = (int)$config->getAppValue('bookmarks', 'performance.maxBookmarksperAccount', 0); + $this->limit = (int)$config->getAppValue('bookmarks', 'performance.maxBookmarksperAccount', '0'); $this->publicMapper = $publicMapper; $this->deleteTagsQuery = $this->getDeleteTagsQuery(); @@ -202,7 +202,7 @@ public function find(int $id): Bookmark { public function findAll(string $userId, QueryParameters $queryParams, bool $withGroupBy = true): array { $rootFolder = $this->folderMapper->findRootFolder($userId); // gives us all bookmarks in this folder, recursively - [$cte, $params, $paramTypes] = $this->_generateCTE($rootFolder->getId()); + [$cte, $params, $paramTypes] = $this->_generateCTE($rootFolder->getId(), $queryParams->getSoftDeletedFolders()); $qb = $this->db->getQueryBuilder(); $bookmark_cols = array_map(static function ($c) { @@ -213,14 +213,14 @@ public function findAll(string $userId, QueryParameters $queryParams, bool $with $qb->groupBy($bookmark_cols); if ($withGroupBy) { - $this->_selectFolders($qb); + $this->_selectFolders($qb, $queryParams->getSoftDeleted()); $this->_selectTags($qb); } $qb->automaticTablePrefix(false); $qb ->from('*PREFIX*bookmarks', 'b') - ->join('b', 'folder_tree', 'tree', 'tree.item_id = b.id AND tree.type = '.$qb->createPositionalParameter(TreeMapper::TYPE_BOOKMARK)); + ->innerJoin('b', 'folder_tree', 'tree', 'tree.item_id = b.id AND tree.type = ' . $qb->createPositionalParameter(TreeMapper::TYPE_BOOKMARK) . ($queryParams->getSoftDeleted() ? ' AND tree.soft_deleted_at is NOT NULL' : ' AND tree.soft_deleted_at is NULL')); $this->_filterUrl($qb, $queryParams); $this->_filterArchived($qb, $queryParams); @@ -261,14 +261,15 @@ protected function findEntitiesWithRawQuery(string $query, array $params, array * @param int $folderId * @return array */ - private function _generateCTE(int $folderId) : array { + private function _generateCTE(int $folderId, bool $withSoftDeleted) : array { // The base case of the recursion is just the folder we're given $baseCase = $this->db->getQueryBuilder(); $baseCase ->selectAlias($baseCase->createFunction($this->getDbType() === 'mysql'? 'cast('.$baseCase->createPositionalParameter($folderId, IQueryBuilder::PARAM_INT).' as UNSIGNED)' : 'cast('.$baseCase->createPositionalParameter($folderId, IQueryBuilder::PARAM_INT).' as BIGINT)'), 'item_id') ->selectAlias($baseCase->createFunction($this->getDbType() === 'mysql'? 'cast(0 as UNSIGNED)' : 'cast(0 as BIGINT)'), 'parent_folder') ->selectAlias($baseCase->createFunction($this->getDbType() === 'mysql'? 'cast('.$baseCase->createPositionalParameter(TreeMapper::TYPE_FOLDER).' as CHAR(20))' : 'cast('.$baseCase->createPositionalParameter(TreeMapper::TYPE_FOLDER).' as TEXT)'), 'type') - ->selectAlias($baseCase->createFunction($this->getDbType() === 'mysql'? 'cast(0 as UNSIGNED)' : 'cast(0 as BIGINT)'), 'idx'); + ->selectAlias($baseCase->createFunction($this->getDbType() === 'mysql'? 'cast(0 as UNSIGNED)' : 'cast(0 as BIGINT)'), 'idx') + ->selectAlias($baseCase->createFunction($this->getDbType() === 'mysql'? 'cast(NULL as DATETIME)' : 'cast(NULL as timestamp)'), 'soft_deleted_at'); // The first recursive case lists all children of folders we've already found $recursiveCase = $this->db->getQueryBuilder(); @@ -278,8 +279,9 @@ private function _generateCTE(int $folderId) : array { ->selectAlias('tr.parent_folder', 'parent_folder') ->selectAlias('tr.type', 'type') ->selectAlias('tr.index', 'idx') + ->selectAlias('tr.soft_deleted_at', 'soft_deleted_at') ->from('*PREFIX*bookmarks_tree', 'tr') - ->join('tr', $this->getDbType() === 'mysql'? 'folder_tree' : 'inner_folder_tree', 'e', 'e.item_id = tr.parent_folder AND e.type = '.$recursiveCase->createPositionalParameter(TreeMapper::TYPE_FOLDER)); + ->join('tr', $this->getDbType() === 'mysql'? 'folder_tree' : 'inner_folder_tree', 'e', 'e.item_id = tr.parent_folder AND e.type = '.$recursiveCase->createPositionalParameter(TreeMapper::TYPE_FOLDER) . (!$withSoftDeleted ? ' AND e.soft_deleted_at is NULL' : '')); // The second recursive case lists all children of shared folders we've already found $recursiveCaseShares = $this->db->getQueryBuilder(); @@ -289,12 +291,13 @@ private function _generateCTE(int $folderId) : array { ->addSelect('e.parent_folder') ->selectAlias($recursiveCaseShares->createFunction($recursiveCaseShares->createPositionalParameter(TreeMapper::TYPE_FOLDER)), 'type') ->selectAlias('e.idx', 'idx') + ->selectAlias('e.soft_deleted_at', 'soft_deleted_at') ->from(($this->getDbType() === 'mysql'? 'folder_tree' : 'second_folder_tree'), 'e') - ->join('e', '*PREFIX*bookmarks_shared_folders', 's', 's.id = e.item_id AND e.type = '.$recursiveCaseShares->createPositionalParameter(TreeMapper::TYPE_SHARE)); + ->join('e', '*PREFIX*bookmarks_shared_folders', 's', 's.id = e.item_id AND e.type = '.$recursiveCaseShares->createPositionalParameter(TreeMapper::TYPE_SHARE) . (!$withSoftDeleted ? ' AND e.soft_deleted_at is NULL' : '')); if ($this->getDbType() === 'mysql') { // For mysql we can just throw these three queries together in a CTE - $withRecursiveQuery = 'WITH RECURSIVE folder_tree(item_id, parent_folder, type, idx) AS ( ' . + $withRecursiveQuery = 'WITH RECURSIVE folder_tree(item_id, parent_folder, type, idx, soft_deleted_at) AS ( ' . $baseCase->getSQL() . ' UNION ALL ' . $recursiveCase->getSQL() . ' UNION ALL ' . $recursiveCaseShares->getSQL() . ')'; } else { @@ -304,13 +307,13 @@ private function _generateCTE(int $folderId) : array { $secondBaseCase = $this->db->getQueryBuilder(); $secondBaseCase->automaticTablePrefix(false); $secondBaseCase - ->select('item_id', 'parent_folder', 'type', 'idx') + ->select('item_id', 'parent_folder', 'type', 'idx', 'soft_deleted_at') ->from('inner_folder_tree'); $thirdBaseCase = $this->db->getQueryBuilder(); $thirdBaseCase->automaticTablePrefix(false); $thirdBaseCase - ->select('item_id', 'parent_folder', 'type', 'idx') + ->select('item_id', 'parent_folder', 'type', 'idx', 'soft_deleted_at') ->from('second_folder_tree'); $secondRecursiveCase = $this->db->getQueryBuilder(); @@ -320,17 +323,18 @@ private function _generateCTE(int $folderId) : array { ->selectAlias('tr.parent_folder', 'parent_folder') ->selectAlias('tr.type', 'type') ->selectAlias('tr.index', 'idx') + ->selectAlias('tr.soft_deleted_at', 'soft_deleted_at') ->from('*PREFIX*bookmarks_tree', 'tr') - ->join('tr', 'folder_tree', 'e', 'e.item_id = tr.parent_folder AND e.type = '.$secondRecursiveCase->createPositionalParameter(TreeMapper::TYPE_FOLDER)); + ->join('tr', 'folder_tree', 'e', 'e.item_id = tr.parent_folder AND e.type = '.$secondRecursiveCase->createPositionalParameter(TreeMapper::TYPE_FOLDER) . (!$withSoftDeleted ? ' AND e.soft_deleted_at is NULL' : '')); // First the base case together with the normal recurisve case // Then the second helper base case together with the recursive shares case // then we need another instance of the first recursive case, duplicated here as secondRecursive case // to recurse into child folders of shared folders // Note: This doesn't cover cases where a shared folder is inside a shared folder. - $withRecursiveQuery = 'WITH RECURSIVE folder_tree(item_id, parent_folder, type, idx) AS ( ' . - 'WITH RECURSIVE second_folder_tree(item_id, parent_folder, type, idx) AS (' . - 'WITH RECURSIVE inner_folder_tree(item_id, parent_folder, type, idx) AS ( ' . + $withRecursiveQuery = 'WITH RECURSIVE folder_tree(item_id, parent_folder, type, idx, soft_deleted_at) AS ( ' . + 'WITH RECURSIVE second_folder_tree(item_id, parent_folder, type, idx, soft_deleted_at) AS (' . + 'WITH RECURSIVE inner_folder_tree(item_id, parent_folder, type, idx, soft_deleted_at) AS ( ' . $baseCase->getSQL() . ' UNION ALL ' . $recursiveCase->getSQL() . ')' . ' ' . $secondBaseCase->getSQL() . ' UNION ALL '. $recursiveCaseShares->getSQL() .')'. ' ' . $thirdBaseCase->getSQL() . ' UNION ALL ' . $secondRecursiveCase->getSQL(). ')'; @@ -609,7 +613,7 @@ public function findAllInPublicFolder(string $token, QueryParameters $queryParam $folder = $this->folderMapper->find($publicFolder->getFolderId()); // gives us all bookmarks in this folder, recursively - [$cte, $params, $paramTypes] = $this->_generateCTE($folder->getId()); + [$cte, $params, $paramTypes] = $this->_generateCTE($folder->getId(), false); $qb = $this->db->getQueryBuilder(); $qb->automaticTablePrefix(false); @@ -621,13 +625,13 @@ public function findAllInPublicFolder(string $token, QueryParameters $queryParam $qb->groupBy($bookmark_cols); if ($withGroupBy) { - $this->_selectFolders($qb); + $this->_selectFolders($qb, false); $this->_selectTags($qb); } $qb ->from('*PREFIX*bookmarks', 'b') - ->join('b', 'folder_tree', 'tree', 'tree.item_id = b.id AND tree.type = '.$qb->createPositionalParameter(TreeMapper::TYPE_BOOKMARK)); + ->join('b', 'folder_tree', 'tree', 'tree.item_id = b.id AND tree.type = ' . $qb->createPositionalParameter(TreeMapper::TYPE_BOOKMARK) . 'AND tree.soft_deleted_at is NULL'); $this->_filterUrl($qb, $queryParams); @@ -799,8 +803,8 @@ private function getDbType(): string { /** * @param IQueryBuilder $qb */ - private function _selectFolders(IQueryBuilder $qb): void { - $qb->leftJoin('b', '*PREFIX*bookmarks_tree', 'tr2', 'b.id = tr2.id AND tr2.type = '.$qb->createPositionalParameter(TreeMapper::TYPE_BOOKMARK)); + private function _selectFolders(IQueryBuilder $qb, bool $isSoftDeleted): void { + $qb->leftJoin('b', '*PREFIX*bookmarks_tree', 'tr2', 'b.id = tr2.id AND tr2.type = '.$qb->createPositionalParameter(TreeMapper::TYPE_BOOKMARK) . ($isSoftDeleted ? ' AND tr2.soft_deleted_at is NOT NULL' : ' AND tr2.soft_deleted_at is NULL')); if ($this->getDbType() === 'pgsql') { $folders = $qb->createFunction('array_to_string(array_agg(' . $qb->getColumnName('tr2.parent_folder') . "), ',')"); } else { diff --git a/lib/Db/Share.php b/lib/Db/Share.php index 730f352095..1e096125be 100644 --- a/lib/Db/Share.php +++ b/lib/Db/Share.php @@ -61,7 +61,7 @@ public function __construct() { /** * @return array * - * @psalm-return array{id: mixed, folderId: mixed, owner: mixed, participant: mixed, type: mixed, canWrite: mixed, canShare: mixed, createdAt: mixed} + * @psalm-return array{id: mixed, folderId: mixed, owner: mixed, participant: mixed, type: mixed, canWrite: mixed, canShare: mixed, createdAt: mixed, participantDisplayName: string} */ public function toArray(): array { return [ diff --git a/lib/Db/SharedFolderMapper.php b/lib/Db/SharedFolderMapper.php index 539538a993..d43ea24887 100644 --- a/lib/Db/SharedFolderMapper.php +++ b/lib/Db/SharedFolderMapper.php @@ -185,10 +185,11 @@ public function findByOwnerAndUser(string $owner, string $userId): array { /** * @param int $shareId - * @param int $userId + * @param string $userId * @return SharedFolder * @throws DoesNotExistException * @throws MultipleObjectsReturnedException + * @throws Exception */ public function findByShareAndUser(int $shareId, string $userId): SharedFolder { $qb = $this->db->getQueryBuilder(); diff --git a/lib/Db/TagMapper.php b/lib/Db/TagMapper.php index a91781f075..b42c05db9c 100644 --- a/lib/Db/TagMapper.php +++ b/lib/Db/TagMapper.php @@ -42,7 +42,7 @@ public function findAllWithCount($userId): array { ->selectAlias($qb->createFunction('COUNT(DISTINCT ' . $qb->getColumnName('t.bookmark_id') . ')'), 'count') ->from('bookmarks_tags', 't') ->innerJoin('t', 'bookmarks', 'b', $qb->expr()->eq('b.id', 't.bookmark_id')) - ->leftJoin('b', 'bookmarks_tree', 'tr', 'b.id = tr.id AND tr.type = '.$qb->createPositionalParameter(TreeMapper::TYPE_BOOKMARK)) + ->innerJoin('b', 'bookmarks_tree', 'tr', 'b.id = tr.id AND tr.type = '.$qb->createPositionalParameter(TreeMapper::TYPE_BOOKMARK) . ' AND tr.soft_deleted_at IS NULL') ->leftJoin('tr', 'bookmarks_shared_folders', 'sf', $qb->expr()->eq('tr.parent_folder', 'sf.folder_id')) ->where($qb->expr()->eq('b.user_id', $qb->createPositionalParameter($userId))) ->orWhere($qb->expr()->andX( diff --git a/lib/Db/TreeMapper.php b/lib/Db/TreeMapper.php index 6fa789a6b6..aa24e3ace4 100644 --- a/lib/Db/TreeMapper.php +++ b/lib/Db/TreeMapper.php @@ -8,29 +8,31 @@ namespace OCA\Bookmarks\Db; use OCA\Bookmarks\Events\BeforeDeleteEvent; +use OCA\Bookmarks\Events\BeforeSoftDeleteEvent; +use OCA\Bookmarks\Events\BeforeSoftUndeleteEvent; use OCA\Bookmarks\Events\MoveEvent; use OCA\Bookmarks\Events\UpdateEvent; use OCA\Bookmarks\Exception\ChildrenOrderValidationError; use OCA\Bookmarks\Exception\UnsupportedOperation; use OCA\Bookmarks\Exception\UrlParseError; +use OCA\Bookmarks\QueryParameters; use OCA\Bookmarks\Service\TreeCacheManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\Entity; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Db\QBMapper; +use OCP\AppFramework\Utility\ITimeFactory; use OCP\DB\Exception; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\EventDispatcher\IEventDispatcher; -use OCP\IConfig; use OCP\IDBConnection; use OCP\IUserManager; use PDO; +use Psr\Log\LoggerInterface; use function call_user_func; /** - * Class TreeMapper - * - * @package OCA\Bookmarks\Db + * @psalm-extends QBMapper */ class TreeMapper extends QBMapper { public const TYPE_SHARE = 'share'; @@ -38,44 +40,31 @@ class TreeMapper extends QBMapper { public const TYPE_BOOKMARK = 'bookmark'; protected $entityClasses = [ - self::TYPE_SHARE => SharedFolder::class, - self::TYPE_FOLDER => Folder::class, - self::TYPE_BOOKMARK => Bookmark::class, + TreeMapper::TYPE_SHARE => SharedFolder::class, + TreeMapper::TYPE_FOLDER => Folder::class, + TreeMapper::TYPE_BOOKMARK => Bookmark::class, ]; protected $entityTables = [ - self::TYPE_SHARE => 'bookmarks_shared_folders', - self::TYPE_FOLDER => 'bookmarks_folders', - self::TYPE_BOOKMARK => 'bookmarks', + TreeMapper::TYPE_SHARE => 'bookmarks_shared_folders', + TreeMapper::TYPE_FOLDER => 'bookmarks_folders', + TreeMapper::TYPE_BOOKMARK => 'bookmarks', ]; protected $entityColumns = []; - private IEventDispatcher $eventDispatcher; - - protected BookmarkMapper $bookmarkMapper; - - protected FolderMapper $folderMapper; - - protected TreeCacheManager $treeCache; - - private ShareMapper $shareMapper; - - private SharedFolderMapper $sharedFolderMapper; - - private PublicFolderMapper $publicFolderMapper; private IQueryBuilder $insertQuery; private IQueryBuilder $parentQuery; private array $getChildrenQuery; + private array $getSoftDeletedChildrenQuery; private IQueryBuilder $getChildrenOrderQuery; - private IUserManager $userManager; /** - * FolderMapper constructor. + * TreeMapper constructor. * * @param IDBConnection $db * @param IEventDispatcher $eventDispatcher @@ -83,38 +72,45 @@ class TreeMapper extends QBMapper { * @param BookmarkMapper $bookmarkMapper * @param ShareMapper $shareMapper * @param SharedFolderMapper $sharedFolderMapper - * @param TagMapper $tagMapper - * @param IConfig $config * @param PublicFolderMapper $publicFolderMapper * @param TreeCacheManager $treeCache * @param IUserManager $userManager + * @param ITimeFactory $timeFactory */ - public function __construct(IDBConnection $db, IEventDispatcher $eventDispatcher, FolderMapper $folderMapper, BookmarkMapper $bookmarkMapper, ShareMapper $shareMapper, SharedFolderMapper $sharedFolderMapper, PublicFolderMapper $publicFolderMapper, TreeCacheManager $treeCache, IUserManager $userManager) { + public function __construct( + IDBConnection $db, + private IEventDispatcher $eventDispatcher, + private FolderMapper $folderMapper, + private BookmarkMapper $bookmarkMapper, + private ShareMapper $shareMapper, + private SharedFolderMapper $sharedFolderMapper, + private PublicFolderMapper $publicFolderMapper, + private TreeCacheManager $treeCache, + private IUserManager $userManager, + private ITimeFactory $timeFactory, + private LoggerInterface $logger, + ) { parent::__construct($db, 'bookmarks_tree'); - $this->eventDispatcher = $eventDispatcher; - $this->folderMapper = $folderMapper; - $this->bookmarkMapper = $bookmarkMapper; - $this->shareMapper = $shareMapper; - $this->sharedFolderMapper = $sharedFolderMapper; $this->entityColumns = [ - self::TYPE_SHARE => SharedFolder::$columns, - self::TYPE_FOLDER => Folder::$columns, - self::TYPE_BOOKMARK => Bookmark::$columns, + TreeMapper::TYPE_SHARE => SharedFolder::$columns, + TreeMapper::TYPE_FOLDER => Folder::$columns, + TreeMapper::TYPE_BOOKMARK => Bookmark::$columns, ]; - $this->publicFolderMapper = $publicFolderMapper; $this->insertQuery = $this->getInsertQuery(); $this->parentQuery = $this->getParentQuery(); $this->getChildrenOrderQuery = $this->getGetChildrenOrderQuery(); $this->getChildrenQuery = [ - self::TYPE_BOOKMARK => $this->getFindChildrenQuery(self::TYPE_BOOKMARK), - self::TYPE_FOLDER => $this->getFindChildrenQuery(self::TYPE_FOLDER), - self::TYPE_SHARE => $this->getFindChildrenQuery(self::TYPE_SHARE) + TreeMapper::TYPE_BOOKMARK => $this->getFindChildrenQuery(TreeMapper::TYPE_BOOKMARK), + TreeMapper::TYPE_FOLDER => $this->getFindChildrenQuery(TreeMapper::TYPE_FOLDER), + TreeMapper::TYPE_SHARE => $this->getFindChildrenQuery(TreeMapper::TYPE_SHARE) + ]; + $this->getSoftDeletedChildrenQuery = [ + TreeMapper::TYPE_BOOKMARK => $this->getFindSoftDeletedChildrenQuery(TreeMapper::TYPE_BOOKMARK), + TreeMapper::TYPE_FOLDER => $this->getFindSoftDeletedChildrenQuery(TreeMapper::TYPE_FOLDER), + TreeMapper::TYPE_SHARE => $this->getFindSoftDeletedChildrenQuery(TreeMapper::TYPE_SHARE) ]; - - $this->treeCache = $treeCache; - $this->userManager = $userManager; } /** @@ -136,8 +132,8 @@ protected function mapRowToEntityWithClass(array $row, string $entityClass): Ent * @param IQueryBuilder $query * @param string $type * @psalm-param T $type - * @psalm-template T as self::TYPE_* - * @psalm-template E as (T is self::TYPE_FOLDER ? Folder : (T is self::TYPE_BOOKMARK ? Bookmark : SharedFolder)) + * @psalm-template T as TreeMapper::TYPE_* + * @psalm-template E as (T is TreeMapper::TYPE_FOLDER ? Folder : (T is TreeMapper::TYPE_BOOKMARK ? Bookmark : SharedFolder)) * @return Entity[] all fetched entities * @psalm-return list */ @@ -163,8 +159,8 @@ protected function findEntitiesWithType(IQueryBuilder $query, string $type): arr * @param string $type * @psalm-param T $type * @return E the entity - * @psalm-template T as self::TYPE_* - * @psalm-template E as (T is self::TYPE_FOLDER ? Folder : (T is self::TYPE_BOOKMARK ? Bookmark : SharedFolder)) + * @psalm-template T as TreeMapper::TYPE_* + * @psalm-template E as (T is TreeMapper::TYPE_FOLDER ? Folder : (T is TreeMapper::TYPE_BOOKMARK ? Bookmark : SharedFolder)) * @throws DoesNotExistException if the item does not exist * @throws MultipleObjectsReturnedException if more than one item exist */ @@ -203,7 +199,7 @@ protected function getInsertQuery(): IQueryBuilder { } protected function getParentQuery(): IQueryBuilder { - $qb = $this->selectFromType(self::TYPE_FOLDER); + $qb = $this->selectFromType(TreeMapper::TYPE_FOLDER); $qb ->join('i', 'bookmarks_tree', 't', $qb->expr()->eq('t.parent_folder', 'i.id')) ->where($qb->expr()->eq('t.id', $qb->createParameter('id'))) @@ -217,6 +213,7 @@ protected function getGetChildrenOrderQuery(): IQueryBuilder { ->select('id', 'type', 'index') ->from('bookmarks_tree') ->where($qb->expr()->eq('parent_folder', $qb->createParameter('parent_folder'))) + ->andWhere($qb->expr()->isNull('soft_deleted_at')) ->orderBy('index', 'ASC'); return $qb; } @@ -227,30 +224,42 @@ protected function getFindChildrenQuery(string $type): IQueryBuilder { ->join('i', 'bookmarks_tree', 't', $qb->expr()->eq('t.id', 'i.id')) ->where($qb->expr()->eq('t.parent_folder', $qb->createParameter('parent_folder'))) ->andWhere($qb->expr()->eq('t.type', $qb->createNamedParameter($type))) + ->andWhere($qb->expr()->isNull('t.soft_deleted_at')) ->orderBy('t.index', 'ASC'); return $qb; } + protected function getFindSoftDeletedChildrenQuery(string $type): IQueryBuilder { + $qb = $this->selectFromType($type); + $qb + ->join('i', 'bookmarks_tree', 't', $qb->expr()->eq('t.id', 'i.id')) + ->where($qb->expr()->eq('t.parent_folder', $qb->createParameter('parent_folder'))) + ->andWhere($qb->expr()->eq('t.type', $qb->createNamedParameter($type))) + ->andWhere($qb->expr()->isNotNull('t.soft_deleted_at')) + ->orderBy('t.index', 'ASC'); + return $qb; + } + /** - * @param int $folderId * @param string $type * @psalm-param T $type - * + * @param int $folderId + * @param bool $softDeleted * @return Entity[] - * @psalm-template T as self::TYPE_* - * @psalm-template E as (T is self::TYPE_FOLDER ? Folder : (T is self::TYPE_BOOKMARK ? Bookmark : SharedFolder)) - * - * @psalm-return list + * @psalm-return E[] + * @psalm-template T as TreeMapper::TYPE_* + * @psalm-template E as (T is TreeMapper::TYPE_FOLDER ? Folder : (T is TreeMapper::TYPE_BOOKMARK ? Bookmark : SharedFolder)) */ - public function findChildren(string $type, int $folderId): array { - $qb = $this->selectFromType($type, [], $this->getChildrenQuery[$type]); + public function findChildren(string $type, int $folderId, ?bool $softDeleted = null): array { + $listSoftDeleted = $softDeleted ?? $this->isEntrySoftDeleted($type, $folderId); + $qb = $this->selectFromType($type, [], !$listSoftDeleted ? $this->getChildrenQuery[$type] : $this->getSoftDeletedChildrenQuery[$type]); $qb->setParameter('parent_folder', $folderId); return $this->findEntitiesWithType($qb, $type); } /** * @param string $type - * @psalm-param self::TYPE_* $type + * @psalm-param TreeMapper::TYPE_* $type * @param int $itemId * @return Entity * @psalm-return Folder @@ -263,12 +272,12 @@ public function findParentOf(string $type, int $itemId): Entity { 'id' => $itemId, 'type' => $type, ]); - return $this->findEntityWithType($qb, self::TYPE_FOLDER); + return $this->findEntityWithType($qb, TreeMapper::TYPE_FOLDER); } /** * @param string $type - * @psalm-param self::TYPE_* $type + * @psalm-param TreeMapper::TYPE_* $type * @param int $itemId * * @return Entity[] @@ -280,7 +289,7 @@ public function findParentsOf(string $type, int $itemId): array { 'id' => $itemId, 'type' => $type, ]); - return $this->findEntitiesWithType($qb, self::TYPE_FOLDER); + return $this->findEntitiesWithType($qb, TreeMapper::TYPE_FOLDER); } /** @@ -289,8 +298,8 @@ public function findParentsOf(string $type, int $itemId): array { * @param int $folderId * @return Entity[] * @psalm-return E[] - * @psalm-template T as self::TYPE_* - * @psalm-template E as (T is self::TYPE_FOLDER ? Folder : (T is self::TYPE_BOOKMARK ? Bookmark : SharedFolder)) + * @psalm-template T as TreeMapper::TYPE_* + * @psalm-template E as (T is TreeMapper::TYPE_FOLDER ? Folder : (T is TreeMapper::TYPE_BOOKMARK ? Bookmark : SharedFolder)) */ public function findByAncestorFolder(string $type, int $folderId): array { $descendants = []; @@ -306,7 +315,7 @@ public function findByAncestorFolder(string $type, int $folderId): array { /** * @param int $folderId - * @param self::TYPE_* $type + * @param TreeMapper::TYPE_* $type * @param int $descendantId * @return bool */ @@ -316,7 +325,7 @@ public function hasDescendant(int $folderId, string $type, int $descendantId): b return $ancestor->getId(); }, $ancestors), true)) { $ancestors = array_flatten(array_map(function (Entity $ancestor) { - return $this->findParentsOf(self::TYPE_FOLDER, $ancestor->getId()); + return $this->findParentsOf(TreeMapper::TYPE_FOLDER, $ancestor->getId()); }, $ancestors)); if (count($ancestors) === 0) { return false; @@ -328,7 +337,7 @@ public function hasDescendant(int $folderId, string $type, int $descendantId): b /** * @param string $type - * @psalm-param self::TYPE_* $type + * @psalm-param TreeMapper::TYPE_* $type * @param int $id * @param int|null $folderId * @return void @@ -339,15 +348,15 @@ public function hasDescendant(int $folderId, string $type, int $descendantId): b public function deleteEntry(string $type, int $id, ?int $folderId = null): void { $this->eventDispatcher->dispatch(BeforeDeleteEvent::class, new BeforeDeleteEvent($type, $id)); - if ($type === self::TYPE_FOLDER) { + if ($type === TreeMapper::TYPE_FOLDER) { // First get all shares out of the way - $descendantShares = $this->findByAncestorFolder(self::TYPE_SHARE, $id); + $descendantShares = $this->findByAncestorFolder(TreeMapper::TYPE_SHARE, $id); foreach ($descendantShares as $share) { - $this->deleteEntry(self::TYPE_SHARE, $share->getId(), $id); + $this->deleteEntry(TreeMapper::TYPE_SHARE, $share->getId(), $id); } // then get all folders in this sub tree - $descendantFolders = $this->findByAncestorFolder(self::TYPE_FOLDER, $id); + $descendantFolders = $this->findByAncestorFolder(TreeMapper::TYPE_FOLDER, $id); $folder = $this->folderMapper->find($id); $descendantFolders[] = $folder; @@ -355,7 +364,7 @@ public function deleteEntry(string $type, int $id, ?int $folderId = null): void $qb = $this->db->getQueryBuilder(); $qb ->delete('bookmarks_tree') - ->where($qb->expr()->eq('type', $qb->createPositionalParameter(self::TYPE_BOOKMARK))) + ->where($qb->expr()->eq('type', $qb->createPositionalParameter(TreeMapper::TYPE_BOOKMARK))) ->andWhere($qb->expr()->in('parent_folder', $qb->createPositionalParameter(array_map(static function ($folder) { return $folder->getId(); }, $descendantFolders), IQueryBuilder::PARAM_INT_ARRAY))); @@ -364,7 +373,7 @@ public function deleteEntry(string $type, int $id, ?int $folderId = null): void // remove all folders entries from this subtree foreach ($descendantFolders as $descendantFolder) { $this->removeFolderTangibles($descendantFolder->getId()); - $this->remove(self::TYPE_FOLDER, $descendantFolder->getId()); + $this->remove(TreeMapper::TYPE_FOLDER, $descendantFolder->getId()); $this->folderMapper->delete($descendantFolder); } @@ -372,7 +381,7 @@ public function deleteEntry(string $type, int $id, ?int $folderId = null): void $qb = $this->db->getQueryBuilder(); $qb->select('b.id') ->from('bookmarks', 'b') - ->leftJoin('b', 'bookmarks_tree', 't', 'b.id = t.id AND t.type = '.$qb->createPositionalParameter(self::TYPE_BOOKMARK)) + ->leftJoin('b', 'bookmarks_tree', 't', 'b.id = t.id AND t.type = '.$qb->createPositionalParameter(TreeMapper::TYPE_BOOKMARK)) ->where($qb->expr()->isNull('t.id')); $orphanedBookmarks = $qb->execute(); while ($bookmark = $orphanedBookmarks->fetchColumn()) { @@ -385,21 +394,157 @@ public function deleteEntry(string $type, int $id, ?int $folderId = null): void return; } - if ($type === self::TYPE_SHARE) { + if ($type === TreeMapper::TYPE_SHARE) { $this->remove($type, $id); // This will only be removed if the share is removed! //$sharedFolder = $this->sharedFolderMapper->find($id); //$this->sharedFolderMapper->delete($sharedFolder); } - if ($type === self::TYPE_BOOKMARK) { - $this->removeFromFolders(self::TYPE_BOOKMARK, $id, [$folderId]); + if ($type === TreeMapper::TYPE_BOOKMARK) { + $this->removeFromFolders(TreeMapper::TYPE_BOOKMARK, $id, [$folderId]); + } + } + + /** + * @param string $type + * @psalm-param TreeMapper::TYPE_* $type + * @param int $id + * @param int|null $folderId + * @return void + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws UnsupportedOperation + * @throws Exception + */ + public function softDeleteEntry(string $type, int $id, ?int $folderId = null): void { + $this->eventDispatcher->dispatchTyped(new BeforeSoftDeleteEvent($type, $id)); + + if ($type === TreeMapper::TYPE_FOLDER) { + // First get all shares out of the way + $descendantShares = $this->findByAncestorFolder(TreeMapper::TYPE_SHARE, $id); + foreach ($descendantShares as $share) { + $this->softDeleteEntry(TreeMapper::TYPE_SHARE, $share->getId()); + } + + // then get all folders in this sub tree + $descendantFolders = $this->findByAncestorFolder(TreeMapper::TYPE_FOLDER, $id); + $folder = $this->folderMapper->find($id); + $descendantFoldersPlusThisFolder = [...$descendantFolders, $folder]; + + // soft delete all descendant bookmarks entries from this subtree + $qb = $this->db->getQueryBuilder(); + $qb + ->update('bookmarks_tree') + ->set('soft_deleted_at', $qb->createNamedParameter($this->timeFactory->getDateTime(), IQueryBuilder::PARAM_DATE)) + ->where($qb->expr()->eq('type', $qb->createNamedParameter(TreeMapper::TYPE_BOOKMARK))) + ->andWhere($qb->expr()->in('parent_folder', $qb->createNamedParameter(array_map(static function ($folder) { + return $folder->getId(); + }, $descendantFoldersPlusThisFolder), IQueryBuilder::PARAM_INT_ARRAY))); + $qb->execute(); + + // soft delete all folder entries from this subtree + foreach ($descendantFoldersPlusThisFolder as $descendantFolder) { + // set entry as deleted + // this has to come last, because otherwise findByAncestorFolder doesn't work anymore + $qb = $this->db->getQueryBuilder(); + $qb + ->update('bookmarks_tree') + ->set('soft_deleted_at', $qb->createNamedParameter($this->timeFactory->getDateTime(), IQueryBuilder::PARAM_DATE)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($descendantFolder->getId(), IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('type', $qb->createNamedParameter($type))); + $qb->executeStatement(); + } + + return; + } + + // set entry as deleted + $qb = $this->db->getQueryBuilder(); + $qb + ->update('bookmarks_tree') + ->set('soft_deleted_at', $qb->createNamedParameter($this->timeFactory->getDateTime(), IQueryBuilder::PARAM_DATE)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('type', $qb->createNamedParameter($type))); + if ($folderId !== null) { + $qb->andWhere($qb->expr()->eq('parent_folder', $qb->createNamedParameter($folderId, IQueryBuilder::PARAM_INT))); } + $qb->executeStatement(); } /** * @param string $type - * @psalm-param self::TYPE_* $type + * @psalm-param TreeMapper::TYPE_* $type + * @param int $id + * @param int|null $folderId + * @return void + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws UnsupportedOperation + * @throws Exception + */ + public function softUndeleteEntry(string $type, int $id, ?int $folderId = null): void { + $this->eventDispatcher->dispatchTyped(new BeforeSoftUndeleteEvent($type, $id)); + + if ($type === TreeMapper::TYPE_FOLDER) { + // First get all shares out of the way + $descendantShares = $this->findByAncestorFolder(TreeMapper::TYPE_SHARE, $id); + foreach ($descendantShares as $share) { + $this->softUndeleteEntry(TreeMapper::TYPE_SHARE, $share->getId()); + } + + // then get all folders in this sub tree + $descendantFolders = $this->findByAncestorFolder(TreeMapper::TYPE_FOLDER, $id); + $folder = $this->folderMapper->find($id); + $descendantFoldersPlusThisFolder = [...$descendantFolders, $folder]; + + $foldersToUndeleteFrom = array_map(static function ($folder) { + return $folder->getId(); + }, $descendantFoldersPlusThisFolder); + + // undelete all descendant bookmarks entries from this subtree + $qb = $this->db->getQueryBuilder(); + $qb + ->update('bookmarks_tree') + ->set('soft_deleted_at', $qb->createNamedParameter(null, IQueryBuilder::PARAM_DATE)) + ->where($qb->expr()->eq('type', $qb->createNamedParameter(TreeMapper::TYPE_BOOKMARK))) + ->andWhere($qb->expr()->in('parent_folder', $qb->createNamedParameter($foldersToUndeleteFrom, IQueryBuilder::PARAM_INT_ARRAY))); + $qb->executeStatement(); + + // soft delete all folder entries from this subtree + foreach ($descendantFoldersPlusThisFolder as $descendantFolder) { + // set entry as not deleted + $qb = $this->db->getQueryBuilder(); + $qb + ->update('bookmarks_tree') + ->set('soft_deleted_at', $qb->createNamedParameter(null, IQueryBuilder::PARAM_DATE)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($descendantFolder->getId(), IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('type', $qb->createNamedParameter(TreeMapper::TYPE_FOLDER, IQueryBuilder::PARAM_STR))); + $qb->executeStatement(); + } + + return; + } + + // set entry as not deleted + // has to come last to not break findByAncestorFolder + $qb = $this->db->getQueryBuilder(); + $qb + ->update('bookmarks_tree') + ->set('soft_deleted_at', $qb->createNamedParameter(null, IQueryBuilder::PARAM_DATE)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_STR))); + if ($folderId !== null) { + $qb->set('index', $qb->createNamedParameter($this->countChildren($folderId))); + $qb->andWhere($qb->expr()->eq('parent_folder', $qb->createNamedParameter($folderId, IQueryBuilder::PARAM_INT))); + } + $qb->executeStatement(); + + } + + /** + * @param string $type + * @psalm-param TreeMapper::TYPE_* $type * @param int $itemId * @return void * @throws Exception @@ -424,14 +569,14 @@ public function deleteShare(int $shareId): void { $sharedFolders = $this->sharedFolderMapper->findByShare($shareId); foreach ($sharedFolders as $sharedFolder) { $this->sharedFolderMapper->delete($sharedFolder); - $this->deleteEntry(self::TYPE_SHARE, $sharedFolder->getId()); + $this->deleteEntry(TreeMapper::TYPE_SHARE, $sharedFolder->getId()); } $this->shareMapper->delete($share); } /** * @param string $type - * @psalm-param self::TYPE_* $type + * @psalm-param TreeMapper::TYPE_* $type * @param int $itemId * @param int $newParentFolderId * @param int|null $index @@ -440,7 +585,7 @@ public function deleteShare(int $shareId): void { * @throws UnsupportedOperation */ public function move(string $type, int $itemId, int $newParentFolderId, ?int $index = null): void { - if ($type === self::TYPE_BOOKMARK) { + if ($type === TreeMapper::TYPE_BOOKMARK) { throw new UnsupportedOperation('Cannot move Bookmark'); } try { @@ -450,7 +595,7 @@ public function move(string $type, int $itemId, int $newParentFolderId, ?int $in $currentParent = null; } - if ($type !== self::TYPE_SHARE) { + if ($type !== TreeMapper::TYPE_SHARE) { $folderId = $itemId; } else { $sharedFolder = $this->sharedFolderMapper->find($itemId); @@ -465,7 +610,7 @@ public function move(string $type, int $itemId, int $newParentFolderId, ?int $in } } - if ($this->hasDescendant($folderId, self::TYPE_FOLDER, $newParentFolderId)) { + if ($this->hasDescendant($folderId, TreeMapper::TYPE_FOLDER, $newParentFolderId)) { throw new UnsupportedOperation('Cannot nest a folder inside one of its descendants'); } @@ -504,7 +649,7 @@ public function move(string $type, int $itemId, int $newParentFolderId, ?int $in /** * @brief Add a bookmark to a set of folders * @param string $type - * @psalm-param self::TYPE_BOOKMARK $type + * @psalm-param TreeMapper::TYPE_BOOKMARK $type * @param int $itemId * @param array $folders Set of folders ids to add the bookmark to * @throws DoesNotExistException @@ -512,7 +657,7 @@ public function move(string $type, int $itemId, int $newParentFolderId, ?int $in * @throws UnsupportedOperation|Exception */ public function setToFolders(string $type, int $itemId, array $folders): void { - if ($type !== self::TYPE_BOOKMARK) { + if ($type !== TreeMapper::TYPE_BOOKMARK) { throw new UnsupportedOperation('Only bookmarks can be in multiple folders'); } if (count($folders) === 0) { @@ -531,14 +676,14 @@ public function setToFolders(string $type, int $itemId, array $folders): void { /** * @brief Add a bookmark to a set of folders * @param string $type - * @psalm-param self::TYPE_BOOKMARK $type + * @psalm-param TreeMapper::TYPE_BOOKMARK $type * @param int $itemId The bookmark reference * @param array $folders Set of folders ids to add the bookmark to * @param int|null $index * @throws UnsupportedOperation|Exception */ public function addToFolders(string $type, int $itemId, array $folders, ?int $index = null): void { - if ($type !== self::TYPE_BOOKMARK) { + if ($type !== TreeMapper::TYPE_BOOKMARK) { throw new UnsupportedOperation('Only bookmarks can be in multiple folders'); } $currentFolders = array_map(static function (Folder $f) { @@ -566,7 +711,7 @@ public function addToFolders(string $type, int $itemId, array $folders, ?int $in /** * @brief Remove a bookmark from a set of folders * @param string $type - * @psalm-param self::TYPE_BOOKMARK $type + * @psalm-param TreeMapper::TYPE_BOOKMARK $type * @param int $itemId The bookmark reference * @param array $folders Set of folders ids to add the bookmark to * @throws DoesNotExistException @@ -574,7 +719,7 @@ public function addToFolders(string $type, int $itemId, array $folders, ?int $in * @throws UnsupportedOperation|Exception */ public function removeFromFolders(string $type, int $itemId, array $folders): void { - if ($type !== self::TYPE_BOOKMARK) { + if ($type !== TreeMapper::TYPE_BOOKMARK) { throw new UnsupportedOperation('Only bookmarks can be in multiple folders'); } $foldersLeft = count($this->findParentsOf($type, $itemId)); @@ -625,7 +770,7 @@ public function setChildrenOrder(int $folderId, array $newChildrenOrder): void { ->from('bookmarks_shared_folders', 's') ->innerJoin('s', 'bookmarks_tree', 't', $qb->expr()->eq('t.id', 's.id')) ->where($qb->expr()->eq('t.parent_folder', $qb->createPositionalParameter($folderId))) - ->andWhere($qb->expr()->eq('t.type', $qb->createPositionalParameter(self::TYPE_SHARE))) + ->andWhere($qb->expr()->eq('t.type', $qb->createPositionalParameter(TreeMapper::TYPE_SHARE))) ->orderBy('t.index', 'ASC'); $childShares = $qb->execute()->fetchAll(); @@ -635,12 +780,12 @@ public function setChildrenOrder(int $folderId, array $newChildrenOrder): void { }, []); foreach ($newChildrenOrder as $i => $child) { - if (!in_array($child['type'], [self::TYPE_FOLDER, self::TYPE_BOOKMARK], true)) { + if (!in_array($child['type'], [TreeMapper::TYPE_FOLDER, TreeMapper::TYPE_BOOKMARK], true)) { continue; } - if (($child['type'] === self::TYPE_FOLDER) && isset($foldersToShares[$child['id']])) { - $child['type'] = self::TYPE_SHARE; + if (($child['type'] === TreeMapper::TYPE_FOLDER) && isset($foldersToShares[$child['id']])) { + $child['type'] = TreeMapper::TYPE_SHARE; $child['id'] = $foldersToShares[$child['id']]; } @@ -654,7 +799,7 @@ public function setChildrenOrder(int $folderId, array $newChildrenOrder): void { $qb->execute(); } - $this->eventDispatcher->dispatch(UpdateEvent::class, new UpdateEvent(self::TYPE_FOLDER, $folderId)); + $this->eventDispatcher->dispatch(UpdateEvent::class, new UpdateEvent(TreeMapper::TYPE_FOLDER, $folderId)); } /** @@ -677,20 +822,20 @@ public function getChildrenOrder(int $folderId, int $layers = 0): array { $qb->setParameter('parent_folder', $folderId); $children = $qb->execute()->fetchAll(); - $qb = $this->getChildrenQuery[self::TYPE_SHARE]; - $this->selectFromType(self::TYPE_SHARE, ['t.index'], $qb); + $qb = $this->getChildrenQuery[TreeMapper::TYPE_SHARE]; + $this->selectFromType(TreeMapper::TYPE_SHARE, ['t.index'], $qb); $qb->setParameter('parent_folder', $folderId); - $childShares = $qb->execute()->fetchAll(); + $childShares = $qb->execute()->fetchAll() ?? []; $children = array_map(function ($child) use ($layers, $childShares) { $item = ['type' => $child['type'], 'id' => (int)$child['id']]; - if ($item['type'] === self::TYPE_SHARE) { - $item['type'] = self::TYPE_FOLDER; + if ($item['type'] === TreeMapper::TYPE_SHARE) { + $item['type'] = TreeMapper::TYPE_FOLDER; $item['id'] = (int)array_shift($childShares)['folder_id']; } - if ($item['type'] === self::TYPE_FOLDER && $layers !== 0) { + if ($item['type'] === TreeMapper::TYPE_FOLDER && $layers !== 0) { $item['children'] = $this->getChildrenOrder($item['id'], $layers - 1); } return $item; @@ -703,29 +848,54 @@ public function getChildrenOrder(int $folderId, int $layers = 0): array { return $children; } + public function isEntrySoftDeleted(string $type, int $id, ?int $folderId = null) { + $qb = $this->db->getQueryBuilder(); + $qb + ->select('soft_deleted_at') + ->from('bookmarks_tree') + ->where($qb->expr()->eq('id', $qb->createPositionalParameter($id, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('type', $qb->createPositionalParameter($type, IQueryBuilder::PARAM_STR))) + ->setMaxResults(1); + if ($folderId !== null) { + $qb->andWhere($qb->expr()->eq('parent_folder', $qb->createPositionalParameter($folderId, IQueryBuilder::PARAM_INT))); + } + $result = $qb->executeQuery(); + $results = $result->fetchAll(); + return count($results) >= 1 && $results[0]['soft_deleted_at'] !== null; + } + /** * @param int $folderId * @param int $layers [-1, inf] + * @param bool|null $isSoftDeleted * * @return array * * @psalm-return list */ - public function getSubFolders(int $folderId, $layers = 0): array { - $folders = $this->treeCache->get(TreeCacheManager::CATEGORY_SUBFOLDERS, TreeMapper::TYPE_FOLDER, $folderId); - if ($folders !== null) { - return $folders; + public function getSubFolders(int $folderId, $layers = 0, ?bool $isSoftDeleted = null): array { + $isSoftDeleted = $isSoftDeleted ?? $this->isEntrySoftDeleted(TreeMapper::TYPE_FOLDER, $folderId); + if (!$isSoftDeleted) { + $folders = $this->treeCache->get(TreeCacheManager::CATEGORY_SUBFOLDERS, TreeMapper::TYPE_FOLDER, $folderId); + if ($folders !== null) { + return $folders; + } + } else { + $folders = $this->treeCache->get(TreeCacheManager::CATEGORY_DELETED_SUBFOLDERS, TreeMapper::TYPE_FOLDER, $folderId); + if ($folders !== null) { + return $folders; + } } - $folders = array_map(function (Folder $folder) use ($layers, $folderId) { + $folders = array_map(function (Folder $folder) use ($layers, $folderId, $isSoftDeleted) { $array = $folder->toArray(); $array['userDisplayName'] = $this->userManager->get($array['userId'])->getDisplayName(); $array['parent_folder'] = $folderId; if ($layers !== 0) { - $array['children'] = $this->getSubFolders($folder->getId(), $layers - 1); + $array['children'] = $this->getSubFolders($folder->getId(), $layers - 1, $isSoftDeleted); } return $array; - }, $this->findChildren(self::TYPE_FOLDER, $folderId)); - $shares = array_map(function (SharedFolder $sharedFolder) use ($layers, $folderId) { + }, $this->findChildren(TreeMapper::TYPE_FOLDER, $folderId, $isSoftDeleted)); + $shares = array_map(function (SharedFolder $sharedFolder) use ($layers, $folderId, $isSoftDeleted) { $share = $this->shareMapper->findBySharedFolder($sharedFolder->getId()); $array = $sharedFolder->toArray(); $array['id'] = $share->getFolderId(); @@ -733,30 +903,83 @@ public function getSubFolders(int $folderId, $layers = 0): array { $array['userDisplayName'] = $this->userManager->get($array['userId'])->getDisplayName(); $array['parent_folder'] = $folderId; if ($layers !== 0) { - $array['children'] = $this->getSubFolders($share->getFolderId(), $layers - 1); + $array['children'] = $this->getSubFolders($share->getFolderId(), $layers - 1, $isSoftDeleted); } return $array; - }, $this->findChildren(self::TYPE_SHARE, $folderId)); + }, $this->findChildren(TreeMapper::TYPE_SHARE, $folderId, $isSoftDeleted)); if (count($shares) > 0) { array_push($folders, ...$shares); } if ($layers < 0) { - $this->treeCache->set(TreeCacheManager::CATEGORY_SUBFOLDERS, TreeMapper::TYPE_FOLDER, $folderId, $folders); + if (!$isSoftDeleted) { + $this->treeCache->set(TreeCacheManager::CATEGORY_SUBFOLDERS, TreeMapper::TYPE_FOLDER, $folderId, $folders); + } else { + $this->treeCache->set(TreeCacheManager::CATEGORY_DELETED_SUBFOLDERS, TreeMapper::TYPE_FOLDER, $folderId, $folders); + } } return $folders; } + /** + * @param string $userId + * @param string $type + * @psalm-param T $type + * @return array + * @psalm-return E[] + * @psalm-template T as TreeMapper::TYPE_* + * @psalm-template E as (T is TreeMapper::TYPE_FOLDER ? Folder : (T is TreeMapper::TYPE_BOOKMARK ? Bookmark : SharedFolder)) + * @throws UrlParseError|Exception + */ + public function getSoftDeletedRootItems(string $userId, string $type): array { + if ($type === TreeMapper::TYPE_FOLDER || $type === TreeMapper::TYPE_SHARE) { + $qb = $this->selectFromType($type); + $qb + ->join('i', 'bookmarks_tree', 't', $qb->expr()->eq('t.id', 'i.id')) + ->where($qb->expr()->isNotNull('t.soft_deleted_at')) + ->andWhere($qb->expr()->eq('t.type', $qb->createPositionalParameter($type, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('i.user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR))); + $items = $this->findEntitiesWithType($qb, $type); + + if ($type === TreeMapper::TYPE_SHARE) { + return $items; + } + + $topmostFolders = []; + foreach ($items as $folder) { + $topmostFolders[$folder->getId()] = $folder; + } + + foreach ($items as $folder1) { + foreach ($items as $folder2) { + if ($folder1->getId() !== $folder2->getId() && $this->hasDescendant($folder1->getId(), TreeMapper::TYPE_FOLDER, $folder2->getId())) { + $topmostFolders[$folder2->getId()] = false; + } + } + } + + return array_filter(array_values($topmostFolders), fn ($value) => $value !== false); + } + if ($type === TreeMapper::TYPE_BOOKMARK) { + $params = new QueryParameters(); + $params->setSoftDeleted(true); + $params->setSoftDeletedFolders(false); + return $this->bookmarkMapper->findAll($userId, $params); + } + throw new \RuntimeException('Given item type does not exist'); + } + /** * @brief Count the children in the given folder * @param int $folderId - * @return mixed + * @return int */ - public function countChildren(int $folderId) { + public function countChildren(int $folderId): int { $qb = $this->db->getQueryBuilder(); $qb ->select($qb->func()->count('index', 'count')) ->from('bookmarks_tree') - ->where($qb->expr()->eq('parent_folder', $qb->createPositionalParameter($folderId))); + ->where($qb->expr()->eq('parent_folder', $qb->createPositionalParameter($folderId))) + ->andWhere($qb->expr()->isNull('soft_deleted_at')); return $qb->execute()->fetch(PDO::FETCH_COLUMN); } @@ -776,7 +999,8 @@ public function countBookmarksInFolder(int $folderId): int { ->from('bookmarks', 'b') ->innerJoin('b', 'bookmarks_tree', 't', $qb->expr()->eq('t.id', 'b.id')) ->where($qb->expr()->eq('t.parent_folder', $qb->createPositionalParameter($folderId))) - ->andWhere($qb->expr()->eq('t.type', $qb->createPositionalParameter(self::TYPE_BOOKMARK))); + ->andWhere($qb->expr()->eq('t.type', $qb->createPositionalParameter(TreeMapper::TYPE_BOOKMARK))) + ->andWhere($qb->expr()->isNull('t.soft_deleted_at')); $countChildren = $qb->execute()->fetch(PDO::FETCH_COLUMN); $qb = $this->db->getQueryBuilder(); @@ -785,7 +1009,9 @@ public function countBookmarksInFolder(int $folderId): int { ->from('bookmarks_folders', 'f') ->innerJoin('f', 'bookmarks_tree', 't', $qb->expr()->eq('t.id', 'f.id')) ->where($qb->expr()->eq('t.parent_folder', $qb->createPositionalParameter($folderId))) - ->andWhere($qb->expr()->eq('t.type', $qb->createPositionalParameter(self::TYPE_FOLDER))); + ->andWhere($qb->expr()->eq('t.type', $qb->createPositionalParameter(TreeMapper::TYPE_FOLDER))) + ->andWhere($qb->expr()->isNull('t.soft_deleted_at')); + ; $childFolders = $qb->execute()->fetchAll(PDO::FETCH_COLUMN); foreach ($childFolders as $subFolderId) { @@ -798,7 +1024,7 @@ public function countBookmarksInFolder(int $folderId): int { /** * @return array * - * @psalm-return list + * @psalm-return list, id: int, type: 'bookmark'|'folder'}>, id: int, title: mixed, type: 'folder'|'bookmark', userId: string, ...}> */ public function getChildren(int $folderId, int $layers = 0): array { $children = $this->treeCache->get(TreeCacheManager::CATEGORY_CHILDREN, TreeMapper::TYPE_FOLDER, $folderId); @@ -809,18 +1035,18 @@ public function getChildren(int $folderId, int $layers = 0): array { $children = $this->treeCache->get(TreeCacheManager::CATEGORY_CHILDREN_LAYER, TreeMapper::TYPE_FOLDER, $folderId); if ($children === null) { - $qb = $this->getChildrenQuery[self::TYPE_BOOKMARK]; - $this->selectFromType(self::TYPE_BOOKMARK, ['t.index', 't.type'], $qb); + $qb = $this->getChildrenQuery[TreeMapper::TYPE_BOOKMARK]; + $this->selectFromType(TreeMapper::TYPE_BOOKMARK, ['t.index', 't.type'], $qb); $qb->setParameter('parent_folder', $folderId); $childBookmarks = $qb->execute()->fetchAll(); - $qb = $this->getChildrenQuery[self::TYPE_FOLDER]; - $this->selectFromType(self::TYPE_FOLDER, ['t.index', 't.type'], $qb); + $qb = $this->getChildrenQuery[TreeMapper::TYPE_FOLDER]; + $this->selectFromType(TreeMapper::TYPE_FOLDER, ['t.index', 't.type'], $qb); $qb->setParameter('parent_folder', $folderId); $childFolders = $qb->execute()->fetchAll(); - $qb = $this->getChildrenQuery[self::TYPE_SHARE]; - $this->selectFromType(self::TYPE_SHARE, ['t.index', 't.type'], $qb); + $qb = $this->getChildrenQuery[TreeMapper::TYPE_SHARE]; + $this->selectFromType(TreeMapper::TYPE_SHARE, ['t.index', 't.type'], $qb); $qb->setParameter('parent_folder', $folderId); $childShares = $qb->execute()->fetchAll(); @@ -834,16 +1060,16 @@ public function getChildren(int $folderId, int $layers = 0): array { $children = array_map(function ($child) use ($layers) { $item = ['type' => $child['type'], 'id' => (int)$child['id'], 'title' => $child['title'], 'userId' => $child['user_id']]; - if ($item['type'] === self::TYPE_SHARE) { - $item['type'] = self::TYPE_FOLDER; + if ($item['type'] === TreeMapper::TYPE_SHARE) { + $item['type'] = TreeMapper::TYPE_FOLDER; $item['id'] = (int)$child['folder_id']; } - if ($item['type'] === self::TYPE_BOOKMARK) { + if ($item['type'] === TreeMapper::TYPE_BOOKMARK) { $item = array_merge(Bookmark::fromRow(array_intersect_key($child, array_flip(Bookmark::$columns)))->toArray(), $item); } - if ($item['type'] === self::TYPE_FOLDER && $layers !== 0) { + if ($item['type'] === TreeMapper::TYPE_FOLDER && $layers !== 0) { $item['children'] = $this->getChildren($item['id'], $layers - 1); } @@ -921,7 +1147,7 @@ public function isFolderSharedWithUser(int $folderId, string $userId): bool { // noop } - $ancestors = $this->findParentsOf(self::TYPE_FOLDER, $folderId); + $ancestors = $this->findParentsOf(TreeMapper::TYPE_FOLDER, $folderId); foreach ($ancestors as $ancestorFolder) { try { $this->sharedFolderMapper->findByFolderAndUser($ancestorFolder->getId(), $userId); @@ -943,7 +1169,7 @@ public function containsSharedFolderFromUser(Folder $folder, string $userId): bo $sharedFolders = $this->sharedFolderMapper->findByOwnerAndUser($userId, $folder->getUserId()); foreach ($sharedFolders as $sharedFolder) { - if ($this->hasDescendant($folder->getId(), self::TYPE_SHARE, $sharedFolder->getId())) { + if ($this->hasDescendant($folder->getId(), TreeMapper::TYPE_SHARE, $sharedFolder->getId())) { return true; } } @@ -963,7 +1189,7 @@ public function containsFoldersSharedToUser(Folder $folder, string $userId): boo if ($folder->getId() === $sharedFolder->getFolderId()) { return true; } - if ($this->hasDescendant($folder->getId(), self::TYPE_FOLDER, $sharedFolder->getFolderId())) { + if ($this->hasDescendant($folder->getId(), TreeMapper::TYPE_FOLDER, $sharedFolder->getFolderId())) { return true; } } @@ -971,4 +1197,33 @@ public function containsFoldersSharedToUser(Folder $folder, string $userId): boo return false; } + /** + * @param int $limit + * @param float|int $maxAge + * @return void + */ + public function deleteOldTrashbinItems(int $limit, float|int $maxAge): void { + $qb = $this->db->getQueryBuilder(); + $qb->select('type', 'id', 'parent_folder')->from('bookmarks_tree'); + $qb->where($qb->expr()->neq('type', $qb->createNamedParameter(TreeMapper::TYPE_SHARE, IQueryBuilder::PARAM_STR))); + $cutoffDate = $this->timeFactory->getDateTime(); + $cutoffDate->modify('- ' . $maxAge . ' seconds'); + $qb->andWhere($qb->expr()->lt('soft_deleted_at', $qb->createNamedParameter($cutoffDate, IQueryBuilder::PARAM_DATE))); + $qb->setMaxResults($limit); + try { + $result = $qb->executeQuery(); + } catch (Exception $e) { + $this->logger->error('Could not query for old trash bin items', ['exception' => $e]); + } + while($row = $result->fetch()) { + try { + $this->deleteEntry($row['type'], $row['id'], $row['parent_folder']); + } catch (DoesNotExistException $e) { + // noop + } catch (UnsupportedOperation|MultipleObjectsReturnedException $e) { + $this->logger->error('Could not delete old trash bin item: ' . var_export($row, true), ['exception' => $e]); + } + } + } + } diff --git a/lib/Events/BeforeSoftDeleteEvent.php b/lib/Events/BeforeSoftDeleteEvent.php new file mode 100644 index 0000000000..fb098b4bdc --- /dev/null +++ b/lib/Events/BeforeSoftDeleteEvent.php @@ -0,0 +1,11 @@ + + * @psalm-extends Response + */ class ExportResponse extends Response { private $returnstring; diff --git a/lib/Hooks/BeforeTemplateRenderedListener.php b/lib/Hooks/BeforeTemplateRenderedListener.php index 0ad9ab8418..e321e876cc 100644 --- a/lib/Hooks/BeforeTemplateRenderedListener.php +++ b/lib/Hooks/BeforeTemplateRenderedListener.php @@ -13,6 +13,9 @@ use OCP\IRequest; use OCP\Util; +/** + * @psalm-implements IEventListener + */ class BeforeTemplateRenderedListener implements IEventListener { private $request; public function __construct(IRequest $request) { diff --git a/lib/Hooks/UsersGroupsCirclesListener.php b/lib/Hooks/UsersGroupsCirclesListener.php index ee6c9b3bf8..6a02d2ca31 100644 --- a/lib/Hooks/UsersGroupsCirclesListener.php +++ b/lib/Hooks/UsersGroupsCirclesListener.php @@ -143,7 +143,7 @@ private function removeParticipantFromShare(Share $share, int $type, string $par } elseif ($type === IShare::TYPE_USER) { try { $sharedFoldersToDelete = $this->sharedFolderMapper->findByShareAndUser($share->getId(), $participant); - } catch (DoesNotExistException|MultipleObjectsReturnedException $e) { + } catch (DoesNotExistException|MultipleObjectsReturnedException|Exception $e) { return; } foreach ($sharedFoldersToDelete as $sharedFolder) { @@ -180,7 +180,7 @@ private function addParticipantToShare(Share $share, int $type, string $particip $this->sharedFolderMapper->findByShareAndUser($share->getId(), $participant); // if this does not throw, the user already has this folder return; - } catch (DoesNotExistException|MultipleObjectsReturnedException $e) { + } catch (DoesNotExistException|MultipleObjectsReturnedException|Exception $e) { // noop } try { diff --git a/lib/Migration/GroupSharesUpdateRepairStep.php b/lib/Migration/GroupSharesUpdateRepairStep.php index 67695c503b..7378d5e17d 100644 --- a/lib/Migration/GroupSharesUpdateRepairStep.php +++ b/lib/Migration/GroupSharesUpdateRepairStep.php @@ -114,7 +114,7 @@ public function run(IOutput $output) { $notInGroupUsers = array_diff($usersInShare, $usersInGroup); foreach ($notInGroupUsers as $userId) { - $this->folders->deleteSharedFolderOrFolder($userId, $groupShare['folder_id']); + $this->folders->deleteSharedFolderOrFolder($userId, $groupShare['folder_id'], true); $sharedFolder = $this->sharedFolderMapper->findByFolderAndUser($groupShare['folder_id'], $userId); $this->sharedFolderMapper->delete($sharedFolder); $deleted++; diff --git a/lib/Migration/Version014001000Date20240524124721.php b/lib/Migration/Version014001000Date20240524124721.php new file mode 100644 index 0000000000..152e41e1f6 --- /dev/null +++ b/lib/Migration/Version014001000Date20240524124721.php @@ -0,0 +1,39 @@ +hasTable('bookmarks_tree')) { + $table = $schema->getTable('bookmarks_tree'); + $table->addColumn('soft_deleted_at', Types::DATETIME, ['notnull' => false]); + $table->addIndex(['soft_deleted_at', 'id', 'type', 'parent_folder'], 'bookmarks_tree_deleted'); + } + + return $schema; + } + +} diff --git a/lib/QueryParameters.php b/lib/QueryParameters.php index 40e4e394a3..d28dfb39d1 100644 --- a/lib/QueryParameters.php +++ b/lib/QueryParameters.php @@ -13,19 +13,21 @@ class QueryParameters { public const CONJ_AND = 'and'; public const CONJ_OR = 'or'; - private $limit = 10; - private $offset = 0; + private int $limit = 10; + private int $offset = 0; private $sortBy; private $conjunction = self::CONJ_AND; private $folder; - private $url; - private $untagged = false; - private $unavailable = false; - private $archived = false; - private $duplicated = false; + private ?string $url = null; + private bool $untagged = false; + private bool $unavailable = false; + private bool $archived = false; + private bool $duplicated = false; private $search = []; private $tags = []; private bool $recursive = false; + private bool $softDeleted = false; + private bool $softDeletedFolders = false; /** * @return array @@ -258,4 +260,36 @@ public function setRecursive(bool $recursive): self { public function getRecursive(): bool { return $this->recursive; } + + /** + * @return bool + */ + public function getSoftDeleted(): bool { + return $this->softDeleted; + } + + /** + * @param bool $softDeleted + * @return $this + */ + public function setSoftDeleted(bool $softDeleted): QueryParameters { + $this->softDeleted = $softDeleted; + return $this; + } + + /** + * @return bool + */ + public function getSoftDeletedFolders(): bool { + return $this->softDeletedFolders; + } + + /** + * @param bool $softDeletedFolders + * @return $this + */ + public function setSoftDeletedFolders(bool $softDeletedFolders): QueryParameters { + $this->softDeletedFolders = $softDeletedFolders; + return $this; + } } diff --git a/lib/Service/BookmarkService.php b/lib/Service/BookmarkService.php index c20f549b3b..22577dea56 100644 --- a/lib/Service/BookmarkService.php +++ b/lib/Service/BookmarkService.php @@ -25,6 +25,7 @@ use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\BackgroundJob\IJobList; +use OCP\DB\Exception; use OCP\EventDispatcher\IEventDispatcher; class BookmarkService { @@ -374,9 +375,14 @@ public function update(string $userId, $id, ?string $url = null, ?string $title * @throws DoesNotExistException * @throws MultipleObjectsReturnedException * @throws UnsupportedOperation + * @throws Exception */ - public function removeFromFolder(int $folderId, int $bookmarkId): void { - $this->treeMapper->removeFromFolders(TreeMapper::TYPE_BOOKMARK, $bookmarkId, [$folderId]); + public function removeFromFolder(int $folderId, int $bookmarkId, bool $hardDelete = false): void { + if ($hardDelete) { + $this->treeMapper->removeFromFolders(TreeMapper::TYPE_BOOKMARK, $bookmarkId, [$folderId]); + } else { + $this->treeMapper->softDeleteEntry(TreeMapper::TYPE_BOOKMARK, $bookmarkId, $folderId); + } } /** @@ -407,6 +413,17 @@ public function addToFolder(int $folderId, int $bookmarkId): void { } } + /** + * @param int $folderId + * @param int $bookmarkId + * @throws DoesNotExistException|MultipleObjectsReturnedException|UnsupportedOperation|Exception + */ + public function undeleteInFolder(int $folderId, int $bookmarkId): void { + $this->folderMapper->find($folderId); + $this->bookmarkMapper->find($bookmarkId); + $this->treeMapper->softUndeleteEntry(TreeMapper::TYPE_BOOKMARK, $bookmarkId, $folderId); + } + /** * @param int $id * @throws DoesNotExistException diff --git a/lib/Service/BookmarksParser.php b/lib/Service/BookmarksParser.php index 8798075f12..e9b14ed537 100644 --- a/lib/Service/BookmarksParser.php +++ b/lib/Service/BookmarksParser.php @@ -279,7 +279,7 @@ private function getAttributes(DOMNode $node): array { } if (isset($attributes['last_modified'])) { $modified = new DateTime(); - $modified->setTimestamp((int)$attributes['last_modified']); + $modified->setTimestamp($attributes['last_modified'] instanceof DateTime ? $attributes['last_modified']->getTimestamp() : (int)$attributes['last_modified']); $attributes['last_modified'] = $modified; } } diff --git a/lib/Service/FolderService.php b/lib/Service/FolderService.php index 610aa084a7..1202fc907d 100644 --- a/lib/Service/FolderService.php +++ b/lib/Service/FolderService.php @@ -135,38 +135,45 @@ public function findSharedFolderOrFolder($userId, $folderId) { } /** - * @param $userId - * @param $folderId + * @param string $userId + * @param int $folderId * @throws DoesNotExistException * @throws MultipleObjectsReturnedException * @throws UnsupportedOperation + * @throws Exception */ - public function deleteSharedFolderOrFolder($userId, $folderId): void { - /** - * @var $folder Folder - */ + public function deleteSharedFolderOrFolder(string $userId, int $folderId, bool $hardDelete): void { $folder = $this->folderMapper->find($folderId); if ($userId === null || $userId === $folder->getUserId()) { - $this->treeMapper->deleteEntry(TreeMapper::TYPE_FOLDER, $folder->getId()); + if ($hardDelete) { + $this->treeMapper->deleteEntry(TreeMapper::TYPE_FOLDER, $folder->getId()); + } else { + $this->treeMapper->softDeleteEntry(TreeMapper::TYPE_FOLDER, $folder->getId()); + } return; } try { // folder is shared folder - /** - * @var $sharedFolder SharedFolder - */ $sharedFolder = $this->sharedFolderMapper->findByFolderAndUser($folder->getId(), $userId); - $this->treeMapper->deleteEntry(TreeMapper::TYPE_SHARE, $sharedFolder->getId()); + if ($hardDelete) { + $this->treeMapper->deleteEntry(TreeMapper::TYPE_SHARE, $sharedFolder->getId()); + } else { + $this->treeMapper->softDeleteEntry(TreeMapper::TYPE_SHARE, $sharedFolder->getId()); + } return; } catch (DoesNotExistException $e) { // noop } // folder is subfolder of share - $this->treeMapper->deleteEntry(TreeMapper::TYPE_FOLDER, $folder->getId()); - $this->folderMapper->delete($folder); + if ($hardDelete) { + $this->treeMapper->deleteEntry(TreeMapper::TYPE_FOLDER, $folder->getId()); + $this->folderMapper->delete($folder); + } else { + $this->treeMapper->softDeleteEntry(TreeMapper::TYPE_FOLDER, $folder->getId()); + } } /** @@ -179,6 +186,31 @@ public function deleteShare($shareId): void { $this->treeMapper->deleteShare($shareId); } + /** + * @throws UnsupportedOperation + * @throws MultipleObjectsReturnedException + * @throws DoesNotExistException|Exception + */ + public function undelete(?string $userId, int $folderId): void { + $folder = $this->folderMapper->find($folderId); + if ($userId === null || $userId === $folder->getUserId()) { + $this->treeMapper->softUndeleteEntry(TreeMapper::TYPE_FOLDER, $folderId); + return; + } + + try { + // folder is shared folder + $sharedFolder = $this->sharedFolderMapper->findByFolderAndUser($folder->getId(), $userId); + $this->treeMapper->softUndeleteEntry(TreeMapper::TYPE_SHARE, $sharedFolder->getId()); + return; + } catch (DoesNotExistException $e) { + // noop + } + + // folder is subfolder of share + $this->treeMapper->softUndeleteEntry(TreeMapper::TYPE_FOLDER, $folder->getId()); + } + /** * @param string $userId * @param int $folderId diff --git a/lib/Service/HtmlExporter.php b/lib/Service/HtmlExporter.php index 7caef48fa0..664955dad2 100644 --- a/lib/Service/HtmlExporter.php +++ b/lib/Service/HtmlExporter.php @@ -57,14 +57,14 @@ public function __construct(BookmarkMapper $bookmarkMapper, FolderMapper $folder } /** - * @param int $userId + * @param string $userId * @param int $folderId * @return string * @throws UnauthorizedAccessError * @throws DoesNotExistException * @throws MultipleObjectsReturnedException */ - public function exportFolder($userId, int $folderId): string { + public function exportFolder(string $userId, int $folderId): string { $file = ' Bookmarks'; @@ -75,7 +75,7 @@ public function exportFolder($userId, int $folderId): string { } /** - * @param int $userId + * @param string $userId * @param int $id * @param bool $onlyContent * @return string @@ -83,7 +83,7 @@ public function exportFolder($userId, int $folderId): string { * @throws MultipleObjectsReturnedException * @throws UnauthorizedAccessError */ - protected function serializeFolder($userId, int $id, bool $onlyContent = false): string { + protected function serializeFolder(string $userId, int $id, bool $onlyContent = false): string { if ($onlyContent) { $output = ''; } else { diff --git a/lib/Service/HtmlImporter.php b/lib/Service/HtmlImporter.php index a203d3a5f4..e3fafac927 100644 --- a/lib/Service/HtmlImporter.php +++ b/lib/Service/HtmlImporter.php @@ -77,7 +77,7 @@ public function __construct(BookmarkMapper $bookmarkMapper, FolderMapper $folder /** * @brief Import Bookmarks from html formatted file * - * @param int $userId + * @param string $userId * @param string $file * @param int|null $rootFolder * @@ -90,7 +90,7 @@ public function __construct(BookmarkMapper $bookmarkMapper, FolderMapper $folder * @throws UserLimitExceededError * @throws HtmlParseError */ - public function importFile($userId, string $file, ?int $rootFolder = null): array { + public function importFile(string $userId, string $file, ?int $rootFolder = null): array { $content = file_get_contents($file); return $this->import($userId, $content, $rootFolder); } @@ -98,7 +98,7 @@ public function importFile($userId, string $file, ?int $rootFolder = null): arra /** * @brief Import Bookmarks from html * - * @param int $userId + * @param string $userId * @param string $content * @param int|null $rootFolderId * @@ -109,11 +109,11 @@ public function importFile($userId, string $file, ?int $rootFolder = null): arra * @throws HtmlParseError * @throws MultipleObjectsReturnedException * @throws UnauthorizedAccessError - * @throws UserLimitExceededError + * @throws UserLimitExceededError|UnsupportedOperation * * @psalm-return array{imported: list, errors: array} */ - public function import($userId, string $content, ?int $rootFolderId = null): array { + public function import(string $userId, string $content, ?int $rootFolderId = null): array { $imported = []; $errors = []; diff --git a/lib/Service/TreeCacheManager.php b/lib/Service/TreeCacheManager.php index d3fba12e8a..cb0da3a0de 100644 --- a/lib/Service/TreeCacheManager.php +++ b/lib/Service/TreeCacheManager.php @@ -12,6 +12,7 @@ use OCA\Bookmarks\Db\FolderMapper; use OCA\Bookmarks\Db\SharedFolderMapper; use OCA\Bookmarks\Db\ShareMapper; +use OCA\Bookmarks\Db\TagMapper; use OCA\Bookmarks\Db\TreeMapper; use OCA\Bookmarks\Events\ChangeEvent; use OCA\Bookmarks\Events\MoveEvent; @@ -25,34 +26,19 @@ use Psr\Container\ContainerInterface; use UnexpectedValueException; +/** + * @psalm-implements IEventListener + */ class TreeCacheManager implements IEventListener { + public const TTL = 60 * 60 * 24 * 30; // one month public const CATEGORY_HASH = 'hashes'; public const CATEGORY_SUBFOLDERS = 'subFolders'; + public const CATEGORY_DELETED_SUBFOLDERS = 'deletedSubFolders'; public const CATEGORY_FOLDERCOUNT = 'folderCount'; public const CATEGORY_CHILDREN = 'children'; public const CATEGORY_CHILDREN_LAYER = 'children_layer'; public const CATEGORY_CHILDORDER = 'childOrder'; - /** - * @var BookmarkMapper - */ - protected $bookmarkMapper; - /** - * @var ShareMapper - */ - protected $shareMapper; - /** - * @var SharedFolderMapper - */ - protected $sharedFolderMapper; - /** - * @var TreeMapper - */ - protected $treeMapper; - /** - * @var FolderMapper - */ - protected $folderMapper; /** * @var bool */ @@ -62,11 +48,6 @@ class TreeCacheManager implements IEventListener { * @var ICache[] */ private $caches = []; - private ContainerInterface $appContainer; - /** - * @var \OCA\Bookmarks\Db\TagMapper - */ - private $tagMapper; /** * FolderMapper constructor. @@ -77,21 +58,24 @@ class TreeCacheManager implements IEventListener { * @param SharedFolderMapper $sharedFolderMapper * @param ICacheFactory $cacheFactory * @param ContainerInterface $appContainer - * @param \OCA\Bookmarks\Db\TagMapper $tagMapper + * @param TagMapper $tagMapper */ - public function __construct(FolderMapper $folderMapper, BookmarkMapper $bookmarkMapper, ShareMapper $shareMapper, SharedFolderMapper $sharedFolderMapper, ICacheFactory $cacheFactory, ContainerInterface $appContainer, \OCA\Bookmarks\Db\TagMapper $tagMapper) { - $this->folderMapper = $folderMapper; - $this->bookmarkMapper = $bookmarkMapper; - $this->shareMapper = $shareMapper; - $this->sharedFolderMapper = $sharedFolderMapper; + public function __construct( + protected FolderMapper $folderMapper, + protected BookmarkMapper $bookmarkMapper, + protected ShareMapper $shareMapper, + protected SharedFolderMapper $sharedFolderMapper, + protected ICacheFactory $cacheFactory, + protected ContainerInterface $appContainer, + protected TagMapper $tagMapper + ) { $this->caches[self::CATEGORY_HASH] = $cacheFactory->createLocal('bookmarks:'.self::CATEGORY_HASH); $this->caches[self::CATEGORY_SUBFOLDERS] = $cacheFactory->createLocal('bookmarks:'.self::CATEGORY_SUBFOLDERS); + $this->caches[self::CATEGORY_DELETED_SUBFOLDERS] = $cacheFactory->createLocal('bookmarks:'.self::CATEGORY_DELETED_SUBFOLDERS); $this->caches[self::CATEGORY_FOLDERCOUNT] = $cacheFactory->createLocal('bookmarks:'.self::CATEGORY_FOLDERCOUNT); $this->caches[self::CATEGORY_CHILDREN] = $cacheFactory->createLocal('bookmarks:'.self::CATEGORY_CHILDREN); $this->caches[self::CATEGORY_CHILDREN_LAYER] = $cacheFactory->createLocal('bookmarks:'.self::CATEGORY_CHILDREN_LAYER); $this->caches[self::CATEGORY_CHILDORDER] = $cacheFactory->createLocal('bookmarks:'.self::CATEGORY_CHILDORDER); - $this->appContainer = $appContainer; - $this->tagMapper = $tagMapper; } @@ -128,7 +112,7 @@ public function get(string $category, string $type, int $id) { */ public function set(string $category, string $type, int $id, $data) { $key = $this->getCacheKey($type, $id); - return $this->caches[$category]->set($key, $data, 60 * 60 * 24); + return $this->caches[$category]->set($key, $data, self::TTL); } /** diff --git a/lib/Service/UserSettingsService.php b/lib/Service/UserSettingsService.php index b5162d33bc..03d953df43 100644 --- a/lib/Service/UserSettingsService.php +++ b/lib/Service/UserSettingsService.php @@ -42,7 +42,7 @@ public function get(string $key): string { $default = 'grid'; } if ($key === 'limit') { - return $this->config->getAppValue('bookmarks', 'performance.maxBookmarksperAccount', 0); + return $this->config->getAppValue('bookmarks', 'performance.maxBookmarksperAccount', '0'); } if ($key === 'archive.enabled') { $default = (string) true; diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 82f8cd5c63..a53dddef8f 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,107 +1,143 @@ - + + + + + + - - UsersGroupsCirclesListener::class - UsersGroupsCirclesListener::class - UsersGroupsCirclesListener::class - UsersGroupsCirclesListener::class - UsersGroupsCirclesListener::class - UsersGroupsCirclesListener::class - UsersGroupsCirclesListener::class - registerEventListener - registerEventListener - registerEventListener - registerEventListener - registerEventListener - registerEventListener - registerEventListener - registerEventListener - registerEventListener - registerEventListener - registerEventListener - registerEventListener - registerEventListener + + + + + + + + - - CircleDestroyedEvent - CircleMemberAddedEvent - CircleMemberRemovedEvent + + + + + + + + + - - \OC\DB\Exceptions\DbalException + + - - \OCP\Server::get(CirclesService::class)->getCircle($participant) + + getCircle($participant)]]> + + + bookmarkMapper->findAll($userId, $params)]]> + + + + + + + + + + + + + findEntityWithType($qb, TreeMapper::TYPE_FOLDER);]]> + + + + + + + + + + + + - - $entity - File + + + - - exportContextIDs + + + + + + + - - IEventListener + + - - $event + + - - $circle - $circle - CircleDestroyedEvent + + + + - - UsersGroupsCirclesListener + + + - - \OC\User\NoUserException - \OC\User\NoUserException - \OC\User\NoUserException - \OC\User\NoUserException - \OC\User\NoUserException + + + + + + - - ?Circle - Circle - CircleProbe - CirclesManager - CirclesManager - CirclesManager - Member - Member - Member + + + + + + + + + + - - $e - $e - Client - NoUserException + + + + + - - $circle + + - - setMinimumImageDimensions + + + + + + + diff --git a/src/components/Bookmark.vue b/src/components/Bookmark.vue index 9e743c1b24..668b4fbdd4 100644 --- a/src/components/Bookmark.vue +++ b/src/components/Bookmark.vue @@ -5,91 +5,118 @@ -->