diff --git a/assets/controllers/subject_controller.js b/assets/controllers/subject_controller.js index 95331ad07..cd38b0eb0 100644 --- a/assets/controllers/subject_controller.js +++ b/assets/controllers/subject_controller.js @@ -199,6 +199,46 @@ export default class extends Controller { } } + /** + * Calls the address attached to the nearest link node. Replaces the outer html of the nearest `cssclass` parameter + * with the response from the link + */ + async linkCallback(event) { + const { cssclass: cssClass, refreshlink: refreshLink, refreshselector: refreshSelector } = event.params + event.preventDefault(); + + const a = event.target.closest('a'); + + try { + this.loadingValue = true; + + let response = await fetch(a.href, { + method: 'GET', + }); + + response = await ok(response); + response = await response.json(); + + event.target.closest(`.${cssClass}`).outerHTML = response.html; + + const refreshElement = this.element.querySelector(refreshSelector) + console.log("linkCallback refresh stuff", refreshLink, refreshSelector, refreshElement) + + if (!!refreshLink && refreshLink !== "" && !!refreshElement) { + let response = await fetch(refreshLink, { + method: 'GET', + }); + + response = await ok(response); + response = await response.json(); + refreshElement.outerHTML = response.html; + } + } catch (e) { + } finally { + this.loadingValue = false; + } + } + loadingValueChanged(val) { const submitButton = this.containerTarget.querySelector('form button[type="submit"]'); diff --git a/assets/styles/app.scss b/assets/styles/app.scss index 7f60d159a..57fb23d5d 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -1,5 +1,6 @@ @import '@fortawesome/fontawesome-free/scss/fontawesome'; @import '@fortawesome/fontawesome-free/scss/solid'; +@import '@fortawesome/fontawesome-free/scss/regular'; @import '@fortawesome/fontawesome-free/scss/brands'; @import 'simple-icons-font/font/simple-icons'; @import 'glightbox/dist/css/glightbox.min.css'; @@ -21,6 +22,7 @@ @import 'layout/alerts'; @import 'layout/forms'; @import 'layout/images'; +@import 'layout/icons'; @import 'components/announcement'; @import 'components/topbar'; @import 'components/header'; @@ -51,6 +53,7 @@ @import 'components/settings_row'; @import 'pages/post_single'; @import 'pages/post_front'; +@import 'pages/page_bookmarks'; @import 'themes/kbin'; @import 'themes/default'; @import 'themes/solarized'; diff --git a/assets/styles/layout/_icons.scss b/assets/styles/layout/_icons.scss new file mode 100644 index 000000000..0f94dbfc0 --- /dev/null +++ b/assets/styles/layout/_icons.scss @@ -0,0 +1,3 @@ +i.active { + color: var(--kbin-color-icon-active, orange); +} diff --git a/assets/styles/pages/page_bookmarks.scss b/assets/styles/pages/page_bookmarks.scss new file mode 100644 index 000000000..557f314dd --- /dev/null +++ b/assets/styles/pages/page_bookmarks.scss @@ -0,0 +1,6 @@ +.page-bookmarks { + .entry, .entry-comment, .post, .post-comment, .comment { + margin-top: 0!important; + margin-bottom: .5em!important; + } +} diff --git a/config/kbin_routes/bookmark.yaml b/config/kbin_routes/bookmark.yaml new file mode 100644 index 000000000..487c8f307 --- /dev/null +++ b/config/kbin_routes/bookmark.yaml @@ -0,0 +1,71 @@ +bookmark_front: + controller: App\Controller\BookmarkListController::front + defaults: { sortBy: hot, time: '∞', federation: all } + path: /bookmark-lists/show/{list}/{sortBy}/{time}/{federation} + methods: [GET] + requirements: &front_requirement + sortBy: "%default_sort_options%" + time: "%default_time_options%" + federation: "%default_federation_options%" + +bookmark_lists: + controller: App\Controller\BookmarkListController::list + path: /bookmark-lists + methods: [GET, POST] + +bookmark_lists_menu_refresh_status: + controller: App\Controller\BookmarkListController::subjectBookmarkMenuListRefresh + path: /blr/{subject_id}/{subject_type} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + +bookmark_lists_make_default: + controller: App\Controller\BookmarkListController::makeDefault + path: /bookmark-lists/makeDefault + methods: [GET] + +bookmark_lists_edit_list: + controller: App\Controller\BookmarkListController::editList + path: /bookmark-lists/editList/{list} + methods: [GET, POST] + +bookmark_lists_delete_list: + controller: App\Controller\BookmarkListController::deleteList + path: /bookmark-lists/deleteList/{list} + methods: [GET] + +subject_bookmark_standard: + controller: App\Controller\BookmarkController::subjectBookmarkStandard + path: /bos/{subject_id}/{subject_type} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + +subject_bookmark_refresh_status: + controller: App\Controller\BookmarkController::subjectBookmarkRefresh + path: /bor/{subject_id}/{subject_type} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + +subject_bookmark_to_list: + controller: App\Controller\BookmarkController::subjectBookmarkToList + path: /bol/{subject_id}/{subject_type}/{list} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + +subject_remove_bookmarks: + controller: App\Controller\BookmarkController::subjectRemoveBookmarks + path: /rbo/{subject_id}/{subject_type} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + +subject_remove_bookmark_from_list: + controller: App\Controller\BookmarkController::subjectRemoveBookmarkFromList + path: /rbol/{subject_id}/{subject_type}/{list} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] diff --git a/config/kbin_routes/bookmark_api.yaml b/config/kbin_routes/bookmark_api.yaml new file mode 100644 index 000000000..f69ad45eb --- /dev/null +++ b/config/kbin_routes/bookmark_api.yaml @@ -0,0 +1,61 @@ +api_bookmark_front: + controller: App\Controller\Api\Bookmark\BookmarkListApiController::front + path: /api/bookmark-lists/show + methods: [GET] + format: json + +api_bookmark_lists: + controller: App\Controller\Api\Bookmark\BookmarkListApiController::list + path: /api/bookmark-lists + methods: [GET] + format: json + +api_bookmark_lists_make_default: + controller: App\Controller\Api\Bookmark\BookmarkListApiController::makeDefault + path: /api/bookmark-lists/{list_name}/makeDefault + methods: [GET] + format: json + +api_bookmark_lists_edit_list: + controller: App\Controller\Api\Bookmark\BookmarkListApiController::editList + path: /api/bookmark-lists/{list_name} + methods: [POST] + format: json + +api_bookmark_lists_delete_list: + controller: App\Controller\Api\Bookmark\BookmarkListApiController::deleteList + path: /api/bookmark-lists/{list_name} + methods: [DELETE] + format: json + +api_subject_bookmark_standard: + controller: App\Controller\Api\Bookmark\BookmarkApiController::subjectBookmarkStandard + path: /api/bos/{subject_id}/{subject_type} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + format: json + +api_subject_bookmark_to_list: + controller: App\Controller\Api\Bookmark\BookmarkApiController::subjectBookmarkToList + path: /api/bol/{subject_id}/{subject_type}/{list_name} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + format: json + +api_subject_remove_bookmarks: + controller: App\Controller\Api\Bookmark\BookmarkApiController::subjectRemoveBookmarks + path: /api/rbo/{subject_id}/{subject_type} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + format: json + +api_subject_remove_bookmark_from_list: + controller: App\Controller\Api\Bookmark\BookmarkApiController::subjectRemoveBookmarkFromList + path: /api/rbol/{subject_id}/{subject_type}/{list_name} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + format: json diff --git a/config/packages/league_oauth2_server.yaml b/config/packages/league_oauth2_server.yaml index 714d49007..221fde7b4 100644 --- a/config/packages/league_oauth2_server.yaml +++ b/config/packages/league_oauth2_server.yaml @@ -59,6 +59,13 @@ league_oauth2_server: "user:profile", "user:profile:read", "user:profile:edit", + "user:bookmark", + "user:bookmark:add", + "user:bookmark:remove", + "user:bookmark:list", + "user:bookmark:list:read", + "user:bookmark:list:edit", + "user:bookmark:list:delete", "user:message", "user:message:read", "user:message:create", diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 44e441b7d..709761e2f 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -230,6 +230,17 @@ security: 'ROLE_OAUTH2_USER:OAUTH_CLIENTS:READ', 'ROLE_OAUTH2_USER:OAUTH_CLIENTS:EDIT', ] + 'ROLE_OAUTH2_USER:BOOKMARK': + [ + 'ROLE_OAUTH2_USER:BOOKMARK:ADD', + 'ROLE_OAUTH2_USER:BOOKMARK:REMOVE', + ] + 'ROLE_OAUTH2_USER:BOOKMARK_LIST': + [ + 'ROLE_OAUTH2_USER:BOOKMARK_LIST:READ', + 'ROLE_OAUTH2_USER:BOOKMARK_LIST:EDIT', + 'ROLE_OAUTH2_USER:BOOKMARK_LIST:DELETE', + ] 'ROLE_OAUTH2_MODERATE': [ 'ROLE_OAUTH2_MODERATE:ENTRY', diff --git a/config/services.yaml b/config/services.yaml index 021b2591b..b5b32fbcc 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -83,6 +83,7 @@ parameters: default_subscription_options: sub|fav|mod|all|home default_federation_options: local|all default_content_options: threads|microblog + default_subject_type_options: entry|entry_comment|post|post_comment comment_sort_options: top|hot|active|newest|oldest diff --git a/migrations/Version20240831151328.php b/migrations/Version20240831151328.php new file mode 100644 index 000000000..d680579ca --- /dev/null +++ b/migrations/Version20240831151328.php @@ -0,0 +1,56 @@ +addSql('CREATE SEQUENCE bookmark_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE bookmark_list_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE bookmark (id INT NOT NULL, list_id INT NOT NULL, user_id INT NOT NULL, entry_id INT DEFAULT NULL, entry_comment_id INT DEFAULT NULL, post_id INT DEFAULT NULL, post_comment_id INT DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_DA62921D3DAE168B ON bookmark (list_id)'); + $this->addSql('CREATE INDEX IDX_DA62921DA76ED395 ON bookmark (user_id)'); + $this->addSql('CREATE INDEX IDX_DA62921DBA364942 ON bookmark (entry_id)'); + $this->addSql('CREATE INDEX IDX_DA62921D60C33421 ON bookmark (entry_comment_id)'); + $this->addSql('CREATE INDEX IDX_DA62921D4B89032C ON bookmark (post_id)'); + $this->addSql('CREATE INDEX IDX_DA62921DDB1174D2 ON bookmark (post_comment_id)'); + $this->addSql('CREATE UNIQUE INDEX bookmark_list_entry_entryComment_post_postComment_idx ON bookmark (list_id, entry_id, entry_comment_id, post_id, post_comment_id)'); + $this->addSql('COMMENT ON COLUMN bookmark.created_at IS \'(DC2Type:datetimetz_immutable)\''); + $this->addSql('CREATE TABLE bookmark_list (id INT NOT NULL, user_id INT NOT NULL, name VARCHAR(255) NOT NULL, is_default BOOLEAN NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_A650C0C4A76ED395 ON bookmark_list (user_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_A650C0C4A76ED3955E237E06 ON bookmark_list (user_id, name)'); + $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921D3DAE168B FOREIGN KEY (list_id) REFERENCES bookmark_list (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921DA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921DBA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921D60C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921D4B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921DDB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE bookmark_list ADD CONSTRAINT FK_A650C0C4A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP SEQUENCE bookmark_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE bookmark_list_id_seq CASCADE'); + $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921D3DAE168B'); + $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921DA76ED395'); + $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921DBA364942'); + $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921D60C33421'); + $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921D4B89032C'); + $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921DDB1174D2'); + $this->addSql('ALTER TABLE bookmark_list DROP CONSTRAINT FK_A650C0C4A76ED395'); + $this->addSql('DROP TABLE bookmark'); + $this->addSql('DROP TABLE bookmark_list'); + } +} diff --git a/src/Controller/Api/BaseApi.php b/src/Controller/Api/BaseApi.php index 470391322..4485c7143 100644 --- a/src/Controller/Api/BaseApi.php +++ b/src/Controller/Api/BaseApi.php @@ -30,6 +30,8 @@ use App\Factory\PostCommentFactory; use App\Factory\PostFactory; use App\Form\Constraint\ImageConstraint; +use App\Repository\BookmarkListRepository; +use App\Repository\BookmarkRepository; use App\Repository\Criteria; use App\Repository\EntryCommentRepository; use App\Repository\EntryRepository; @@ -39,12 +41,13 @@ use App\Repository\PostRepository; use App\Repository\TagLinkRepository; use App\Schema\PaginationSchema; +use App\Service\BookmarkManager; use App\Service\IpResolver; use App\Service\ReportManager; use Doctrine\ORM\EntityManagerInterface; use League\Bundle\OAuth2ServerBundle\Model\AccessToken; use League\Bundle\OAuth2ServerBundle\Security\Authentication\Token\OAuth2Token; -use Pagerfanta\Pagerfanta; +use Pagerfanta\PagerfantaInterface; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; use Psr\Log\LoggerInterface; @@ -85,6 +88,9 @@ public function __construct( protected readonly EntryCommentRepository $entryCommentRepository, protected readonly PostRepository $postRepository, protected readonly PostCommentRepository $postCommentRepository, + protected readonly BookmarkListRepository $bookmarkListRepository, + protected readonly BookmarkRepository $bookmarkRepository, + protected readonly BookmarkManager $bookmarkManager, private readonly ImageRepository $imageRepository, private readonly ReportManager $reportManager, private readonly OAuth2ClientAccessRepository $clientAccessRepository, @@ -189,7 +195,7 @@ public function getAccessToken(?OAuth2Token $oAuth2Token): ?AccessToken ->findOneBy(['identifier' => $oAuth2Token->getAttribute('access_token_id')]); } - public function serializePaginated(array $serializedItems, Pagerfanta $pagerfanta): array + public function serializePaginated(array $serializedItems, PagerfantaInterface $pagerfanta): array { return [ 'items' => $serializedItems, diff --git a/src/Controller/Api/Bookmark/BookmarkApiController.php b/src/Controller/Api/Bookmark/BookmarkApiController.php new file mode 100644 index 000000000..e7a79f9f5 --- /dev/null +++ b/src/Controller/Api/Bookmark/BookmarkApiController.php @@ -0,0 +1,265 @@ +getUserOrThrow(); + $headers = $this->rateLimit($apiUpdateLimiter); + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id); + if (null === $subject) { + throw new NotFoundHttpException(code: 404, headers: $headers); + } + $this->bookmarkManager->addBookmarkToDefaultList($user, $subject); + + return new JsonResponse(status: 200, headers: $headers); + } + + #[OA\Response( + response: 200, + description: 'Add a bookmark for the subject in the specified list', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: null + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'The specified subject or list does not exist', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'subject_id', + description: 'The id of the subject to be added to the specified list', + in: 'path', + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'subject_type', + description: 'the type of the subject', + in: 'path', + schema: new OA\Schema(type: 'string', enum: ['entry', 'entry_comment', 'post', 'post_comment']) + )] + #[OA\Tag(name: 'bookmark:list')] + #[Security(name: 'oauth2', scopes: ['user:bookmark:add'])] + #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK:ADD')] + public function subjectBookmarkToList(string $list_name, int $subject_id, string $subject_type, RateLimiterFactory $apiUpdateLimiter): JsonResponse + { + $user = $this->getUserOrThrow(); + $headers = $this->rateLimit($apiUpdateLimiter); + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id); + if (null === $subject) { + throw new NotFoundHttpException(code: 404, headers: $headers); + } + $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name); + if (null === $list) { + throw new NotFoundHttpException(code: 404, headers: $headers); + } + $this->bookmarkManager->addBookmark($user, $list, $subject); + + return new JsonResponse(status: 200, headers: $headers); + } + + #[OA\Response( + response: 200, + description: 'Remove bookmark for the subject from the specified list', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: null + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'The specified subject or list does not exist', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'subject_id', + description: 'The id of the subject to be removed', + in: 'path', + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'subject_type', + description: 'the type of the subject', + in: 'path', + schema: new OA\Schema(type: 'string', enum: ['entry', 'entry_comment', 'post', 'post_comment']) + )] + #[OA\Tag(name: 'bookmark:list')] + #[Security(name: 'oauth2', scopes: ['user:bookmark:remove'])] + #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK:REMOVE')] + public function subjectRemoveBookmarkFromList(string $list_name, int $subject_id, string $subject_type, RateLimiterFactory $apiUpdateLimiter): JsonResponse + { + $user = $this->getUserOrThrow(); + $headers = $this->rateLimit($apiUpdateLimiter); + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id); + if (null === $subject) { + throw new NotFoundHttpException(code: 404, headers: $headers); + } + $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name); + if (null === $list) { + throw new NotFoundHttpException(code: 404, headers: $headers); + } + $this->bookmarkRepository->removeBookmarkFromList($user, $list, $subject); + + return new JsonResponse(status: 200, headers: $headers); + } + + #[OA\Response( + response: 200, + description: 'Remove all bookmarks for the subject', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: null + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'The specified subject does not exist', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'subject_id', + description: 'The id of the subject to be removed', + in: 'path', + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'subject_type', + description: 'the type of the subject', + in: 'path', + schema: new OA\Schema(type: 'string', enum: ['entry', 'entry_comment', 'post', 'post_comment']) + )] + #[OA\Tag(name: 'bookmark:list')] + #[Security(name: 'oauth2', scopes: ['user:bookmark:remove'])] + #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK:REMOVE')] + public function subjectRemoveBookmarks(int $subject_id, string $subject_type, RateLimiterFactory $apiUpdateLimiter): JsonResponse + { + $user = $this->getUserOrThrow(); + $headers = $this->rateLimit($apiUpdateLimiter); + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id); + if (null === $subject) { + throw new NotFoundHttpException(code: 404, headers: $headers); + } + $this->bookmarkRepository->removeAllBookmarksForContent($user, $subject); + + return new JsonResponse(status: 200, headers: $headers); + } +} diff --git a/src/Controller/Api/Bookmark/BookmarkListApiController.php b/src/Controller/Api/Bookmark/BookmarkListApiController.php new file mode 100644 index 000000000..d7e5c10bc --- /dev/null +++ b/src/Controller/Api/Bookmark/BookmarkListApiController.php @@ -0,0 +1,378 @@ +getUserOrThrow(); + $headers = $this->rateLimit($apiReadLimiter); + $criteria = new EntryPageView($p ?? 1); + $criteria->setTime($criteria->resolveTime($time ?? Criteria::TIME_ALL)); + $criteria->setType($criteria->resolveType($type ?? 'all')); + $criteria->showSortOption($criteria->resolveSort($sort ?? Criteria::SORT_NEW)); + $criteria->setFederation($federation ?? Criteria::AP_ALL); + + if (null !== $list_id) { + $bookmarkList = $this->bookmarkListRepository->findOneBy(['id' => $list_id, 'user' => $user]); + if (null === $bookmarkList) { + return new JsonResponse(status: 404, headers: $headers); + } + } else { + $bookmarkList = $this->bookmarkListRepository->findOneByUserDefault($user); + } + $pagerfanta = $this->bookmarkRepository->findPopulatedByList($bookmarkList, $criteria, $perPage); + $objects = $pagerfanta->getCurrentPageResults(); + $items = array_map(fn (ContentInterface $item) => $this->serializeContentInterface($item), $objects); + $result = $this->serializePaginated($items, $pagerfanta); + + return new JsonResponse($result, status: 200, headers: $headers); + } + + #[OA\Response( + response: 200, + description: 'Returns all bookmark lists from the user', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'items', + type: 'array', + items: new OA\Items(ref: new Model(type: BookmarkListDto::class)) + ), + ], + type: 'object' + ) + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Tag(name: 'bookmark:list')] + #[Security(name: 'oauth2', scopes: ['user:bookmark:list:read'])] + #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK_LIST:READ')] + public function list(RateLimiterFactory $apiReadLimiter): JsonResponse + { + $user = $this->getUserOrThrow(); + $headers = $this->rateLimit($apiReadLimiter); + $items = array_map(fn (BookmarkList $list) => BookmarkListDto::fromList($list), $this->bookmarkListRepository->findByUser($user)); + $response = [ + 'items' => $items, + ]; + + return new JsonResponse($response, status: 200, headers: $headers); + } + + #[OA\Response( + response: 200, + description: 'Sets the provided list as the default', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: null + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'The requested list does not exist', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'list_name', + description: 'The name of the list to be made the default', + in: 'path', + schema: new OA\Schema(type: 'string') + )] + #[OA\Tag(name: 'bookmark:list')] + #[Security(name: 'oauth2', scopes: ['user:bookmark:list:edit'])] + #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK_LIST:EDIT')] + public function makeDefault(string $list_name, RateLimiterFactory $apiUpdateLimiter): JsonResponse + { + $user = $this->getUserOrThrow(); + $headers = $this->rateLimit($apiUpdateLimiter); + $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name); + if (null === $list) { + throw new NotFoundHttpException(headers: $headers); + } + $this->bookmarkListRepository->makeListDefault($user, $list); + + return new JsonResponse(status: 200, headers: $headers); + } + + #[OA\Response( + response: 200, + description: 'Edits the supplied list', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new Model(type: BookmarkListDto::class), + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'The requested list does not exist', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'list_name', + description: 'The name of the list to be edited', + in: 'path', + schema: new OA\Schema(type: 'string') + )] + #[OA\RequestBody(content: new Model( + type: BookmarkListDto::class, + groups: ['common'] + ))] + #[OA\Tag(name: 'bookmark:list')] + #[Security(name: 'oauth2', scopes: ['user:bookmark:list:edit'])] + #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK_LIST:EDIT')] + public function editList(string $list_name, #[MapRequestPayload] BookmarkListDto $dto, RateLimiterFactory $apiUpdateLimiter): JsonResponse + { + $user = $this->getUserOrThrow(); + $headers = $this->rateLimit($apiUpdateLimiter); + $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name); + if (null === $list) { + throw new NotFoundHttpException(headers: $headers); + } + $this->bookmarkListRepository->editList($user, $list, $dto); + $list = $this->bookmarkListRepository->findOneBy(['id' => $list->getId()]); + + return new JsonResponse(BookmarkListDto::fromList($list), status: 200, headers: $headers); + } + + #[OA\Response( + response: 200, + description: 'Deletes the provided list', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: null + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'The requested list does not exist', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'list_name', + description: 'The name of the list to be deleted', + in: 'path', + schema: new OA\Schema(type: 'string') + )] + #[OA\Tag(name: 'bookmark:list')] + #[Security(name: 'oauth2', scopes: ['user:bookmark:list:delete'])] + #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK_LIST:DELETE')] + public function deleteList(string $list_name, RateLimiterFactory $apiDeleteLimiter): JsonResponse + { + $user = $this->getUserOrThrow(); + $headers = $this->rateLimit($apiDeleteLimiter); + $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name); + if (null === $list) { + throw new NotFoundHttpException(headers: $headers); + } + $this->bookmarkListRepository->deleteList($list); + + return new JsonResponse(status: 200, headers: $headers); + } +} diff --git a/src/Controller/BookmarkController.php b/src/Controller/BookmarkController.php new file mode 100644 index 000000000..55f4c1b18 --- /dev/null +++ b/src/Controller/BookmarkController.php @@ -0,0 +1,145 @@ +entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); + $this->bookmarkManager->addBookmarkToDefaultList($this->getUserOrThrow(), $subjectEntity); + if ($request->isXmlHttpRequest()) { + return new JsonResponse([ + 'html' => $this->renderView('components/_ajax.html.twig', [ + 'component' => 'bookmark_standard', + 'attributes' => [ + 'subject' => $subjectEntity, + 'subjectClass' => $subjectClass, + ], + ] + ), + ]); + } + + return $this->redirect($request->headers->get('Referer')); + } + + #[IsGranted('ROLE_USER')] + public function subjectBookmarkRefresh(int $subject_id, string $subject_type, Request $request): Response + { + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); + if ($request->isXmlHttpRequest()) { + return new JsonResponse([ + 'html' => $this->renderView('components/_ajax.html.twig', [ + 'component' => 'bookmark_standard', + 'attributes' => [ + 'subject' => $subjectEntity, + 'subjectClass' => $subjectClass, + ], + ] + ), + ]); + } + + return $this->redirect($request->headers->get('Referer')); + } + + #[IsGranted('ROLE_USER')] + public function subjectBookmarkToList(int $subject_id, string $subject_type, #[MapEntity] BookmarkList $list, Request $request): Response + { + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); + $user = $this->getUserOrThrow(); + if ($user->getId() !== $list->user->getId()) { + throw new AccessDeniedHttpException(); + } + $this->bookmarkManager->addBookmark($user, $list, $subjectEntity); + if ($request->isXmlHttpRequest()) { + return new JsonResponse([ + 'html' => $this->renderView('components/_ajax.html.twig', [ + 'component' => 'bookmark_list', + 'attributes' => [ + 'subject' => $subjectEntity, + 'subjectClass' => $subjectClass, + 'list' => $list, + ], + ] + ), + ]); + } + + return $this->redirect($request->headers->get('Referer')); + } + + #[IsGranted('ROLE_USER')] + public function subjectRemoveBookmarks(int $subject_id, string $subject_type, Request $request): Response + { + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); + $this->bookmarkRepository->removeAllBookmarksForContent($this->getUserOrThrow(), $subjectEntity); + if ($request->isXmlHttpRequest()) { + return new JsonResponse([ + 'html' => $this->renderView('components/_ajax.html.twig', [ + 'component' => 'bookmark_standard', + 'attributes' => [ + 'subject' => $subjectEntity, + 'subjectClass' => $subjectClass, + ], + ] + ), + ]); + } + + return $this->redirect($request->headers->get('Referer')); + } + + #[IsGranted('ROLE_USER')] + public function subjectRemoveBookmarkFromList(int $subject_id, string $subject_type, #[MapEntity] BookmarkList $list, Request $request): Response + { + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); + $user = $this->getUserOrThrow(); + if ($user->getId() !== $list->user->getId()) { + throw new AccessDeniedHttpException(); + } + $this->bookmarkRepository->removeBookmarkFromList($user, $list, $subjectEntity); + if ($request->isXmlHttpRequest()) { + return new JsonResponse([ + 'html' => $this->renderView('components/_ajax.html.twig', [ + 'component' => 'bookmark_list', + 'attributes' => [ + 'subject' => $subjectEntity, + 'subjectClass' => $subjectClass, + 'list' => $list, + ], + ] + ), + ]); + } + + return $this->redirect($request->headers->get('Referer')); + } +} diff --git a/src/Controller/BookmarkListController.php b/src/Controller/BookmarkListController.php new file mode 100644 index 000000000..098f5bcbe --- /dev/null +++ b/src/Controller/BookmarkListController.php @@ -0,0 +1,180 @@ +getPageNb($request); + $user = $this->getUserOrThrow(); + $criteria = new EntryPageView($page); + $criteria->setTime($criteria->resolveTime($time)); + $criteria->setType($criteria->resolveType($type)); + $criteria->showSortOption($criteria->resolveSort($sortBy ?? Criteria::SORT_NEW)); + $criteria->setFederation($federation); + + if (null !== $list) { + $bookmarkList = $this->bookmarkListRepository->findOneByUserAndName($user, $list); + } else { + $bookmarkList = $this->bookmarkListRepository->findOneByUserDefault($user); + } + $res = $this->bookmarkRepository->findPopulatedByList($bookmarkList, $criteria); + $objects = $res->getCurrentPageResults(); + $lists = $this->bookmarkListRepository->findByUser($user); + + $this->logger->info('got results in list {l}: {r}', ['l' => $list, 'r' => $objects]); + + if ($request->isXmlHttpRequest()) { + return new JsonResponse([ + 'html' => $this->renderView('layout/_subject_list.html.twig', [ + 'results' => $objects, + 'pagination' => $res, + ]), + ]); + } + + return $this->render( + 'bookmark/front.html.twig', + [ + 'criteria' => $criteria, + 'list' => $bookmarkList, + 'lists' => $lists, + 'results' => $objects, + 'pagination' => $res, + ] + ); + } + + #[IsGranted('ROLE_USER')] + public function list(Request $request): Response + { + $user = $this->getUserOrThrow(); + $dto = new BookmarkListDto(); + $form = $this->createForm(BookmarkListType::class, $dto); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + /** @var BookmarkListDto $dto */ + $dto = $form->getData(); + $list = $this->bookmarkManager->createList($user, $dto->name); + if ($dto->isDefault) { + $this->bookmarkListRepository->makeListDefault($user, $list); + } + + return $this->redirectToRoute('bookmark_lists'); + } + + return $this->render('bookmark/overview.html.twig', [ + 'lists' => $this->bookmarkListRepository->findByUser($user), + 'form' => $form->createView(), + ], + new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200) + ); + } + + #[IsGranted('ROLE_USER')] + public function subjectBookmarkMenuListRefresh(int $subject_id, string $subject_type, Request $request): Response + { + $user = $this->getUserOrThrow(); + $bookmarkLists = $this->bookmarkListRepository->findByUser($user); + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); + if ($request->isXmlHttpRequest()) { + return new JsonResponse([ + 'html' => $this->renderView('components/_ajax.html.twig', [ + 'component' => 'bookmark_menu_list', + 'attributes' => [ + 'subject' => $subjectEntity, + 'subjectClass' => $subjectClass, + 'bookmarkLists' => $bookmarkLists, + ], + ] + ), + ]); + } + + return $this->redirect($request->headers->get('Referer')); + } + + #[IsGranted('ROLE_USER')] + public function makeDefault(#[MapQueryParameter] ?int $makeDefault): Response + { + $user = $this->getUserOrThrow(); + $this->logger->info('making list id {id} default for user {u}', ['user' => $user->username, 'id' => $makeDefault]); + if (null !== $makeDefault) { + $list = $this->bookmarkListRepository->findOneBy(['id' => $makeDefault]); + $this->bookmarkListRepository->makeListDefault($user, $list); + } + + return $this->redirectToRoute('bookmark_lists'); + } + + #[IsGranted('ROLE_USER')] + public function editList(#[MapEntity] BookmarkList $list, Request $request): Response + { + $user = $this->getUserOrThrow(); + $dto = BookmarkListDto::fromList($list); + $form = $this->createForm(BookmarkListType::class, $dto); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + $dto = $form->getData(); + $this->bookmarkListRepository->editList($user, $list, $dto); + + return $this->redirectToRoute('bookmark_lists'); + } + + return $this->render('bookmark/edit.html.twig', [ + 'list' => $list, + 'form' => $form->createView(), + ]); + } + + #[IsGranted('ROLE_USER')] + public function deleteList(#[MapEntity] BookmarkList $list): Response + { + $user = $this->getUserOrThrow(); + if ($user->getId() !== $list->user->getId()) { + $this->logger->error('user {u} tried to delete a list that is not his own: {l}', ['u' => $user->username, 'l' => "$list->name ({$list->getId()})"]); + throw new AccessDeniedHttpException(); + } + $this->bookmarkListRepository->deleteList($list); + + return $this->redirectToRoute('bookmark_lists'); + } +} diff --git a/src/DTO/BookmarkListDto.php b/src/DTO/BookmarkListDto.php new file mode 100644 index 000000000..39bc3d8e2 --- /dev/null +++ b/src/DTO/BookmarkListDto.php @@ -0,0 +1,42 @@ +name = $list->name; + $dto->isDefault = $list->isDefault; + $dto->count = $list->entities->count(); + + return $dto; + } + + public function jsonSerialize(): array + { + return [ + 'name' => $this->name, + 'isDefault' => $this->isDefault, + 'count' => $this->count, + ]; + } +} diff --git a/src/DTO/OAuth2ClientDto.php b/src/DTO/OAuth2ClientDto.php index a35598d40..4d4eed9af 100644 --- a/src/DTO/OAuth2ClientDto.php +++ b/src/DTO/OAuth2ClientDto.php @@ -62,6 +62,13 @@ class OAuth2ClientDto extends ImageUploadDto implements \JsonSerializable 'user:profile', 'user:profile:read', 'user:profile:edit', + 'user:bookmark', + 'user:bookmark:add', + 'user:bookmark:remove', + 'user:bookmark:list', + 'user:bookmark:list:read', + 'user:bookmark:list:edit', + 'user:bookmark:list:delete', 'user:message', 'user:message:read', 'user:message:create', diff --git a/src/Entity/Bookmark.php b/src/Entity/Bookmark.php new file mode 100644 index 000000000..3902e3948 --- /dev/null +++ b/src/Entity/Bookmark.php @@ -0,0 +1,70 @@ +user = $user; + $this->list = $list; + $this->createdAtTraitConstruct(); + } + + public function setContent(Post|EntryComment|PostComment|Entry $content): void + { + if ($content instanceof Entry) { + $this->entry = $content; + } elseif ($content instanceof EntryComment) { + $this->entryComment = $content; + } elseif ($content instanceof Post) { + $this->post = $content; + } elseif ($content instanceof PostComment) { + $this->postComment = $content; + } + } + + public function getContent(): Entry|EntryComment|Post|PostComment + { + return $this->entry ?? $this->entryComment ?? $this->post ?? $this->postComment; + } +} diff --git a/src/Entity/BookmarkList.php b/src/Entity/BookmarkList.php new file mode 100644 index 000000000..52673e1cf --- /dev/null +++ b/src/Entity/BookmarkList.php @@ -0,0 +1,51 @@ +user = $user; + $this->name = $name; + $this->isDefault = $isDefault; + $this->entities = new ArrayCollection(); + } + + public function getId(): int + { + return $this->id; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index 8c39eca36..8095a2300 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -223,6 +223,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Visibil public Collection $notifications; #[OneToMany(mappedBy: 'user', targetEntity: UserPushSubscription::class, fetch: 'EXTRA_LAZY')] public Collection $pushSubscriptions; + #[OneToMany(mappedBy: 'user', targetEntity: BookmarkList::class, fetch: 'EXTRA_LAZY')] + public Collection $bookmarkLists; #[Id] #[GeneratedValue] #[Column(type: 'integer')] diff --git a/src/Form/BookmarkListType.php b/src/Form/BookmarkListType.php new file mode 100644 index 000000000..f10ba5cf1 --- /dev/null +++ b/src/Form/BookmarkListType.php @@ -0,0 +1,35 @@ +add('name', TextType::class) + ->add('isDefault', CheckboxType::class, [ + 'required' => false, + ]) + ->add('submit', SubmitType::class); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults( + [ + 'data_class' => BookmarkListDto::class, + ] + ); + } +} diff --git a/src/Pagination/NativeQueryAdapter.php b/src/Pagination/NativeQueryAdapter.php index 959d47b60..4888ca604 100644 --- a/src/Pagination/NativeQueryAdapter.php +++ b/src/Pagination/NativeQueryAdapter.php @@ -8,7 +8,9 @@ use App\Pagination\Transformation\VoidTransformer; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception; +use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Statement; +use Doctrine\DBAL\Types\Types; use Pagerfanta\Adapter\AdapterInterface; /** @@ -35,7 +37,7 @@ public function __construct( $sql2 = 'SELECT COUNT(*) as cnt FROM ('.$sql.') sub'; $stmt2 = $this->conn->prepare($sql2); foreach ($this->parameters as $key => $value) { - $stmt2->bindValue($key, $value); + $stmt2->bindValue($key, $value, $this->getSqlType($value)); } $result = $stmt2->executeQuery()->fetchAllAssociative(); $this->numOfResults = $result[0]['cnt']; @@ -43,7 +45,7 @@ public function __construct( $this->statement = $this->conn->prepare($sql.' LIMIT :limit OFFSET :offset'); foreach ($this->parameters as $key => $value) { - $this->statement->bindValue($key, $value); + $this->statement->bindValue($key, $value, $this->getSqlType($value)); } } @@ -59,4 +61,15 @@ public function getSlice(int $offset, int $length): iterable return $this->transformer->transform($this->statement->executeQuery()->fetchAllAssociative()); } + + private function getSqlType(mixed $value): mixed + { + if ($value instanceof \DateTimeImmutable) { + return Types::DATETIMETZ_IMMUTABLE; + } elseif ($value instanceof \DateTime) { + return Types::DATETIMETZ_MUTABLE; + } + + return ParameterType::STRING; + } } diff --git a/src/Pagination/Transformation/ContentPopulationTransformer.php b/src/Pagination/Transformation/ContentPopulationTransformer.php index 522e98d97..41b005136 100644 --- a/src/Pagination/Transformation/ContentPopulationTransformer.php +++ b/src/Pagination/Transformation/ContentPopulationTransformer.php @@ -19,6 +19,7 @@ public function __construct( public function transform(iterable $input): iterable { + $positionsArray = $this->buildPositionArray($input); $entries = $this->entityManager->getRepository(Entry::class)->findBy( ['id' => $this->getOverviewIds((array) $input, 'entry')] ); @@ -32,10 +33,7 @@ public function transform(iterable $input): iterable ['id' => $this->getOverviewIds((array) $input, 'post_comment')] ); - $result = array_merge($entries, $entryComments, $post, $postComment); - uasort($result, fn ($a, $b) => $a->getCreatedAt() > $b->getCreatedAt() ? -1 : 1); - - return $result; + return $this->applyPositions($positionsArray, $entries, $entryComments, $post, $postComment); } private function getOverviewIds(array $result, string $type): array @@ -44,4 +42,67 @@ private function getOverviewIds(array $result, string $type): array return array_map(fn ($subject) => $subject['id'], $result); } + + /** + * @return int[][] + */ + private function buildPositionArray(iterable $input): array + { + $entryPositions = []; + $entryCommentPositions = []; + $postPositions = []; + $postCommentPositions = []; + $i = 0; + foreach ($input as $current) { + switch ($current['type']) { + case 'entry': + $entryPositions[$current['id']] = $i; + break; + case 'entry_comment': + $entryCommentPositions[$current['id']] = $i; + break; + case 'post': + $postPositions[$current['id']] = $i; + break; + case 'post_comment': + $postCommentPositions[$current['id']] = $i; + break; + } + ++$i; + } + + return [ + 'entry' => $entryPositions, + 'entry_comment' => $entryCommentPositions, + 'post' => $postPositions, + 'post_comment' => $postCommentPositions, + ]; + } + + /** + * @param int[][] $positionsArray + * @param Entry[] $entries + * @param EntryComment[] $entryComments + * @param Post[] $posts + * @param PostComment[] $postComments + */ + private function applyPositions(array $positionsArray, array $entries, array $entryComments, array $posts, array $postComments): array + { + $result = []; + foreach ($entries as $entry) { + $result[$positionsArray['entry'][$entry->getId()]] = $entry; + } + foreach ($entryComments as $entryComment) { + $result[$positionsArray['entry_comment'][$entryComment->getId()]] = $entryComment; + } + foreach ($posts as $post) { + $result[$positionsArray['post'][$post->getId()]] = $post; + } + foreach ($postComments as $postComment) { + $result[$positionsArray['post_comment'][$postComment->getId()]] = $postComment; + } + ksort($result, SORT_NUMERIC); + + return $result; + } } diff --git a/src/Repository/BookmarkListRepository.php b/src/Repository/BookmarkListRepository.php new file mode 100644 index 000000000..c942dc500 --- /dev/null +++ b/src/Repository/BookmarkListRepository.php @@ -0,0 +1,83 @@ +findBy(['user' => $user]); + } + + public function findOneByUserAndName(User $user, string $name): ?BookmarkList + { + return $this->findOneBy(['user' => $user, 'name' => $name]); + } + + public function findOneByUserDefault(User $user): BookmarkList + { + $list = $this->findOneBy(['user' => $user, 'isDefault' => true]); + if (null === $list) { + $list = new BookmarkList($user, 'Default', true); + $this->entityManager->persist($list); + $this->entityManager->flush(); + } + + return $list; + } + + public function makeListDefault(User $user, BookmarkList $list): void + { + $sql = 'UPDATE bookmark_list SET is_default = false WHERE user_id = :user'; + $conn = $this->entityManager->getConnection(); + $stmt = $conn->prepare($sql); + $stmt->executeStatement(['user' => $user->getId()]); + + $sql = 'UPDATE bookmark_list SET is_default = true WHERE user_id = :user AND id = :id'; + $stmt = $conn->prepare($sql); + $stmt->executeStatement(['user' => $user->getId(), 'id' => $list->getId()]); + } + + public function deleteList(BookmarkList $list): void + { + $sql = 'DELETE FROM bookmark_list WHERE id = :id'; + $conn = $this->entityManager->getConnection(); + $stmt = $conn->prepare($sql); + $stmt->executeStatement(['id' => $list->getId()]); + } + + public function editList(User $user, BookmarkList $list, BookmarkListDto $dto): void + { + $sql = 'UPDATE bookmark_list SET name = :name WHERE id = :id'; + $conn = $this->entityManager->getConnection(); + $stmt = $conn->prepare($sql); + $stmt->executeStatement(['id' => $list->getId(), 'name' => $dto->name]); + + if ($dto->isDefault) { + $this->makeListDefault($user, $list); + } + } +} diff --git a/src/Repository/BookmarkRepository.php b/src/Repository/BookmarkRepository.php new file mode 100644 index 000000000..39cd79c1c --- /dev/null +++ b/src/Repository/BookmarkRepository.php @@ -0,0 +1,180 @@ +createQueryBuilder('b') + ->where('b.user = :user') + ->andWhere('b.list = :list') + ->setParameter('user', $user) + ->setParameter('list', $list) + ->getQuery() + ->getResult(); + } + + public function removeAllBookmarksForContent(User $user, Entry|EntryComment|Post|PostComment $content): void + { + if ($content instanceof Entry) { + $contentWhere = 'entry_id = :id'; + } elseif ($content instanceof EntryComment) { + $contentWhere = 'entry_comment_id = :id'; + } elseif ($content instanceof Post) { + $contentWhere = 'post_id = :id'; + } elseif ($content instanceof PostComment) { + $contentWhere = 'post_comment_id = :id'; + } else { + throw new \LogicException(); + } + + $sql = "DELETE FROM bookmark WHERE user_id = :u AND $contentWhere"; + $conn = $this->entityManager->getConnection(); + $stmt = $conn->prepare($sql); + $stmt->executeStatement(['u' => $user->getId(), 'id' => $content->getId()]); + } + + public function removeBookmarkFromList(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): void + { + if ($content instanceof Entry) { + $contentWhere = 'entry_id = :id'; + } elseif ($content instanceof EntryComment) { + $contentWhere = 'entry_comment_id = :id'; + } elseif ($content instanceof Post) { + $contentWhere = 'post_id = :id'; + } elseif ($content instanceof PostComment) { + $contentWhere = 'post_comment_id = :id'; + } else { + throw new \LogicException(); + } + + $sql = "DELETE FROM bookmark WHERE user_id = :u AND list_id = :l AND $contentWhere"; + $conn = $this->entityManager->getConnection(); + $stmt = $conn->prepare($sql); + $stmt->executeStatement(['u' => $user->getId(), 'l' => $list->getId(), 'id' => $content->getId()]); + } + + public function findPopulatedByList(BookmarkList $list, Criteria $criteria, ?int $perPage = null): PagerfantaInterface + { + $entryWhereArr = ['b.list_id = :list']; + $entryCommentWhereArr = ['b.list_id = :list']; + $postWhereArr = ['b.list_id = :list']; + $postCommentWhereArr = ['b.list_id = :list']; + $parameters = [ + 'list' => $list->getId(), + ]; + + $orderBy = match ($criteria->sortOption) { + Criteria::SORT_OLD => 'ORDER BY i.created_at ASC', + Criteria::SORT_TOP => 'ORDER BY i.score DESC, i.created_at DESC', + Criteria::SORT_HOT => 'ORDER BY i.ranking DESC, i.created_at DESC', + default => 'ORDER BY created_at DESC', + }; + + if (Criteria::AP_LOCAL === $criteria->federation) { + $entryWhereArr[] = 'e.ap_id IS NULL'; + $entryCommentWhereArr[] = 'ec.ap_id IS NULL'; + $postWhereArr[] = 'p.ap_id IS NULL'; + $postCommentWhereArr[] = 'pc.ap_id IS NULL'; + } + + if ('all' !== $criteria->type) { + $entryWhereArr[] = 'e.type = :type'; + $entryCommentWhereArr[] = 'false'; + $postWhereArr[] = 'false'; + $postCommentWhereArr[] = 'false'; + + $parameters['type'] = $criteria->type; + } + + if (Criteria::TIME_ALL !== $criteria->time) { + $entryWhereArr[] = 'b.created_at > :time'; + $entryCommentWhereArr[] = 'b.created_at > :time'; + $postWhereArr[] = 'b.created_at > :time'; + $postCommentWhereArr[] = 'b.created_at > :time'; + + $parameters['time'] = $criteria->getSince(); + } + + $entryWhere = $this->makeWhereString($entryWhereArr); + $entryCommentWhere = $this->makeWhereString($entryCommentWhereArr); + $postWhere = $this->makeWhereString($postWhereArr); + $postCommentWhere = $this->makeWhereString($postCommentWhereArr); + + $sql = " + SELECT * FROM ( + SELECT e.id AS id, e.ap_id AS ap_id, e.score AS score, e.ranking AS ranking, b.created_at AS created_at, 'entry' AS type FROM bookmark b + INNER JOIN entry e ON b.entry_id = e.id $entryWhere + UNION + SELECT ec.id AS id, ec.ap_id AS ap_id, (ec.up_votes + ec.favourite_count - ec.down_votes) AS score, ec.up_votes AS ranking, b.created_at AS created_at, 'entry_comment' AS type FROM bookmark b + INNER JOIN entry_comment ec ON b.entry_comment_id = ec.id $entryCommentWhere + UNION + SELECT p.id AS id, p.ap_id AS ap_id, p.score AS score, p.ranking AS ranking, b.created_at AS created_at, 'post' AS type FROM bookmark b + INNER JOIN post p ON b.post_id = p.id $postWhere + UNION + SELECT pc.id AS id, pc.ap_id AS ap_id, (pc.up_votes + pc.favourite_count - pc.down_votes) AS score, pc.up_votes AS ranking, b.created_at AS created_at, 'post_comment' AS type FROM bookmark b + INNER JOIN post_comment pc ON b.post_comment_id = pc.id $postCommentWhere + ) i $orderBy + "; + + $this->logger->info('bookmark list sql: {sql}', ['sql' => $sql]); + + $conn = $this->entityManager->getConnection(); + $adapter = new NativeQueryAdapter($conn, $sql, $parameters, transformer: $this->transformer); + + return Pagerfanta::createForCurrentPageWithMaxPerPage($adapter, $criteria->page, $perPage ?? EntryRepository::PER_PAGE); + } + + private function makeWhereString(array $whereClauses): string + { + if (empty($whereClauses)) { + return ''; + } + + $where = 'WHERE '; + $i = 0; + foreach ($whereClauses as $whereClause) { + if ($i > 0) { + $where .= ' AND '; + } + $where .= $whereClause; + ++$i; + } + + return $where; + } +} diff --git a/src/Schema/PaginationSchema.php b/src/Schema/PaginationSchema.php index a311d533d..15b1c9021 100644 --- a/src/Schema/PaginationSchema.php +++ b/src/Schema/PaginationSchema.php @@ -5,7 +5,7 @@ namespace App\Schema; use OpenApi\Attributes as OA; -use Pagerfanta\Pagerfanta; +use Pagerfanta\PagerfantaInterface; #[OA\Schema()] class PaginationSchema implements \JsonSerializable @@ -19,7 +19,7 @@ class PaginationSchema implements \JsonSerializable #[OA\Property(description: 'Max number of items per page')] public int $perPage = 0; - public function __construct(Pagerfanta $pagerfanta) + public function __construct(PagerfantaInterface $pagerfanta) { $this->count = $pagerfanta->count(); $this->currentPage = $pagerfanta->getCurrentPage(); diff --git a/src/Service/BookmarkManager.php b/src/Service/BookmarkManager.php new file mode 100644 index 000000000..11529a06b --- /dev/null +++ b/src/Service/BookmarkManager.php @@ -0,0 +1,90 @@ +entityManager->persist($list); + $this->entityManager->flush(); + + return $list; + } + + public function isBookmarked(User $user, Entry|EntryComment|Post|PostComment $content): bool + { + if ($content instanceof Entry) { + return !empty($this->bookmarkRepository->findBy(['user' => $user, 'entry' => $content])); + } elseif ($content instanceof EntryComment) { + return !empty($this->bookmarkRepository->findBy(['user' => $user, 'entryComment' => $content])); + } elseif ($content instanceof Post) { + return !empty($this->bookmarkRepository->findBy(['user' => $user, 'post' => $content])); + } elseif ($content instanceof PostComment) { + return !empty($this->bookmarkRepository->findBy(['user' => $user, 'postComment' => $content])); + } + + return false; + } + + public function isBookmarkedInList(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): bool + { + if ($content instanceof Entry) { + return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'entry' => $content]); + } elseif ($content instanceof EntryComment) { + return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'entryComment' => $content]); + } elseif ($content instanceof Post) { + return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'post' => $content]); + } elseif ($content instanceof PostComment) { + return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'postComment' => $content]); + } + + return false; + } + + public function addBookmarkToDefaultList(User $user, Entry|EntryComment|Post|PostComment $content): void + { + $list = $this->bookmarkListRepository->findOneByUserDefault($user); + $this->addBookmark($user, $list, $content); + } + + public function addBookmark(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): void + { + $bookmark = new Bookmark($user, $list); + $bookmark->setContent($content); + $this->entityManager->persist($bookmark); + $this->entityManager->flush(); + } + + public static function GetClassFromSubjectType(string $subjectType): string + { + return match ($subjectType) { + 'entry' => Entry::class, + 'entry_comment' => EntryComment::class, + 'post' => Post::class, + 'post_comment' => PostComment::class, + default => throw new \LogicException("cannot match type $subjectType") + }; + } +} diff --git a/src/Twig/Components/BookmarkListComponent.php b/src/Twig/Components/BookmarkListComponent.php new file mode 100644 index 000000000..95ac59965 --- /dev/null +++ b/src/Twig/Components/BookmarkListComponent.php @@ -0,0 +1,20 @@ +comment->root?->getId() ?? $this->comment->getId(); - $userId = $this->security->getUser()?->getId(); - - return $this->cache->get( - "entry_comments_nested_{$commentId}_{$userId}_{$this->view}_{$this->requestStack->getCurrentRequest()?->getLocale()}", - function (ItemInterface $item) use ($commentId, $userId) { - $item->expiresAfter(3600); - $item->tag(['entry_comments_user_'.$userId]); - $item->tag(['entry_comment_'.$commentId]); - - return $this->twig->render( - 'components/entry_comments_nested.html.twig', - [ - 'comment' => $this->comment, - 'level' => $this->level, - 'view' => $this->view, - ] - ); - } - ); - } } diff --git a/src/Twig/Components/PostCommentsNestedComponent.php b/src/Twig/Components/PostCommentsNestedComponent.php index 97035da45..f812856e2 100644 --- a/src/Twig/Components/PostCommentsNestedComponent.php +++ b/src/Twig/Components/PostCommentsNestedComponent.php @@ -6,53 +6,12 @@ use App\Controller\User\ThemeSettingsController; use App\Entity\PostComment; -use Symfony\Bundle\SecurityBundle\Security; -use Symfony\Component\HttpFoundation\RequestStack; -use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\Cache\ItemInterface; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; -use Symfony\UX\TwigComponent\ComponentAttributes; -use Twig\Environment; -#[AsTwigComponent('post_comments_nested', template: 'components/_cached.html.twig')] +#[AsTwigComponent('post_comments_nested')] final class PostCommentsNestedComponent { public PostComment $comment; public int $level; public string $view = ThemeSettingsController::TREE; - - public function __construct( - private readonly Environment $twig, - private readonly Security $security, - private readonly CacheInterface $cache, - private readonly RequestStack $requestStack - ) { - } - - public function getHtml(ComponentAttributes $attributes): string - { - $comment = $this->comment->root ?? $this->comment; - $commentId = $comment->getId(); - $postId = $comment->post->getId(); - $userId = $this->security->getUser()?->getId(); - - return $this->cache->get( - "post_comments_nested_{$commentId}_{$userId}_{$this->view}_{$this->requestStack->getCurrentRequest()?->getLocale()}", - function (ItemInterface $item) use ($commentId, $userId, $postId) { - $item->expiresAfter(3600); - $item->tag(['post_comments_user_'.$userId]); - $item->tag(['post_comment_'.$commentId]); - $item->tag(['post_'.$postId]); - - return $this->twig->render( - 'components/post_comments_nested.html.twig', - [ - 'comment' => $this->comment, - 'level' => $this->level, - 'view' => $this->view, - ] - ); - } - ); - } } diff --git a/src/Twig/Extension/BookmarkExtension.php b/src/Twig/Extension/BookmarkExtension.php new file mode 100644 index 000000000..e3ac3367c --- /dev/null +++ b/src/Twig/Extension/BookmarkExtension.php @@ -0,0 +1,22 @@ +bookmarkListRepository->findByUser($user); + } + + public function getBookmarkListEntryCount(BookmarkList $list): int + { + return $list->entities->count(); + } + + public function isContentBookmarked(User $user, Entry|EntryComment|Post|PostComment $content): bool + { + return $this->bookmarkManager->isBookmarked($user, $content); + } + + public function isContentBookmarkedInList(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): bool + { + return $this->bookmarkManager->isBookmarkedInList($user, $list, $content); + } +} diff --git a/src/Twig/Runtime/FrontExtensionRuntime.php b/src/Twig/Runtime/FrontExtensionRuntime.php index 5bb43294c..b52735041 100644 --- a/src/Twig/Runtime/FrontExtensionRuntime.php +++ b/src/Twig/Runtime/FrontExtensionRuntime.php @@ -4,6 +4,10 @@ namespace App\Twig\Runtime; +use App\Entity\Entry; +use App\Entity\EntryComment; +use App\Entity\Post; +use App\Entity\PostComment; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Twig\Extension\RuntimeExtensionInterface; @@ -63,4 +67,24 @@ private function getFrontRoute(string $currentRoute, array $params): string return 'front_short'; } } + + public function getClass(mixed $object): string + { + return \get_class($object); + } + + public function getSubjectType(mixed $object): string + { + if ($object instanceof Entry) { + return 'entry'; + } elseif ($object instanceof EntryComment) { + return 'entry_comment'; + } elseif ($object instanceof Post) { + return 'post'; + } elseif ($object instanceof PostComment) { + return 'post_comment'; + } else { + throw new \LogicException('unknown class '.\get_class($object)); + } + } } diff --git a/templates/bookmark/_form_edit.html.twig b/templates/bookmark/_form_edit.html.twig new file mode 100644 index 000000000..ca58c553f --- /dev/null +++ b/templates/bookmark/_form_edit.html.twig @@ -0,0 +1,14 @@ +{{ form_start(form, {attr: {class: 'bookmark_edit'}}) }} + +{{ form_row(form.name, {label: 'bookmark_list_create_label'}) }} + +
+ | {{ 'name'|trans }} | +{{ 'count'|trans }} | ++ |
---|---|---|---|
+ {% if list.isDefault %} + + {% endif %} + | +{{ list.name }} | +{{ get_bookmark_list_entry_count(list) }} | ++ {% if not list.isDefault %} + + {% endif %} + + + + + + + | +