diff --git a/extensions/likes/extend.php b/extensions/likes/extend.php index c59a497e58..95ee5d1efe 100644 --- a/extensions/likes/extend.php +++ b/extensions/likes/extend.php @@ -19,9 +19,10 @@ use Flarum\Likes\Notification\PostLikedBlueprint; use Flarum\Likes\Query\LikedByFilter; use Flarum\Likes\Query\LikedFilter; -use Flarum\Post\Filter\PostFilterer; +use Flarum\Post\Filter\PostSearcher; use Flarum\Post\Post; -use Flarum\User\Filter\UserFilterer; +use Flarum\Search\Database\DatabaseSearchDriver; +use Flarum\User\Search\UserSearcher; use Flarum\User\User; return [ @@ -76,11 +77,9 @@ ->listen(PostWasUnliked::class, Listener\SendNotificationWhenPostIsUnliked::class) ->subscribe(Listener\SaveLikesToDatabase::class), - (new Extend\Filter(PostFilterer::class)) - ->addFilter(LikedByFilter::class), - - (new Extend\Filter(UserFilterer::class)) - ->addFilter(LikedFilter::class), + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->addFilter(PostSearcher::class, LikedByFilter::class) + ->addFilter(UserSearcher::class, LikedFilter::class), (new Extend\Settings()) ->default('flarum-likes.like_own_post', true), diff --git a/extensions/likes/src/Query/LikedByFilter.php b/extensions/likes/src/Query/LikedByFilter.php index 13e09ace0c..6fdb89c9a2 100644 --- a/extensions/likes/src/Query/LikedByFilter.php +++ b/extensions/likes/src/Query/LikedByFilter.php @@ -9,10 +9,14 @@ namespace Flarum\Likes\Query; -use Flarum\Filter\FilterInterface; -use Flarum\Filter\FilterState; -use Flarum\Filter\ValidateFilterTrait; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; +use Flarum\Search\SearchState; +use Flarum\Search\ValidateFilterTrait; +/** + * @implements FilterInterface + */ class LikedByFilter implements FilterInterface { use ValidateFilterTrait; @@ -22,11 +26,11 @@ public function getFilterKey(): string return 'likedBy'; } - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void + public function filter(SearchState $state, string|array $value, bool $negate): void { - $likedId = $this->asInt($filterValue); + $likedId = $this->asInt($value); - $filterState + $state ->getQuery() ->whereIn('id', function ($query) use ($likedId, $negate) { $query->select('post_id') diff --git a/extensions/likes/src/Query/LikedFilter.php b/extensions/likes/src/Query/LikedFilter.php index 155b4c4441..b291d97eaf 100644 --- a/extensions/likes/src/Query/LikedFilter.php +++ b/extensions/likes/src/Query/LikedFilter.php @@ -9,10 +9,14 @@ namespace Flarum\Likes\Query; -use Flarum\Filter\FilterInterface; -use Flarum\Filter\FilterState; -use Flarum\Filter\ValidateFilterTrait; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; +use Flarum\Search\SearchState; +use Flarum\Search\ValidateFilterTrait; +/** + * @implements FilterInterface + */ class LikedFilter implements FilterInterface { use ValidateFilterTrait; @@ -22,11 +26,11 @@ public function getFilterKey(): string return 'liked'; } - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void + public function filter(SearchState $state, string|array $value, bool $negate): void { - $likedId = $this->asString($filterValue); + $likedId = $this->asString($value); - $filterState + $state ->getQuery() ->whereIn('id', function ($query) use ($likedId) { $query->select('user_id') diff --git a/extensions/lock/extend.php b/extensions/lock/extend.php index 9cf4429c6e..26000af97a 100644 --- a/extensions/lock/extend.php +++ b/extensions/lock/extend.php @@ -11,16 +11,16 @@ use Flarum\Api\Serializer\DiscussionSerializer; use Flarum\Discussion\Discussion; use Flarum\Discussion\Event\Saving; -use Flarum\Discussion\Filter\DiscussionFilterer; use Flarum\Discussion\Search\DiscussionSearcher; use Flarum\Extend; use Flarum\Lock\Access; use Flarum\Lock\Event\DiscussionWasLocked; use Flarum\Lock\Event\DiscussionWasUnlocked; +use Flarum\Lock\Filter\LockedFilter; use Flarum\Lock\Listener; use Flarum\Lock\Notification\DiscussionLockedBlueprint; use Flarum\Lock\Post\DiscussionLockedPost; -use Flarum\Lock\Query\LockedFilterGambit; +use Flarum\Search\Database\DatabaseSearchDriver; return [ (new Extend\Frontend('forum')) @@ -57,9 +57,6 @@ (new Extend\Policy()) ->modelPolicy(Discussion::class, Access\DiscussionPolicy::class), - (new Extend\Filter(DiscussionFilterer::class)) - ->addFilter(LockedFilterGambit::class), - - (new Extend\SimpleFlarumSearch(DiscussionSearcher::class)) - ->addGambit(LockedFilterGambit::class), + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->addFilter(DiscussionSearcher::class, LockedFilter::class), ]; diff --git a/extensions/lock/js/src/admin/extend.ts b/extensions/lock/js/src/admin/extend.ts new file mode 100644 index 0000000000..9ce8b5f124 --- /dev/null +++ b/extensions/lock/js/src/admin/extend.ts @@ -0,0 +1 @@ +export { default as default } from '../common/extend'; diff --git a/extensions/lock/js/src/admin/index.js b/extensions/lock/js/src/admin/index.js index 55c5a208ac..f32f9daf78 100644 --- a/extensions/lock/js/src/admin/index.js +++ b/extensions/lock/js/src/admin/index.js @@ -1,5 +1,7 @@ import app from 'flarum/admin/app'; +export { default as extend } from './extend'; + app.initializers.add('lock', () => { app.extensionData.for('flarum-lock').registerPermission( { diff --git a/extensions/lock/js/src/common/extend.ts b/extensions/lock/js/src/common/extend.ts new file mode 100644 index 0000000000..8f6a32cf5b --- /dev/null +++ b/extensions/lock/js/src/common/extend.ts @@ -0,0 +1,7 @@ +import Extend from 'flarum/common/extenders'; +import LockedGambit from './query/discussions/LockedGambit'; + +export default [ + new Extend.Search() // + .gambit('discussions', LockedGambit), +]; diff --git a/extensions/lock/js/src/common/query/discussions/LockedGambit.ts b/extensions/lock/js/src/common/query/discussions/LockedGambit.ts new file mode 100644 index 0000000000..b3639fd35d --- /dev/null +++ b/extensions/lock/js/src/common/query/discussions/LockedGambit.ts @@ -0,0 +1,23 @@ +import IGambit from 'flarum/common/query/IGambit'; + +export default class LockedGambit implements IGambit { + pattern(): string { + return 'is:locked'; + } + + toFilter(_matches: string[], negate: boolean): Record { + const key = (negate ? '-' : '') + 'locked'; + + return { + [key]: true, + }; + } + + filterKey(): string { + return 'locked'; + } + + fromFilter(value: string, negate: boolean): string { + return `${negate ? '-' : ''}is:locked`; + } +} diff --git a/extensions/lock/js/src/forum/extend.ts b/extensions/lock/js/src/forum/extend.ts index 2e510e1054..2a65da5633 100644 --- a/extensions/lock/js/src/forum/extend.ts +++ b/extensions/lock/js/src/forum/extend.ts @@ -2,7 +2,11 @@ import Extend from 'flarum/common/extenders'; import Discussion from 'flarum/common/models/Discussion'; import DiscussionLockedPost from './components/DiscussionLockedPost'; +import commonExtend from '../common/extend'; + export default [ + ...commonExtend, + new Extend.PostTypes() // .add('discussionLocked', DiscussionLockedPost), diff --git a/extensions/lock/src/Filter/LockedFilter.php b/extensions/lock/src/Filter/LockedFilter.php new file mode 100644 index 0000000000..6fb2349f59 --- /dev/null +++ b/extensions/lock/src/Filter/LockedFilter.php @@ -0,0 +1,36 @@ + + */ +class LockedFilter implements FilterInterface +{ + public function getFilterKey(): string + { + return 'locked'; + } + + public function filter(SearchState $state, string|array $value, bool $negate): void + { + $this->constrain($state->getQuery(), $negate); + } + + protected function constrain(Builder $query, bool $negate): void + { + $query->where('is_locked', ! $negate); + } +} diff --git a/extensions/lock/src/Query/LockedFilterGambit.php b/extensions/lock/src/Query/LockedFilterGambit.php deleted file mode 100644 index 2c3d14ad98..0000000000 --- a/extensions/lock/src/Query/LockedFilterGambit.php +++ /dev/null @@ -1,44 +0,0 @@ -constrain($search->getQuery(), $negate); - } - - public function getFilterKey(): string - { - return 'locked'; - } - - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void - { - $this->constrain($filterState->getQuery(), $negate); - } - - protected function constrain(Builder $query, bool $negate): void - { - $query->where('is_locked', ! $negate); - } -} diff --git a/extensions/mentions/extend.php b/extensions/mentions/extend.php index ef19a68a06..a85d04dab5 100644 --- a/extensions/mentions/extend.php +++ b/extensions/mentions/extend.php @@ -24,8 +24,9 @@ use Flarum\Post\Event\Posted; use Flarum\Post\Event\Restored; use Flarum\Post\Event\Revised; -use Flarum\Post\Filter\PostFilterer; +use Flarum\Post\Filter\PostSearcher; use Flarum\Post\Post; +use Flarum\Search\Database\DatabaseSearchDriver; use Flarum\Tags\Api\Serializer\TagSerializer; use Flarum\User\User; @@ -114,9 +115,9 @@ ->listen(Hidden::class, Listener\UpdateMentionsMetadataWhenInvisible::class) ->listen(Deleted::class, Listener\UpdateMentionsMetadataWhenInvisible::class), - (new Extend\Filter(PostFilterer::class)) - ->addFilter(Filter\MentionedFilter::class) - ->addFilter(Filter\MentionedPostFilter::class), + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->addFilter(PostSearcher::class, Filter\MentionedFilter::class) + ->addFilter(PostSearcher::class, Filter\MentionedPostFilter::class), (new Extend\ApiSerializer(CurrentUserSerializer::class)) ->attribute('canMentionGroups', function (CurrentUserSerializer $serializer, User $user): bool { diff --git a/extensions/mentions/src/Filter/MentionedFilter.php b/extensions/mentions/src/Filter/MentionedFilter.php index a0b470d932..2c555de981 100644 --- a/extensions/mentions/src/Filter/MentionedFilter.php +++ b/extensions/mentions/src/Filter/MentionedFilter.php @@ -9,10 +9,14 @@ namespace Flarum\Mentions\Filter; -use Flarum\Filter\FilterInterface; -use Flarum\Filter\FilterState; -use Flarum\Filter\ValidateFilterTrait; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; +use Flarum\Search\SearchState; +use Flarum\Search\ValidateFilterTrait; +/** + * @implements FilterInterface + */ class MentionedFilter implements FilterInterface { use ValidateFilterTrait; @@ -22,11 +26,11 @@ public function getFilterKey(): string return 'mentioned'; } - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void + public function filter(SearchState $state, string|array $value, bool $negate): void { - $mentionedId = $this->asInt($filterValue); + $mentionedId = $this->asInt($value); - $filterState + $state ->getQuery() ->join('post_mentions_user', 'posts.id', '=', 'post_mentions_user.post_id') ->where('post_mentions_user.mentions_user_id', $negate ? '!=' : '=', $mentionedId); diff --git a/extensions/mentions/src/Filter/MentionedPostFilter.php b/extensions/mentions/src/Filter/MentionedPostFilter.php index da46bbe2c9..193ba33953 100644 --- a/extensions/mentions/src/Filter/MentionedPostFilter.php +++ b/extensions/mentions/src/Filter/MentionedPostFilter.php @@ -9,9 +9,13 @@ namespace Flarum\Mentions\Filter; -use Flarum\Filter\FilterInterface; -use Flarum\Filter\FilterState; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; +use Flarum\Search\SearchState; +/** + * @implements FilterInterface + */ class MentionedPostFilter implements FilterInterface { public function getFilterKey(): string @@ -19,11 +23,11 @@ public function getFilterKey(): string return 'mentionedPost'; } - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void + public function filter(SearchState $state, string|array $value, bool $negate): void { - $mentionedId = trim($filterValue, '"'); + $mentionedId = trim($value, '"'); - $filterState + $state ->getQuery() ->join('post_mentions_post', 'posts.id', '=', 'post_mentions_post.post_id') ->where('post_mentions_post.mentions_post_id', $negate ? '!=' : '=', $mentionedId); diff --git a/extensions/nicknames/extend.php b/extensions/nicknames/extend.php index 4cd3589c25..c2c5239ecf 100644 --- a/extensions/nicknames/extend.php +++ b/extensions/nicknames/extend.php @@ -12,6 +12,7 @@ use Flarum\Api\Serializer\UserSerializer; use Flarum\Extend; use Flarum\Nicknames\Access\UserPolicy; +use Flarum\Search\Database\DatabaseSearchDriver; use Flarum\User\Event\Saving; use Flarum\User\Search\UserSearcher; use Flarum\User\User; @@ -52,8 +53,8 @@ (new Extend\Validator(UserValidator::class)) ->configure(AddNicknameValidation::class), - (new Extend\SimpleFlarumSearch(UserSearcher::class)) - ->setFullTextGambit(NicknameFullTextGambit::class), + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->setFulltext(UserSearcher::class, NicknameFullTextFilter::class), (new Extend\Policy()) ->modelPolicy(User::class, UserPolicy::class), diff --git a/extensions/nicknames/js/src/admin/index.js b/extensions/nicknames/js/src/admin/index.js index 929bce3513..196821b7a2 100644 --- a/extensions/nicknames/js/src/admin/index.js +++ b/extensions/nicknames/js/src/admin/index.js @@ -1,6 +1,9 @@ import app from 'flarum/admin/app'; import Alert from 'flarum/common/components/Alert'; import Link from 'flarum/common/components/Link'; +import BasicsPage from 'flarum/admin/components/BasicsPage'; +import extractText from 'flarum/common/utils/extractText'; +import { extend } from 'flarum/common/extend'; app.initializers.add('flarum/nicknames', () => { app.extensionData @@ -55,4 +58,8 @@ app.initializers.add('flarum/nicknames', () => { }, 'start' ); + + extend(BasicsPage.prototype, 'driverLocale', function (locale) { + locale.display_name['nickname'] = extractText(app.translator.trans('flarum-nicknames.admin.basics.display_name_driver_options.nickname')); + }); }); diff --git a/extensions/nicknames/locale/en.yml b/extensions/nicknames/locale/en.yml index dfe15640ad..e969afea07 100644 --- a/extensions/nicknames/locale/en.yml +++ b/extensions/nicknames/locale/en.yml @@ -1,5 +1,8 @@ flarum-nicknames: admin: + basics: + display_name_driver_options: + nickname: Nickname permissions: edit_own_nickname_label: Edit own nickname settings: diff --git a/extensions/nicknames/src/NicknameFullTextGambit.php b/extensions/nicknames/src/NicknameFullTextFilter.php similarity index 64% rename from extensions/nicknames/src/NicknameFullTextGambit.php rename to extensions/nicknames/src/NicknameFullTextFilter.php index 1079e865f0..455f424be8 100644 --- a/extensions/nicknames/src/NicknameFullTextGambit.php +++ b/extensions/nicknames/src/NicknameFullTextFilter.php @@ -9,19 +9,16 @@ namespace Flarum\Nicknames; -/* - * This file is part of Flarum. - * - * For detailed copyright and license information, please view the - * LICENSE file that was distributed with this source code. - */ - -use Flarum\Search\GambitInterface; +use Flarum\Search\AbstractFulltextFilter; +use Flarum\Search\Database\DatabaseSearchState; use Flarum\Search\SearchState; use Flarum\User\UserRepository; use Illuminate\Database\Eloquent\Builder; -class NicknameFullTextGambit implements GambitInterface +/** + * @extends AbstractFulltextFilter + */ +class NicknameFullTextFilter extends AbstractFulltextFilter { public function __construct( protected UserRepository $users @@ -37,14 +34,12 @@ private function getUserSearchSubQuery(string $searchValue): Builder ->orWhere('nickname', 'like', "{$searchValue}%"); } - public function apply(SearchState $search, string $bit): bool + public function search(SearchState $state, string $value): void { - $search->getQuery() + $state->getQuery() ->whereIn( 'id', - $this->getUserSearchSubQuery($bit) + $this->getUserSearchSubQuery($value) ); - - return true; } } diff --git a/extensions/sticky/extend.php b/extensions/sticky/extend.php index 3a9ff0bc22..4ca43eec05 100644 --- a/extensions/sticky/extend.php +++ b/extensions/sticky/extend.php @@ -11,16 +11,16 @@ use Flarum\Api\Serializer\DiscussionSerializer; use Flarum\Discussion\Discussion; use Flarum\Discussion\Event\Saving; -use Flarum\Discussion\Filter\DiscussionFilterer; use Flarum\Discussion\Search\DiscussionSearcher; use Flarum\Extend; +use Flarum\Search\Database\DatabaseSearchDriver; use Flarum\Sticky\Event\DiscussionWasStickied; use Flarum\Sticky\Event\DiscussionWasUnstickied; use Flarum\Sticky\Listener; use Flarum\Sticky\Listener\SaveStickyToDatabase; use Flarum\Sticky\PinStickiedDiscussionsToTop; use Flarum\Sticky\Post\DiscussionStickiedPost; -use Flarum\Sticky\Query\StickyFilterGambit; +use Flarum\Sticky\Query\StickyFilter; return [ (new Extend\Frontend('forum')) @@ -54,10 +54,7 @@ ->listen(DiscussionWasStickied::class, [Listener\CreatePostWhenDiscussionIsStickied::class, 'whenDiscussionWasStickied']) ->listen(DiscussionWasUnstickied::class, [Listener\CreatePostWhenDiscussionIsStickied::class, 'whenDiscussionWasUnstickied']), - (new Extend\Filter(DiscussionFilterer::class)) - ->addFilter(StickyFilterGambit::class) - ->addFilterMutator(PinStickiedDiscussionsToTop::class), - - (new Extend\SimpleFlarumSearch(DiscussionSearcher::class)) - ->addGambit(StickyFilterGambit::class), + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->addFilter(DiscussionSearcher::class, StickyFilter::class) + ->addMutator(DiscussionSearcher::class, PinStickiedDiscussionsToTop::class), ]; diff --git a/extensions/sticky/js/src/admin/extend.ts b/extensions/sticky/js/src/admin/extend.ts new file mode 100644 index 0000000000..9ce8b5f124 --- /dev/null +++ b/extensions/sticky/js/src/admin/extend.ts @@ -0,0 +1 @@ +export { default as default } from '../common/extend'; diff --git a/extensions/sticky/js/src/admin/index.js b/extensions/sticky/js/src/admin/index.js index 29b4448a83..057c5ea345 100644 --- a/extensions/sticky/js/src/admin/index.js +++ b/extensions/sticky/js/src/admin/index.js @@ -1,5 +1,7 @@ import app from 'flarum/admin/app'; +export { default as extend } from './extend'; + app.initializers.add('flarum-sticky', () => { app.extensionData.for('flarum-sticky').registerPermission( { diff --git a/extensions/sticky/js/src/common/extend.ts b/extensions/sticky/js/src/common/extend.ts new file mode 100644 index 0000000000..7a677a5c35 --- /dev/null +++ b/extensions/sticky/js/src/common/extend.ts @@ -0,0 +1,7 @@ +import Extend from 'flarum/common/extenders'; +import StickyGambit from './query/discussions/StickyGambit'; + +export default [ + new Extend.Search() // + .gambit('discussions', StickyGambit), +]; diff --git a/extensions/sticky/js/src/common/query/discussions/StickyGambit.ts b/extensions/sticky/js/src/common/query/discussions/StickyGambit.ts new file mode 100644 index 0000000000..d1ab9f688c --- /dev/null +++ b/extensions/sticky/js/src/common/query/discussions/StickyGambit.ts @@ -0,0 +1,23 @@ +import IGambit from 'flarum/common/query/IGambit'; + +export default class StickyGambit implements IGambit { + pattern(): string { + return 'is:sticky'; + } + + toFilter(_matches: string[], negate: boolean): Record { + const key = (negate ? '-' : '') + 'sticky'; + + return { + [key]: true, + }; + } + + filterKey(): string { + return 'sticky'; + } + + fromFilter(value: string, negate: boolean): string { + return `${negate ? '-' : ''}is:sticky`; + } +} diff --git a/extensions/sticky/js/src/forum/extend.ts b/extensions/sticky/js/src/forum/extend.ts index c3a73c859a..9dc89ed7a2 100644 --- a/extensions/sticky/js/src/forum/extend.ts +++ b/extensions/sticky/js/src/forum/extend.ts @@ -2,7 +2,11 @@ import Extend from 'flarum/common/extenders'; import Discussion from 'flarum/common/models/Discussion'; import DiscussionStickiedPost from './components/DiscussionStickiedPost'; +import commonExtend from '../common/extend'; + export default [ + ...commonExtend, + new Extend.PostTypes() // .add('discussionStickied', DiscussionStickiedPost), diff --git a/extensions/sticky/src/PinStickiedDiscussionsToTop.php b/extensions/sticky/src/PinStickiedDiscussionsToTop.php index b16eaefa8b..c8a044216c 100755 --- a/extensions/sticky/src/PinStickiedDiscussionsToTop.php +++ b/extensions/sticky/src/PinStickiedDiscussionsToTop.php @@ -9,23 +9,23 @@ namespace Flarum\Sticky; -use Flarum\Filter\FilterState; -use Flarum\Query\QueryCriteria; -use Flarum\Tags\Query\TagFilterGambit; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\SearchCriteria; +use Flarum\Tags\Search\Filter\TagFilter; class PinStickiedDiscussionsToTop { - public function __invoke(FilterState $filterState, QueryCriteria $criteria): void + public function __invoke(DatabaseSearchState $state, SearchCriteria $criteria): void { - if ($criteria->sortIsDefault) { - $query = $filterState->getQuery(); + if ($criteria->sortIsDefault && ! $state->isFulltextSearch()) { + $query = $state->getQuery(); // If we are viewing a specific tag, then pin all stickied // discussions to the top no matter what. - $filters = $filterState->getActiveFilters(); + $filters = $state->getActiveFilters(); if ($count = count($filters)) { - if ($count === 1 && $filters[0] instanceof TagFilterGambit) { + if ($count === 1 && $filters[0] instanceof TagFilter) { if (! is_array($query->orders)) { $query->orders = []; } @@ -51,14 +51,14 @@ public function __invoke(FilterState $filterState, QueryCriteria $criteria): voi ->selectRaw('1') ->from('discussion_user as sticky') ->whereColumn('sticky.discussion_id', 'id') - ->where('sticky.user_id', '=', $filterState->getActor()->id) + ->where('sticky.user_id', '=', $state->getActor()->id) ->whereColumn('sticky.last_read_post_number', '>=', 'last_post_number'); // Add the bindings manually (rather than as the second // argument in orderByRaw) for now due to a bug in Laravel which // would add the bindings in the wrong order. $query->orderByRaw('is_sticky and not exists ('.$read->toSql().') and last_posted_at > ? desc') - ->addBinding(array_merge($read->getBindings(), [$filterState->getActor()->marked_all_as_read_at ?: 0]), 'union'); + ->addBinding(array_merge($read->getBindings(), [$state->getActor()->marked_all_as_read_at ?: 0]), 'union'); $query->unionOrders = array_merge($query->unionOrders, $query->orders); $query->unionLimit = $query->limit; diff --git a/extensions/sticky/src/Query/StickyFilter.php b/extensions/sticky/src/Query/StickyFilter.php new file mode 100644 index 0000000000..74ab036ebf --- /dev/null +++ b/extensions/sticky/src/Query/StickyFilter.php @@ -0,0 +1,36 @@ + + */ +class StickyFilter implements FilterInterface +{ + public function getFilterKey(): string + { + return 'sticky'; + } + + public function filter(SearchState $state, string|array $value, bool $negate): void + { + $this->constrain($state->getQuery(), $negate); + } + + protected function constrain(Builder $query, bool $negate): void + { + $query->where('is_sticky', ! $negate); + } +} diff --git a/extensions/sticky/src/Query/StickyFilterGambit.php b/extensions/sticky/src/Query/StickyFilterGambit.php deleted file mode 100644 index e95ddd0507..0000000000 --- a/extensions/sticky/src/Query/StickyFilterGambit.php +++ /dev/null @@ -1,44 +0,0 @@ -constrain($search->getQuery(), $negate); - } - - public function getFilterKey(): string - { - return 'sticky'; - } - - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void - { - $this->constrain($filterState->getQuery(), $negate); - } - - protected function constrain(Builder $query, bool $negate): void - { - $query->where('is_sticky', ! $negate); - } -} diff --git a/extensions/subscriptions/extend.php b/extensions/subscriptions/extend.php index 468265cc1e..f114e11fea 100644 --- a/extensions/subscriptions/extend.php +++ b/extensions/subscriptions/extend.php @@ -12,7 +12,6 @@ use Flarum\Approval\Event\PostWasApproved; use Flarum\Discussion\Discussion; use Flarum\Discussion\Event\Saving; -use Flarum\Discussion\Filter\DiscussionFilterer; use Flarum\Discussion\Search\DiscussionSearcher; use Flarum\Discussion\UserState; use Flarum\Extend; @@ -20,14 +19,18 @@ use Flarum\Post\Event\Hidden; use Flarum\Post\Event\Posted; use Flarum\Post\Event\Restored; +use Flarum\Search\Database\DatabaseSearchDriver; +use Flarum\Subscriptions\Filter\SubscriptionFilter; use Flarum\Subscriptions\HideIgnoredFromAllDiscussionsPage; use Flarum\Subscriptions\Listener; use Flarum\Subscriptions\Notification\FilterVisiblePostsBeforeSending; use Flarum\Subscriptions\Notification\NewPostBlueprint; -use Flarum\Subscriptions\Query\SubscriptionFilterGambit; use Flarum\User\User; return [ + (new Extend\Frontend('admin')) + ->js(__DIR__.'/js/dist/admin.js'), + (new Extend\Frontend('forum')) ->js(__DIR__.'/js/dist/forum.js') ->css(__DIR__.'/less/forum.less') @@ -67,12 +70,9 @@ ->listen(Deleted::class, Listener\DeleteNotificationWhenPostIsHiddenOrDeleted::class) ->listen(Posted::class, Listener\FollowAfterReply::class), - (new Extend\Filter(DiscussionFilterer::class)) - ->addFilter(SubscriptionFilterGambit::class) - ->addFilterMutator(HideIgnoredFromAllDiscussionsPage::class), - - (new Extend\SimpleFlarumSearch(DiscussionSearcher::class)) - ->addGambit(SubscriptionFilterGambit::class), + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->addFilter(DiscussionSearcher::class, SubscriptionFilter::class) + ->addMutator(DiscussionSearcher::class, HideIgnoredFromAllDiscussionsPage::class), (new Extend\User()) ->registerPreference('flarum-subscriptions.notify_for_all_posts', 'boolval', false), diff --git a/extensions/subscriptions/js/admin.ts b/extensions/subscriptions/js/admin.ts new file mode 100644 index 0000000000..3e69ff3b97 --- /dev/null +++ b/extensions/subscriptions/js/admin.ts @@ -0,0 +1 @@ +export * from './src/admin'; diff --git a/extensions/subscriptions/js/src/admin/extend.ts b/extensions/subscriptions/js/src/admin/extend.ts new file mode 100644 index 0000000000..9ce8b5f124 --- /dev/null +++ b/extensions/subscriptions/js/src/admin/extend.ts @@ -0,0 +1 @@ +export { default as default } from '../common/extend'; diff --git a/extensions/subscriptions/js/src/admin/index.ts b/extensions/subscriptions/js/src/admin/index.ts new file mode 100644 index 0000000000..6d2293da0b --- /dev/null +++ b/extensions/subscriptions/js/src/admin/index.ts @@ -0,0 +1 @@ +export { default as extend } from './extend'; diff --git a/extensions/subscriptions/js/src/common/extend.ts b/extensions/subscriptions/js/src/common/extend.ts new file mode 100644 index 0000000000..081bda241a --- /dev/null +++ b/extensions/subscriptions/js/src/common/extend.ts @@ -0,0 +1,7 @@ +import Extend from 'flarum/common/extenders'; +import SubscriptionGambit from './query/discussions/SubscriptionGambit'; + +export default [ + new Extend.Search() // + .gambit('discussions', SubscriptionGambit), +]; diff --git a/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts b/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts new file mode 100644 index 0000000000..6d1a137078 --- /dev/null +++ b/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts @@ -0,0 +1,23 @@ +import IGambit from 'flarum/common/query/IGambit'; + +export default class SubscriptionGambit implements IGambit { + pattern(): string { + return 'is:(follow|ignor)(?:ing|ed)'; + } + + toFilter(matches: string[], negate: boolean): Record { + const type = matches[1] === 'follow' ? 'following' : 'ignoring'; + + return { + subscription: type, + }; + } + + filterKey(): string { + return 'subscription'; + } + + fromFilter(value: string, negate: boolean): string { + return `${negate ? '-' : ''}is:${value}`; + } +} diff --git a/extensions/subscriptions/js/src/forum/addSubscriptionFilter.js b/extensions/subscriptions/js/src/forum/addSubscriptionFilter.js index bd886731f7..02e6d912b7 100644 --- a/extensions/subscriptions/js/src/forum/addSubscriptionFilter.js +++ b/extensions/subscriptions/js/src/forum/addSubscriptionFilter.js @@ -36,12 +36,7 @@ export default function addSubscriptionFilter() { extend(DiscussionListState.prototype, 'requestParams', function (params) { if (this.params.onFollowing) { params.filter ||= {}; - - if (params.filter.q) { - params.filter.q += ' is:following'; - } else { - params.filter.subscription = 'following'; - } + params.filter.subscription = 'following'; } }); } diff --git a/extensions/subscriptions/js/src/forum/extend.ts b/extensions/subscriptions/js/src/forum/extend.ts index 18a948b212..065976b40e 100644 --- a/extensions/subscriptions/js/src/forum/extend.ts +++ b/extensions/subscriptions/js/src/forum/extend.ts @@ -2,7 +2,11 @@ import Extend from 'flarum/common/extenders'; import IndexPage from 'flarum/forum/components/IndexPage'; import Discussion from 'flarum/common/models/Discussion'; +import commonExtend from '../common/extend'; + export default [ + ...commonExtend, + new Extend.Routes() // .add('following', '/following', IndexPage), diff --git a/extensions/subscriptions/src/Query/SubscriptionFilterGambit.php b/extensions/subscriptions/src/Filter/SubscriptionFilter.php similarity index 52% rename from extensions/subscriptions/src/Query/SubscriptionFilterGambit.php rename to extensions/subscriptions/src/Filter/SubscriptionFilter.php index fc3814c08f..6e6f6d2f44 100644 --- a/extensions/subscriptions/src/Query/SubscriptionFilterGambit.php +++ b/extensions/subscriptions/src/Filter/SubscriptionFilter.php @@ -7,42 +7,34 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\Subscriptions\Query; +namespace Flarum\Subscriptions\Filter; -use Flarum\Filter\FilterInterface; -use Flarum\Filter\FilterState; -use Flarum\Filter\ValidateFilterTrait; -use Flarum\Search\AbstractRegexGambit; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; +use Flarum\Search\ValidateFilterTrait; use Flarum\User\User; use Illuminate\Database\Query\Builder; -class SubscriptionFilterGambit extends AbstractRegexGambit implements FilterInterface +/** + * @implements FilterInterface + */ +class SubscriptionFilter implements FilterInterface { use ValidateFilterTrait; - protected function getGambitPattern(): string - { - return 'is:(follow|ignor)(?:ing|ed)'; - } - - protected function conditions(SearchState $search, array $matches, bool $negate): void - { - $this->constrain($search->getQuery(), $search->getActor(), $matches[1], $negate); - } - public function getFilterKey(): string { return 'subscription'; } - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void + public function filter(SearchState $state, string|array $value, bool $negate): void { - $filterValue = $this->asString($filterValue); + $value = $this->asString($value); - preg_match('/^'.$this->getGambitPattern().'$/i', 'is:'.$filterValue, $matches); + preg_match('/^(follow|ignor)(?:ing|ed)$/i', $value, $matches); - $this->constrain($filterState->getQuery(), $filterState->getActor(), $matches[1], $negate); + $this->constrain($state->getQuery(), $state->getActor(), $matches[1], $negate); } protected function constrain(Builder $query, User $actor, string $subscriptionType, bool $negate): void diff --git a/extensions/subscriptions/src/HideIgnoredFromAllDiscussionsPage.php b/extensions/subscriptions/src/HideIgnoredFromAllDiscussionsPage.php index 37b38e7778..2d36132c95 100644 --- a/extensions/subscriptions/src/HideIgnoredFromAllDiscussionsPage.php +++ b/extensions/subscriptions/src/HideIgnoredFromAllDiscussionsPage.php @@ -9,18 +9,18 @@ namespace Flarum\Subscriptions; -use Flarum\Filter\FilterState; -use Flarum\Query\QueryCriteria; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\SearchCriteria; class HideIgnoredFromAllDiscussionsPage { - public function __invoke(FilterState $filterState, QueryCriteria $criteria): void + public function __invoke(DatabaseSearchState $state, SearchCriteria $criteria): void { // We only want to hide on the "all discussions" page. - if (count($filterState->getActiveFilters()) === 0) { + if (count($state->getActiveFilters()) === 0 && ! $state->isFulltextSearch()) { // TODO: might be better as `id IN (subquery)`? - $actor = $filterState->getActor(); - $filterState->getQuery()->whereNotExists(function ($query) use ($actor) { + $actor = $state->getActor(); + $state->getQuery()->whereNotExists(function ($query) use ($actor) { $query->selectRaw(1) ->from('discussion_user') ->whereColumn('discussions.id', 'discussion_id') diff --git a/extensions/suspend/extend.php b/extensions/suspend/extend.php index fe924d00b7..fe4c1c87e3 100644 --- a/extensions/suspend/extend.php +++ b/extensions/suspend/extend.php @@ -10,6 +10,7 @@ use Flarum\Api\Serializer\BasicUserSerializer; use Flarum\Api\Serializer\UserSerializer; use Flarum\Extend; +use Flarum\Search\Database\DatabaseSearchDriver; use Flarum\Suspend\Access\UserPolicy; use Flarum\Suspend\AddUserSuspendAttributes; use Flarum\Suspend\Event\Suspended; @@ -17,10 +18,9 @@ use Flarum\Suspend\Listener; use Flarum\Suspend\Notification\UserSuspendedBlueprint; use Flarum\Suspend\Notification\UserUnsuspendedBlueprint; -use Flarum\Suspend\Query\SuspendedFilterGambit; +use Flarum\Suspend\Query\SuspendedFilter; use Flarum\Suspend\RevokeAccessFromSuspendedUsers; use Flarum\User\Event\Saving; -use Flarum\User\Filter\UserFilterer; use Flarum\User\Search\UserSearcher; use Flarum\User\User; @@ -58,11 +58,8 @@ (new Extend\User()) ->permissionGroups(RevokeAccessFromSuspendedUsers::class), - (new Extend\Filter(UserFilterer::class)) - ->addFilter(SuspendedFilterGambit::class), - - (new Extend\SimpleFlarumSearch(UserSearcher::class)) - ->addGambit(SuspendedFilterGambit::class), + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->addFilter(UserSearcher::class, SuspendedFilter::class), (new Extend\View()) ->namespace('flarum-suspend', __DIR__.'/views'), diff --git a/extensions/suspend/js/src/admin/extend.ts b/extensions/suspend/js/src/admin/extend.ts new file mode 100644 index 0000000000..9ce8b5f124 --- /dev/null +++ b/extensions/suspend/js/src/admin/extend.ts @@ -0,0 +1 @@ +export { default as default } from '../common/extend'; diff --git a/extensions/suspend/js/src/admin/index.js b/extensions/suspend/js/src/admin/index.js index 5b1d905f98..b7ff301320 100644 --- a/extensions/suspend/js/src/admin/index.js +++ b/extensions/suspend/js/src/admin/index.js @@ -1,5 +1,7 @@ import app from 'flarum/admin/app'; +export { default as extend } from './extend'; + app.initializers.add('flarum-suspend', () => { app.extensionData.for('flarum-suspend').registerPermission( { diff --git a/extensions/suspend/js/src/common/extend.ts b/extensions/suspend/js/src/common/extend.ts new file mode 100644 index 0000000000..c576d539fb --- /dev/null +++ b/extensions/suspend/js/src/common/extend.ts @@ -0,0 +1,7 @@ +import Extend from 'flarum/common/extenders'; +import SuspendedGambit from './query/users/SuspendedGambit'; + +export default [ + new Extend.Search() // + .gambit('users', SuspendedGambit), +]; diff --git a/extensions/suspend/js/src/common/query/users/SuspendedGambit.ts b/extensions/suspend/js/src/common/query/users/SuspendedGambit.ts new file mode 100644 index 0000000000..f8c2c4c6c0 --- /dev/null +++ b/extensions/suspend/js/src/common/query/users/SuspendedGambit.ts @@ -0,0 +1,23 @@ +import IGambit from 'flarum/common/query/IGambit'; + +export default class SuspendedGambit implements IGambit { + pattern(): string { + return 'is:suspended'; + } + + toFilter(_matches: string[], negate: boolean): Record { + const key = (negate ? '-' : '') + 'suspended'; + + return { + [key]: true, + }; + } + + filterKey(): string { + return 'suspended'; + } + + fromFilter(value: string, negate: boolean): string { + return `${negate ? '-' : ''}is:suspended`; + } +} diff --git a/extensions/suspend/js/src/forum/extend.ts b/extensions/suspend/js/src/forum/extend.ts index 6fd80fc861..d4de0dec11 100644 --- a/extensions/suspend/js/src/forum/extend.ts +++ b/extensions/suspend/js/src/forum/extend.ts @@ -2,10 +2,14 @@ import Extend from 'flarum/common/extenders'; import User from 'flarum/common/models/User'; import Model from 'flarum/common/Model'; +import commonExtend from '../common/extend'; + export default [ + ...commonExtend, + new Extend.Model(User) .attribute('canSuspend') - .attribute('suspendedUntil', Model.transformDate) + .attribute('suspendedUntil', Model.transformDate) .attribute('suspendReason') .attribute('suspendMessage'), ]; diff --git a/extensions/suspend/src/Query/SuspendedFilterGambit.php b/extensions/suspend/src/Query/SuspendedFilter.php similarity index 51% rename from extensions/suspend/src/Query/SuspendedFilterGambit.php rename to extensions/suspend/src/Query/SuspendedFilter.php index 24b192c4d0..ed9080066e 100644 --- a/extensions/suspend/src/Query/SuspendedFilterGambit.php +++ b/extensions/suspend/src/Query/SuspendedFilter.php @@ -10,52 +10,35 @@ namespace Flarum\Suspend\Query; use Carbon\Carbon; -use Flarum\Filter\FilterInterface; -use Flarum\Filter\FilterState; -use Flarum\Search\AbstractRegexGambit; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\User\Guest; use Flarum\User\UserRepository; use Illuminate\Database\Query\Builder; -class SuspendedFilterGambit extends AbstractRegexGambit implements FilterInterface +/** + * @implements FilterInterface + */ +class SuspendedFilter implements FilterInterface { public function __construct( protected UserRepository $users ) { } - protected function getGambitPattern(): string - { - return 'is:suspended'; - } - - public function apply(SearchState $search, string $bit): bool - { - if (! $search->getActor()->can('suspend', new Guest())) { - return false; - } - - return parent::apply($search, $bit); - } - - protected function conditions(SearchState $search, array $matches, bool $negate): void - { - $this->constrain($search->getQuery(), $negate); - } - public function getFilterKey(): string { return 'suspended'; } - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void + public function filter(SearchState $state, string|array $value, bool $negate): void { - if (! $filterState->getActor()->can('suspend', new Guest())) { + if (! $state->getActor()->can('suspend', new Guest())) { return; } - $this->constrain($filterState->getQuery(), $negate); + $this->constrain($state->getQuery(), $negate); } protected function constrain(Builder $query, bool $negate): void diff --git a/extensions/tags/extend.php b/extensions/tags/extend.php index b89f5f73ba..b4fffbec43 100644 --- a/extensions/tags/extend.php +++ b/extensions/tags/extend.php @@ -13,25 +13,25 @@ use Flarum\Api\Serializer\ForumSerializer; use Flarum\Discussion\Discussion; use Flarum\Discussion\Event\Saving; -use Flarum\Discussion\Filter\DiscussionFilterer; use Flarum\Discussion\Search\DiscussionSearcher; use Flarum\Extend; use Flarum\Flags\Api\Controller\ListFlagsController; use Flarum\Http\RequestUtil; -use Flarum\Post\Filter\PostFilterer; +use Flarum\Post\Filter\PostSearcher; use Flarum\Post\Post; +use Flarum\Search\Database\DatabaseSearchDriver; use Flarum\Tags\Access; use Flarum\Tags\Api\Controller; use Flarum\Tags\Api\Serializer\TagSerializer; use Flarum\Tags\Content; use Flarum\Tags\Event\DiscussionWasTagged; -use Flarum\Tags\Filter\HideHiddenTagsFromAllDiscussionsPage; -use Flarum\Tags\Filter\PostTagFilter; use Flarum\Tags\Listener; use Flarum\Tags\LoadForumTagsRelationship; use Flarum\Tags\Post\DiscussionTaggedPost; -use Flarum\Tags\Query\TagFilterGambit; -use Flarum\Tags\Search\Gambit\FulltextGambit; +use Flarum\Tags\Search\Filter\PostTagFilter; +use Flarum\Tags\Search\Filter\TagFilter; +use Flarum\Tags\Search\FulltextFilter; +use Flarum\Tags\Search\HideHiddenTagsFromAllDiscussionsPage; use Flarum\Tags\Search\TagSearcher; use Flarum\Tags\Tag; use Flarum\Tags\Utf8SlugDriver; @@ -135,18 +135,12 @@ ->listen(DiscussionWasTagged::class, Listener\CreatePostWhenTagsAreChanged::class) ->subscribe(Listener\UpdateTagMetadata::class), - (new Extend\Filter(PostFilterer::class)) - ->addFilter(PostTagFilter::class), - - (new Extend\Filter(DiscussionFilterer::class)) - ->addFilter(TagFilterGambit::class) - ->addFilterMutator(HideHiddenTagsFromAllDiscussionsPage::class), - - (new Extend\SimpleFlarumSearch(DiscussionSearcher::class)) - ->addGambit(TagFilterGambit::class), - - (new Extend\SimpleFlarumSearch(TagSearcher::class)) - ->setFullTextGambit(FullTextGambit::class), + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->addFilter(PostSearcher::class, PostTagFilter::class) + ->addFilter(DiscussionSearcher::class, TagFilter::class) + ->addMutator(DiscussionSearcher::class, HideHiddenTagsFromAllDiscussionsPage::class) + ->addSearcher(Tag::class, TagSearcher::class) + ->setFulltext(TagSearcher::class, FulltextFilter::class), (new Extend\ModelUrl(Tag::class)) ->addSlugDriver('default', Utf8SlugDriver::class), diff --git a/extensions/tags/js/src/admin/components/TagsPage.js b/extensions/tags/js/src/admin/components/TagsPage.js index 21becc24d9..f48e4a4cdd 100644 --- a/extensions/tags/js/src/admin/components/TagsPage.js +++ b/extensions/tags/js/src/admin/components/TagsPage.js @@ -10,6 +10,7 @@ import Form from 'flarum/common/components/Form'; import EditTagModal from './EditTagModal'; import tagIcon from '../../common/helpers/tagIcon'; import sortTags from '../../common/utils/sortTags'; +import FormSectionGroup, { FormSection } from '@flarum/core/src/admin/components/FormSectionGroup'; function tagItem(tag) { return ( @@ -66,17 +67,15 @@ export default class TagsPage extends ExtensionPage {
-
-
- + +
    {tags.filter((tag) => tag.position() !== null && !tag.isChild()).map(tagItem)}
-
+ -
- +
    {tags .filter((tag) => tag.position() === null) @@ -86,41 +85,44 @@ export default class TagsPage extends ExtensionPage { -
-
-
- -
{app.translator.trans('flarum-tags.admin.tag_settings.required_primary_text')}
-
- - {app.translator.trans('flarum-tags.admin.tag_settings.range_separator_text')} - + + + + +
+ +
{app.translator.trans('flarum-tags.admin.tag_settings.required_primary_text')}
+
+ + {app.translator.trans('flarum-tags.admin.tag_settings.range_separator_text')} + +
-
-
- -
{app.translator.trans('flarum-tags.admin.tag_settings.required_secondary_text')}
-
- - {app.translator.trans('flarum-tags.admin.tag_settings.range_separator_text')} - +
+ +
{app.translator.trans('flarum-tags.admin.tag_settings.required_secondary_text')}
+
+ + {app.translator.trans('flarum-tags.admin.tag_settings.range_separator_text')} + +
-
-
{this.submitButton()}
- -
+
{this.submitButton()}
+ + +

{app.translator.trans('flarum-tags.admin.tags.about_tags_text')}

diff --git a/extensions/tags/js/src/common/extend.ts b/extensions/tags/js/src/common/extend.ts index ca8be1f80a..b464dd7d54 100644 --- a/extensions/tags/js/src/common/extend.ts +++ b/extensions/tags/js/src/common/extend.ts @@ -1,7 +1,11 @@ import Extend from 'flarum/common/extenders'; import Tag from './models/Tag'; +import TagGambit from './query/discussions/TagGambit'; export default [ new Extend.Store() // .add('tags', Tag), + + new Extend.Search() // + .gambit('discussions', TagGambit), ]; diff --git a/extensions/tags/js/src/common/query/discussions/TagGambit.ts b/extensions/tags/js/src/common/query/discussions/TagGambit.ts new file mode 100644 index 0000000000..0cae0b17bc --- /dev/null +++ b/extensions/tags/js/src/common/query/discussions/TagGambit.ts @@ -0,0 +1,23 @@ +import IGambit from 'flarum/common/query/IGambit'; + +export default class TagGambit implements IGambit { + pattern(): string { + return 'tag:(.+)'; + } + + toFilter(matches: string[], negate: boolean): Record { + const key = (negate ? '-' : '') + 'tag'; + + return { + [key]: matches[1].split(','), + }; + } + + filterKey(): string { + return 'tag'; + } + + fromFilter(value: string, negate: boolean): string { + return `${negate ? '-' : ''}tag:${value}`; + } +} diff --git a/extensions/tags/js/src/forum/addTagFilter.tsx b/extensions/tags/js/src/forum/addTagFilter.tsx index dd8d7cd69b..2c321247c4 100644 --- a/extensions/tags/js/src/forum/addTagFilter.tsx +++ b/extensions/tags/js/src/forum/addTagFilter.tsx @@ -124,14 +124,8 @@ export default function addTagFilter() { } if (this.params.tags) { - const filter = params.filter ?? {}; - filter.tag = this.params.tags; - // TODO: replace this with a more robust system. - const q = filter.q; - if (q) { - filter.q = `${q} tag:${this.params.tags}`; - } - params.filter = filter; + params.filter ||= {}; + params.filter.tag = this.params.tags; } }); } diff --git a/extensions/tags/less/admin/TagsPage.less b/extensions/tags/less/admin/TagsPage.less index 20c9ca9322..7f19b45545 100644 --- a/extensions/tags/less/admin/TagsPage.less +++ b/extensions/tags/less/admin/TagsPage.less @@ -13,7 +13,6 @@ .TagsContent-list { padding: 20px 0 0; - } .TagList, @@ -22,6 +21,7 @@ padding: 0; color: var(--muted-color); font-size: 13px; + margin-top: 0; >li { display: inline-block; @@ -80,77 +80,35 @@ li:not(.sortable-dragging)>.TagListItem-info:hover>.Button { height: 34px; } -.SettingsGroups { - display: flex; - column-count: 3; - column-gap: 30px; - flex-wrap: wrap; - - @media (@tablet-up) { - .TagGroup--secondary { - max-width: 250px !important; - } +@media (@tablet-up) { + .TagGroup--secondary { + max-width: 250px !important; } +} - .Form { - min-width: 300px; - max-height: 500px; - - >label { - margin-bottom: 10px; - } +.TagList-button { + background: none; + border: 1px dashed var(--control-bg); + height: 40px; + margin: auto auto 0 0; +} - .TagSettings-rangeInput { - input { - width: 80px; - display: inline; - margin: 0 5px; +.TagSettings-rangeInput { + input { + width: 80px; + display: inline; + margin: 0 5px; - &:first-child { - margin-left: 0; - } - } + &:first-child { + margin-left: 0; } } +} - .TagGroup, - .Form { - display: inline-grid; - padding: 10px 20px; - min-height: 20vh; - max-width: 400px; - grid-template-rows: min-content; - border: 1px solid var(--control-bg); - border-radius: var(--border-radius); - flex: 1 1 160px; - - @media (max-width: 1209px) { - margin-bottom: 20px; - } - - >ol { - >li { - margin-top: 8px; - - .Button { - float: right; - visibility: hidden; - margin: -8px -16px -8px 16px; - } - } - } - - .TagList-button { - background: none; - border: 1px dashed var(--control-bg); - height: 40px; - margin: auto auto 0 0; - } - - >label { - float: left; - font-weight: bold; - color: var(--muted-color); +.TagGroup { + ol { + > li:not(:first-child) { + margin-top: 8px; } } } diff --git a/extensions/tags/src/Api/Controller/ListTagsController.php b/extensions/tags/src/Api/Controller/ListTagsController.php index 85400f3c5e..2f23267a6a 100644 --- a/extensions/tags/src/Api/Controller/ListTagsController.php +++ b/extensions/tags/src/Api/Controller/ListTagsController.php @@ -12,9 +12,10 @@ use Flarum\Api\Controller\AbstractListController; use Flarum\Http\RequestUtil; use Flarum\Http\UrlGenerator; -use Flarum\Query\QueryCriteria; +use Flarum\Search\SearchCriteria; +use Flarum\Search\SearchManager; use Flarum\Tags\Api\Serializer\TagSerializer; -use Flarum\Tags\Search\TagSearcher; +use Flarum\Tags\Tag; use Flarum\Tags\TagRepository; use Psr\Http\Message\ServerRequestInterface; use Tobscure\JsonApi\Document; @@ -35,7 +36,7 @@ class ListTagsController extends AbstractListController public function __construct( protected TagRepository $tags, - protected TagSearcher $searcher, + protected SearchManager $search, protected UrlGenerator $url ) { } @@ -53,7 +54,8 @@ protected function data(ServerRequestInterface $request, Document $document): it } if (array_key_exists('q', $filters)) { - $results = $this->searcher->search(new QueryCriteria($actor, $filters), $limit, $offset); + $results = $this->search->query(Tag::class, new SearchCriteria($actor, $filters, $limit, $offset)); + $tags = $results->getResults(); $document->addPaginationLinks( diff --git a/extensions/tags/src/Filter/PostTagFilter.php b/extensions/tags/src/Search/Filter/PostTagFilter.php similarity index 55% rename from extensions/tags/src/Filter/PostTagFilter.php rename to extensions/tags/src/Search/Filter/PostTagFilter.php index 39709c2e12..34506a41ef 100644 --- a/extensions/tags/src/Filter/PostTagFilter.php +++ b/extensions/tags/src/Search/Filter/PostTagFilter.php @@ -7,12 +7,16 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\Tags\Filter; +namespace Flarum\Tags\Search\Filter; -use Flarum\Filter\FilterInterface; -use Flarum\Filter\FilterState; -use Flarum\Filter\ValidateFilterTrait; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; +use Flarum\Search\SearchState; +use Flarum\Search\ValidateFilterTrait; +/** + * @implements FilterInterface + */ class PostTagFilter implements FilterInterface { use ValidateFilterTrait; @@ -22,11 +26,11 @@ public function getFilterKey(): string return 'tag'; } - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void + public function filter(SearchState $state, string|array $value, bool $negate): void { - $ids = $this->asIntArray($filterValue); + $ids = $this->asIntArray($value); - $filterState->getQuery() + $state->getQuery() ->join('discussion_tag', 'discussion_tag.discussion_id', '=', 'posts.discussion_id') ->whereIn('discussion_tag.tag_id', $ids, 'and', $negate); } diff --git a/extensions/tags/src/Query/TagFilterGambit.php b/extensions/tags/src/Search/Filter/TagFilter.php similarity index 71% rename from extensions/tags/src/Query/TagFilterGambit.php rename to extensions/tags/src/Search/Filter/TagFilter.php index 159bb26810..7bc43e828c 100644 --- a/extensions/tags/src/Query/TagFilterGambit.php +++ b/extensions/tags/src/Search/Filter/TagFilter.php @@ -7,20 +7,22 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\Tags\Query; +namespace Flarum\Tags\Search\Filter; -use Flarum\Filter\FilterInterface; -use Flarum\Filter\FilterState; -use Flarum\Filter\ValidateFilterTrait; use Flarum\Http\SlugManager; -use Flarum\Search\AbstractRegexGambit; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; +use Flarum\Search\ValidateFilterTrait; use Flarum\Tags\Tag; use Flarum\User\User; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Query\Builder; -class TagFilterGambit extends AbstractRegexGambit implements FilterInterface +/** + * @implements FilterInterface + */ +class TagFilter implements FilterInterface { use ValidateFilterTrait; @@ -29,24 +31,14 @@ public function __construct( ) { } - protected function getGambitPattern(): string - { - return 'tag:(.+)'; - } - - protected function conditions(SearchState $search, array $matches, bool $negate): void - { - $this->constrain($search->getQuery(), $matches[1], $negate, $search->getActor()); - } - public function getFilterKey(): string { return 'tag'; } - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void + public function filter(SearchState $state, string|array $value, bool $negate): void { - $this->constrain($filterState->getQuery(), $filterValue, $negate, $filterState->getActor()); + $this->constrain($state->getQuery(), $value, $negate, $state->getActor()); } protected function constrain(Builder $query, string|array $rawSlugs, bool $negate, User $actor): void diff --git a/extensions/tags/src/Search/Gambit/FulltextGambit.php b/extensions/tags/src/Search/FulltextFilter.php similarity index 65% rename from extensions/tags/src/Search/Gambit/FulltextGambit.php rename to extensions/tags/src/Search/FulltextFilter.php index ad8d59ca34..adc71fe7cb 100644 --- a/extensions/tags/src/Search/Gambit/FulltextGambit.php +++ b/extensions/tags/src/Search/FulltextFilter.php @@ -7,14 +7,18 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\Tags\Search\Gambit; +namespace Flarum\Tags\Search; -use Flarum\Search\GambitInterface; +use Flarum\Search\AbstractFulltextFilter; +use Flarum\Search\Database\DatabaseSearchState; use Flarum\Search\SearchState; use Flarum\Tags\TagRepository; use Illuminate\Database\Eloquent\Builder; -class FulltextGambit implements GambitInterface +/** + * @extends AbstractFulltextFilter + */ +class FulltextFilter extends AbstractFulltextFilter { public function __construct( protected TagRepository $tags @@ -30,14 +34,12 @@ private function getTagSearchSubQuery(string $searchValue): Builder ->orWhere('slug', 'like', "$searchValue%"); } - public function apply(SearchState $search, string $bit): bool + public function search(SearchState $state, string $value): void { - $search->getQuery() + $state->getQuery() ->whereIn( 'id', - $this->getTagSearchSubQuery($bit) + $this->getTagSearchSubQuery($value) ); - - return true; } } diff --git a/extensions/tags/src/Filter/HideHiddenTagsFromAllDiscussionsPage.php b/extensions/tags/src/Search/HideHiddenTagsFromAllDiscussionsPage.php similarity index 55% rename from extensions/tags/src/Filter/HideHiddenTagsFromAllDiscussionsPage.php rename to extensions/tags/src/Search/HideHiddenTagsFromAllDiscussionsPage.php index b4ae373a08..9954602025 100644 --- a/extensions/tags/src/Filter/HideHiddenTagsFromAllDiscussionsPage.php +++ b/extensions/tags/src/Search/HideHiddenTagsFromAllDiscussionsPage.php @@ -7,21 +7,21 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\Tags\Filter; +namespace Flarum\Tags\Search; -use Flarum\Filter\FilterState; -use Flarum\Query\QueryCriteria; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\SearchCriteria; use Flarum\Tags\Tag; class HideHiddenTagsFromAllDiscussionsPage { - public function __invoke(FilterState $filter, QueryCriteria $queryCriteria): void + public function __invoke(DatabaseSearchState $state, SearchCriteria $queryCriteria): void { - if (count($filter->getActiveFilters()) > 0) { + if (count($state->getActiveFilters()) > 0 || $state->isFulltextSearch()) { return; } - $filter->getQuery()->whereNotIn('discussions.id', function ($query) { + $state->getQuery()->whereNotIn('discussions.id', function ($query) { return $query->select('discussion_id') ->from('discussion_tag') ->whereIn('tag_id', Tag::where('is_hidden', 1)->pluck('id')); diff --git a/extensions/tags/src/Search/TagSearcher.php b/extensions/tags/src/Search/TagSearcher.php index b92ef5960e..9224030d71 100644 --- a/extensions/tags/src/Search/TagSearcher.php +++ b/extensions/tags/src/Search/TagSearcher.php @@ -9,24 +9,15 @@ namespace Flarum\Tags\Search; -use Flarum\Search\AbstractSearcher; -use Flarum\Search\GambitManager; -use Flarum\Tags\TagRepository; +use Flarum\Search\Database\AbstractSearcher; +use Flarum\Tags\Tag; use Flarum\User\User; use Illuminate\Database\Eloquent\Builder; class TagSearcher extends AbstractSearcher { - public function __construct( - protected TagRepository $tags, - GambitManager $gambits, - array $searchMutators - ) { - parent::__construct($gambits, $searchMutators); - } - - protected function getQuery(User $actor): Builder + public function getQuery(User $actor): Builder { - return $this->tags->query()->whereVisibleTo($actor); + return Tag::whereVisibleTo($actor)->select('tags.*'); } } diff --git a/extensions/tags/tests/integration/api/tags/ListWithFulltextSearchTest.php b/extensions/tags/tests/integration/api/tags/ListWithFulltextSearchTest.php index 059e15da5e..d6ff6920ee 100644 --- a/extensions/tags/tests/integration/api/tags/ListWithFulltextSearchTest.php +++ b/extensions/tags/tests/integration/api/tags/ListWithFulltextSearchTest.php @@ -49,9 +49,9 @@ public function can_search_for_tags(string $search, array $expected) ]) ); - $data = json_decode($response->getBody()->getContents(), true)['data']; + $data = json_decode($contents = $response->getBody()->getContents(), true)['data'] ?? []; - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), $contents); $this->assertEquals($expected, Arr::pluck($data, 'id')); } diff --git a/framework/core/js/src/admin/AdminApplication.tsx b/framework/core/js/src/admin/AdminApplication.tsx index 9a16f3381a..751a9a3fb2 100644 --- a/framework/core/js/src/admin/AdminApplication.tsx +++ b/framework/core/js/src/admin/AdminApplication.tsx @@ -40,7 +40,9 @@ export interface AdminApplicationData extends ApplicationData { modelStatistics: Record; displayNameDrivers: string[]; slugDrivers: Record; + searchDrivers: Record; permissions: Record; + advancedPageEmpty: boolean; } export default class AdminApplication extends Application { diff --git a/framework/core/js/src/admin/components/AdminNav.js b/framework/core/js/src/admin/components/AdminNav.js index 1f18a65e5b..3972bc4140 100644 --- a/framework/core/js/src/admin/components/AdminNav.js +++ b/framework/core/js/src/admin/components/AdminNav.js @@ -110,6 +110,16 @@ export default class AdminNav extends Component { 50 ); + if (app.data.settings.show_advanced_settings && !app.data.advancedPageEmpty) { + items.add( + 'advanced', + + {app.translator.trans('core.admin.nav.advanced_button')} + , + 40 + ); + } + items.add( 'search',
diff --git a/framework/core/js/src/admin/components/AdminPage.tsx b/framework/core/js/src/admin/components/AdminPage.tsx index 48d8cc6444..03c677ac6f 100644 --- a/framework/core/js/src/admin/components/AdminPage.tsx +++ b/framework/core/js/src/admin/components/AdminPage.tsx @@ -14,6 +14,7 @@ import ColorPreviewInput from '../../common/components/ColorPreviewInput'; import ItemList from '../../common/utils/ItemList'; import type { IUploadImageButtonAttrs } from './UploadImageButton'; import UploadImageButton from './UploadImageButton'; +import extractText from '../../common/utils/extractText'; export interface AdminHeaderOptions { title: Mithril.Children; @@ -410,4 +411,12 @@ export default abstract class AdminPage { + return { + 'Flarum\\Discussion\\Discussion': extractText(app.translator.trans('core.admin.models.discussions')), + 'Flarum\\User\\User': extractText(app.translator.trans('core.admin.models.users')), + 'Flarum\\Post\\Post': extractText(app.translator.trans('core.admin.models.posts')), + }; + } } diff --git a/framework/core/js/src/admin/components/AdvancedPage.tsx b/framework/core/js/src/admin/components/AdvancedPage.tsx new file mode 100644 index 0000000000..21067aa061 --- /dev/null +++ b/framework/core/js/src/admin/components/AdvancedPage.tsx @@ -0,0 +1,73 @@ +import app from '../../admin/app'; +import AdminPage from './AdminPage'; +import type { IPageAttrs } from '../../common/components/Page'; +import type Mithril from 'mithril'; +import Form from '../../common/components/Form'; +import extractText from '../../common/utils/extractText'; +import FormSectionGroup, { FormSection } from './FormSectionGroup'; + +export default class AdvancedPage extends AdminPage { + searchDriverOptions: Record> = {}; + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + + const locale = this.driverLocale(); + + Object.keys(app.data.searchDrivers).forEach((model) => { + this.searchDriverOptions[model] = {}; + + app.data.searchDrivers[model].forEach((option) => { + this.searchDriverOptions[model][option] = locale.search[option] || option; + }); + }); + } + + headerInfo() { + return { + className: 'AdvancedPage', + icon: 'fas fa-cog', + title: app.translator.trans('core.admin.advanced.title'), + description: app.translator.trans('core.admin.advanced.description'), + }; + } + + content() { + return [ +
+ + + + {Object.keys(this.searchDriverOptions).map((model) => { + const options = this.searchDriverOptions[model]; + const modelLocale = this.modelLocale()[model] || model; + + if (Object.keys(options).length > 1) { + return this.buildSettingComponent({ + type: 'select', + setting: `search_driver_${model}`, + options, + label: app.translator.trans('core.admin.advanced.search.driver_heading', { model: modelLocale }), + help: app.translator.trans('core.admin.advanced.search.driver_text', { model: modelLocale }), + }); + } + + return null; + })} + + +
+ +
{this.submitButton()}
+ , + ]; + } + + driverLocale(): Record> { + return { + search: { + default: extractText(app.translator.trans('core.admin.advanced.search.driver_options.default')), + }, + }; + } +} diff --git a/framework/core/js/src/admin/components/BasicsPage.tsx b/framework/core/js/src/admin/components/BasicsPage.tsx index abfa9f77c8..edd81393e6 100644 --- a/framework/core/js/src/admin/components/BasicsPage.tsx +++ b/framework/core/js/src/admin/components/BasicsPage.tsx @@ -5,8 +5,13 @@ import AdminPage from './AdminPage'; import type { IPageAttrs } from '../../common/components/Page'; import type Mithril from 'mithril'; import Form from '../../common/components/Form'; +import extractText from '../../common/utils/extractText'; export type HomePageItem = { path: string; label: Mithril.Children }; +export type DriverLocale = { + display_name: Record; + slug: Record>; +}; export default class BasicsPage extends AdminPage { localeOptions: Record = {}; @@ -20,15 +25,17 @@ export default class BasicsPage ext this.localeOptions[i] = `${app.data.locales[i]} (${i})`; }); + const driverLocale = this.driverLocale(); + app.data.displayNameDrivers.forEach((identifier) => { - this.displayNameOptions[identifier] = identifier; + this.displayNameOptions[identifier] = driverLocale.display_name[identifier] || identifier; }); Object.keys(app.data.slugDrivers).forEach((model) => { this.slugDriverOptions[model] = {}; app.data.slugDrivers[model].forEach((option) => { - this.slugDriverOptions[model][option] = option; + this.slugDriverOptions[model][option] = (driverLocale.slug[model] && driverLocale.slug[model][option]) || option; }); }); } @@ -108,14 +115,15 @@ export default class BasicsPage ext {Object.keys(this.slugDriverOptions).map((model) => { const options = this.slugDriverOptions[model]; + const modelLocale = this.modelLocale()[model] || model; if (Object.keys(options).length > 1) { return this.buildSettingComponent({ type: 'select', setting: `slug_driver_${model}`, options, - label: app.translator.trans('core.admin.basics.slug_driver_heading', { model }), - help: app.translator.trans('core.admin.basics.slug_driver_text', { model }), + label: app.translator.trans('core.admin.basics.slug_driver_heading', { model: modelLocale }), + help: app.translator.trans('core.admin.basics.slug_driver_text', { model: modelLocale }), }); } @@ -141,4 +149,22 @@ export default class BasicsPage ext return items; } + + driverLocale(): DriverLocale { + return { + display_name: { + username: extractText(app.translator.trans('core.admin.basics.display_name_driver_options.username')), + }, + slug: { + 'Flarum\\Discussion\\Discussion': { + default: extractText(app.translator.trans('core.admin.basics.slug_driver_options.discussions.default')), + utf8: extractText(app.translator.trans('core.admin.basics.slug_driver_options.discussions.utf8')), + }, + 'Flarum\\User\\User': { + default: extractText(app.translator.trans('core.admin.basics.slug_driver_options.users.default')), + id: extractText(app.translator.trans('core.admin.basics.slug_driver_options.users.id')), + }, + }, + }; + } } diff --git a/framework/core/js/src/admin/components/FormSectionGroup.tsx b/framework/core/js/src/admin/components/FormSectionGroup.tsx new file mode 100644 index 0000000000..fa45574152 --- /dev/null +++ b/framework/core/js/src/admin/components/FormSectionGroup.tsx @@ -0,0 +1,35 @@ +import Component from '../../common/Component'; +import type { ComponentAttrs } from '../../common/Component'; +import Mithril from 'mithril'; +import classList from '../../common/utils/classList'; + +export interface IFormSectionGroupAttrs extends ComponentAttrs {} + +export default class FormSectionGroup extends Component { + view(vnode: Mithril.Vnode) { + const { className, ...attrs } = this.attrs; + + return ( +
+ {vnode.children} +
+ ); + } +} + +export interface IFormSectionAttrs extends ComponentAttrs { + label: any; +} + +export class FormSection extends Component { + view(vnode: Mithril.Vnode) { + const { className, ...attrs } = this.attrs; + + return ( +
+ +
{vnode.children}
+
+ ); + } +} diff --git a/framework/core/js/src/admin/components/StatusWidget.js b/framework/core/js/src/admin/components/StatusWidget.js index 4dc042d29c..ee6dc3ebf6 100644 --- a/framework/core/js/src/admin/components/StatusWidget.js +++ b/framework/core/js/src/admin/components/StatusWidget.js @@ -6,6 +6,7 @@ import Dropdown from '../../common/components/Dropdown'; import Button from '../../common/components/Button'; import LoadingModal from './LoadingModal'; import LinkButton from '../../common/components/LinkButton'; +import saveSettings from '../utils/saveSettings.js'; export default class StatusWidget extends DashboardWidget { className() { @@ -71,6 +72,25 @@ export default class StatusWidget extends DashboardWidget { ); + if (!app.data.advancedPageEmpty) { + items.add( + 'toggleAdvancedPage', + + ); + } + return items; } diff --git a/framework/core/js/src/admin/routes.ts b/framework/core/js/src/admin/routes.ts index 87271c88d8..fda289869b 100644 --- a/framework/core/js/src/admin/routes.ts +++ b/framework/core/js/src/admin/routes.ts @@ -7,6 +7,7 @@ import MailPage from './components/MailPage'; import UserListPage from './components/UserListPage'; import ExtensionPage from './components/ExtensionPage'; import ExtensionPageResolver from './resolvers/ExtensionPageResolver'; +import AdvancedPage from './components/AdvancedPage'; /** * Helper functions to generate URLs to admin pages. @@ -24,6 +25,7 @@ export default function (app: AdminApplication) { appearance: { path: '/appearance', component: AppearancePage }, mail: { path: '/mail', component: MailPage }, users: { path: '/users', component: UserListPage }, + advanced: { path: '/advanced', component: AdvancedPage }, extension: { path: '/extension/:id', component: ExtensionPage, resolverClass: ExtensionPageResolver }, }; } diff --git a/framework/core/js/src/common/GambitManager.ts b/framework/core/js/src/common/GambitManager.ts new file mode 100644 index 0000000000..10774c0920 --- /dev/null +++ b/framework/core/js/src/common/GambitManager.ts @@ -0,0 +1,72 @@ +import IGambit from './query/IGambit'; +import AuthorGambit from './query/discussions/AuthorGambit'; +import CreatedGambit from './query/discussions/CreatedGambit'; +import HiddenGambit from './query/discussions/HiddenGambit'; +import UnreadGambit from './query/discussions/UnreadGambit'; +import EmailGambit from './query/users/EmailGambit'; +import GroupGambit from './query/users/GroupGambit'; + +/** + * The gambit registry. A map of resource types to gambit classes that + * should be used to filter resources of that type. Gambits are automatically + * converted to API filters when requesting resources. Gambits must be applied + * on a filter object that has a `q` property containing the search query. + */ +export default class GambitManager { + gambits: Record IGambit>> = { + discussions: [AuthorGambit, CreatedGambit, HiddenGambit, UnreadGambit], + users: [EmailGambit, GroupGambit], + }; + + public apply(type: string, filter: Record): Record { + const gambits = this.gambits[type] || []; + + if (gambits.length === 0) return filter; + + const bits: string[] = filter.q.split(' '); + + for (const gambitClass of gambits) { + const gambit = new gambitClass(); + + for (const bit of bits) { + const pattern = `^(-?)${gambit.pattern()}$`; + let matches = bit.match(pattern); + + if (matches) { + const negate = matches[1] === '-'; + + matches.splice(1, 1); + + Object.assign(filter, gambit.toFilter(matches, negate)); + + filter.q = filter.q.replace(bit, ''); + } + } + } + + filter.q = filter.q.trim().replace(/\s+/g, ' '); + + return filter; + } + + public from(type: string, q: string, filter: Record): string { + const gambits = this.gambits[type] || []; + + if (gambits.length === 0) return q; + + Object.keys(filter).forEach((key) => { + for (const gambitClass of gambits) { + const gambit = new gambitClass(); + const negate = key[0] === '-'; + + if (negate) key = key.substring(1); + + if (gambit.filterKey() !== key) continue; + + q += ` ${gambit.fromFilter(filter[key], negate)}`; + } + }); + + return q; + } +} diff --git a/framework/core/js/src/common/Store.ts b/framework/core/js/src/common/Store.ts index 052ee020f0..f1d9e8784e 100644 --- a/framework/core/js/src/common/Store.ts +++ b/framework/core/js/src/common/Store.ts @@ -1,6 +1,7 @@ import app from '../common/app'; import { FlarumRequestOptions } from './Application'; import Model, { ModelData, SavedModelData } from './Model'; +import GambitManager from './GambitManager'; export interface MetaInformation { [key: string]: any; @@ -20,7 +21,7 @@ export interface ApiQueryParamsPlural { | { q: string; } - | Record; + | Record; page?: { near?: number; offset?: number; @@ -88,6 +89,12 @@ export default class Store { */ models: Record; + /** + * The gambit manager that will convert search query gambits + * into API filters. + */ + gambits = new GambitManager(); + constructor(models: Record) { this.models = models; } @@ -178,6 +185,10 @@ export default class Store { url += '/' + idOrParams; } + if ('filter' in params && params?.filter?.q) { + params.filter = this.gambits.apply(type, params.filter); + } + return app .request ? ApiPayloadPlural : ApiPayloadSingle>({ method: 'GET', diff --git a/framework/core/js/src/common/extenders/Search.ts b/framework/core/js/src/common/extenders/Search.ts new file mode 100644 index 0000000000..c2c54cc0b8 --- /dev/null +++ b/framework/core/js/src/common/extenders/Search.ts @@ -0,0 +1,24 @@ +import type IExtender from './IExtender'; +import type { IExtensionModule } from './IExtender'; +import type Application from '../Application'; +import IGambit from '../query/IGambit'; + +export default class Search implements IExtender { + protected gambits: Record IGambit>> = {}; + + public gambit(modelType: string, gambit: new () => IGambit): this { + this.gambits[modelType] = this.gambits[modelType] || []; + this.gambits[modelType].push(gambit); + + return this; + } + + extend(app: Application, extension: IExtensionModule): void { + for (const [modelType, gambits] of Object.entries(this.gambits)) { + for (const gambit of gambits) { + app.store.gambits.gambits[modelType] = app.store.gambits.gambits[modelType] || []; + app.store.gambits.gambits[modelType].push(gambit); + } + } + } +} diff --git a/framework/core/js/src/common/extenders/index.ts b/framework/core/js/src/common/extenders/index.ts index e2d5dda532..86efd55d2a 100644 --- a/framework/core/js/src/common/extenders/index.ts +++ b/framework/core/js/src/common/extenders/index.ts @@ -2,12 +2,14 @@ import Model from './Model'; import PostTypes from './PostTypes'; import Routes from './Routes'; import Store from './Store'; +import Search from './Search'; const extenders = { Model, PostTypes, Routes, Store, + Search, }; export default extenders; diff --git a/framework/core/js/src/common/query/IGambit.ts b/framework/core/js/src/common/query/IGambit.ts new file mode 100644 index 0000000000..a10e8bdb11 --- /dev/null +++ b/framework/core/js/src/common/query/IGambit.ts @@ -0,0 +1,6 @@ +export default interface IGambit { + pattern(): string; + toFilter(matches: string[], negate: boolean): Record; + filterKey(): string; + fromFilter(value: string, negate: boolean): string; +} diff --git a/framework/core/js/src/common/query/discussions/AuthorGambit.ts b/framework/core/js/src/common/query/discussions/AuthorGambit.ts new file mode 100644 index 0000000000..f38f71cda6 --- /dev/null +++ b/framework/core/js/src/common/query/discussions/AuthorGambit.ts @@ -0,0 +1,23 @@ +import IGambit from '../IGambit'; + +export default class AuthorGambit implements IGambit { + public pattern(): string { + return 'author:(.+)'; + } + + public toFilter(matches: string[], negate: boolean): Record { + const key = (negate ? '-' : '') + 'author'; + + return { + [key]: matches[1].split(','), + }; + } + + filterKey(): string { + return 'author'; + } + + fromFilter(value: string, negate: boolean): string { + return `${negate ? '-' : ''}author:${value}`; + } +} diff --git a/framework/core/js/src/common/query/discussions/CreatedGambit.ts b/framework/core/js/src/common/query/discussions/CreatedGambit.ts new file mode 100644 index 0000000000..087196165d --- /dev/null +++ b/framework/core/js/src/common/query/discussions/CreatedGambit.ts @@ -0,0 +1,23 @@ +import IGambit from '../IGambit'; + +export default class CreatedGambit implements IGambit { + pattern(): string { + return 'created:(\\d{4}\\-\\d\\d\\-\\d\\d(?:\\.\\.(\\d{4}\\-\\d\\d\\-\\d\\d))?)'; + } + + toFilter(matches: string[], negate: boolean): Record { + const key = (negate ? '-' : '') + 'created'; + + return { + [key]: matches[1], + }; + } + + filterKey(): string { + return 'created'; + } + + fromFilter(value: string, negate: boolean): string { + return `${negate ? '-' : ''}created:${value}`; + } +} diff --git a/framework/core/js/src/common/query/discussions/HiddenGambit.ts b/framework/core/js/src/common/query/discussions/HiddenGambit.ts new file mode 100644 index 0000000000..fb0f448df7 --- /dev/null +++ b/framework/core/js/src/common/query/discussions/HiddenGambit.ts @@ -0,0 +1,23 @@ +import IGambit from '../IGambit'; + +export default class HiddenGambit implements IGambit { + public pattern(): string { + return 'is:hidden'; + } + + public toFilter(_matches: string[], negate: boolean): Record { + const key = (negate ? '-' : '') + 'hidden'; + + return { + [key]: true, + }; + } + + filterKey(): string { + return 'hidden'; + } + + fromFilter(value: string, negate: boolean): string { + return `${negate ? '-' : ''}is:hidden`; + } +} diff --git a/framework/core/js/src/common/query/discussions/UnreadGambit.ts b/framework/core/js/src/common/query/discussions/UnreadGambit.ts new file mode 100644 index 0000000000..9466b76be4 --- /dev/null +++ b/framework/core/js/src/common/query/discussions/UnreadGambit.ts @@ -0,0 +1,23 @@ +import IGambit from '../IGambit'; + +export default class UnreadGambit implements IGambit { + pattern(): string { + return 'is:unread'; + } + + toFilter(_matches: string[], negate: boolean): Record { + const key = (negate ? '-' : '') + 'unread'; + + return { + [key]: true, + }; + } + + filterKey(): string { + return 'unread'; + } + + fromFilter(value: string, negate: boolean): string { + return `${negate ? '-' : ''}is:unread`; + } +} diff --git a/framework/core/js/src/common/query/users/EmailGambit.ts b/framework/core/js/src/common/query/users/EmailGambit.ts new file mode 100644 index 0000000000..5a9d432abd --- /dev/null +++ b/framework/core/js/src/common/query/users/EmailGambit.ts @@ -0,0 +1,23 @@ +import IGambit from '../IGambit'; + +export default class EmailGambit implements IGambit { + pattern(): string { + return 'email:(.+)'; + } + + toFilter(matches: string[], negate: boolean): Record { + const key = (negate ? '-' : '') + 'email'; + + return { + [key]: matches[1], + }; + } + + filterKey(): string { + return 'email'; + } + + fromFilter(value: string, negate: boolean): string { + return `${negate ? '-' : ''}email:${value}`; + } +} diff --git a/framework/core/js/src/common/query/users/GroupGambit.ts b/framework/core/js/src/common/query/users/GroupGambit.ts new file mode 100644 index 0000000000..6293367198 --- /dev/null +++ b/framework/core/js/src/common/query/users/GroupGambit.ts @@ -0,0 +1,23 @@ +import IGambit from '../IGambit'; + +export default class GroupGambit implements IGambit { + pattern(): string { + return 'group:(.+)'; + } + + toFilter(matches: string[], negate: boolean): Record { + const key = (negate ? '-' : '') + 'group'; + + return { + [key]: matches[1].split(','), + }; + } + + filterKey(): string { + return 'group'; + } + + fromFilter(value: string, negate: boolean): string { + return `${negate ? '-' : ''}group:${value}`; + } +} diff --git a/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx b/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx index 3a42945c65..84de850396 100644 --- a/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx +++ b/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx @@ -48,10 +48,15 @@ export default class DiscussionsSearchSource implements SearchSource { ); }) as Array; + const filter = app.store.gambits.apply('discussions', { q: query }); + const q = filter.q || null; + + delete filter.q; + return [
  • {app.translator.trans('core.forum.search.discussions_heading')}
  • ,
  • - + {app.translator.trans('core.forum.search.all_discussions_button', { query })}
  • , diff --git a/framework/core/js/src/forum/states/GlobalSearchState.ts b/framework/core/js/src/forum/states/GlobalSearchState.ts index f728dfe22b..b4ca4e5c26 100644 --- a/framework/core/js/src/forum/states/GlobalSearchState.ts +++ b/framework/core/js/src/forum/states/GlobalSearchState.ts @@ -36,7 +36,14 @@ export default class GlobalSearchState extends SearchState { * @inheritdoc */ getInitialSearch(): string { - return this.currPageProvidesSearch() ? this.params().q : ''; + return this.currPageProvidesSearch() ? this.searchToQuery() : ''; + } + + private searchToQuery(): string { + const q = this.params().q || ''; + const filter = this.params().filter || {}; + + return app.store.gambits.from('users', app.store.gambits.from('discussions', q, filter), filter).trim(); } /** @@ -57,7 +64,7 @@ export default class GlobalSearchState extends SearchState { * 'x' is clicked in the search box in the header. */ protected clearInitialSearch() { - const { q, ...params } = this.params(); + const { q, filter, ...params } = this.params(); setRouteWithForcedRefresh(app.route(app.current.get('routeName'), params)); } @@ -71,6 +78,9 @@ export default class GlobalSearchState extends SearchState { return { sort: m.route.param('sort'), q: m.route.param('q'), + // Objects must be copied, otherwise they are passed by reference. + // Which could end up undesirably modifying the mithril route params. + filter: Object.assign({}, m.route.param('filter')), }; } @@ -80,8 +90,6 @@ export default class GlobalSearchState extends SearchState { params(): SearchParams { const params = this.stickyParams(); - params.filter = m.route.param('filter'); - return params; } diff --git a/framework/core/js/tests/unit/common/GambitManager.test.ts b/framework/core/js/tests/unit/common/GambitManager.test.ts new file mode 100644 index 0000000000..d27e1a6146 --- /dev/null +++ b/framework/core/js/tests/unit/common/GambitManager.test.ts @@ -0,0 +1,34 @@ +import GambitManager from '../../../src/common/GambitManager'; + +const gambits = new GambitManager(); + +test('gambits are converted to filters', function () { + expect(gambits.apply('discussions', { q: 'lorem created:2023-07-07 is:hidden author:behz' })).toStrictEqual({ + q: 'lorem', + created: '2023-07-07', + hidden: true, + author: ['behz'], + }); +}); + +test('gambits are negated when prefixed with a dash', function () { + expect(gambits.apply('discussions', { q: 'lorem -created:2023-07-07 -is:hidden -author:behz' })).toStrictEqual({ + q: 'lorem', + '-created': '2023-07-07', + '-hidden': true, + '-author': ['behz'], + }); +}); + +test('gambits are only applied for the correct resource type', function () { + expect(gambits.apply('users', { q: 'lorem created:2023-07-07 is:hidden author:behz email:behz@machine.local' })).toStrictEqual({ + q: 'lorem created:2023-07-07 is:hidden author:behz', + email: 'behz@machine.local', + }); + expect(gambits.apply('discussions', { q: 'lorem created:2023-07-07..2023-10-18 is:hidden -author:behz email:behz@machine.local' })).toStrictEqual({ + q: 'lorem email:behz@machine.local', + created: '2023-07-07..2023-10-18', + hidden: true, + '-author': ['behz'], + }); +}); diff --git a/framework/core/less/admin.less b/framework/core/less/admin.less index b335ab52ab..6101bcfc65 100644 --- a/framework/core/less/admin.less +++ b/framework/core/less/admin.less @@ -5,6 +5,7 @@ @import "admin/CreateUserModal"; @import "admin/DashboardPage"; @import "admin/DebugWarningWidget"; +@import "admin/FormSectionGroup"; @import "admin/BasicsPage"; @import "admin/PermissionsPage"; @import "admin/EditGroupModal"; diff --git a/framework/core/less/admin/FormSectionGroup.less b/framework/core/less/admin/FormSectionGroup.less new file mode 100644 index 0000000000..4db1c54708 --- /dev/null +++ b/framework/core/less/admin/FormSectionGroup.less @@ -0,0 +1,24 @@ +.FormSectionGroup { + display: flex; + column-gap: 30px; + flex-wrap: wrap; +} + +.FormSection { + --gap: 24px; + display: inline-grid; + padding: 10px 20px 20px; + min-height: 20vh; + min-width: 300px; + max-width: 400px; + grid-template-rows: min-content; + border: 1px solid var(--control-bg); + border-radius: var(--border-radius); + flex: 1 1 160px; + gap: var(--gap); +} + +.FormSection > label { + font-weight: bold; + color: var(--muted-color); +} diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index f11703c49a..015f790862 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -7,6 +7,17 @@ core: # Translations in this namespace are used by the admin interface. admin: + # These translations are used in the Advanced page. + advanced: + description: "Configure advanced settings for your forum." + search: + section_label: Search Drivers + driver_heading: "Search Driver: {model}" + driver_text: Select a driver to be used for searching this model. + driver_options: + default: Default database search + title: Advanced + # These translations are used in the Appearance page. appearance: colored_header_label: Colored Header @@ -38,6 +49,8 @@ core: all_discussions_label: => core.ref.all_discussions default_language_heading: Default Language description: "Set your forum title, language, and other basic settings." + display_name_driver_options: + username: Username display_name_heading: User Display Name display_name_text: Select the driver that should be used for users' display names. By default, the username is shown. forum_description_heading: Forum Description @@ -46,6 +59,13 @@ core: home_page_heading: Home Page home_page_text: Choose the page which users will first see when they visit your forum. show_language_selector_label: Show language selector + slug_driver_options: + discussions: + default: ID with slug + utf8: ID with UTF-8 slug + users: + default: Username + id: ID slug_driver_heading: "Slug Driver: {model}" slug_driver_text: Select a driver to be used for slugging this model. title: Basics @@ -78,6 +98,7 @@ core: inactive: Inactive never-run: Never run title: Dashboard + toggle_advanced_page_button: Toggle Advanced Page tools_button: Tools # These translations are used in the debug warning widget. @@ -183,8 +204,16 @@ core: loading: title: Please Wait... + # These translations are used anywhere to localize model names for drivers. + models: + discussions: => core.ref.discussions + posts: => core.ref.posts + users: => core.ref.users + # These translations are used in the navigation bar. nav: + advanced_button: => core.admin.advanced.title + advanced_title: => core.admin.advanced.description appearance_button: => core.admin.appearance.title appearance_title: => core.admin.appearance.description basics_button: => core.admin.basics.title diff --git a/framework/core/src/Admin/Content/AdminPayload.php b/framework/core/src/Admin/Content/AdminPayload.php index d2e8af4681..557115389e 100644 --- a/framework/core/src/Admin/Content/AdminPayload.php +++ b/framework/core/src/Admin/Content/AdminPayload.php @@ -9,11 +9,14 @@ namespace Flarum\Admin\Content; +use Flarum\Database\AbstractModel; use Flarum\Extension\ExtensionManager; use Flarum\Foundation\ApplicationInfoProvider; use Flarum\Foundation\Config; use Flarum\Frontend\Document; use Flarum\Group\Permission; +use Flarum\Search\AbstractDriver; +use Flarum\Search\SearcherInterface; use Flarum\Settings\Event\Deserializing; use Flarum\Settings\SettingsRepositoryInterface; use Flarum\User\User; @@ -52,6 +55,9 @@ public function __invoke(Document $document, Request $request): void $document->payload['slugDrivers'] = array_map(function ($resourceDrivers) { return array_keys($resourceDrivers); }, $this->container->make('flarum.http.slugDrivers')); + $document->payload['searchDrivers'] = $this->getSearchDrivers(); + + $document->payload['advancedPageEmpty'] = $this->checkAdvancedPageEmpty(); $document->payload['phpVersion'] = $this->appInfo->identifyPHPVersion(); $document->payload['mysqlVersion'] = $this->appInfo->identifyDatabaseVersion(); @@ -77,4 +83,24 @@ public function __invoke(Document $document, Request $request): void ] ]; } + + protected function getSearchDrivers(): array + { + $searchDriversPerModel = []; + + foreach ($this->container->make('flarum.search.drivers') as $driverClass => $searcherClasses) { + /** @var array, class-string> $searcherClasses */ + foreach ($searcherClasses as $modelClass => $searcherClass) { + /** @var class-string $driverClass */ + $searchDriversPerModel[$modelClass][] = $driverClass::name(); + } + } + + return $searchDriversPerModel; + } + + protected function checkAdvancedPageEmpty(): bool + { + return count($this->container->make('flarum.search.drivers')) === 1; + } } diff --git a/framework/core/src/Api/Controller/ListAccessTokensController.php b/framework/core/src/Api/Controller/ListAccessTokensController.php index 5b6b425ba0..98a3eeb14b 100644 --- a/framework/core/src/Api/Controller/ListAccessTokensController.php +++ b/framework/core/src/Api/Controller/ListAccessTokensController.php @@ -10,10 +10,11 @@ namespace Flarum\Api\Controller; use Flarum\Api\Serializer\AccessTokenSerializer; -use Flarum\Http\Filter\AccessTokenFilterer; +use Flarum\Http\AccessToken; use Flarum\Http\RequestUtil; use Flarum\Http\UrlGenerator; -use Flarum\Query\QueryCriteria; +use Flarum\Search\SearchCriteria; +use Flarum\Search\SearchManager; use Psr\Http\Message\ServerRequestInterface; use Tobscure\JsonApi\Document; @@ -23,7 +24,7 @@ class ListAccessTokensController extends AbstractListController public function __construct( protected UrlGenerator $url, - protected AccessTokenFilterer $filterer + protected SearchManager $search ) { } @@ -37,7 +38,7 @@ protected function data(ServerRequestInterface $request, Document $document): it $limit = $this->extractLimit($request); $filter = $this->extractFilter($request); - $tokens = $this->filterer->filter(new QueryCriteria($actor, $filter), $limit, $offset); + $tokens = $this->search->query(AccessToken::class, new SearchCriteria($actor, $filter, $limit, $offset)); $document->addPaginationLinks( $this->url->to('api')->route('access-tokens.index'), diff --git a/framework/core/src/Api/Controller/ListDiscussionsController.php b/framework/core/src/Api/Controller/ListDiscussionsController.php index 54f00a1f34..208f656ae9 100644 --- a/framework/core/src/Api/Controller/ListDiscussionsController.php +++ b/framework/core/src/Api/Controller/ListDiscussionsController.php @@ -11,11 +11,10 @@ use Flarum\Api\Serializer\DiscussionSerializer; use Flarum\Discussion\Discussion; -use Flarum\Discussion\Filter\DiscussionFilterer; -use Flarum\Discussion\Search\DiscussionSearcher; use Flarum\Http\RequestUtil; use Flarum\Http\UrlGenerator; -use Flarum\Query\QueryCriteria; +use Flarum\Search\SearchCriteria; +use Flarum\Search\SearchManager; use Psr\Http\Message\ServerRequestInterface; use Tobscure\JsonApi\Document; @@ -40,8 +39,7 @@ class ListDiscussionsController extends AbstractListController public array $sortFields = ['lastPostedAt', 'commentCount', 'createdAt']; public function __construct( - protected DiscussionFilterer $filterer, - protected DiscussionSearcher $searcher, + protected SearchManager $search, protected UrlGenerator $url ) { } @@ -57,12 +55,10 @@ protected function data(ServerRequestInterface $request, Document $document): it $offset = $this->extractOffset($request); $include = array_merge($this->extractInclude($request), ['state']); - $criteria = new QueryCriteria($actor, $filters, $sort, $sortIsDefault); - if (array_key_exists('q', $filters)) { - $results = $this->searcher->search($criteria, $limit, $offset); - } else { - $results = $this->filterer->filter($criteria, $limit, $offset); - } + $results = $this->search->query( + Discussion::class, + new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault) + ); $this->addPaginationData( $document, diff --git a/framework/core/src/Api/Controller/ListGroupsController.php b/framework/core/src/Api/Controller/ListGroupsController.php index df5c904678..935964e3b0 100644 --- a/framework/core/src/Api/Controller/ListGroupsController.php +++ b/framework/core/src/Api/Controller/ListGroupsController.php @@ -10,10 +10,11 @@ namespace Flarum\Api\Controller; use Flarum\Api\Serializer\GroupSerializer; -use Flarum\Group\Filter\GroupFilterer; +use Flarum\Group\Group; use Flarum\Http\RequestUtil; use Flarum\Http\UrlGenerator; -use Flarum\Query\QueryCriteria; +use Flarum\Search\SearchCriteria; +use Flarum\Search\SearchManager; use Psr\Http\Message\ServerRequestInterface; use Tobscure\JsonApi\Document; @@ -26,7 +27,7 @@ class ListGroupsController extends AbstractListController public int $limit = -1; public function __construct( - protected GroupFilterer $filterer, + protected SearchManager $search, protected UrlGenerator $url ) { } @@ -42,9 +43,10 @@ protected function data(ServerRequestInterface $request, Document $document): it $limit = $this->extractLimit($request); $offset = $this->extractOffset($request); - $criteria = new QueryCriteria($actor, $filters, $sort, $sortIsDefault); - - $queryResults = $this->filterer->filter($criteria, $limit, $offset); + $queryResults = $this->search->query( + Group::class, + new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault) + ); $document->addPaginationLinks( $this->url->to('api')->route('groups.index'), diff --git a/framework/core/src/Api/Controller/ListPostsController.php b/framework/core/src/Api/Controller/ListPostsController.php index 5a6a0cf1c6..4c419bb640 100644 --- a/framework/core/src/Api/Controller/ListPostsController.php +++ b/framework/core/src/Api/Controller/ListPostsController.php @@ -12,9 +12,10 @@ use Flarum\Api\Serializer\PostSerializer; use Flarum\Http\RequestUtil; use Flarum\Http\UrlGenerator; -use Flarum\Post\Filter\PostFilterer; +use Flarum\Post\Post; use Flarum\Post\PostRepository; -use Flarum\Query\QueryCriteria; +use Flarum\Search\SearchCriteria; +use Flarum\Search\SearchManager; use Illuminate\Support\Arr; use Psr\Http\Message\ServerRequestInterface; use Tobscure\JsonApi\Document; @@ -35,7 +36,7 @@ class ListPostsController extends AbstractListController public array $sortFields = ['number', 'createdAt']; public function __construct( - protected PostFilterer $filterer, + protected SearchManager $search, protected PostRepository $posts, protected UrlGenerator $url ) { @@ -53,7 +54,10 @@ protected function data(ServerRequestInterface $request, Document $document): it $offset = $this->extractOffset($request); $include = $this->extractInclude($request); - $results = $this->filterer->filter(new QueryCriteria($actor, $filters, $sort, $sortIsDefault), $limit, $offset); + $results = $this->search->query( + Post::class, + new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault) + ); $document->addPaginationLinks( $this->url->to('api')->route('posts.index'), diff --git a/framework/core/src/Api/Controller/ListUsersController.php b/framework/core/src/Api/Controller/ListUsersController.php index 7926353418..0547afc71b 100644 --- a/framework/core/src/Api/Controller/ListUsersController.php +++ b/framework/core/src/Api/Controller/ListUsersController.php @@ -12,9 +12,9 @@ use Flarum\Api\Serializer\UserSerializer; use Flarum\Http\RequestUtil; use Flarum\Http\UrlGenerator; -use Flarum\Query\QueryCriteria; -use Flarum\User\Filter\UserFilterer; -use Flarum\User\Search\UserSearcher; +use Flarum\Search\SearchCriteria; +use Flarum\Search\SearchManager; +use Flarum\User\User; use Psr\Http\Message\ServerRequestInterface; use Tobscure\JsonApi\Document; @@ -33,8 +33,7 @@ class ListUsersController extends AbstractListController ]; public function __construct( - protected UserFilterer $filterer, - protected UserSearcher $searcher, + protected SearchManager $search, protected UrlGenerator $url ) { } @@ -60,12 +59,10 @@ protected function data(ServerRequestInterface $request, Document $document): it $offset = $this->extractOffset($request); $include = $this->extractInclude($request); - $criteria = new QueryCriteria($actor, $filters, $sort, $sortIsDefault); - if (array_key_exists('q', $filters)) { - $results = $this->searcher->search($criteria, $limit, $offset); - } else { - $results = $this->filterer->filter($criteria, $limit, $offset); - } + $results = $this->search->query( + User::class, + new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault) + ); $document->addPaginationLinks( $this->url->to('api')->route('users.index'), diff --git a/framework/core/src/Database/AbstractModel.php b/framework/core/src/Database/AbstractModel.php index 7a4679d752..812952fbc9 100644 --- a/framework/core/src/Database/AbstractModel.php +++ b/framework/core/src/Database/AbstractModel.php @@ -237,4 +237,14 @@ public function newCollection(array $models = []): Collection { return new Collection($models); } + + public function __sleep() + { + // Closures cannot be serialized. + // We should not need them if we are serializing a model. + $this->afterSaveCallbacks = []; + $this->afterDeleteCallbacks = []; + + return parent::__sleep(); + } } diff --git a/framework/core/src/Database/ScopeVisibilityTrait.php b/framework/core/src/Database/ScopeVisibilityTrait.php index a3e3f333bb..d49880de0b 100644 --- a/framework/core/src/Database/ScopeVisibilityTrait.php +++ b/framework/core/src/Database/ScopeVisibilityTrait.php @@ -13,6 +13,9 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Arr; +/** + * @method static Builder whereVisibleTo(User $user) + */ trait ScopeVisibilityTrait { /** diff --git a/framework/core/src/Discussion/Discussion.php b/framework/core/src/Discussion/Discussion.php index 8082d8a3c2..22ba6985d8 100644 --- a/framework/core/src/Discussion/Discussion.php +++ b/framework/core/src/Discussion/Discussion.php @@ -84,6 +84,8 @@ class Discussion extends AbstractModel 'hidden_at' => 'datetime', ]; + protected $observables = ['hidden']; + /** * The user for which the state relationship should be loaded. */ @@ -142,6 +144,12 @@ public function hide(?User $actor = null): static $this->hidden_user_id = $actor?->id; $this->raise(new Hidden($this)); + + $this->saved(function (self $model) { + if ($model === $this) { + $model->fireModelEvent('hidden', false); + } + }); } return $this; @@ -154,6 +162,12 @@ public function restore(): static $this->hidden_user_id = null; $this->raise(new Restored($this)); + + $this->saved(function (self $model) { + if ($model === $this) { + $model->fireModelEvent('restored', false); + } + }); } return $this; diff --git a/framework/core/src/Discussion/Filter/DiscussionFilterer.php b/framework/core/src/Discussion/Filter/DiscussionFilterer.php deleted file mode 100644 index ec4be9aeb8..0000000000 --- a/framework/core/src/Discussion/Filter/DiscussionFilterer.php +++ /dev/null @@ -1,28 +0,0 @@ -discussions->query()->select('discussions.*')->whereVisibleTo($actor); - } -} diff --git a/framework/core/src/Discussion/Query/CreatedFilterGambit.php b/framework/core/src/Discussion/Query/CreatedFilterGambit.php deleted file mode 100644 index 7d5a98a6bc..0000000000 --- a/framework/core/src/Discussion/Query/CreatedFilterGambit.php +++ /dev/null @@ -1,60 +0,0 @@ -constrain($search->getQuery(), Arr::get($matches, 1), Arr::get($matches, 3), $negate); - } - - public function getFilterKey(): string - { - return 'created'; - } - - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void - { - $filterValue = $this->asString($filterValue); - - preg_match('/^'.$this->getGambitPattern().'$/i', 'created:'.$filterValue, $matches); - - $this->constrain($filterState->getQuery(), Arr::get($matches, 1), Arr::get($matches, 3), $negate); - } - - public function constrain(Builder $query, ?string $firstDate, ?string $secondDate, bool $negate): void - { - // If we've just been provided with a single YYYY-MM-DD date, then find - // discussions that were started on that exact date. But if we've been - // provided with a YYYY-MM-DD..YYYY-MM-DD range, then find discussions - // that were started during that period. - if (empty($secondDate)) { - $query->whereDate('created_at', $negate ? '!=' : '=', $firstDate); - } else { - $query->whereBetween('created_at', [$firstDate, $secondDate], 'and', $negate); - } - } -} diff --git a/framework/core/src/Discussion/Search/DiscussionSearcher.php b/framework/core/src/Discussion/Search/DiscussionSearcher.php index e5c953e2b4..a9438c5205 100644 --- a/framework/core/src/Discussion/Search/DiscussionSearcher.php +++ b/framework/core/src/Discussion/Search/DiscussionSearcher.php @@ -9,26 +9,15 @@ namespace Flarum\Discussion\Search; -use Flarum\Discussion\DiscussionRepository; -use Flarum\Search\AbstractSearcher; -use Flarum\Search\GambitManager; +use Flarum\Discussion\Discussion; +use Flarum\Search\Database\AbstractSearcher; use Flarum\User\User; -use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\Eloquent\Builder; class DiscussionSearcher extends AbstractSearcher { - public function __construct( - protected DiscussionRepository $discussions, - protected Dispatcher $events, - GambitManager $gambits, - array $searchMutators - ) { - parent::__construct($gambits, $searchMutators); - } - - protected function getQuery(User $actor): Builder + public function getQuery(User $actor): Builder { - return $this->discussions->query()->select('discussions.*')->whereVisibleTo($actor); + return Discussion::whereVisibleTo($actor)->select('discussions.*'); } } diff --git a/framework/core/src/Discussion/Query/AuthorFilterGambit.php b/framework/core/src/Discussion/Search/Filter/AuthorFilter.php similarity index 53% rename from framework/core/src/Discussion/Query/AuthorFilterGambit.php rename to framework/core/src/Discussion/Search/Filter/AuthorFilter.php index 16919ff2a6..ef5c44dc79 100644 --- a/framework/core/src/Discussion/Query/AuthorFilterGambit.php +++ b/framework/core/src/Discussion/Search/Filter/AuthorFilter.php @@ -7,17 +7,19 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\Discussion\Query; +namespace Flarum\Discussion\Search\Filter; -use Flarum\Filter\FilterInterface; -use Flarum\Filter\FilterState; -use Flarum\Filter\ValidateFilterTrait; -use Flarum\Search\AbstractRegexGambit; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; +use Flarum\Search\ValidateFilterTrait; use Flarum\User\UserRepository; use Illuminate\Database\Query\Builder; -class AuthorFilterGambit extends AbstractRegexGambit implements FilterInterface +/** + * @implements FilterInterface + */ +class AuthorFilter implements FilterInterface { use ValidateFilterTrait; @@ -26,24 +28,14 @@ public function __construct( ) { } - public function getGambitPattern(): string - { - return 'author:(.+)'; - } - - protected function conditions(SearchState $search, array $matches, bool $negate): void - { - $this->constrain($search->getQuery(), $matches[1], $negate); - } - public function getFilterKey(): string { return 'author'; } - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void + public function filter(SearchState $state, string|array $value, bool $negate): void { - $this->constrain($filterState->getQuery(), $filterValue, $negate); + $this->constrain($state->getQuery(), $value, $negate); } protected function constrain(Builder $query, string|array $rawUsernames, bool $negate): void diff --git a/framework/core/src/Discussion/Search/Filter/CreatedFilter.php b/framework/core/src/Discussion/Search/Filter/CreatedFilter.php new file mode 100644 index 0000000000..c66bac114a --- /dev/null +++ b/framework/core/src/Discussion/Search/Filter/CreatedFilter.php @@ -0,0 +1,55 @@ + + */ +class CreatedFilter implements FilterInterface +{ + use ValidateFilterTrait; + + public function getFilterKey(): string + { + return 'created'; + } + + public function filter(SearchState $state, string|array $value, bool $negate): void + { + $value = $this->asString($value); + + preg_match('/^(\d{4}-\d{2}-\d{2})(?:\.\.(\d{4}-\d{2}-\d{2}))?$/', $value, $matches); + + $from = Arr::get($matches, 1); + $to = Arr::get($matches, 2); + + $this->constrain($state->getQuery(), $from, $to, $negate); + } + + public function constrain(Builder $query, ?string $from, ?string $to, bool $negate): void + { + // If we've just been provided with a single YYYY-MM-DD date, then find + // discussions that were started on that exact date. But if we've been + // provided with a YYYY-MM-DD..YYYY-MM-DD range, then find discussions + // that were started during that period. + if (empty($to)) { + $query->whereDate('created_at', $negate ? '!=' : '=', $from); + } else { + $query->whereBetween('created_at', [$from, $to], 'and', $negate); + } + } +} diff --git a/framework/core/src/Discussion/Query/HiddenFilterGambit.php b/framework/core/src/Discussion/Search/Filter/HiddenFilter.php similarity index 53% rename from framework/core/src/Discussion/Query/HiddenFilterGambit.php rename to framework/core/src/Discussion/Search/Filter/HiddenFilter.php index d0bebdf95b..e9e52cae86 100644 --- a/framework/core/src/Discussion/Query/HiddenFilterGambit.php +++ b/framework/core/src/Discussion/Search/Filter/HiddenFilter.php @@ -7,34 +7,26 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\Discussion\Query; +namespace Flarum\Discussion\Search\Filter; -use Flarum\Filter\FilterInterface; -use Flarum\Filter\FilterState; -use Flarum\Search\AbstractRegexGambit; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Illuminate\Database\Query\Builder; -class HiddenFilterGambit extends AbstractRegexGambit implements FilterInterface +/** + * @implements FilterInterface + */ +class HiddenFilter implements FilterInterface { - public function getGambitPattern(): string - { - return 'is:hidden'; - } - - protected function conditions(SearchState $search, array $matches, bool $negate): void - { - $this->constrain($search->getQuery(), $negate); - } - public function getFilterKey(): string { return 'hidden'; } - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void + public function filter(SearchState $state, string|array $value, bool $negate): void { - $this->constrain($filterState->getQuery(), $negate); + $this->constrain($state->getQuery(), $negate); } protected function constrain(Builder $query, bool $negate): void diff --git a/framework/core/src/Discussion/Query/UnreadFilterGambit.php b/framework/core/src/Discussion/Search/Filter/UnreadFilter.php similarity index 51% rename from framework/core/src/Discussion/Query/UnreadFilterGambit.php rename to framework/core/src/Discussion/Search/Filter/UnreadFilter.php index 5709681e3f..6c12ac6359 100644 --- a/framework/core/src/Discussion/Query/UnreadFilterGambit.php +++ b/framework/core/src/Discussion/Search/Filter/UnreadFilter.php @@ -7,39 +7,23 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\Discussion\Query; +namespace Flarum\Discussion\Search\Filter; use Flarum\Discussion\DiscussionRepository; -use Flarum\Filter\FilterInterface; -use Flarum\Filter\FilterState; -use Flarum\Search\AbstractRegexGambit; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\User\User; use Illuminate\Database\Query\Builder; -class UnreadFilterGambit extends AbstractRegexGambit implements FilterInterface +/** + * @implements FilterInterface + */ +class UnreadFilter implements FilterInterface { - /** - * @var \Flarum\Discussion\DiscussionRepository - */ - protected $discussions; - - /** - * @param \Flarum\Discussion\DiscussionRepository $discussions - */ - public function __construct(DiscussionRepository $discussions) - { - $this->discussions = $discussions; - } - - public function getGambitPattern(): string - { - return 'is:unread'; - } - - protected function conditions(SearchState $search, array $matches, bool $negate): void - { - $this->constrain($search->getQuery(), $search->getActor(), $negate); + public function __construct( + protected DiscussionRepository $discussions + ) { } public function getFilterKey(): string @@ -47,9 +31,9 @@ public function getFilterKey(): string return 'unread'; } - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void + public function filter(SearchState $state, string|array $value, bool $negate): void { - $this->constrain($filterState->getQuery(), $filterState->getActor(), $negate); + $this->constrain($state->getQuery(), $state->getActor(), $negate); } protected function constrain(Builder $query, User $actor, bool $negate): void diff --git a/framework/core/src/Discussion/Search/Gambit/FulltextGambit.php b/framework/core/src/Discussion/Search/FulltextFilter.php similarity index 73% rename from framework/core/src/Discussion/Search/Gambit/FulltextGambit.php rename to framework/core/src/Discussion/Search/FulltextFilter.php index 5e72febfe4..da0b62a039 100644 --- a/framework/core/src/Discussion/Search/Gambit/FulltextGambit.php +++ b/framework/core/src/Discussion/Search/FulltextFilter.php @@ -7,41 +7,46 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\Discussion\Search\Gambit; +namespace Flarum\Discussion\Search; use Flarum\Discussion\Discussion; use Flarum\Post\Post; -use Flarum\Search\GambitInterface; +use Flarum\Search\AbstractFulltextFilter; +use Flarum\Search\Database\DatabaseSearchState; use Flarum\Search\SearchState; +use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Expression; -class FulltextGambit implements GambitInterface +/** + * @extends AbstractFulltextFilter + */ +class FulltextFilter extends AbstractFulltextFilter { - public function apply(SearchState $search, string $bit): bool + public function search(SearchState $state, string $value): void { // Replace all non-word characters with spaces. // We do this to prevent MySQL fulltext search boolean mode from taking // effect: https://dev.mysql.com/doc/refman/5.7/en/fulltext-boolean.html - $bit = preg_replace('/[^\p{L}\p{N}\p{M}_]+/u', ' ', $bit); + $value = preg_replace('/[^\p{L}\p{N}\p{M}_]+/u', ' ', $value); - $query = $search->getQuery(); + $query = $state->getQuery(); $grammar = $query->getGrammar(); $discussionSubquery = Discussion::select('id') ->selectRaw('NULL as score') ->selectRaw('first_post_id as most_relevant_post_id') - ->whereRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (? IN BOOLEAN MODE)', [$bit]); + ->whereRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (? IN BOOLEAN MODE)', [$value]); // Construct a subquery to fetch discussions which contain relevant // posts. Retrieve the collective relevance of each discussion's posts, // which we will use later in the order by clause, and also retrieve // the ID of the most relevant post. - $subquery = Post::whereVisibleTo($search->getActor()) + $subquery = Post::whereVisibleTo($state->getActor()) ->select('posts.discussion_id') - ->selectRaw('SUM(MATCH('.$grammar->wrap('posts.content').') AGAINST (?)) as score', [$bit]) - ->selectRaw('SUBSTRING_INDEX(GROUP_CONCAT('.$grammar->wrap('posts.id').' ORDER BY MATCH('.$grammar->wrap('posts.content').') AGAINST (?) DESC, '.$grammar->wrap('posts.number').'), \',\', 1) as most_relevant_post_id', [$bit]) + ->selectRaw('SUM(MATCH('.$grammar->wrap('posts.content').') AGAINST (?)) as score', [$value]) + ->selectRaw('SUBSTRING_INDEX(GROUP_CONCAT('.$grammar->wrap('posts.id').' ORDER BY MATCH('.$grammar->wrap('posts.content').') AGAINST (?) DESC, '.$grammar->wrap('posts.number').'), \',\', 1) as most_relevant_post_id', [$value]) ->where('posts.type', 'comment') - ->whereRaw('MATCH('.$grammar->wrap('posts.content').') AGAINST (? IN BOOLEAN MODE)', [$bit]) + ->whereRaw('MATCH('.$grammar->wrap('posts.content').') AGAINST (? IN BOOLEAN MODE)', [$value]) ->groupBy('posts.discussion_id') ->union($discussionSubquery); @@ -58,11 +63,9 @@ public function apply(SearchState $search, string $bit): bool ->groupBy('discussions.id') ->addBinding($subquery->getBindings(), 'join'); - $search->setDefaultSort(function ($query) use ($grammar, $bit) { - $query->orderByRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (?) desc', [$bit]); + $state->setDefaultSort(function (Builder $query) use ($grammar, $value) { + $query->orderByRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (?) desc', [$value]); $query->orderBy('posts_ft.score', 'desc'); }); - - return true; } } diff --git a/framework/core/src/Extend/Filter.php b/framework/core/src/Extend/Filter.php deleted file mode 100644 index cfc54c4760..0000000000 --- a/framework/core/src/Extend/Filter.php +++ /dev/null @@ -1,82 +0,0 @@ - $filtererClass: The ::class attribute of the filterer to extend. - */ - public function __construct( - private readonly string $filtererClass - ) { - } - - /** - * Add a filter to run when the filtererClass is filtered. - * - * @param string $filterClass: The ::class attribute of the filter you are adding. - * @return self - */ - public function addFilter(string $filterClass): self - { - $this->filters[] = $filterClass; - - return $this; - } - - /** - * Add a callback through which to run all filter queries after filters have been applied. - * - * @param (callable(FilterState $filter, QueryCriteria $criteria): void)|class-string $callback - * - * The callback can be a closure or an invokable class, and should accept: - * - Flarum\Filter\FilterState $filter - * - Flarum\Query\QueryCriteria $criteria - * - * The callable should return void. - * - * @return self - */ - public function addFilterMutator(callable|string $callback): self - { - $this->filterMutators[] = $callback; - - return $this; - } - - public function extend(Container $container, Extension $extension = null): void - { - $container->extend('flarum.filter.filters', function ($originalFilters) { - foreach ($this->filters as $filter) { - $originalFilters[$this->filtererClass][] = $filter; - } - - return $originalFilters; - }); - - $container->extend('flarum.filter.filter_mutators', function ($originalMutators) { - foreach ($this->filterMutators as $mutator) { - $originalMutators[$this->filtererClass][] = $mutator; - } - - return $originalMutators; - }); - } -} diff --git a/framework/core/src/Extend/SearchDriver.php b/framework/core/src/Extend/SearchDriver.php new file mode 100644 index 0000000000..3e4a227652 --- /dev/null +++ b/framework/core/src/Extend/SearchDriver.php @@ -0,0 +1,184 @@ + $driverClass: The driver class you are modifying or adding. + * This driver must extend \Flarum\Search\AbstractDriver. + */ + public function __construct( + private readonly string $driverClass + ) { + } + + /** + * Add a filter to this searcher. Filters are used to filter search queries. + * + * @param class-string $modelClass : The class of the model subject to searching/filtering. + * This model must extend \Flarum\Database\AbstractModel. + * @param class-string $searcherClass : The class of the Searcher for this model + * This searcher must implement \Flarum\Search\SearcherInterface. + * Or extend \Flarum\Search\Database\AbstractSearcher if using the default driver. + * @return self + */ + public function addSearcher(string $modelClass, string $searcherClass): self + { + $this->searchers[$modelClass] = $searcherClass; + + return $this; + } + + /** + * Add a filter to this searcher. Filters are used to filter search queries. + * + * @param class-string $searcherClass : The class of the Searcher for this model + * This searcher must implement \Flarum\Search\SearcherInterface. + * Or extend \Flarum\Search\Database\AbstractSearcher if using the default driver. + * @param class-string $filterClass: The ::class attribute of the filter you are adding. + * This filter must implement \Flarum\Search\FilterInterface + * @return self + */ + public function addFilter(string $searcherClass, string $filterClass): self + { + $this->filters[$searcherClass][] = compact('filterClass'); + + return $this; + } + + /** + * Replace a filter from this searcher. Filters are used to filter search queries. + * + * @param class-string $searcherClass : The class of the Searcher for this model + * This searcher must implement \Flarum\Search\SearcherInterface. + * Or extend \Flarum\Search\Database\AbstractSearcher if using the default driver. + * @param class-string $replaceFilterClass : The ::class attribute of the filter you are replacing. + * @param class-string $filterClass : The ::class attribute of the filter you are adding. + * This filter must implement \Flarum\Search\FilterInterface + * @return self + */ + public function replaceFilter(string $searcherClass, string $replaceFilterClass, string $filterClass): self + { + $this->filters[$searcherClass][] = [ + 'replace' => $replaceFilterClass, + 'filterClass' => $filterClass, + ]; + + return $this; + } + + /** + * Set the full text filter for this searcher. The full text filter actually executes the search. + * + * @param class-string $searcherClass : The class of the Searcher for this model + * This searcher must implement \Flarum\Search\SearcherInterface. + * Or extend \Flarum\Search\Database\AbstractSearcher if using the default driver. + * @param class-string $fulltextClass: The ::class attribute of the full test filter you are adding. + * This filter must implement \Flarum\Search\FilterInterface + * @return self + */ + public function setFulltext(string $searcherClass, string $fulltextClass): self + { + $this->fulltext[$searcherClass] = $fulltextClass; + + return $this; + } + + /** + * Add a callback through which to run all search queries after filters have been applied. + * + * @param class-string $searcherClass : The class of the Searcher for this model + * This searcher must implement \Flarum\Search\SearcherInterface. + * Or extend \Flarum\Search\Database\AbstractSearcher if using the default driver. + * @param (callable(SearchState $search, SearchCriteria $criteria): void)|class-string $callback + * + * The callback can be a closure or an invokable class, and should accept: + * - \Flarum\Search\SearchState $search + * - \Flarum\Query\QueryCriteria $criteria + * + * The callback should return void. + * + * @return self + */ + public function addMutator(string $searcherClass, callable|string $callback): self + { + $this->mutators[$searcherClass][] = $callback; + + return $this; + } + + public function extend(Container $container, Extension $extension = null): void + { + $container->extend('flarum.search.drivers', function (array $oldDrivers) { + $oldDrivers[$this->driverClass] = array_merge( + $oldDrivers[$this->driverClass] ?? [], + $this->searchers + ); + + return $oldDrivers; + }); + + $container->extend('flarum.search.fulltext', function (array $oldFulltextFilters) { + foreach ($this->fulltext as $searcherClass => $fulltextClass) { + $oldFulltextFilters[$searcherClass] = $fulltextClass; + } + + return $oldFulltextFilters; + }); + + $container->extend('flarum.search.filters', function (array $oldFilters) { + foreach ($this->filters as $searcherClass => $filters) { + // Start by removing any filters that will be replaced. + $oldFilters[$searcherClass] = array_filter( + $oldFilters[$searcherClass] ?? [], + function ($filter) use ($filters) { + return ! in_array($filter, Arr::pluck($filters, 'replace')); + } + ); + + // Add the new filters. + $oldFilters[$searcherClass] = array_merge( + $oldFilters[$searcherClass], + Arr::pluck($filters, 'filterClass') + ); + } + + return $oldFilters; + }); + + $container->extend('flarum.search.mutators', function (array $oldMutators) { + foreach ($this->mutators as $searcherClass => $mutators) { + $oldMutators[$searcherClass] = array_merge( + $oldMutators[$searcherClass] ?? [], + $mutators + ); + } + + return $oldMutators; + }); + } +} diff --git a/framework/core/src/Extend/SearchIndex.php b/framework/core/src/Extend/SearchIndex.php new file mode 100644 index 0000000000..277ad55f8b --- /dev/null +++ b/framework/core/src/Extend/SearchIndex.php @@ -0,0 +1,50 @@ +indexers[$resourceClass][] = $indexerClass; + + return $this; + } + + public function extend(Container $container, Extension $extension = null): void + { + if (empty($this->indexers)) { + return; + } + + $container->extend('flarum.search.indexers', function (array $indexers) { + foreach ($this->indexers as $resourceClass => $indexerClasses) { + $indexers[$resourceClass] = array_merge( + $indexers[$resourceClass] ?? [], + $indexerClasses + ); + } + + return $indexers; + }); + } +} diff --git a/framework/core/src/Extend/SimpleFlarumSearch.php b/framework/core/src/Extend/SimpleFlarumSearch.php deleted file mode 100644 index 0e1924c37f..0000000000 --- a/framework/core/src/Extend/SimpleFlarumSearch.php +++ /dev/null @@ -1,109 +0,0 @@ - $searcher: The ::class attribute of the Searcher you are modifying. - * This searcher must extend \Flarum\Search\AbstractSearcher. - */ - public function __construct( - private readonly string $searcher - ) { - } - - /** - * Add a gambit to this searcher. Gambits are used to filter search queries. - * - * @param class-string $gambitClass: The ::class attribute of the gambit you are adding. - * This gambit must extend \Flarum\Search\AbstractRegexGambit - * @return self - */ - public function addGambit(string $gambitClass): self - { - $this->gambits[] = $gambitClass; - - return $this; - } - - /** - * Set the full text gambit for this searcher. The full text gambit actually executes the search. - * - * @param class-string $gambitClass: The ::class attribute of the full test gambit you are adding. - * This gambit must implement \Flarum\Search\GambitInterface - * @return self - */ - public function setFullTextGambit(string $gambitClass): self - { - $this->fullTextGambit = $gambitClass; - - return $this; - } - - /** - * Add a callback through which to run all search queries after gambits have been applied. - * - * @param (callable(SearchState $search, QueryCriteria $criteria): void)|class-string $callback - * - * The callback can be a closure or an invokable class, and should accept: - * - \Flarum\Search\SearchState $search - * - \Flarum\Query\QueryCriteria $criteria - * - * The callback should return void. - * - * @return self - */ - public function addSearchMutator(callable|string $callback): self - { - $this->searchMutators[] = $callback; - - return $this; - } - - public function extend(Container $container, Extension $extension = null): void - { - if (! is_null($this->fullTextGambit)) { - $container->extend('flarum.simple_search.fulltext_gambits', function ($oldFulltextGambits) { - $oldFulltextGambits[$this->searcher] = $this->fullTextGambit; - - return $oldFulltextGambits; - }); - } - - $container->extend('flarum.simple_search.gambits', function ($oldGambits) { - foreach ($this->gambits as $gambit) { - $oldGambits[$this->searcher][] = $gambit; - } - - return $oldGambits; - }); - - $container->extend('flarum.simple_search.search_mutators', function ($oldMutators) { - foreach ($this->searchMutators as $mutator) { - $oldMutators[$this->searcher][] = $mutator; - } - - return $oldMutators; - }); - } -} diff --git a/framework/core/src/Filter/AbstractFilterer.php b/framework/core/src/Filter/AbstractFilterer.php deleted file mode 100644 index 7db2e197c2..0000000000 --- a/framework/core/src/Filter/AbstractFilterer.php +++ /dev/null @@ -1,76 +0,0 @@ - */ - protected array $filters, - protected array $filterMutators - ) { - } - - abstract protected function getQuery(User $actor): Builder; - - /** - * @throws InvalidArgumentException - */ - public function filter(QueryCriteria $criteria, int $limit = null, int $offset = 0): QueryResults - { - $actor = $criteria->actor; - - $query = $this->getQuery($actor); - - $filterState = new FilterState($query->getQuery(), $actor); - - foreach ($criteria->query as $filterKey => $filterValue) { - $negate = false; - - if (str_starts_with($filterKey, '-')) { - $negate = true; - $filterKey = substr($filterKey, 1); - } - - foreach (($this->filters[$filterKey] ?? []) as $filter) { - $filterState->addActiveFilter($filter); - $filter->filter($filterState, $filterValue, $negate); - } - } - - $this->applySort($filterState, $criteria->sort, $criteria->sortIsDefault); - $this->applyOffset($filterState, $offset); - $this->applyLimit($filterState, $limit + 1); - - foreach ($this->filterMutators as $mutator) { - $mutator($filterState, $criteria); - } - - // Execute the filter query and retrieve the results. We get one more - // results than the user asked for, so that we can say if there are more - // results. If there are, we will get rid of that extra result. - $results = $query->get(); - - if ($areMoreResults = $limit > 0 && $results->count() > $limit) { - $results->pop(); - } - - return new QueryResults($results, $areMoreResults); - } -} diff --git a/framework/core/src/Filter/FilterServiceProvider.php b/framework/core/src/Filter/FilterServiceProvider.php deleted file mode 100644 index 0698d5ad76..0000000000 --- a/framework/core/src/Filter/FilterServiceProvider.php +++ /dev/null @@ -1,96 +0,0 @@ -container->singleton('flarum.filter.filters', function () { - return [ - AccessTokenFilterer::class => [ - HttpFilter\UserFilter::class, - ], - DiscussionFilterer::class => [ - DiscussionQuery\AuthorFilterGambit::class, - DiscussionQuery\CreatedFilterGambit::class, - DiscussionQuery\HiddenFilterGambit::class, - DiscussionQuery\UnreadFilterGambit::class, - ], - UserFilterer::class => [ - UserQuery\EmailFilterGambit::class, - UserQuery\GroupFilterGambit::class, - ], - GroupFilterer::class => [ - GroupFilter\HiddenFilter::class, - ], - PostFilterer::class => [ - PostFilter\AuthorFilter::class, - PostFilter\DiscussionFilter::class, - PostFilter\IdFilter::class, - PostFilter\NumberFilter::class, - PostFilter\TypeFilter::class - ], - ]; - }); - - $this->container->singleton('flarum.filter.filter_mutators', function () { - return []; - }); - } - - public function boot(Container $container): void - { - // We can resolve the filter mutators in the when->needs->give callback, - // but we need to resolve at least one regardless so we know which - // filterers we need to register filters for. - $filters = $this->container->make('flarum.filter.filters'); - - foreach ($filters as $filterer => $filterClasses) { - $container - ->when($filterer) - ->needs('$filters') - ->give(function () use ($filterClasses) { - $compiled = []; - - foreach ($filterClasses as $filterClass) { - $filter = $this->container->make($filterClass); - $compiled[$filter->getFilterKey()][] = $filter; - } - - return $compiled; - }); - - $container - ->when($filterer) - ->needs('$filterMutators') - ->give(function () use ($container, $filterer) { - return array_map(function ($filterMutatorClass) { - return ContainerUtil::wrapCallback($filterMutatorClass, $this->container); - }, Arr::get($container->make('flarum.filter.filter_mutators'), $filterer, [])); - }); - } - } -} diff --git a/framework/core/src/Filter/FilterState.php b/framework/core/src/Filter/FilterState.php deleted file mode 100644 index 9b8b19ed91..0000000000 --- a/framework/core/src/Filter/FilterState.php +++ /dev/null @@ -1,41 +0,0 @@ -activeFilters; - } - - /** - * Add a filter as being active. - * - * @param FilterInterface $filter - * @return void - */ - public function addActiveFilter(FilterInterface $filter): void - { - $this->activeFilters[] = $filter; - } -} diff --git a/framework/core/src/Foundation/InstalledSite.php b/framework/core/src/Foundation/InstalledSite.php index 2d52395f73..b4b41e823b 100644 --- a/framework/core/src/Foundation/InstalledSite.php +++ b/framework/core/src/Foundation/InstalledSite.php @@ -18,7 +18,6 @@ use Flarum\Extend\ExtenderInterface; use Flarum\Extension\ExtensionServiceProvider; use Flarum\Filesystem\FilesystemServiceProvider; -use Flarum\Filter\FilterServiceProvider; use Flarum\Formatter\FormatterServiceProvider; use Flarum\Forum\ForumServiceProvider; use Flarum\Frontend\FrontendServiceProvider; @@ -110,7 +109,6 @@ protected function bootLaravel(): Container $app->register(ExtensionServiceProvider::class); $app->register(ErrorServiceProvider::class); $app->register(FilesystemServiceProvider::class); - $app->register(FilterServiceProvider::class); $app->register(FormatterServiceProvider::class); $app->register(ForumServiceProvider::class); $app->register(FrontendServiceProvider::class); diff --git a/framework/core/src/Group/Filter/GroupFilterer.php b/framework/core/src/Group/Filter/GroupFilterer.php deleted file mode 100644 index 8233b4780f..0000000000 --- a/framework/core/src/Group/Filter/GroupFilterer.php +++ /dev/null @@ -1,31 +0,0 @@ -groups->query()->whereVisibleTo($actor); - } -} diff --git a/framework/core/src/Group/Filter/GroupSearcher.php b/framework/core/src/Group/Filter/GroupSearcher.php new file mode 100644 index 0000000000..45569dccea --- /dev/null +++ b/framework/core/src/Group/Filter/GroupSearcher.php @@ -0,0 +1,23 @@ +select('groups.*'); + } +} diff --git a/framework/core/src/Group/Filter/HiddenFilter.php b/framework/core/src/Group/Filter/HiddenFilter.php index 38d7c65673..9a4060358b 100644 --- a/framework/core/src/Group/Filter/HiddenFilter.php +++ b/framework/core/src/Group/Filter/HiddenFilter.php @@ -9,10 +9,14 @@ namespace Flarum\Group\Filter; -use Flarum\Filter\FilterInterface; -use Flarum\Filter\FilterState; -use Flarum\Filter\ValidateFilterTrait; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; +use Flarum\Search\SearchState; +use Flarum\Search\ValidateFilterTrait; +/** + * @implements FilterInterface + */ class HiddenFilter implements FilterInterface { use ValidateFilterTrait; @@ -22,10 +26,10 @@ public function getFilterKey(): string return 'hidden'; } - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void + public function filter(SearchState $state, string|array $value, bool $negate): void { - $hidden = $this->asBool($filterValue); + $hidden = $this->asBool($value); - $filterState->getQuery()->where('is_hidden', $negate ? '!=' : '=', $hidden); + $state->getQuery()->where('is_hidden', $negate ? '!=' : '=', $hidden); } } diff --git a/framework/core/src/Http/Filter/AccessTokenFilterer.php b/framework/core/src/Http/Filter/AccessTokenSearcher.php similarity index 72% rename from framework/core/src/Http/Filter/AccessTokenFilterer.php rename to framework/core/src/Http/Filter/AccessTokenSearcher.php index c55df90d8e..8fd3d9d9a9 100644 --- a/framework/core/src/Http/Filter/AccessTokenFilterer.php +++ b/framework/core/src/Http/Filter/AccessTokenSearcher.php @@ -9,14 +9,14 @@ namespace Flarum\Http\Filter; -use Flarum\Filter\AbstractFilterer; use Flarum\Http\AccessToken; +use Flarum\Search\Database\AbstractSearcher; use Flarum\User\User; use Illuminate\Database\Eloquent\Builder; -class AccessTokenFilterer extends AbstractFilterer +class AccessTokenSearcher extends AbstractSearcher { - protected function getQuery(User $actor): Builder + public function getQuery(User $actor): Builder { return AccessToken::query()->whereVisibleTo($actor); } diff --git a/framework/core/src/Http/Filter/UserFilter.php b/framework/core/src/Http/Filter/UserFilter.php index 5a8e4f68b0..216151abff 100644 --- a/framework/core/src/Http/Filter/UserFilter.php +++ b/framework/core/src/Http/Filter/UserFilter.php @@ -10,14 +10,16 @@ namespace Flarum\Http\Filter; use Flarum\Api\Controller\ListAccessTokensController; -use Flarum\Filter\FilterInterface; -use Flarum\Filter\FilterState; -use Flarum\Filter\ValidateFilterTrait; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; +use Flarum\Search\SearchState; +use Flarum\Search\ValidateFilterTrait; /** * Filters an access tokens request by the related user. * * @see ListAccessTokensController + * @implements FilterInterface */ class UserFilter implements FilterInterface { @@ -28,10 +30,10 @@ public function getFilterKey(): string return 'user'; } - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void + public function filter(SearchState $state, string|array $value, bool $negate): void { - $filterValue = $this->asInt($filterValue); + $value = $this->asInt($value); - $filterState->getQuery()->where('user_id', $negate ? '!=' : '=', $filterValue); + $state->getQuery()->where('user_id', $negate ? '!=' : '=', $value); } } diff --git a/framework/core/src/Post/CommentPost.php b/framework/core/src/Post/CommentPost.php index c1b150ddb3..c93216f65a 100644 --- a/framework/core/src/Post/CommentPost.php +++ b/framework/core/src/Post/CommentPost.php @@ -28,6 +28,8 @@ class CommentPost extends Post public static string $type = 'comment'; protected static Formatter $formatter; + protected $observables = ['hidden']; + public static function reply(int $discussionId, string $content, int $userId, ?string $ipAddress, ?User $actor = null): static { $post = new static; @@ -69,6 +71,12 @@ public function hide(?User $actor = null): static $this->hidden_user_id = $actor?->id; $this->raise(new Hidden($this)); + + $this->saved(function (self $model) { + if ($model === $this) { + $model->fireModelEvent('hidden', false); + } + }); } return $this; @@ -81,6 +89,12 @@ public function restore(): static $this->hidden_user_id = null; $this->raise(new Restored($this)); + + $this->saved(function (self $model) { + if ($model === $this) { + $model->fireModelEvent('restored', false); + } + }); } return $this; diff --git a/framework/core/src/Post/Filter/AuthorFilter.php b/framework/core/src/Post/Filter/AuthorFilter.php index b9f337db6c..f4bebf67c7 100644 --- a/framework/core/src/Post/Filter/AuthorFilter.php +++ b/framework/core/src/Post/Filter/AuthorFilter.php @@ -9,11 +9,15 @@ namespace Flarum\Post\Filter; -use Flarum\Filter\FilterInterface; -use Flarum\Filter\FilterState; -use Flarum\Filter\ValidateFilterTrait; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; +use Flarum\Search\SearchState; +use Flarum\Search\ValidateFilterTrait; use Flarum\User\UserRepository; +/** + * @implements FilterInterface + */ class AuthorFilter implements FilterInterface { use ValidateFilterTrait; @@ -28,12 +32,12 @@ public function getFilterKey(): string return 'author'; } - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void + public function filter(SearchState $state, string|array $value, bool $negate): void { - $usernames = $this->asStringArray($filterValue); + $usernames = $this->asStringArray($value); $ids = $this->users->query()->whereIn('username', $usernames)->pluck('id'); - $filterState->getQuery()->whereIn('posts.user_id', $ids, 'and', $negate); + $state->getQuery()->whereIn('posts.user_id', $ids, 'and', $negate); } } diff --git a/framework/core/src/Post/Filter/DiscussionFilter.php b/framework/core/src/Post/Filter/DiscussionFilter.php index 1c7fa566b7..dac9523886 100644 --- a/framework/core/src/Post/Filter/DiscussionFilter.php +++ b/framework/core/src/Post/Filter/DiscussionFilter.php @@ -9,10 +9,14 @@ namespace Flarum\Post\Filter; -use Flarum\Filter\FilterInterface; -use Flarum\Filter\FilterState; -use Flarum\Filter\ValidateFilterTrait; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; +use Flarum\Search\SearchState; +use Flarum\Search\ValidateFilterTrait; +/** + * @implements FilterInterface + */ class DiscussionFilter implements FilterInterface { use ValidateFilterTrait; @@ -22,10 +26,10 @@ public function getFilterKey(): string return 'discussion'; } - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void + public function filter(SearchState $state, string|array $value, bool $negate): void { - $discussionId = $this->asInt($filterValue); + $discussionId = $this->asInt($value); - $filterState->getQuery()->where('posts.discussion_id', $negate ? '!=' : '=', $discussionId); + $state->getQuery()->where('posts.discussion_id', $negate ? '!=' : '=', $discussionId); } } diff --git a/framework/core/src/Post/Filter/IdFilter.php b/framework/core/src/Post/Filter/IdFilter.php index fe908920ba..e6b1befcfa 100644 --- a/framework/core/src/Post/Filter/IdFilter.php +++ b/framework/core/src/Post/Filter/IdFilter.php @@ -9,10 +9,14 @@ namespace Flarum\Post\Filter; -use Flarum\Filter\FilterInterface; -use Flarum\Filter\FilterState; -use Flarum\Filter\ValidateFilterTrait; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; +use Flarum\Search\SearchState; +use Flarum\Search\ValidateFilterTrait; +/** + * @implements FilterInterface + */ class IdFilter implements FilterInterface { use ValidateFilterTrait; @@ -22,10 +26,10 @@ public function getFilterKey(): string return 'id'; } - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void + public function filter(SearchState $state, string|array $value, bool $negate): void { - $ids = $this->asIntArray($filterValue); + $ids = $this->asIntArray($value); - $filterState->getQuery()->whereIn('posts.id', $ids, 'and', $negate); + $state->getQuery()->whereIn('posts.id', $ids, 'and', $negate); } } diff --git a/framework/core/src/Post/Filter/NumberFilter.php b/framework/core/src/Post/Filter/NumberFilter.php index 182ed8d32c..cab7f37d78 100644 --- a/framework/core/src/Post/Filter/NumberFilter.php +++ b/framework/core/src/Post/Filter/NumberFilter.php @@ -9,10 +9,14 @@ namespace Flarum\Post\Filter; -use Flarum\Filter\FilterInterface; -use Flarum\Filter\FilterState; -use Flarum\Filter\ValidateFilterTrait; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; +use Flarum\Search\SearchState; +use Flarum\Search\ValidateFilterTrait; +/** + * @implements FilterInterface + */ class NumberFilter implements FilterInterface { use ValidateFilterTrait; @@ -22,10 +26,10 @@ public function getFilterKey(): string return 'number'; } - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void + public function filter(SearchState $state, string|array $value, bool $negate): void { - $number = $this->asInt($filterValue); + $number = $this->asInt($value); - $filterState->getQuery()->where('posts.number', $negate ? '!=' : '=', $number); + $state->getQuery()->where('posts.number', $negate ? '!=' : '=', $number); } } diff --git a/framework/core/src/Post/Filter/PostFilterer.php b/framework/core/src/Post/Filter/PostFilterer.php deleted file mode 100644 index 1cc559f3d0..0000000000 --- a/framework/core/src/Post/Filter/PostFilterer.php +++ /dev/null @@ -1,31 +0,0 @@ -posts->query()->select('posts.*')->whereVisibleTo($actor); - } -} diff --git a/framework/core/src/Post/Filter/PostSearcher.php b/framework/core/src/Post/Filter/PostSearcher.php new file mode 100644 index 0000000000..46e510dc5c --- /dev/null +++ b/framework/core/src/Post/Filter/PostSearcher.php @@ -0,0 +1,23 @@ +select('posts.*'); + } +} diff --git a/framework/core/src/Post/Filter/TypeFilter.php b/framework/core/src/Post/Filter/TypeFilter.php index e8fa0debf1..5c9010f64e 100644 --- a/framework/core/src/Post/Filter/TypeFilter.php +++ b/framework/core/src/Post/Filter/TypeFilter.php @@ -9,10 +9,14 @@ namespace Flarum\Post\Filter; -use Flarum\Filter\FilterInterface; -use Flarum\Filter\FilterState; -use Flarum\Filter\ValidateFilterTrait; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; +use Flarum\Search\SearchState; +use Flarum\Search\ValidateFilterTrait; +/** + * @implements FilterInterface + */ class TypeFilter implements FilterInterface { use ValidateFilterTrait; @@ -22,10 +26,10 @@ public function getFilterKey(): string return 'type'; } - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void + public function filter(SearchState $state, string|array $value, bool $negate): void { - $type = $this->asString($filterValue); + $type = $this->asString($value); - $filterState->getQuery()->where('posts.type', $negate ? '!=' : '=', $type); + $state->getQuery()->where('posts.type', $negate ? '!=' : '=', $type); } } diff --git a/framework/core/src/Query/AbstractQueryState.php b/framework/core/src/Query/AbstractQueryState.php deleted file mode 100644 index 0c9400f0f4..0000000000 --- a/framework/core/src/Query/AbstractQueryState.php +++ /dev/null @@ -1,59 +0,0 @@ -|Closure $defaultSort - */ - protected array|Closure $defaultSort = [] - ) { - } - - /** - * Get the query builder for the search results query. - */ - public function getQuery(): Builder - { - return $this->query; - } - - public function getActor(): User - { - return $this->actor; - } - - public function getDefaultSort(): array|Closure - { - return $this->defaultSort; - } - - /** - * Set the default sort order for the search. This will only be applied if - * a sort order has not been specified in the search criteria. - */ - public function setDefaultSort(array|Closure $defaultSort): void - { - $this->defaultSort = $defaultSort; - } -} diff --git a/framework/core/src/Query/ApplyQueryParametersTrait.php b/framework/core/src/Query/ApplyQueryParametersTrait.php deleted file mode 100644 index a6e1f7b884..0000000000 --- a/framework/core/src/Query/ApplyQueryParametersTrait.php +++ /dev/null @@ -1,56 +0,0 @@ -getDefaultSort())) { - $sort = $query->getDefaultSort(); - } - - if (is_callable($sort)) { - $sort($query->getQuery()); - } else { - foreach ((array) $sort as $field => $order) { - if (is_array($order)) { - foreach ($order as $value) { - $query->getQuery()->orderByRaw(Str::snake($field).' != ?', [$value]); - } - } else { - $query->getQuery()->orderBy(Str::snake($field), $order); - } - } - } - } - - protected function applyOffset(AbstractQueryState $query, int $offset): void - { - if ($offset > 0) { - $query->getQuery()->skip($offset); - } - } - - protected function applyLimit(AbstractQueryState $query, ?int $limit): void - { - if ($limit > 0) { - $query->getQuery()->take($limit); - } - } -} diff --git a/framework/core/src/Search/AbstractDriver.php b/framework/core/src/Search/AbstractDriver.php new file mode 100644 index 0000000000..19dc23250a --- /dev/null +++ b/framework/core/src/Search/AbstractDriver.php @@ -0,0 +1,42 @@ +, class-string> + */ + protected array $searchers, + protected Container $container + ) { + } + + abstract public static function name(): string; + + public function getSearchers(): array + { + return $this->searchers; + } + + public function supports(string $modelClass): bool + { + return isset($this->searchers[$modelClass]); + } + + public function searcher(string $resourceClass): SearcherInterface + { + return $this->container->make($this->searchers[$resourceClass]); + } +} diff --git a/framework/core/src/Search/AbstractFulltextFilter.php b/framework/core/src/Search/AbstractFulltextFilter.php new file mode 100644 index 0000000000..c9c6f5b830 --- /dev/null +++ b/framework/core/src/Search/AbstractFulltextFilter.php @@ -0,0 +1,34 @@ + + */ +abstract class AbstractFulltextFilter implements FilterInterface +{ + public function getFilterKey(): string + { + return 'q'; + } + + public function filter(SearchState $state, array|string $value, bool $negate): void + { + $this->search($state, $value); + } + + /** + * @param TState $state + */ + abstract public function search(SearchState $state, string $value): void; +} diff --git a/framework/core/src/Search/AbstractRegexGambit.php b/framework/core/src/Search/AbstractRegexGambit.php deleted file mode 100644 index 9372842178..0000000000 --- a/framework/core/src/Search/AbstractRegexGambit.php +++ /dev/null @@ -1,49 +0,0 @@ -match($bit)) { - list($negate) = array_splice($matches, 1, 1); - - $this->conditions($search, $matches, (bool) $negate); - } - - return (bool) $matches; - } - - /** - * Match the bit against this gambit. - */ - protected function match(string $bit): ?array - { - if (! empty($bit) && preg_match('/^(-?)'.$this->getGambitPattern().'$/i', $bit, $matches)) { - return $matches; - } - - return null; - } - - /** - * Apply conditions to the search, given that the gambit was matched. - * - * @param array $matches An array of matches from the search bit. - * @param bool $negate Whether the bit was negated, and thus whether the conditions should be negated. - */ - abstract protected function conditions(SearchState $search, array $matches, bool $negate): void; -} diff --git a/framework/core/src/Search/AbstractSearcher.php b/framework/core/src/Search/AbstractSearcher.php deleted file mode 100644 index a59408a91a..0000000000 --- a/framework/core/src/Search/AbstractSearcher.php +++ /dev/null @@ -1,58 +0,0 @@ -actor; - - $query = $this->getQuery($actor); - - $search = new SearchState($query->getQuery(), $actor); - - $this->gambits->apply($search, $criteria->query['q']); - $this->applySort($search, $criteria->sort, $criteria->sortIsDefault); - $this->applyOffset($search, $offset); - $this->applyLimit($search, $limit + 1); - - foreach ($this->searchMutators as $mutator) { - $mutator($search, $criteria); - } - - // Execute the search query and retrieve the results. We get one more - // results than the user asked for, so that we can say if there are more - // results. If there are, we will get rid of that extra result. - $results = $query->get(); - - if ($areMoreResults = $limit > 0 && $results->count() > $limit) { - $results->pop(); - } - - return new QueryResults($results, $areMoreResults); - } -} diff --git a/framework/core/src/Search/Database/AbstractSearcher.php b/framework/core/src/Search/Database/AbstractSearcher.php new file mode 100644 index 0000000000..86f026f0ea --- /dev/null +++ b/framework/core/src/Search/Database/AbstractSearcher.php @@ -0,0 +1,118 @@ + */ + protected array $mutators + ) { + } + + public function search(SearchCriteria $criteria): SearchResults + { + $actor = $criteria->actor; + + $query = $this->getQuery($actor); + + $search = new DatabaseSearchState($actor, $criteria->isFulltext()); + $search->setQuery($query->getQuery()); + + $this->filters->apply($search, $criteria->filters); + + $this->applySort($search, $criteria->sort, $criteria->sortIsDefault); + $this->applyOffset($search, $criteria->offset); + $this->applyLimit($search, $criteria->limit + 1); + + foreach ($this->mutators as $mutator) { + $mutator($search, $criteria); + } + + // Execute the search query and retrieve the results. We get one more + // results than the user asked for, so that we can say if there are more + // results. If there are, we will get rid of that extra result. + $results = $query->get(); + + if ($areMoreResults = $criteria->limit > 0 && $results->count() > $criteria->limit) { + $results->pop(); + } + + return new SearchResults($results, $areMoreResults, $this->getTotalResults($query)); + } + + protected function getTotalResults(Builder $query): Closure + { + return function () use ($query) { + $query = $query->toBase(); + + if ($query->unions) { + $query->unions = null; // @phpstan-ignore-line + $query->unionLimit = null; // @phpstan-ignore-line + $query->unionOffset = null; // @phpstan-ignore-line + $query->unionOrders = null; // @phpstan-ignore-line + $query->setBindings([], 'union'); + } + + $query->offset = null; // @phpstan-ignore-line + $query->limit = null; // @phpstan-ignore-line + $query->orders = null; // @phpstan-ignore-line + $query->setBindings([], 'order'); + + return $query->getConnection() + ->table($query, 'results') + ->count(); + }; + } + + protected function applySort(DatabaseSearchState $state, ?array $sort = null, bool $sortIsDefault = false): void + { + if ($sortIsDefault && ! empty($state->getDefaultSort())) { + $sort = $state->getDefaultSort(); + } + + if (is_callable($sort)) { + $sort($state->getQuery()); + } else { + foreach ((array) $sort as $field => $order) { + if (is_array($order)) { + foreach ($order as $value) { + $state->getQuery()->orderByRaw(Str::snake($field).' != ?', [$value]); + } + } else { + $state->getQuery()->orderBy(Str::snake($field), $order); + } + } + } + } + + protected function applyOffset(DatabaseSearchState $state, int $offset): void + { + if ($offset > 0) { + $state->getQuery()->skip($offset); + } + } + + protected function applyLimit(DatabaseSearchState $state, ?int $limit): void + { + if ($limit > 0) { + $state->getQuery()->take($limit); + } + } +} diff --git a/framework/core/src/Search/Database/DatabaseSearchDriver.php b/framework/core/src/Search/Database/DatabaseSearchDriver.php new file mode 100644 index 0000000000..c3c27f5a29 --- /dev/null +++ b/framework/core/src/Search/Database/DatabaseSearchDriver.php @@ -0,0 +1,20 @@ +query = $query; + } + + public function getQuery(): Builder + { + return $this->query; + } +} diff --git a/framework/core/src/Filter/FilterInterface.php b/framework/core/src/Search/Filter/FilterInterface.php similarity index 63% rename from framework/core/src/Filter/FilterInterface.php rename to framework/core/src/Search/Filter/FilterInterface.php index 481978a1e8..47592b3f70 100644 --- a/framework/core/src/Filter/FilterInterface.php +++ b/framework/core/src/Search/Filter/FilterInterface.php @@ -7,8 +7,13 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\Filter; +namespace Flarum\Search\Filter; +use Flarum\Search\SearchState; + +/** + * @template TState of SearchState + */ interface FilterInterface { /** @@ -18,6 +23,8 @@ public function getFilterKey(): string; /** * Filters a query. + * + * @param TState $state */ - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void; + public function filter(SearchState $state, string|array $value, bool $negate): void; } diff --git a/framework/core/src/Search/Filter/FilterManager.php b/framework/core/src/Search/Filter/FilterManager.php new file mode 100644 index 0000000000..8164ab86a9 --- /dev/null +++ b/framework/core/src/Search/Filter/FilterManager.php @@ -0,0 +1,67 @@ + + */ + protected array $filters = []; + + public function __construct( + protected ?AbstractFulltextFilter $fulltext = null + ) { + } + + public function add(FilterInterface $filter): void + { + $this->filters[$filter->getFilterKey()][] = $filter; + } + + public function apply(SearchState $search, array $filters): void + { + $this->applyFulltext($search, $filters['q'] ?? null); + $this->applyFilters($search, $filters); + } + + protected function applyFilters(SearchState $search, array $filters): void + { + foreach ($filters as $filterKey => $filterValue) { + $negate = false; + + if (str_starts_with($filterKey, '-')) { + $negate = true; + $filterKey = substr($filterKey, 1); + } + + foreach (($this->filters[$filterKey] ?? []) as $filter) { + $search->addActiveFilter($filter); + $filter->filter($search, $filterValue, $negate); + } + } + } + + protected function applyFulltext(SearchState $search, ?string $query): void + { + if ($this->fulltext && $query) { + $search->addActiveFilter($this->fulltext); + $this->fulltext->search($search, $query); + } + } + + public function getFulltext(): ?AbstractFulltextFilter + { + return $this->fulltext; + } +} diff --git a/framework/core/src/Search/GambitInterface.php b/framework/core/src/Search/GambitInterface.php deleted file mode 100644 index 99fb01729b..0000000000 --- a/framework/core/src/Search/GambitInterface.php +++ /dev/null @@ -1,21 +0,0 @@ -gambits[] = $gambit; - } - - /** - * Apply gambits to a search, given a search query. - */ - public function apply(SearchState $search, string $query): void - { - $query = $this->applyGambits($search, $query); - - if ($query) { - $this->applyFulltext($search, $query); - } - } - - /** - * Explode a search query into an array of bits. - */ - protected function explode(string $query): array - { - return str_getcsv($query, ' '); - } - - protected function applyGambits(SearchState $search, string $query): string - { - $bits = array_filter($this->explode($query)); - - if (! $bits) { - return ''; - } - - foreach ($bits as $k => $bit) { - foreach ($this->gambits as $gambit) { - if (! $gambit instanceof GambitInterface) { - throw new LogicException( - 'Gambit '.$gambit::class.' does not implement '.GambitInterface::class - ); - } - - if ($gambit->apply($search, $bit)) { - $search->addActiveGambit($gambit); - unset($bits[$k]); - break; - } - } - } - - return implode(' ', $bits); - } - - protected function applyFulltext(SearchState $search, string $query): void - { - $search->addActiveGambit($this->fulltextGambit); - $this->fulltextGambit->apply($search, $query); - } -} diff --git a/framework/core/src/Search/IndexerInterface.php b/framework/core/src/Search/IndexerInterface.php new file mode 100644 index 0000000000..6d229ef820 --- /dev/null +++ b/framework/core/src/Search/IndexerInterface.php @@ -0,0 +1,37 @@ +models)) { + return; + } + + $indexer = $container->make($this->indexerClass); + + $indexer->{$this->operation}($this->models); + } +} diff --git a/framework/core/src/Search/Listener/ModelObserver.php b/framework/core/src/Search/Listener/ModelObserver.php new file mode 100644 index 0000000000..58bcf0f712 --- /dev/null +++ b/framework/core/src/Search/Listener/ModelObserver.php @@ -0,0 +1,65 @@ +runIndexJob($model, IndexJob::SAVE); + } + + public function updated(AbstractModel $model): void + { + $this->runIndexJob($model, IndexJob::SAVE); + } + + public function hidden(AbstractModel $model): void + { + $this->runIndexJob($model, IndexJob::DELETE); + } + + public function deleted(AbstractModel $model): void + { + $this->runIndexJob($model, IndexJob::DELETE); + } + + public function forceDeleted(AbstractModel $model): void + { + $this->runIndexJob($model, IndexJob::DELETE); + } + + public function restored(AbstractModel $model): void + { + $this->runIndexJob($model, IndexJob::SAVE); + } + + private function runIndexJob(AbstractModel $model, string $operation): void + { + if ($this->search->indexable($model::class)) { + foreach ($this->search->indexers($model::class) as $indexerClass) { + $queue = property_exists($indexerClass, 'queue') ? $indexerClass::$queue : null; + + $this->queue->pushOn($queue, new IndexJob($indexerClass, [$model], $operation)); + } + } + } +} diff --git a/framework/core/src/Query/QueryCriteria.php b/framework/core/src/Search/SearchCriteria.php similarity index 75% rename from framework/core/src/Query/QueryCriteria.php rename to framework/core/src/Search/SearchCriteria.php index 44d4728211..c13f408d1c 100644 --- a/framework/core/src/Query/QueryCriteria.php +++ b/framework/core/src/Search/SearchCriteria.php @@ -7,7 +7,7 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\Query; +namespace Flarum\Search; use Flarum\User\User; @@ -16,11 +16,13 @@ * query. The limit and offset are not included because they only determine * which part of the entire result set will be returned. */ -class QueryCriteria +class SearchCriteria { public function __construct( public User $actor, - public array $query, + public array $filters, + public ?int $limit = null, + public int $offset = 0, /** * An array of sort-order pairs, where the column is the key, and the order * is the value. The order may be 'asc', 'desc', or an array of IDs to @@ -33,7 +35,12 @@ public function __construct( * Is the sort for this request the default sort from the controller? * If false, the current request specifies a sort. */ - public bool $sortIsDefault = false + public bool $sortIsDefault = false, ) { } + + public function isFulltext(): bool + { + return in_array('q', array_keys($this->filters), true); + } } diff --git a/framework/core/src/Search/SearchManager.php b/framework/core/src/Search/SearchManager.php new file mode 100644 index 0000000000..dfaafaa31a --- /dev/null +++ b/framework/core/src/Search/SearchManager.php @@ -0,0 +1,71 @@ +> */ + protected array $drivers, + /** @var array, array>> */ + protected array $indexers, + protected SettingsRepositoryInterface $settings, + protected Container $container + ) { + } + + public function driver(?string $name): AbstractDriver + { + $driver = Arr::first($this->drivers, fn ($driver) => $driver::name() === $name); + + if (! $driver) { + return $this->driver(DatabaseSearchDriver::name()); + } + + return $this->container->make($driver); + } + + public function driverFor(string $resourceClass): AbstractDriver + { + return $this->driver($this->settings->get("search_driver_$resourceClass")); + } + + /** + * @param class-string $resourceClass + * @return array> + */ + public function indexers(string $resourceClass): array + { + return $this->indexers[$resourceClass] ?? []; + } + + public function indexable(string $resourceClass): bool + { + return ! empty($this->indexers[$resourceClass]); + } + + public function query(string $resourceClass, SearchCriteria $criteria): SearchResults + { + $driver = $this->driverFor($resourceClass); + $defaultDriver = $this->driver(DatabaseSearchDriver::name()); + + if ($criteria->isFulltext() || ! $defaultDriver->supports($resourceClass)) { + return $driver->searcher($resourceClass)->search($criteria); + } + + return $defaultDriver->searcher($resourceClass)->search($criteria); + } +} diff --git a/framework/core/src/Query/QueryResults.php b/framework/core/src/Search/SearchResults.php similarity index 64% rename from framework/core/src/Query/QueryResults.php rename to framework/core/src/Search/SearchResults.php index a3882dce00..fb3dde8231 100644 --- a/framework/core/src/Query/QueryResults.php +++ b/framework/core/src/Search/SearchResults.php @@ -7,15 +7,18 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\Query; +namespace Flarum\Search; +use Closure; use Illuminate\Database\Eloquent\Collection; -class QueryResults +class SearchResults { public function __construct( protected Collection $results, - protected bool $areMoreResults + protected bool $areMoreResults, + /** @var Closure(): int */ + protected Closure $totalResults ) { } @@ -28,4 +31,9 @@ public function areMoreResults(): bool { return $this->areMoreResults; } + + public function getTotalResults(): int + { + return ($this->totalResults)(); + } } diff --git a/framework/core/src/Search/SearchServiceProvider.php b/framework/core/src/Search/SearchServiceProvider.php index 65d1eb2757..95c1452f94 100644 --- a/framework/core/src/Search/SearchServiceProvider.php +++ b/framework/core/src/Search/SearchServiceProvider.php @@ -9,75 +9,153 @@ namespace Flarum\Search; -use Flarum\Discussion\Query as DiscussionQuery; +use Flarum\Discussion\Discussion; use Flarum\Discussion\Search\DiscussionSearcher; -use Flarum\Discussion\Search\Gambit\FulltextGambit as DiscussionFulltextGambit; +use Flarum\Discussion\Search\Filter as DiscussionFilter; +use Flarum\Discussion\Search\FulltextFilter as DiscussionFulltextFilter; use Flarum\Foundation\AbstractServiceProvider; use Flarum\Foundation\ContainerUtil; -use Flarum\User\Query as UserQuery; -use Flarum\User\Search\Gambit\FulltextGambit as UserFulltextGambit; +use Flarum\Group\Filter as GroupFilter; +use Flarum\Group\Filter\GroupSearcher; +use Flarum\Group\Group; +use Flarum\Http\AccessToken; +use Flarum\Http\Filter\AccessTokenSearcher; +use Flarum\Http\Filter as HttpFilter; +use Flarum\Post\Filter as PostFilter; +use Flarum\Post\Filter\PostSearcher; +use Flarum\Post\Post; +use Flarum\Search\Filter\FilterManager; +use Flarum\Search\Listener\ModelObserver; +use Flarum\Settings\SettingsRepositoryInterface; +use Flarum\User\Search\Filter as UserFilter; +use Flarum\User\Search\FulltextFilter as UserFulltextFilter; use Flarum\User\Search\UserSearcher; +use Flarum\User\User; use Illuminate\Contracts\Container\Container; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; class SearchServiceProvider extends AbstractServiceProvider { public function register(): void { - $this->container->singleton('flarum.simple_search.fulltext_gambits', function () { + $this->container->singleton('flarum.search', function (Container $container) { + return new SearchManager( + array_keys($container->make('flarum.search.drivers')), + $container->make('flarum.search.indexers'), + $container->make(SettingsRepositoryInterface::class), + $container, + ); + }); + + $this->container->alias('flarum.search', SearchManager::class); + + $this->container->singleton('flarum.search.drivers', function () { + return [ + Database\DatabaseSearchDriver::class => [ + Discussion::class => DiscussionSearcher::class, + User::class => UserSearcher::class, + Post::class => PostSearcher::class, + Group::class => GroupSearcher::class, + AccessToken::class => AccessTokenSearcher::class, + ], + ]; + }); + + $this->container->singleton('flarum.search.fulltext', function () { return [ - DiscussionSearcher::class => DiscussionFulltextGambit::class, - UserSearcher::class => UserFulltextGambit::class + DiscussionSearcher::class => DiscussionFulltextFilter::class, + UserSearcher::class => UserFulltextFilter::class ]; }); - $this->container->singleton('flarum.simple_search.gambits', function () { + $this->container->singleton('flarum.search.filters', function () { return [ + AccessTokenSearcher::class => [ + HttpFilter\UserFilter::class, + ], DiscussionSearcher::class => [ - DiscussionQuery\AuthorFilterGambit::class, - DiscussionQuery\CreatedFilterGambit::class, - DiscussionQuery\HiddenFilterGambit::class, - DiscussionQuery\UnreadFilterGambit::class, + DiscussionFilter\AuthorFilter::class, + DiscussionFilter\CreatedFilter::class, + DiscussionFilter\HiddenFilter::class, + DiscussionFilter\UnreadFilter::class, ], UserSearcher::class => [ - UserQuery\EmailFilterGambit::class, - UserQuery\GroupFilterGambit::class, - ] + UserFilter\EmailFilter::class, + UserFilter\GroupFilter::class, + ], + GroupSearcher::class => [ + GroupFilter\HiddenFilter::class, + ], + PostSearcher::class => [ + PostFilter\AuthorFilter::class, + PostFilter\DiscussionFilter::class, + PostFilter\IdFilter::class, + PostFilter\NumberFilter::class, + PostFilter\TypeFilter::class + ], ]; }); - $this->container->singleton('flarum.simple_search.search_mutators', function () { + $this->container->singleton('flarum.search.mutators', function () { return []; }); + + // Indexers aren't driver specific. + // For example, a search driver implementation may support searching discussions, + // and would need to index discussions for that, but it would also need to index + // posts without supporting searching them, because it needs to index the posts for + // searching discussions. + $this->container->singleton('flarum.search.indexers', function () { + return [ + // Model::class => [...], + ]; + }); } public function boot(Container $container): void { - $fullTextGambits = $container->make('flarum.simple_search.fulltext_gambits'); - - foreach ($fullTextGambits as $searcher => $fullTextGambitClass) { + foreach ($container->make('flarum.search.drivers') as $driverClass => $searchers) { $container - ->when($searcher) - ->needs(GambitManager::class) - ->give(function () use ($container, $searcher, $fullTextGambitClass) { - $gambitManager = new GambitManager($container->make($fullTextGambitClass)); - foreach (Arr::get($container->make('flarum.simple_search.gambits'), $searcher, []) as $gambit) { - $gambitManager->add($container->make($gambit)); - } + ->when($driverClass) + ->needs('$searchers') + ->give($searchers); - return $gambitManager; - }); + foreach ($searchers as $searcher) { + $container + ->when($searcher) + ->needs(FilterManager::class) + ->give(function () use ($container, $searcher) { + $fulltext = $container->make('flarum.search.fulltext'); + $fulltextClass = $fulltext[$searcher] ?? null; - $container - ->when($searcher) - ->needs('$searchMutators') - ->give(function () use ($container, $searcher) { - $searchMutators = Arr::get($container->make('flarum.simple_search.search_mutators'), $searcher, []); - - return array_map(function ($mutator) { - return ContainerUtil::wrapCallback($mutator, $this->container); - }, $searchMutators); - }); + $manager = new FilterManager( + $fulltextClass ? $container->make($fulltextClass) : null + ); + + foreach (Arr::get($container->make('flarum.search.filters'), $searcher, []) as $filter) { + $manager->add($container->make($filter)); + } + + return $manager; + }); + + $container + ->when($searcher) + ->needs('$mutators') + ->give(function () use ($container, $searcher) { + $searchMutators = Arr::get($container->make('flarum.search.mutators'), $searcher, []); + + return array_map(function ($mutator) { + return ContainerUtil::wrapCallback($mutator, $this->container); + }, $searchMutators); + }); + } + } + + /** @var \Flarum\Database\AbstractModel $modelClass */ + foreach ($container->make('flarum.search.indexers') as $modelClass => $indexers) { + $modelClass::observe(ModelObserver::class); } } } diff --git a/framework/core/src/Search/SearchState.php b/framework/core/src/Search/SearchState.php index bee14cb8b7..a505f980d5 100644 --- a/framework/core/src/Search/SearchState.php +++ b/framework/core/src/Search/SearchState.php @@ -9,30 +9,71 @@ namespace Flarum\Search; -use Flarum\Query\AbstractQueryState; +use Closure; +use Flarum\Search\Filter\FilterInterface; +use Flarum\User\User; -class SearchState extends AbstractQueryState +class SearchState { /** - * @var GambitInterface[] + * @var FilterInterface[] */ - protected array $activeGambits = []; + protected array $activeFilters = []; + + final public function __construct( + protected User $actor, + /** + * Whether this is a fulltext search or just filtering. + */ + protected bool $fulltextSearch, + /** + * An array of sort-order pairs, where the column + * is the key, and the order is the value. The order may be 'asc', + * 'desc', or an array of IDs to order by. + * Alternatively, a callable may be used. + * + * @var array|Closure $defaultSort + */ + protected array|Closure $defaultSort = [] + ) { + } + + public function getActor(): User + { + return $this->actor; + } + + public function getDefaultSort(): array|Closure + { + return $this->defaultSort; + } /** - * Get a list of the gambits that are active in this search. - * - * @return GambitInterface[] + * Set the default sort order for the search. This will only be applied if + * a sort order has not been specified in the search criteria. */ - public function getActiveGambits(): array + public function setDefaultSort(array|Closure $defaultSort): void + { + $this->defaultSort = $defaultSort; + } + + public function isFulltextSearch(): bool { - return $this->activeGambits; + return $this->fulltextSearch; } /** - * Add a gambit as being active in this search. + * Get a list of the filters that are active. + * + * @return FilterInterface[] */ - public function addActiveGambit(GambitInterface $gambit): void + public function getActiveFilters(): array + { + return $this->activeFilters; + } + + public function addActiveFilter(FilterInterface $filter): void { - $this->activeGambits[] = $gambit; + $this->activeFilters[] = $filter; } } diff --git a/framework/core/src/Search/SearcherInterface.php b/framework/core/src/Search/SearcherInterface.php new file mode 100644 index 0000000000..832d5f5bba --- /dev/null +++ b/framework/core/src/Search/SearcherInterface.php @@ -0,0 +1,20 @@ + '#4D698E', 'theme_secondary_color' => '#4D698E', 'mail_format' => 'multipart', + 'search_driver_Flarum\User\User' => 'default', + 'search_driver_Flarum\Discussion\Discussion' => 'default', + 'search_driver_Flarum\Group\Group' => 'default', + 'search_driver_Flarum\Post\Post' => 'default', + 'search_driver_Flarum\Http\AccessToken' => 'default', ]); }); diff --git a/framework/core/src/User/Filter/UserFilterer.php b/framework/core/src/User/Filter/UserFilterer.php deleted file mode 100644 index 3af54ff765..0000000000 --- a/framework/core/src/User/Filter/UserFilterer.php +++ /dev/null @@ -1,31 +0,0 @@ -users->query()->whereVisibleTo($actor); - } -} diff --git a/framework/core/src/User/Query/EmailFilterGambit.php b/framework/core/src/User/Query/EmailFilterGambit.php deleted file mode 100644 index c0028fe843..0000000000 --- a/framework/core/src/User/Query/EmailFilterGambit.php +++ /dev/null @@ -1,62 +0,0 @@ -getActor()->hasPermission('user.edit')) { - return false; - } - - return parent::apply($search, $bit); - } - - public function getGambitPattern(): string - { - return 'email:(.+)'; - } - - protected function conditions(SearchState $search, array $matches, bool $negate): void - { - $this->constrain($search->getQuery(), $matches[1], $negate); - } - - public function getFilterKey(): string - { - return 'email'; - } - - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void - { - if (! $filterState->getActor()->hasPermission('user.edit')) { - return; - } - - $this->constrain($filterState->getQuery(), $filterValue, $negate); - } - - protected function constrain(Builder $query, string|array $rawEmail, bool $negate): void - { - $email = $this->asString($rawEmail); - - $query->where('email', $negate ? '!=' : '=', $email); - } -} diff --git a/framework/core/src/User/Search/Filter/EmailFilter.php b/framework/core/src/User/Search/Filter/EmailFilter.php new file mode 100644 index 0000000000..c0e4eb49fe --- /dev/null +++ b/framework/core/src/User/Search/Filter/EmailFilter.php @@ -0,0 +1,45 @@ + + */ +class EmailFilter implements FilterInterface +{ + use ValidateFilterTrait; + + public function getFilterKey(): string + { + return 'email'; + } + + public function filter(SearchState $state, string|array $value, bool $negate): void + { + if (! $state->getActor()->hasPermission('user.edit')) { + return; + } + + $this->constrain($state->getQuery(), $value, $negate); + } + + protected function constrain(Builder $query, string|array $rawEmail, bool $negate): void + { + $email = $this->asString($rawEmail); + + $query->where('email', $negate ? '!=' : '=', $email); + } +} diff --git a/framework/core/src/User/Query/GroupFilterGambit.php b/framework/core/src/User/Search/Filter/GroupFilter.php similarity index 63% rename from framework/core/src/User/Query/GroupFilterGambit.php rename to framework/core/src/User/Search/Filter/GroupFilter.php index ebb118b42d..b1de2035c9 100644 --- a/framework/core/src/User/Query/GroupFilterGambit.php +++ b/framework/core/src/User/Search/Filter/GroupFilter.php @@ -7,39 +7,31 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\User\Query; +namespace Flarum\User\Search\Filter; -use Flarum\Filter\FilterInterface; -use Flarum\Filter\FilterState; -use Flarum\Filter\ValidateFilterTrait; use Flarum\Group\Group; -use Flarum\Search\AbstractRegexGambit; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; +use Flarum\Search\ValidateFilterTrait; use Flarum\User\User; use Illuminate\Database\Query\Builder; -class GroupFilterGambit extends AbstractRegexGambit implements FilterInterface +/** + * @implements FilterInterface + */ +class GroupFilter implements FilterInterface { use ValidateFilterTrait; - public function getGambitPattern(): string - { - return 'group:(.+)'; - } - - protected function conditions(SearchState $search, array $matches, bool $negate): void - { - $this->constrain($search->getQuery(), $search->getActor(), $matches[1], $negate); - } - public function getFilterKey(): string { return 'group'; } - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void + public function filter(SearchState $state, string|array $value, bool $negate): void { - $this->constrain($filterState->getQuery(), $filterState->getActor(), $filterValue, $negate); + $this->constrain($state->getQuery(), $state->getActor(), $value, $negate); } protected function constrain(Builder $query, User $actor, string|array $rawQuery, bool $negate): void diff --git a/framework/core/src/User/Search/Gambit/FulltextGambit.php b/framework/core/src/User/Search/FulltextFilter.php similarity index 65% rename from framework/core/src/User/Search/Gambit/FulltextGambit.php rename to framework/core/src/User/Search/FulltextFilter.php index f3eeeebe74..3f862d7365 100644 --- a/framework/core/src/User/Search/Gambit/FulltextGambit.php +++ b/framework/core/src/User/Search/FulltextFilter.php @@ -7,15 +7,19 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\User\Search\Gambit; +namespace Flarum\User\Search; -use Flarum\Search\GambitInterface; +use Flarum\Search\AbstractFulltextFilter; +use Flarum\Search\Database\DatabaseSearchState; use Flarum\Search\SearchState; use Flarum\User\User; use Flarum\User\UserRepository; use Illuminate\Database\Eloquent\Builder; -class FulltextGambit implements GambitInterface +/** + * @extends AbstractFulltextFilter + */ +class FulltextFilter extends AbstractFulltextFilter { public function __construct( protected UserRepository $users @@ -33,14 +37,12 @@ private function getUserSearchSubQuery(string $searchValue): Builder ->where('username', 'like', "$searchValue%"); } - public function apply(SearchState $search, string $bit): bool + public function search(SearchState $state, string $value): void { - $search->getQuery() + $state->getQuery() ->whereIn( 'id', - $this->getUserSearchSubQuery($bit) + $this->getUserSearchSubQuery($value) ); - - return true; } } diff --git a/framework/core/src/User/Search/UserSearcher.php b/framework/core/src/User/Search/UserSearcher.php index f6cbeab0bf..118e34bd26 100644 --- a/framework/core/src/User/Search/UserSearcher.php +++ b/framework/core/src/User/Search/UserSearcher.php @@ -9,26 +9,14 @@ namespace Flarum\User\Search; -use Flarum\Search\AbstractSearcher; -use Flarum\Search\GambitManager; +use Flarum\Search\Database\AbstractSearcher; use Flarum\User\User; -use Flarum\User\UserRepository; -use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\Eloquent\Builder; class UserSearcher extends AbstractSearcher { - public function __construct( - protected UserRepository $users, - protected Dispatcher $events, - GambitManager $gambits, - array $searchMutators - ) { - parent::__construct($gambits, $searchMutators); - } - - protected function getQuery(User $actor): Builder + public function getQuery(User $actor): Builder { - return $this->users->query()->whereVisibleTo($actor); + return User::whereVisibleTo($actor)->select('users.*'); } } diff --git a/framework/core/src/User/User.php b/framework/core/src/User/User.php index 1c44496b12..a891035a46 100644 --- a/framework/core/src/User/User.php +++ b/framework/core/src/User/User.php @@ -55,6 +55,17 @@ * @property \Carbon\Carbon|null $read_notifications_at * @property int $discussion_count * @property int $comment_count + * @property-read Collection $groups + * @property-read Collection $visibleGroups + * @property-read Collection $notifications + * @property-read Collection $accessTokens + * @property-read Collection $posts + * @property-read Collection $discussions + * @property-read Collection $read + * @property-read Collection $unreadNotifications + * @property-read Collection $loginProviders + * @property-read Collection $emailTokens + * @property-read Collection $passwordTokens */ class User extends AbstractModel { diff --git a/framework/core/tests/integration/api/AbstractSerializeControllerTest.php b/framework/core/tests/integration/api/AbstractSerializeControllerTest.php index 9b98cc4877..cd5be3d891 100644 --- a/framework/core/tests/integration/api/AbstractSerializeControllerTest.php +++ b/framework/core/tests/integration/api/AbstractSerializeControllerTest.php @@ -30,9 +30,9 @@ public function test_missing_serializer_class_throws_exception() $this->request('GET', '/api/dummy-serialize') ); - $json = json_decode((string) $response->getBody(), true); + $json = json_decode($contents = (string) $response->getBody(), true); - $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals(500, $response->getStatusCode(), $contents); $this->assertStringStartsWith('InvalidArgumentException: Serializer required for controller: '.DummySerializeController::class, $json['errors'][0]['detail']); } } diff --git a/framework/core/tests/integration/api/access_tokens/DeleteTest.php b/framework/core/tests/integration/api/access_tokens/DeleteTest.php index b12b9dbd1e..e2a9d1a25f 100644 --- a/framework/core/tests/integration/api/access_tokens/DeleteTest.php +++ b/framework/core/tests/integration/api/access_tokens/DeleteTest.php @@ -115,7 +115,7 @@ public function user_cannot_delete_current_session_token() $response = $this->send($request); - $this->assertEquals(403, $response->getStatusCode()); + $this->assertEquals(403, $response->getStatusCode(), $response->getBody()->getContents()); } /** diff --git a/framework/core/tests/integration/api/access_tokens/ListTest.php b/framework/core/tests/integration/api/access_tokens/ListTest.php index 2a077bedbe..1e11766ddb 100644 --- a/framework/core/tests/integration/api/access_tokens/ListTest.php +++ b/framework/core/tests/integration/api/access_tokens/ListTest.php @@ -62,11 +62,11 @@ public function user_can_view_access_tokens(int $authenticatedAs, array $canView $request = $this->request('GET', '/api/access-tokens', compact('authenticatedAs')) ); - $data = Arr::get(json_decode($response->getBody()->getContents(), true), 'data'); + $data = Arr::get(json_decode($contents = $response->getBody()->getContents(), true), 'data'); $testsTokenId = AccessToken::findValid($request->getAttribute('tests_token'))->id; - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), $contents); $this->assertEqualsCanonicalizing(array_merge($canViewIds, [$testsTokenId]), Arr::pluck($data, 'id')); } @@ -112,14 +112,14 @@ public function user_needs_permissions_to_use_user_filter(int $authenticatedAs, ]) ); - $data = Arr::get(json_decode($response->getBody()->getContents(), true), 'data'); + $data = Arr::get(json_decode($contents = $response->getBody()->getContents(), true), 'data'); $testsTokenId = AccessToken::findValid($request->getAttribute('tests_token'))->id; if ($authenticatedAs === $userId) { $canViewIds[] = $testsTokenId; } - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), $contents); $this->assertEqualsCanonicalizing($canViewIds, Arr::pluck($data, 'id')); } diff --git a/framework/core/tests/integration/api/discussions/ListTest.php b/framework/core/tests/integration/api/discussions/ListTest.php index 63078ddce1..170f9b05da 100644 --- a/framework/core/tests/integration/api/discussions/ListTest.php +++ b/framework/core/tests/integration/api/discussions/ListTest.php @@ -265,200 +265,4 @@ public function unread_filter_works_when_negated() // Order-independent comparison $this->assertEqualsCanonicalizing(['1', '2'], Arr::pluck($data, 'id'), 'IDs do not match'); } - - /** - * @test - */ - public function author_gambit_works() - { - $response = $this->send( - $this->request('GET', '/api/discussions') - ->withQueryParams([ - 'filter' => ['q' => 'author:normal'], - 'include' => 'mostRelevantPost', - ]) - ); - - $data = json_decode($response->getBody()->getContents(), true)['data']; - - // Order-independent comparison - $this->assertEqualsCanonicalizing(['2', '3'], Arr::pluck($data, 'id'), 'IDs do not match'); - } - - /** - * @test - */ - public function author_gambit_works_negated() - { - $response = $this->send( - $this->request('GET', '/api/discussions') - ->withQueryParams([ - 'filter' => ['q' => '-author:normal'], - 'include' => 'mostRelevantPost', - ]) - ); - - $data = json_decode($response->getBody()->getContents(), true)['data']; - - // Order-independent comparison - $this->assertEquals(['1'], Arr::pluck($data, 'id'), 'IDs do not match'); - } - - /** - * @test - */ - public function created_gambit_works_with_date() - { - $response = $this->send( - $this->request('GET', '/api/discussions') - ->withQueryParams([ - 'filter' => ['q' => 'created:1995-05-21'], - 'include' => 'mostRelevantPost', - ]) - ); - - $data = json_decode($response->getBody()->getContents(), true)['data']; - - // Order-independent comparison - $this->assertEquals(['3'], Arr::pluck($data, 'id'), 'IDs do not match'); - } - - /** - * @test - */ - public function created_gambit_works_negated_with_date() - { - $response = $this->send( - $this->request('GET', '/api/discussions') - ->withQueryParams([ - 'filter' => ['q' => '-created:1995-05-21'], - 'include' => 'mostRelevantPost', - ]) - ); - - $data = json_decode($response->getBody()->getContents(), true)['data']; - - // Order-independent comparison - $this->assertEqualsCanonicalizing(['1', '2'], Arr::pluck($data, 'id'), 'IDs do not match'); - } - - /** - * @test - */ - public function created_gambit_works_with_range() - { - $response = $this->send( - $this->request('GET', '/api/discussions') - ->withQueryParams([ - 'filter' => ['q' => 'created:1980-05-21..2000-05-21'], - 'include' => 'mostRelevantPost', - ]) - ); - - $data = json_decode($response->getBody()->getContents(), true)['data']; - - // Order-independent comparison - $this->assertEqualsCanonicalizing(['2', '3'], Arr::pluck($data, 'id'), 'IDs do not match'); - } - - /** - * @test - */ - public function created_gambit_works_negated_with_range() - { - $response = $this->send( - $this->request('GET', '/api/discussions') - ->withQueryParams([ - 'filter' => ['q' => '-created:1980-05-21..2000-05-21'], - 'include' => 'mostRelevantPost', - ]) - ); - - $data = json_decode($response->getBody()->getContents(), true)['data']; - - // Order-independent comparison - $this->assertEquals(['1'], Arr::pluck($data, 'id'), 'IDs do not match'); - } - - /** - * @test - */ - public function hidden_gambit_works() - { - $response = $this->send( - $this->request('GET', '/api/discussions', ['authenticatedAs' => 1]) - ->withQueryParams([ - 'filter' => ['q' => 'is:hidden'], - 'include' => 'mostRelevantPost', - ]) - ); - - $data = json_decode($response->getBody()->getContents(), true)['data']; - - // Order-independent comparison - $this->assertEquals(['4'], Arr::pluck($data, 'id'), 'IDs do not match'); - } - - /** - * @test - */ - public function hidden_gambit_works_negated() - { - $response = $this->send( - $this->request('GET', '/api/discussions', ['authenticatedAs' => 1]) - ->withQueryParams([ - 'filter' => ['q' => '-is:hidden'], - 'include' => 'mostRelevantPost', - ]) - ); - - $data = json_decode($response->getBody()->getContents(), true)['data']; - - // Order-independent comparison - $this->assertEqualsCanonicalizing(['1', '2', '3'], Arr::pluck($data, 'id'), 'IDs do not match'); - } - - /** - * @test - */ - public function unread_gambit_works() - { - $this->app(); - $this->read(); - - $response = $this->send( - $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) - ->withQueryParams([ - 'filter' => ['q' => 'is:unread'], - 'include' => 'mostRelevantPost', - ]) - ); - - $data = json_decode($response->getBody()->getContents(), true)['data']; - - // Order-independent comparison - $this->assertEquals(['3'], Arr::pluck($data, 'id'), 'IDs do not match'); - } - - /** - * @test - */ - public function unread_gambit_works_when_negated() - { - $this->app(); - $this->read(); - - $response = $this->send( - $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) - ->withQueryParams([ - 'filter' => ['q' => '-is:unread'], - 'include' => 'mostRelevantPost', - ]) - ); - - $data = json_decode($response->getBody()->getContents(), true)['data']; - - // Order-independent comparison - $this->assertEqualsCanonicalizing(['1', '2'], Arr::pluck($data, 'id'), 'IDs do not match'); - } } diff --git a/framework/core/tests/integration/api/posts/ListTest.php b/framework/core/tests/integration/api/posts/ListTest.php index fad1721722..8cc025999f 100644 --- a/framework/core/tests/integration/api/posts/ListTest.php +++ b/framework/core/tests/integration/api/posts/ListTest.php @@ -14,7 +14,7 @@ use Flarum\Testing\integration\TestCase; use Illuminate\Support\Arr; -class ListTests extends TestCase +class ListTest extends TestCase { use RetrievesAuthorizedUsers; diff --git a/framework/core/tests/integration/api/users/GroupSearchTest.php b/framework/core/tests/integration/api/users/GroupSearchTest.php index 42b8447bbd..803bd263d2 100644 --- a/framework/core/tests/integration/api/users/GroupSearchTest.php +++ b/framework/core/tests/integration/api/users/GroupSearchTest.php @@ -236,7 +236,7 @@ private function createRequest(array $group, int $userId = null) return $this->send( $this->request('GET', '/api/users', $auth) - ->withQueryParams(['filter' => ['q' => 'group:'.implode(',', $group)]]) + ->withQueryParams(['filter' => ['group' => implode(',', $group)]]) ); } diff --git a/framework/core/tests/integration/api/users/ListTest.php b/framework/core/tests/integration/api/users/ListTest.php index 86c4588811..4ff425c98e 100644 --- a/framework/core/tests/integration/api/users/ListTest.php +++ b/framework/core/tests/integration/api/users/ListTest.php @@ -223,94 +223,4 @@ public function email_filter_only_works_for_admin() $data = json_decode($response->getBody()->getContents(), true)['data']; $this->assertEquals(['1', '2'], Arr::pluck($data, 'id')); } - - /** - * @test - */ - public function group_gambit_works() - { - $response = $this->send( - $this->request('GET', '/api/users', [ - 'authenticatedAs' => 1, - ])->withQueryParams([ - 'filter' => ['q' => 'group:1'], - ]) - ); - - $this->assertEquals(200, $response->getStatusCode()); - $data = json_decode($response->getBody()->getContents(), true)['data']; - $this->assertEquals(['1'], Arr::pluck($data, 'id')); - } - - /** - * @test - */ - public function group_gambit_works_negated() - { - $response = $this->send( - $this->request('GET', '/api/users', [ - 'authenticatedAs' => 1, - ])->withQueryParams([ - 'filter' => ['q' => '-group:1'], - ]) - ); - - $this->assertEquals(200, $response->getStatusCode()); - $data = json_decode($response->getBody()->getContents(), true)['data']; - $this->assertEquals(['2'], Arr::pluck($data, 'id')); - } - - /** - * @test - */ - public function email_gambit_works() - { - $response = $this->send( - $this->request('GET', '/api/users', [ - 'authenticatedAs' => 1, - ])->withQueryParams([ - 'filter' => ['q' => 'email:admin@machine.local'], - ]) - ); - - $this->assertEquals(200, $response->getStatusCode()); - $data = json_decode($response->getBody()->getContents(), true)['data']; - $this->assertEquals(['1'], Arr::pluck($data, 'id')); - } - - /** - * @test - */ - public function email_gambit_works_negated() - { - $response = $this->send( - $this->request('GET', '/api/users', [ - 'authenticatedAs' => 1, - ])->withQueryParams([ - 'filter' => ['q' => '-email:admin@machine.local'], - ]) - ); - - $this->assertEquals(200, $response->getStatusCode()); - $data = json_decode($response->getBody()->getContents(), true)['data']; - $this->assertEquals(['2'], Arr::pluck($data, 'id')); - } - - /** - * @test - */ - public function email_gambit_only_works_for_admin() - { - $response = $this->send( - $this->request('GET', '/api/users', [ - 'authenticatedAs' => 2, - ])->withQueryParams([ - 'filter' => ['q' => 'email:admin@machine.local'], - ]) - ); - - $this->assertEquals(200, $response->getStatusCode()); - $data = json_decode($response->getBody()->getContents(), true)['data']; - $this->assertEquals([], Arr::pluck($data, 'id')); - } } diff --git a/framework/core/tests/integration/extenders/FilterTest.php b/framework/core/tests/integration/extenders/FilterTest.php deleted file mode 100644 index b2e790f127..0000000000 --- a/framework/core/tests/integration/extenders/FilterTest.php +++ /dev/null @@ -1,131 +0,0 @@ -prepareDatabase([ - 'discussions' => [ - ['id' => 1, 'title' => 'DISCUSSION 1', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 1, 'comment_count' => 1], - ['id' => 2, 'title' => 'DISCUSSION 2', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 2, 'comment_count' => 1], - ], - 'posts' => [ - ['id' => 1, 'discussion_id' => 1, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '

    foo bar

    '], - ['id' => 2, 'discussion_id' => 2, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '

    foo bar not the same

    '], - ], - 'users' => [ - $this->normalUser(), - ], - ]); - } - - public function filterDiscussions($filters, $limit = null) - { - $response = $this->send( - $this->request('GET', '/api/discussions', [ - 'authenticatedAs' => 1, - ])->withQueryParams([ - 'filter' => $filters, - 'include' => 'mostRelevantPost', - ]) - ); - - return json_decode($response->getBody()->getContents(), true)['data']; - } - - /** - * @test - */ - public function works_as_expected_with_no_modifications() - { - $this->prepDb(); - - $searchForAll = json_encode($this->filterDiscussions([], 5)); - $this->assertStringContainsString('DISCUSSION 1', $searchForAll); - $this->assertStringContainsString('DISCUSSION 2', $searchForAll); - } - - /** - * @test - */ - public function custom_filter_has_effect_if_added() - { - $this->extend((new Extend\Filter(DiscussionFilterer::class))->addFilter(NoResultFilter::class)); - - $this->prepDb(); - - $withResultSearch = json_encode($this->filterDiscussions(['noResult' => 0], 5)); - $this->assertStringContainsString('DISCUSSION 1', $withResultSearch); - $this->assertStringContainsString('DISCUSSION 2', $withResultSearch); - $this->assertEquals([], $this->filterDiscussions(['noResult' => 1], 5)); - } - - /** - * @test - */ - public function filter_mutator_has_effect_if_added() - { - $this->extend((new Extend\Filter(DiscussionFilterer::class))->addFilterMutator(function ($filterState, $criteria) { - $filterState->getQuery()->whereRaw('1=0'); - })); - - $this->prepDb(); - - $this->assertEquals([], $this->filterDiscussions([], 5)); - } - - /** - * @test - */ - public function filter_mutator_has_effect_if_added_with_invokable_class() - { - $this->extend((new Extend\Filter(DiscussionFilterer::class))->addFilterMutator(CustomFilterMutator::class)); - - $this->prepDb(); - - $this->assertEquals([], $this->filterDiscussions([], 5)); - } -} - -class NoResultFilter implements FilterInterface -{ - public function getFilterKey(): string - { - return 'noResult'; - } - - public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void - { - if ($filterValue) { - $filterState->getQuery() - ->whereRaw('0=1'); - } - } -} - -class CustomFilterMutator -{ - public function __invoke($filterState, $criteria) - { - $filterState->getQuery()->whereRaw('1=0'); - } -} diff --git a/framework/core/tests/integration/extenders/SimpleFlarumSearchTest.php b/framework/core/tests/integration/extenders/SearchDriverTest.php similarity index 58% rename from framework/core/tests/integration/extenders/SimpleFlarumSearchTest.php rename to framework/core/tests/integration/extenders/SearchDriverTest.php index ce4f8062d5..802598da24 100644 --- a/framework/core/tests/integration/extenders/SimpleFlarumSearchTest.php +++ b/framework/core/tests/integration/extenders/SearchDriverTest.php @@ -10,21 +10,22 @@ namespace Flarum\Tests\integration\extenders; use Carbon\Carbon; +use Flarum\Discussion\Discussion; use Flarum\Discussion\Search\DiscussionSearcher; +use Flarum\Discussion\Search\Filter\UnreadFilter; use Flarum\Extend; -use Flarum\Group\Group; -use Flarum\Query\QueryCriteria; -use Flarum\Search\AbstractRegexGambit; -use Flarum\Search\AbstractSearcher; -use Flarum\Search\GambitInterface; +use Flarum\Search\AbstractFulltextFilter; +use Flarum\Search\Database\DatabaseSearchDriver; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; +use Flarum\Search\SearchCriteria; +use Flarum\Search\SearchManager; use Flarum\Search\SearchState; use Flarum\Testing\integration\RetrievesAuthorizedUsers; use Flarum\Testing\integration\TestCase; use Flarum\User\User; -use Illuminate\Contracts\Container\BindingResolutionException; -use Illuminate\Database\Eloquent\Builder; -class SimpleFlarumSearchTest extends TestCase +class SearchDriverTest extends TestCase { use RetrievesAuthorizedUsers; @@ -62,15 +63,19 @@ protected function tearDown(): void $this->database()->table('posts')->whereIn('id', [1, 2])->delete(); } - public function searchDiscussions($query, $limit = null) + public function searchDiscussions($query, $limit = null, array $filters = []) { $this->app(); $actor = User::find(1); - $criteria = new QueryCriteria($actor, ['q' => $query]); + $filters['q'] = $query; - return $this->app()->getContainer()->make(DiscussionSearcher::class)->search($criteria, $limit)->getResults(); + return $this->app() + ->getContainer() + ->make(SearchManager::class) + ->query(Discussion::class, new SearchCriteria($actor, $filters, $limit)) + ->getResults(); } /** @@ -94,7 +99,10 @@ public function works_as_expected_with_no_modifications() */ public function custom_full_text_gambit_has_effect_if_added() { - $this->extend((new Extend\SimpleFlarumSearch(DiscussionSearcher::class))->setFullTextGambit(NoResultFullTextGambit::class)); + $this->extend( + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->setFulltext(DiscussionSearcher::class, NoResultFullTextFilter::class) + ); $this->assertEquals('[]', json_encode($this->searchDiscussions('in text', 5))); } @@ -102,38 +110,49 @@ public function custom_full_text_gambit_has_effect_if_added() /** * @test */ - public function custom_filter_gambit_has_effect_if_added() + public function custom_filter_has_effect_if_added() { - $this->extend((new Extend\SimpleFlarumSearch(DiscussionSearcher::class))->addGambit(NoResultFilterGambit::class)); + $this->extend( + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->addFilter(DiscussionSearcher::class, NoResultFilter::class) + ); $this->prepDb(); - $withResultSearch = json_encode($this->searchDiscussions('noResult:0', 5)); + $withResultSearch = json_encode($this->searchDiscussions('', 5, ['noResult' => '0'])); $this->assertStringContainsString('DISCUSSION 1', $withResultSearch); $this->assertStringContainsString('DISCUSSION 2', $withResultSearch); - $this->assertEquals('[]', json_encode($this->searchDiscussions('noResult:1', 5))); + $this->assertEquals('[]', json_encode($this->searchDiscussions('', 5, ['noResult' => '1']))); } /** * @test */ - public function search_mutator_has_effect_if_added() + public function existing_filter_can_be_replaced() { - $this->extend((new Extend\SimpleFlarumSearch(DiscussionSearcher::class))->addSearchMutator(function ($search, $criteria) { - $search->getquery()->whereRaw('1=0'); - })); + $this->extend( + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->replaceFilter(DiscussionSearcher::class, UnreadFilter::class, NoResultFilter::class) + ); $this->prepDb(); - $this->assertEquals('[]', json_encode($this->searchDiscussions('in text', 5))); + $this->assertNotContains(UnreadFilter::class, $this->app()->getContainer()->make('flarum.search.filters')[DiscussionSearcher::class]); + $this->assertContains(NoResultFilter::class, $this->app()->getContainer()->make('flarum.search.filters')[DiscussionSearcher::class]); + $this->assertEquals('[]', json_encode($this->searchDiscussions('', 5, ['noResult' => '1']))); } /** * @test */ - public function search_mutator_has_effect_if_added_with_invokable_class() + public function search_mutator_has_effect_if_added() { - $this->extend((new Extend\SimpleFlarumSearch(DiscussionSearcher::class))->addSearchMutator(CustomSearchMutator::class)); + $this->extend( + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->addMutator(DiscussionSearcher::class, function (DatabaseSearchState $search) { + $search->getQuery()->whereRaw('1=0'); + }) + ); $this->prepDb(); @@ -143,58 +162,43 @@ public function search_mutator_has_effect_if_added_with_invokable_class() /** * @test */ - public function cant_resolve_custom_searcher_without_fulltext_gambit() - { - $this->expectException(BindingResolutionException::class); - - $this->app()->getContainer()->make(CustomSearcher::class); - } - - /** - * @test - */ - public function can_resolve_custom_searcher_with_fulltext_gambit() + public function search_mutator_has_effect_if_added_with_invokable_class() { $this->extend( - (new Extend\SimpleFlarumSearch(CustomSearcher::class))->setFullTextGambit(CustomFullTextGambit::class) + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->addMutator(DiscussionSearcher::class, CustomSearchMutator::class) ); - $anExceptionWasThrown = false; - - try { - $this->app()->getContainer()->make(CustomSearcher::class); - } catch (BindingResolutionException) { - $anExceptionWasThrown = true; - } + $this->prepDb(); - $this->assertFalse($anExceptionWasThrown); + $this->assertEquals('[]', json_encode($this->searchDiscussions('in text', 5))); } } -class NoResultFullTextGambit implements GambitInterface +class NoResultFullTextFilter extends AbstractFulltextFilter { - public function apply(SearchState $search, string $bit): bool + public function search(SearchState $state, string $value): void { - $search->getQuery() - ->whereRaw('0=1'); - - return true; + $state->getQuery()->whereRaw('0=1'); } } -class NoResultFilterGambit extends AbstractRegexGambit +/** + * @implements FilterInterface + */ +class NoResultFilter implements FilterInterface { - public function getGambitPattern(): string + public function getFilterKey(): string { - return 'noResult:(.+)'; + return 'noResult'; } - public function conditions(SearchState $search, array $matches, bool $negate): void + public function filter(SearchState $state, array|string $value, bool $negate): void { - $noResults = trim($matches[1], ' '); + $noResults = trim($value, ' '); + if ($noResults == '1') { - $search->getQuery() - ->whereRaw('0=1'); + $state->getQuery()->whereRaw('0=1'); } } } @@ -206,20 +210,3 @@ public function __invoke($search, $criteria) $search->getQuery()->whereRaw('1=0'); } } - -class CustomSearcher extends AbstractSearcher -{ - // This isn't actually used, we just need it to implement the abstract method. - protected function getQuery(User $actor): Builder - { - return Group::query(); - } -} - -class CustomFullTextGambit implements GambitInterface -{ - public function apply(SearchState $search, string $bit): bool - { - return true; - } -} diff --git a/framework/core/tests/integration/extenders/SearchIndexTest.php b/framework/core/tests/integration/extenders/SearchIndexTest.php new file mode 100644 index 0000000000..0ceccaa471 --- /dev/null +++ b/framework/core/tests/integration/extenders/SearchIndexTest.php @@ -0,0 +1,212 @@ +prepareDatabase([ + 'discussions' => [ + ['id' => 1, 'title' => 'DISCUSSION 1', 'created_at' => Carbon::now()->subDays(1)->toDateTimeString(), 'hidden_at' => null, 'comment_count' => 1, 'user_id' => 1, 'first_post_id' => 1], + ['id' => 2, 'title' => 'DISCUSSION 2', 'created_at' => Carbon::now()->subDays(2)->toDateTimeString(), 'hidden_at' => Carbon::now(), 'comment_count' => 1, 'user_id' => 1], + ], + 'posts' => [ + ['id' => 1, 'number' => 1, 'discussion_id' => 1, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

    content

    ', 'hidden_at' => null], + ['id' => 2, 'number' => 2, 'discussion_id' => 1, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

    content

    ', 'hidden_at' => Carbon::now()], + ], + ]); + } + + public static function modelProvider(): array + { + return [ + ['discussions', Discussion::class, 'title'], + ['posts', CommentPost::class, 'content'], + ]; + } + + /** @dataProvider modelProvider */ + public function test_indexer_triggered_on_create(string $type, string $modelClass, string $attribute) + { + $this->extend( + (new Extend\SearchIndex()) + ->indexer($modelClass, TestIndexer::class) + ); + + // Create discussion + $response = $this->send( + $this->request('POST', "/api/$type", [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + $attribute => 'test', + ], + 'relationships' => ($type === 'posts' ? [ + 'discussion' => [ + 'data' => [ + 'type' => 'discussions', + 'id' => 1, + ], + ], + ] : null), + ] + ], + ]), + ); + + Assert::assertEquals('save', TestIndexer::$triggered, $response->getBody()->getContents()); + } + + /** @dataProvider modelProvider */ + public function test_indexer_triggered_on_save(string $type, string $modelClass, string $attribute) + { + $this->extend( + (new Extend\SearchIndex()) + ->indexer($modelClass, TestIndexer::class) + ); + + // Rename discussion + $response = $this->send( + $this->request('PATCH', "/api/$type/1", [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + $attribute => 'changed' + ] + ] + ], + ]), + ); + + Assert::assertEquals('save', TestIndexer::$triggered, $response->getBody()->getContents()); + } + + /** @dataProvider modelProvider */ + public function test_indexer_triggered_on_delete(string $type, string $modelClass, string $attribute) + { + $this->extend( + (new Extend\SearchIndex()) + ->indexer($modelClass, TestIndexer::class) + ); + + // Delete discussion + $response = $this->send( + $this->request('DELETE', "/api/$type/1", [ + 'authenticatedAs' => 1, + 'json' => [], + ]), + ); + + Assert::assertEquals('delete', TestIndexer::$triggered, $response->getBody()->getContents()); + } + + /** @dataProvider modelProvider */ + public function test_indexer_triggered_on_hide(string $type, string $modelClass, string $attribute) + { + $this->extend( + (new Extend\SearchIndex()) + ->indexer($modelClass, TestIndexer::class) + ); + + // Hide discussion + $response = $this->send( + $this->request('PATCH', "/api/$type/1", [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'isHidden' => true + ] + ] + ], + ]), + ); + + Assert::assertEquals('delete', TestIndexer::$triggered, $response->getBody()->getContents()); + } + + /** @dataProvider modelProvider */ + public function test_indexer_triggered_on_restore(string $type, string $modelClass, string $attribute) + { + $this->extend( + (new Extend\SearchIndex()) + ->indexer($modelClass, TestIndexer::class) + ); + + // Restore discussion + $response = $this->send( + $this->request('PATCH', "/api/$type/2", [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'isHidden' => false + ] + ] + ], + ]), + ); + + Assert::assertEquals('save', TestIndexer::$triggered, $response->getBody()->getContents()); + } + + protected function tearDown(): void + { + TestIndexer::$triggered = null; + + parent::tearDown(); + } +} + +class TestIndexer implements IndexerInterface +{ + public static ?string $triggered = null; + + public static function index(): string + { + return 'test'; + } + + public function save(array $models): void + { + self::$triggered = 'save'; + } + + public function delete(array $models): void + { + self::$triggered = 'delete'; + } + + public function build(): void + { + // + } + + public function flush(): void + { + // + } +}