1
0
mirror of https://github.com/flarum/core.git synced 2025-08-04 23:47:32 +02:00

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
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\Notification\PostLikedBlueprint;
use Flarum\Likes\Query\LikedByFilter; use Flarum\Likes\Query\LikedByFilter;
use Flarum\Likes\Query\LikedFilter; use Flarum\Likes\Query\LikedFilter;
use Flarum\Post\Filter\PostFilterer; use Flarum\Post\Filter\PostSearcher;
use Flarum\Post\Post; use Flarum\Post\Post;
use Flarum\User\Filter\UserFilterer; use Flarum\Search\Database\DatabaseSearchDriver;
use Flarum\User\Search\UserSearcher;
use Flarum\User\User; use Flarum\User\User;
return [ return [
@@ -76,11 +77,9 @@ return [
->listen(PostWasUnliked::class, Listener\SendNotificationWhenPostIsUnliked::class) ->listen(PostWasUnliked::class, Listener\SendNotificationWhenPostIsUnliked::class)
->subscribe(Listener\SaveLikesToDatabase::class), ->subscribe(Listener\SaveLikesToDatabase::class),
(new Extend\Filter(PostFilterer::class)) (new Extend\SearchDriver(DatabaseSearchDriver::class))
->addFilter(LikedByFilter::class), ->addFilter(PostSearcher::class, LikedByFilter::class)
->addFilter(UserSearcher::class, LikedFilter::class),
(new Extend\Filter(UserFilterer::class))
->addFilter(LikedFilter::class),
(new Extend\Settings()) (new Extend\Settings())
->default('flarum-likes.like_own_post', true), ->default('flarum-likes.like_own_post', true),

View File

@@ -9,10 +9,14 @@
namespace Flarum\Likes\Query; namespace Flarum\Likes\Query;
use Flarum\Filter\FilterInterface; use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Filter\FilterState; use Flarum\Search\Filter\FilterInterface;
use Flarum\Filter\ValidateFilterTrait; use Flarum\Search\SearchState;
use Flarum\Search\ValidateFilterTrait;
/**
* @implements FilterInterface<DatabaseSearchState>
*/
class LikedByFilter implements FilterInterface class LikedByFilter implements FilterInterface
{ {
use ValidateFilterTrait; use ValidateFilterTrait;
@@ -22,11 +26,11 @@ class LikedByFilter implements FilterInterface
return 'likedBy'; 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() ->getQuery()
->whereIn('id', function ($query) use ($likedId, $negate) { ->whereIn('id', function ($query) use ($likedId, $negate) {
$query->select('post_id') $query->select('post_id')

View File

@@ -9,10 +9,14 @@
namespace Flarum\Likes\Query; namespace Flarum\Likes\Query;
use Flarum\Filter\FilterInterface; use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Filter\FilterState; use Flarum\Search\Filter\FilterInterface;
use Flarum\Filter\ValidateFilterTrait; use Flarum\Search\SearchState;
use Flarum\Search\ValidateFilterTrait;
/**
* @implements FilterInterface<DatabaseSearchState>
*/
class LikedFilter implements FilterInterface class LikedFilter implements FilterInterface
{ {
use ValidateFilterTrait; use ValidateFilterTrait;
@@ -22,11 +26,11 @@ class LikedFilter implements FilterInterface
return 'liked'; 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() ->getQuery()
->whereIn('id', function ($query) use ($likedId) { ->whereIn('id', function ($query) use ($likedId) {
$query->select('user_id') $query->select('user_id')

View File

@@ -11,16 +11,16 @@ use Flarum\Api\Serializer\BasicDiscussionSerializer;
use Flarum\Api\Serializer\DiscussionSerializer; use Flarum\Api\Serializer\DiscussionSerializer;
use Flarum\Discussion\Discussion; use Flarum\Discussion\Discussion;
use Flarum\Discussion\Event\Saving; use Flarum\Discussion\Event\Saving;
use Flarum\Discussion\Filter\DiscussionFilterer;
use Flarum\Discussion\Search\DiscussionSearcher; use Flarum\Discussion\Search\DiscussionSearcher;
use Flarum\Extend; use Flarum\Extend;
use Flarum\Lock\Access; use Flarum\Lock\Access;
use Flarum\Lock\Event\DiscussionWasLocked; use Flarum\Lock\Event\DiscussionWasLocked;
use Flarum\Lock\Event\DiscussionWasUnlocked; use Flarum\Lock\Event\DiscussionWasUnlocked;
use Flarum\Lock\Filter\LockedFilter;
use Flarum\Lock\Listener; use Flarum\Lock\Listener;
use Flarum\Lock\Notification\DiscussionLockedBlueprint; use Flarum\Lock\Notification\DiscussionLockedBlueprint;
use Flarum\Lock\Post\DiscussionLockedPost; use Flarum\Lock\Post\DiscussionLockedPost;
use Flarum\Lock\Query\LockedFilterGambit; use Flarum\Search\Database\DatabaseSearchDriver;
return [ return [
(new Extend\Frontend('forum')) (new Extend\Frontend('forum'))
@@ -57,9 +57,6 @@ return [
(new Extend\Policy()) (new Extend\Policy())
->modelPolicy(Discussion::class, Access\DiscussionPolicy::class), ->modelPolicy(Discussion::class, Access\DiscussionPolicy::class),
(new Extend\Filter(DiscussionFilterer::class)) (new Extend\SearchDriver(DatabaseSearchDriver::class))
->addFilter(LockedFilterGambit::class), ->addFilter(DiscussionSearcher::class, LockedFilter::class),
(new Extend\SimpleFlarumSearch(DiscussionSearcher::class))
->addGambit(LockedFilterGambit::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'; import app from 'flarum/admin/app';
export { default as extend } from './extend';
app.initializers.add('lock', () => { app.initializers.add('lock', () => {
app.extensionData.for('flarum-lock').registerPermission( 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 Discussion from 'flarum/common/models/Discussion';
import DiscussionLockedPost from './components/DiscussionLockedPost'; import DiscussionLockedPost from './components/DiscussionLockedPost';
import commonExtend from '../common/extend';
export default [ export default [
...commonExtend,
new Extend.PostTypes() // new Extend.PostTypes() //
.add('discussionLocked', DiscussionLockedPost), .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\Posted;
use Flarum\Post\Event\Restored; use Flarum\Post\Event\Restored;
use Flarum\Post\Event\Revised; use Flarum\Post\Event\Revised;
use Flarum\Post\Filter\PostFilterer; use Flarum\Post\Filter\PostSearcher;
use Flarum\Post\Post; use Flarum\Post\Post;
use Flarum\Search\Database\DatabaseSearchDriver;
use Flarum\Tags\Api\Serializer\TagSerializer; use Flarum\Tags\Api\Serializer\TagSerializer;
use Flarum\User\User; use Flarum\User\User;
@@ -114,9 +115,9 @@ return [
->listen(Hidden::class, Listener\UpdateMentionsMetadataWhenInvisible::class) ->listen(Hidden::class, Listener\UpdateMentionsMetadataWhenInvisible::class)
->listen(Deleted::class, Listener\UpdateMentionsMetadataWhenInvisible::class), ->listen(Deleted::class, Listener\UpdateMentionsMetadataWhenInvisible::class),
(new Extend\Filter(PostFilterer::class)) (new Extend\SearchDriver(DatabaseSearchDriver::class))
->addFilter(Filter\MentionedFilter::class) ->addFilter(PostSearcher::class, Filter\MentionedFilter::class)
->addFilter(Filter\MentionedPostFilter::class), ->addFilter(PostSearcher::class, Filter\MentionedPostFilter::class),
(new Extend\ApiSerializer(CurrentUserSerializer::class)) (new Extend\ApiSerializer(CurrentUserSerializer::class))
->attribute('canMentionGroups', function (CurrentUserSerializer $serializer, User $user): bool { ->attribute('canMentionGroups', function (CurrentUserSerializer $serializer, User $user): bool {

View File

@@ -9,10 +9,14 @@
namespace Flarum\Mentions\Filter; namespace Flarum\Mentions\Filter;
use Flarum\Filter\FilterInterface; use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Filter\FilterState; use Flarum\Search\Filter\FilterInterface;
use Flarum\Filter\ValidateFilterTrait; use Flarum\Search\SearchState;
use Flarum\Search\ValidateFilterTrait;
/**
* @implements FilterInterface<DatabaseSearchState>
*/
class MentionedFilter implements FilterInterface class MentionedFilter implements FilterInterface
{ {
use ValidateFilterTrait; use ValidateFilterTrait;
@@ -22,11 +26,11 @@ class MentionedFilter implements FilterInterface
return 'mentioned'; 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() ->getQuery()
->join('post_mentions_user', 'posts.id', '=', 'post_mentions_user.post_id') ->join('post_mentions_user', 'posts.id', '=', 'post_mentions_user.post_id')
->where('post_mentions_user.mentions_user_id', $negate ? '!=' : '=', $mentionedId); ->where('post_mentions_user.mentions_user_id', $negate ? '!=' : '=', $mentionedId);

View File

@@ -9,9 +9,13 @@
namespace Flarum\Mentions\Filter; namespace Flarum\Mentions\Filter;
use Flarum\Filter\FilterInterface; use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Filter\FilterState; use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\SearchState;
/**
* @implements FilterInterface<DatabaseSearchState>
*/
class MentionedPostFilter implements FilterInterface class MentionedPostFilter implements FilterInterface
{ {
public function getFilterKey(): string public function getFilterKey(): string
@@ -19,11 +23,11 @@ class MentionedPostFilter implements FilterInterface
return 'mentionedPost'; 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() ->getQuery()
->join('post_mentions_post', 'posts.id', '=', 'post_mentions_post.post_id') ->join('post_mentions_post', 'posts.id', '=', 'post_mentions_post.post_id')
->where('post_mentions_post.mentions_post_id', $negate ? '!=' : '=', $mentionedId); ->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\Api\Serializer\UserSerializer;
use Flarum\Extend; use Flarum\Extend;
use Flarum\Nicknames\Access\UserPolicy; use Flarum\Nicknames\Access\UserPolicy;
use Flarum\Search\Database\DatabaseSearchDriver;
use Flarum\User\Event\Saving; use Flarum\User\Event\Saving;
use Flarum\User\Search\UserSearcher; use Flarum\User\Search\UserSearcher;
use Flarum\User\User; use Flarum\User\User;
@@ -52,8 +53,8 @@ return [
(new Extend\Validator(UserValidator::class)) (new Extend\Validator(UserValidator::class))
->configure(AddNicknameValidation::class), ->configure(AddNicknameValidation::class),
(new Extend\SimpleFlarumSearch(UserSearcher::class)) (new Extend\SearchDriver(DatabaseSearchDriver::class))
->setFullTextGambit(NicknameFullTextGambit::class), ->setFulltext(UserSearcher::class, NicknameFullTextFilter::class),
(new Extend\Policy()) (new Extend\Policy())
->modelPolicy(User::class, UserPolicy::class), ->modelPolicy(User::class, UserPolicy::class),

View File

@@ -1,6 +1,9 @@
import app from 'flarum/admin/app'; import app from 'flarum/admin/app';
import Alert from 'flarum/common/components/Alert'; import Alert from 'flarum/common/components/Alert';
import Link from 'flarum/common/components/Link'; 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.initializers.add('flarum/nicknames', () => {
app.extensionData app.extensionData
@@ -55,4 +58,8 @@ app.initializers.add('flarum/nicknames', () => {
}, },
'start' '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: flarum-nicknames:
admin: admin:
basics:
display_name_driver_options:
nickname: Nickname
permissions: permissions:
edit_own_nickname_label: Edit own nickname edit_own_nickname_label: Edit own nickname
settings: settings:

View File

@@ -9,19 +9,16 @@
namespace Flarum\Nicknames; namespace Flarum\Nicknames;
/* use Flarum\Search\AbstractFulltextFilter;
* This file is part of Flarum. use Flarum\Search\Database\DatabaseSearchState;
*
* 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\SearchState; use Flarum\Search\SearchState;
use Flarum\User\UserRepository; use Flarum\User\UserRepository;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
class NicknameFullTextGambit implements GambitInterface /**
* @extends AbstractFulltextFilter<DatabaseSearchState>
*/
class NicknameFullTextFilter extends AbstractFulltextFilter
{ {
public function __construct( public function __construct(
protected UserRepository $users protected UserRepository $users
@@ -37,14 +34,12 @@ class NicknameFullTextGambit implements GambitInterface
->orWhere('nickname', 'like', "{$searchValue}%"); ->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( ->whereIn(
'id', '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\Api\Serializer\DiscussionSerializer;
use Flarum\Discussion\Discussion; use Flarum\Discussion\Discussion;
use Flarum\Discussion\Event\Saving; use Flarum\Discussion\Event\Saving;
use Flarum\Discussion\Filter\DiscussionFilterer;
use Flarum\Discussion\Search\DiscussionSearcher; use Flarum\Discussion\Search\DiscussionSearcher;
use Flarum\Extend; use Flarum\Extend;
use Flarum\Search\Database\DatabaseSearchDriver;
use Flarum\Sticky\Event\DiscussionWasStickied; use Flarum\Sticky\Event\DiscussionWasStickied;
use Flarum\Sticky\Event\DiscussionWasUnstickied; use Flarum\Sticky\Event\DiscussionWasUnstickied;
use Flarum\Sticky\Listener; use Flarum\Sticky\Listener;
use Flarum\Sticky\Listener\SaveStickyToDatabase; use Flarum\Sticky\Listener\SaveStickyToDatabase;
use Flarum\Sticky\PinStickiedDiscussionsToTop; use Flarum\Sticky\PinStickiedDiscussionsToTop;
use Flarum\Sticky\Post\DiscussionStickiedPost; use Flarum\Sticky\Post\DiscussionStickiedPost;
use Flarum\Sticky\Query\StickyFilterGambit; use Flarum\Sticky\Query\StickyFilter;
return [ return [
(new Extend\Frontend('forum')) (new Extend\Frontend('forum'))
@@ -54,10 +54,7 @@ return [
->listen(DiscussionWasStickied::class, [Listener\CreatePostWhenDiscussionIsStickied::class, 'whenDiscussionWasStickied']) ->listen(DiscussionWasStickied::class, [Listener\CreatePostWhenDiscussionIsStickied::class, 'whenDiscussionWasStickied'])
->listen(DiscussionWasUnstickied::class, [Listener\CreatePostWhenDiscussionIsStickied::class, 'whenDiscussionWasUnstickied']), ->listen(DiscussionWasUnstickied::class, [Listener\CreatePostWhenDiscussionIsStickied::class, 'whenDiscussionWasUnstickied']),
(new Extend\Filter(DiscussionFilterer::class)) (new Extend\SearchDriver(DatabaseSearchDriver::class))
->addFilter(StickyFilterGambit::class) ->addFilter(DiscussionSearcher::class, StickyFilter::class)
->addFilterMutator(PinStickiedDiscussionsToTop::class), ->addMutator(DiscussionSearcher::class, PinStickiedDiscussionsToTop::class),
(new Extend\SimpleFlarumSearch(DiscussionSearcher::class))
->addGambit(StickyFilterGambit::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'; import app from 'flarum/admin/app';
export { default as extend } from './extend';
app.initializers.add('flarum-sticky', () => { app.initializers.add('flarum-sticky', () => {
app.extensionData.for('flarum-sticky').registerPermission( 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 Discussion from 'flarum/common/models/Discussion';
import DiscussionStickiedPost from './components/DiscussionStickiedPost'; import DiscussionStickiedPost from './components/DiscussionStickiedPost';
import commonExtend from '../common/extend';
export default [ export default [
...commonExtend,
new Extend.PostTypes() // new Extend.PostTypes() //
.add('discussionStickied', DiscussionStickiedPost), .add('discussionStickied', DiscussionStickiedPost),

View File

@@ -9,23 +9,23 @@
namespace Flarum\Sticky; namespace Flarum\Sticky;
use Flarum\Filter\FilterState; use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Query\QueryCriteria; use Flarum\Search\SearchCriteria;
use Flarum\Tags\Query\TagFilterGambit; use Flarum\Tags\Search\Filter\TagFilter;
class PinStickiedDiscussionsToTop class PinStickiedDiscussionsToTop
{ {
public function __invoke(FilterState $filterState, QueryCriteria $criteria): void public function __invoke(DatabaseSearchState $state, SearchCriteria $criteria): void
{ {
if ($criteria->sortIsDefault) { if ($criteria->sortIsDefault && ! $state->isFulltextSearch()) {
$query = $filterState->getQuery(); $query = $state->getQuery();
// If we are viewing a specific tag, then pin all stickied // If we are viewing a specific tag, then pin all stickied
// discussions to the top no matter what. // discussions to the top no matter what.
$filters = $filterState->getActiveFilters(); $filters = $state->getActiveFilters();
if ($count = count($filters)) { if ($count = count($filters)) {
if ($count === 1 && $filters[0] instanceof TagFilterGambit) { if ($count === 1 && $filters[0] instanceof TagFilter) {
if (! is_array($query->orders)) { if (! is_array($query->orders)) {
$query->orders = []; $query->orders = [];
} }
@@ -51,14 +51,14 @@ class PinStickiedDiscussionsToTop
->selectRaw('1') ->selectRaw('1')
->from('discussion_user as sticky') ->from('discussion_user as sticky')
->whereColumn('sticky.discussion_id', 'id') ->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'); ->whereColumn('sticky.last_read_post_number', '>=', 'last_post_number');
// Add the bindings manually (rather than as the second // Add the bindings manually (rather than as the second
// argument in orderByRaw) for now due to a bug in Laravel which // argument in orderByRaw) for now due to a bug in Laravel which
// would add the bindings in the wrong order. // would add the bindings in the wrong order.
$query->orderByRaw('is_sticky and not exists ('.$read->toSql().') and last_posted_at > ? desc') $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->unionOrders = array_merge($query->unionOrders, $query->orders);
$query->unionLimit = $query->limit; $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\Approval\Event\PostWasApproved;
use Flarum\Discussion\Discussion; use Flarum\Discussion\Discussion;
use Flarum\Discussion\Event\Saving; use Flarum\Discussion\Event\Saving;
use Flarum\Discussion\Filter\DiscussionFilterer;
use Flarum\Discussion\Search\DiscussionSearcher; use Flarum\Discussion\Search\DiscussionSearcher;
use Flarum\Discussion\UserState; use Flarum\Discussion\UserState;
use Flarum\Extend; use Flarum\Extend;
@@ -20,14 +19,18 @@ use Flarum\Post\Event\Deleted;
use Flarum\Post\Event\Hidden; use Flarum\Post\Event\Hidden;
use Flarum\Post\Event\Posted; use Flarum\Post\Event\Posted;
use Flarum\Post\Event\Restored; use Flarum\Post\Event\Restored;
use Flarum\Search\Database\DatabaseSearchDriver;
use Flarum\Subscriptions\Filter\SubscriptionFilter;
use Flarum\Subscriptions\HideIgnoredFromAllDiscussionsPage; use Flarum\Subscriptions\HideIgnoredFromAllDiscussionsPage;
use Flarum\Subscriptions\Listener; use Flarum\Subscriptions\Listener;
use Flarum\Subscriptions\Notification\FilterVisiblePostsBeforeSending; use Flarum\Subscriptions\Notification\FilterVisiblePostsBeforeSending;
use Flarum\Subscriptions\Notification\NewPostBlueprint; use Flarum\Subscriptions\Notification\NewPostBlueprint;
use Flarum\Subscriptions\Query\SubscriptionFilterGambit;
use Flarum\User\User; use Flarum\User\User;
return [ return [
(new Extend\Frontend('admin'))
->js(__DIR__.'/js/dist/admin.js'),
(new Extend\Frontend('forum')) (new Extend\Frontend('forum'))
->js(__DIR__.'/js/dist/forum.js') ->js(__DIR__.'/js/dist/forum.js')
->css(__DIR__.'/less/forum.less') ->css(__DIR__.'/less/forum.less')
@@ -67,12 +70,9 @@ return [
->listen(Deleted::class, Listener\DeleteNotificationWhenPostIsHiddenOrDeleted::class) ->listen(Deleted::class, Listener\DeleteNotificationWhenPostIsHiddenOrDeleted::class)
->listen(Posted::class, Listener\FollowAfterReply::class), ->listen(Posted::class, Listener\FollowAfterReply::class),
(new Extend\Filter(DiscussionFilterer::class)) (new Extend\SearchDriver(DatabaseSearchDriver::class))
->addFilter(SubscriptionFilterGambit::class) ->addFilter(DiscussionSearcher::class, SubscriptionFilter::class)
->addFilterMutator(HideIgnoredFromAllDiscussionsPage::class), ->addMutator(DiscussionSearcher::class, HideIgnoredFromAllDiscussionsPage::class),
(new Extend\SimpleFlarumSearch(DiscussionSearcher::class))
->addGambit(SubscriptionFilterGambit::class),
(new Extend\User()) (new Extend\User())
->registerPreference('flarum-subscriptions.notify_for_all_posts', 'boolval', false), ->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) { extend(DiscussionListState.prototype, 'requestParams', function (params) {
if (this.params.onFollowing) { if (this.params.onFollowing) {
params.filter ||= {}; params.filter ||= {};
params.filter.subscription = 'following';
if (params.filter.q) {
params.filter.q += ' is:following';
} else {
params.filter.subscription = 'following';
}
} }
}); });
} }

View File

@@ -2,7 +2,11 @@ import Extend from 'flarum/common/extenders';
import IndexPage from 'flarum/forum/components/IndexPage'; import IndexPage from 'flarum/forum/components/IndexPage';
import Discussion from 'flarum/common/models/Discussion'; import Discussion from 'flarum/common/models/Discussion';
import commonExtend from '../common/extend';
export default [ export default [
...commonExtend,
new Extend.Routes() // new Extend.Routes() //
.add('following', '/following', IndexPage), .add('following', '/following', IndexPage),

View File

@@ -7,42 +7,34 @@
* LICENSE file that was distributed with this source code. * LICENSE file that was distributed with this source code.
*/ */
namespace Flarum\Subscriptions\Query; namespace Flarum\Subscriptions\Filter;
use Flarum\Filter\FilterInterface; use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Filter\FilterState; use Flarum\Search\Filter\FilterInterface;
use Flarum\Filter\ValidateFilterTrait;
use Flarum\Search\AbstractRegexGambit;
use Flarum\Search\SearchState; use Flarum\Search\SearchState;
use Flarum\Search\ValidateFilterTrait;
use Flarum\User\User; use Flarum\User\User;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
class SubscriptionFilterGambit extends AbstractRegexGambit implements FilterInterface /**
* @implements FilterInterface<DatabaseSearchState>
*/
class SubscriptionFilter implements FilterInterface
{ {
use ValidateFilterTrait; 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 public function getFilterKey(): string
{ {
return 'subscription'; 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 protected function constrain(Builder $query, User $actor, string $subscriptionType, bool $negate): void

View File

@@ -9,18 +9,18 @@
namespace Flarum\Subscriptions; namespace Flarum\Subscriptions;
use Flarum\Filter\FilterState; use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Query\QueryCriteria; use Flarum\Search\SearchCriteria;
class HideIgnoredFromAllDiscussionsPage 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. // 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)`? // TODO: might be better as `id IN (subquery)`?
$actor = $filterState->getActor(); $actor = $state->getActor();
$filterState->getQuery()->whereNotExists(function ($query) use ($actor) { $state->getQuery()->whereNotExists(function ($query) use ($actor) {
$query->selectRaw(1) $query->selectRaw(1)
->from('discussion_user') ->from('discussion_user')
->whereColumn('discussions.id', 'discussion_id') ->whereColumn('discussions.id', 'discussion_id')

View File

@@ -10,6 +10,7 @@
use Flarum\Api\Serializer\BasicUserSerializer; use Flarum\Api\Serializer\BasicUserSerializer;
use Flarum\Api\Serializer\UserSerializer; use Flarum\Api\Serializer\UserSerializer;
use Flarum\Extend; use Flarum\Extend;
use Flarum\Search\Database\DatabaseSearchDriver;
use Flarum\Suspend\Access\UserPolicy; use Flarum\Suspend\Access\UserPolicy;
use Flarum\Suspend\AddUserSuspendAttributes; use Flarum\Suspend\AddUserSuspendAttributes;
use Flarum\Suspend\Event\Suspended; use Flarum\Suspend\Event\Suspended;
@@ -17,10 +18,9 @@ use Flarum\Suspend\Event\Unsuspended;
use Flarum\Suspend\Listener; use Flarum\Suspend\Listener;
use Flarum\Suspend\Notification\UserSuspendedBlueprint; use Flarum\Suspend\Notification\UserSuspendedBlueprint;
use Flarum\Suspend\Notification\UserUnsuspendedBlueprint; use Flarum\Suspend\Notification\UserUnsuspendedBlueprint;
use Flarum\Suspend\Query\SuspendedFilterGambit; use Flarum\Suspend\Query\SuspendedFilter;
use Flarum\Suspend\RevokeAccessFromSuspendedUsers; use Flarum\Suspend\RevokeAccessFromSuspendedUsers;
use Flarum\User\Event\Saving; use Flarum\User\Event\Saving;
use Flarum\User\Filter\UserFilterer;
use Flarum\User\Search\UserSearcher; use Flarum\User\Search\UserSearcher;
use Flarum\User\User; use Flarum\User\User;
@@ -58,11 +58,8 @@ return [
(new Extend\User()) (new Extend\User())
->permissionGroups(RevokeAccessFromSuspendedUsers::class), ->permissionGroups(RevokeAccessFromSuspendedUsers::class),
(new Extend\Filter(UserFilterer::class)) (new Extend\SearchDriver(DatabaseSearchDriver::class))
->addFilter(SuspendedFilterGambit::class), ->addFilter(UserSearcher::class, SuspendedFilter::class),
(new Extend\SimpleFlarumSearch(UserSearcher::class))
->addGambit(SuspendedFilterGambit::class),
(new Extend\View()) (new Extend\View())
->namespace('flarum-suspend', __DIR__.'/views'), ->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'; import app from 'flarum/admin/app';
export { default as extend } from './extend';
app.initializers.add('flarum-suspend', () => { app.initializers.add('flarum-suspend', () => {
app.extensionData.for('flarum-suspend').registerPermission( 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 User from 'flarum/common/models/User';
import Model from 'flarum/common/Model'; import Model from 'flarum/common/Model';
import commonExtend from '../common/extend';
export default [ export default [
...commonExtend,
new Extend.Model(User) new Extend.Model(User)
.attribute<boolean>('canSuspend') .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>('suspendReason')
.attribute<string | null | undefined>('suspendMessage'), .attribute<string | null | undefined>('suspendMessage'),
]; ];

View File

@@ -10,52 +10,35 @@
namespace Flarum\Suspend\Query; namespace Flarum\Suspend\Query;
use Carbon\Carbon; use Carbon\Carbon;
use Flarum\Filter\FilterInterface; use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Filter\FilterState; use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\AbstractRegexGambit;
use Flarum\Search\SearchState; use Flarum\Search\SearchState;
use Flarum\User\Guest; use Flarum\User\Guest;
use Flarum\User\UserRepository; use Flarum\User\UserRepository;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
class SuspendedFilterGambit extends AbstractRegexGambit implements FilterInterface /**
* @implements FilterInterface<DatabaseSearchState>
*/
class SuspendedFilter implements FilterInterface
{ {
public function __construct( public function __construct(
protected UserRepository $users 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 public function getFilterKey(): string
{ {
return 'suspended'; 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; return;
} }
$this->constrain($filterState->getQuery(), $negate); $this->constrain($state->getQuery(), $negate);
} }
protected function constrain(Builder $query, bool $negate): void 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\Api\Serializer\ForumSerializer;
use Flarum\Discussion\Discussion; use Flarum\Discussion\Discussion;
use Flarum\Discussion\Event\Saving; use Flarum\Discussion\Event\Saving;
use Flarum\Discussion\Filter\DiscussionFilterer;
use Flarum\Discussion\Search\DiscussionSearcher; use Flarum\Discussion\Search\DiscussionSearcher;
use Flarum\Extend; use Flarum\Extend;
use Flarum\Flags\Api\Controller\ListFlagsController; use Flarum\Flags\Api\Controller\ListFlagsController;
use Flarum\Http\RequestUtil; use Flarum\Http\RequestUtil;
use Flarum\Post\Filter\PostFilterer; use Flarum\Post\Filter\PostSearcher;
use Flarum\Post\Post; use Flarum\Post\Post;
use Flarum\Search\Database\DatabaseSearchDriver;
use Flarum\Tags\Access; use Flarum\Tags\Access;
use Flarum\Tags\Api\Controller; use Flarum\Tags\Api\Controller;
use Flarum\Tags\Api\Serializer\TagSerializer; use Flarum\Tags\Api\Serializer\TagSerializer;
use Flarum\Tags\Content; use Flarum\Tags\Content;
use Flarum\Tags\Event\DiscussionWasTagged; use Flarum\Tags\Event\DiscussionWasTagged;
use Flarum\Tags\Filter\HideHiddenTagsFromAllDiscussionsPage;
use Flarum\Tags\Filter\PostTagFilter;
use Flarum\Tags\Listener; use Flarum\Tags\Listener;
use Flarum\Tags\LoadForumTagsRelationship; use Flarum\Tags\LoadForumTagsRelationship;
use Flarum\Tags\Post\DiscussionTaggedPost; use Flarum\Tags\Post\DiscussionTaggedPost;
use Flarum\Tags\Query\TagFilterGambit; use Flarum\Tags\Search\Filter\PostTagFilter;
use Flarum\Tags\Search\Gambit\FulltextGambit; use Flarum\Tags\Search\Filter\TagFilter;
use Flarum\Tags\Search\FulltextFilter;
use Flarum\Tags\Search\HideHiddenTagsFromAllDiscussionsPage;
use Flarum\Tags\Search\TagSearcher; use Flarum\Tags\Search\TagSearcher;
use Flarum\Tags\Tag; use Flarum\Tags\Tag;
use Flarum\Tags\Utf8SlugDriver; use Flarum\Tags\Utf8SlugDriver;
@@ -135,18 +135,12 @@ return [
->listen(DiscussionWasTagged::class, Listener\CreatePostWhenTagsAreChanged::class) ->listen(DiscussionWasTagged::class, Listener\CreatePostWhenTagsAreChanged::class)
->subscribe(Listener\UpdateTagMetadata::class), ->subscribe(Listener\UpdateTagMetadata::class),
(new Extend\Filter(PostFilterer::class)) (new Extend\SearchDriver(DatabaseSearchDriver::class))
->addFilter(PostTagFilter::class), ->addFilter(PostSearcher::class, PostTagFilter::class)
->addFilter(DiscussionSearcher::class, TagFilter::class)
(new Extend\Filter(DiscussionFilterer::class)) ->addMutator(DiscussionSearcher::class, HideHiddenTagsFromAllDiscussionsPage::class)
->addFilter(TagFilterGambit::class) ->addSearcher(Tag::class, TagSearcher::class)
->addFilterMutator(HideHiddenTagsFromAllDiscussionsPage::class), ->setFulltext(TagSearcher::class, FulltextFilter::class),
(new Extend\SimpleFlarumSearch(DiscussionSearcher::class))
->addGambit(TagFilterGambit::class),
(new Extend\SimpleFlarumSearch(TagSearcher::class))
->setFullTextGambit(FullTextGambit::class),
(new Extend\ModelUrl(Tag::class)) (new Extend\ModelUrl(Tag::class))
->addSlugDriver('default', Utf8SlugDriver::class), ->addSlugDriver('default', Utf8SlugDriver::class),

View File

@@ -10,6 +10,7 @@ import Form from 'flarum/common/components/Form';
import EditTagModal from './EditTagModal'; import EditTagModal from './EditTagModal';
import tagIcon from '../../common/helpers/tagIcon'; import tagIcon from '../../common/helpers/tagIcon';
import sortTags from '../../common/utils/sortTags'; import sortTags from '../../common/utils/sortTags';
import FormSectionGroup, { FormSection } from '@flarum/core/src/admin/components/FormSectionGroup';
function tagItem(tag) { function tagItem(tag) {
return ( return (
@@ -66,17 +67,15 @@ export default class TagsPage extends ExtensionPage {
<div className="TagsContent"> <div className="TagsContent">
<div className="TagsContent-list"> <div className="TagsContent-list">
<div className="container" key={this.forcedRefreshKey} oncreate={this.onListOnCreate.bind(this)}> <div className="container" key={this.forcedRefreshKey} oncreate={this.onListOnCreate.bind(this)}>
<div className="SettingsGroups"> <FormSectionGroup>
<div className="TagGroup"> <FormSection className="TagGroup" label={app.translator.trans('flarum-tags.admin.tags.primary_heading')}>
<label>{app.translator.trans('flarum-tags.admin.tags.primary_heading')}</label>
<ol className="TagList TagList--primary">{tags.filter((tag) => tag.position() !== null && !tag.isChild()).map(tagItem)}</ol> <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 })}> <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')} {app.translator.trans('flarum-tags.admin.tags.create_primary_tag_button')}
</Button> </Button>
</div> </FormSection>
<div className="TagGroup TagGroup--secondary"> <FormSection className="TagGroup TagGroup--secondary" label={app.translator.trans('flarum-tags.admin.tags.secondary_heading')}>
<label>{app.translator.trans('flarum-tags.admin.tags.secondary_heading')}</label>
<ul className="TagList"> <ul className="TagList">
{tags {tags
.filter((tag) => tag.position() === null) .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 })}> <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')} {app.translator.trans('flarum-tags.admin.tags.create_secondary_tag_button')}
</Button> </Button>
</div> </FormSection>
<Form label={app.translator.trans('flarum-tags.admin.tags.settings_heading')}>
<div className="Form-group"> <FormSection label={app.translator.trans('flarum-tags.admin.tags.settings_heading')}>
<label>{app.translator.trans('flarum-tags.admin.tag_settings.required_primary_heading')}</label> <Form>
<div className="helpText">{app.translator.trans('flarum-tags.admin.tag_settings.required_primary_text')}</div> <div className="Form-group">
<div className="TagSettings-rangeInput"> <label>{app.translator.trans('flarum-tags.admin.tag_settings.required_primary_heading')}</label>
<input <div className="helpText">{app.translator.trans('flarum-tags.admin.tag_settings.required_primary_text')}</div>
className="FormControl" <div className="TagSettings-rangeInput">
type="number" <input
min="0" className="FormControl"
value={minPrimaryTags()} type="number"
oninput={withAttr('value', this.setMinTags.bind(this, minPrimaryTags, maxPrimaryTags))} min="0"
/> value={minPrimaryTags()}
{app.translator.trans('flarum-tags.admin.tag_settings.range_separator_text')} oninput={withAttr('value', this.setMinTags.bind(this, minPrimaryTags, maxPrimaryTags))}
<input className="FormControl" type="number" min={minPrimaryTags()} bidi={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> <div className="Form-group">
<div className="Form-group"> <label>{app.translator.trans('flarum-tags.admin.tag_settings.required_secondary_heading')}</label>
<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="helpText">{app.translator.trans('flarum-tags.admin.tag_settings.required_secondary_text')}</div> <div className="TagSettings-rangeInput">
<div className="TagSettings-rangeInput"> <input
<input className="FormControl"
className="FormControl" type="number"
type="number" min="0"
min="0" value={minSecondaryTags()}
value={minSecondaryTags()} oninput={withAttr('value', this.setMinTags.bind(this, minSecondaryTags, maxSecondaryTags))}
oninput={withAttr('value', this.setMinTags.bind(this, minSecondaryTags, maxSecondaryTags))} />
/> {app.translator.trans('flarum-tags.admin.tag_settings.range_separator_text')}
{app.translator.trans('flarum-tags.admin.tag_settings.range_separator_text')} <input className="FormControl" type="number" min={minSecondaryTags()} bidi={maxSecondaryTags} />
<input className="FormControl" type="number" min={minSecondaryTags()} bidi={maxSecondaryTags} /> </div>
</div> </div>
</div> <div className="Form-group Form-controls">{this.submitButton()}</div>
<div className="Form-group Form-controls">{this.submitButton()}</div> </Form>
</Form> </FormSection>
</div> </FormSectionGroup>
<div className="TagsContent-footer"> <div className="TagsContent-footer">
<p>{app.translator.trans('flarum-tags.admin.tags.about_tags_text')}</p> <p>{app.translator.trans('flarum-tags.admin.tags.about_tags_text')}</p>
</div> </div>

View File

@@ -1,7 +1,11 @@
import Extend from 'flarum/common/extenders'; import Extend from 'flarum/common/extenders';
import Tag from './models/Tag'; import Tag from './models/Tag';
import TagGambit from './query/discussions/TagGambit';
export default [ export default [
new Extend.Store() // new Extend.Store() //
.add('tags', Tag), .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) { if (this.params.tags) {
const filter = params.filter ?? {}; params.filter ||= {};
filter.tag = this.params.tags; params.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;
} }
}); });
} }

View File

@@ -13,7 +13,6 @@
.TagsContent-list { .TagsContent-list {
padding: 20px 0 0; padding: 20px 0 0;
} }
.TagList, .TagList,
@@ -22,6 +21,7 @@
padding: 0; padding: 0;
color: var(--muted-color); color: var(--muted-color);
font-size: 13px; font-size: 13px;
margin-top: 0;
>li { >li {
display: inline-block; display: inline-block;
@@ -80,77 +80,35 @@ li:not(.sortable-dragging)>.TagListItem-info:hover>.Button {
height: 34px; height: 34px;
} }
.SettingsGroups { @media (@tablet-up) {
display: flex; .TagGroup--secondary {
column-count: 3; max-width: 250px !important;
column-gap: 30px;
flex-wrap: wrap;
@media (@tablet-up) {
.TagGroup--secondary {
max-width: 250px !important;
}
} }
}
.Form { .TagList-button {
min-width: 300px; background: none;
max-height: 500px; border: 1px dashed var(--control-bg);
height: 40px;
margin: auto auto 0 0;
}
>label { .TagSettings-rangeInput {
margin-bottom: 10px; input {
} width: 80px;
display: inline;
margin: 0 5px;
.TagSettings-rangeInput { &:first-child {
input { margin-left: 0;
width: 80px; }
display: inline; }
margin: 0 5px; }
&:first-child { .TagGroup {
margin-left: 0; ol {
} > li:not(:first-child) {
} margin-top: 8px;
}
}
.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);
} }
} }
} }

View File

@@ -12,9 +12,10 @@ namespace Flarum\Tags\Api\Controller;
use Flarum\Api\Controller\AbstractListController; use Flarum\Api\Controller\AbstractListController;
use Flarum\Http\RequestUtil; use Flarum\Http\RequestUtil;
use Flarum\Http\UrlGenerator; 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\Api\Serializer\TagSerializer;
use Flarum\Tags\Search\TagSearcher; use Flarum\Tags\Tag;
use Flarum\Tags\TagRepository; use Flarum\Tags\TagRepository;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document; use Tobscure\JsonApi\Document;
@@ -35,7 +36,7 @@ class ListTagsController extends AbstractListController
public function __construct( public function __construct(
protected TagRepository $tags, protected TagRepository $tags,
protected TagSearcher $searcher, protected SearchManager $search,
protected UrlGenerator $url protected UrlGenerator $url
) { ) {
} }
@@ -53,7 +54,8 @@ class ListTagsController extends AbstractListController
} }
if (array_key_exists('q', $filters)) { 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(); $tags = $results->getResults();
$document->addPaginationLinks( $document->addPaginationLinks(

View File

@@ -7,12 +7,16 @@
* LICENSE file that was distributed with this source code. * LICENSE file that was distributed with this source code.
*/ */
namespace Flarum\Tags\Filter; namespace Flarum\Tags\Search\Filter;
use Flarum\Filter\FilterInterface; use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Filter\FilterState; use Flarum\Search\Filter\FilterInterface;
use Flarum\Filter\ValidateFilterTrait; use Flarum\Search\SearchState;
use Flarum\Search\ValidateFilterTrait;
/**
* @implements FilterInterface<DatabaseSearchState>
*/
class PostTagFilter implements FilterInterface class PostTagFilter implements FilterInterface
{ {
use ValidateFilterTrait; use ValidateFilterTrait;
@@ -22,11 +26,11 @@ class PostTagFilter implements FilterInterface
return 'tag'; 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') ->join('discussion_tag', 'discussion_tag.discussion_id', '=', 'posts.discussion_id')
->whereIn('discussion_tag.tag_id', $ids, 'and', $negate); ->whereIn('discussion_tag.tag_id', $ids, 'and', $negate);
} }

View File

@@ -7,20 +7,22 @@
* LICENSE file that was distributed with this source code. * 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\Http\SlugManager;
use Flarum\Search\AbstractRegexGambit; use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\SearchState; use Flarum\Search\SearchState;
use Flarum\Search\ValidateFilterTrait;
use Flarum\Tags\Tag; use Flarum\Tags\Tag;
use Flarum\User\User; use Flarum\User\User;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
class TagFilterGambit extends AbstractRegexGambit implements FilterInterface /**
* @implements FilterInterface<DatabaseSearchState>
*/
class TagFilter implements FilterInterface
{ {
use ValidateFilterTrait; 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 public function getFilterKey(): string
{ {
return 'tag'; 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 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. * 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\Search\SearchState;
use Flarum\Tags\TagRepository; use Flarum\Tags\TagRepository;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
class FulltextGambit implements GambitInterface /**
* @extends AbstractFulltextFilter<DatabaseSearchState>
*/
class FulltextFilter extends AbstractFulltextFilter
{ {
public function __construct( public function __construct(
protected TagRepository $tags protected TagRepository $tags
@@ -30,14 +34,12 @@ class FulltextGambit implements GambitInterface
->orWhere('slug', 'like', "$searchValue%"); ->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( ->whereIn(
'id', 'id',
$this->getTagSearchSubQuery($bit) $this->getTagSearchSubQuery($value)
); );
return true;
} }
} }

View File

@@ -7,21 +7,21 @@
* LICENSE file that was distributed with this source code. * LICENSE file that was distributed with this source code.
*/ */
namespace Flarum\Tags\Filter; namespace Flarum\Tags\Search;
use Flarum\Filter\FilterState; use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Query\QueryCriteria; use Flarum\Search\SearchCriteria;
use Flarum\Tags\Tag; use Flarum\Tags\Tag;
class HideHiddenTagsFromAllDiscussionsPage 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; return;
} }
$filter->getQuery()->whereNotIn('discussions.id', function ($query) { $state->getQuery()->whereNotIn('discussions.id', function ($query) {
return $query->select('discussion_id') return $query->select('discussion_id')
->from('discussion_tag') ->from('discussion_tag')
->whereIn('tag_id', Tag::where('is_hidden', 1)->pluck('id')); ->whereIn('tag_id', Tag::where('is_hidden', 1)->pluck('id'));

View File

@@ -9,24 +9,15 @@
namespace Flarum\Tags\Search; namespace Flarum\Tags\Search;
use Flarum\Search\AbstractSearcher; use Flarum\Search\Database\AbstractSearcher;
use Flarum\Search\GambitManager; use Flarum\Tags\Tag;
use Flarum\Tags\TagRepository;
use Flarum\User\User; use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
class TagSearcher extends AbstractSearcher class TagSearcher extends AbstractSearcher
{ {
public function __construct( public function getQuery(User $actor): Builder
protected TagRepository $tags,
GambitManager $gambits,
array $searchMutators
) {
parent::__construct($gambits, $searchMutators);
}
protected 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')); $this->assertEquals($expected, Arr::pluck($data, 'id'));
} }

View File

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

View File

@@ -110,6 +110,16 @@ export default class AdminNav extends Component {
50 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( items.add(
'search', 'search',
<div className="Search-input"> <div className="Search-input">

View File

@@ -14,6 +14,7 @@ import ColorPreviewInput from '../../common/components/ColorPreviewInput';
import ItemList from '../../common/utils/ItemList'; import ItemList from '../../common/utils/ItemList';
import type { IUploadImageButtonAttrs } from './UploadImageButton'; import type { IUploadImageButtonAttrs } from './UploadImageButton';
import UploadImageButton from './UploadImageButton'; import UploadImageButton from './UploadImageButton';
import extractText from '../../common/utils/extractText';
export interface AdminHeaderOptions { export interface AdminHeaderOptions {
title: Mithril.Children; 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)); 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 { IPageAttrs } from '../../common/components/Page';
import type Mithril from 'mithril'; import type Mithril from 'mithril';
import Form from '../../common/components/Form'; import Form from '../../common/components/Form';
import extractText from '../../common/utils/extractText';
export type HomePageItem = { path: string; label: Mithril.Children }; 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> { export default class BasicsPage<CustomAttrs extends IPageAttrs = IPageAttrs> extends AdminPage<CustomAttrs> {
localeOptions: Record<string, string> = {}; 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})`; this.localeOptions[i] = `${app.data.locales[i]} (${i})`;
}); });
const driverLocale = this.driverLocale();
app.data.displayNameDrivers.forEach((identifier) => { app.data.displayNameDrivers.forEach((identifier) => {
this.displayNameOptions[identifier] = identifier; this.displayNameOptions[identifier] = driverLocale.display_name[identifier] || identifier;
}); });
Object.keys(app.data.slugDrivers).forEach((model) => { Object.keys(app.data.slugDrivers).forEach((model) => {
this.slugDriverOptions[model] = {}; this.slugDriverOptions[model] = {};
app.data.slugDrivers[model].forEach((option) => { 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) => { {Object.keys(this.slugDriverOptions).map((model) => {
const options = this.slugDriverOptions[model]; const options = this.slugDriverOptions[model];
const modelLocale = this.modelLocale()[model] || model;
if (Object.keys(options).length > 1) { if (Object.keys(options).length > 1) {
return this.buildSettingComponent({ return this.buildSettingComponent({
type: 'select', type: 'select',
setting: `slug_driver_${model}`, setting: `slug_driver_${model}`,
options, options,
label: app.translator.trans('core.admin.basics.slug_driver_heading', { model }), label: app.translator.trans('core.admin.basics.slug_driver_heading', { model: modelLocale }),
help: app.translator.trans('core.admin.basics.slug_driver_text', { model }), 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; 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 Button from '../../common/components/Button';
import LoadingModal from './LoadingModal'; import LoadingModal from './LoadingModal';
import LinkButton from '../../common/components/LinkButton'; import LinkButton from '../../common/components/LinkButton';
import saveSettings from '../utils/saveSettings.js';
export default class StatusWidget extends DashboardWidget { export default class StatusWidget extends DashboardWidget {
className() { 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> <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; return items;
} }

View File

@@ -7,6 +7,7 @@ import MailPage from './components/MailPage';
import UserListPage from './components/UserListPage'; import UserListPage from './components/UserListPage';
import ExtensionPage from './components/ExtensionPage'; import ExtensionPage from './components/ExtensionPage';
import ExtensionPageResolver from './resolvers/ExtensionPageResolver'; import ExtensionPageResolver from './resolvers/ExtensionPageResolver';
import AdvancedPage from './components/AdvancedPage';
/** /**
* Helper functions to generate URLs to admin pages. * Helper functions to generate URLs to admin pages.
@@ -24,6 +25,7 @@ export default function (app: AdminApplication) {
appearance: { path: '/appearance', component: AppearancePage }, appearance: { path: '/appearance', component: AppearancePage },
mail: { path: '/mail', component: MailPage }, mail: { path: '/mail', component: MailPage },
users: { path: '/users', component: UserListPage }, users: { path: '/users', component: UserListPage },
advanced: { path: '/advanced', component: AdvancedPage },
extension: { path: '/extension/:id', component: ExtensionPage, resolverClass: ExtensionPageResolver }, 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 app from '../common/app';
import { FlarumRequestOptions } from './Application'; import { FlarumRequestOptions } from './Application';
import Model, { ModelData, SavedModelData } from './Model'; import Model, { ModelData, SavedModelData } from './Model';
import GambitManager from './GambitManager';
export interface MetaInformation { export interface MetaInformation {
[key: string]: any; [key: string]: any;
@@ -20,7 +21,7 @@ export interface ApiQueryParamsPlural {
| { | {
q: string; q: string;
} }
| Record<string, string>; | Record<string, any>;
page?: { page?: {
near?: number; near?: number;
offset?: number; offset?: number;
@@ -88,6 +89,12 @@ export default class Store {
*/ */
models: Record<string, { new (): Model }>; 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 }>) { constructor(models: Record<string, { new (): Model }>) {
this.models = models; this.models = models;
} }
@@ -178,6 +185,10 @@ export default class Store {
url += '/' + idOrParams; url += '/' + idOrParams;
} }
if ('filter' in params && params?.filter?.q) {
params.filter = this.gambits.apply(type, params.filter);
}
return app return app
.request<M extends Array<infer _T> ? ApiPayloadPlural : ApiPayloadSingle>({ .request<M extends Array<infer _T> ? ApiPayloadPlural : ApiPayloadSingle>({
method: 'GET', 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 PostTypes from './PostTypes';
import Routes from './Routes'; import Routes from './Routes';
import Store from './Store'; import Store from './Store';
import Search from './Search';
const extenders = { const extenders = {
Model, Model,
PostTypes, PostTypes,
Routes, Routes,
Store, Store,
Search,
}; };
export default extenders; 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>; }) as Array<Mithril.Vnode>;
const filter = app.store.gambits.apply('discussions', { q: query });
const q = filter.q || null;
delete filter.q;
return [ return [
<li className="Dropdown-header">{app.translator.trans('core.forum.search.discussions_heading')}</li>, <li className="Dropdown-header">{app.translator.trans('core.forum.search.discussions_heading')}</li>,
<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 })} {app.translator.trans('core.forum.search.all_discussions_button', { query })}
</LinkButton> </LinkButton>
</li>, </li>,

View File

@@ -36,7 +36,14 @@ export default class GlobalSearchState extends SearchState {
* @inheritdoc * @inheritdoc
*/ */
getInitialSearch(): string { 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. * 'x' is clicked in the search box in the header.
*/ */
protected clearInitialSearch() { protected clearInitialSearch() {
const { q, ...params } = this.params(); const { q, filter, ...params } = this.params();
setRouteWithForcedRefresh(app.route(app.current.get('routeName'), params)); setRouteWithForcedRefresh(app.route(app.current.get('routeName'), params));
} }
@@ -71,6 +78,9 @@ export default class GlobalSearchState extends SearchState {
return { return {
sort: m.route.param('sort'), sort: m.route.param('sort'),
q: m.route.param('q'), 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 { params(): SearchParams {
const params = this.stickyParams(); const params = this.stickyParams();
params.filter = m.route.param('filter');
return params; 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/CreateUserModal";
@import "admin/DashboardPage"; @import "admin/DashboardPage";
@import "admin/DebugWarningWidget"; @import "admin/DebugWarningWidget";
@import "admin/FormSectionGroup";
@import "admin/BasicsPage"; @import "admin/BasicsPage";
@import "admin/PermissionsPage"; @import "admin/PermissionsPage";
@import "admin/EditGroupModal"; @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. # Translations in this namespace are used by the admin interface.
admin: 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. # These translations are used in the Appearance page.
appearance: appearance:
colored_header_label: Colored Header colored_header_label: Colored Header
@@ -38,6 +49,8 @@ core:
all_discussions_label: => core.ref.all_discussions all_discussions_label: => core.ref.all_discussions
default_language_heading: Default Language default_language_heading: Default Language
description: "Set your forum title, language, and other basic settings." description: "Set your forum title, language, and other basic settings."
display_name_driver_options:
username: Username
display_name_heading: User Display Name 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. 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 forum_description_heading: Forum Description
@@ -46,6 +59,13 @@ core:
home_page_heading: Home Page home_page_heading: Home Page
home_page_text: Choose the page which users will first see when they visit your forum. home_page_text: Choose the page which users will first see when they visit your forum.
show_language_selector_label: Show language selector 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_heading: "Slug Driver: {model}"
slug_driver_text: Select a driver to be used for slugging this model. slug_driver_text: Select a driver to be used for slugging this model.
title: Basics title: Basics
@@ -78,6 +98,7 @@ core:
inactive: Inactive inactive: Inactive
never-run: Never run never-run: Never run
title: Dashboard title: Dashboard
toggle_advanced_page_button: Toggle Advanced Page
tools_button: Tools tools_button: Tools
# These translations are used in the debug warning widget. # These translations are used in the debug warning widget.
@@ -183,8 +204,16 @@ core:
loading: loading:
title: Please Wait... 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. # These translations are used in the navigation bar.
nav: nav:
advanced_button: => core.admin.advanced.title
advanced_title: => core.admin.advanced.description
appearance_button: => core.admin.appearance.title appearance_button: => core.admin.appearance.title
appearance_title: => core.admin.appearance.description appearance_title: => core.admin.appearance.description
basics_button: => core.admin.basics.title basics_button: => core.admin.basics.title

View File

@@ -9,11 +9,14 @@
namespace Flarum\Admin\Content; namespace Flarum\Admin\Content;
use Flarum\Database\AbstractModel;
use Flarum\Extension\ExtensionManager; use Flarum\Extension\ExtensionManager;
use Flarum\Foundation\ApplicationInfoProvider; use Flarum\Foundation\ApplicationInfoProvider;
use Flarum\Foundation\Config; use Flarum\Foundation\Config;
use Flarum\Frontend\Document; use Flarum\Frontend\Document;
use Flarum\Group\Permission; use Flarum\Group\Permission;
use Flarum\Search\AbstractDriver;
use Flarum\Search\SearcherInterface;
use Flarum\Settings\Event\Deserializing; use Flarum\Settings\Event\Deserializing;
use Flarum\Settings\SettingsRepositoryInterface; use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\User; use Flarum\User\User;
@@ -52,6 +55,9 @@ class AdminPayload
$document->payload['slugDrivers'] = array_map(function ($resourceDrivers) { $document->payload['slugDrivers'] = array_map(function ($resourceDrivers) {
return array_keys($resourceDrivers); return array_keys($resourceDrivers);
}, $this->container->make('flarum.http.slugDrivers')); }, $this->container->make('flarum.http.slugDrivers'));
$document->payload['searchDrivers'] = $this->getSearchDrivers();
$document->payload['advancedPageEmpty'] = $this->checkAdvancedPageEmpty();
$document->payload['phpVersion'] = $this->appInfo->identifyPHPVersion(); $document->payload['phpVersion'] = $this->appInfo->identifyPHPVersion();
$document->payload['mysqlVersion'] = $this->appInfo->identifyDatabaseVersion(); $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; namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\AccessTokenSerializer; use Flarum\Api\Serializer\AccessTokenSerializer;
use Flarum\Http\Filter\AccessTokenFilterer; use Flarum\Http\AccessToken;
use Flarum\Http\RequestUtil; use Flarum\Http\RequestUtil;
use Flarum\Http\UrlGenerator; use Flarum\Http\UrlGenerator;
use Flarum\Query\QueryCriteria; use Flarum\Search\SearchCriteria;
use Flarum\Search\SearchManager;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document; use Tobscure\JsonApi\Document;
@@ -23,7 +24,7 @@ class ListAccessTokensController extends AbstractListController
public function __construct( public function __construct(
protected UrlGenerator $url, protected UrlGenerator $url,
protected AccessTokenFilterer $filterer protected SearchManager $search
) { ) {
} }
@@ -37,7 +38,7 @@ class ListAccessTokensController extends AbstractListController
$limit = $this->extractLimit($request); $limit = $this->extractLimit($request);
$filter = $this->extractFilter($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( $document->addPaginationLinks(
$this->url->to('api')->route('access-tokens.index'), $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\Api\Serializer\DiscussionSerializer;
use Flarum\Discussion\Discussion; use Flarum\Discussion\Discussion;
use Flarum\Discussion\Filter\DiscussionFilterer;
use Flarum\Discussion\Search\DiscussionSearcher;
use Flarum\Http\RequestUtil; use Flarum\Http\RequestUtil;
use Flarum\Http\UrlGenerator; use Flarum\Http\UrlGenerator;
use Flarum\Query\QueryCriteria; use Flarum\Search\SearchCriteria;
use Flarum\Search\SearchManager;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document; use Tobscure\JsonApi\Document;
@@ -40,8 +39,7 @@ class ListDiscussionsController extends AbstractListController
public array $sortFields = ['lastPostedAt', 'commentCount', 'createdAt']; public array $sortFields = ['lastPostedAt', 'commentCount', 'createdAt'];
public function __construct( public function __construct(
protected DiscussionFilterer $filterer, protected SearchManager $search,
protected DiscussionSearcher $searcher,
protected UrlGenerator $url protected UrlGenerator $url
) { ) {
} }
@@ -57,12 +55,10 @@ class ListDiscussionsController extends AbstractListController
$offset = $this->extractOffset($request); $offset = $this->extractOffset($request);
$include = array_merge($this->extractInclude($request), ['state']); $include = array_merge($this->extractInclude($request), ['state']);
$criteria = new QueryCriteria($actor, $filters, $sort, $sortIsDefault); $results = $this->search->query(
if (array_key_exists('q', $filters)) { Discussion::class,
$results = $this->searcher->search($criteria, $limit, $offset); new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault)
} else { );
$results = $this->filterer->filter($criteria, $limit, $offset);
}
$this->addPaginationData( $this->addPaginationData(
$document, $document,

View File

@@ -10,10 +10,11 @@
namespace Flarum\Api\Controller; namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\GroupSerializer; use Flarum\Api\Serializer\GroupSerializer;
use Flarum\Group\Filter\GroupFilterer; use Flarum\Group\Group;
use Flarum\Http\RequestUtil; use Flarum\Http\RequestUtil;
use Flarum\Http\UrlGenerator; use Flarum\Http\UrlGenerator;
use Flarum\Query\QueryCriteria; use Flarum\Search\SearchCriteria;
use Flarum\Search\SearchManager;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document; use Tobscure\JsonApi\Document;
@@ -26,7 +27,7 @@ class ListGroupsController extends AbstractListController
public int $limit = -1; public int $limit = -1;
public function __construct( public function __construct(
protected GroupFilterer $filterer, protected SearchManager $search,
protected UrlGenerator $url protected UrlGenerator $url
) { ) {
} }
@@ -42,9 +43,10 @@ class ListGroupsController extends AbstractListController
$limit = $this->extractLimit($request); $limit = $this->extractLimit($request);
$offset = $this->extractOffset($request); $offset = $this->extractOffset($request);
$criteria = new QueryCriteria($actor, $filters, $sort, $sortIsDefault); $queryResults = $this->search->query(
Group::class,
$queryResults = $this->filterer->filter($criteria, $limit, $offset); new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault)
);
$document->addPaginationLinks( $document->addPaginationLinks(
$this->url->to('api')->route('groups.index'), $this->url->to('api')->route('groups.index'),

View File

@@ -12,9 +12,10 @@ namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\PostSerializer; use Flarum\Api\Serializer\PostSerializer;
use Flarum\Http\RequestUtil; use Flarum\Http\RequestUtil;
use Flarum\Http\UrlGenerator; use Flarum\Http\UrlGenerator;
use Flarum\Post\Filter\PostFilterer; use Flarum\Post\Post;
use Flarum\Post\PostRepository; use Flarum\Post\PostRepository;
use Flarum\Query\QueryCriteria; use Flarum\Search\SearchCriteria;
use Flarum\Search\SearchManager;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document; use Tobscure\JsonApi\Document;
@@ -35,7 +36,7 @@ class ListPostsController extends AbstractListController
public array $sortFields = ['number', 'createdAt']; public array $sortFields = ['number', 'createdAt'];
public function __construct( public function __construct(
protected PostFilterer $filterer, protected SearchManager $search,
protected PostRepository $posts, protected PostRepository $posts,
protected UrlGenerator $url protected UrlGenerator $url
) { ) {
@@ -53,7 +54,10 @@ class ListPostsController extends AbstractListController
$offset = $this->extractOffset($request); $offset = $this->extractOffset($request);
$include = $this->extractInclude($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( $document->addPaginationLinks(
$this->url->to('api')->route('posts.index'), $this->url->to('api')->route('posts.index'),

View File

@@ -12,9 +12,9 @@ namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\UserSerializer; use Flarum\Api\Serializer\UserSerializer;
use Flarum\Http\RequestUtil; use Flarum\Http\RequestUtil;
use Flarum\Http\UrlGenerator; use Flarum\Http\UrlGenerator;
use Flarum\Query\QueryCriteria; use Flarum\Search\SearchCriteria;
use Flarum\User\Filter\UserFilterer; use Flarum\Search\SearchManager;
use Flarum\User\Search\UserSearcher; use Flarum\User\User;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document; use Tobscure\JsonApi\Document;
@@ -33,8 +33,7 @@ class ListUsersController extends AbstractListController
]; ];
public function __construct( public function __construct(
protected UserFilterer $filterer, protected SearchManager $search,
protected UserSearcher $searcher,
protected UrlGenerator $url protected UrlGenerator $url
) { ) {
} }
@@ -60,12 +59,10 @@ class ListUsersController extends AbstractListController
$offset = $this->extractOffset($request); $offset = $this->extractOffset($request);
$include = $this->extractInclude($request); $include = $this->extractInclude($request);
$criteria = new QueryCriteria($actor, $filters, $sort, $sortIsDefault); $results = $this->search->query(
if (array_key_exists('q', $filters)) { User::class,
$results = $this->searcher->search($criteria, $limit, $offset); new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault)
} else { );
$results = $this->filterer->filter($criteria, $limit, $offset);
}
$document->addPaginationLinks( $document->addPaginationLinks(
$this->url->to('api')->route('users.index'), $this->url->to('api')->route('users.index'),

View File

@@ -237,4 +237,14 @@ abstract class AbstractModel extends Eloquent
{ {
return new Collection($models); 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\Database\Eloquent\Builder;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
/**
* @method static Builder whereVisibleTo(User $user)
*/
trait ScopeVisibilityTrait trait ScopeVisibilityTrait
{ {
/** /**

View File

@@ -84,6 +84,8 @@ class Discussion extends AbstractModel
'hidden_at' => 'datetime', 'hidden_at' => 'datetime',
]; ];
protected $observables = ['hidden'];
/** /**
* The user for which the state relationship should be loaded. * 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->hidden_user_id = $actor?->id;
$this->raise(new Hidden($this)); $this->raise(new Hidden($this));
$this->saved(function (self $model) {
if ($model === $this) {
$model->fireModelEvent('hidden', false);
}
});
} }
return $this; return $this;
@@ -154,6 +162,12 @@ class Discussion extends AbstractModel
$this->hidden_user_id = null; $this->hidden_user_id = null;
$this->raise(new Restored($this)); $this->raise(new Restored($this));
$this->saved(function (self $model) {
if ($model === $this) {
$model->fireModelEvent('restored', false);
}
});
} }
return $this; 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; namespace Flarum\Discussion\Search;
use Flarum\Discussion\DiscussionRepository; use Flarum\Discussion\Discussion;
use Flarum\Search\AbstractSearcher; use Flarum\Search\Database\AbstractSearcher;
use Flarum\Search\GambitManager;
use Flarum\User\User; use Flarum\User\User;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
class DiscussionSearcher extends AbstractSearcher class DiscussionSearcher extends AbstractSearcher
{ {
public function __construct( public function getQuery(User $actor): Builder
protected DiscussionRepository $discussions,
protected Dispatcher $events,
GambitManager $gambits,
array $searchMutators
) {
parent::__construct($gambits, $searchMutators);
}
protected 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. * LICENSE file that was distributed with this source code.
*/ */
namespace Flarum\Discussion\Query; namespace Flarum\Discussion\Search\Filter;
use Flarum\Filter\FilterInterface; use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Filter\FilterState; use Flarum\Search\Filter\FilterInterface;
use Flarum\Filter\ValidateFilterTrait;
use Flarum\Search\AbstractRegexGambit;
use Flarum\Search\SearchState; use Flarum\Search\SearchState;
use Flarum\Search\ValidateFilterTrait;
use Flarum\User\UserRepository; use Flarum\User\UserRepository;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
class AuthorFilterGambit extends AbstractRegexGambit implements FilterInterface /**
* @implements FilterInterface<DatabaseSearchState>
*/
class AuthorFilter implements FilterInterface
{ {
use ValidateFilterTrait; 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 public function getFilterKey(): string
{ {
return 'author'; 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 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. * LICENSE file that was distributed with this source code.
*/ */
namespace Flarum\Discussion\Query; namespace Flarum\Discussion\Search\Filter;
use Flarum\Filter\FilterInterface; use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Filter\FilterState; use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\AbstractRegexGambit;
use Flarum\Search\SearchState; use Flarum\Search\SearchState;
use Illuminate\Database\Query\Builder; 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 public function getFilterKey(): string
{ {
return 'hidden'; 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 protected function constrain(Builder $query, bool $negate): void

View File

@@ -7,39 +7,23 @@
* LICENSE file that was distributed with this source code. * LICENSE file that was distributed with this source code.
*/ */
namespace Flarum\Discussion\Query; namespace Flarum\Discussion\Search\Filter;
use Flarum\Discussion\DiscussionRepository; use Flarum\Discussion\DiscussionRepository;
use Flarum\Filter\FilterInterface; use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Filter\FilterState; use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\AbstractRegexGambit;
use Flarum\Search\SearchState; use Flarum\Search\SearchState;
use Flarum\User\User; use Flarum\User\User;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
class UnreadFilterGambit extends AbstractRegexGambit implements FilterInterface /**
* @implements FilterInterface<DatabaseSearchState>
*/
class UnreadFilter implements FilterInterface
{ {
/** public function __construct(
* @var \Flarum\Discussion\DiscussionRepository protected DiscussionRepository $discussions
*/ ) {
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 getFilterKey(): string public function getFilterKey(): string
@@ -47,9 +31,9 @@ class UnreadFilterGambit extends AbstractRegexGambit implements FilterInterface
return 'unread'; 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 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. * LICENSE file that was distributed with this source code.
*/ */
namespace Flarum\Discussion\Search\Gambit; namespace Flarum\Discussion\Search;
use Flarum\Discussion\Discussion; use Flarum\Discussion\Discussion;
use Flarum\Post\Post; use Flarum\Post\Post;
use Flarum\Search\GambitInterface; use Flarum\Search\AbstractFulltextFilter;
use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Search\SearchState; use Flarum\Search\SearchState;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Query\Expression; 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. // Replace all non-word characters with spaces.
// We do this to prevent MySQL fulltext search boolean mode from taking // 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 // 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(); $grammar = $query->getGrammar();
$discussionSubquery = Discussion::select('id') $discussionSubquery = Discussion::select('id')
->selectRaw('NULL as score') ->selectRaw('NULL as score')
->selectRaw('first_post_id as most_relevant_post_id') ->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 // Construct a subquery to fetch discussions which contain relevant
// posts. Retrieve the collective relevance of each discussion's posts, // posts. Retrieve the collective relevance of each discussion's posts,
// which we will use later in the order by clause, and also retrieve // which we will use later in the order by clause, and also retrieve
// the ID of the most relevant post. // the ID of the most relevant post.
$subquery = Post::whereVisibleTo($search->getActor()) $subquery = Post::whereVisibleTo($state->getActor())
->select('posts.discussion_id') ->select('posts.discussion_id')
->selectRaw('SUM(MATCH('.$grammar->wrap('posts.content').') AGAINST (?)) as score', [$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', [$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', [$value])
->where('posts.type', 'comment') ->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') ->groupBy('posts.discussion_id')
->union($discussionSubquery); ->union($discussionSubquery);
@@ -58,11 +63,9 @@ class FulltextGambit implements GambitInterface
->groupBy('discussions.id') ->groupBy('discussions.id')
->addBinding($subquery->getBindings(), 'join'); ->addBinding($subquery->getBindings(), 'join');
$search->setDefaultSort(function ($query) use ($grammar, $bit) { $state->setDefaultSort(function (Builder $query) use ($grammar, $value) {
$query->orderByRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (?) desc', [$bit]); $query->orderByRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (?) desc', [$value]);
$query->orderBy('posts_ft.score', 'desc'); $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