diff --git a/.gitignore b/.gitignore index 0bce5491f..6efd3f502 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # IDEA/PhpStorm *.iml .idea/ +.run/ .DS_Store supervisord.log supervisord.pid diff --git a/assets/controllers/form-related-links_controller.js b/assets/controllers/form-related-links_controller.js new file mode 100644 index 000000000..8c4548681 --- /dev/null +++ b/assets/controllers/form-related-links_controller.js @@ -0,0 +1,66 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['relatedContainer']; + + static values = { + index: Number, + label: String, + value: String, + deleteIcon: String, + }; + + connect() { + const container = this.element; + container + .querySelectorAll('.related-link-row') + .forEach((item) => { + this.#addButtonDeleteLink(item); + }); + } + + addRelatedElement() { + console.log('entra'); + console.log(this.labelValue, 'label'); + console.log(this.valueValue, 'value'); + + const rowNode = document.createElement('div'); + rowNode.className = 'related-link-row'; + + const nodeLabel = this.#htmlToNode(this.labelValue.replace( + /__name__/g, + this.indexValue, + )); + rowNode.appendChild(nodeLabel); + + const nodeValue = this.#htmlToNode(this.valueValue.replace( + /__name__/g, + this.indexValue, + )); + rowNode.appendChild(nodeValue); + + this.#addButtonDeleteLink(rowNode); + + this.relatedContainerTarget.appendChild(rowNode); + this.indexValue++; + } + + #addButtonDeleteLink(item) { + const removeFormButton = document.createElement('button'); + removeFormButton.innerHTML = this.deleteIconValue; + removeFormButton.className = 'btn btn__secondary delete-button'; + + item.append(removeFormButton); + + removeFormButton.addEventListener('click', (e) => { + e.preventDefault(); + item.remove(); + }); + } + + #htmlToNode(html) { + const template = document.createElement('div'); + template.innerHTML = html; + return template.firstChild; + } +} 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..c7965527b 100644 --- a/assets/styles/layout/_forms.scss +++ b/assets/styles/layout/_forms.scss @@ -122,9 +122,12 @@ input[type=radio] { align-content: center; cursor: pointer; + @extend %fa-icon; + @extend .fa-solid; + &::before { font-family: var(--kbin-font-awesome-font-family); - content: "\f00c"; + content: fa-content($fa-var-check); transform: scale(0); transition: 100ms transform ease-in; } @@ -525,3 +528,47 @@ div.input-box { border-radius: var(--kbin-rounded-edges-radius) !important; } } + +.form-control { + display: block; + width: 100 +} + +.related-link-fieldset { + border: 0; + margin: 0; + padding: 0; +} + +.related-link-fieldset legend { + margin-bottom: 0.5rem; +} + +.related-links-group { + margin-bottom: 0.25rem; +} + +.related-link-row { + display: flex; + flex-wrap: nowrap; + row-gap: 0.5rem; + column-gap: 0.2rem; + align-items: center; + margin-bottom: 0.25rem; +} + +.related-link-row div { + margin-bottom: 0; + flex-grow: 1 +} + +.delete-button { + padding: 0; + border: 0; +} + +.add-button-container { + display: flex; + margin-right: 13.9px; + justify-content: center; +} 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..4a11952f7 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,10 +363,20 @@ figure { gap: .25rem; } +@include media-breakpoint-down(lg) { + .flex.mobile { + display: block; + } +} + .flex-wrap { flex-wrap: wrap; } +.flex-grow-1 { + flex-grow: 1; +} + pre, code { white-space: pre-wrap; word-wrap: break-word; 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/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..7d622e14f 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -82,6 +82,7 @@ parameters: default_subscription_options: sub|fav|mod|all|home default_federation_options: local|all default_content_options: threads|microblog + default_subject_type_options: entry|entry_comment|post|post_comment comment_sort_options: top|hot|active|newest|oldest diff --git a/migrations/Version20240822112013.php b/migrations/Version20240822112013.php new file mode 100644 index 000000000..23299f38a --- /dev/null +++ b/migrations/Version20240822112013.php @@ -0,0 +1,26 @@ +addSql('ALTER TABLE "user" ADD related_links JSONB DEFAULT \'[]\' NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE "user" DROP related_links'); + } +} 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/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/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/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/RelatedLinkDTO.php b/src/DTO/RelatedLinkDTO.php new file mode 100644 index 000000000..4c4aa9929 --- /dev/null +++ b/src/DTO/RelatedLinkDTO.php @@ -0,0 +1,42 @@ +label; + } + + public function setLabel(string $label): void + { + $this->label = $label; + } + + public function getValue(): string + { + return $this->value; + } + + public function setValue(string $value): void + { + $this->value = $value; + } + + public function isVerifiedLink(): bool + { + return $this->verifiedLink; + } + + public function setVerifiedLink(bool $verifiedLink): void + { + $this->verifiedLink = $verifiedLink; + } +} 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/DTO/UserDto.php b/src/DTO/UserDto.php index 8ee3fa090..5767e92a3 100644 --- a/src/DTO/UserDto.php +++ b/src/DTO/UserDto.php @@ -50,6 +50,9 @@ class UserDto implements UserDtoInterface public ?string $serverSoftware = null; public ?string $serverSoftwareVersion = null; + /** @var RelatedLinkDTO[] */ + public array $relatedLinks = []; + #[Assert\Callback] public function validate( ExecutionContextInterface $context, @@ -91,6 +94,7 @@ public static function create( ?bool $isBot = null, ?bool $isAdmin = null, ?bool $isGlobalModerator = null, + array $relatedLinks = [], ): self { $dto = new UserDto(); $dto->id = $id; @@ -107,6 +111,7 @@ public static function create( $dto->isBot = $isBot; $dto->isAdmin = $isAdmin; $dto->isGlobalModerator = $isGlobalModerator; + $dto->relatedLinks = $relatedLinks; return $dto; } diff --git a/src/Entity/Bookmark.php b/src/Entity/Bookmark.php new file mode 100644 index 000000000..3902e3948 --- /dev/null +++ b/src/Entity/Bookmark.php @@ -0,0 +1,70 @@ +user = $user; + $this->list = $list; + $this->createdAtTraitConstruct(); + } + + public function setContent(Post|EntryComment|PostComment|Entry $content): void + { + if ($content instanceof Entry) { + $this->entry = $content; + } elseif ($content instanceof EntryComment) { + $this->entryComment = $content; + } elseif ($content instanceof Post) { + $this->post = $content; + } elseif ($content instanceof PostComment) { + $this->postComment = $content; + } + } + + public function getContent(): Entry|EntryComment|Post|PostComment + { + return $this->entry ?? $this->entryComment ?? $this->post ?? $this->postComment; + } +} diff --git a/src/Entity/BookmarkList.php b/src/Entity/BookmarkList.php new file mode 100644 index 000000000..52673e1cf --- /dev/null +++ b/src/Entity/BookmarkList.php @@ -0,0 +1,51 @@ +user = $user; + $this->name = $name; + $this->isDefault = $isDefault; + $this->entities = new ArrayCollection(); + } + + public function getId(): int + { + return $this->id; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index 8c39eca36..3b89bf817 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')] @@ -238,6 +240,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Visibil #[Column(type: 'string', nullable: false, options: ['default' => self::USER_TYPE_PERSON])] public string $type; + #[Column(type: 'json', nullable: false, options: ['jsonb' => true, 'default' => '[]'])] + public array $relatedLinks = []; + public function __construct( string $email, string $username, @@ -894,4 +899,20 @@ public function canUpdateUser(User $actor): bool return $this->apDomain === $actor->apDomain; } } + + public function getRelatedLinks(): array + { + return $this->relatedLinks; + } + + public function isVerifiedRelatedLinkExists(): bool + { + foreach ($this->relatedLinks as $relatedLink) { + if (true === $relatedLink['verifiedLink']) { + return true; + } + } + + return false; + } } diff --git a/src/Factory/UserFactory.php b/src/Factory/UserFactory.php index 569f66de5..709fc3f96 100644 --- a/src/Factory/UserFactory.php +++ b/src/Factory/UserFactory.php @@ -4,11 +4,13 @@ namespace App\Factory; +use App\DTO\RelatedLinkDTO; use App\DTO\UserDto; use App\DTO\UserSmallResponseDto; use App\Entity\User; use App\Repository\InstanceRepository; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; class UserFactory { @@ -16,6 +18,7 @@ public function __construct( private readonly ImageFactory $imageFactory, private readonly InstanceRepository $instanceRepository, private readonly Security $security, + private readonly DenormalizerInterface $denormalizer, ) { } @@ -36,6 +39,7 @@ public function createDto(User $user): UserDto 'Service' === $user->type, // setting isBot $user->isAdmin(), $user->isModerator(), + $this->denormalizer->denormalize($user->getRelatedLinks(), \sprintf('%s[]', RelatedLinkDTO::class)), ); /** @var User $currentUser */ 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/Form/UserBasicType.php b/src/Form/UserBasicType.php index aad0f45e8..ede6d7d37 100644 --- a/src/Form/UserBasicType.php +++ b/src/Form/UserBasicType.php @@ -10,6 +10,7 @@ use App\Form\EventListener\DisableFieldsOnUserEdit; use App\Form\EventListener\ImageListener; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; @@ -31,7 +32,15 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $builder ->add('username', TextType::class, ['required' => false]) ->add('about', TextareaType::class, ['required' => false]) - ->add('submit', SubmitType::class); + ->add('relatedLinks', CollectionType::class, [ + 'entry_type' => UserRelatedDataType::class, + 'entry_options' => ['label' => false], + 'label' => false, + 'allow_add' => true, + 'allow_delete' => true, + ]) + ->add('submit', SubmitType::class) + ; $builder->addEventSubscriber($this->disableUsernameFieldOnUserEdit); $builder->addEventSubscriber($this->addAvatarFieldOnUserEdit); diff --git a/src/Form/UserRelatedDataType.php b/src/Form/UserRelatedDataType.php new file mode 100644 index 000000000..f1eb74246 --- /dev/null +++ b/src/Form/UserRelatedDataType.php @@ -0,0 +1,68 @@ +add('label', TextType::class) + ->add('value', UrlType::class) + ->setDataMapper($this); + } + + /** + * @param RelatedLinkDTO|null $viewData + */ + public function mapDataToForms($viewData, \Traversable $forms): void + { + if (null === $viewData) { + return; + } + + if (!$viewData instanceof RelatedLinkDTO) { + throw new UnexpectedTypeException($viewData, RelatedLinkDTO::class); + } + + /** @var FormInterface[] $forms */ + $forms = iterator_to_array($forms); + + $forms['label']->setData($viewData->getLabel()); + $forms['value']->setData($viewData->getValue()); + } + + public function mapFormsToData(\Traversable $forms, &$viewData): void + { + /** @var FormInterface[] $forms */ + $forms = iterator_to_array($forms); + + // as data is passed by reference, overriding it will change it in + // the form object as well + // beware of type inconsistency, see caution below + $viewData = new RelatedLinkDTO(); + $viewData->setLabel($forms['label']->getData()); + $viewData->setValue($forms['value']->getData()); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults( + [ + 'data_class' => RelatedLinkDTO::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/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/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/Service/UserManager.php b/src/Service/UserManager.php index a6a2c8a73..da530cb47 100644 --- a/src/Service/UserManager.php +++ b/src/Service/UserManager.php @@ -37,6 +37,7 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\RateLimiter\RateLimiterFactory; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; @@ -58,7 +59,8 @@ public function __construct( private ImageRepository $imageRepository, private Security $security, private CacheInterface $cache, - private ReputationRepository $reputationRepository + private ReputationRepository $reputationRepository, + private NormalizerInterface $normalizer, ) { } @@ -212,6 +214,8 @@ public function edit(User $user, UserDto $dto): User $user->setTotpSecret($dto->totpSecret); } + $user->relatedLinks = $this->normalizer->normalize($dto->relatedLinks); + $user->lastActive = new \DateTime(); $this->entityManager->flush(); 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'}) }} + +
+ | {{ 'name'|trans }} | +{{ 'count'|trans }} | ++ |
---|---|---|---|
+ {% if list.isDefault %} + + {% endif %} + | +{{ list.name }} | +{{ get_bookmark_list_entry_count(list) }} | ++ {% if not list.isDefault %} + + {% endif %} + + + + + + + | +