mirror of
https://github.com/flarum/framework.git
synced 2025-01-19 07:42:48 +08: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:
parent
9e04b010d8
commit
4b126d9f4c
|
@ -19,9 +19,10 @@ use Flarum\Likes\Event\PostWasUnliked;
|
|||
use Flarum\Likes\Notification\PostLikedBlueprint;
|
||||
use Flarum\Likes\Query\LikedByFilter;
|
||||
use Flarum\Likes\Query\LikedFilter;
|
||||
use Flarum\Post\Filter\PostFilterer;
|
||||
use Flarum\Post\Filter\PostSearcher;
|
||||
use Flarum\Post\Post;
|
||||
use Flarum\User\Filter\UserFilterer;
|
||||
use Flarum\Search\Database\DatabaseSearchDriver;
|
||||
use Flarum\User\Search\UserSearcher;
|
||||
use Flarum\User\User;
|
||||
|
||||
return [
|
||||
|
@ -76,11 +77,9 @@ return [
|
|||
->listen(PostWasUnliked::class, Listener\SendNotificationWhenPostIsUnliked::class)
|
||||
->subscribe(Listener\SaveLikesToDatabase::class),
|
||||
|
||||
(new Extend\Filter(PostFilterer::class))
|
||||
->addFilter(LikedByFilter::class),
|
||||
|
||||
(new Extend\Filter(UserFilterer::class))
|
||||
->addFilter(LikedFilter::class),
|
||||
(new Extend\SearchDriver(DatabaseSearchDriver::class))
|
||||
->addFilter(PostSearcher::class, LikedByFilter::class)
|
||||
->addFilter(UserSearcher::class, LikedFilter::class),
|
||||
|
||||
(new Extend\Settings())
|
||||
->default('flarum-likes.like_own_post', true),
|
||||
|
|
|
@ -9,10 +9,14 @@
|
|||
|
||||
namespace Flarum\Likes\Query;
|
||||
|
||||
use Flarum\Filter\FilterInterface;
|
||||
use Flarum\Filter\FilterState;
|
||||
use Flarum\Filter\ValidateFilterTrait;
|
||||
use Flarum\Search\Database\DatabaseSearchState;
|
||||
use Flarum\Search\Filter\FilterInterface;
|
||||
use Flarum\Search\SearchState;
|
||||
use Flarum\Search\ValidateFilterTrait;
|
||||
|
||||
/**
|
||||
* @implements FilterInterface<DatabaseSearchState>
|
||||
*/
|
||||
class LikedByFilter implements FilterInterface
|
||||
{
|
||||
use ValidateFilterTrait;
|
||||
|
@ -22,11 +26,11 @@ class LikedByFilter implements FilterInterface
|
|||
return 'likedBy';
|
||||
}
|
||||
|
||||
public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void
|
||||
public function filter(SearchState $state, string|array $value, bool $negate): void
|
||||
{
|
||||
$likedId = $this->asInt($filterValue);
|
||||
$likedId = $this->asInt($value);
|
||||
|
||||
$filterState
|
||||
$state
|
||||
->getQuery()
|
||||
->whereIn('id', function ($query) use ($likedId, $negate) {
|
||||
$query->select('post_id')
|
||||
|
|
|
@ -9,10 +9,14 @@
|
|||
|
||||
namespace Flarum\Likes\Query;
|
||||
|
||||
use Flarum\Filter\FilterInterface;
|
||||
use Flarum\Filter\FilterState;
|
||||
use Flarum\Filter\ValidateFilterTrait;
|
||||
use Flarum\Search\Database\DatabaseSearchState;
|
||||
use Flarum\Search\Filter\FilterInterface;
|
||||
use Flarum\Search\SearchState;
|
||||
use Flarum\Search\ValidateFilterTrait;
|
||||
|
||||
/**
|
||||
* @implements FilterInterface<DatabaseSearchState>
|
||||
*/
|
||||
class LikedFilter implements FilterInterface
|
||||
{
|
||||
use ValidateFilterTrait;
|
||||
|
@ -22,11 +26,11 @@ class LikedFilter implements FilterInterface
|
|||
return 'liked';
|
||||
}
|
||||
|
||||
public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void
|
||||
public function filter(SearchState $state, string|array $value, bool $negate): void
|
||||
{
|
||||
$likedId = $this->asString($filterValue);
|
||||
$likedId = $this->asString($value);
|
||||
|
||||
$filterState
|
||||
$state
|
||||
->getQuery()
|
||||
->whereIn('id', function ($query) use ($likedId) {
|
||||
$query->select('user_id')
|
||||
|
|
|
@ -11,16 +11,16 @@ use Flarum\Api\Serializer\BasicDiscussionSerializer;
|
|||
use Flarum\Api\Serializer\DiscussionSerializer;
|
||||
use Flarum\Discussion\Discussion;
|
||||
use Flarum\Discussion\Event\Saving;
|
||||
use Flarum\Discussion\Filter\DiscussionFilterer;
|
||||
use Flarum\Discussion\Search\DiscussionSearcher;
|
||||
use Flarum\Extend;
|
||||
use Flarum\Lock\Access;
|
||||
use Flarum\Lock\Event\DiscussionWasLocked;
|
||||
use Flarum\Lock\Event\DiscussionWasUnlocked;
|
||||
use Flarum\Lock\Filter\LockedFilter;
|
||||
use Flarum\Lock\Listener;
|
||||
use Flarum\Lock\Notification\DiscussionLockedBlueprint;
|
||||
use Flarum\Lock\Post\DiscussionLockedPost;
|
||||
use Flarum\Lock\Query\LockedFilterGambit;
|
||||
use Flarum\Search\Database\DatabaseSearchDriver;
|
||||
|
||||
return [
|
||||
(new Extend\Frontend('forum'))
|
||||
|
@ -57,9 +57,6 @@ return [
|
|||
(new Extend\Policy())
|
||||
->modelPolicy(Discussion::class, Access\DiscussionPolicy::class),
|
||||
|
||||
(new Extend\Filter(DiscussionFilterer::class))
|
||||
->addFilter(LockedFilterGambit::class),
|
||||
|
||||
(new Extend\SimpleFlarumSearch(DiscussionSearcher::class))
|
||||
->addGambit(LockedFilterGambit::class),
|
||||
(new Extend\SearchDriver(DatabaseSearchDriver::class))
|
||||
->addFilter(DiscussionSearcher::class, LockedFilter::class),
|
||||
];
|
||||
|
|
1
extensions/lock/js/src/admin/extend.ts
Normal file
1
extensions/lock/js/src/admin/extend.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { default as default } from '../common/extend';
|
|
@ -1,5 +1,7 @@
|
|||
import app from 'flarum/admin/app';
|
||||
|
||||
export { default as extend } from './extend';
|
||||
|
||||
app.initializers.add('lock', () => {
|
||||
app.extensionData.for('flarum-lock').registerPermission(
|
||||
{
|
||||
|
|
7
extensions/lock/js/src/common/extend.ts
Normal file
7
extensions/lock/js/src/common/extend.ts
Normal 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),
|
||||
];
|
|
@ -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`;
|
||||
}
|
||||
}
|
|
@ -2,7 +2,11 @@ import Extend from 'flarum/common/extenders';
|
|||
import Discussion from 'flarum/common/models/Discussion';
|
||||
import DiscussionLockedPost from './components/DiscussionLockedPost';
|
||||
|
||||
import commonExtend from '../common/extend';
|
||||
|
||||
export default [
|
||||
...commonExtend,
|
||||
|
||||
new Extend.PostTypes() //
|
||||
.add('discussionLocked', DiscussionLockedPost),
|
||||
|
||||
|
|
36
extensions/lock/src/Filter/LockedFilter.php
Normal file
36
extensions/lock/src/Filter/LockedFilter.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -24,8 +24,9 @@ use Flarum\Post\Event\Hidden;
|
|||
use Flarum\Post\Event\Posted;
|
||||
use Flarum\Post\Event\Restored;
|
||||
use Flarum\Post\Event\Revised;
|
||||
use Flarum\Post\Filter\PostFilterer;
|
||||
use Flarum\Post\Filter\PostSearcher;
|
||||
use Flarum\Post\Post;
|
||||
use Flarum\Search\Database\DatabaseSearchDriver;
|
||||
use Flarum\Tags\Api\Serializer\TagSerializer;
|
||||
use Flarum\User\User;
|
||||
|
||||
|
@ -114,9 +115,9 @@ return [
|
|||
->listen(Hidden::class, Listener\UpdateMentionsMetadataWhenInvisible::class)
|
||||
->listen(Deleted::class, Listener\UpdateMentionsMetadataWhenInvisible::class),
|
||||
|
||||
(new Extend\Filter(PostFilterer::class))
|
||||
->addFilter(Filter\MentionedFilter::class)
|
||||
->addFilter(Filter\MentionedPostFilter::class),
|
||||
(new Extend\SearchDriver(DatabaseSearchDriver::class))
|
||||
->addFilter(PostSearcher::class, Filter\MentionedFilter::class)
|
||||
->addFilter(PostSearcher::class, Filter\MentionedPostFilter::class),
|
||||
|
||||
(new Extend\ApiSerializer(CurrentUserSerializer::class))
|
||||
->attribute('canMentionGroups', function (CurrentUserSerializer $serializer, User $user): bool {
|
||||
|
|
|
@ -9,10 +9,14 @@
|
|||
|
||||
namespace Flarum\Mentions\Filter;
|
||||
|
||||
use Flarum\Filter\FilterInterface;
|
||||
use Flarum\Filter\FilterState;
|
||||
use Flarum\Filter\ValidateFilterTrait;
|
||||
use Flarum\Search\Database\DatabaseSearchState;
|
||||
use Flarum\Search\Filter\FilterInterface;
|
||||
use Flarum\Search\SearchState;
|
||||
use Flarum\Search\ValidateFilterTrait;
|
||||
|
||||
/**
|
||||
* @implements FilterInterface<DatabaseSearchState>
|
||||
*/
|
||||
class MentionedFilter implements FilterInterface
|
||||
{
|
||||
use ValidateFilterTrait;
|
||||
|
@ -22,11 +26,11 @@ class MentionedFilter implements FilterInterface
|
|||
return 'mentioned';
|
||||
}
|
||||
|
||||
public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void
|
||||
public function filter(SearchState $state, string|array $value, bool $negate): void
|
||||
{
|
||||
$mentionedId = $this->asInt($filterValue);
|
||||
$mentionedId = $this->asInt($value);
|
||||
|
||||
$filterState
|
||||
$state
|
||||
->getQuery()
|
||||
->join('post_mentions_user', 'posts.id', '=', 'post_mentions_user.post_id')
|
||||
->where('post_mentions_user.mentions_user_id', $negate ? '!=' : '=', $mentionedId);
|
||||
|
|
|
@ -9,9 +9,13 @@
|
|||
|
||||
namespace Flarum\Mentions\Filter;
|
||||
|
||||
use Flarum\Filter\FilterInterface;
|
||||
use Flarum\Filter\FilterState;
|
||||
use Flarum\Search\Database\DatabaseSearchState;
|
||||
use Flarum\Search\Filter\FilterInterface;
|
||||
use Flarum\Search\SearchState;
|
||||
|
||||
/**
|
||||
* @implements FilterInterface<DatabaseSearchState>
|
||||
*/
|
||||
class MentionedPostFilter implements FilterInterface
|
||||
{
|
||||
public function getFilterKey(): string
|
||||
|
@ -19,11 +23,11 @@ class MentionedPostFilter implements FilterInterface
|
|||
return 'mentionedPost';
|
||||
}
|
||||
|
||||
public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void
|
||||
public function filter(SearchState $state, string|array $value, bool $negate): void
|
||||
{
|
||||
$mentionedId = trim($filterValue, '"');
|
||||
$mentionedId = trim($value, '"');
|
||||
|
||||
$filterState
|
||||
$state
|
||||
->getQuery()
|
||||
->join('post_mentions_post', 'posts.id', '=', 'post_mentions_post.post_id')
|
||||
->where('post_mentions_post.mentions_post_id', $negate ? '!=' : '=', $mentionedId);
|
||||
|
|
|
@ -12,6 +12,7 @@ namespace Flarum\Nicknames;
|
|||
use Flarum\Api\Serializer\UserSerializer;
|
||||
use Flarum\Extend;
|
||||
use Flarum\Nicknames\Access\UserPolicy;
|
||||
use Flarum\Search\Database\DatabaseSearchDriver;
|
||||
use Flarum\User\Event\Saving;
|
||||
use Flarum\User\Search\UserSearcher;
|
||||
use Flarum\User\User;
|
||||
|
@ -52,8 +53,8 @@ return [
|
|||
(new Extend\Validator(UserValidator::class))
|
||||
->configure(AddNicknameValidation::class),
|
||||
|
||||
(new Extend\SimpleFlarumSearch(UserSearcher::class))
|
||||
->setFullTextGambit(NicknameFullTextGambit::class),
|
||||
(new Extend\SearchDriver(DatabaseSearchDriver::class))
|
||||
->setFulltext(UserSearcher::class, NicknameFullTextFilter::class),
|
||||
|
||||
(new Extend\Policy())
|
||||
->modelPolicy(User::class, UserPolicy::class),
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
import app from 'flarum/admin/app';
|
||||
import Alert from 'flarum/common/components/Alert';
|
||||
import Link from 'flarum/common/components/Link';
|
||||
import BasicsPage from 'flarum/admin/components/BasicsPage';
|
||||
import extractText from 'flarum/common/utils/extractText';
|
||||
import { extend } from 'flarum/common/extend';
|
||||
|
||||
app.initializers.add('flarum/nicknames', () => {
|
||||
app.extensionData
|
||||
|
@ -55,4 +58,8 @@ app.initializers.add('flarum/nicknames', () => {
|
|||
},
|
||||
'start'
|
||||
);
|
||||
|
||||
extend(BasicsPage.prototype, 'driverLocale', function (locale) {
|
||||
locale.display_name['nickname'] = extractText(app.translator.trans('flarum-nicknames.admin.basics.display_name_driver_options.nickname'));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
flarum-nicknames:
|
||||
admin:
|
||||
basics:
|
||||
display_name_driver_options:
|
||||
nickname: Nickname
|
||||
permissions:
|
||||
edit_own_nickname_label: Edit own nickname
|
||||
settings:
|
||||
|
|
|
@ -9,19 +9,16 @@
|
|||
|
||||
namespace Flarum\Nicknames;
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
use Flarum\Search\GambitInterface;
|
||||
use Flarum\Search\AbstractFulltextFilter;
|
||||
use Flarum\Search\Database\DatabaseSearchState;
|
||||
use Flarum\Search\SearchState;
|
||||
use Flarum\User\UserRepository;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class NicknameFullTextGambit implements GambitInterface
|
||||
/**
|
||||
* @extends AbstractFulltextFilter<DatabaseSearchState>
|
||||
*/
|
||||
class NicknameFullTextFilter extends AbstractFulltextFilter
|
||||
{
|
||||
public function __construct(
|
||||
protected UserRepository $users
|
||||
|
@ -37,14 +34,12 @@ class NicknameFullTextGambit implements GambitInterface
|
|||
->orWhere('nickname', 'like', "{$searchValue}%");
|
||||
}
|
||||
|
||||
public function apply(SearchState $search, string $bit): bool
|
||||
public function search(SearchState $state, string $value): void
|
||||
{
|
||||
$search->getQuery()
|
||||
$state->getQuery()
|
||||
->whereIn(
|
||||
'id',
|
||||
$this->getUserSearchSubQuery($bit)
|
||||
$this->getUserSearchSubQuery($value)
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -11,16 +11,16 @@ use Flarum\Api\Controller\ListDiscussionsController;
|
|||
use Flarum\Api\Serializer\DiscussionSerializer;
|
||||
use Flarum\Discussion\Discussion;
|
||||
use Flarum\Discussion\Event\Saving;
|
||||
use Flarum\Discussion\Filter\DiscussionFilterer;
|
||||
use Flarum\Discussion\Search\DiscussionSearcher;
|
||||
use Flarum\Extend;
|
||||
use Flarum\Search\Database\DatabaseSearchDriver;
|
||||
use Flarum\Sticky\Event\DiscussionWasStickied;
|
||||
use Flarum\Sticky\Event\DiscussionWasUnstickied;
|
||||
use Flarum\Sticky\Listener;
|
||||
use Flarum\Sticky\Listener\SaveStickyToDatabase;
|
||||
use Flarum\Sticky\PinStickiedDiscussionsToTop;
|
||||
use Flarum\Sticky\Post\DiscussionStickiedPost;
|
||||
use Flarum\Sticky\Query\StickyFilterGambit;
|
||||
use Flarum\Sticky\Query\StickyFilter;
|
||||
|
||||
return [
|
||||
(new Extend\Frontend('forum'))
|
||||
|
@ -54,10 +54,7 @@ return [
|
|||
->listen(DiscussionWasStickied::class, [Listener\CreatePostWhenDiscussionIsStickied::class, 'whenDiscussionWasStickied'])
|
||||
->listen(DiscussionWasUnstickied::class, [Listener\CreatePostWhenDiscussionIsStickied::class, 'whenDiscussionWasUnstickied']),
|
||||
|
||||
(new Extend\Filter(DiscussionFilterer::class))
|
||||
->addFilter(StickyFilterGambit::class)
|
||||
->addFilterMutator(PinStickiedDiscussionsToTop::class),
|
||||
|
||||
(new Extend\SimpleFlarumSearch(DiscussionSearcher::class))
|
||||
->addGambit(StickyFilterGambit::class),
|
||||
(new Extend\SearchDriver(DatabaseSearchDriver::class))
|
||||
->addFilter(DiscussionSearcher::class, StickyFilter::class)
|
||||
->addMutator(DiscussionSearcher::class, PinStickiedDiscussionsToTop::class),
|
||||
];
|
||||
|
|
1
extensions/sticky/js/src/admin/extend.ts
Normal file
1
extensions/sticky/js/src/admin/extend.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { default as default } from '../common/extend';
|
|
@ -1,5 +1,7 @@
|
|||
import app from 'flarum/admin/app';
|
||||
|
||||
export { default as extend } from './extend';
|
||||
|
||||
app.initializers.add('flarum-sticky', () => {
|
||||
app.extensionData.for('flarum-sticky').registerPermission(
|
||||
{
|
||||
|
|
7
extensions/sticky/js/src/common/extend.ts
Normal file
7
extensions/sticky/js/src/common/extend.ts
Normal 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),
|
||||
];
|
|
@ -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`;
|
||||
}
|
||||
}
|
|
@ -2,7 +2,11 @@ import Extend from 'flarum/common/extenders';
|
|||
import Discussion from 'flarum/common/models/Discussion';
|
||||
import DiscussionStickiedPost from './components/DiscussionStickiedPost';
|
||||
|
||||
import commonExtend from '../common/extend';
|
||||
|
||||
export default [
|
||||
...commonExtend,
|
||||
|
||||
new Extend.PostTypes() //
|
||||
.add('discussionStickied', DiscussionStickiedPost),
|
||||
|
||||
|
|
|
@ -9,23 +9,23 @@
|
|||
|
||||
namespace Flarum\Sticky;
|
||||
|
||||
use Flarum\Filter\FilterState;
|
||||
use Flarum\Query\QueryCriteria;
|
||||
use Flarum\Tags\Query\TagFilterGambit;
|
||||
use Flarum\Search\Database\DatabaseSearchState;
|
||||
use Flarum\Search\SearchCriteria;
|
||||
use Flarum\Tags\Search\Filter\TagFilter;
|
||||
|
||||
class PinStickiedDiscussionsToTop
|
||||
{
|
||||
public function __invoke(FilterState $filterState, QueryCriteria $criteria): void
|
||||
public function __invoke(DatabaseSearchState $state, SearchCriteria $criteria): void
|
||||
{
|
||||
if ($criteria->sortIsDefault) {
|
||||
$query = $filterState->getQuery();
|
||||
if ($criteria->sortIsDefault && ! $state->isFulltextSearch()) {
|
||||
$query = $state->getQuery();
|
||||
|
||||
// If we are viewing a specific tag, then pin all stickied
|
||||
// discussions to the top no matter what.
|
||||
$filters = $filterState->getActiveFilters();
|
||||
$filters = $state->getActiveFilters();
|
||||
|
||||
if ($count = count($filters)) {
|
||||
if ($count === 1 && $filters[0] instanceof TagFilterGambit) {
|
||||
if ($count === 1 && $filters[0] instanceof TagFilter) {
|
||||
if (! is_array($query->orders)) {
|
||||
$query->orders = [];
|
||||
}
|
||||
|
@ -51,14 +51,14 @@ class PinStickiedDiscussionsToTop
|
|||
->selectRaw('1')
|
||||
->from('discussion_user as sticky')
|
||||
->whereColumn('sticky.discussion_id', 'id')
|
||||
->where('sticky.user_id', '=', $filterState->getActor()->id)
|
||||
->where('sticky.user_id', '=', $state->getActor()->id)
|
||||
->whereColumn('sticky.last_read_post_number', '>=', 'last_post_number');
|
||||
|
||||
// Add the bindings manually (rather than as the second
|
||||
// argument in orderByRaw) for now due to a bug in Laravel which
|
||||
// would add the bindings in the wrong order.
|
||||
$query->orderByRaw('is_sticky and not exists ('.$read->toSql().') and last_posted_at > ? desc')
|
||||
->addBinding(array_merge($read->getBindings(), [$filterState->getActor()->marked_all_as_read_at ?: 0]), 'union');
|
||||
->addBinding(array_merge($read->getBindings(), [$state->getActor()->marked_all_as_read_at ?: 0]), 'union');
|
||||
|
||||
$query->unionOrders = array_merge($query->unionOrders, $query->orders);
|
||||
$query->unionLimit = $query->limit;
|
||||
|
|
36
extensions/sticky/src/Query/StickyFilter.php
Normal file
36
extensions/sticky/src/Query/StickyFilter.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -12,7 +12,6 @@ use Flarum\Api\Serializer\DiscussionSerializer;
|
|||
use Flarum\Approval\Event\PostWasApproved;
|
||||
use Flarum\Discussion\Discussion;
|
||||
use Flarum\Discussion\Event\Saving;
|
||||
use Flarum\Discussion\Filter\DiscussionFilterer;
|
||||
use Flarum\Discussion\Search\DiscussionSearcher;
|
||||
use Flarum\Discussion\UserState;
|
||||
use Flarum\Extend;
|
||||
|
@ -20,14 +19,18 @@ use Flarum\Post\Event\Deleted;
|
|||
use Flarum\Post\Event\Hidden;
|
||||
use Flarum\Post\Event\Posted;
|
||||
use Flarum\Post\Event\Restored;
|
||||
use Flarum\Search\Database\DatabaseSearchDriver;
|
||||
use Flarum\Subscriptions\Filter\SubscriptionFilter;
|
||||
use Flarum\Subscriptions\HideIgnoredFromAllDiscussionsPage;
|
||||
use Flarum\Subscriptions\Listener;
|
||||
use Flarum\Subscriptions\Notification\FilterVisiblePostsBeforeSending;
|
||||
use Flarum\Subscriptions\Notification\NewPostBlueprint;
|
||||
use Flarum\Subscriptions\Query\SubscriptionFilterGambit;
|
||||
use Flarum\User\User;
|
||||
|
||||
return [
|
||||
(new Extend\Frontend('admin'))
|
||||
->js(__DIR__.'/js/dist/admin.js'),
|
||||
|
||||
(new Extend\Frontend('forum'))
|
||||
->js(__DIR__.'/js/dist/forum.js')
|
||||
->css(__DIR__.'/less/forum.less')
|
||||
|
@ -67,12 +70,9 @@ return [
|
|||
->listen(Deleted::class, Listener\DeleteNotificationWhenPostIsHiddenOrDeleted::class)
|
||||
->listen(Posted::class, Listener\FollowAfterReply::class),
|
||||
|
||||
(new Extend\Filter(DiscussionFilterer::class))
|
||||
->addFilter(SubscriptionFilterGambit::class)
|
||||
->addFilterMutator(HideIgnoredFromAllDiscussionsPage::class),
|
||||
|
||||
(new Extend\SimpleFlarumSearch(DiscussionSearcher::class))
|
||||
->addGambit(SubscriptionFilterGambit::class),
|
||||
(new Extend\SearchDriver(DatabaseSearchDriver::class))
|
||||
->addFilter(DiscussionSearcher::class, SubscriptionFilter::class)
|
||||
->addMutator(DiscussionSearcher::class, HideIgnoredFromAllDiscussionsPage::class),
|
||||
|
||||
(new Extend\User())
|
||||
->registerPreference('flarum-subscriptions.notify_for_all_posts', 'boolval', false),
|
||||
|
|
1
extensions/subscriptions/js/admin.ts
Normal file
1
extensions/subscriptions/js/admin.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './src/admin';
|
1
extensions/subscriptions/js/src/admin/extend.ts
Normal file
1
extensions/subscriptions/js/src/admin/extend.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { default as default } from '../common/extend';
|
1
extensions/subscriptions/js/src/admin/index.ts
Normal file
1
extensions/subscriptions/js/src/admin/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { default as extend } from './extend';
|
7
extensions/subscriptions/js/src/common/extend.ts
Normal file
7
extensions/subscriptions/js/src/common/extend.ts
Normal 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),
|
||||
];
|
|
@ -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}`;
|
||||
}
|
||||
}
|
|
@ -36,12 +36,7 @@ export default function addSubscriptionFilter() {
|
|||
extend(DiscussionListState.prototype, 'requestParams', function (params) {
|
||||
if (this.params.onFollowing) {
|
||||
params.filter ||= {};
|
||||
|
||||
if (params.filter.q) {
|
||||
params.filter.q += ' is:following';
|
||||
} else {
|
||||
params.filter.subscription = 'following';
|
||||
}
|
||||
params.filter.subscription = 'following';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -2,7 +2,11 @@ import Extend from 'flarum/common/extenders';
|
|||
import IndexPage from 'flarum/forum/components/IndexPage';
|
||||
import Discussion from 'flarum/common/models/Discussion';
|
||||
|
||||
import commonExtend from '../common/extend';
|
||||
|
||||
export default [
|
||||
...commonExtend,
|
||||
|
||||
new Extend.Routes() //
|
||||
.add('following', '/following', IndexPage),
|
||||
|
||||
|
|
|
@ -7,42 +7,34 @@
|
|||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Subscriptions\Query;
|
||||
namespace Flarum\Subscriptions\Filter;
|
||||
|
||||
use Flarum\Filter\FilterInterface;
|
||||
use Flarum\Filter\FilterState;
|
||||
use Flarum\Filter\ValidateFilterTrait;
|
||||
use Flarum\Search\AbstractRegexGambit;
|
||||
use Flarum\Search\Database\DatabaseSearchState;
|
||||
use Flarum\Search\Filter\FilterInterface;
|
||||
use Flarum\Search\SearchState;
|
||||
use Flarum\Search\ValidateFilterTrait;
|
||||
use Flarum\User\User;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
|
||||
class SubscriptionFilterGambit extends AbstractRegexGambit implements FilterInterface
|
||||
/**
|
||||
* @implements FilterInterface<DatabaseSearchState>
|
||||
*/
|
||||
class SubscriptionFilter implements FilterInterface
|
||||
{
|
||||
use ValidateFilterTrait;
|
||||
|
||||
protected function getGambitPattern(): string
|
||||
{
|
||||
return 'is:(follow|ignor)(?:ing|ed)';
|
||||
}
|
||||
|
||||
protected function conditions(SearchState $search, array $matches, bool $negate): void
|
||||
{
|
||||
$this->constrain($search->getQuery(), $search->getActor(), $matches[1], $negate);
|
||||
}
|
||||
|
||||
public function getFilterKey(): string
|
||||
{
|
||||
return 'subscription';
|
||||
}
|
||||
|
||||
public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void
|
||||
public function filter(SearchState $state, string|array $value, bool $negate): void
|
||||
{
|
||||
$filterValue = $this->asString($filterValue);
|
||||
$value = $this->asString($value);
|
||||
|
||||
preg_match('/^'.$this->getGambitPattern().'$/i', 'is:'.$filterValue, $matches);
|
||||
preg_match('/^(follow|ignor)(?:ing|ed)$/i', $value, $matches);
|
||||
|
||||
$this->constrain($filterState->getQuery(), $filterState->getActor(), $matches[1], $negate);
|
||||
$this->constrain($state->getQuery(), $state->getActor(), $matches[1], $negate);
|
||||
}
|
||||
|
||||
protected function constrain(Builder $query, User $actor, string $subscriptionType, bool $negate): void
|
|
@ -9,18 +9,18 @@
|
|||
|
||||
namespace Flarum\Subscriptions;
|
||||
|
||||
use Flarum\Filter\FilterState;
|
||||
use Flarum\Query\QueryCriteria;
|
||||
use Flarum\Search\Database\DatabaseSearchState;
|
||||
use Flarum\Search\SearchCriteria;
|
||||
|
||||
class HideIgnoredFromAllDiscussionsPage
|
||||
{
|
||||
public function __invoke(FilterState $filterState, QueryCriteria $criteria): void
|
||||
public function __invoke(DatabaseSearchState $state, SearchCriteria $criteria): void
|
||||
{
|
||||
// We only want to hide on the "all discussions" page.
|
||||
if (count($filterState->getActiveFilters()) === 0) {
|
||||
if (count($state->getActiveFilters()) === 0 && ! $state->isFulltextSearch()) {
|
||||
// TODO: might be better as `id IN (subquery)`?
|
||||
$actor = $filterState->getActor();
|
||||
$filterState->getQuery()->whereNotExists(function ($query) use ($actor) {
|
||||
$actor = $state->getActor();
|
||||
$state->getQuery()->whereNotExists(function ($query) use ($actor) {
|
||||
$query->selectRaw(1)
|
||||
->from('discussion_user')
|
||||
->whereColumn('discussions.id', 'discussion_id')
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
use Flarum\Api\Serializer\BasicUserSerializer;
|
||||
use Flarum\Api\Serializer\UserSerializer;
|
||||
use Flarum\Extend;
|
||||
use Flarum\Search\Database\DatabaseSearchDriver;
|
||||
use Flarum\Suspend\Access\UserPolicy;
|
||||
use Flarum\Suspend\AddUserSuspendAttributes;
|
||||
use Flarum\Suspend\Event\Suspended;
|
||||
|
@ -17,10 +18,9 @@ use Flarum\Suspend\Event\Unsuspended;
|
|||
use Flarum\Suspend\Listener;
|
||||
use Flarum\Suspend\Notification\UserSuspendedBlueprint;
|
||||
use Flarum\Suspend\Notification\UserUnsuspendedBlueprint;
|
||||
use Flarum\Suspend\Query\SuspendedFilterGambit;
|
||||
use Flarum\Suspend\Query\SuspendedFilter;
|
||||
use Flarum\Suspend\RevokeAccessFromSuspendedUsers;
|
||||
use Flarum\User\Event\Saving;
|
||||
use Flarum\User\Filter\UserFilterer;
|
||||
use Flarum\User\Search\UserSearcher;
|
||||
use Flarum\User\User;
|
||||
|
||||
|
@ -58,11 +58,8 @@ return [
|
|||
(new Extend\User())
|
||||
->permissionGroups(RevokeAccessFromSuspendedUsers::class),
|
||||
|
||||
(new Extend\Filter(UserFilterer::class))
|
||||
->addFilter(SuspendedFilterGambit::class),
|
||||
|
||||
(new Extend\SimpleFlarumSearch(UserSearcher::class))
|
||||
->addGambit(SuspendedFilterGambit::class),
|
||||
(new Extend\SearchDriver(DatabaseSearchDriver::class))
|
||||
->addFilter(UserSearcher::class, SuspendedFilter::class),
|
||||
|
||||
(new Extend\View())
|
||||
->namespace('flarum-suspend', __DIR__.'/views'),
|
||||
|
|
1
extensions/suspend/js/src/admin/extend.ts
Normal file
1
extensions/suspend/js/src/admin/extend.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { default as default } from '../common/extend';
|
|
@ -1,5 +1,7 @@
|
|||
import app from 'flarum/admin/app';
|
||||
|
||||
export { default as extend } from './extend';
|
||||
|
||||
app.initializers.add('flarum-suspend', () => {
|
||||
app.extensionData.for('flarum-suspend').registerPermission(
|
||||
{
|
||||
|
|
7
extensions/suspend/js/src/common/extend.ts
Normal file
7
extensions/suspend/js/src/common/extend.ts
Normal 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),
|
||||
];
|
|
@ -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`;
|
||||
}
|
||||
}
|
|
@ -2,10 +2,14 @@ import Extend from 'flarum/common/extenders';
|
|||
import User from 'flarum/common/models/User';
|
||||
import Model from 'flarum/common/Model';
|
||||
|
||||
import commonExtend from '../common/extend';
|
||||
|
||||
export default [
|
||||
...commonExtend,
|
||||
|
||||
new Extend.Model(User)
|
||||
.attribute<boolean>('canSuspend')
|
||||
.attribute<Date, string | null | undefined>('suspendedUntil', Model.transformDate)
|
||||
.attribute<Date | null | undefined, string | null | undefined>('suspendedUntil', Model.transformDate)
|
||||
.attribute<string | null | undefined>('suspendReason')
|
||||
.attribute<string | null | undefined>('suspendMessage'),
|
||||
];
|
||||
|
|
|
@ -10,52 +10,35 @@
|
|||
namespace Flarum\Suspend\Query;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Flarum\Filter\FilterInterface;
|
||||
use Flarum\Filter\FilterState;
|
||||
use Flarum\Search\AbstractRegexGambit;
|
||||
use Flarum\Search\Database\DatabaseSearchState;
|
||||
use Flarum\Search\Filter\FilterInterface;
|
||||
use Flarum\Search\SearchState;
|
||||
use Flarum\User\Guest;
|
||||
use Flarum\User\UserRepository;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
|
||||
class SuspendedFilterGambit extends AbstractRegexGambit implements FilterInterface
|
||||
/**
|
||||
* @implements FilterInterface<DatabaseSearchState>
|
||||
*/
|
||||
class SuspendedFilter implements FilterInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected UserRepository $users
|
||||
) {
|
||||
}
|
||||
|
||||
protected function getGambitPattern(): string
|
||||
{
|
||||
return 'is:suspended';
|
||||
}
|
||||
|
||||
public function apply(SearchState $search, string $bit): bool
|
||||
{
|
||||
if (! $search->getActor()->can('suspend', new Guest())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::apply($search, $bit);
|
||||
}
|
||||
|
||||
protected function conditions(SearchState $search, array $matches, bool $negate): void
|
||||
{
|
||||
$this->constrain($search->getQuery(), $negate);
|
||||
}
|
||||
|
||||
public function getFilterKey(): string
|
||||
{
|
||||
return 'suspended';
|
||||
}
|
||||
|
||||
public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void
|
||||
public function filter(SearchState $state, string|array $value, bool $negate): void
|
||||
{
|
||||
if (! $filterState->getActor()->can('suspend', new Guest())) {
|
||||
if (! $state->getActor()->can('suspend', new Guest())) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->constrain($filterState->getQuery(), $negate);
|
||||
$this->constrain($state->getQuery(), $negate);
|
||||
}
|
||||
|
||||
protected function constrain(Builder $query, bool $negate): void
|
|
@ -13,25 +13,25 @@ use Flarum\Api\Serializer\DiscussionSerializer;
|
|||
use Flarum\Api\Serializer\ForumSerializer;
|
||||
use Flarum\Discussion\Discussion;
|
||||
use Flarum\Discussion\Event\Saving;
|
||||
use Flarum\Discussion\Filter\DiscussionFilterer;
|
||||
use Flarum\Discussion\Search\DiscussionSearcher;
|
||||
use Flarum\Extend;
|
||||
use Flarum\Flags\Api\Controller\ListFlagsController;
|
||||
use Flarum\Http\RequestUtil;
|
||||
use Flarum\Post\Filter\PostFilterer;
|
||||
use Flarum\Post\Filter\PostSearcher;
|
||||
use Flarum\Post\Post;
|
||||
use Flarum\Search\Database\DatabaseSearchDriver;
|
||||
use Flarum\Tags\Access;
|
||||
use Flarum\Tags\Api\Controller;
|
||||
use Flarum\Tags\Api\Serializer\TagSerializer;
|
||||
use Flarum\Tags\Content;
|
||||
use Flarum\Tags\Event\DiscussionWasTagged;
|
||||
use Flarum\Tags\Filter\HideHiddenTagsFromAllDiscussionsPage;
|
||||
use Flarum\Tags\Filter\PostTagFilter;
|
||||
use Flarum\Tags\Listener;
|
||||
use Flarum\Tags\LoadForumTagsRelationship;
|
||||
use Flarum\Tags\Post\DiscussionTaggedPost;
|
||||
use Flarum\Tags\Query\TagFilterGambit;
|
||||
use Flarum\Tags\Search\Gambit\FulltextGambit;
|
||||
use Flarum\Tags\Search\Filter\PostTagFilter;
|
||||
use Flarum\Tags\Search\Filter\TagFilter;
|
||||
use Flarum\Tags\Search\FulltextFilter;
|
||||
use Flarum\Tags\Search\HideHiddenTagsFromAllDiscussionsPage;
|
||||
use Flarum\Tags\Search\TagSearcher;
|
||||
use Flarum\Tags\Tag;
|
||||
use Flarum\Tags\Utf8SlugDriver;
|
||||
|
@ -135,18 +135,12 @@ return [
|
|||
->listen(DiscussionWasTagged::class, Listener\CreatePostWhenTagsAreChanged::class)
|
||||
->subscribe(Listener\UpdateTagMetadata::class),
|
||||
|
||||
(new Extend\Filter(PostFilterer::class))
|
||||
->addFilter(PostTagFilter::class),
|
||||
|
||||
(new Extend\Filter(DiscussionFilterer::class))
|
||||
->addFilter(TagFilterGambit::class)
|
||||
->addFilterMutator(HideHiddenTagsFromAllDiscussionsPage::class),
|
||||
|
||||
(new Extend\SimpleFlarumSearch(DiscussionSearcher::class))
|
||||
->addGambit(TagFilterGambit::class),
|
||||
|
||||
(new Extend\SimpleFlarumSearch(TagSearcher::class))
|
||||
->setFullTextGambit(FullTextGambit::class),
|
||||
(new Extend\SearchDriver(DatabaseSearchDriver::class))
|
||||
->addFilter(PostSearcher::class, PostTagFilter::class)
|
||||
->addFilter(DiscussionSearcher::class, TagFilter::class)
|
||||
->addMutator(DiscussionSearcher::class, HideHiddenTagsFromAllDiscussionsPage::class)
|
||||
->addSearcher(Tag::class, TagSearcher::class)
|
||||
->setFulltext(TagSearcher::class, FulltextFilter::class),
|
||||
|
||||
(new Extend\ModelUrl(Tag::class))
|
||||
->addSlugDriver('default', Utf8SlugDriver::class),
|
||||
|
|
|
@ -10,6 +10,7 @@ import Form from 'flarum/common/components/Form';
|
|||
import EditTagModal from './EditTagModal';
|
||||
import tagIcon from '../../common/helpers/tagIcon';
|
||||
import sortTags from '../../common/utils/sortTags';
|
||||
import FormSectionGroup, { FormSection } from '@flarum/core/src/admin/components/FormSectionGroup';
|
||||
|
||||
function tagItem(tag) {
|
||||
return (
|
||||
|
@ -66,17 +67,15 @@ export default class TagsPage extends ExtensionPage {
|
|||
<div className="TagsContent">
|
||||
<div className="TagsContent-list">
|
||||
<div className="container" key={this.forcedRefreshKey} oncreate={this.onListOnCreate.bind(this)}>
|
||||
<div className="SettingsGroups">
|
||||
<div className="TagGroup">
|
||||
<label>{app.translator.trans('flarum-tags.admin.tags.primary_heading')}</label>
|
||||
<FormSectionGroup>
|
||||
<FormSection className="TagGroup" label={app.translator.trans('flarum-tags.admin.tags.primary_heading')}>
|
||||
<ol className="TagList TagList--primary">{tags.filter((tag) => tag.position() !== null && !tag.isChild()).map(tagItem)}</ol>
|
||||
<Button className="Button TagList-button" icon="fas fa-plus" onclick={() => app.modal.show(EditTagModal, { primary: true })}>
|
||||
{app.translator.trans('flarum-tags.admin.tags.create_primary_tag_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<div className="TagGroup TagGroup--secondary">
|
||||
<label>{app.translator.trans('flarum-tags.admin.tags.secondary_heading')}</label>
|
||||
<FormSection className="TagGroup TagGroup--secondary" label={app.translator.trans('flarum-tags.admin.tags.secondary_heading')}>
|
||||
<ul className="TagList">
|
||||
{tags
|
||||
.filter((tag) => tag.position() === null)
|
||||
|
@ -86,41 +85,44 @@ export default class TagsPage extends ExtensionPage {
|
|||
<Button className="Button TagList-button" icon="fas fa-plus" onclick={() => app.modal.show(EditTagModal, { primary: false })}>
|
||||
{app.translator.trans('flarum-tags.admin.tags.create_secondary_tag_button')}
|
||||
</Button>
|
||||
</div>
|
||||
<Form label={app.translator.trans('flarum-tags.admin.tags.settings_heading')}>
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('flarum-tags.admin.tag_settings.required_primary_heading')}</label>
|
||||
<div className="helpText">{app.translator.trans('flarum-tags.admin.tag_settings.required_primary_text')}</div>
|
||||
<div className="TagSettings-rangeInput">
|
||||
<input
|
||||
className="FormControl"
|
||||
type="number"
|
||||
min="0"
|
||||
value={minPrimaryTags()}
|
||||
oninput={withAttr('value', this.setMinTags.bind(this, minPrimaryTags, maxPrimaryTags))}
|
||||
/>
|
||||
{app.translator.trans('flarum-tags.admin.tag_settings.range_separator_text')}
|
||||
<input className="FormControl" type="number" min={minPrimaryTags()} bidi={maxPrimaryTags} />
|
||||
</FormSection>
|
||||
|
||||
<FormSection label={app.translator.trans('flarum-tags.admin.tags.settings_heading')}>
|
||||
<Form>
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('flarum-tags.admin.tag_settings.required_primary_heading')}</label>
|
||||
<div className="helpText">{app.translator.trans('flarum-tags.admin.tag_settings.required_primary_text')}</div>
|
||||
<div className="TagSettings-rangeInput">
|
||||
<input
|
||||
className="FormControl"
|
||||
type="number"
|
||||
min="0"
|
||||
value={minPrimaryTags()}
|
||||
oninput={withAttr('value', this.setMinTags.bind(this, minPrimaryTags, maxPrimaryTags))}
|
||||
/>
|
||||
{app.translator.trans('flarum-tags.admin.tag_settings.range_separator_text')}
|
||||
<input className="FormControl" type="number" min={minPrimaryTags()} bidi={maxPrimaryTags} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('flarum-tags.admin.tag_settings.required_secondary_heading')}</label>
|
||||
<div className="helpText">{app.translator.trans('flarum-tags.admin.tag_settings.required_secondary_text')}</div>
|
||||
<div className="TagSettings-rangeInput">
|
||||
<input
|
||||
className="FormControl"
|
||||
type="number"
|
||||
min="0"
|
||||
value={minSecondaryTags()}
|
||||
oninput={withAttr('value', this.setMinTags.bind(this, minSecondaryTags, maxSecondaryTags))}
|
||||
/>
|
||||
{app.translator.trans('flarum-tags.admin.tag_settings.range_separator_text')}
|
||||
<input className="FormControl" type="number" min={minSecondaryTags()} bidi={maxSecondaryTags} />
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('flarum-tags.admin.tag_settings.required_secondary_heading')}</label>
|
||||
<div className="helpText">{app.translator.trans('flarum-tags.admin.tag_settings.required_secondary_text')}</div>
|
||||
<div className="TagSettings-rangeInput">
|
||||
<input
|
||||
className="FormControl"
|
||||
type="number"
|
||||
min="0"
|
||||
value={minSecondaryTags()}
|
||||
oninput={withAttr('value', this.setMinTags.bind(this, minSecondaryTags, maxSecondaryTags))}
|
||||
/>
|
||||
{app.translator.trans('flarum-tags.admin.tag_settings.range_separator_text')}
|
||||
<input className="FormControl" type="number" min={minSecondaryTags()} bidi={maxSecondaryTags} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="Form-group Form-controls">{this.submitButton()}</div>
|
||||
</Form>
|
||||
</div>
|
||||
<div className="Form-group Form-controls">{this.submitButton()}</div>
|
||||
</Form>
|
||||
</FormSection>
|
||||
</FormSectionGroup>
|
||||
<div className="TagsContent-footer">
|
||||
<p>{app.translator.trans('flarum-tags.admin.tags.about_tags_text')}</p>
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
import Extend from 'flarum/common/extenders';
|
||||
import Tag from './models/Tag';
|
||||
import TagGambit from './query/discussions/TagGambit';
|
||||
|
||||
export default [
|
||||
new Extend.Store() //
|
||||
.add('tags', Tag),
|
||||
|
||||
new Extend.Search() //
|
||||
.gambit('discussions', TagGambit),
|
||||
];
|
||||
|
|
23
extensions/tags/js/src/common/query/discussions/TagGambit.ts
Normal file
23
extensions/tags/js/src/common/query/discussions/TagGambit.ts
Normal 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}`;
|
||||
}
|
||||
}
|
|
@ -124,14 +124,8 @@ export default function addTagFilter() {
|
|||
}
|
||||
|
||||
if (this.params.tags) {
|
||||
const filter = params.filter ?? {};
|
||||
filter.tag = this.params.tags;
|
||||
// TODO: replace this with a more robust system.
|
||||
const q = filter.q;
|
||||
if (q) {
|
||||
filter.q = `${q} tag:${this.params.tags}`;
|
||||
}
|
||||
params.filter = filter;
|
||||
params.filter ||= {};
|
||||
params.filter.tag = this.params.tags;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -13,7 +13,6 @@
|
|||
|
||||
.TagsContent-list {
|
||||
padding: 20px 0 0;
|
||||
|
||||
}
|
||||
|
||||
.TagList,
|
||||
|
@ -22,6 +21,7 @@
|
|||
padding: 0;
|
||||
color: var(--muted-color);
|
||||
font-size: 13px;
|
||||
margin-top: 0;
|
||||
|
||||
>li {
|
||||
display: inline-block;
|
||||
|
@ -80,77 +80,35 @@ li:not(.sortable-dragging)>.TagListItem-info:hover>.Button {
|
|||
height: 34px;
|
||||
}
|
||||
|
||||
.SettingsGroups {
|
||||
display: flex;
|
||||
column-count: 3;
|
||||
column-gap: 30px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@media (@tablet-up) {
|
||||
.TagGroup--secondary {
|
||||
max-width: 250px !important;
|
||||
}
|
||||
@media (@tablet-up) {
|
||||
.TagGroup--secondary {
|
||||
max-width: 250px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.Form {
|
||||
min-width: 300px;
|
||||
max-height: 500px;
|
||||
.TagList-button {
|
||||
background: none;
|
||||
border: 1px dashed var(--control-bg);
|
||||
height: 40px;
|
||||
margin: auto auto 0 0;
|
||||
}
|
||||
|
||||
>label {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.TagSettings-rangeInput {
|
||||
input {
|
||||
width: 80px;
|
||||
display: inline;
|
||||
margin: 0 5px;
|
||||
|
||||
.TagSettings-rangeInput {
|
||||
input {
|
||||
width: 80px;
|
||||
display: inline;
|
||||
margin: 0 5px;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.TagGroup,
|
||||
.Form {
|
||||
display: inline-grid;
|
||||
padding: 10px 20px;
|
||||
min-height: 20vh;
|
||||
max-width: 400px;
|
||||
grid-template-rows: min-content;
|
||||
border: 1px solid var(--control-bg);
|
||||
border-radius: var(--border-radius);
|
||||
flex: 1 1 160px;
|
||||
|
||||
@media (max-width: 1209px) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
>ol {
|
||||
>li {
|
||||
margin-top: 8px;
|
||||
|
||||
.Button {
|
||||
float: right;
|
||||
visibility: hidden;
|
||||
margin: -8px -16px -8px 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.TagList-button {
|
||||
background: none;
|
||||
border: 1px dashed var(--control-bg);
|
||||
height: 40px;
|
||||
margin: auto auto 0 0;
|
||||
}
|
||||
|
||||
>label {
|
||||
float: left;
|
||||
font-weight: bold;
|
||||
color: var(--muted-color);
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.TagGroup {
|
||||
ol {
|
||||
> li:not(:first-child) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,9 +12,10 @@ namespace Flarum\Tags\Api\Controller;
|
|||
use Flarum\Api\Controller\AbstractListController;
|
||||
use Flarum\Http\RequestUtil;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Query\QueryCriteria;
|
||||
use Flarum\Search\SearchCriteria;
|
||||
use Flarum\Search\SearchManager;
|
||||
use Flarum\Tags\Api\Serializer\TagSerializer;
|
||||
use Flarum\Tags\Search\TagSearcher;
|
||||
use Flarum\Tags\Tag;
|
||||
use Flarum\Tags\TagRepository;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Tobscure\JsonApi\Document;
|
||||
|
@ -35,7 +36,7 @@ class ListTagsController extends AbstractListController
|
|||
|
||||
public function __construct(
|
||||
protected TagRepository $tags,
|
||||
protected TagSearcher $searcher,
|
||||
protected SearchManager $search,
|
||||
protected UrlGenerator $url
|
||||
) {
|
||||
}
|
||||
|
@ -53,7 +54,8 @@ class ListTagsController extends AbstractListController
|
|||
}
|
||||
|
||||
if (array_key_exists('q', $filters)) {
|
||||
$results = $this->searcher->search(new QueryCriteria($actor, $filters), $limit, $offset);
|
||||
$results = $this->search->query(Tag::class, new SearchCriteria($actor, $filters, $limit, $offset));
|
||||
|
||||
$tags = $results->getResults();
|
||||
|
||||
$document->addPaginationLinks(
|
||||
|
|
|
@ -7,12 +7,16 @@
|
|||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tags\Filter;
|
||||
namespace Flarum\Tags\Search\Filter;
|
||||
|
||||
use Flarum\Filter\FilterInterface;
|
||||
use Flarum\Filter\FilterState;
|
||||
use Flarum\Filter\ValidateFilterTrait;
|
||||
use Flarum\Search\Database\DatabaseSearchState;
|
||||
use Flarum\Search\Filter\FilterInterface;
|
||||
use Flarum\Search\SearchState;
|
||||
use Flarum\Search\ValidateFilterTrait;
|
||||
|
||||
/**
|
||||
* @implements FilterInterface<DatabaseSearchState>
|
||||
*/
|
||||
class PostTagFilter implements FilterInterface
|
||||
{
|
||||
use ValidateFilterTrait;
|
||||
|
@ -22,11 +26,11 @@ class PostTagFilter implements FilterInterface
|
|||
return 'tag';
|
||||
}
|
||||
|
||||
public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void
|
||||
public function filter(SearchState $state, string|array $value, bool $negate): void
|
||||
{
|
||||
$ids = $this->asIntArray($filterValue);
|
||||
$ids = $this->asIntArray($value);
|
||||
|
||||
$filterState->getQuery()
|
||||
$state->getQuery()
|
||||
->join('discussion_tag', 'discussion_tag.discussion_id', '=', 'posts.discussion_id')
|
||||
->whereIn('discussion_tag.tag_id', $ids, 'and', $negate);
|
||||
}
|
|
@ -7,20 +7,22 @@
|
|||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tags\Query;
|
||||
namespace Flarum\Tags\Search\Filter;
|
||||
|
||||
use Flarum\Filter\FilterInterface;
|
||||
use Flarum\Filter\FilterState;
|
||||
use Flarum\Filter\ValidateFilterTrait;
|
||||
use Flarum\Http\SlugManager;
|
||||
use Flarum\Search\AbstractRegexGambit;
|
||||
use Flarum\Search\Database\DatabaseSearchState;
|
||||
use Flarum\Search\Filter\FilterInterface;
|
||||
use Flarum\Search\SearchState;
|
||||
use Flarum\Search\ValidateFilterTrait;
|
||||
use Flarum\Tags\Tag;
|
||||
use Flarum\User\User;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
|
||||
class TagFilterGambit extends AbstractRegexGambit implements FilterInterface
|
||||
/**
|
||||
* @implements FilterInterface<DatabaseSearchState>
|
||||
*/
|
||||
class TagFilter implements FilterInterface
|
||||
{
|
||||
use ValidateFilterTrait;
|
||||
|
||||
|
@ -29,24 +31,14 @@ class TagFilterGambit extends AbstractRegexGambit implements FilterInterface
|
|||
) {
|
||||
}
|
||||
|
||||
protected function getGambitPattern(): string
|
||||
{
|
||||
return 'tag:(.+)';
|
||||
}
|
||||
|
||||
protected function conditions(SearchState $search, array $matches, bool $negate): void
|
||||
{
|
||||
$this->constrain($search->getQuery(), $matches[1], $negate, $search->getActor());
|
||||
}
|
||||
|
||||
public function getFilterKey(): string
|
||||
{
|
||||
return 'tag';
|
||||
}
|
||||
|
||||
public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void
|
||||
public function filter(SearchState $state, string|array $value, bool $negate): void
|
||||
{
|
||||
$this->constrain($filterState->getQuery(), $filterValue, $negate, $filterState->getActor());
|
||||
$this->constrain($state->getQuery(), $value, $negate, $state->getActor());
|
||||
}
|
||||
|
||||
protected function constrain(Builder $query, string|array $rawSlugs, bool $negate, User $actor): void
|
|
@ -7,14 +7,18 @@
|
|||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tags\Search\Gambit;
|
||||
namespace Flarum\Tags\Search;
|
||||
|
||||
use Flarum\Search\GambitInterface;
|
||||
use Flarum\Search\AbstractFulltextFilter;
|
||||
use Flarum\Search\Database\DatabaseSearchState;
|
||||
use Flarum\Search\SearchState;
|
||||
use Flarum\Tags\TagRepository;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class FulltextGambit implements GambitInterface
|
||||
/**
|
||||
* @extends AbstractFulltextFilter<DatabaseSearchState>
|
||||
*/
|
||||
class FulltextFilter extends AbstractFulltextFilter
|
||||
{
|
||||
public function __construct(
|
||||
protected TagRepository $tags
|
||||
|
@ -30,14 +34,12 @@ class FulltextGambit implements GambitInterface
|
|||
->orWhere('slug', 'like', "$searchValue%");
|
||||
}
|
||||
|
||||
public function apply(SearchState $search, string $bit): bool
|
||||
public function search(SearchState $state, string $value): void
|
||||
{
|
||||
$search->getQuery()
|
||||
$state->getQuery()
|
||||
->whereIn(
|
||||
'id',
|
||||
$this->getTagSearchSubQuery($bit)
|
||||
$this->getTagSearchSubQuery($value)
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -7,21 +7,21 @@
|
|||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tags\Filter;
|
||||
namespace Flarum\Tags\Search;
|
||||
|
||||
use Flarum\Filter\FilterState;
|
||||
use Flarum\Query\QueryCriteria;
|
||||
use Flarum\Search\Database\DatabaseSearchState;
|
||||
use Flarum\Search\SearchCriteria;
|
||||
use Flarum\Tags\Tag;
|
||||
|
||||
class HideHiddenTagsFromAllDiscussionsPage
|
||||
{
|
||||
public function __invoke(FilterState $filter, QueryCriteria $queryCriteria): void
|
||||
public function __invoke(DatabaseSearchState $state, SearchCriteria $queryCriteria): void
|
||||
{
|
||||
if (count($filter->getActiveFilters()) > 0) {
|
||||
if (count($state->getActiveFilters()) > 0 || $state->isFulltextSearch()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$filter->getQuery()->whereNotIn('discussions.id', function ($query) {
|
||||
$state->getQuery()->whereNotIn('discussions.id', function ($query) {
|
||||
return $query->select('discussion_id')
|
||||
->from('discussion_tag')
|
||||
->whereIn('tag_id', Tag::where('is_hidden', 1)->pluck('id'));
|
|
@ -9,24 +9,15 @@
|
|||
|
||||
namespace Flarum\Tags\Search;
|
||||
|
||||
use Flarum\Search\AbstractSearcher;
|
||||
use Flarum\Search\GambitManager;
|
||||
use Flarum\Tags\TagRepository;
|
||||
use Flarum\Search\Database\AbstractSearcher;
|
||||
use Flarum\Tags\Tag;
|
||||
use Flarum\User\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class TagSearcher extends AbstractSearcher
|
||||
{
|
||||
public function __construct(
|
||||
protected TagRepository $tags,
|
||||
GambitManager $gambits,
|
||||
array $searchMutators
|
||||
) {
|
||||
parent::__construct($gambits, $searchMutators);
|
||||
}
|
||||
|
||||
protected function getQuery(User $actor): Builder
|
||||
public function getQuery(User $actor): Builder
|
||||
{
|
||||
return $this->tags->query()->whereVisibleTo($actor);
|
||||
return Tag::whereVisibleTo($actor)->select('tags.*');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'));
|
||||
}
|
||||
|
||||
|
|
|
@ -40,7 +40,9 @@ export interface AdminApplicationData extends ApplicationData {
|
|||
modelStatistics: Record<string, { total: number }>;
|
||||
displayNameDrivers: string[];
|
||||
slugDrivers: Record<string, string[]>;
|
||||
searchDrivers: Record<string, string[]>;
|
||||
permissions: Record<string, string[]>;
|
||||
advancedPageEmpty: boolean;
|
||||
}
|
||||
|
||||
export default class AdminApplication extends Application {
|
||||
|
|
|
@ -110,6 +110,16 @@ export default class AdminNav extends Component {
|
|||
50
|
||||
);
|
||||
|
||||
if (app.data.settings.show_advanced_settings && !app.data.advancedPageEmpty) {
|
||||
items.add(
|
||||
'advanced',
|
||||
<LinkButton href={app.route('advanced')} icon="fas fa-cog" title={app.translator.trans('core.admin.nav.advanced_title')}>
|
||||
{app.translator.trans('core.admin.nav.advanced_button')}
|
||||
</LinkButton>,
|
||||
40
|
||||
);
|
||||
}
|
||||
|
||||
items.add(
|
||||
'search',
|
||||
<div className="Search-input">
|
||||
|
|
|
@ -14,6 +14,7 @@ import ColorPreviewInput from '../../common/components/ColorPreviewInput';
|
|||
import ItemList from '../../common/utils/ItemList';
|
||||
import type { IUploadImageButtonAttrs } from './UploadImageButton';
|
||||
import UploadImageButton from './UploadImageButton';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
|
||||
export interface AdminHeaderOptions {
|
||||
title: Mithril.Children;
|
||||
|
@ -410,4 +411,12 @@ export default abstract class AdminPage<CustomAttrs extends IPageAttrs = IPageAt
|
|||
|
||||
return saveSettings(this.dirty()).then(this.onsaved.bind(this));
|
||||
}
|
||||
|
||||
modelLocale(): Record<string, string> {
|
||||
return {
|
||||
'Flarum\\Discussion\\Discussion': extractText(app.translator.trans('core.admin.models.discussions')),
|
||||
'Flarum\\User\\User': extractText(app.translator.trans('core.admin.models.users')),
|
||||
'Flarum\\Post\\Post': extractText(app.translator.trans('core.admin.models.posts')),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
73
framework/core/js/src/admin/components/AdvancedPage.tsx
Normal file
73
framework/core/js/src/admin/components/AdvancedPage.tsx
Normal 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')),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
|
@ -5,8 +5,13 @@ import AdminPage from './AdminPage';
|
|||
import type { IPageAttrs } from '../../common/components/Page';
|
||||
import type Mithril from 'mithril';
|
||||
import Form from '../../common/components/Form';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
|
||||
export type HomePageItem = { path: string; label: Mithril.Children };
|
||||
export type DriverLocale = {
|
||||
display_name: Record<string, string>;
|
||||
slug: Record<string, Record<string, string>>;
|
||||
};
|
||||
|
||||
export default class BasicsPage<CustomAttrs extends IPageAttrs = IPageAttrs> extends AdminPage<CustomAttrs> {
|
||||
localeOptions: Record<string, string> = {};
|
||||
|
@ -20,15 +25,17 @@ export default class BasicsPage<CustomAttrs extends IPageAttrs = IPageAttrs> ext
|
|||
this.localeOptions[i] = `${app.data.locales[i]} (${i})`;
|
||||
});
|
||||
|
||||
const driverLocale = this.driverLocale();
|
||||
|
||||
app.data.displayNameDrivers.forEach((identifier) => {
|
||||
this.displayNameOptions[identifier] = identifier;
|
||||
this.displayNameOptions[identifier] = driverLocale.display_name[identifier] || identifier;
|
||||
});
|
||||
|
||||
Object.keys(app.data.slugDrivers).forEach((model) => {
|
||||
this.slugDriverOptions[model] = {};
|
||||
|
||||
app.data.slugDrivers[model].forEach((option) => {
|
||||
this.slugDriverOptions[model][option] = option;
|
||||
this.slugDriverOptions[model][option] = (driverLocale.slug[model] && driverLocale.slug[model][option]) || option;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -108,14 +115,15 @@ export default class BasicsPage<CustomAttrs extends IPageAttrs = IPageAttrs> ext
|
|||
|
||||
{Object.keys(this.slugDriverOptions).map((model) => {
|
||||
const options = this.slugDriverOptions[model];
|
||||
const modelLocale = this.modelLocale()[model] || model;
|
||||
|
||||
if (Object.keys(options).length > 1) {
|
||||
return this.buildSettingComponent({
|
||||
type: 'select',
|
||||
setting: `slug_driver_${model}`,
|
||||
options,
|
||||
label: app.translator.trans('core.admin.basics.slug_driver_heading', { model }),
|
||||
help: app.translator.trans('core.admin.basics.slug_driver_text', { model }),
|
||||
label: app.translator.trans('core.admin.basics.slug_driver_heading', { model: modelLocale }),
|
||||
help: app.translator.trans('core.admin.basics.slug_driver_text', { model: modelLocale }),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -141,4 +149,22 @@ export default class BasicsPage<CustomAttrs extends IPageAttrs = IPageAttrs> ext
|
|||
|
||||
return items;
|
||||
}
|
||||
|
||||
driverLocale(): DriverLocale {
|
||||
return {
|
||||
display_name: {
|
||||
username: extractText(app.translator.trans('core.admin.basics.display_name_driver_options.username')),
|
||||
},
|
||||
slug: {
|
||||
'Flarum\\Discussion\\Discussion': {
|
||||
default: extractText(app.translator.trans('core.admin.basics.slug_driver_options.discussions.default')),
|
||||
utf8: extractText(app.translator.trans('core.admin.basics.slug_driver_options.discussions.utf8')),
|
||||
},
|
||||
'Flarum\\User\\User': {
|
||||
default: extractText(app.translator.trans('core.admin.basics.slug_driver_options.users.default')),
|
||||
id: extractText(app.translator.trans('core.admin.basics.slug_driver_options.users.id')),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
35
framework/core/js/src/admin/components/FormSectionGroup.tsx
Normal file
35
framework/core/js/src/admin/components/FormSectionGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -6,6 +6,7 @@ import Dropdown from '../../common/components/Dropdown';
|
|||
import Button from '../../common/components/Button';
|
||||
import LoadingModal from './LoadingModal';
|
||||
import LinkButton from '../../common/components/LinkButton';
|
||||
import saveSettings from '../utils/saveSettings.js';
|
||||
|
||||
export default class StatusWidget extends DashboardWidget {
|
||||
className() {
|
||||
|
@ -71,6 +72,25 @@ export default class StatusWidget extends DashboardWidget {
|
|||
<Button onclick={this.handleClearCache.bind(this)}>{app.translator.trans('core.admin.dashboard.clear_cache_button')}</Button>
|
||||
);
|
||||
|
||||
if (!app.data.advancedPageEmpty) {
|
||||
items.add(
|
||||
'toggleAdvancedPage',
|
||||
<Button
|
||||
onclick={() => {
|
||||
saveSettings({
|
||||
show_advanced_settings: !app.data.settings.show_advanced_settings,
|
||||
});
|
||||
|
||||
if (app.data.settings.show_advanced_settings) {
|
||||
m.route.set(app.route('advanced'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{app.translator.trans('core.admin.dashboard.toggle_advanced_page_button')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import MailPage from './components/MailPage';
|
|||
import UserListPage from './components/UserListPage';
|
||||
import ExtensionPage from './components/ExtensionPage';
|
||||
import ExtensionPageResolver from './resolvers/ExtensionPageResolver';
|
||||
import AdvancedPage from './components/AdvancedPage';
|
||||
|
||||
/**
|
||||
* Helper functions to generate URLs to admin pages.
|
||||
|
@ -24,6 +25,7 @@ export default function (app: AdminApplication) {
|
|||
appearance: { path: '/appearance', component: AppearancePage },
|
||||
mail: { path: '/mail', component: MailPage },
|
||||
users: { path: '/users', component: UserListPage },
|
||||
advanced: { path: '/advanced', component: AdvancedPage },
|
||||
extension: { path: '/extension/:id', component: ExtensionPage, resolverClass: ExtensionPageResolver },
|
||||
};
|
||||
}
|
||||
|
|
72
framework/core/js/src/common/GambitManager.ts
Normal file
72
framework/core/js/src/common/GambitManager.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import app from '../common/app';
|
||||
import { FlarumRequestOptions } from './Application';
|
||||
import Model, { ModelData, SavedModelData } from './Model';
|
||||
import GambitManager from './GambitManager';
|
||||
|
||||
export interface MetaInformation {
|
||||
[key: string]: any;
|
||||
|
@ -20,7 +21,7 @@ export interface ApiQueryParamsPlural {
|
|||
| {
|
||||
q: string;
|
||||
}
|
||||
| Record<string, string>;
|
||||
| Record<string, any>;
|
||||
page?: {
|
||||
near?: number;
|
||||
offset?: number;
|
||||
|
@ -88,6 +89,12 @@ export default class Store {
|
|||
*/
|
||||
models: Record<string, { new (): Model }>;
|
||||
|
||||
/**
|
||||
* The gambit manager that will convert search query gambits
|
||||
* into API filters.
|
||||
*/
|
||||
gambits = new GambitManager();
|
||||
|
||||
constructor(models: Record<string, { new (): Model }>) {
|
||||
this.models = models;
|
||||
}
|
||||
|
@ -178,6 +185,10 @@ export default class Store {
|
|||
url += '/' + idOrParams;
|
||||
}
|
||||
|
||||
if ('filter' in params && params?.filter?.q) {
|
||||
params.filter = this.gambits.apply(type, params.filter);
|
||||
}
|
||||
|
||||
return app
|
||||
.request<M extends Array<infer _T> ? ApiPayloadPlural : ApiPayloadSingle>({
|
||||
method: 'GET',
|
||||
|
|
24
framework/core/js/src/common/extenders/Search.ts
Normal file
24
framework/core/js/src/common/extenders/Search.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,12 +2,14 @@ import Model from './Model';
|
|||
import PostTypes from './PostTypes';
|
||||
import Routes from './Routes';
|
||||
import Store from './Store';
|
||||
import Search from './Search';
|
||||
|
||||
const extenders = {
|
||||
Model,
|
||||
PostTypes,
|
||||
Routes,
|
||||
Store,
|
||||
Search,
|
||||
};
|
||||
|
||||
export default extenders;
|
||||
|
|
6
framework/core/js/src/common/query/IGambit.ts
Normal file
6
framework/core/js/src/common/query/IGambit.ts
Normal 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;
|
||||
}
|
|
@ -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}`;
|
||||
}
|
||||
}
|
|
@ -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}`;
|
||||
}
|
||||
}
|
|
@ -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`;
|
||||
}
|
||||
}
|
|
@ -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`;
|
||||
}
|
||||
}
|
23
framework/core/js/src/common/query/users/EmailGambit.ts
Normal file
23
framework/core/js/src/common/query/users/EmailGambit.ts
Normal 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}`;
|
||||
}
|
||||
}
|
23
framework/core/js/src/common/query/users/GroupGambit.ts
Normal file
23
framework/core/js/src/common/query/users/GroupGambit.ts
Normal 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}`;
|
||||
}
|
||||
}
|
|
@ -48,10 +48,15 @@ export default class DiscussionsSearchSource implements SearchSource {
|
|||
);
|
||||
}) as Array<Mithril.Vnode>;
|
||||
|
||||
const filter = app.store.gambits.apply('discussions', { q: query });
|
||||
const q = filter.q || null;
|
||||
|
||||
delete filter.q;
|
||||
|
||||
return [
|
||||
<li className="Dropdown-header">{app.translator.trans('core.forum.search.discussions_heading')}</li>,
|
||||
<li>
|
||||
<LinkButton icon="fas fa-search" href={app.route('index', { q: query })}>
|
||||
<LinkButton icon="fas fa-search" href={app.route('index', { q, filter })}>
|
||||
{app.translator.trans('core.forum.search.all_discussions_button', { query })}
|
||||
</LinkButton>
|
||||
</li>,
|
||||
|
|
|
@ -36,7 +36,14 @@ export default class GlobalSearchState extends SearchState {
|
|||
* @inheritdoc
|
||||
*/
|
||||
getInitialSearch(): string {
|
||||
return this.currPageProvidesSearch() ? this.params().q : '';
|
||||
return this.currPageProvidesSearch() ? this.searchToQuery() : '';
|
||||
}
|
||||
|
||||
private searchToQuery(): string {
|
||||
const q = this.params().q || '';
|
||||
const filter = this.params().filter || {};
|
||||
|
||||
return app.store.gambits.from('users', app.store.gambits.from('discussions', q, filter), filter).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -57,7 +64,7 @@ export default class GlobalSearchState extends SearchState {
|
|||
* 'x' is clicked in the search box in the header.
|
||||
*/
|
||||
protected clearInitialSearch() {
|
||||
const { q, ...params } = this.params();
|
||||
const { q, filter, ...params } = this.params();
|
||||
|
||||
setRouteWithForcedRefresh(app.route(app.current.get('routeName'), params));
|
||||
}
|
||||
|
@ -71,6 +78,9 @@ export default class GlobalSearchState extends SearchState {
|
|||
return {
|
||||
sort: m.route.param('sort'),
|
||||
q: m.route.param('q'),
|
||||
// Objects must be copied, otherwise they are passed by reference.
|
||||
// Which could end up undesirably modifying the mithril route params.
|
||||
filter: Object.assign({}, m.route.param('filter')),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -80,8 +90,6 @@ export default class GlobalSearchState extends SearchState {
|
|||
params(): SearchParams {
|
||||
const params = this.stickyParams();
|
||||
|
||||
params.filter = m.route.param('filter');
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
|
|
34
framework/core/js/tests/unit/common/GambitManager.test.ts
Normal file
34
framework/core/js/tests/unit/common/GambitManager.test.ts
Normal 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'],
|
||||
});
|
||||
});
|
|
@ -5,6 +5,7 @@
|
|||
@import "admin/CreateUserModal";
|
||||
@import "admin/DashboardPage";
|
||||
@import "admin/DebugWarningWidget";
|
||||
@import "admin/FormSectionGroup";
|
||||
@import "admin/BasicsPage";
|
||||
@import "admin/PermissionsPage";
|
||||
@import "admin/EditGroupModal";
|
||||
|
|
24
framework/core/less/admin/FormSectionGroup.less
Normal file
24
framework/core/less/admin/FormSectionGroup.less
Normal 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);
|
||||
}
|
|
@ -7,6 +7,17 @@ core:
|
|||
# Translations in this namespace are used by the admin interface.
|
||||
admin:
|
||||
|
||||
# These translations are used in the Advanced page.
|
||||
advanced:
|
||||
description: "Configure advanced settings for your forum."
|
||||
search:
|
||||
section_label: Search Drivers
|
||||
driver_heading: "Search Driver: {model}"
|
||||
driver_text: Select a driver to be used for searching this model.
|
||||
driver_options:
|
||||
default: Default database search
|
||||
title: Advanced
|
||||
|
||||
# These translations are used in the Appearance page.
|
||||
appearance:
|
||||
colored_header_label: Colored Header
|
||||
|
@ -38,6 +49,8 @@ core:
|
|||
all_discussions_label: => core.ref.all_discussions
|
||||
default_language_heading: Default Language
|
||||
description: "Set your forum title, language, and other basic settings."
|
||||
display_name_driver_options:
|
||||
username: Username
|
||||
display_name_heading: User Display Name
|
||||
display_name_text: Select the driver that should be used for users' display names. By default, the username is shown.
|
||||
forum_description_heading: Forum Description
|
||||
|
@ -46,6 +59,13 @@ core:
|
|||
home_page_heading: Home Page
|
||||
home_page_text: Choose the page which users will first see when they visit your forum.
|
||||
show_language_selector_label: Show language selector
|
||||
slug_driver_options:
|
||||
discussions:
|
||||
default: ID with slug
|
||||
utf8: ID with UTF-8 slug
|
||||
users:
|
||||
default: Username
|
||||
id: ID
|
||||
slug_driver_heading: "Slug Driver: {model}"
|
||||
slug_driver_text: Select a driver to be used for slugging this model.
|
||||
title: Basics
|
||||
|
@ -78,6 +98,7 @@ core:
|
|||
inactive: Inactive
|
||||
never-run: Never run
|
||||
title: Dashboard
|
||||
toggle_advanced_page_button: Toggle Advanced Page
|
||||
tools_button: Tools
|
||||
|
||||
# These translations are used in the debug warning widget.
|
||||
|
@ -183,8 +204,16 @@ core:
|
|||
loading:
|
||||
title: Please Wait...
|
||||
|
||||
# These translations are used anywhere to localize model names for drivers.
|
||||
models:
|
||||
discussions: => core.ref.discussions
|
||||
posts: => core.ref.posts
|
||||
users: => core.ref.users
|
||||
|
||||
# These translations are used in the navigation bar.
|
||||
nav:
|
||||
advanced_button: => core.admin.advanced.title
|
||||
advanced_title: => core.admin.advanced.description
|
||||
appearance_button: => core.admin.appearance.title
|
||||
appearance_title: => core.admin.appearance.description
|
||||
basics_button: => core.admin.basics.title
|
||||
|
|
|
@ -9,11 +9,14 @@
|
|||
|
||||
namespace Flarum\Admin\Content;
|
||||
|
||||
use Flarum\Database\AbstractModel;
|
||||
use Flarum\Extension\ExtensionManager;
|
||||
use Flarum\Foundation\ApplicationInfoProvider;
|
||||
use Flarum\Foundation\Config;
|
||||
use Flarum\Frontend\Document;
|
||||
use Flarum\Group\Permission;
|
||||
use Flarum\Search\AbstractDriver;
|
||||
use Flarum\Search\SearcherInterface;
|
||||
use Flarum\Settings\Event\Deserializing;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Flarum\User\User;
|
||||
|
@ -52,6 +55,9 @@ class AdminPayload
|
|||
$document->payload['slugDrivers'] = array_map(function ($resourceDrivers) {
|
||||
return array_keys($resourceDrivers);
|
||||
}, $this->container->make('flarum.http.slugDrivers'));
|
||||
$document->payload['searchDrivers'] = $this->getSearchDrivers();
|
||||
|
||||
$document->payload['advancedPageEmpty'] = $this->checkAdvancedPageEmpty();
|
||||
|
||||
$document->payload['phpVersion'] = $this->appInfo->identifyPHPVersion();
|
||||
$document->payload['mysqlVersion'] = $this->appInfo->identifyDatabaseVersion();
|
||||
|
@ -77,4 +83,24 @@ class AdminPayload
|
|||
]
|
||||
];
|
||||
}
|
||||
|
||||
protected function getSearchDrivers(): array
|
||||
{
|
||||
$searchDriversPerModel = [];
|
||||
|
||||
foreach ($this->container->make('flarum.search.drivers') as $driverClass => $searcherClasses) {
|
||||
/** @var array<class-string<AbstractModel>, class-string<SearcherInterface>> $searcherClasses */
|
||||
foreach ($searcherClasses as $modelClass => $searcherClass) {
|
||||
/** @var class-string<AbstractDriver> $driverClass */
|
||||
$searchDriversPerModel[$modelClass][] = $driverClass::name();
|
||||
}
|
||||
}
|
||||
|
||||
return $searchDriversPerModel;
|
||||
}
|
||||
|
||||
protected function checkAdvancedPageEmpty(): bool
|
||||
{
|
||||
return count($this->container->make('flarum.search.drivers')) === 1;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,10 +10,11 @@
|
|||
namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Api\Serializer\AccessTokenSerializer;
|
||||
use Flarum\Http\Filter\AccessTokenFilterer;
|
||||
use Flarum\Http\AccessToken;
|
||||
use Flarum\Http\RequestUtil;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Query\QueryCriteria;
|
||||
use Flarum\Search\SearchCriteria;
|
||||
use Flarum\Search\SearchManager;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Tobscure\JsonApi\Document;
|
||||
|
||||
|
@ -23,7 +24,7 @@ class ListAccessTokensController extends AbstractListController
|
|||
|
||||
public function __construct(
|
||||
protected UrlGenerator $url,
|
||||
protected AccessTokenFilterer $filterer
|
||||
protected SearchManager $search
|
||||
) {
|
||||
}
|
||||
|
||||
|
@ -37,7 +38,7 @@ class ListAccessTokensController extends AbstractListController
|
|||
$limit = $this->extractLimit($request);
|
||||
$filter = $this->extractFilter($request);
|
||||
|
||||
$tokens = $this->filterer->filter(new QueryCriteria($actor, $filter), $limit, $offset);
|
||||
$tokens = $this->search->query(AccessToken::class, new SearchCriteria($actor, $filter, $limit, $offset));
|
||||
|
||||
$document->addPaginationLinks(
|
||||
$this->url->to('api')->route('access-tokens.index'),
|
||||
|
|
|
@ -11,11 +11,10 @@ namespace Flarum\Api\Controller;
|
|||
|
||||
use Flarum\Api\Serializer\DiscussionSerializer;
|
||||
use Flarum\Discussion\Discussion;
|
||||
use Flarum\Discussion\Filter\DiscussionFilterer;
|
||||
use Flarum\Discussion\Search\DiscussionSearcher;
|
||||
use Flarum\Http\RequestUtil;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Query\QueryCriteria;
|
||||
use Flarum\Search\SearchCriteria;
|
||||
use Flarum\Search\SearchManager;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Tobscure\JsonApi\Document;
|
||||
|
||||
|
@ -40,8 +39,7 @@ class ListDiscussionsController extends AbstractListController
|
|||
public array $sortFields = ['lastPostedAt', 'commentCount', 'createdAt'];
|
||||
|
||||
public function __construct(
|
||||
protected DiscussionFilterer $filterer,
|
||||
protected DiscussionSearcher $searcher,
|
||||
protected SearchManager $search,
|
||||
protected UrlGenerator $url
|
||||
) {
|
||||
}
|
||||
|
@ -57,12 +55,10 @@ class ListDiscussionsController extends AbstractListController
|
|||
$offset = $this->extractOffset($request);
|
||||
$include = array_merge($this->extractInclude($request), ['state']);
|
||||
|
||||
$criteria = new QueryCriteria($actor, $filters, $sort, $sortIsDefault);
|
||||
if (array_key_exists('q', $filters)) {
|
||||
$results = $this->searcher->search($criteria, $limit, $offset);
|
||||
} else {
|
||||
$results = $this->filterer->filter($criteria, $limit, $offset);
|
||||
}
|
||||
$results = $this->search->query(
|
||||
Discussion::class,
|
||||
new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault)
|
||||
);
|
||||
|
||||
$this->addPaginationData(
|
||||
$document,
|
||||
|
|
|
@ -10,10 +10,11 @@
|
|||
namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Api\Serializer\GroupSerializer;
|
||||
use Flarum\Group\Filter\GroupFilterer;
|
||||
use Flarum\Group\Group;
|
||||
use Flarum\Http\RequestUtil;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Query\QueryCriteria;
|
||||
use Flarum\Search\SearchCriteria;
|
||||
use Flarum\Search\SearchManager;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Tobscure\JsonApi\Document;
|
||||
|
||||
|
@ -26,7 +27,7 @@ class ListGroupsController extends AbstractListController
|
|||
public int $limit = -1;
|
||||
|
||||
public function __construct(
|
||||
protected GroupFilterer $filterer,
|
||||
protected SearchManager $search,
|
||||
protected UrlGenerator $url
|
||||
) {
|
||||
}
|
||||
|
@ -42,9 +43,10 @@ class ListGroupsController extends AbstractListController
|
|||
$limit = $this->extractLimit($request);
|
||||
$offset = $this->extractOffset($request);
|
||||
|
||||
$criteria = new QueryCriteria($actor, $filters, $sort, $sortIsDefault);
|
||||
|
||||
$queryResults = $this->filterer->filter($criteria, $limit, $offset);
|
||||
$queryResults = $this->search->query(
|
||||
Group::class,
|
||||
new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault)
|
||||
);
|
||||
|
||||
$document->addPaginationLinks(
|
||||
$this->url->to('api')->route('groups.index'),
|
||||
|
|
|
@ -12,9 +12,10 @@ namespace Flarum\Api\Controller;
|
|||
use Flarum\Api\Serializer\PostSerializer;
|
||||
use Flarum\Http\RequestUtil;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Post\Filter\PostFilterer;
|
||||
use Flarum\Post\Post;
|
||||
use Flarum\Post\PostRepository;
|
||||
use Flarum\Query\QueryCriteria;
|
||||
use Flarum\Search\SearchCriteria;
|
||||
use Flarum\Search\SearchManager;
|
||||
use Illuminate\Support\Arr;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Tobscure\JsonApi\Document;
|
||||
|
@ -35,7 +36,7 @@ class ListPostsController extends AbstractListController
|
|||
public array $sortFields = ['number', 'createdAt'];
|
||||
|
||||
public function __construct(
|
||||
protected PostFilterer $filterer,
|
||||
protected SearchManager $search,
|
||||
protected PostRepository $posts,
|
||||
protected UrlGenerator $url
|
||||
) {
|
||||
|
@ -53,7 +54,10 @@ class ListPostsController extends AbstractListController
|
|||
$offset = $this->extractOffset($request);
|
||||
$include = $this->extractInclude($request);
|
||||
|
||||
$results = $this->filterer->filter(new QueryCriteria($actor, $filters, $sort, $sortIsDefault), $limit, $offset);
|
||||
$results = $this->search->query(
|
||||
Post::class,
|
||||
new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault)
|
||||
);
|
||||
|
||||
$document->addPaginationLinks(
|
||||
$this->url->to('api')->route('posts.index'),
|
||||
|
|
|
@ -12,9 +12,9 @@ namespace Flarum\Api\Controller;
|
|||
use Flarum\Api\Serializer\UserSerializer;
|
||||
use Flarum\Http\RequestUtil;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Query\QueryCriteria;
|
||||
use Flarum\User\Filter\UserFilterer;
|
||||
use Flarum\User\Search\UserSearcher;
|
||||
use Flarum\Search\SearchCriteria;
|
||||
use Flarum\Search\SearchManager;
|
||||
use Flarum\User\User;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Tobscure\JsonApi\Document;
|
||||
|
||||
|
@ -33,8 +33,7 @@ class ListUsersController extends AbstractListController
|
|||
];
|
||||
|
||||
public function __construct(
|
||||
protected UserFilterer $filterer,
|
||||
protected UserSearcher $searcher,
|
||||
protected SearchManager $search,
|
||||
protected UrlGenerator $url
|
||||
) {
|
||||
}
|
||||
|
@ -60,12 +59,10 @@ class ListUsersController extends AbstractListController
|
|||
$offset = $this->extractOffset($request);
|
||||
$include = $this->extractInclude($request);
|
||||
|
||||
$criteria = new QueryCriteria($actor, $filters, $sort, $sortIsDefault);
|
||||
if (array_key_exists('q', $filters)) {
|
||||
$results = $this->searcher->search($criteria, $limit, $offset);
|
||||
} else {
|
||||
$results = $this->filterer->filter($criteria, $limit, $offset);
|
||||
}
|
||||
$results = $this->search->query(
|
||||
User::class,
|
||||
new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault)
|
||||
);
|
||||
|
||||
$document->addPaginationLinks(
|
||||
$this->url->to('api')->route('users.index'),
|
||||
|
|
|
@ -237,4 +237,14 @@ abstract class AbstractModel extends Eloquent
|
|||
{
|
||||
return new Collection($models);
|
||||
}
|
||||
|
||||
public function __sleep()
|
||||
{
|
||||
// Closures cannot be serialized.
|
||||
// We should not need them if we are serializing a model.
|
||||
$this->afterSaveCallbacks = [];
|
||||
$this->afterDeleteCallbacks = [];
|
||||
|
||||
return parent::__sleep();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,9 @@ use Flarum\User\User;
|
|||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
/**
|
||||
* @method static Builder whereVisibleTo(User $user)
|
||||
*/
|
||||
trait ScopeVisibilityTrait
|
||||
{
|
||||
/**
|
||||
|
|
|
@ -84,6 +84,8 @@ class Discussion extends AbstractModel
|
|||
'hidden_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $observables = ['hidden'];
|
||||
|
||||
/**
|
||||
* The user for which the state relationship should be loaded.
|
||||
*/
|
||||
|
@ -142,6 +144,12 @@ class Discussion extends AbstractModel
|
|||
$this->hidden_user_id = $actor?->id;
|
||||
|
||||
$this->raise(new Hidden($this));
|
||||
|
||||
$this->saved(function (self $model) {
|
||||
if ($model === $this) {
|
||||
$model->fireModelEvent('hidden', false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $this;
|
||||
|
@ -154,6 +162,12 @@ class Discussion extends AbstractModel
|
|||
$this->hidden_user_id = null;
|
||||
|
||||
$this->raise(new Restored($this));
|
||||
|
||||
$this->saved(function (self $model) {
|
||||
if ($model === $this) {
|
||||
$model->fireModelEvent('restored', false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $this;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,26 +9,15 @@
|
|||
|
||||
namespace Flarum\Discussion\Search;
|
||||
|
||||
use Flarum\Discussion\DiscussionRepository;
|
||||
use Flarum\Search\AbstractSearcher;
|
||||
use Flarum\Search\GambitManager;
|
||||
use Flarum\Discussion\Discussion;
|
||||
use Flarum\Search\Database\AbstractSearcher;
|
||||
use Flarum\User\User;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class DiscussionSearcher extends AbstractSearcher
|
||||
{
|
||||
public function __construct(
|
||||
protected DiscussionRepository $discussions,
|
||||
protected Dispatcher $events,
|
||||
GambitManager $gambits,
|
||||
array $searchMutators
|
||||
) {
|
||||
parent::__construct($gambits, $searchMutators);
|
||||
}
|
||||
|
||||
protected function getQuery(User $actor): Builder
|
||||
public function getQuery(User $actor): Builder
|
||||
{
|
||||
return $this->discussions->query()->select('discussions.*')->whereVisibleTo($actor);
|
||||
return Discussion::whereVisibleTo($actor)->select('discussions.*');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,17 +7,19 @@
|
|||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Discussion\Query;
|
||||
namespace Flarum\Discussion\Search\Filter;
|
||||
|
||||
use Flarum\Filter\FilterInterface;
|
||||
use Flarum\Filter\FilterState;
|
||||
use Flarum\Filter\ValidateFilterTrait;
|
||||
use Flarum\Search\AbstractRegexGambit;
|
||||
use Flarum\Search\Database\DatabaseSearchState;
|
||||
use Flarum\Search\Filter\FilterInterface;
|
||||
use Flarum\Search\SearchState;
|
||||
use Flarum\Search\ValidateFilterTrait;
|
||||
use Flarum\User\UserRepository;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
|
||||
class AuthorFilterGambit extends AbstractRegexGambit implements FilterInterface
|
||||
/**
|
||||
* @implements FilterInterface<DatabaseSearchState>
|
||||
*/
|
||||
class AuthorFilter implements FilterInterface
|
||||
{
|
||||
use ValidateFilterTrait;
|
||||
|
||||
|
@ -26,24 +28,14 @@ class AuthorFilterGambit extends AbstractRegexGambit implements FilterInterface
|
|||
) {
|
||||
}
|
||||
|
||||
public function getGambitPattern(): string
|
||||
{
|
||||
return 'author:(.+)';
|
||||
}
|
||||
|
||||
protected function conditions(SearchState $search, array $matches, bool $negate): void
|
||||
{
|
||||
$this->constrain($search->getQuery(), $matches[1], $negate);
|
||||
}
|
||||
|
||||
public function getFilterKey(): string
|
||||
{
|
||||
return 'author';
|
||||
}
|
||||
|
||||
public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void
|
||||
public function filter(SearchState $state, string|array $value, bool $negate): void
|
||||
{
|
||||
$this->constrain($filterState->getQuery(), $filterValue, $negate);
|
||||
$this->constrain($state->getQuery(), $value, $negate);
|
||||
}
|
||||
|
||||
protected function constrain(Builder $query, string|array $rawUsernames, bool $negate): void
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,34 +7,26 @@
|
|||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Discussion\Query;
|
||||
namespace Flarum\Discussion\Search\Filter;
|
||||
|
||||
use Flarum\Filter\FilterInterface;
|
||||
use Flarum\Filter\FilterState;
|
||||
use Flarum\Search\AbstractRegexGambit;
|
||||
use Flarum\Search\Database\DatabaseSearchState;
|
||||
use Flarum\Search\Filter\FilterInterface;
|
||||
use Flarum\Search\SearchState;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
|
||||
class HiddenFilterGambit extends AbstractRegexGambit implements FilterInterface
|
||||
/**
|
||||
* @implements FilterInterface<DatabaseSearchState>
|
||||
*/
|
||||
class HiddenFilter implements FilterInterface
|
||||
{
|
||||
public function getGambitPattern(): string
|
||||
{
|
||||
return 'is:hidden';
|
||||
}
|
||||
|
||||
protected function conditions(SearchState $search, array $matches, bool $negate): void
|
||||
{
|
||||
$this->constrain($search->getQuery(), $negate);
|
||||
}
|
||||
|
||||
public function getFilterKey(): string
|
||||
{
|
||||
return 'hidden';
|
||||
}
|
||||
|
||||
public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void
|
||||
public function filter(SearchState $state, string|array $value, bool $negate): void
|
||||
{
|
||||
$this->constrain($filterState->getQuery(), $negate);
|
||||
$this->constrain($state->getQuery(), $negate);
|
||||
}
|
||||
|
||||
protected function constrain(Builder $query, bool $negate): void
|
|
@ -7,39 +7,23 @@
|
|||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Discussion\Query;
|
||||
namespace Flarum\Discussion\Search\Filter;
|
||||
|
||||
use Flarum\Discussion\DiscussionRepository;
|
||||
use Flarum\Filter\FilterInterface;
|
||||
use Flarum\Filter\FilterState;
|
||||
use Flarum\Search\AbstractRegexGambit;
|
||||
use Flarum\Search\Database\DatabaseSearchState;
|
||||
use Flarum\Search\Filter\FilterInterface;
|
||||
use Flarum\Search\SearchState;
|
||||
use Flarum\User\User;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
|
||||
class UnreadFilterGambit extends AbstractRegexGambit implements FilterInterface
|
||||
/**
|
||||
* @implements FilterInterface<DatabaseSearchState>
|
||||
*/
|
||||
class UnreadFilter implements FilterInterface
|
||||
{
|
||||
/**
|
||||
* @var \Flarum\Discussion\DiscussionRepository
|
||||
*/
|
||||
protected $discussions;
|
||||
|
||||
/**
|
||||
* @param \Flarum\Discussion\DiscussionRepository $discussions
|
||||
*/
|
||||
public function __construct(DiscussionRepository $discussions)
|
||||
{
|
||||
$this->discussions = $discussions;
|
||||
}
|
||||
|
||||
public function getGambitPattern(): string
|
||||
{
|
||||
return 'is:unread';
|
||||
}
|
||||
|
||||
protected function conditions(SearchState $search, array $matches, bool $negate): void
|
||||
{
|
||||
$this->constrain($search->getQuery(), $search->getActor(), $negate);
|
||||
public function __construct(
|
||||
protected DiscussionRepository $discussions
|
||||
) {
|
||||
}
|
||||
|
||||
public function getFilterKey(): string
|
||||
|
@ -47,9 +31,9 @@ class UnreadFilterGambit extends AbstractRegexGambit implements FilterInterface
|
|||
return 'unread';
|
||||
}
|
||||
|
||||
public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void
|
||||
public function filter(SearchState $state, string|array $value, bool $negate): void
|
||||
{
|
||||
$this->constrain($filterState->getQuery(), $filterState->getActor(), $negate);
|
||||
$this->constrain($state->getQuery(), $state->getActor(), $negate);
|
||||
}
|
||||
|
||||
protected function constrain(Builder $query, User $actor, bool $negate): void
|
|
@ -7,41 +7,46 @@
|
|||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Discussion\Search\Gambit;
|
||||
namespace Flarum\Discussion\Search;
|
||||
|
||||
use Flarum\Discussion\Discussion;
|
||||
use Flarum\Post\Post;
|
||||
use Flarum\Search\GambitInterface;
|
||||
use Flarum\Search\AbstractFulltextFilter;
|
||||
use Flarum\Search\Database\DatabaseSearchState;
|
||||
use Flarum\Search\SearchState;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Database\Query\Expression;
|
||||
|
||||
class FulltextGambit implements GambitInterface
|
||||
/**
|
||||
* @extends AbstractFulltextFilter<DatabaseSearchState>
|
||||
*/
|
||||
class FulltextFilter extends AbstractFulltextFilter
|
||||
{
|
||||
public function apply(SearchState $search, string $bit): bool
|
||||
public function search(SearchState $state, string $value): void
|
||||
{
|
||||
// Replace all non-word characters with spaces.
|
||||
// We do this to prevent MySQL fulltext search boolean mode from taking
|
||||
// effect: https://dev.mysql.com/doc/refman/5.7/en/fulltext-boolean.html
|
||||
$bit = preg_replace('/[^\p{L}\p{N}\p{M}_]+/u', ' ', $bit);
|
||||
$value = preg_replace('/[^\p{L}\p{N}\p{M}_]+/u', ' ', $value);
|
||||
|
||||
$query = $search->getQuery();
|
||||
$query = $state->getQuery();
|
||||
$grammar = $query->getGrammar();
|
||||
|
||||
$discussionSubquery = Discussion::select('id')
|
||||
->selectRaw('NULL as score')
|
||||
->selectRaw('first_post_id as most_relevant_post_id')
|
||||
->whereRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (? IN BOOLEAN MODE)', [$bit]);
|
||||
->whereRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (? IN BOOLEAN MODE)', [$value]);
|
||||
|
||||
// Construct a subquery to fetch discussions which contain relevant
|
||||
// posts. Retrieve the collective relevance of each discussion's posts,
|
||||
// which we will use later in the order by clause, and also retrieve
|
||||
// the ID of the most relevant post.
|
||||
$subquery = Post::whereVisibleTo($search->getActor())
|
||||
$subquery = Post::whereVisibleTo($state->getActor())
|
||||
->select('posts.discussion_id')
|
||||
->selectRaw('SUM(MATCH('.$grammar->wrap('posts.content').') AGAINST (?)) as score', [$bit])
|
||||
->selectRaw('SUBSTRING_INDEX(GROUP_CONCAT('.$grammar->wrap('posts.id').' ORDER BY MATCH('.$grammar->wrap('posts.content').') AGAINST (?) DESC, '.$grammar->wrap('posts.number').'), \',\', 1) as most_relevant_post_id', [$bit])
|
||||
->selectRaw('SUM(MATCH('.$grammar->wrap('posts.content').') AGAINST (?)) as score', [$value])
|
||||
->selectRaw('SUBSTRING_INDEX(GROUP_CONCAT('.$grammar->wrap('posts.id').' ORDER BY MATCH('.$grammar->wrap('posts.content').') AGAINST (?) DESC, '.$grammar->wrap('posts.number').'), \',\', 1) as most_relevant_post_id', [$value])
|
||||
->where('posts.type', 'comment')
|
||||
->whereRaw('MATCH('.$grammar->wrap('posts.content').') AGAINST (? IN BOOLEAN MODE)', [$bit])
|
||||
->whereRaw('MATCH('.$grammar->wrap('posts.content').') AGAINST (? IN BOOLEAN MODE)', [$value])
|
||||
->groupBy('posts.discussion_id')
|
||||
->union($discussionSubquery);
|
||||
|
||||
|
@ -58,11 +63,9 @@ class FulltextGambit implements GambitInterface
|
|||
->groupBy('discussions.id')
|
||||
->addBinding($subquery->getBindings(), 'join');
|
||||
|
||||
$search->setDefaultSort(function ($query) use ($grammar, $bit) {
|
||||
$query->orderByRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (?) desc', [$bit]);
|
||||
$state->setDefaultSort(function (Builder $query) use ($grammar, $value) {
|
||||
$query->orderByRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (?) desc', [$value]);
|
||||
$query->orderBy('posts_ft.score', 'desc');
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -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
Loading…
Reference in New Issue
Block a user