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..e9f9b0dc9 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'; @@ -35,6 +37,7 @@ @import 'components/figure_image'; @import 'components/figure_lightbox'; @import 'components/post'; +@import 'components/search'; @import 'components/subject'; @import 'components/login'; @import 'components/modlog'; @@ -51,6 +54,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/components/_search.scss b/assets/styles/components/_search.scss new file mode 100644 index 000000000..d179dd372 --- /dev/null +++ b/assets/styles/components/_search.scss @@ -0,0 +1,24 @@ +.search-container { + background: var(--kbin-input-bg); + border: var(--kbin-input-border); + border-radius: var(--kbin-rounded-edges-radius) !important; + + input.form-control { + border-radius: 0 !important; + border: none; + background: transparent; + margin: 0 .5em; + padding: .5rem .25rem; + } + + button { + border-radius: 0 var(--kbin-rounded-edges-radius) var(--kbin-rounded-edges-radius) 0 !important; + border: 0; + padding: 1rem 0.5rem; + + &:not(:hover) { + background: var(--kbin-input-bg); + color: var(--kbin-input-text-color) !important; + } + } +} diff --git a/assets/styles/layout/_forms.scss b/assets/styles/layout/_forms.scss index 8d42c269d..e0e606d7a 100644 --- a/assets/styles/layout/_forms.scss +++ b/assets/styles/layout/_forms.scss @@ -525,3 +525,9 @@ div.input-box { border-radius: var(--kbin-rounded-edges-radius) !important; } } + +.form-control { + display: block; + width: 100%; + +} 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/layout/_layout.scss b/assets/styles/layout/_layout.scss index 789c8f20c..c7b387c12 100644 --- a/assets/styles/layout/_layout.scss +++ b/assets/styles/layout/_layout.scss @@ -214,7 +214,9 @@ figure { code, .ts-control > [data-value].item, .image-preview-container { - border-radius: var(--kbin-rounded-edges-radius) !important; + &:not(.ignore-edges) { + border-radius: var(--kbin-rounded-edges-radius) !important; + } } .ts-wrapper { @@ -361,6 +363,12 @@ figure { gap: .25rem; } +@include media-breakpoint-down(lg) { + .flex.mobile { + display: block; + } +} + .flex-wrap { flex-wrap: wrap; } diff --git a/assets/styles/layout/_section.scss b/assets/styles/layout/_section.scss index 7f44e8166..47e75a8eb 100644 --- a/assets/styles/layout/_section.scss +++ b/assets/styles/layout/_section.scss @@ -68,11 +68,3 @@ color: var(--kbin-alert-danger-text-color); } } - -.page-search { - .section--top { - button { - padding: 1rem 1.5rem; - } - } -} \ No newline at end of file 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/kbin_routes/magazine_api.yaml b/config/kbin_routes/magazine_api.yaml index 518a63a02..f364322cc 100644 --- a/config/kbin_routes/magazine_api.yaml +++ b/config/kbin_routes/magazine_api.yaml @@ -1,6 +1,13 @@ -# Create an article entry in a magazine +# Create a thread entry in a magazine +api_magazine_entry_create_thread: + controller: App\Controller\Api\Entry\MagazineEntryCreateApi::thread + path: /api/magazine/{magazine_id}/thread + methods: [ POST ] + format: json + +# Create thread entry, same as above, for for backwards compatibility, also points to thread method api_magazine_entry_create_article: - controller: App\Controller\Api\Entry\MagazineEntryCreateApi::article + controller: App\Controller\Api\Entry\MagazineEntryCreateApi::thread path: /api/magazine/{magazine_id}/article methods: [ POST ] format: json @@ -148,4 +155,4 @@ api_magazine_modlog: controller: App\Controller\Api\Magazine\MagazineModLogApi::collection path: /api/magazine/{magazine_id}/log methods: [ GET ] - format: json \ No newline at end of file + 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 626baa730..c3dee042c 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 b5924f19a..3754eeb9e 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -78,10 +78,11 @@ parameters: front_sort_options: top|hot|active|newest|oldest|commented # TODO remove fallback after tag rework default_sort_options: top|hot|active|newest|oldest|commented default_time_options: 3h|6h|12h|1d|1w|1m|1y|all|∞ - default_type_options: article|articles|link|links|video|videos|photo|photos|image|images|all + default_type_options: article|articles|thread|threads|link|links|video|videos|photo|photos|image|images|all 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/docs/postman/kbin.postman_collection.json b/docs/postman/kbin.postman_collection.json index 597a5e501..75798c559 100644 --- a/docs/postman/kbin.postman_collection.json +++ b/docs/postman/kbin.postman_collection.json @@ -1367,7 +1367,7 @@ ] }, { - "name": "Create article in magazine", + "name": "Create thread in magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true @@ -1392,7 +1392,7 @@ } }, "url": { - "raw": "https://{{host}}/api/magazine/:magazine_id/article", + "raw": "https://{{host}}/api/magazine/:magazine_id/thread", "protocol": "https", "host": [ "{{host}}" @@ -1401,7 +1401,7 @@ "api", "magazine", ":magazine_id", - "article" + "thread" ], "variable": [ { 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/Api/Entry/EntriesUpdateApi.php b/src/Controller/Api/Entry/EntriesUpdateApi.php index 80221433f..86f49ae0f 100644 --- a/src/Controller/Api/Entry/EntriesUpdateApi.php +++ b/src/Controller/Api/Entry/EntriesUpdateApi.php @@ -65,7 +65,7 @@ class EntriesUpdateApi extends EntriesBaseApi type: EntryRequestDto::class, groups: [ 'common', - Entry::ENTRY_TYPE_ARTICLE, + Entry::ENTRY_TYPE_THREAD, ] ))] #[OA\Tag(name: 'entry')] @@ -84,7 +84,7 @@ public function __invoke( $dto = $this->deserializeEntry($manager->createDto($entry), context: [ 'groups' => [ 'common', - Entry::ENTRY_TYPE_ARTICLE, + Entry::ENTRY_TYPE_THREAD, ], ]); diff --git a/src/Controller/Api/Entry/MagazineEntryCreateApi.php b/src/Controller/Api/Entry/MagazineEntryCreateApi.php index 9c6d08e3f..d31205976 100644 --- a/src/Controller/Api/Entry/MagazineEntryCreateApi.php +++ b/src/Controller/Api/Entry/MagazineEntryCreateApi.php @@ -78,14 +78,14 @@ class MagazineEntryCreateApi extends EntriesBaseApi #[OA\RequestBody(content: new Model( type: EntryRequestDto::class, groups: [ - Entry::ENTRY_TYPE_ARTICLE, + Entry::ENTRY_TYPE_THREAD, 'common', ] ))] #[OA\Tag(name: 'magazine')] #[Security(name: 'oauth2', scopes: ['entry:create'])] #[IsGranted('ROLE_OAUTH2_ENTRY:CREATE')] - public function article( + public function thread( #[MapEntity(id: 'magazine_id')] Magazine $magazine, EntryManager $manager, @@ -95,7 +95,7 @@ public function article( $entry = $this->createEntry($magazine, $manager, context: [ 'groups' => [ - Entry::ENTRY_TYPE_ARTICLE, + Entry::ENTRY_TYPE_THREAD, 'common', ], ]); diff --git a/src/Controller/Api/Search/SearchRetrieveApi.php b/src/Controller/Api/Search/SearchRetrieveApi.php index c92b4a0ec..f2499fcad 100644 --- a/src/Controller/Api/Search/SearchRetrieveApi.php +++ b/src/Controller/Api/Search/SearchRetrieveApi.php @@ -103,6 +103,27 @@ class SearchRetrieveApi extends BaseApi required: true, schema: new OA\Schema(type: 'string') )] + #[OA\Parameter( + name: 'authorId', + description: 'User id of the author', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'magazineId', + description: 'Id of the magazine', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'type', + description: 'The type of content', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string', enum: ['', 'entry', 'post']) + )] #[OA\Tag(name: 'search')] public function __invoke( SearchManager $manager, @@ -122,8 +143,16 @@ public function __invoke( $page = $this->getPageNb($request); $perPage = self::constrainPerPage($request->get('perPage', SearchRepository::PER_PAGE)); + $authorIdRaw = $request->get('authorId'); + $authorId = null === $authorIdRaw ? null : \intval($authorIdRaw); + $magazineIdRaw = $request->get('magazineId'); + $magazineId = null === $magazineIdRaw ? null : \intval($magazineIdRaw); + $type = $request->get('type'); + if ('entry' !== $type && 'post' !== $type && null !== $type) { + throw new BadRequestHttpException(); + } - $items = $manager->findPaginated($this->getUser(), $q, $page, $perPage); + $items = $manager->findPaginated($this->getUser(), $q, $page, $perPage, authorId: $authorId, magazineId: $magazineId, specificType: $type); $dtos = []; foreach ($items->getCurrentPageResults() as $value) { \assert($value instanceof ContentInterface); diff --git a/src/Controller/Api/User/UserRetrieveApi.php b/src/Controller/Api/User/UserRetrieveApi.php index 97ce14fef..ab95009af 100644 --- a/src/Controller/Api/User/UserRetrieveApi.php +++ b/src/Controller/Api/User/UserRetrieveApi.php @@ -275,6 +275,18 @@ public function settings( in: 'query', schema: new OA\Schema(type: 'string', default: UserRepository::USERS_ALL, enum: UserRepository::USERS_OPTIONS) )] + #[OA\Parameter( + name: 'q', + description: 'The term to search for', + in: 'query', + schema: new OA\Schema(type: 'string') + )] + #[OA\Parameter( + name: 'withAbout', + description: 'Only include users with a filled in profile', + in: 'query', + schema: new OA\Schema(type: 'boolean') + )] #[OA\Tag(name: 'user')] public function collection( UserRepository $userRepository, @@ -286,11 +298,15 @@ public function collection( $request = $this->request->getCurrentRequest(); $group = $request->get('group', UserRepository::USERS_ALL); + $withAboutRaw = $request->get('withAbout'); + $withAbout = null === $withAboutRaw ? false : \boolval($withAboutRaw); - $users = $userRepository->findWithAboutPaginated( + $users = $userRepository->findPaginated( $this->getPageNb($request), + $withAbout, $group, - $this->constrainPerPage($request->get('perPage', UserRepository::PER_PAGE)) + $this->constrainPerPage($request->get('perPage', UserRepository::PER_PAGE)), + $request->get('q'), ); $dtos = []; 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/Controller/Entry/EntryFormTrait.php b/src/Controller/Entry/EntryFormTrait.php index 400e47224..d00da5038 100644 --- a/src/Controller/Entry/EntryFormTrait.php +++ b/src/Controller/Entry/EntryFormTrait.php @@ -18,7 +18,7 @@ trait EntryFormTrait { private function createFormByType(string $type, ?EntryDto $dto = null): FormInterface { - if (Entry::ENTRY_TYPE_ARTICLE === $type) { + if (Entry::ENTRY_TYPE_THREAD === $type) { return $this->createForm(EntryArticleType::class, $dto); } diff --git a/src/Controller/Entry/EntryTemplateTrait.php b/src/Controller/Entry/EntryTemplateTrait.php index 514ff2855..2c38aaa6f 100644 --- a/src/Controller/Entry/EntryTemplateTrait.php +++ b/src/Controller/Entry/EntryTemplateTrait.php @@ -12,8 +12,8 @@ private function getTemplateName(?string $type, ?bool $edit = false): string { $prefix = $edit ? 'edit' : 'create'; - if (!$type || Entry::ENTRY_TYPE_ARTICLE === $type) { - return "entry/{$prefix}_article.html.twig"; + if (!$type || Entry::ENTRY_TYPE_THREAD === $type) { + return "entry/{$prefix}_thread.html.twig"; } if (Entry::ENTRY_TYPE_IMAGE === $type) { diff --git a/src/Controller/SearchController.php b/src/Controller/SearchController.php index c7451b83e..1bbe8ea38 100644 --- a/src/Controller/SearchController.php +++ b/src/Controller/SearchController.php @@ -5,8 +5,10 @@ namespace App\Controller; use App\ActivityPub\ActorHandle; +use App\DTO\SearchDto; use App\Entity\Magazine; use App\Entity\User; +use App\Form\SearchType; use App\Message\ActivityPub\Inbox\ActivityMessage; use App\Service\ActivityPub\ApHttpClient; use App\Service\ActivityPubManager; @@ -33,52 +35,63 @@ public function __construct( public function __invoke(Request $request): Response { - $query = $request->query->get('q') ? trim($request->query->get('q')) : null; - - if (!$query) { - return $this->render( - 'search/front.html.twig', - [ - 'objects' => [], - 'results' => [], - 'q' => '', - ] - ); - } - - $this->logger->debug('searching for {query}', ['query' => $query]); - - $objects = []; + $dto = new SearchDto(); + $form = $this->createForm(SearchType::class, $dto, ['csrf_protection' => false]); + try { + $form = $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + /** @var SearchDto $dto */ + $dto = $form->getData(); + $query = $dto->q; + $this->logger->debug('searching for {query}', ['query' => $query]); + + $objects = []; + + // looking up handles (users and mags) + if (str_contains($query, '@') && $this->federatedSearchAllowed()) { + if ($handle = ActorHandle::parse($query)) { + $this->logger->debug('searching for a matched webfinger {query}', ['query' => $query]); + $objects = array_merge($objects, $this->lookupHandle($handle)); + } else { + $this->logger->debug("query doesn't look like a valid handle...", ['query' => $query]); + } + } - // looking up handles (users and mags) - if (str_contains($query, '@') && $this->federatedSearchAllowed()) { - if ($handle = ActorHandle::parse($query)) { - $this->logger->debug('searching for a matched webfinger {query}', ['query' => $query]); - $objects = array_merge($objects, $this->lookupHandle($handle)); - } else { - $this->logger->debug("query doesn't look like a valid handle...", ['query' => $query]); - } - } + // looking up object by AP id (i.e. urls) + if (false !== filter_var($query, FILTER_VALIDATE_URL)) { + $objects = $this->manager->findByApId($query); + if (!$objects) { + $body = $this->apHttpClient->getActivityObject($query, false); + $this->bus->dispatch(new ActivityMessage($body)); + } + } - // looking up object by AP id (i.e. urls) - if (false !== filter_var($query, FILTER_VALIDATE_URL)) { - $objects = $this->manager->findByApId($query); - if (!$objects) { - $body = $this->apHttpClient->getActivityObject($query, false); - $this->bus->dispatch(new ActivityMessage($body)); + $user = $this->getUser(); + $res = $this->manager->findPaginated($user, $query, $this->getPageNb($request), authorId: $dto->user?->getId(), magazineId: $dto->magazine?->getId(), specificType: $dto->type); + + $this->logger->debug('results: {num}', ['num' => $res->count()]); + + return $this->render( + 'search/front.html.twig', + [ + 'objects' => $objects, + 'results' => $this->overviewManager->buildList($res), + 'pagination' => $res, + 'form' => $form->createView(), + 'q' => $query, + ] + ); } + } catch (\Exception $e) { + $this->logger->error($e); } - $user = $this->getUser(); - $res = $this->manager->findPaginated($user, $query, $this->getPageNb($request)); - return $this->render( 'search/front.html.twig', [ - 'objects' => $objects, - 'results' => $this->overviewManager->buildList($res), - 'pagination' => $res, - 'q' => $request->query->get('q'), + 'objects' => [], + 'results' => [], + 'form' => $form->createView(), ] ); } 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/ContentRequestDto.php b/src/DTO/ContentRequestDto.php index b223b1ac3..cce28498c 100644 --- a/src/DTO/ContentRequestDto.php +++ b/src/DTO/ContentRequestDto.php @@ -12,7 +12,7 @@ class ContentRequestDto extends ImageUploadDto { #[Groups([ - Entry::ENTRY_TYPE_ARTICLE, + Entry::ENTRY_TYPE_THREAD, Entry::ENTRY_TYPE_LINK, 'post', 'comment', diff --git a/src/DTO/EntryDto.php b/src/DTO/EntryDto.php index fb8eff5cc..c81ad52b7 100644 --- a/src/DTO/EntryDto.php +++ b/src/DTO/EntryDto.php @@ -31,7 +31,7 @@ class EntryDto implements ContentVisibilityInterface #[Assert\Length(max: Entry::MAX_BODY_LENGTH)] public ?string $body = null; public ?string $lang = null; - public string $type = Entry::ENTRY_TYPE_ARTICLE; + public string $type = Entry::ENTRY_TYPE_THREAD; public int $comments = 0; public int $uv = 0; public int $dv = 0; @@ -145,7 +145,7 @@ public function getType(): string $type = Entry::ENTRY_TYPE_IMAGE; if ($this->body) { - $type = Entry::ENTRY_TYPE_ARTICLE; + $type = Entry::ENTRY_TYPE_THREAD; } return $type; diff --git a/src/DTO/EntryRequestDto.php b/src/DTO/EntryRequestDto.php index 3068fbd45..d23bb8466 100644 --- a/src/DTO/EntryRequestDto.php +++ b/src/DTO/EntryRequestDto.php @@ -13,7 +13,7 @@ class EntryRequestDto extends ContentRequestDto { #[Groups([ - Entry::ENTRY_TYPE_ARTICLE, + Entry::ENTRY_TYPE_THREAD, Entry::ENTRY_TYPE_LINK, Entry::ENTRY_TYPE_IMAGE, Entry::ENTRY_TYPE_VIDEO, @@ -26,7 +26,7 @@ class EntryRequestDto extends ContentRequestDto ])] public ?string $url = null; #[Groups([ - Entry::ENTRY_TYPE_ARTICLE, + Entry::ENTRY_TYPE_THREAD, Entry::ENTRY_TYPE_LINK, Entry::ENTRY_TYPE_IMAGE, Entry::ENTRY_TYPE_VIDEO, @@ -36,7 +36,7 @@ class EntryRequestDto extends ContentRequestDto // TODO: Support badges whenever/however they're implemented // #[Groups([ - // Entry::ENTRY_TYPE_ARTICLE, + // Entry::ENTRY_TYPE_THREAD, // Entry::ENTRY_TYPE_LINK, // Entry::ENTRY_TYPE_IMAGE, // Entry::ENTRY_TYPE_VIDEO, @@ -45,7 +45,7 @@ class EntryRequestDto extends ContentRequestDto // public ?array $badges = null; #[Groups([ - Entry::ENTRY_TYPE_ARTICLE, + Entry::ENTRY_TYPE_THREAD, Entry::ENTRY_TYPE_LINK, Entry::ENTRY_TYPE_IMAGE, Entry::ENTRY_TYPE_VIDEO, diff --git a/src/DTO/EntryResponseDto.php b/src/DTO/EntryResponseDto.php index 8d761a624..40e06db6a 100644 --- a/src/DTO/EntryResponseDto.php +++ b/src/DTO/EntryResponseDto.php @@ -40,7 +40,7 @@ class EntryResponseDto implements \JsonSerializable public ?\DateTimeImmutable $createdAt = null; public ?\DateTimeImmutable $editedAt = null; public ?\DateTime $lastActive = null; - #[OA\Property(example: Entry::ENTRY_TYPE_ARTICLE, enum: Entry::ENTRY_TYPE_OPTIONS)] + #[OA\Property(example: Entry::ENTRY_TYPE_THREAD, enum: Entry::ENTRY_TYPE_OPTIONS)] public ?string $type = null; public ?string $slug = null; public ?string $apId = null; 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/DTO/SearchDto.php b/src/DTO/SearchDto.php index c9070f0df..de26533c6 100644 --- a/src/DTO/SearchDto.php +++ b/src/DTO/SearchDto.php @@ -4,7 +4,13 @@ namespace App\DTO; +use App\Entity\Magazine; +use App\Entity\User; + class SearchDto { - public string $val; + public string $q; + public ?string $type = null; + public ?User $user = null; + public ?Magazine $magazine = null; } 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/Entry.php b/src/Entity/Entry.php index 75f96955d..745ab6a97 100644 --- a/src/Entity/Entry.php +++ b/src/Entity/Entry.php @@ -56,12 +56,12 @@ class Entry implements VotableInterface, CommentInterface, DomainInterface, Visi CreatedAtTrait::__construct as createdAtTraitConstruct; } - public const ENTRY_TYPE_ARTICLE = 'article'; + public const ENTRY_TYPE_THREAD = 'thread'; public const ENTRY_TYPE_LINK = 'link'; public const ENTRY_TYPE_IMAGE = 'image'; public const ENTRY_TYPE_VIDEO = 'video'; public const ENTRY_TYPE_OPTIONS = [ - self::ENTRY_TYPE_ARTICLE, + self::ENTRY_TYPE_THREAD, self::ENTRY_TYPE_LINK, self::ENTRY_TYPE_IMAGE, self::ENTRY_TYPE_VIDEO, @@ -90,7 +90,7 @@ class Entry implements VotableInterface, CommentInterface, DomainInterface, Visi #[Column(type: 'text', length: self::MAX_BODY_LENGTH, nullable: true)] public ?string $body = null; #[Column(type: 'string', nullable: false)] - public string $type = self::ENTRY_TYPE_ARTICLE; + public string $type = self::ENTRY_TYPE_THREAD; #[Column(type: 'string', nullable: false)] public string $lang = 'en'; #[Column(type: 'boolean', options: ['default' => false])] 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/Form/SearchType.php b/src/Form/SearchType.php new file mode 100644 index 000000000..5d7410e50 --- /dev/null +++ b/src/Form/SearchType.php @@ -0,0 +1,36 @@ +setMethod('GET') + ->add('q', TextType::class, [ + 'required' => true, + 'attr' => [ + 'placeholder' => 'type_search_term', + ], + ]) + ->add('magazine', MagazineAutocompleteType::class, ['required' => false]) + ->add('user', UserAutocompleteType::class, ['required' => false]) + ->add('type', ChoiceType::class, [ + 'choices' => [ + 'search_type_all' => null, + 'search_type_entry' => 'entry', + 'search_type_post' => 'post', + ], + ]); + } +} diff --git a/src/Form/Type/UserAutocompleteType.php b/src/Form/Type/UserAutocompleteType.php new file mode 100644 index 000000000..d1cc01909 --- /dev/null +++ b/src/Form/Type/UserAutocompleteType.php @@ -0,0 +1,59 @@ +setDefaults([ + 'class' => User::class, + 'choice_label' => 'username', + 'placeholder' => 'select_user', + 'filter_query' => function (QueryBuilder $qb, string $query) { + if ($currentUser = $this->security->getUser()) { + $qb + ->andWhere( + \sprintf( + 'entity.id NOT IN (SELECT IDENTITY(ub.blocked) FROM %s ub WHERE ub.blocker = :user)', + UserBlock::class, + ) + ) + ->setParameter('user', $currentUser); + } + + if (!$query) { + return; + } + + $qb->andWhere('entity.username LIKE :filter') + ->andWhere('entity.visibility = :visibility') + ->setParameter('filter', '%'.$query.'%') + ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) + ; + }, + ]); + } + + public function getParent(): string + { + return BaseEntityAutocompleteType::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..e8982f4c6 --- /dev/null +++ b/src/Repository/BookmarkRepository.php @@ -0,0 +1,162 @@ +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 = SqlHelpers::makeWhereString($entryWhereArr); + $entryCommentWhere = SqlHelpers::makeWhereString($entryCommentWhereArr); + $postWhere = SqlHelpers::makeWhereString($postWhereArr); + $postCommentWhere = SqlHelpers::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); + } +} diff --git a/src/Repository/Criteria.php b/src/Repository/Criteria.php index ce495155f..530033033 100644 --- a/src/Repository/Criteria.php +++ b/src/Repository/Criteria.php @@ -231,7 +231,7 @@ public function resolveTime(?string $value, bool $reverse = false): ?string public function resolveType(?string $value): ?string { return match ($value) { - 'article', 'articles' => Entry::ENTRY_TYPE_ARTICLE, + 'article', 'articles', 'thread' => Entry::ENTRY_TYPE_THREAD, 'link', 'links' => Entry::ENTRY_TYPE_LINK, 'video', 'videos' => Entry::ENTRY_TYPE_VIDEO, 'photo', 'photos', 'image', 'images' => Entry::ENTRY_TYPE_IMAGE, @@ -242,7 +242,7 @@ public function resolveType(?string $value): ?string public function translateType(): string { return match ($this->resolveType($this->type)) { - Entry::ENTRY_TYPE_ARTICLE => 'threads', + Entry::ENTRY_TYPE_THREAD => 'threads', Entry::ENTRY_TYPE_LINK => 'links', Entry::ENTRY_TYPE_VIDEO => 'videos', Entry::ENTRY_TYPE_IMAGE => 'photos', diff --git a/src/Repository/EntryRepository.php b/src/Repository/EntryRepository.php index 2dfcbe7e8..cc7540e2f 100644 --- a/src/Repository/EntryRepository.php +++ b/src/Repository/EntryRepository.php @@ -24,6 +24,7 @@ use App\PageView\EntryPageView; use App\Pagination\AdapterFactory; use App\Service\SettingsManager; +use App\Utils\SqlHelpers; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Types\Types; @@ -56,6 +57,7 @@ public function __construct( private readonly CacheInterface $cache, private readonly AdapterFactory $adapterFactory, private readonly SettingsManager $settingsManager, + private readonly SqlHelpers $sqlHelpers ) { parent::__construct($registry, Entry::class); } @@ -151,6 +153,7 @@ private function addBannedHashtagClause(QueryBuilder $qb): void private function filter(QueryBuilder $qb, EntryPageView $criteria): QueryBuilder { + /** @var User $user */ $user = $this->security->getUser(); if (Criteria::AP_LOCAL === $criteria->federation) { @@ -339,12 +342,11 @@ public function findToDelete(User $user, int $limit): array ->getResult(); } - public function findRelatedByTag(string $tag, ?int $limit = 1): array + public function findRelatedByTag(string $tag, ?int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('e'); - return $qb - ->andWhere('e.visibility = :visibility') + $qb->andWhere('e.visibility = :visibility') ->andWhere('m.visibility = :visibility') ->andWhere('u.visibility = :visibility') ->andWhere('u.isDeleted = false') @@ -360,16 +362,23 @@ public function findRelatedByTag(string $tag, ?int $limit = 1): array 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE, 'tag' => $tag, ]) - ->setMaxResults($limit) - ->getQuery() + ->setMaxResults($limit); + + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + + return $qb->getQuery() ->getResult(); } - public function findRelatedByMagazine(string $name, ?int $limit = 1): array + public function findRelatedByMagazine(string $name, ?int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('e'); - return $qb->where('m.name LIKE :name OR m.title LIKE :title') + $qb->where('m.name LIKE :name OR m.title LIKE :title') ->andWhere('e.visibility = :visibility') ->andWhere('m.visibility = :visibility') ->andWhere('u.visibility = :visibility') @@ -382,12 +391,19 @@ public function findRelatedByMagazine(string $name, ?int $limit = 1): array ->setParameters( ['name' => "%{$name}%", 'title' => "%{$name}%", 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE] ) - ->setMaxResults($limit) - ->getQuery() + ->setMaxResults($limit); + + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + + return $qb->getQuery() ->getResult(); } - public function findLast(int $limit): array + public function findLast(int $limit, ?User $user = null): array { $qb = $this->createQueryBuilder('e'); @@ -401,10 +417,16 @@ public function findLast(int $limit): array $qb = $qb->andWhere('m.apId IS NULL'); } + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + return $qb->join('e.magazine', 'm') ->join('e.user', 'u') ->orderBy('e.createdAt', 'DESC') - ->setParameters(['visibility' => VisibilityInterface::VISIBILITY_VISIBLE]) + ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->setMaxResults($limit) ->getQuery() ->getResult(); diff --git a/src/Repository/MagazineRepository.php b/src/Repository/MagazineRepository.php index 0f42034f4..ebd92131e 100644 --- a/src/Repository/MagazineRepository.php +++ b/src/Repository/MagazineRepository.php @@ -16,6 +16,7 @@ use App\Entity\User; use App\PageView\MagazinePageView; use App\Service\SettingsManager; +use App\Utils\SqlHelpers; use App\Utils\SubscriptionSort; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Common\Collections\Collection; @@ -49,7 +50,7 @@ class MagazineRepository extends ServiceEntityRepository self::SORT_NEWEST, ]; - public function __construct(ManagerRegistry $registry, private readonly SettingsManager $settingsManager) + public function __construct(ManagerRegistry $registry, private readonly SettingsManager $settingsManager, private readonly SqlHelpers $sqlHelpers) { parent::__construct($registry, Magazine::class); } @@ -478,21 +479,23 @@ public function search(string $magazine, int $page, int $perPage = self::PER_PAG return $pagerfanta; } - public function findRandom(): array + public function findRandom(?User $user = null): array { $conn = $this->getEntityManager()->getConnection(); - $sql = ' - SELECT id FROM magazine - '; + $whereClauses = []; + $parameters = []; if ($this->settingsManager->get('MBIN_SIDEBAR_SECTIONS_LOCAL_ONLY')) { - $sql .= 'WHERE ap_id IS NULL'; + $whereClauses[] = 'm.ap_id IS NULL'; + } + if (null !== $user) { + $subSql = 'SELECT * FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :user'; + $whereClauses[] = "NOT EXISTS($subSql)"; + $parameters['user'] = $user->getId(); } - $sql .= ' - ORDER BY random() - LIMIT 5 - '; + $whereString = SqlHelpers::makeWhereString($whereClauses); + $sql = "SELECT m.id FROM magazine m $whereString ORDER BY random() LIMIT 5"; $stmt = $conn->prepare($sql); - $stmt = $stmt->executeQuery(); + $stmt = $stmt->executeQuery($parameters); $ids = $stmt->fetchAllAssociative(); return $this->createQueryBuilder('m') @@ -505,17 +508,23 @@ public function findRandom(): array ->getResult(); } - public function findRelated(string $magazine): array + public function findRelated(string $magazine, ?User $user = null): array { - return $this->createQueryBuilder('m') + $qb = $this->createQueryBuilder('m') ->where('m.entryCount > 0 OR m.postCount > 0') ->andWhere('m.title LIKE :magazine OR m.description LIKE :magazine OR m.name LIKE :magazine') ->andWhere('m.isAdult = false') ->andWhere('m.visibility = :visibility') ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->setParameter('magazine', "%{$magazine}%") - ->setMaxResults(5) - ->getQuery() + ->setMaxResults(5); + + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))); + $qb->setParameter('user', $user); + } + + return $qb->getQuery() ->getResult(); } diff --git a/src/Repository/PostRepository.php b/src/Repository/PostRepository.php index 870b20490..380e7470d 100644 --- a/src/Repository/PostRepository.php +++ b/src/Repository/PostRepository.php @@ -23,6 +23,7 @@ use App\PageView\PostPageView; use App\Pagination\AdapterFactory; use App\Service\SettingsManager; +use App\Utils\SqlHelpers; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Types\Types; @@ -54,6 +55,7 @@ public function __construct( private readonly CacheInterface $cache, private readonly AdapterFactory $adapterFactory, private readonly SettingsManager $settingsManager, + private readonly SqlHelpers $sqlHelpers, ) { parent::__construct($registry, Post::class); } @@ -143,6 +145,7 @@ private function addBannedHashtagClause(QueryBuilder $qb): void private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder { + /** @var User|null $user */ $user = $this->security->getUser(); if (Criteria::AP_LOCAL === $criteria->federation) { @@ -168,8 +171,8 @@ private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder if ($criteria->subscribed) { $qb->andWhere( - 'EXISTS (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :user AND ms.magazine = p.magazine) - OR + 'EXISTS (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :user AND ms.magazine = p.magazine) + OR EXISTS (SELECT IDENTITY(uf.following) FROM '.UserFollow::class.' uf WHERE uf.follower = :user AND uf.following = p.user) OR p.user = :user' @@ -307,11 +310,11 @@ public function findToDelete(User $user, int $limit): array ->getResult(); } - public function findRelatedByTag(string $tag, ?int $limit = 1): array + public function findRelatedByTag(string $tag, ?int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('p'); - return $qb + $qb = $qb ->andWhere('p.visibility = :visibility') ->andWhere('m.visibility = :visibility') ->andWhere('u.visibility = :visibility') @@ -328,16 +331,23 @@ public function findRelatedByTag(string $tag, ?int $limit = 1): array 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE, 'name' => $tag, ]) - ->setMaxResults($limit) - ->getQuery() + ->setMaxResults($limit); + + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + + return $qb->getQuery() ->getResult(); } - public function findRelatedByMagazine(string $name, ?int $limit = 1): array + public function findRelatedByMagazine(string $name, ?int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('p'); - return $qb->where('m.name LIKE :name OR m.title LIKE :title') + $qb = $qb->where('m.name LIKE :name OR m.title LIKE :title') ->andWhere('p.visibility = :visibility') ->andWhere('m.visibility = :visibility') ->andWhere('u.visibility = :visibility') @@ -349,12 +359,19 @@ public function findRelatedByMagazine(string $name, ?int $limit = 1): array ->setParameters( ['name' => "%{$name}%", 'title' => "%{$name}%", 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE] ) - ->setMaxResults($limit) - ->getQuery() + ->setMaxResults($limit); + + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + + return $qb->getQuery() ->getResult(); } - public function findLast(int $limit = 1): array + public function findLast(int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('p'); @@ -365,9 +382,16 @@ public function findLast(int $limit = 1): array $qb = $qb->andWhere('m.apId IS NULL'); } + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + return $qb->join('p.magazine', 'm') + ->join('p.user', 'u') ->orderBy('p.createdAt', 'DESC') - ->setParameters(['visibility' => VisibilityInterface::VISIBILITY_VISIBLE]) + ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->setMaxResults($limit) ->getQuery() ->getResult(); diff --git a/src/Repository/SearchRepository.php b/src/Repository/SearchRepository.php index a5d89e5eb..84a412133 100644 --- a/src/Repository/SearchRepository.php +++ b/src/Repository/SearchRepository.php @@ -13,6 +13,7 @@ use Doctrine\ORM\EntityManagerInterface; use Pagerfanta\Pagerfanta; use Pagerfanta\PagerfantaInterface; +use Psr\Log\LoggerInterface; class SearchRepository { @@ -21,6 +22,7 @@ class SearchRepository public function __construct( private readonly EntityManagerInterface $entityManager, private readonly ContentPopulationTransformer $transformer, + private readonly LoggerInterface $logger, ) { } @@ -79,10 +81,15 @@ public function findBoosts(int $page, User $user): PagerfantaInterface return $pagerfanta; } - public function search(?User $searchingUser, string $query, int $page = 1): PagerfantaInterface + /** + * @param 'entry'|'post'|null $specificType + */ + public function search(?User $searchingUser, string $query, int $page = 1, ?int $authorId = null, ?int $magazineId = null, ?string $specificType = null): PagerfantaInterface { + $authorWhere = null !== $authorId ? 'AND e.user_id = :authorId' : ''; + $magazineWhere = null !== $magazineId ? 'AND e.magazine_id = :magazineId' : ''; $conn = $this->entityManager->getConnection(); - $sql = "SELECT e.id, e.created_at, e.visibility, 'entry' AS type FROM entry e + $sqlEntry = "SELECT e.id, e.created_at, e.visibility, 'entry' AS type FROM entry e INNER JOIN public.user u ON u.id = user_id INNER JOIN magazine m ON e.magazine_id = m.id WHERE (body_ts @@ plainto_tsquery( :query ) = true OR title_ts @@ plainto_tsquery( :query ) = true) @@ -91,6 +98,7 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.entry_id = e.id) + $authorWhere $magazineWhere UNION ALL SELECT e.id, e.created_at, e.visibility, 'entry_comment' AS type FROM entry_comment e INNER JOIN public.user u ON u.id = user_id @@ -101,8 +109,9 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.entry_comment_id = e.id) - UNION ALL - SELECT e.id, e.created_at, e.visibility, 'post' AS type FROM post e + $authorWhere $magazineWhere + "; + $sqlPost = "SELECT e.id, e.created_at, e.visibility, 'post' AS type FROM post e INNER JOIN public.user u ON u.id = user_id INNER JOIN magazine m ON e.magazine_id = m.id WHERE body_ts @@ plainto_tsquery( :query ) = true @@ -111,6 +120,7 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.post_id = e.id) + $authorWhere $magazineWhere UNION ALL SELECT e.id, e.created_at, e.visibility, 'post_comment' AS type FROM post_comment e INNER JOIN public.user u ON u.id = user_id @@ -121,12 +131,38 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.post_comment_id = e.id) - ORDER BY created_at DESC"; - $adapter = new NativeQueryAdapter($conn, $sql, [ + $authorWhere $magazineWhere + "; + + if (null === $specificType) { + $sql = "$sqlEntry UNION ALL $sqlPost ORDER BY created_at DESC"; + } else { + if ('entry' === $specificType) { + $sql = "$sqlEntry ORDER BY created_at DESC"; + } elseif ('post' === $specificType) { + $sql = "$sqlPost ORDER BY created_at DESC"; + } else { + throw new \LogicException($specificType.' is not supported'); + } + } + + $this->logger->debug('Search query: {sql}', ['sql' => $sql]); + + $parameters = [ 'query' => $query, 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE, 'queryingUser' => $searchingUser?->getId() ?? -1, - ], transformer: $this->transformer); + ]; + + if (null !== $authorId) { + $parameters['authorId'] = $authorId; + } + + if (null !== $magazineId) { + $parameters['magazineId'] = $magazineId; + } + + $adapter = new NativeQueryAdapter($conn, $sql, $parameters, transformer: $this->transformer); $pagerfanta = new Pagerfanta($adapter); $pagerfanta->setCurrentPage($page); diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 2c7c91dda..2b714b151 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -477,12 +477,9 @@ private function findUsersQueryBuilder(string $group, ?bool $recentlyActive = tr ->orderBy('u.lastActive', 'DESC'); } - public function findWithAboutPaginated( - int $page, - string $group = self::USERS_ALL, - int $perPage = self::PER_PAGE - ): PagerfantaInterface { - $query = $this->findWithAboutQueryBuilder($group)->getQuery(); + public function findPaginated(int $page, bool $needsAbout, string $group = self::USERS_ALL, int $perPage = self::PER_PAGE, ?string $query = null): PagerfantaInterface + { + $query = $this->findQueryBuilder($group, $query, $needsAbout)->getQuery(); $pagerfanta = new Pagerfanta( new QueryAdapter( @@ -500,11 +497,19 @@ public function findWithAboutPaginated( return $pagerfanta; } - private function findWithAboutQueryBuilder(string $group): QueryBuilder + private function findQueryBuilder(string $group, ?string $query, bool $needsAbout): QueryBuilder { - $qb = $this->createQueryBuilder('u') - ->andWhere('u.about != \'\'') - ->andWhere('u.about IS NOT NULL'); + $qb = $this->createQueryBuilder('u'); + + if ($needsAbout) { + $qb->andWhere('u.about != \'\'') + ->andWhere('u.about IS NOT NULL'); + } + + if (null !== $query) { + $qb->andWhere('u.username LIKE :query') + ->setParameter('query', '%'.$query.'%'); + } switch ($group) { case self::USERS_LOCAL: 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/Service/EntryManager.php b/src/Service/EntryManager.php index 0d600a1f9..6ff9775ed 100644 --- a/src/Service/EntryManager.php +++ b/src/Service/EntryManager.php @@ -164,7 +164,7 @@ private function setType(EntryDto $dto, Entry $entry): Entry } if ($dto->body) { - $entry->type = Entry::ENTRY_TYPE_ARTICLE; + $entry->type = Entry::ENTRY_TYPE_THREAD; $entry->hasEmbed = false; } diff --git a/src/Service/SearchManager.php b/src/Service/SearchManager.php index 8b62a943f..fbdae9916 100644 --- a/src/Service/SearchManager.php +++ b/src/Service/SearchManager.php @@ -47,9 +47,9 @@ public function findDomainsPaginated(string $domain, int $page = 1, int $perPage return $this->domainRepository->search($domain, $page, $perPage); } - public function findPaginated(?User $queryingUser, string $val, int $page = 1, int $perPage = SearchRepository::PER_PAGE): PagerfantaInterface + public function findPaginated(?User $queryingUser, string $val, int $page = 1, int $perPage = SearchRepository::PER_PAGE, ?int $authorId = null, ?int $magazineId = null, ?string $specificType = null): PagerfantaInterface { - return $this->repository->search($queryingUser, $val, $page, $perPage); + return $this->repository->search($queryingUser, $val, $page, authorId: $authorId, magazineId: $magazineId, specificType: $specificType); } public function findByApId(string $url): array 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/Components/RelatedEntriesComponent.php b/src/Twig/Components/RelatedEntriesComponent.php index febe94156..4615006c3 100644 --- a/src/Twig/Components/RelatedEntriesComponent.php +++ b/src/Twig/Components/RelatedEntriesComponent.php @@ -5,9 +5,11 @@ namespace App\Twig\Components; use App\Entity\Entry; +use App\Entity\User; use App\Repository\EntryRepository; use App\Service\MentionManager; use App\Service\SettingsManager; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; @@ -31,7 +33,8 @@ public function __construct( private readonly EntryRepository $repository, private readonly CacheInterface $cache, private readonly SettingsManager $settingsManager, - private readonly MentionManager $mentionManager + private readonly MentionManager $mentionManager, + private readonly Security $security, ) { } @@ -49,19 +52,23 @@ public function mount(?string $magazine, ?string $tag): void $entryId = $this->entry?->getId(); $magazine = str_replace('@', '', $magazine ?? ''); + /** @var User|null $user */ + $user = $this->security->getUser(); + $cacheKey = "related_entries_{$magazine}_{$tag}_{$entryId}_{$this->type}_{$this->settingsManager->getLocale()}_{$user?->getId()}"; $entryIds = $this->cache->get( - "related_entries_{$magazine}_{$tag}_{$entryId}_{$this->type}_{$this->settingsManager->getLocale()}", - function (ItemInterface $item) use ($magazine, $tag) { + $cacheKey, + function (ItemInterface $item) use ($magazine, $tag, $user) { $item->expiresAfter(60 * 5); // 5 minutes $entries = match ($this->type) { - self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20), + self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20, user: $user), self::TYPE_MAGAZINE => $this->repository->findRelatedByTag( $this->mentionManager->getUsername($magazine), - $this->limit + 20 + $this->limit + 20, + user: $user, ), - default => $this->repository->findLast($this->limit + 150), + default => $this->repository->findLast($this->limit + 150, user: $user), }; $entries = array_filter($entries, fn (Entry $e) => !$e->isAdult && !$e->magazine->isAdult); diff --git a/src/Twig/Components/RelatedMagazinesComponent.php b/src/Twig/Components/RelatedMagazinesComponent.php index 741dea67f..0871cda57 100644 --- a/src/Twig/Components/RelatedMagazinesComponent.php +++ b/src/Twig/Components/RelatedMagazinesComponent.php @@ -5,8 +5,10 @@ namespace App\Twig\Components; use App\Entity\Magazine; +use App\Entity\User; use App\Repository\MagazineRepository; use App\Service\SettingsManager; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; @@ -28,6 +30,7 @@ public function __construct( private readonly MagazineRepository $repository, private readonly CacheInterface $cache, private readonly SettingsManager $settingsManager, + private readonly Security $security, ) { } @@ -44,16 +47,18 @@ public function mount(?string $magazine, ?string $tag): void } $magazine = str_replace('@', '', $magazine ?? ''); + /** @var User|null $user */ + $user = $this->security->getUser(); $magazineIds = $this->cache->get( - "related_magazines_{$magazine}_{$tag}_{$this->type}_{$this->settingsManager->getLocale()}", - function (ItemInterface $item) use ($magazine, $tag) { + "related_magazines_{$magazine}_{$tag}_{$this->type}_{$this->settingsManager->getLocale()}_{$user?->getId()}", + function (ItemInterface $item) use ($magazine, $tag, $user) { $item->expiresAfter(60 * 5); // 5 minutes $magazines = match ($this->type) { - self::TYPE_TAG => $this->repository->findRelated($tag), - self::TYPE_MAGAZINE => $this->repository->findRelated($magazine), - default => $this->repository->findRandom(), + self::TYPE_TAG => $this->repository->findRelated($tag, user: $user), + self::TYPE_MAGAZINE => $this->repository->findRelated($magazine, user: $user), + default => $this->repository->findRandom(user: $user), }; $magazines = array_filter($magazines, fn ($m) => $m->name !== $magazine); diff --git a/src/Twig/Components/RelatedPostsComponent.php b/src/Twig/Components/RelatedPostsComponent.php index bfc1d944d..a5a033950 100644 --- a/src/Twig/Components/RelatedPostsComponent.php +++ b/src/Twig/Components/RelatedPostsComponent.php @@ -5,9 +5,11 @@ namespace App\Twig\Components; use App\Entity\Post; +use App\Entity\User; use App\Repository\PostRepository; use App\Service\MentionManager; use App\Service\SettingsManager; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; @@ -30,7 +32,8 @@ public function __construct( private readonly PostRepository $repository, private readonly CacheInterface $cache, private readonly SettingsManager $settingsManager, - private readonly MentionManager $mentionManager + private readonly MentionManager $mentionManager, + private readonly Security $security, ) { } @@ -46,21 +49,25 @@ public function mount(?string $magazine, ?string $tag): void $this->type = self::TYPE_MAGAZINE; } + /** @var User|null $user */ + $user = $this->security->getUser(); + $postId = $this->post?->getId(); $magazine = str_replace('@', '', $magazine ?? ''); $postIds = $this->cache->get( - "related_posts_{$magazine}_{$tag}_{$postId}_{$this->type}_{$this->settingsManager->getLocale()}", - function (ItemInterface $item) use ($magazine, $tag) { + "related_posts_{$magazine}_{$tag}_{$postId}_{$this->type}_{$this->settingsManager->getLocale()}_{$user?->getId()}", + function (ItemInterface $item) use ($magazine, $tag, $user) { $item->expiresAfter(60 * 5); // 5 minutes $posts = match ($this->type) { - self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20), + self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20, user: $user), self::TYPE_MAGAZINE => $this->repository->findRelatedByTag( $this->mentionManager->getUsername($magazine), - $this->limit + 20 + $this->limit + 20, + user: $user ), - default => $this->repository->findLast($this->limit + 150), + default => $this->repository->findLast($this->limit + 150, user: $user), }; $posts = array_filter($posts, fn (Post $p) => !$p->isAdult && !$p->magazine->isAdult); 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/src/Utils/SqlHelpers.php b/src/Utils/SqlHelpers.php new file mode 100644 index 000000000..39e9de186 --- /dev/null +++ b/src/Utils/SqlHelpers.php @@ -0,0 +1,59 @@ + 0) { + $where .= ' AND '; + } + $where .= $whereClause; + ++$i; + } + + return $where; + } + + public function getBlockedMagazinesDql(User $user): string + { + return $this->entityManager->createQueryBuilder() + ->select('bm') + ->from(MagazineBlock::class, 'bm') + ->where('bm.magazine = m') + ->andWhere('bm.user = :user') + ->setParameter('user', $user) + ->getDQL(); + } + + public function getBlockedUsersDql(User $user): string + { + return $this->entityManager->createQueryBuilder() + ->select('ub') + ->from(UserBlock::class, 'ub') + ->where('ub.blocker = :user') + ->andWhere('ub.blocked = u') + ->setParameter('user', $user) + ->getDql(); + } +} 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'}) }} + +
+ {{ form_row(form.isDefault, {label: 'bookmark_list_make_default', row_attr: {class: 'checkbox'}}) }} +
+ +
+ {% set btn_label = is_create ? 'bookmark_list_create' : 'bookmark_list_edit' %} + {{ form_row(form.submit, {label: btn_label, attr: {class: 'btn btn__primary'}}) }} +
+ +{{ form_end(form) }} diff --git a/templates/bookmark/_options.html.twig b/templates/bookmark/_options.html.twig new file mode 100644 index 000000000..d57833c0f --- /dev/null +++ b/templates/bookmark/_options.html.twig @@ -0,0 +1,231 @@ +{% set showFilterLabels = app.request.cookies.get('kbin_general_filter_labels')|default('on') %} + + diff --git a/templates/bookmark/edit.html.twig b/templates/bookmark/edit.html.twig new file mode 100644 index 000000000..4aec0c0b8 --- /dev/null +++ b/templates/bookmark/edit.html.twig @@ -0,0 +1,24 @@ +{% extends 'base.html.twig' %} + +{%- block title -%} + {{- 'bookmarks_list'|trans({'%list%': list.name}) }} - {{ parent() -}} +{%- endblock -%} + +{% block mainClass %}page-bookmarks{% endblock %} + +{% block header_nav %} +{% endblock %} + +{% block sidebar_top %} +{% endblock %} + +{% block body %} +

{{ 'bookmarks_list_edit'|trans }}

+ +
+
+ {% include 'bookmark/_form_edit.html.twig' with {is_create: false} %} +
+
+ +{% endblock %} diff --git a/templates/bookmark/front.html.twig b/templates/bookmark/front.html.twig new file mode 100644 index 000000000..627370b74 --- /dev/null +++ b/templates/bookmark/front.html.twig @@ -0,0 +1,25 @@ +{% extends 'base.html.twig' %} + +{%- block title -%} + {{- 'bookmarks_list'|trans({'%list%': list.name}) }} - {{ parent() -}} +{%- endblock -%} + +{% block mainClass %}page-bookmarks{% endblock %} + +{% block header_nav %} +{% endblock %} + +{% block sidebar_top %} +{% endblock %} + +{% block body %} +

{{ 'bookmarks'|trans }}

+ + {% include 'bookmark/_options.html.twig' %} +
+ {% include 'layout/_subject_list.html.twig' %} +
+{% endblock %} diff --git a/templates/bookmark/overview.html.twig b/templates/bookmark/overview.html.twig new file mode 100644 index 000000000..4972ea4e6 --- /dev/null +++ b/templates/bookmark/overview.html.twig @@ -0,0 +1,77 @@ +{% extends 'base.html.twig' %} + +{%- block title -%} + {{- 'bookmark_lists'|trans }} - {{ parent() -}} +{%- endblock -%} + +{% block mainClass %}page-bookmark-lists{% endblock %} + +{% block header_nav %} +{% endblock %} + +{% block sidebar_top %} +{% endblock %} + +{% block body %} +

{{ 'bookmark_lists'|trans }}

+ +
+
+ {% include('bookmark/_form_edit.html.twig') with {is_create: true} %} +
+
+ + {% if lists|length %} +
+
+ + + + + + + + + + + {% for list in lists %} + + + + + + + {% endfor %} + +
{{ 'name'|trans }}{{ 'count'|trans }}
+ {% if list.isDefault %} + + {% endif %} + {{ list.name }}{{ get_bookmark_list_entry_count(list) }} + {% if not list.isDefault %} +
+ + +
+ {% endif %} + + + + + + +
+
+
+ {% else %} + + {% endif %} +{% endblock %} diff --git a/templates/components/bookmark_list.html.twig b/templates/components/bookmark_list.html.twig new file mode 100644 index 000000000..be65c8849 --- /dev/null +++ b/templates/components/bookmark_list.html.twig @@ -0,0 +1,19 @@ +
  • + {% if is_bookmarked_in_list(app.user, list, subject) %} + + + {{ 'bookmark_remove_from_list'|trans({'%list%': list.name}) }} + + {% else %} + + + {{ 'bookmark_add_to_list'|trans({'%list%': list.name}) }} + + {% endif %} +
  • diff --git a/templates/components/bookmark_menu_list.html.twig b/templates/components/bookmark_menu_list.html.twig new file mode 100644 index 000000000..3a6126616 --- /dev/null +++ b/templates/components/bookmark_menu_list.html.twig @@ -0,0 +1,5 @@ +
    + {% for list in bookmarkLists %} + {{ component('bookmark_list', { subject: subject, list: list }) }} + {% endfor %} +
    diff --git a/templates/components/bookmark_standard.html.twig b/templates/components/bookmark_standard.html.twig new file mode 100644 index 000000000..d8d502a9e --- /dev/null +++ b/templates/components/bookmark_standard.html.twig @@ -0,0 +1,17 @@ +
  • + {% if is_bookmarked(app.user, subject) %} + + + + {% else %} + + + + {% endif %} +
  • diff --git a/templates/components/entry.html.twig b/templates/components/entry.html.twig index 59cd3242f..c58c9c8d3 100644 --- a/templates/components/entry.html.twig +++ b/templates/components/entry.html.twig @@ -113,7 +113,7 @@ {% if entry.image %} {% if entry.type is same as 'link' or entry.type is same as 'video' %} {{ include('components/_figure_entry.html.twig', {entry: entry, type: 'link'}) }} - {% elseif entry.type is same as 'image' or entry.type is same as 'article' %} + {% elseif entry.type is same as 'image' or entry.type is same as 'article' or entry.type is same as 'thread' %} {{ include('components/_figure_entry.html.twig', {entry: entry, type: 'image'}) }} {% endif %} {% else %} @@ -141,9 +141,9 @@ {% endif %} - {% if entry.type is same as 'article' %} + {% if entry.type is same as 'article' or entry.type is same as 'thread' %} @@ -171,6 +171,9 @@ subject: entry }) }} + {% if app.user is defined and app.user is not same as null %} + {{ component('bookmark_standard', { subject: entry }) }} + {% endif %} {% include 'entry/_menu.html.twig' %}
  • diff --git a/templates/components/entry_comment.html.twig b/templates/components/entry_comment.html.twig index 2bbec6930..1f87f77b5 100644 --- a/templates/components/entry_comment.html.twig +++ b/templates/components/entry_comment.html.twig @@ -102,6 +102,9 @@
  • {{ component('boost', {subject: comment}) }}
  • + {% if app.user is defined and app.user is not same as null %} + {{ component('bookmark_standard', { subject: comment }) }} + {% endif %} {% include 'entry/comment/_menu.html.twig' %}
  • diff --git a/templates/components/entry_cross.html.twig b/templates/components/entry_cross.html.twig index 6fbfbb9cd..49019364c 100644 --- a/templates/components/entry_cross.html.twig +++ b/templates/components/entry_cross.html.twig @@ -1,4 +1,4 @@ - + diff --git a/templates/components/post.html.twig b/templates/components/post.html.twig index 04a79d7e6..d03e769ca 100644 --- a/templates/components/post.html.twig +++ b/templates/components/post.html.twig @@ -110,6 +110,9 @@ subject: post }) }}
  • + {% if app.user is defined and app.user is not same as null %} + {{ component('bookmark_standard', { subject: post }) }} + {% endif %} {% include 'post/_menu.html.twig' %}
  • diff --git a/templates/components/post_comment.html.twig b/templates/components/post_comment.html.twig index b91eb650a..7755aceac 100644 --- a/templates/components/post_comment.html.twig +++ b/templates/components/post_comment.html.twig @@ -102,6 +102,9 @@ subject: comment }) }}
  • + {% if app.user is defined and app.user is not same as null %} + {{ component('bookmark_standard', { subject: comment }) }} + {% endif %} {% include 'post/comment/_menu.html.twig' %}
  • diff --git a/templates/domain/_options.html.twig b/templates/domain/_options.html.twig index c50c3326c..ea827aa85 100644 --- a/templates/domain/_options.html.twig +++ b/templates/domain/_options.html.twig @@ -134,7 +134,7 @@
  • - +   {{ 'threads'|trans }}
  • diff --git a/templates/entry/_create_options.html.twig b/templates/entry/_create_options.html.twig index bd1db7c3b..c8a7bcc7d 100644 --- a/templates/entry/_create_options.html.twig +++ b/templates/entry/_create_options.html.twig @@ -11,9 +11,9 @@
  • - - {{ 'type.article'|trans }} + + {{ 'type.thread'|trans }}
  • @@ -36,9 +36,9 @@
  • - - {{ 'type.article'|trans }} + + {{ 'type.thread'|trans }}
  • diff --git a/templates/entry/_form_article.html.twig b/templates/entry/_form_thread.html.twig similarity index 93% rename from templates/entry/_form_article.html.twig rename to templates/entry/_form_thread.html.twig index ca937bf19..efe8b208b 100644 --- a/templates/entry/_form_article.html.twig +++ b/templates/entry/_form_thread.html.twig @@ -14,7 +14,7 @@ 'data-action' : 'input-length#updateDisplay', 'data-input-length-max-value' : constant('App\\Entity\\Entry::MAX_TITLE_LENGTH') }}) }} - {{ component('editor_toolbar', {id: 'entry_article_body'}) }} + {{ component('editor_toolbar', {id: 'entry_thread_body'}) }} {{ form_row(form.body, { label: false, attr: { placeholder: 'body', @@ -59,7 +59,7 @@ {{ form_row(form.lang, {label: false}) }}
  • - {{ form_row(form.submit, {label: edit ? 'edit_article' : 'add_new_article', attr: {class: 'btn btn__primary'}}) }} + {{ form_row(form.submit, {label: edit ? 'edit_thread' : 'add_new_thread', attr: {class: 'btn btn__primary'}}) }}
  • diff --git a/templates/entry/_menu.html.twig b/templates/entry/_menu.html.twig index e94186bfd..5dae6bf39 100644 --- a/templates/entry/_menu.html.twig +++ b/templates/entry/_menu.html.twig @@ -20,6 +20,14 @@ {% endif %} + {% if app.user is defined and app.user is not same as null %} + {% set bookmarkLists = get_bookmark_lists(app.user) %} + {% if bookmarkLists|length %} + + {{ component('bookmark_menu_list', { bookmarkLists: bookmarkLists, subject: entry }) }} + {% endif %} + {% endif %} +
  • {{ 'copy_url'|trans }}
  • + {% if is_granted('edit', entry) or (app.user and entry.isAuthor(app.user)) or is_granted('moderate', entry) %} {% endif %} diff --git a/templates/entry/_options.html.twig b/templates/entry/_options.html.twig index 0562dfe53..728de76b4 100644 --- a/templates/entry/_options.html.twig +++ b/templates/entry/_options.html.twig @@ -134,7 +134,7 @@
  • - +   {{ 'threads'|trans }}
  • diff --git a/templates/entry/comment/_menu.html.twig b/templates/entry/comment/_menu.html.twig index 7b1fc2c80..6c250689d 100644 --- a/templates/entry/comment/_menu.html.twig +++ b/templates/entry/comment/_menu.html.twig @@ -14,6 +14,17 @@ {{ 'activity'|trans }} + + {% if app.user is defined and app.user is not same as null %} + {% set bookmarkLists = get_bookmark_lists(app.user) %} + {% if bookmarkLists|length %} + + {% for list in bookmarkLists %} + {{ component('bookmark_list', { subject: comment, subjectType: 'entry_comment', list: list }) }} + {% endfor %} + {% endif %} + {% endif %} +
  • -

    {{ 'add_new_article'|trans }}

    +

    {{ 'add_new_thread'|trans }}

    {% include 'layout/_flash.html.twig' %} {% include('user/_visibility_info.html.twig') %} @@ -23,7 +23,7 @@ {% if user.visibility is same as 'visible' %}
    - {% include 'entry/_form_article.html.twig' %} + {% include 'entry/_form_thread.html.twig' %}
    {% endif %} diff --git a/templates/entry/edit_entry.html.twig b/templates/entry/edit_entry.html.twig index 8ef11af74..1cacd6e82 100644 --- a/templates/entry/edit_entry.html.twig +++ b/templates/entry/edit_entry.html.twig @@ -1,10 +1,10 @@ {% extends 'base.html.twig' %} {%- block title -%} - {{- 'edit_entry'|trans }} - {{ parent() -}} + {{- 'edit_thread'|trans }} - {{ parent() -}} {%- endblock -%} -{% block mainClass %}page-entry-create page-entry-edit-article{% endblock %} +{% block mainClass %}page-entry-create page-entry-edit-thread{% endblock %} {% block header_nav %} {% endblock %} @@ -19,7 +19,7 @@ }) }}
    -

    {{ 'edit_entry'|trans }}

    +

    {{ 'edit_thread'|trans }}

    {% include 'layout/_flash.html.twig' %}
    diff --git a/templates/layout/_header.html.twig b/templates/layout/_header.html.twig index d6c216481..5f0a2c06c 100644 --- a/templates/layout/_header.html.twig +++ b/templates/layout/_header.html.twig @@ -52,9 +52,9 @@
  • - - {{ 'add_new_article'|trans }} + + {{ 'add_new_thread'|trans }}
  • @@ -76,9 +76,9 @@
  • - - {{ 'add_new_article'|trans }} + + {{ 'add_new_thread'|trans }}
  • @@ -145,6 +145,12 @@ {{ app.user.countNewNotifications }}
  • +
  • + + {{ 'bookmark_lists'|trans }} + +
  • {% if is_granted('ROLE_ADMIN') %}
  • {{ form_start(form) }} -
    - {{ form_widget(form.query) }} -
    diff --git a/templates/post/_menu.html.twig b/templates/post/_menu.html.twig index e4db4f07a..dbdadc6f5 100644 --- a/templates/post/_menu.html.twig +++ b/templates/post/_menu.html.twig @@ -14,6 +14,17 @@ {{ 'activity'|trans }}
  • + + {% if app.user is defined and app.user is not same as null %} + {% set bookmarkLists = get_bookmark_lists(app.user) %} + {% if bookmarkLists|length %} + + {% for list in bookmarkLists %} + {{ component('bookmark_list', { subject: post, subjectType: 'post', list: list }) }} + {% endfor %} + {% endif %} + {% endif %} +
  • - +   {{ 'threads'|trans }}
  • diff --git a/templates/post/comment/_menu.html.twig b/templates/post/comment/_menu.html.twig index b9f9aed7b..582b56c90 100644 --- a/templates/post/comment/_menu.html.twig +++ b/templates/post/comment/_menu.html.twig @@ -14,6 +14,17 @@ {{ 'activity'|trans }} + + {% if app.user is defined and app.user is not same as null %} + {% set bookmarkLists = get_bookmark_lists(app.user) %} + {% if bookmarkLists|length %} + + {% for list in bookmarkLists %} + {{ component('bookmark_list', { subject: comment, subjectType: 'post_comment', list: list }) }} + {% endfor %} + {% endif %} + {% endif %} +
  • + {{ form_widget(form.q, {label: false, 'attr': {'class': 'form-control'}}) }} + + + + +
    + {{ form_widget(form.magazine, {label: false, 'attr': {'class': 'form-control'}}) }} + {{ form_widget(form.user, {label: false, 'attr': {'class': 'form-control'}}) }} +
    + {{ form_widget(form.type, {label: false, 'attr': {'class': 'form-control', 'style': 'padding: 1rem .5rem;'}}) }} +
    +
    + +{{ form_end(form) }} diff --git a/templates/search/front.html.twig b/templates/search/front.html.twig index feac7ebf2..0b72851d0 100644 --- a/templates/search/front.html.twig +++ b/templates/search/front.html.twig @@ -15,17 +15,7 @@ {% block body %}

    {{ 'search'|trans }}

    -
    -
    -
    - - -
    -
    -
    + {% include 'search/form.html.twig' %}