feat: revamp search (#3893)

* refactor: move gambits to frontend (#3885)
* refactor: move gambits to frontend
* test: GambitManager
* refactor: merge filterer and searcher concepts (#3892)
* chore: drop remaining backend regex gambits
* refactor: merge filterer & searcher concept
* refactor: adapt extenders
* refactor: no longer need to push gambits to `q`
* refactor: filters to gambits
* refactor: drop shred `Query` namespace
* chore: cleanup
* chore: leftover gambit references on the backend (#3894)
* chore: leftover gambit references on the backend
* chore: namespace
* feat: search driver backend extension API (#3902)
* feat: first iteration of search drivers
* feat: indexer API & tweaks
* feat: changes after POC driver
* fix: properly fire custom observables
* chore: remove debugging code
* fix: phpstan
* fix: custom eloquent events
* chore: drop POC usage
* test: indexer extender API
* fix: extension searcher fails without filters
* fix: phpstan
* fix: frontend created gambit
* feat: advanced page and localized driver settings (#3905)
* feat: allow getting total search results and replacing filters (#3906)
* feat: allow accessing total search results
* feat: allow replacing filters
* chore: phpstan
This commit is contained in:
Sami Mazouz 2023-11-11 19:43:09 +01:00 committed by GitHub
parent 9e04b010d8
commit 4b126d9f4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
161 changed files with 2734 additions and 2197 deletions

View File

@ -19,9 +19,10 @@ use Flarum\Likes\Event\PostWasUnliked;
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 @@ return [
->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),

View File

@ -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<DatabaseSearchState>
*/
class LikedByFilter implements FilterInterface
{
use ValidateFilterTrait;
@ -22,11 +26,11 @@ class LikedByFilter implements FilterInterface
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')

View File

@ -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<DatabaseSearchState>
*/
class LikedFilter implements FilterInterface
{
use ValidateFilterTrait;
@ -22,11 +26,11 @@ class LikedFilter implements FilterInterface
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')

View File

@ -11,16 +11,16 @@ use Flarum\Api\Serializer\BasicDiscussionSerializer;
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 @@ return [
(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),
];

View File

@ -0,0 +1 @@
export { default as default } from '../common/extend';

View File

@ -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(
{

View File

@ -0,0 +1,7 @@
import Extend from 'flarum/common/extenders';
import LockedGambit from './query/discussions/LockedGambit';
export default [
new Extend.Search() //
.gambit('discussions', LockedGambit),
];

View File

@ -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<string, any> {
const key = (negate ? '-' : '') + 'locked';
return {
[key]: true,
};
}
filterKey(): string {
return 'locked';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}is:locked`;
}
}

View File

@ -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),

View File

@ -0,0 +1,36 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Lock\Filter;
use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\SearchState;
use Illuminate\Database\Query\Builder;
/**
* @implements FilterInterface<DatabaseSearchState>
*/
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);
}
}

View File

@ -1,44 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Lock\Query;
use Flarum\Filter\FilterInterface;
use Flarum\Filter\FilterState;
use Flarum\Search\AbstractRegexGambit;
use Flarum\Search\SearchState;
use Illuminate\Database\Query\Builder;
class LockedFilterGambit extends AbstractRegexGambit implements FilterInterface
{
protected function getGambitPattern(): string
{
return 'is:locked';
}
protected function conditions(SearchState $search, array $matches, bool $negate): void
{
$this->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);
}
}

View File

@ -24,8 +24,9 @@ use Flarum\Post\Event\Hidden;
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 @@ return [
->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 {

View File

@ -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<DatabaseSearchState>
*/
class MentionedFilter implements FilterInterface
{
use ValidateFilterTrait;
@ -22,11 +26,11 @@ class MentionedFilter implements FilterInterface
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);

View File

@ -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<DatabaseSearchState>
*/
class MentionedPostFilter implements FilterInterface
{
public function getFilterKey(): string
@ -19,11 +23,11 @@ class MentionedPostFilter implements FilterInterface
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);

View File

@ -12,6 +12,7 @@ namespace Flarum\Nicknames;
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 @@ return [
(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),

View File

@ -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'));
});
});

View File

@ -1,5 +1,8 @@
flarum-nicknames:
admin:
basics:
display_name_driver_options:
nickname: Nickname
permissions:
edit_own_nickname_label: Edit own nickname
settings:

View File

@ -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<DatabaseSearchState>
*/
class NicknameFullTextFilter extends AbstractFulltextFilter
{
public function __construct(
protected UserRepository $users
@ -37,14 +34,12 @@ class NicknameFullTextGambit implements GambitInterface
->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;
}
}

View File

@ -11,16 +11,16 @@ use Flarum\Api\Controller\ListDiscussionsController;
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 @@ return [
->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),
];

View File

@ -0,0 +1 @@
export { default as default } from '../common/extend';

View File

@ -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(
{

View File

@ -0,0 +1,7 @@
import Extend from 'flarum/common/extenders';
import StickyGambit from './query/discussions/StickyGambit';
export default [
new Extend.Search() //
.gambit('discussions', StickyGambit),
];

View File

@ -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<string, any> {
const key = (negate ? '-' : '') + 'sticky';
return {
[key]: true,
};
}
filterKey(): string {
return 'sticky';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}is:sticky`;
}
}

View File

@ -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),

View File

@ -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 @@ class PinStickiedDiscussionsToTop
->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;

View File

@ -0,0 +1,36 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Sticky\Query;
use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\SearchState;
use Illuminate\Database\Query\Builder;
/**
* @implements FilterInterface<DatabaseSearchState>
*/
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);
}
}

View File

@ -1,44 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Sticky\Query;
use Flarum\Filter\FilterInterface;
use Flarum\Filter\FilterState;
use Flarum\Search\AbstractRegexGambit;
use Flarum\Search\SearchState;
use Illuminate\Database\Query\Builder;
class StickyFilterGambit extends AbstractRegexGambit implements FilterInterface
{
protected function getGambitPattern(): string
{
return 'is:sticky';
}
protected function conditions(SearchState $search, array $matches, bool $negate): void
{
$this->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);
}
}

View File

@ -12,7 +12,6 @@ use Flarum\Api\Serializer\DiscussionSerializer;
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\Deleted;
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 @@ return [
->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),

View File

@ -0,0 +1 @@
export * from './src/admin';

View File

@ -0,0 +1 @@
export { default as default } from '../common/extend';

View File

@ -0,0 +1 @@
export { default as extend } from './extend';

View File

@ -0,0 +1,7 @@
import Extend from 'flarum/common/extenders';
import SubscriptionGambit from './query/discussions/SubscriptionGambit';
export default [
new Extend.Search() //
.gambit('discussions', SubscriptionGambit),
];

View File

@ -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<string, any> {
const type = matches[1] === 'follow' ? 'following' : 'ignoring';
return {
subscription: type,
};
}
filterKey(): string {
return 'subscription';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}is:${value}`;
}
}

View File

@ -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';
}
});
}

View File

@ -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),

View File

@ -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<DatabaseSearchState>
*/
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

View File

@ -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')

View File

@ -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\Event\Unsuspended;
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 @@ return [
(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'),

View File

@ -0,0 +1 @@
export { default as default } from '../common/extend';

View File

@ -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(
{

View File

@ -0,0 +1,7 @@
import Extend from 'flarum/common/extenders';
import SuspendedGambit from './query/users/SuspendedGambit';
export default [
new Extend.Search() //
.gambit('users', SuspendedGambit),
];

View File

@ -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<string, any> {
const key = (negate ? '-' : '') + 'suspended';
return {
[key]: true,
};
}
filterKey(): string {
return 'suspended';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}is:suspended`;
}
}

View File

@ -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<boolean>('canSuspend')
.attribute<Date, string | null | undefined>('suspendedUntil', Model.transformDate)
.attribute<Date | null | undefined, string | null | undefined>('suspendedUntil', Model.transformDate)
.attribute<string | null | undefined>('suspendReason')
.attribute<string | null | undefined>('suspendMessage'),
];

View File

@ -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<DatabaseSearchState>
*/
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

View File

@ -13,25 +13,25 @@ use Flarum\Api\Serializer\DiscussionSerializer;
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 @@ return [
->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),

View File

@ -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 {
<div className="TagsContent">
<div className="TagsContent-list">
<div className="container" key={this.forcedRefreshKey} oncreate={this.onListOnCreate.bind(this)}>
<div className="SettingsGroups">
<div className="TagGroup">
<label>{app.translator.trans('flarum-tags.admin.tags.primary_heading')}</label>
<FormSectionGroup>
<FormSection className="TagGroup" label={app.translator.trans('flarum-tags.admin.tags.primary_heading')}>
<ol className="TagList TagList--primary">{tags.filter((tag) => tag.position() !== null && !tag.isChild()).map(tagItem)}</ol>
<Button className="Button TagList-button" icon="fas fa-plus" onclick={() => app.modal.show(EditTagModal, { primary: true })}>
{app.translator.trans('flarum-tags.admin.tags.create_primary_tag_button')}
</Button>
</div>
</FormSection>
<div className="TagGroup TagGroup--secondary">
<label>{app.translator.trans('flarum-tags.admin.tags.secondary_heading')}</label>
<FormSection className="TagGroup TagGroup--secondary" label={app.translator.trans('flarum-tags.admin.tags.secondary_heading')}>
<ul className="TagList">
{tags
.filter((tag) => tag.position() === null)
@ -86,41 +85,44 @@ export default class TagsPage extends ExtensionPage {
<Button className="Button TagList-button" icon="fas fa-plus" onclick={() => app.modal.show(EditTagModal, { primary: false })}>
{app.translator.trans('flarum-tags.admin.tags.create_secondary_tag_button')}
</Button>
</div>
<Form label={app.translator.trans('flarum-tags.admin.tags.settings_heading')}>
<div className="Form-group">
<label>{app.translator.trans('flarum-tags.admin.tag_settings.required_primary_heading')}</label>
<div className="helpText">{app.translator.trans('flarum-tags.admin.tag_settings.required_primary_text')}</div>
<div className="TagSettings-rangeInput">
<input
className="FormControl"
type="number"
min="0"
value={minPrimaryTags()}
oninput={withAttr('value', this.setMinTags.bind(this, minPrimaryTags, maxPrimaryTags))}
/>
{app.translator.trans('flarum-tags.admin.tag_settings.range_separator_text')}
<input className="FormControl" type="number" min={minPrimaryTags()} bidi={maxPrimaryTags} />
</FormSection>
<FormSection label={app.translator.trans('flarum-tags.admin.tags.settings_heading')}>
<Form>
<div className="Form-group">
<label>{app.translator.trans('flarum-tags.admin.tag_settings.required_primary_heading')}</label>
<div className="helpText">{app.translator.trans('flarum-tags.admin.tag_settings.required_primary_text')}</div>
<div className="TagSettings-rangeInput">
<input
className="FormControl"
type="number"
min="0"
value={minPrimaryTags()}
oninput={withAttr('value', this.setMinTags.bind(this, minPrimaryTags, maxPrimaryTags))}
/>
{app.translator.trans('flarum-tags.admin.tag_settings.range_separator_text')}
<input className="FormControl" type="number" min={minPrimaryTags()} bidi={maxPrimaryTags} />
</div>
</div>
</div>
<div className="Form-group">
<label>{app.translator.trans('flarum-tags.admin.tag_settings.required_secondary_heading')}</label>
<div className="helpText">{app.translator.trans('flarum-tags.admin.tag_settings.required_secondary_text')}</div>
<div className="TagSettings-rangeInput">
<input
className="FormControl"
type="number"
min="0"
value={minSecondaryTags()}
oninput={withAttr('value', this.setMinTags.bind(this, minSecondaryTags, maxSecondaryTags))}
/>
{app.translator.trans('flarum-tags.admin.tag_settings.range_separator_text')}
<input className="FormControl" type="number" min={minSecondaryTags()} bidi={maxSecondaryTags} />
<div className="Form-group">
<label>{app.translator.trans('flarum-tags.admin.tag_settings.required_secondary_heading')}</label>
<div className="helpText">{app.translator.trans('flarum-tags.admin.tag_settings.required_secondary_text')}</div>
<div className="TagSettings-rangeInput">
<input
className="FormControl"
type="number"
min="0"
value={minSecondaryTags()}
oninput={withAttr('value', this.setMinTags.bind(this, minSecondaryTags, maxSecondaryTags))}
/>
{app.translator.trans('flarum-tags.admin.tag_settings.range_separator_text')}
<input className="FormControl" type="number" min={minSecondaryTags()} bidi={maxSecondaryTags} />
</div>
</div>
</div>
<div className="Form-group Form-controls">{this.submitButton()}</div>
</Form>
</div>
<div className="Form-group Form-controls">{this.submitButton()}</div>
</Form>
</FormSection>
</FormSectionGroup>
<div className="TagsContent-footer">
<p>{app.translator.trans('flarum-tags.admin.tags.about_tags_text')}</p>
</div>

View File

@ -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),
];

View File

@ -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<string, any> {
const key = (negate ? '-' : '') + 'tag';
return {
[key]: matches[1].split(','),
};
}
filterKey(): string {
return 'tag';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}tag:${value}`;
}
}

View File

@ -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;
}
});
}

View File

@ -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;
.TagList-button {
background: none;
border: 1px dashed var(--control-bg);
height: 40px;
margin: auto auto 0 0;
}
>label {
margin-bottom: 10px;
}
.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;
}
}
}
}
.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);
&:first-child {
margin-left: 0;
}
}
}
.TagGroup {
ol {
> li:not(:first-child) {
margin-top: 8px;
}
}
}

View File

@ -12,9 +12,10 @@ namespace Flarum\Tags\Api\Controller;
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 @@ class ListTagsController extends AbstractListController
}
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(

View File

@ -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<DatabaseSearchState>
*/
class PostTagFilter implements FilterInterface
{
use ValidateFilterTrait;
@ -22,11 +26,11 @@ class PostTagFilter implements FilterInterface
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);
}

View File

@ -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<DatabaseSearchState>
*/
class TagFilter implements FilterInterface
{
use ValidateFilterTrait;
@ -29,24 +31,14 @@ class TagFilterGambit extends AbstractRegexGambit implements FilterInterface
) {
}
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

View File

@ -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<DatabaseSearchState>
*/
class FulltextFilter extends AbstractFulltextFilter
{
public function __construct(
protected TagRepository $tags
@ -30,14 +34,12 @@ class FulltextGambit implements GambitInterface
->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;
}
}

View File

@ -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'));

View File

@ -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.*');
}
}

View File

@ -49,9 +49,9 @@ class ListWithFulltextSearchTest extends TestCase
])
);
$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'));
}

View File

@ -40,7 +40,9 @@ export interface AdminApplicationData extends ApplicationData {
modelStatistics: Record<string, { total: number }>;
displayNameDrivers: string[];
slugDrivers: Record<string, string[]>;
searchDrivers: Record<string, string[]>;
permissions: Record<string, string[]>;
advancedPageEmpty: boolean;
}
export default class AdminApplication extends Application {

View File

@ -110,6 +110,16 @@ export default class AdminNav extends Component {
50
);
if (app.data.settings.show_advanced_settings && !app.data.advancedPageEmpty) {
items.add(
'advanced',
<LinkButton href={app.route('advanced')} icon="fas fa-cog" title={app.translator.trans('core.admin.nav.advanced_title')}>
{app.translator.trans('core.admin.nav.advanced_button')}
</LinkButton>,
40
);
}
items.add(
'search',
<div className="Search-input">

View File

@ -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<CustomAttrs extends IPageAttrs = IPageAt
return saveSettings(this.dirty()).then(this.onsaved.bind(this));
}
modelLocale(): Record<string, string> {
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')),
};
}
}

View File

@ -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<CustomAttrs extends IPageAttrs = IPageAttrs> extends AdminPage<CustomAttrs> {
searchDriverOptions: Record<string, Record<string, string>> = {};
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
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 [
<Form className="AdvancedPage-container">
<FormSectionGroup>
<FormSection label={app.translator.trans('core.admin.advanced.search.section_label')}>
<Form>
{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;
})}
</Form>
</FormSection>
</FormSectionGroup>
<div className="Form-group Form-controls">{this.submitButton()}</div>
</Form>,
];
}
driverLocale(): Record<string, Record<string, string>> {
return {
search: {
default: extractText(app.translator.trans('core.admin.advanced.search.driver_options.default')),
},
};
}
}

View File

@ -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<string, string>;
slug: Record<string, Record<string, string>>;
};
export default class BasicsPage<CustomAttrs extends IPageAttrs = IPageAttrs> extends AdminPage<CustomAttrs> {
localeOptions: Record<string, string> = {};
@ -20,15 +25,17 @@ export default class BasicsPage<CustomAttrs extends IPageAttrs = IPageAttrs> 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<CustomAttrs extends IPageAttrs = IPageAttrs> 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<CustomAttrs extends IPageAttrs = IPageAttrs> 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')),
},
},
};
}
}

View File

@ -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<CustomAttrs extends IFormSectionGroupAttrs = IFormSectionGroupAttrs> extends Component<CustomAttrs> {
view(vnode: Mithril.Vnode<CustomAttrs, this>) {
const { className, ...attrs } = this.attrs;
return (
<div className={classList('FormSectionGroup', className)} {...attrs}>
{vnode.children}
</div>
);
}
}
export interface IFormSectionAttrs extends ComponentAttrs {
label: any;
}
export class FormSection<CustomAttrs extends IFormSectionAttrs = IFormSectionAttrs> extends Component<CustomAttrs> {
view(vnode: Mithril.Vnode<CustomAttrs, this>) {
const { className, ...attrs } = this.attrs;
return (
<div className={classList('FormSection', className)} {...attrs}>
<label>{this.attrs.label}</label>
<div className="FormSection-body">{vnode.children}</div>
</div>
);
}
}

View File

@ -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 {
<Button onclick={this.handleClearCache.bind(this)}>{app.translator.trans('core.admin.dashboard.clear_cache_button')}</Button>
);
if (!app.data.advancedPageEmpty) {
items.add(
'toggleAdvancedPage',
<Button
onclick={() => {
saveSettings({
show_advanced_settings: !app.data.settings.show_advanced_settings,
});
if (app.data.settings.show_advanced_settings) {
m.route.set(app.route('advanced'));
}
}}
>
{app.translator.trans('core.admin.dashboard.toggle_advanced_page_button')}
</Button>
);
}
return items;
}

View File

@ -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 },
};
}

View File

@ -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<string, Array<new () => IGambit>> = {
discussions: [AuthorGambit, CreatedGambit, HiddenGambit, UnreadGambit],
users: [EmailGambit, GroupGambit],
};
public apply(type: string, filter: Record<string, any>): Record<string, any> {
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, any>): 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;
}
}

View File

@ -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<string, string>;
| Record<string, any>;
page?: {
near?: number;
offset?: number;
@ -88,6 +89,12 @@ export default class Store {
*/
models: Record<string, { new (): Model }>;
/**
* The gambit manager that will convert search query gambits
* into API filters.
*/
gambits = new GambitManager();
constructor(models: Record<string, { new (): Model }>) {
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<M extends Array<infer _T> ? ApiPayloadPlural : ApiPayloadSingle>({
method: 'GET',

View File

@ -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<string, Array<new () => 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);
}
}
}
}

View File

@ -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;

View File

@ -0,0 +1,6 @@
export default interface IGambit {
pattern(): string;
toFilter(matches: string[], negate: boolean): Record<string, any>;
filterKey(): string;
fromFilter(value: string, negate: boolean): string;
}

View File

@ -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<string, any> {
const key = (negate ? '-' : '') + 'author';
return {
[key]: matches[1].split(','),
};
}
filterKey(): string {
return 'author';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}author:${value}`;
}
}

View File

@ -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<string, any> {
const key = (negate ? '-' : '') + 'created';
return {
[key]: matches[1],
};
}
filterKey(): string {
return 'created';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}created:${value}`;
}
}

View File

@ -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<string, any> {
const key = (negate ? '-' : '') + 'hidden';
return {
[key]: true,
};
}
filterKey(): string {
return 'hidden';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}is:hidden`;
}
}

View File

@ -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<string, any> {
const key = (negate ? '-' : '') + 'unread';
return {
[key]: true,
};
}
filterKey(): string {
return 'unread';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}is:unread`;
}
}

View File

@ -0,0 +1,23 @@
import IGambit from '../IGambit';
export default class EmailGambit implements IGambit {
pattern(): string {
return 'email:(.+)';
}
toFilter(matches: string[], negate: boolean): Record<string, any> {
const key = (negate ? '-' : '') + 'email';
return {
[key]: matches[1],
};
}
filterKey(): string {
return 'email';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}email:${value}`;
}
}

View File

@ -0,0 +1,23 @@
import IGambit from '../IGambit';
export default class GroupGambit implements IGambit {
pattern(): string {
return 'group:(.+)';
}
toFilter(matches: string[], negate: boolean): Record<string, any> {
const key = (negate ? '-' : '') + 'group';
return {
[key]: matches[1].split(','),
};
}
filterKey(): string {
return 'group';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}group:${value}`;
}
}

View File

@ -48,10 +48,15 @@ export default class DiscussionsSearchSource implements SearchSource {
);
}) as Array<Mithril.Vnode>;
const filter = app.store.gambits.apply('discussions', { q: query });
const q = filter.q || null;
delete filter.q;
return [
<li className="Dropdown-header">{app.translator.trans('core.forum.search.discussions_heading')}</li>,
<li>
<LinkButton icon="fas fa-search" href={app.route('index', { q: query })}>
<LinkButton icon="fas fa-search" href={app.route('index', { q, filter })}>
{app.translator.trans('core.forum.search.all_discussions_button', { query })}
</LinkButton>
</li>,

View File

@ -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;
}

View File

@ -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'],
});
});

View File

@ -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";

View File

@ -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);
}

View File

@ -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

View File

@ -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 @@ class AdminPayload
$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 @@ class AdminPayload
]
];
}
protected function getSearchDrivers(): array
{
$searchDriversPerModel = [];
foreach ($this->container->make('flarum.search.drivers') as $driverClass => $searcherClasses) {
/** @var array<class-string<AbstractModel>, class-string<SearcherInterface>> $searcherClasses */
foreach ($searcherClasses as $modelClass => $searcherClass) {
/** @var class-string<AbstractDriver> $driverClass */
$searchDriversPerModel[$modelClass][] = $driverClass::name();
}
}
return $searchDriversPerModel;
}
protected function checkAdvancedPageEmpty(): bool
{
return count($this->container->make('flarum.search.drivers')) === 1;
}
}

View File

@ -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 @@ class ListAccessTokensController extends AbstractListController
$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'),

View File

@ -11,11 +11,10 @@ namespace Flarum\Api\Controller;
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 @@ class ListDiscussionsController extends AbstractListController
$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,

View File

@ -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 @@ class ListGroupsController extends AbstractListController
$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'),

View File

@ -12,9 +12,10 @@ namespace Flarum\Api\Controller;
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 @@ class ListPostsController extends AbstractListController
$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'),

View File

@ -12,9 +12,9 @@ namespace Flarum\Api\Controller;
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 @@ class ListUsersController extends AbstractListController
$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'),

View File

@ -237,4 +237,14 @@ abstract class AbstractModel extends Eloquent
{
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();
}
}

View File

@ -13,6 +13,9 @@ use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
/**
* @method static Builder whereVisibleTo(User $user)
*/
trait ScopeVisibilityTrait
{
/**

View File

@ -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 @@ class Discussion extends AbstractModel
$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 @@ class Discussion extends AbstractModel
$this->hidden_user_id = null;
$this->raise(new Restored($this));
$this->saved(function (self $model) {
if ($model === $this) {
$model->fireModelEvent('restored', false);
}
});
}
return $this;

View File

@ -1,28 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Discussion\Filter;
use Flarum\Discussion\DiscussionRepository;
use Flarum\Filter\AbstractFilterer;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
class DiscussionFilterer extends AbstractFilterer
{
public function __construct(protected DiscussionRepository $discussions, array $filters, array $filterMutators)
{
parent::__construct($filters, $filterMutators);
}
protected function getQuery(User $actor): Builder
{
return $this->discussions->query()->select('discussions.*')->whereVisibleTo($actor);
}
}

View File

@ -1,60 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Discussion\Query;
use Flarum\Filter\FilterInterface;
use Flarum\Filter\FilterState;
use Flarum\Filter\ValidateFilterTrait;
use Flarum\Search\AbstractRegexGambit;
use Flarum\Search\SearchState;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
class CreatedFilterGambit extends AbstractRegexGambit implements FilterInterface
{
use ValidateFilterTrait;
public function getGambitPattern(): string
{
return 'created:(\d{4}\-\d\d\-\d\d)(\.\.(\d{4}\-\d\d\-\d\d))?';
}
protected function conditions(SearchState $search, array $matches, bool $negate): void
{
$this->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);
}
}
}

View File

@ -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.*');
}
}

View File

@ -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<DatabaseSearchState>
*/
class AuthorFilter implements FilterInterface
{
use ValidateFilterTrait;
@ -26,24 +28,14 @@ class AuthorFilterGambit extends AbstractRegexGambit implements FilterInterface
) {
}
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

View File

@ -0,0 +1,55 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Discussion\Search\Filter;
use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\SearchState;
use Flarum\Search\ValidateFilterTrait;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
/**
* @implements FilterInterface<DatabaseSearchState>
*/
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);
}
}
}

View File

@ -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<DatabaseSearchState>
*/
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

View File

@ -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<DatabaseSearchState>
*/
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 @@ class UnreadFilterGambit extends AbstractRegexGambit implements FilterInterface
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

View File

@ -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<DatabaseSearchState>
*/
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 @@ class FulltextGambit implements GambitInterface
->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;
}
}

View File

@ -1,82 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Extend;
use Flarum\Extension\Extension;
use Flarum\Filter\AbstractFilterer;
use Flarum\Filter\FilterState;
use Flarum\Query\QueryCriteria;
use Illuminate\Contracts\Container\Container;
class Filter implements ExtenderInterface
{
private array $filters = [];
private array $filterMutators = [];
/**
* @param class-string<AbstractFilterer> $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;
});
}
}

Some files were not shown because too many files have changed in this diff Show More