Compare commits
17 Commits
main-backu
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
bf19eed20f | |
|
|
2fc66b9bf7 | |
|
|
439c74f0a3 | |
|
|
f486292f15 | |
|
|
4412583cd9 | |
|
|
81e7b6502d | |
|
|
3039b65946 | |
|
|
e2e2d1428f | |
|
|
80d66c98c4 | |
|
|
ff2a252e99 | |
|
|
5929a191f6 | |
|
|
6699c8cb58 | |
|
|
369ee504f3 | |
|
|
eeb091273d | |
|
|
d252a17586 | |
|
|
a9dc8bfb16 | |
|
|
82e6ec786f |
143
README.md
143
README.md
|
|
@ -5,47 +5,154 @@ fulltext search with one that is completely relying on the proven elasticsearch
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Sync discussions to elastic search using your queue, unobtrusively for the user.
|
- Sync discussions and posts to Elasticsearch using your queue, unobtrusively for the user.
|
||||||
- Reduces search loading times to well below 400ms (local tests with 50.000 discussion **260ms**)
|
- Reduces search loading times to well below 400ms (local tests with 50,000 discussions: **260ms**)
|
||||||
- Uses Flarum's group permissions and tags system.
|
- Uses Flarum's group permissions and tags system.
|
||||||
- Compatible with Friends of Flarum Byōbu.
|
- Compatible with Friends of Flarum Byōbu.
|
||||||
|
|
||||||
## Installation
|
## Requirements
|
||||||
|
|
||||||
Use composer:
|
- Elasticsearch 7.x or OpenSearch 1.x+
|
||||||
|
- A non-sync queue driver with a running worker (`php flarum queue:work`) is strongly recommended for production. The extension works with the default sync driver, but index jobs run inline which adds latency to user-facing changes like posting.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
composer require blomstra/search:*
|
composer require blomstra/search:*
|
||||||
```
|
```
|
||||||
|
|
||||||
Enable the extension inside the admin area and configure the settings.
|
Enable the extension in the admin area and configure the Elasticsearch endpoint and index name in the extension settings.
|
||||||
|
|
||||||
### Set up
|
## Setting up the index
|
||||||
|
|
||||||
Enable the extension in your admin area. Now to seed your existing discussions use the following command:
|
### First install
|
||||||
|
|
||||||
```
|
Run the build command once. It creates a timestamped concrete index, immediately aliases your configured index name to it, and begins queuing documents. Search is available and improves as the queue processes:
|
||||||
php flarum blomstra:search:index
|
|
||||||
|
```bash
|
||||||
|
php flarum blomstra:search:index build
|
||||||
|
php flarum queue:work
|
||||||
```
|
```
|
||||||
|
|
||||||
All mutations to discussions are automatically added and removed from the elasticsearch index.
|
### Subsequent rebuilds (zero-downtime)
|
||||||
|
|
||||||
### FAQ
|
When you need to rebuild the full index (e.g. after a mapping change):
|
||||||
|
|
||||||
*I have another question.*
|
```bash
|
||||||
Reach out to us via https://helpdesk.blomstra.net. We will get back to you as soon as we can. If you have a running subscription please mention when you started your plan and/or which plan you are on. Always add sufficient information when reporting errors. We prefer errors being reported here, but understand that sometimes you can't.
|
# Simple rebuild — promotes automatically once all jobs are queued
|
||||||
|
php flarum blomstra:search:index build
|
||||||
|
|
||||||
|
# Or keep a backup of the old index in case you need to roll back
|
||||||
|
php flarum blomstra:search:index build --keep-backup
|
||||||
|
```
|
||||||
|
|
||||||
|
After the queue drains, fill any gaps from content posted during the build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php flarum blomstra:search:index fill
|
||||||
|
```
|
||||||
|
|
||||||
|
If you kept a backup and want to roll back:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php flarum blomstra:search:index rollback
|
||||||
|
```
|
||||||
|
|
||||||
|
Once satisfied with the new index, drop the backup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php flarum blomstra:search:index discard --backup
|
||||||
|
```
|
||||||
|
|
||||||
|
### Blue-green rebuild (manual promotion)
|
||||||
|
|
||||||
|
Use `--staging` to keep the old index live until you explicitly promote:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Build into a staging index — live index is untouched
|
||||||
|
php flarum blomstra:search:index build --staging
|
||||||
|
|
||||||
|
# 2. Drain the queue
|
||||||
|
php flarum queue:work --stop-when-empty
|
||||||
|
|
||||||
|
# 3. Promote the staging index to live
|
||||||
|
php flarum blomstra:search:index promote
|
||||||
|
# Or keep the old index as a backup:
|
||||||
|
php flarum blomstra:search:index promote --keep-backup
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resuming or cancelling an interrupted build
|
||||||
|
|
||||||
|
If a build is interrupted, re-run it with the appropriate flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Resume each seeder from where it left off
|
||||||
|
php flarum blomstra:search:index build --resume
|
||||||
|
|
||||||
|
# Drop the staging index and start completely fresh
|
||||||
|
php flarum blomstra:search:index build --fresh
|
||||||
|
|
||||||
|
# Cancel the build without starting a new one
|
||||||
|
php flarum blomstra:search:index discard --pending
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filling gaps in an existing index
|
||||||
|
|
||||||
|
If documents are missing from the live index (e.g. due to queue failures):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php flarum blomstra:search:index fill
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updating the mapping only
|
||||||
|
|
||||||
|
To push a mapping change to the live index without rebuilding:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php flarum blomstra:search:index mapping
|
||||||
|
```
|
||||||
|
|
||||||
|
## Command reference
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `build` | Rebuild the index and promote automatically once all jobs are queued. On first install, aliases immediately so search is live during seeding. |
|
||||||
|
| `build --keep-backup` | Rebuild and promote, retaining the old index as a backup for rollback. |
|
||||||
|
| `build --staging` | Build into a staging index without promoting — use `promote` when ready (blue-green workflow). |
|
||||||
|
| `build --resume` | Resume an interrupted build from where each seeder left off. |
|
||||||
|
| `build --fresh` | Drop the staging index and start completely fresh. |
|
||||||
|
| `promote` | Atomically swap the alias to the staging index. Prompts for confirmation (blue-green workflow). |
|
||||||
|
| `promote --keep-backup` | Promote and retain the replaced live index as a backup for rollback. |
|
||||||
|
| `rollback` | Restore the backup index to live. Deletes the index that was live. |
|
||||||
|
| `discard --pending` | Drop the staging index without promoting (cancels an in-progress build). |
|
||||||
|
| `discard --backup` | Drop the backup index (cleanup after `--keep-backup`). |
|
||||||
|
| `mapping` | Push updated mapping to the live index without rebuilding or reseeding. |
|
||||||
|
| `fill` | Seed only documents missing from the live index. |
|
||||||
|
| `build --only=discussions` | Seed only the specified document type (`discussions` or `posts`). |
|
||||||
|
| `build --throttle=N` | Wait N seconds between batches (reduces queue pressure). |
|
||||||
|
| `build --max-id=N` | Limit seeding to documents with ID ≤ N. |
|
||||||
|
| `promote --i-am-sure` | Skip the promotion confirmation prompt (for scripts and CI). |
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
## Queue configuration
|
||||||
|
|
||||||
|
*"Can I dispatch indexing jobs to a specific queue?"*
|
||||||
|
|
||||||
*Can I dispatch the sync jobs to another queue?*
|
|
||||||
Yes:
|
Yes:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
\Blomstra\Search\Observe\Job::$onQueue = 'sync';
|
\Blomstra\Search\Jobs\Job::$onQueue = 'search';
|
||||||
```
|
```
|
||||||
|
|
||||||
|
*"I have a different question"*
|
||||||
|
|
||||||
|
Reach out ot us via https://support.on-floxum.com/t/ext-search . If you have an active subscription, please mention what plan you are on.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- Blomstra provides managed Flarum hosting.
|
- Floxum provides managed Flarum hosting.
|
||||||
- https://blomstra.net
|
- https://floxum.com
|
||||||
- https://blomstra.community/t/ext-search
|
- https://support.on-floxum.com/t/ext-search
|
||||||
|
|
||||||
Icon made by [Freepik](https://www.freepik.com) from [Flaticon](https://www.flaticon.com/).
|
Icon made by [Freepik](https://www.freepik.com) from [Flaticon](https://www.flaticon.com/).
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,10 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">= 8.1",
|
"php": ">= 8.0",
|
||||||
"flarum/core": "^2.0",
|
"flarum/core": "^1.2.0",
|
||||||
"elasticsearch/elasticsearch": "7.17.2",
|
"elasticsearch/elasticsearch": "7.*",
|
||||||
"spatie/elasticsearch-query-builder": "^1.4.0"
|
"spatie/elasticsearch-query-builder": "^1.3.0"
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"flarum/core": "^2.x-dev"
|
|
||||||
},
|
},
|
||||||
"extra": {
|
"extra": {
|
||||||
"flarum-extension": {
|
"flarum-extension": {
|
||||||
|
|
@ -48,10 +45,5 @@
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Blomstra\\Search\\": "src/"
|
"Blomstra\\Search\\": "src/"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"allow-plugins": {
|
|
||||||
"php-http/discovery": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
extend.php
25
extend.php
|
|
@ -12,38 +12,27 @@
|
||||||
|
|
||||||
namespace Blomstra\Search;
|
namespace Blomstra\Search;
|
||||||
|
|
||||||
use Flarum\Discussion\Discussion as FlarumDiscussion;
|
|
||||||
use Flarum\Extend as Flarum;
|
use Flarum\Extend as Flarum;
|
||||||
use Flarum\Post\Post as FlarumPost;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
(new Flarum\ServiceProvider())->register(Provider::class),
|
||||||
|
|
||||||
(new Flarum\Frontend('forum'))
|
(new Flarum\Frontend('forum'))
|
||||||
->js(__DIR__.'/js/dist/forum.js'),
|
->js(__DIR__.'/js/dist/forum.js'),
|
||||||
(new Flarum\Frontend('admin'))
|
(new Flarum\Frontend('admin'))
|
||||||
->js(__DIR__.'/js/dist/admin.js'),
|
->js(__DIR__.'/js/dist/admin.js')
|
||||||
|
->css(__DIR__.'/resources/less/admin.less'),
|
||||||
|
|
||||||
new Flarum\Locales(__DIR__.'/resources/locale'),
|
new Flarum\Locales(__DIR__.'/resources/locale'),
|
||||||
|
|
||||||
(new Flarum\ServiceProvider())
|
|
||||||
->register(Provider::class),
|
|
||||||
|
|
||||||
(new Flarum\Routes('api'))
|
(new Flarum\Routes('api'))
|
||||||
|
->get('/blomstra/search/{type}', 'blomstra.search', Api\Controllers\SearchController::class)
|
||||||
->put('/blomstra/search/index', 'blomstra.search.index', Api\Controllers\IndexController::class),
|
->put('/blomstra/search/index', 'blomstra.search.index', Api\Controllers\IndexController::class),
|
||||||
|
|
||||||
(new Flarum\Console())
|
(new Flarum\Console())
|
||||||
->command(Commands\BuildCommand::class),
|
->command(Commands\BuildCommand::class),
|
||||||
|
|
||||||
(new Flarum\Settings())
|
(new Flarum\Settings())
|
||||||
->default('blomstra-search.search-discussion-subjects', true)
|
->default('blomstra-search.search-discussion-subjects', true)
|
||||||
->default('blomstra-search.search-post-bodies', true),
|
->default('blomstra-search.search-post-bodies', true)
|
||||||
|
->default('blomstra-search.min-search-length', Commands\BuildCommand::DEFAULT_MIN_SEARCH_LENGTH),
|
||||||
(new Flarum\SearchDriver(Search\ElasticSearchDriver::class))
|
|
||||||
->addSearcher(FlarumDiscussion::class, Discussion\DiscussionSearcher::class)
|
|
||||||
->addFilter(Discussion\DiscussionSearcher::class, Discussion\PrivateFilterMutator::class)
|
|
||||||
->addMutator(Discussion\DiscussionSearcher::class, Discussion\PrivateFilterMutator::mutate(...))
|
|
||||||
->setFulltext(Discussion\DiscussionSearcher::class, Discussion\FulltextFilter::class),
|
|
||||||
|
|
||||||
(new Flarum\SearchIndex())
|
|
||||||
->indexer(FlarumDiscussion::class, Discussion\DiscussionIndexer::class)
|
|
||||||
->indexer(FlarumPost::class, Post\CommentPostIndexer::class),
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
(()=>{var a={n:e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return a.d(t,{a:t}),t},d:(e,t)=>{for(var r in t)a.o(t,r)&&!a.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},o:(a,e)=>Object.prototype.hasOwnProperty.call(a,e),r:a=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(a,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(a,"__esModule",{value:!0})}},e={};(()=>{"use strict";a.r(e);const t=flarum.core.compat["admin/app"];var r=a.n(t);r().initializers.add("blomstra-search",(function(){var a=new Map;["arabic","armenian","basque","bengali","brazilian","bulgarian","catalan","cjk","czech","danish","dutch","english","estonian","finnish","french","galician","german","greek","hindi","hungarian","indonesian","irish","italian","latvian","lithuanian","norwegian","persian","portuguese","romanian","russian","sorani","spanish","swedish","turkish","thai"].forEach((function(e){a.set(e,e)})),r().extensionData.for("blomstra-search").registerSetting({setting:"blomstra-search.elastic-endpoint",label:r().translator.trans("blomstra-search.admin.elastic-endpoint"),type:"input"}).registerSetting({setting:"blomstra-search.elastic-username",label:r().translator.trans("blomstra-search.admin.elastic-username"),type:"input"}).registerSetting({setting:"blomstra-search.elastic-password",label:r().translator.trans("blomstra-search.admin.elastic-password"),type:"password"}).registerSetting({setting:"blomstra-search.elastic-index",label:r().translator.trans("blomstra-search.admin.elastic-index"),default:"flarum",type:"input"}).registerSetting({setting:"blomstra-search.analyzer-language",label:r().translator.trans("blomstra-search.admin.analyzer.label"),help:r().translator.trans("blomstra-search.admin.analyzer.help"),type:"select",options:Object.fromEntries(a.entries()),default:"english"}).registerSetting({setting:"blomstra-search.elastic-index",label:r().translator.trans("blomstra-search.admin.elastic-index"),default:"flarum",type:"input"}).registerSetting({setting:"blomstra-search.search-discussion-subjects",label:r().translator.trans("blomstra-search.admin.search-discussion-subjects"),type:"switch"}).registerSetting({setting:"blomstra-search.search-post-bodies",label:r().translator.trans("blomstra-search.admin.search-post-bodies"),type:"switch"})}))})(),module.exports=e})();
|
(()=>{var t={n:a=>{var e=a&&a.__esModule?()=>a.default:()=>a;return t.d(e,{a:e}),e},d:(a,e)=>{for(var r in e)t.o(e,r)&&!t.o(a,r)&&Object.defineProperty(a,r,{enumerable:!0,get:e[r]})},o:(t,a)=>Object.prototype.hasOwnProperty.call(t,a)};(()=>{"use strict";function a(t,e){return a=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,a){return t.__proto__=a,t},a(t,e)}const e=flarum.core.compat["admin/app"];var r=t.n(e);const n=flarum.core.compat["common/extend"],s=flarum.core.compat["admin/components/DashboardPage"];var i=t.n(s);const o=flarum.core.compat["admin/components/DashboardWidget"];var l=t.n(o);const c=flarum.core.compat["common/components/Alert"];var h=t.n(c),d=function(t){var e,n;function s(){return t.apply(this,arguments)||this}n=t,(e=s).prototype=Object.create(n.prototype),e.prototype.constructor=e,a(e,n);var i=s.prototype;return i.className=function(){return"ReindexWarningWidget"},i.content=function(){return m(h(),{type:"warning",dismissible:!1,icon:"fas fa-exclamation-triangle",title:r().translator.trans("blomstra-search.admin.reindex-required.title")},r().translator.trans("blomstra-search.admin.reindex-required.detail"))},s}(l());r().initializers.add("blomstra-search",function(){var t=r().data.settings["blomstra-search.active-index"],a=r().data.settings["blomstra-search.index-compatible"];t&&"v2"!==a&&(0,n.extend)(i().prototype,"availableWidgets",function(t){t.add("blomstra-search-reindex",m(d),110)});var e=new Map;["arabic","armenian","basque","bengali","brazilian","bulgarian","catalan","cjk","czech","danish","dutch","english","estonian","finnish","french","galician","german","greek","hindi","hungarian","indonesian","irish","italian","latvian","lithuanian","norwegian","persian","portuguese","romanian","russian","sorani","spanish","swedish","turkish","thai"].forEach(function(t){e.set(t,t)}),r().extensionData.for("blomstra-search").registerSetting(function(){var a=r().data.settings["blomstra-search.indexed-analyzer"];if(!t||!a)return null;var e=this.setting("blomstra-search.analyzer-language")()||"english",n=this.setting("blomstra-search.min-search-length")(),s=String(r().data.settings["blomstra-search.indexed-min-search-length"]||r().data.settings["blomstra-search.min-search-length"]);return e===a&&n===s?null:m(h(),{type:"warning",dismissible:!1,icon:"fas fa-exclamation-triangle"},r().translator.trans("blomstra-search.admin.index-settings-changed"))}).registerSetting({setting:"blomstra-search.elastic-endpoint",label:r().translator.trans("blomstra-search.admin.elastic-endpoint"),type:"input"}).registerSetting({setting:"blomstra-search.elastic-username",label:r().translator.trans("blomstra-search.admin.elastic-username"),type:"input"}).registerSetting({setting:"blomstra-search.elastic-password",label:r().translator.trans("blomstra-search.admin.elastic-password"),type:"password"}).registerSetting({setting:"blomstra-search.elastic-index",label:r().translator.trans("blomstra-search.admin.elastic-index"),default:"flarum",type:"input"}).registerSetting({setting:"blomstra-search.analyzer-language",label:r().translator.trans("blomstra-search.admin.analyzer.label"),help:r().translator.trans("blomstra-search.admin.analyzer.help"),type:"select",options:Object.fromEntries(e.entries()),default:"english"}).registerSetting({setting:"blomstra-search.search-discussion-subjects",label:r().translator.trans("blomstra-search.admin.search-discussion-subjects"),type:"switch"}).registerSetting({setting:"blomstra-search.search-post-bodies",label:r().translator.trans("blomstra-search.admin.search-post-bodies"),type:"switch"}).registerSetting({setting:"blomstra-search.min-search-length",label:r().translator.trans("blomstra-search.admin.min-search-length.label"),help:r().translator.trans("blomstra-search.admin.min-search-length.help"),type:"select",options:{1:"1",2:"2",3:"3",4:"4"},default:r().data.settings["blomstra-search.min-search-length"]})})})(),module.exports={}})();
|
||||||
//# sourceMappingURL=admin.js.map
|
//# sourceMappingURL=admin.js.map
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +1,40 @@
|
||||||
import app from 'flarum/admin/app';
|
import app from 'flarum/admin/app';
|
||||||
|
import { extend } from 'flarum/common/extend';
|
||||||
|
import DashboardPage from 'flarum/admin/components/DashboardPage';
|
||||||
|
import DashboardWidget from 'flarum/admin/components/DashboardWidget';
|
||||||
|
import Alert from 'flarum/common/components/Alert';
|
||||||
|
|
||||||
|
const REQUIRED_INDEX_COMPAT = 'v2';
|
||||||
|
|
||||||
|
class ReindexWarningWidget extends DashboardWidget {
|
||||||
|
className() {
|
||||||
|
return 'ReindexWarningWidget';
|
||||||
|
}
|
||||||
|
|
||||||
|
content() {
|
||||||
|
return m(
|
||||||
|
Alert,
|
||||||
|
{
|
||||||
|
type: 'warning',
|
||||||
|
dismissible: false,
|
||||||
|
icon: 'fas fa-exclamation-triangle',
|
||||||
|
title: app.translator.trans('blomstra-search.admin.reindex-required.title'),
|
||||||
|
},
|
||||||
|
app.translator.trans('blomstra-search.admin.reindex-required.detail')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
app.initializers.add('blomstra-search', () => {
|
app.initializers.add('blomstra-search', () => {
|
||||||
|
const activeIndex = app.data.settings['blomstra-search.active-index'];
|
||||||
|
const compatVersion = app.data.settings['blomstra-search.index-compatible'];
|
||||||
|
|
||||||
|
if (activeIndex && compatVersion !== REQUIRED_INDEX_COMPAT) {
|
||||||
|
extend(DashboardPage.prototype, 'availableWidgets', function (items) {
|
||||||
|
items.add('blomstra-search-reindex', m(ReindexWarningWidget), 110);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const languages = new Map();
|
const languages = new Map();
|
||||||
[
|
[
|
||||||
'arabic',
|
'arabic',
|
||||||
|
|
@ -44,6 +78,25 @@ app.initializers.add('blomstra-search', () => {
|
||||||
|
|
||||||
app.extensionData
|
app.extensionData
|
||||||
.for('blomstra-search')
|
.for('blomstra-search')
|
||||||
|
.registerSetting(function (this: any) {
|
||||||
|
// No index yet, or first build predates this tracking — stay silent.
|
||||||
|
const indexedAnalyzer = app.data.settings['blomstra-search.indexed-analyzer'];
|
||||||
|
if (!activeIndex || !indexedAnalyzer) return null;
|
||||||
|
|
||||||
|
const currentAnalyzer = this.setting('blomstra-search.analyzer-language')() || 'english';
|
||||||
|
const currentMinLength = this.setting('blomstra-search.min-search-length')();
|
||||||
|
const indexedMinLength = String(
|
||||||
|
app.data.settings['blomstra-search.indexed-min-search-length'] || app.data.settings['blomstra-search.min-search-length']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentAnalyzer === indexedAnalyzer && currentMinLength === indexedMinLength) return null;
|
||||||
|
|
||||||
|
return m(
|
||||||
|
Alert,
|
||||||
|
{ type: 'warning', dismissible: false, icon: 'fas fa-exclamation-triangle' },
|
||||||
|
app.translator.trans('blomstra-search.admin.index-settings-changed')
|
||||||
|
);
|
||||||
|
})
|
||||||
.registerSetting({
|
.registerSetting({
|
||||||
setting: 'blomstra-search.elastic-endpoint',
|
setting: 'blomstra-search.elastic-endpoint',
|
||||||
label: app.translator.trans('blomstra-search.admin.elastic-endpoint'),
|
label: app.translator.trans('blomstra-search.admin.elastic-endpoint'),
|
||||||
|
|
@ -73,12 +126,6 @@ app.initializers.add('blomstra-search', () => {
|
||||||
options: Object.fromEntries(languages.entries()),
|
options: Object.fromEntries(languages.entries()),
|
||||||
default: 'english',
|
default: 'english',
|
||||||
})
|
})
|
||||||
.registerSetting({
|
|
||||||
setting: 'blomstra-search.elastic-index',
|
|
||||||
label: app.translator.trans('blomstra-search.admin.elastic-index'),
|
|
||||||
default: 'flarum',
|
|
||||||
type: 'input',
|
|
||||||
})
|
|
||||||
.registerSetting({
|
.registerSetting({
|
||||||
setting: 'blomstra-search.search-discussion-subjects',
|
setting: 'blomstra-search.search-discussion-subjects',
|
||||||
label: app.translator.trans('blomstra-search.admin.search-discussion-subjects'),
|
label: app.translator.trans('blomstra-search.admin.search-discussion-subjects'),
|
||||||
|
|
@ -88,5 +135,13 @@ app.initializers.add('blomstra-search', () => {
|
||||||
setting: 'blomstra-search.search-post-bodies',
|
setting: 'blomstra-search.search-post-bodies',
|
||||||
label: app.translator.trans('blomstra-search.admin.search-post-bodies'),
|
label: app.translator.trans('blomstra-search.admin.search-post-bodies'),
|
||||||
type: 'switch',
|
type: 'switch',
|
||||||
|
})
|
||||||
|
.registerSetting({
|
||||||
|
setting: 'blomstra-search.min-search-length',
|
||||||
|
label: app.translator.trans('blomstra-search.admin.min-search-length.label'),
|
||||||
|
help: app.translator.trans('blomstra-search.admin.min-search-length.help'),
|
||||||
|
type: 'select',
|
||||||
|
options: { '1': '1', '2': '2', '3': '3', '4': '4' },
|
||||||
|
default: app.data.settings['blomstra-search.min-search-length'],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import app from 'flarum/forum/app';
|
||||||
|
|
||||||
|
import { override } from 'flarum/common/extend';
|
||||||
|
|
||||||
|
import DiscussionListState from 'flarum/forum/states/DiscussionListState';
|
||||||
|
|
||||||
|
export default function extendDiscussionState() {
|
||||||
|
override(DiscussionListState.prototype, 'loadPage', async function (this: DiscussionListState, original, page: number = 1) {
|
||||||
|
const preloaded = app.data.apiDocument || null;
|
||||||
|
|
||||||
|
// If existing payload is given or no search is made, fallback on native page.
|
||||||
|
if (preloaded || !this.requestParams()?.filter?.q) return original.call(this, page);
|
||||||
|
|
||||||
|
const params = this.requestParams();
|
||||||
|
params.page = {
|
||||||
|
offset: this.pageSize * (page - 1),
|
||||||
|
...params.page,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Array.isArray(params.include)) {
|
||||||
|
params.include = params.include.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct API search URI
|
||||||
|
const url = `${app.forum.attribute('apiUrl')}/blomstra/search/${this.type}`;
|
||||||
|
|
||||||
|
// Make API GET request
|
||||||
|
const results = await app.request({ params, url, method: 'GET' });
|
||||||
|
|
||||||
|
// Parse API response into models and push to store
|
||||||
|
return app.store.pushPayload(results);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import app from 'flarum/forum/app';
|
||||||
|
import highlight from 'flarum/common/helpers/highlight';
|
||||||
|
import LinkButton from 'flarum/common/components/LinkButton';
|
||||||
|
import Link from 'flarum/common/components/Link';
|
||||||
|
import { SearchSource } from 'flarum/forum/components/Search';
|
||||||
|
import type Mithril from 'mithril';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `DiscussionsSearchSource` finds and displays discussion search results in
|
||||||
|
* the search dropdown.
|
||||||
|
*/
|
||||||
|
export default class DiscussionsSearchSource implements SearchSource {
|
||||||
|
/**
|
||||||
|
* Map of lowercase search queries to their respective model results
|
||||||
|
*/
|
||||||
|
protected results = new Map<string, any[]>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model name used for assembling API endpoint URL, and for frontend DOM.
|
||||||
|
*/
|
||||||
|
private type = 'discussions';
|
||||||
|
|
||||||
|
async search(query: string): Promise<void> {
|
||||||
|
query = query.toLowerCase();
|
||||||
|
|
||||||
|
this.results.set(query, []);
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
filter: { q: query },
|
||||||
|
page: { limit: 3 },
|
||||||
|
include: 'mostRelevantPost',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Construct API search URI
|
||||||
|
const url = `${app.forum.attribute('apiUrl')}/blomstra/search/${this.type}`;
|
||||||
|
|
||||||
|
// Make API GET request
|
||||||
|
const results = await app.request({ params, url, method: 'GET' });
|
||||||
|
|
||||||
|
// Parse API response into models and push to store
|
||||||
|
const models = app.store.pushPayload(results);
|
||||||
|
|
||||||
|
// Add models to results map
|
||||||
|
this.results.set(query, models);
|
||||||
|
}
|
||||||
|
|
||||||
|
view(query: string): Array<Mithril.Vnode> {
|
||||||
|
query = query.toLowerCase();
|
||||||
|
|
||||||
|
// Get results from map
|
||||||
|
const queryResults = this.results.get(query) || [];
|
||||||
|
|
||||||
|
const results = queryResults.map((discussion: any) => {
|
||||||
|
const mostRelevantPost = discussion.mostRelevantPost();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="DiscussionSearchResult" data-index={`${this.type}${discussion.id()}`}>
|
||||||
|
<Link href={app.route.discussion(discussion, mostRelevantPost && mostRelevantPost.number())}>
|
||||||
|
<div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div>
|
||||||
|
{!!mostRelevantPost && <div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain(), query, 100)}</div>}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 })}>
|
||||||
|
{app.translator.trans('core.forum.search.all_discussions_button', { query })}
|
||||||
|
</LinkButton>
|
||||||
|
</li>,
|
||||||
|
...results,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,32 @@
|
||||||
import app from 'flarum/forum/app';
|
import app from 'flarum/forum/app';
|
||||||
|
|
||||||
|
import Search, { SearchAttrs, SearchSource } from 'flarum/forum/components/Search';
|
||||||
|
|
||||||
|
import { extend } from 'flarum/common/extend';
|
||||||
|
import ItemList from 'flarum/common/utils/ItemList';
|
||||||
|
|
||||||
|
import DiscussionsSearchSource from './SearchSources/DiscussionsSearchSource';
|
||||||
|
import extendDiscussionState from './PaginatedListStates/extendDiscussionState';
|
||||||
|
|
||||||
app.initializers.add('blomstra-search', () => {
|
app.initializers.add('blomstra-search', () => {
|
||||||
//
|
const minLength = parseInt(app.data.settings['blomstra-search.min-search-length'] || String(Search.MIN_SEARCH_LEN), 10);
|
||||||
|
if (minLength !== Search.MIN_SEARCH_LEN) {
|
||||||
|
// Flarum provides no extension point for MIN_SEARCH_LEN, so we overwrite the
|
||||||
|
// static property directly. TypeScript `readonly` is compile-time only — at
|
||||||
|
// runtime this is a plain property assignment and is safe as long as no code
|
||||||
|
// reads MIN_SEARCH_LEN before this initializer runs.
|
||||||
|
(Search as any).MIN_SEARCH_LEN = minLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
extend(Search.prototype, 'sourceItems', function (this: Search<SearchAttrs>, items: ItemList<SearchSource>) {
|
||||||
|
items.replace('discussions', new DiscussionsSearchSource());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.initializers.add(
|
||||||
|
'blomstra-search-early',
|
||||||
|
() => {
|
||||||
|
extendDiscussionState();
|
||||||
|
},
|
||||||
|
999999
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
.ReindexWarningWidget {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
@ -8,8 +8,18 @@ blomstra-search:
|
||||||
label: Analyzer language
|
label: Analyzer language
|
||||||
help: |
|
help: |
|
||||||
The analyzer makes search understand stop words and undertakes language
|
The analyzer makes search understand stop words and undertakes language
|
||||||
specific improvements for indexing.
|
specific improvements for indexing. Changing this requires a full index rebuild to take effect.
|
||||||
|
index-settings-changed: "Search index settings have changed. Run: php flarum blomstra:search:index build"
|
||||||
|
reindex-required:
|
||||||
|
title: Search index rebuild required
|
||||||
|
detail: "The search index is not compatible with this version of the extension. Run: php flarum blomstra:search:index build"
|
||||||
search-discussion-subjects: Search inside discussion titles
|
search-discussion-subjects: Search inside discussion titles
|
||||||
search-post-bodies: Search inside comments
|
search-post-bodies: Search inside comments
|
||||||
match-sentences: Match search term against full sentence
|
match-sentences: Match search term against full sentence
|
||||||
match-words: Match search term against full words
|
match-words: Match search term against full words
|
||||||
|
min-search-length:
|
||||||
|
label: Minimum search query length
|
||||||
|
help: |
|
||||||
|
Minimum number of characters required before a search is triggered. Lower this to 1 or 2
|
||||||
|
for CJK (Chinese, Japanese, Korean) communities where single characters carry full meaning.
|
||||||
|
Changing this requires a full index rebuild to take effect.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of blomstra/search.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2022 Blomstra Ltd.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE.md
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Blomstra\Search\Api;
|
||||||
|
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
|
||||||
|
class Client extends \Flarum\Api\Client
|
||||||
|
{
|
||||||
|
public function get(string $path): ResponseInterface
|
||||||
|
{
|
||||||
|
if ($path === '/discussions' && Arr::has($this->queryParams, 'filter.q')) {
|
||||||
|
return parent::get('/blomstra/search/discussions');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::get($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,11 +12,12 @@
|
||||||
|
|
||||||
namespace Blomstra\Search\Api\Controllers;
|
namespace Blomstra\Search\Api\Controllers;
|
||||||
|
|
||||||
use Blomstra\Search\Elasticsearch\Builder;
|
use Blomstra\Search\Elasticsearch\HasChildQuery;
|
||||||
use Blomstra\Search\Elasticsearch\MatchPhraseQuery;
|
use Blomstra\Search\Elasticsearch\MatchPhraseQuery;
|
||||||
use Blomstra\Search\Elasticsearch\MatchQuery;
|
use Blomstra\Search\Elasticsearch\MatchQuery;
|
||||||
use Blomstra\Search\Elasticsearch\TermsQuery;
|
use Blomstra\Search\Elasticsearch\TermsQuery;
|
||||||
use Blomstra\Search\Save\Document as ElasticDocument;
|
use Blomstra\Search\Searchers\CommentPostSearcher;
|
||||||
|
use Blomstra\Search\Searchers\DiscussionSearcher;
|
||||||
use Blomstra\Search\Searchers\Searcher;
|
use Blomstra\Search\Searchers\Searcher;
|
||||||
use Elasticsearch\Client;
|
use Elasticsearch\Client;
|
||||||
use Flarum\Api\Controller\ListDiscussionsController;
|
use Flarum\Api\Controller\ListDiscussionsController;
|
||||||
|
|
@ -27,14 +28,16 @@ use Flarum\Group\Group;
|
||||||
use Flarum\Http\RequestUtil;
|
use Flarum\Http\RequestUtil;
|
||||||
use Flarum\Http\UrlGenerator;
|
use Flarum\Http\UrlGenerator;
|
||||||
use Flarum\Settings\SettingsRepositoryInterface;
|
use Flarum\Settings\SettingsRepositoryInterface;
|
||||||
|
use Flarum\Tags\Tag;
|
||||||
use Flarum\User\User;
|
use Flarum\User\User;
|
||||||
use Illuminate\Contracts\Container\Container;
|
use Illuminate\Contracts\Container\Container;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Spatie\ElasticsearchQueryBuilder\Builder;
|
||||||
use Spatie\ElasticsearchQueryBuilder\Queries\BoolQuery;
|
use Spatie\ElasticsearchQueryBuilder\Queries\BoolQuery;
|
||||||
use Spatie\ElasticsearchQueryBuilder\Queries\Query;
|
|
||||||
use Spatie\ElasticsearchQueryBuilder\Queries\TermQuery;
|
use Spatie\ElasticsearchQueryBuilder\Queries\TermQuery;
|
||||||
use Spatie\ElasticsearchQueryBuilder\Sorts\Sort;
|
use Spatie\ElasticsearchQueryBuilder\Sorts\Sort;
|
||||||
use Tobscure\JsonApi\Document;
|
use Tobscure\JsonApi\Document;
|
||||||
|
|
@ -47,109 +50,103 @@ class SearchController extends ListDiscussionsController
|
||||||
'lastPostedAt' => 'updated_at',
|
'lastPostedAt' => 'updated_at',
|
||||||
'createdAt' => 'created_at',
|
'createdAt' => 'created_at',
|
||||||
'commentCount' => 'comment_count',
|
'commentCount' => 'comment_count',
|
||||||
|
'view_count' => 'view_count',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected Collection $searchers;
|
|
||||||
protected bool $matchSentences;
|
protected bool $matchSentences;
|
||||||
protected bool $matchWords;
|
protected bool $matchWords;
|
||||||
|
protected ?Searcher $discussionSearcher;
|
||||||
|
protected ?Searcher $postSearcher;
|
||||||
|
|
||||||
public function __construct(protected Client $elastic, protected UrlGenerator $uri, Container $container, SettingsRepositoryInterface $settings)
|
public function __construct(protected Client $elastic, protected UrlGenerator $uri, Container $container, SettingsRepositoryInterface $settings)
|
||||||
{
|
{
|
||||||
$this->searchers = $this->gatherSearchers($container->tagged('blomstra.search.searchers'));
|
|
||||||
|
|
||||||
$this->matchSentences = true;
|
$this->matchSentences = true;
|
||||||
$this->matchWords = true;
|
$this->matchWords = true;
|
||||||
}
|
|
||||||
|
|
||||||
protected function gatherSearchers(iterable $searchers)
|
$searchers = collect($container->tagged('blomstra.search.searchers'));
|
||||||
{
|
|
||||||
return collect($searchers)
|
$this->discussionSearcher = $searchers->first(fn ($s) => $s instanceof DiscussionSearcher);
|
||||||
->map(fn ($searcher) => new $searcher())
|
$this->postSearcher = $searchers->first(fn ($s) => $s instanceof CommentPostSearcher);
|
||||||
->filter(fn (Searcher $searcher) => $searcher->enabled());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function data(ServerRequestInterface $request, Document $document)
|
protected function data(ServerRequestInterface $request, Document $document)
|
||||||
{
|
{
|
||||||
// Not used for now.
|
|
||||||
$type = Arr::get($request->getQueryParams(), 'type');
|
|
||||||
|
|
||||||
$actor = RequestUtil::getActor($request);
|
$actor = RequestUtil::getActor($request);
|
||||||
|
|
||||||
$filters = $this->extractFilter($request);
|
$filters = $this->extractFilter($request);
|
||||||
|
|
||||||
$search = $this->getSearch($filters);
|
$search = $this->getSearch($filters);
|
||||||
|
|
||||||
$limit = $this->extractLimit($request);
|
$limit = $this->extractLimit($request);
|
||||||
$offset = $this->extractOffset($request);
|
$offset = $this->extractOffset($request);
|
||||||
|
|
||||||
$include = array_merge($this->extractInclude($request), ['state']);
|
$include = array_merge($this->extractInclude($request), ['state']);
|
||||||
|
|
||||||
$filterQuery = BoolQuery::create();
|
$query = BoolQuery::create()
|
||||||
|
// Always restrict to discussion documents; posts are only searched via has_child.
|
||||||
|
->add(TermQuery::create('join_field', 'discussion'), 'filter');
|
||||||
|
|
||||||
if (!empty($search)) {
|
if (!empty($search)) {
|
||||||
if ($this->matchSentences) {
|
$query->add($this->buildTextQuery($search, $actor));
|
||||||
$filterQuery->add($this->sentenceMatch($search));
|
|
||||||
}
|
|
||||||
if ($this->matchWords) {
|
|
||||||
$filterQuery->add($this->wordMatch($search, 'and'));
|
|
||||||
}
|
|
||||||
if ($this->matchWords) {
|
|
||||||
$filterQuery->add($this->wordMatch($search, 'or'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->addFilters($query, $actor, $filters);
|
||||||
|
|
||||||
$builder = (new Builder($this->elastic))
|
$builder = (new Builder($this->elastic))
|
||||||
->index(resolve('blomstra.search.elastic_index'))
|
->index(resolve('blomstra.search.elastic_index'))
|
||||||
->size($limit + 1)
|
->size($limit + 1)
|
||||||
->from($this->extractOffset($request))
|
->from($offset)
|
||||||
->addQuery(
|
->addQuery($query);
|
||||||
$this->addFilters($filterQuery, $actor, $filters)
|
|
||||||
);
|
$knownSortFields = array_merge(array_values($this->translateSort), ['rawId']);
|
||||||
|
$logger = resolve(LoggerInterface::class);
|
||||||
|
|
||||||
|
$phpSortField = null;
|
||||||
|
$phpSortDir = 'desc';
|
||||||
|
|
||||||
foreach ($this->extractSort($request) as $field => $direction) {
|
foreach ($this->extractSort($request) as $field => $direction) {
|
||||||
$field = $this->translateSort[$field] ?? $field;
|
$translated = $this->translateSort[$field] ?? $field;
|
||||||
$builder->addSort(new Sort($field, $direction));
|
|
||||||
|
if (!in_array($translated, $knownSortFields)) {
|
||||||
|
$logger->warning("blomstra/search: unknown sort field \"{$field}\", ignoring.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder->addSort(new Sort($translated, $direction));
|
||||||
|
|
||||||
|
if ($phpSortField === null && $translated !== 'rawId') {
|
||||||
|
$phpSortField = $translated;
|
||||||
|
$phpSortDir = $direction;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = $builder->search();
|
$response = $builder->search();
|
||||||
|
|
||||||
Discussion::setStateUser($actor);
|
Discussion::setStateUser($actor);
|
||||||
|
|
||||||
// Eager load groups for use in the policies (isAdmin check)
|
|
||||||
if (in_array('mostRelevantPost.user', $include)) {
|
if (in_array('mostRelevantPost.user', $include)) {
|
||||||
$include[] = 'mostRelevantPost.user.groups';
|
$include[] = 'mostRelevantPost.user.groups';
|
||||||
|
|
||||||
// If the first level of the relationship wasn't explicitly included,
|
|
||||||
// add it so the code below can look for it
|
|
||||||
if (!in_array('mostRelevantPost', $include)) {
|
if (!in_array('mostRelevantPost', $include)) {
|
||||||
$include[] = 'mostRelevantPost';
|
$include[] = 'mostRelevantPost';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// we need to retrieve all discussion ids and when the results are posts,
|
// All hits are discussion documents. Extract the best-matching post ID from
|
||||||
// their ids as most relevant post id
|
// inner_hits when a has_child clause matched (i.e. the match came from a post).
|
||||||
$results = Collection::make(Arr::get($response, 'hits.hits'))
|
$results = Collection::make(Arr::get($response, 'hits.hits'))
|
||||||
->map(function ($hit) {
|
->map(function ($hit) {
|
||||||
$type = $hit['_source']['type'];
|
// _id is "discussions:123" — parse the numeric part directly.
|
||||||
$id = Str::after($hit['_source']['id'], "$type:");
|
$discussionId = Str::after($hit['_id'], 'discussions:');
|
||||||
|
|
||||||
|
// rawId on the inner hit gives us the integer post ID.
|
||||||
|
$bestPostId = Arr::get($hit, 'inner_hits.best_post.hits.hits.0._source.rawId');
|
||||||
|
|
||||||
if ($type === 'posts') {
|
|
||||||
return [
|
return [
|
||||||
'most_relevant_post_id' => $id,
|
'discussion_id' => $discussionId,
|
||||||
'weight' => Arr::get($hit, 'sort.0'),
|
'most_relevant_post_id' => $bestPostId,
|
||||||
|
'weight' => Arr::get($hit, 'sort.0', Arr::get($hit, '_score', 0)),
|
||||||
];
|
];
|
||||||
} else {
|
|
||||||
return [
|
|
||||||
'discussion_id' => $id,
|
|
||||||
'weight' => Arr::get($hit, 'sort.0'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$document->addPaginationLinks(
|
$document->addPaginationLinks(
|
||||||
$this->uri->to('api')->route('blomstra.search', [
|
$this->uri->to('api')->route('blomstra.search', ['type' => 'discussions']),
|
||||||
'type' => 'discussions',
|
|
||||||
]),
|
|
||||||
$request->getQueryParams(),
|
$request->getQueryParams(),
|
||||||
$offset,
|
$offset,
|
||||||
$limit,
|
$limit,
|
||||||
|
|
@ -159,28 +156,25 @@ class SearchController extends ListDiscussionsController
|
||||||
$results = $results->take($limit);
|
$results = $results->take($limit);
|
||||||
|
|
||||||
$discussions = Discussion::query()
|
$discussions = Discussion::query()
|
||||||
->select('discussions.*')
|
->when(
|
||||||
->join('posts', 'posts.discussion_id', 'discussions.id')
|
$actor->isGuest() || !$actor->hasPermission('discussion.hide'),
|
||||||
// Extra safety to prevent leaking hidden discussion (titles) towards search results.
|
fn ($q) => $q->whereNull('hidden_at')
|
||||||
->when($actor->isGuest() || !$actor->hasPermission('discussion.hide'), fn ($query) => $query->whereNull('discussions.hidden_at'))
|
)
|
||||||
->where(function ($query) use ($results) {
|
->whereIn('id', $results->pluck('discussion_id')->filter())
|
||||||
$query
|
|
||||||
->whereIn('discussions.id', $results->pluck('discussion_id')->filter())
|
|
||||||
->orWhereIn('posts.id', $results->pluck('most_relevant_post_id')->filter());
|
|
||||||
})
|
|
||||||
->get()
|
->get()
|
||||||
->each(function (Discussion $discussion) use ($results) {
|
->each(function (Discussion $discussion) use ($results) {
|
||||||
if (in_array($discussion->id, $results->pluck('discussion_id')->toArray())) {
|
$result = $results->firstWhere('discussion_id', $discussion->id);
|
||||||
$discussion->most_relevant_post_id = $discussion->first_post_id;
|
|
||||||
$discussion->weight = $results->firstWhere('discussion_id', $discussion->id)['weight'] ?? 0;
|
$discussion->most_relevant_post_id = $result['most_relevant_post_id']
|
||||||
} else {
|
?? $discussion->first_post_id;
|
||||||
$post = $discussion->posts()->whereIn('id', $results->pluck('most_relevant_post_id'))->first();
|
$discussion->weight = $result['weight'] ?? 0;
|
||||||
$discussion->most_relevant_post_id = $post?->id ?? $discussion->first_post_id;
|
|
||||||
$discussion->weight = $results->firstWhere('most_relevant_post_id', $post?->id)['weight'] ?? 0;
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
->keyBy('id')
|
->keyBy('id')
|
||||||
->sortByDesc('weight')
|
->when(
|
||||||
|
$phpSortField,
|
||||||
|
fn ($c) => $phpSortDir === 'desc' ? $c->sortByDesc($phpSortField) : $c->sortBy($phpSortField),
|
||||||
|
fn ($c) => $c->sortByDesc('weight')
|
||||||
|
)
|
||||||
->unique();
|
->unique();
|
||||||
|
|
||||||
$this->loadRelations($discussions, $include);
|
$this->loadRelations($discussions, $include);
|
||||||
|
|
@ -198,33 +192,77 @@ class SearchController extends ListDiscussionsController
|
||||||
return $discussions;
|
return $discussions;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getDocument(string $type): ?ElasticDocument
|
/**
|
||||||
|
* Build the text-matching portion of the query.
|
||||||
|
*
|
||||||
|
* Discussion titles are matched directly (filtered to join_field=discussion).
|
||||||
|
* Post bodies are matched via has_child with score_mode=sum so discussions
|
||||||
|
* with many matching posts score higher than those with a single strong match.
|
||||||
|
* inner_hits returns the best-scoring post for use as mostRelevantPost.
|
||||||
|
* Hidden posts are only included in matching for users with post.hide permission.
|
||||||
|
*/
|
||||||
|
protected function buildTextQuery(string $search, User $actor): BoolQuery
|
||||||
{
|
{
|
||||||
$documents = resolve(Container::class)->tagged('blomstra.search.documents');
|
$textQuery = BoolQuery::create();
|
||||||
|
|
||||||
return collect($documents)->first(function (ElasticDocument $document) use ($type) {
|
if ($this->discussionSearcher?->enabled()) {
|
||||||
return $document->type() === $type;
|
$textQuery->add($this->buildShouldClauses($search, $this->discussionSearcher->boost()), 'should');
|
||||||
});
|
}
|
||||||
|
|
||||||
|
if ($this->postSearcher?->enabled()) {
|
||||||
|
$postQuery = $this->buildShouldClauses($search, $this->postSearcher->boost());
|
||||||
|
|
||||||
|
// Guests and non-moderators may not see hidden posts; exclude them from child matching.
|
||||||
|
if ($actor->isGuest() || !$actor->hasPermission('post.hide')) {
|
||||||
|
$postQuery->add(TermQuery::create('is_hidden', false), 'filter');
|
||||||
|
}
|
||||||
|
|
||||||
|
$textQuery->add(
|
||||||
|
HasChildQuery::create('post', $postQuery)->withInnerHits(),
|
||||||
|
'should'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $textQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function buildShouldClauses(string $search, float $boost): BoolQuery
|
||||||
|
{
|
||||||
|
$should = BoolQuery::create();
|
||||||
|
|
||||||
|
if ($this->matchSentences) {
|
||||||
|
$should->add((new MatchPhraseQuery('content', $search))->boost(2 * $boost), 'should');
|
||||||
|
}
|
||||||
|
if ($this->matchWords) {
|
||||||
|
$should->add((new MatchQuery('content', $search))->operator('and')->boost(1.8 * $boost), 'should');
|
||||||
|
$should->add((new MatchQuery('content', $search))->operator('or')->boost(0.8 * $boost), 'should');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $should;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function extensionEnabled(string $extension): bool
|
protected function extensionEnabled(string $extension): bool
|
||||||
{
|
{
|
||||||
/** @var ExtensionManager $manager */
|
return resolve(ExtensionManager::class)->isEnabled($extension);
|
||||||
$manager = resolve(ExtensionManager::class);
|
|
||||||
|
|
||||||
return $manager->isEnabled($extension);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function addFilters(BoolQuery $query, User $actor, array $filters = []): BoolQuery
|
protected function addFilters(BoolQuery $query, User $actor, array $filters = []): void
|
||||||
{
|
{
|
||||||
$groups = $this->getGroups($actor);
|
$groups = $this->getGroups($actor);
|
||||||
|
|
||||||
$onlyPrivate = Str::contains($filters['q'] ?? '', 'is:private');
|
$onlyPrivate = Str::contains($filters['q'] ?? '', 'is:private');
|
||||||
|
|
||||||
$subQuery = BoolQuery::create()
|
$subQuery = BoolQuery::create()
|
||||||
->add(TermQuery::create('is_private', 'false'))
|
->add(TermQuery::create('is_private', 'false'))
|
||||||
->add(TermsQuery::create('groups', $groups->toArray()));
|
->add(TermsQuery::create('groups', $groups->toArray()));
|
||||||
|
|
||||||
|
if ($this->extensionEnabled('flarum-tags') && !empty($filters['tag'])) {
|
||||||
|
$slugs = is_array($filters['tag']) ? $filters['tag'] : explode(',', $filters['tag']);
|
||||||
|
$tagIds = Tag::query()->whereIn('slug', $slugs)->pluck('id')->toArray();
|
||||||
|
if (!empty($tagIds)) {
|
||||||
|
$query->add(TermsQuery::create('tags', $tagIds), 'filter');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->extensionEnabled('fof-byobu') && $actor->exists) {
|
if ($this->extensionEnabled('fof-byobu') && $actor->exists) {
|
||||||
$byobuQuery = BoolQuery::create()
|
$byobuQuery = BoolQuery::create()
|
||||||
->add(TermQuery::create('is_private', 'true'))
|
->add(TermQuery::create('is_private', 'true'))
|
||||||
|
|
@ -243,55 +281,12 @@ class SearchController extends ListDiscussionsController
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$query->add(
|
$query->add($subQuery, 'filter');
|
||||||
$subQuery,
|
|
||||||
'filter'
|
|
||||||
);
|
|
||||||
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function boolQuery(Query $parent, float $boost = 1): Query
|
|
||||||
{
|
|
||||||
$bool = new BoolQuery();
|
|
||||||
|
|
||||||
/** @var Searcher $searcher */
|
|
||||||
foreach ($this->searchers as $searcher) {
|
|
||||||
$searcher = new $searcher();
|
|
||||||
|
|
||||||
$bool->add(
|
|
||||||
BoolQuery::create()
|
|
||||||
->add(TermQuery::create('type', $searcher->type()), 'filter')
|
|
||||||
->add(clone $parent->boost($boost * $searcher->boost())),
|
|
||||||
'should'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $bool;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function sentenceMatch(string $q): Query
|
|
||||||
{
|
|
||||||
$query = (new MatchPhraseQuery('content', $q));
|
|
||||||
|
|
||||||
return $this->boolQuery($query, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function wordMatch(string $q, string $operator = 'or'): Query
|
|
||||||
{
|
|
||||||
$query = (new MatchQuery('content', $q))
|
|
||||||
->operator($operator);
|
|
||||||
|
|
||||||
$boost = $operator === 'and' ? 1.8 : .8;
|
|
||||||
|
|
||||||
return $this->boolQuery($query, $boost);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getGroups(User $actor): Collection
|
protected function getGroups(User $actor): Collection
|
||||||
{
|
{
|
||||||
/** @var Collection $groups */
|
|
||||||
$groups = $actor->groups->pluck('id');
|
$groups = $actor->groups->pluck('id');
|
||||||
|
|
||||||
$groups->add(Group::GUEST_ID);
|
$groups->add(Group::GUEST_ID);
|
||||||
|
|
||||||
if ($actor->is_email_confirmed) {
|
if ($actor->is_email_confirmed) {
|
||||||
|
|
@ -307,9 +302,7 @@ class SearchController extends ListDiscussionsController
|
||||||
|
|
||||||
if ($search) {
|
if ($search) {
|
||||||
$q = collect(explode(' ', $search))
|
$q = collect(explode(' ', $search))
|
||||||
->filter(function (string $part) {
|
->filter(fn (string $part) => $part !== 'is:private')
|
||||||
return $part !== 'is:private';
|
|
||||||
})
|
|
||||||
->filter()
|
->filter()
|
||||||
->join(' ');
|
->join(' ');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,93 +12,360 @@
|
||||||
|
|
||||||
namespace Blomstra\Search\Commands;
|
namespace Blomstra\Search\Commands;
|
||||||
|
|
||||||
use Blomstra\Search\Discussion\DiscussionIndexer;
|
use Blomstra\Search\Jobs\Job;
|
||||||
use Blomstra\Search\Elasticsearch\Builder;
|
use Blomstra\Search\Jobs\UpdateSearchJob;
|
||||||
use Blomstra\Search\Post\CommentPostIndexer;
|
use Blomstra\Search\Seeders\Seeder;
|
||||||
use Elasticsearch\Client;
|
use Elasticsearch\Client;
|
||||||
use Flarum\Discussion\Discussion;
|
|
||||||
use Flarum\Post\Post;
|
|
||||||
use Flarum\Search\Job\IndexJob;
|
|
||||||
use Flarum\Settings\SettingsRepositoryInterface;
|
use Flarum\Settings\SettingsRepositoryInterface;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Contracts\Container\Container;
|
use Illuminate\Contracts\Container\Container;
|
||||||
use Illuminate\Contracts\Queue\Queue;
|
use Illuminate\Contracts\Queue\Queue;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use Spatie\ElasticsearchQueryBuilder\Builder;
|
||||||
use Spatie\ElasticsearchQueryBuilder\Queries\BoolQuery;
|
use Spatie\ElasticsearchQueryBuilder\Queries\BoolQuery;
|
||||||
use Spatie\ElasticsearchQueryBuilder\Queries\RangeQuery;
|
use Spatie\ElasticsearchQueryBuilder\Queries\RangeQuery;
|
||||||
|
use Spatie\ElasticsearchQueryBuilder\Queries\TermQuery;
|
||||||
|
|
||||||
class BuildCommand extends Command
|
class BuildCommand extends Command
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Bump this when a mapping change requires a full reindex.
|
||||||
|
* Written into the mapping's _meta.index_compat_version on every build and
|
||||||
|
* read back into blomstra-search.index-compatible by saveIndexedConfig so the
|
||||||
|
* value always reflects the live index — including after a promote/rollback.
|
||||||
|
*/
|
||||||
|
public const INDEX_COMPAT_VERSION = 'v2';
|
||||||
|
|
||||||
|
/** Matches Flarum's Search::MIN_SEARCH_LEN — the default minimum query length. */
|
||||||
|
public const DEFAULT_MIN_SEARCH_LENGTH = 3;
|
||||||
|
|
||||||
protected $signature = 'blomstra:search:index
|
protected $signature = 'blomstra:search:index
|
||||||
{--max-id= : Limits for each object the number of items to seed}
|
{action? : build | promote | rollback | discard | mapping | fill}
|
||||||
{--throttle= : Number of seconds to wait between pushing to the queue}
|
{--fresh}
|
||||||
{--only= : type of model to index, eg discussions or posts}
|
{--resume}
|
||||||
{--recreate : create or recreate the index}
|
{--staging}
|
||||||
{--mapping : recreate the mapping}
|
{--keep-backup}
|
||||||
{--continue : continue each object type where you left off}
|
{--pending}
|
||||||
{--seed-missing : attempt to seed only objects that are missing in the index}';
|
{--backup}
|
||||||
|
{--only=}
|
||||||
|
{--max-id=}
|
||||||
|
{--throttle=}
|
||||||
|
{--i-am-sure}';
|
||||||
|
|
||||||
protected $description = 'Rebuilds the complete search server with its documents.';
|
protected $description = 'Build and manage the Elasticsearch search index.';
|
||||||
|
|
||||||
public function handle(Container $container, Client $client, Queue $queue, SettingsRepositoryInterface $settings)
|
protected $help = <<<'HELP'
|
||||||
|
<comment>Actions and their options:</comment>
|
||||||
|
|
||||||
|
<info>build</info> Queue documents and promote automatically.
|
||||||
|
<comment>--resume</comment> Resume an interrupted build from where it left off
|
||||||
|
<comment>--fresh</comment> Drop the staging index and start completely fresh
|
||||||
|
<comment>--staging</comment> Keep in staging — requires explicit <info>promote</info> (blue-green)
|
||||||
|
<comment>--keep-backup</comment> Retain the replaced index for rollback
|
||||||
|
|
||||||
|
<info>promote</info> Swap alias to staging index (blue-green workflow).
|
||||||
|
<comment>--keep-backup</comment> Retain the replaced index for rollback
|
||||||
|
<comment>--i-am-sure</comment> Skip the confirmation prompt
|
||||||
|
|
||||||
|
<info>rollback</info> Restore the backup index to live.
|
||||||
|
|
||||||
|
<info>discard</info> Drop an index without promoting.
|
||||||
|
<comment>--pending</comment> Drop the staging index (cancel a build)
|
||||||
|
<comment>--backup</comment> Drop the backup index (cleanup after --keep-backup)
|
||||||
|
|
||||||
|
<info>mapping</info> Push updated mapping to the live index only.
|
||||||
|
|
||||||
|
<info>fill</info> Seed only documents missing from the live index.
|
||||||
|
|
||||||
|
<comment>Shared seeding options (build / fill):</comment>
|
||||||
|
<comment>--only=TYPE</comment> Seed only this type: <info>discussions</info> or <info>posts</info>
|
||||||
|
<comment>--throttle=N</comment> Seconds to wait between batches
|
||||||
|
<comment>--max-id=N</comment> Limit seeding to IDs up to this value
|
||||||
|
HELP;
|
||||||
|
|
||||||
|
public function handle(Container $container): void
|
||||||
{
|
{
|
||||||
$indexers = [
|
/** @var Client $client */
|
||||||
'discussions' => DiscussionIndexer::class,
|
$client = $container->make(Client::class);
|
||||||
'posts' => CommentPostIndexer::class,
|
|
||||||
];
|
|
||||||
$models = [
|
|
||||||
'discussions' => Discussion::class,
|
|
||||||
'posts' => Post::class,
|
|
||||||
];
|
|
||||||
|
|
||||||
$only = $this->option('only');
|
/** @var SettingsRepositoryInterface $settings */
|
||||||
$onlyIndexers = $only
|
$settings = $container->make(SettingsRepositoryInterface::class);
|
||||||
? Arr::only($indexers, is_array($only) ? $only : explode(',', $only))
|
|
||||||
: $indexers;
|
|
||||||
|
|
||||||
foreach ($onlyIndexers as $type => $indexerClass) {
|
/** @var string $alias */
|
||||||
/** @var DiscussionIndexer|CommentPostIndexer $indexer */
|
$alias = $container->make('blomstra.search.elastic_index');
|
||||||
$indexer = $container->make($indexerClass);
|
|
||||||
|
|
||||||
$properties = [
|
if (!$this->argument('action')) {
|
||||||
'properties' => $indexer->properties(),
|
$this->call('help', ['command_name' => $this->getName()]);
|
||||||
];
|
return;
|
||||||
|
|
||||||
// Remove/delete the whole index.
|
|
||||||
if ($this->option('recreate')) {
|
|
||||||
$indexer->flush();
|
|
||||||
$indexer->build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the index.
|
switch ($this->argument('action')) {
|
||||||
if (!$this->option('recreate') && $this->option('mapping')) {
|
case 'build':
|
||||||
|
$this->runBuild($client, $alias, $settings, $container);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'promote':
|
||||||
|
$this->runPromote($client, $alias, $settings);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'rollback':
|
||||||
|
$this->runRollback($client, $alias, $settings);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'discard':
|
||||||
|
$this->runDiscard($client, $settings);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'mapping':
|
||||||
$client->indices()->putMapping([
|
$client->indices()->putMapping([
|
||||||
'index' => $indexer::index(),
|
'index' => $alias,
|
||||||
'body' => $properties,
|
'body' => $this->mappingProperties(),
|
||||||
]);
|
]);
|
||||||
|
$this->info('Mapping updated on live index.');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'fill':
|
||||||
|
$this->runSeeders(
|
||||||
|
collect($container->tagged('blomstra.search.seeders')),
|
||||||
|
$container->make(Queue::class),
|
||||||
|
$client,
|
||||||
|
$settings,
|
||||||
|
$alias,
|
||||||
|
seedMissing: true
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
$this->error("Unknown action '{$this->argument('action')}'. Valid actions: build, promote, rollback, discard, mapping, fill.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function runBuild(Client $client, string $alias, SettingsRepositoryInterface $settings, Container $container): void
|
||||||
|
{
|
||||||
|
/** @var Seeder[] $seeders */
|
||||||
|
$seeders = collect($container->tagged('blomstra.search.seeders'));
|
||||||
|
|
||||||
|
// Guard: staging build exists without explicit intent given.
|
||||||
|
$staging = $settings->get('blomstra-search.staging-index');
|
||||||
|
if ($staging && $client->indices()->exists(['index' => $staging])
|
||||||
|
&& !$this->option('resume') && !$this->option('fresh')
|
||||||
|
) {
|
||||||
|
$this->warn("An in-progress build already exists: $staging");
|
||||||
|
$this->line('');
|
||||||
|
$this->line('Choose one of:');
|
||||||
|
$this->line(' blomstra:search:index build --resume Continue from where it left off');
|
||||||
|
$this->line(' blomstra:search:index build --fresh Drop this build and start completely fresh');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$aliasExists = (bool) $client->indices()->existsAlias(['name' => $alias]);
|
||||||
|
$indexExists = !$aliasExists && (bool) $client->indices()->exists(['index' => $alias]);
|
||||||
|
|
||||||
|
if (!$aliasExists && !$indexExists) {
|
||||||
|
// First install: create a timestamped index, alias it immediately, seed live.
|
||||||
|
$targetIndex = $this->prepareFirstInstall($client, $alias, $settings, $seeders);
|
||||||
|
$stagingBuild = false;
|
||||||
|
} else {
|
||||||
|
// Live system: build into a staging index without touching the live alias.
|
||||||
|
$targetIndex = $this->prepareStagingIndex($client, $alias, $settings, $seeders);
|
||||||
|
$stagingBuild = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$client->indices()->putMapping([
|
||||||
|
'index' => $targetIndex,
|
||||||
|
'body' => $this->mappingProperties(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->runSeeders($seeders, $container->make(Queue::class), $client, $settings, $targetIndex);
|
||||||
|
|
||||||
|
if ($stagingBuild) {
|
||||||
|
if ($this->option('staging')) {
|
||||||
|
$staging = $settings->get('blomstra-search.staging-index');
|
||||||
|
$this->info("Staging build complete. Drain the queue, then promote '$staging' to live:");
|
||||||
|
$this->line(' php flarum queue:work --stop-when-empty');
|
||||||
|
$this->line(' php flarum blomstra:search:index promote');
|
||||||
|
} else {
|
||||||
|
$this->info('Jobs queued. Promoting now (search improves as the queue drains)...');
|
||||||
|
$this->performPromote($client, $alias, $settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function runPromote(Client $client, string $alias, SettingsRepositoryInterface $settings): void
|
||||||
|
{
|
||||||
|
if (!$this->option('i-am-sure')) {
|
||||||
|
$this->warn('promote switches the live index. Ensure all queued jobs have finished first:');
|
||||||
|
$this->line(' php flarum queue:work --stop-when-empty');
|
||||||
|
$this->line('');
|
||||||
|
|
||||||
|
if (!$this->confirm('Queue drained? Proceed?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->performPromote($client, $alias, $settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function performPromote(Client $client, string $alias, SettingsRepositoryInterface $settings): void
|
||||||
|
{
|
||||||
|
$staging = $settings->get('blomstra-search.staging-index');
|
||||||
|
|
||||||
|
if (!$staging || !$client->indices()->exists(['index' => $staging])) {
|
||||||
|
$this->error('No staging index ready to promote. Run: blomstra:search:index build');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$aliasExists = (bool) $client->indices()->existsAlias(['name' => $alias]);
|
||||||
|
$indexExists = !$aliasExists && (bool) $client->indices()->exists(['index' => $alias]);
|
||||||
|
|
||||||
|
if ($aliasExists) {
|
||||||
|
$result = $client->indices()->getAlias(['name' => $alias]);
|
||||||
|
$activeIndex = array_key_first($result);
|
||||||
|
|
||||||
|
$client->indices()->updateAliases([
|
||||||
|
'body' => ['actions' => [
|
||||||
|
['remove' => ['index' => $activeIndex, 'alias' => $alias]],
|
||||||
|
['add' => ['index' => $staging, 'alias' => $alias]],
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->info("'$alias' now points to '$staging'. Promote complete.");
|
||||||
|
|
||||||
|
if ($this->option('keep-backup')) {
|
||||||
|
$settings->set('blomstra-search.backup-index', $activeIndex);
|
||||||
|
$this->info("Kept '$activeIndex' as backup — run rollback to revert, discard --backup to drop it.");
|
||||||
|
} else {
|
||||||
|
$client->indices()->delete(['index' => $activeIndex, 'ignore_unavailable' => true]);
|
||||||
|
$this->info("Deleted old index: $activeIndex");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// One-time migration: concrete index → alias (legacy installs only).
|
||||||
|
if ($indexExists) {
|
||||||
|
$client->indices()->delete(['index' => $alias]);
|
||||||
|
$this->info("Deleted legacy concrete index: $alias");
|
||||||
|
}
|
||||||
|
|
||||||
|
$client->indices()->putAlias(['index' => $staging, 'name' => $alias]);
|
||||||
|
$this->info("'$alias' now points to '$staging'. Promote complete.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings->set('blomstra-search.active-index', $staging);
|
||||||
|
$settings->set('blomstra-search.staging-index', null);
|
||||||
|
$this->saveIndexedConfig($client, $settings, $staging);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function runRollback(Client $client, string $alias, SettingsRepositoryInterface $settings): void
|
||||||
|
{
|
||||||
|
$backup = $settings->get('blomstra-search.backup-index');
|
||||||
|
|
||||||
|
if (!$backup || !$client->indices()->exists(['index' => $backup])) {
|
||||||
|
$this->error('No backup index to roll back to. Was promote run with --keep-backup?');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $client->indices()->getAlias(['name' => $alias]);
|
||||||
|
$activeIndex = array_key_first($result);
|
||||||
|
|
||||||
|
$client->indices()->updateAliases([
|
||||||
|
'body' => ['actions' => [
|
||||||
|
['remove' => ['index' => $activeIndex, 'alias' => $alias]],
|
||||||
|
['add' => ['index' => $backup, 'alias' => $alias]],
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$client->indices()->delete(['index' => $activeIndex, 'ignore_unavailable' => true]);
|
||||||
|
$this->info("Rolled back: '$alias' now points to '$backup'. Deleted '$activeIndex'.");
|
||||||
|
|
||||||
|
$settings->set('blomstra-search.active-index', $backup);
|
||||||
|
$settings->set('blomstra-search.backup-index', null);
|
||||||
|
$this->saveIndexedConfig($client, $settings, $backup);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function runDiscard(Client $client, SettingsRepositoryInterface $settings): void
|
||||||
|
{
|
||||||
|
$pending = $this->option('pending');
|
||||||
|
$backup = $this->option('backup');
|
||||||
|
|
||||||
|
if (!$pending && !$backup) {
|
||||||
|
$this->error('Specify what to discard: --pending (staging build) or --backup (rollback index).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pending) {
|
||||||
|
$staging = $settings->get('blomstra-search.staging-index');
|
||||||
|
|
||||||
|
if (!$staging) {
|
||||||
|
$this->warn('No staging index to discard.');
|
||||||
|
} else {
|
||||||
|
if ($client->indices()->exists(['index' => $staging])) {
|
||||||
|
$client->indices()->delete(['index' => $staging]);
|
||||||
|
}
|
||||||
|
$settings->set('blomstra-search.staging-index', null);
|
||||||
|
$this->info("Discarded staging index: $staging");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($backup) {
|
||||||
|
$backupIndex = $settings->get('blomstra-search.backup-index');
|
||||||
|
|
||||||
|
if (!$backupIndex) {
|
||||||
|
$this->warn('No backup index to discard.');
|
||||||
|
} else {
|
||||||
|
if ($client->indices()->exists(['index' => $backupIndex])) {
|
||||||
|
$client->indices()->delete(['index' => $backupIndex]);
|
||||||
|
}
|
||||||
|
$settings->set('blomstra-search.backup-index', null);
|
||||||
|
$this->info("Discarded backup index: $backupIndex");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function runSeeders(
|
||||||
|
iterable $seeders,
|
||||||
|
Queue $queue,
|
||||||
|
Client $client,
|
||||||
|
SettingsRepositoryInterface $settings,
|
||||||
|
string $targetIndex,
|
||||||
|
bool $seedMissing = false
|
||||||
|
): void {
|
||||||
|
$only = $this->option('only');
|
||||||
|
|
||||||
|
/** @var Seeder $seeder */
|
||||||
|
foreach ($seeders as $seeder) {
|
||||||
|
if ($only && $seeder->type() !== $only) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$total = 0;
|
$total = 0;
|
||||||
|
|
||||||
$query = $models[$type]::query();
|
if ($this->option('resume')) {
|
||||||
|
$saved = $this->getContinueAt($settings, $seeder->type());
|
||||||
|
|
||||||
$continueAt = $this->option('continue')
|
if ($saved === 0) {
|
||||||
? ($this->continueAt($type) ?? $query->max('id'))
|
$this->info("Seeder '{$seeder->type()}' already completed in a previous run, skipping.");
|
||||||
: $query->max('id');
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$continueAt = $saved ?? $seeder->query()->max('id');
|
||||||
|
} else {
|
||||||
|
$continueAt = $seeder->query()->max('id');
|
||||||
|
}
|
||||||
|
|
||||||
$seeded = null;
|
$seeded = null;
|
||||||
|
|
||||||
while ($continueAt !== null) {
|
while ($continueAt !== null) {
|
||||||
if ($this->option('seed-missing')) {
|
$rangeFrom = max(1, $continueAt - 2500);
|
||||||
|
$rangeTo = $continueAt;
|
||||||
|
|
||||||
|
if ($seedMissing) {
|
||||||
$response = (new Builder($client))
|
$response = (new Builder($client))
|
||||||
->index($indexer::index())
|
->index($targetIndex)
|
||||||
->size(1000)
|
->size(2500)
|
||||||
->addQuery(
|
->addQuery(
|
||||||
(new BoolQuery())
|
(new BoolQuery())
|
||||||
->add((new RangeQuery('rawId'))
|
->add((new RangeQuery('rawId'))->gte($rangeFrom)->lte($rangeTo))
|
||||||
->gte($continueAt - 1000)
|
->add(TermQuery::create('join_field', $seeder->joinRelation()))
|
||||||
->lte($continueAt))
|
|
||||||
)
|
)
|
||||||
->search();
|
->search();
|
||||||
|
|
||||||
|
|
@ -106,26 +373,29 @@ class BuildCommand extends Command
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var Collection $collection */
|
/** @var Collection $collection */
|
||||||
$collection = $query
|
$collection = $seeder->query()
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->whereBetween('id', [$continueAt - 1000, $continueAt])
|
->whereBetween('id', [$rangeFrom, $rangeTo])
|
||||||
->when($this->option('max-id'), function ($query, $id) {
|
->when($this->option('max-id'), fn ($q, $id) => $q->where('id', '<=', $id))
|
||||||
$query->where('id', '<=', $id);
|
->when($seeded, fn ($q, $seeded) => $q->whereNotIn('id', $seeded))
|
||||||
})
|
|
||||||
->when($seeded, fn ($query, $seeded) => $query->whereNotIn('id', $seeded))
|
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$min = $collection->min('id');
|
$min = $collection->min('id');
|
||||||
|
|
||||||
|
if ($seedMissing && $collection->isEmpty()) {
|
||||||
|
$continueAt = $rangeFrom > 2 ? $rangeFrom - 1 : null;
|
||||||
|
} else {
|
||||||
$continueAt = $min && $min > 2 ? $min - 1 : null;
|
$continueAt = $min && $min > 2 ? $min - 1 : null;
|
||||||
|
}
|
||||||
|
|
||||||
$onQueue = property_exists($indexerClass, 'queue') ? $indexerClass::$queue : null;
|
if ($collection->isNotEmpty()) {
|
||||||
$queue->pushOn($onQueue, new IndexJob($indexerClass, $collection->all(), IndexJob::SAVE));
|
$queue->pushOn(Job::$onQueue, new UpdateSearchJob($collection, $seeder, $targetIndex));
|
||||||
|
}
|
||||||
|
|
||||||
$this->info("Pushed into the index, type: $type, amount: {$collection->count()}.");
|
$this->info("IDs {$rangeFrom}–{$rangeTo} | type: {$seeder->type()} | queued: {$collection->count()}.");
|
||||||
|
|
||||||
$total += $collection->count();
|
$total += $collection->count();
|
||||||
|
$this->setContinueAt($settings, $seeder->type(), $continueAt);
|
||||||
$this->continueAt($type, $continueAt);
|
|
||||||
|
|
||||||
if ($throttle = $this->option('throttle')) {
|
if ($throttle = $this->option('throttle')) {
|
||||||
$this->info("Throttling for $throttle seconds");
|
$this->info("Throttling for $throttle seconds");
|
||||||
|
|
@ -133,21 +403,181 @@ class BuildCommand extends Command
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info("Pushed a total of $total into the index.");
|
$this->setContinueAt($settings, $seeder->type(), 0);
|
||||||
|
$this->info("Queued a total of $total {$seeder->type()} for indexing.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function continueAt(string $type, int $at = null)
|
/**
|
||||||
|
* First install: create a timestamped concrete index, alias the configured name to it
|
||||||
|
* immediately, and return the alias so seeding goes through it and is live from the start.
|
||||||
|
*/
|
||||||
|
protected function prepareFirstInstall(
|
||||||
|
Client $client,
|
||||||
|
string $alias,
|
||||||
|
SettingsRepositoryInterface $settings,
|
||||||
|
iterable $seeders
|
||||||
|
): string {
|
||||||
|
$concrete = $alias . '_' . date('YmdHis');
|
||||||
|
|
||||||
|
$client->indices()->create([
|
||||||
|
'index' => $concrete,
|
||||||
|
'body' => ['settings' => $this->buildIndexSettings($settings)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$client->indices()->putAlias(['index' => $concrete, 'name' => $alias]);
|
||||||
|
|
||||||
|
$settings->set('blomstra-search.active-index', $concrete);
|
||||||
|
$this->saveIndexedConfig($client, $settings, $concrete);
|
||||||
|
|
||||||
|
foreach ($seeders as $seeder) {
|
||||||
|
$this->setContinueAt($settings, $seeder->type(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Created '$concrete', aliased '$alias' → '$concrete'.");
|
||||||
|
$this->info("Index is live — documents become searchable as the queue processes.");
|
||||||
|
|
||||||
|
return $alias;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare the staging index for a blue-green build.
|
||||||
|
*
|
||||||
|
* - If a staging build exists and --fresh is not set, resume it.
|
||||||
|
* - Otherwise create a fresh timestamped index and save it as staging.
|
||||||
|
*/
|
||||||
|
protected function prepareStagingIndex(
|
||||||
|
Client $client,
|
||||||
|
string $alias,
|
||||||
|
SettingsRepositoryInterface $settings,
|
||||||
|
iterable $seeders
|
||||||
|
): string {
|
||||||
|
$staging = $settings->get('blomstra-search.staging-index');
|
||||||
|
|
||||||
|
if ($this->option('fresh') && $staging) {
|
||||||
|
if ($client->indices()->exists(['index' => $staging])) {
|
||||||
|
$client->indices()->delete(['index' => $staging]);
|
||||||
|
$this->info("Dropped staging index: $staging");
|
||||||
|
}
|
||||||
|
$staging = null;
|
||||||
|
$settings->set('blomstra-search.staging-index', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($staging && $client->indices()->exists(['index' => $staging])) {
|
||||||
|
$this->info("Resuming staging index build: $staging");
|
||||||
|
return $staging;
|
||||||
|
}
|
||||||
|
|
||||||
|
$staging = $alias . '_' . date('YmdHis');
|
||||||
|
|
||||||
|
$client->indices()->create([
|
||||||
|
'index' => $staging,
|
||||||
|
'body' => ['settings' => $this->buildIndexSettings($settings)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$settings->set('blomstra-search.staging-index', $staging);
|
||||||
|
|
||||||
|
foreach ($seeders as $seeder) {
|
||||||
|
$this->setContinueAt($settings, $seeder->type(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Created staging index: $staging");
|
||||||
|
|
||||||
|
return $staging;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the analysis config and compat version that are actually live in ES for the
|
||||||
|
* given index. Reading from ES (rather than from Flarum settings) means rollbacks are
|
||||||
|
* also covered: the stored values always reflect the index that is currently aliased,
|
||||||
|
* not the settings at the time the command ran. Indexes built before _meta tracking
|
||||||
|
* existed will yield a null compat version, which correctly triggers the reindex warning.
|
||||||
|
*/
|
||||||
|
protected function saveIndexedConfig(Client $client, SettingsRepositoryInterface $settings, string $indexName): void
|
||||||
{
|
{
|
||||||
/** @var SettingsRepositoryInterface $settings */
|
$settingsResponse = $client->indices()->getSettings(['index' => $indexName]);
|
||||||
$settings = resolve(SettingsRepositoryInterface::class);
|
$analysis = Arr::get($settingsResponse, "$indexName.settings.index.analysis", []);
|
||||||
|
|
||||||
$key = "blomstra-search.continued-at.$type";
|
$analyzer = Arr::get($analysis, 'analyzer.flarum_analyzer.type', 'english');
|
||||||
|
$minGram = (int) Arr::get($analysis, 'filter.partial_search_filter.min_gram', self::DEFAULT_MIN_SEARCH_LENGTH);
|
||||||
|
|
||||||
if ($at) {
|
$mappingResponse = $client->indices()->getMapping(['index' => $indexName]);
|
||||||
$settings->set($key, $at);
|
$compatVersion = Arr::get($mappingResponse, "$indexName.mappings._meta.index_compat_version");
|
||||||
} else {
|
|
||||||
return $settings->get($key);
|
$settings->set('blomstra-search.indexed-analyzer', $analyzer);
|
||||||
|
$settings->set('blomstra-search.indexed-min-search-length', $minGram);
|
||||||
|
$settings->set('blomstra-search.index-compatible', $compatVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function buildIndexSettings(SettingsRepositoryInterface $settings): array
|
||||||
|
{
|
||||||
|
$language = $settings->get('blomstra-search.analyzer-language') ?: 'english';
|
||||||
|
$minGram = max(1, (int) ($settings->get('blomstra-search.min-search-length') ?: self::DEFAULT_MIN_SEARCH_LENGTH));
|
||||||
|
$maxGram = 10;
|
||||||
|
|
||||||
|
if ($minGram >= $maxGram) {
|
||||||
|
$this->error("min_gram ($minGram) must be less than max_gram ($maxGram). Using default.");
|
||||||
|
$minGram = self::DEFAULT_MIN_SEARCH_LENGTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'index.max_ngram_diff' => $maxGram - $minGram,
|
||||||
|
'analysis' => [
|
||||||
|
'analyzer' => [
|
||||||
|
'flarum_analyzer' => [
|
||||||
|
'type' => $language,
|
||||||
|
],
|
||||||
|
'flarum_analyzer_partial' => [
|
||||||
|
'type' => 'custom',
|
||||||
|
'tokenizer' => 'standard',
|
||||||
|
'filter' => ['lowercase', 'partial_search_filter'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'filter' => [
|
||||||
|
'partial_search_filter' => [
|
||||||
|
'type' => 'ngram',
|
||||||
|
'min_gram' => $minGram,
|
||||||
|
'max_gram' => $maxGram,
|
||||||
|
'token_chars' => ['letter', 'digit', 'symbol'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function mappingProperties(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'_meta' => ['index_compat_version' => self::INDEX_COMPAT_VERSION],
|
||||||
|
'properties' => [
|
||||||
|
'join_field' => ['type' => 'join', 'relations' => ['discussion' => 'post']],
|
||||||
|
'discussion_id' => ['type' => 'integer'],
|
||||||
|
'content' => ['type' => 'text', 'analyzer' => 'flarum_analyzer_partial', 'search_analyzer' => 'flarum_analyzer'],
|
||||||
|
'rawId' => ['type' => 'integer'],
|
||||||
|
'created_at' => ['type' => 'date'],
|
||||||
|
'updated_at' => ['type' => 'date'],
|
||||||
|
'is_private' => ['type' => 'boolean'],
|
||||||
|
'is_sticky' => ['type' => 'boolean'],
|
||||||
|
'groups' => ['type' => 'integer'],
|
||||||
|
'tags' => ['type' => 'integer'],
|
||||||
|
'recipient_groups' => ['type' => 'integer'],
|
||||||
|
'recipient_users' => ['type' => 'integer'],
|
||||||
|
'comment_count' => ['type' => 'integer'],
|
||||||
|
'view_count' => ['type' => 'integer'],
|
||||||
|
'is_hidden' => ['type' => 'boolean'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getContinueAt(SettingsRepositoryInterface $settings, string $type): ?int
|
||||||
|
{
|
||||||
|
$raw = $settings->get("blomstra-search.continued-at.$type");
|
||||||
|
|
||||||
|
return $raw !== null ? (int) $raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setContinueAt(SettingsRepositoryInterface $settings, string $type, ?int $at): void
|
||||||
|
{
|
||||||
|
$settings->set("blomstra-search.continued-at.$type", $at);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This file is part of blomstra/search.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2022 Blomstra Ltd.
|
|
||||||
*
|
|
||||||
* For the full copyright and license information, please view the LICENSE.md
|
|
||||||
* file that was distributed with this source code.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Blomstra\Search\Discussion;
|
|
||||||
|
|
||||||
use Blomstra\Search\Save\Document;
|
|
||||||
use Blomstra\Search\Search\Concerns\AppliesAccessControl;
|
|
||||||
use Blomstra\Search\Search\ElasticIndex;
|
|
||||||
use Flarum\Discussion\Discussion;
|
|
||||||
use Flarum\Extension\ExtensionManager;
|
|
||||||
use Flarum\Search\IndexerInterface;
|
|
||||||
|
|
||||||
class DiscussionIndexer implements IndexerInterface
|
|
||||||
{
|
|
||||||
use AppliesAccessControl;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
protected ElasticIndex $elastic,
|
|
||||||
protected ExtensionManager $extensions
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function index(): string
|
|
||||||
{
|
|
||||||
return 'discussions';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function save(array $models): void
|
|
||||||
{
|
|
||||||
$this->elastic->save(self::index(), $models, $this->toDocument(...));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(array $models): void
|
|
||||||
{
|
|
||||||
$this->elastic->delete(self::index(), $models);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function build(): void
|
|
||||||
{
|
|
||||||
$this->elastic->build(self::index(), $this->properties());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function flush(): void
|
|
||||||
{
|
|
||||||
$this->elastic->flush(self::index());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function properties(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'rawId' => ['type' => 'integer'],
|
|
||||||
'discussion_id' => ['type' => 'integer'],
|
|
||||||
'title' => ['type' => 'text', 'analyzer' => 'flarum_analyzer_partial', 'search_analyzer' => 'flarum_analyzer'],
|
|
||||||
'created_at' => ['type' => 'date'],
|
|
||||||
'updated_at' => ['type' => 'date'],
|
|
||||||
'last_posted_at' => ['type' => 'date'],
|
|
||||||
'is_private' => ['type' => 'boolean'],
|
|
||||||
'is_sticky' => ['type' => 'boolean'],
|
|
||||||
'is_locked' => ['type' => 'boolean'],
|
|
||||||
'groups' => ['type' => 'integer'],
|
|
||||||
'tags' => ['type' => 'integer'],
|
|
||||||
'recipient_groups' => ['type' => 'integer'],
|
|
||||||
'recipient_users' => ['type' => 'integer'],
|
|
||||||
'comment_count' => ['type' => 'integer'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toDocument(Discussion $model): Document
|
|
||||||
{
|
|
||||||
$document = new Document([
|
|
||||||
'id' => $model->id,
|
|
||||||
'discussion_id' => $model->id, // duplicated for result aggregation in searching with posts.
|
|
||||||
'rawId' => $model->id,
|
|
||||||
'title' => $model->title,
|
|
||||||
'created_at' => $model->created_at?->toAtomString(),
|
|
||||||
'updated_at' => $model->last_posted_at?->toAtomString(),
|
|
||||||
'is_private' => $model->is_private,
|
|
||||||
'user_id' => $model->user_id,
|
|
||||||
'groups' => $this->groupsForDiscussion($model),
|
|
||||||
'comment_count' => $model->comment_count,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($this->extensions->isEnabled('flarum-tags')) {
|
|
||||||
$document['tags'] = $model->tags->pluck('id')->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->extensions->isEnabled('fof-byobu')) {
|
|
||||||
$document['recipient_users'] = $model->recipientUsers->pluck('id')->toArray();
|
|
||||||
$document['recipient_groups'] = $model->recipientGroups->pluck('id')->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->extensions->isEnabled('flarum-sticky')) {
|
|
||||||
$document['is_sticky'] = $model->is_sticky;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->extensions->isEnabled('flarum-lock')) {
|
|
||||||
$document['is_locked'] = $model->is_locked;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $document;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This file is part of blomstra/search.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2022 Blomstra Ltd.
|
|
||||||
*
|
|
||||||
* For the full copyright and license information, please view the LICENSE.md
|
|
||||||
* file that was distributed with this source code.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Blomstra\Search\Discussion;
|
|
||||||
|
|
||||||
use Blomstra\Search\Post\CommentPostIndexer;
|
|
||||||
use Blomstra\Search\Search\Searcher;
|
|
||||||
use Flarum\Discussion\Discussion;
|
|
||||||
use Flarum\User\User;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class DiscussionSearcher extends Searcher
|
|
||||||
{
|
|
||||||
public function index(): string
|
|
||||||
{
|
|
||||||
return DiscussionIndexer::index().','.CommentPostIndexer::index();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getQuery(User $actor): Builder
|
|
||||||
{
|
|
||||||
return Discussion::whereVisibleTo($actor)->select('discussions.*');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This file is part of blomstra/search.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2022 Blomstra Ltd.
|
|
||||||
*
|
|
||||||
* For the full copyright and license information, please view the LICENSE.md
|
|
||||||
* file that was distributed with this source code.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Blomstra\Search\Discussion;
|
|
||||||
|
|
||||||
use Blomstra\Search\Elasticsearch\Builder;
|
|
||||||
use Blomstra\Search\Elasticsearch\MatchPhraseQuery;
|
|
||||||
use Blomstra\Search\Elasticsearch\MatchQuery;
|
|
||||||
use Blomstra\Search\Elasticsearch\TermsQuery;
|
|
||||||
use Blomstra\Search\Post\CommentPostIndexer;
|
|
||||||
use Blomstra\Search\Search\ElasticSearchState;
|
|
||||||
use Elasticsearch\Client;
|
|
||||||
use Flarum\Discussion\Discussion;
|
|
||||||
use Flarum\Post\Post;
|
|
||||||
use Flarum\Search\AbstractFulltextFilter;
|
|
||||||
use Flarum\Search\SearchCriteria;
|
|
||||||
use Flarum\Search\SearchManager;
|
|
||||||
use Flarum\Search\SearchState;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Spatie\ElasticsearchQueryBuilder\Aggregations\FilterAggregation;
|
|
||||||
use Spatie\ElasticsearchQueryBuilder\Aggregations\TermsAggregation;
|
|
||||||
use Spatie\ElasticsearchQueryBuilder\Aggregations\TopHitsAggregation;
|
|
||||||
use Spatie\ElasticsearchQueryBuilder\Queries\BoolQuery;
|
|
||||||
use Spatie\ElasticsearchQueryBuilder\Queries\Query;
|
|
||||||
use Spatie\ElasticsearchQueryBuilder\Sorts\Sort;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unlike the default flarum database search,
|
|
||||||
* this search will not get score based on the sum of post scores of a discussion.
|
|
||||||
*
|
|
||||||
* @extends AbstractFulltextFilter<ElasticSearchState>
|
|
||||||
*/
|
|
||||||
class FulltextFilter extends AbstractFulltextFilter
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
protected SearchManager $search,
|
|
||||||
protected Client $elastic
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function search(SearchState $state, string $value): void
|
|
||||||
{
|
|
||||||
$builder = $state->getBuilder();
|
|
||||||
|
|
||||||
$query = BoolQuery::create()
|
|
||||||
->add(
|
|
||||||
BoolQuery::create()
|
|
||||||
->add($this->exactMatch('title', $value, 1.2), 'should')
|
|
||||||
->add($this->wordMatch('title', $value, 'and', 1.2), 'should')
|
|
||||||
->add($this->wordMatch('title', $value, 'or', 1.2), 'should'),
|
|
||||||
'should'
|
|
||||||
)
|
|
||||||
->add(
|
|
||||||
BoolQuery::create()
|
|
||||||
->add($this->exactMatch('content', $value), 'should', 1)
|
|
||||||
->add($this->wordMatch('content', $value, 'and', 1), 'should')
|
|
||||||
->add($this->wordMatch('content', $value, 'or', 1), 'should'),
|
|
||||||
'should'
|
|
||||||
);
|
|
||||||
|
|
||||||
$builder->addQuery($query);
|
|
||||||
|
|
||||||
$builder->collapse('discussion_id');
|
|
||||||
|
|
||||||
$aggs = FilterAggregation::create('posts', TermsQuery::create('_index', [CommentPostIndexer::index()]))
|
|
||||||
->aggregation(
|
|
||||||
TermsAggregation::create('per_discussion', 'discussion_id')
|
|
||||||
->aggregation(
|
|
||||||
TopHitsAggregation::create('most_relevant_post_id', 1, Sort::create('_score'))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$builder->addAggregation($aggs);
|
|
||||||
|
|
||||||
$state->setDefaultSort(function (Builder $builder) {
|
|
||||||
$builder->addSort(
|
|
||||||
Sort::create('_score', 'desc')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
$state->retrieveDatabaseRecordsUsing(function (array $response, SearchCriteria $criteria): Collection {
|
|
||||||
$buckets = Collection::make(Arr::get($response, 'aggregations.posts.per_discussion.buckets'))
|
|
||||||
->map(fn (array $hit) => [
|
|
||||||
'discussion_id' => $hit['key'],
|
|
||||||
'most_relevant_post_id' => Arr::get($hit, 'most_relevant_post_id.most_relevant_post_id.hits.hits.0._id'),
|
|
||||||
'most_relevant_post_score' => Arr::get($hit, 'most_relevant_post_id.most_relevant_post_id.hits.hits.0._score'),
|
|
||||||
])->keyBy('discussion_id');
|
|
||||||
|
|
||||||
$results = Collection::make(Arr::get($response, 'hits.hits'))
|
|
||||||
->map(fn (array $hit) => [
|
|
||||||
'discussion_id' => $hit['_source']['discussion_id'],
|
|
||||||
'most_relevant_post_id' => Arr::get($buckets->get($hit['_source']['discussion_id']), 'most_relevant_post_id'),
|
|
||||||
'most_relevant_post_score' => Arr::get($buckets->get($hit['_source']['discussion_id']), 'most_relevant_post_score'),
|
|
||||||
'title_score' => $hit['_score'],
|
|
||||||
])->keyBy('discussion_id');
|
|
||||||
|
|
||||||
// We have $hits and $buckets, both are sorted by score.
|
|
||||||
// $discussionResult represents discussion scores based on the title.
|
|
||||||
// $buckets represents discussion scores based on the sum of all posts,
|
|
||||||
// and also the most relevant post id and its score.
|
|
||||||
// We need to merge these two results into one collection, and sort them by score.
|
|
||||||
// We also need to make sure that the most relevant post id is set on the discussion.
|
|
||||||
$results = $results
|
|
||||||
// Sort by title_score, most_relevant_post_score then posts_score.
|
|
||||||
->sortByDesc(function ($result) {
|
|
||||||
return $result['title_score'] ?? 0
|
|
||||||
+ $result['most_relevant_post_score'] ?? 0
|
|
||||||
+ $result['posts_score'] ?? 0;
|
|
||||||
})
|
|
||||||
->take($criteria->limit);
|
|
||||||
|
|
||||||
$actor = $criteria->actor;
|
|
||||||
$connection = Post::query()->getConnection();
|
|
||||||
|
|
||||||
if ($results->isEmpty()) {
|
|
||||||
return Discussion::whereVisibleTo($actor)
|
|
||||||
->select('discussions.*')
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Discussion::whereVisibleTo($actor)
|
|
||||||
->select('discussions.*')
|
|
||||||
->selectRaw(
|
|
||||||
$connection->raw('COALESCE(('.$connection->getQueryGrammar()->compileSelect(
|
|
||||||
$postsQuery = Post::query()
|
|
||||||
->select('posts.id')
|
|
||||||
->whereIn('posts.id', $results->pluck('most_relevant_post_id')->filter())
|
|
||||||
->whereColumn('discussions.id', '=', 'posts.discussion_id')
|
|
||||||
->limit(1)
|
|
||||||
->toBase()
|
|
||||||
).'), first_post_id) as most_relevant_post_id')->getValue($connection->getQueryGrammar())
|
|
||||||
)
|
|
||||||
->mergeBindings($postsQuery)
|
|
||||||
->whereIn('discussions.id', $results->pluck('discussion_id'))
|
|
||||||
->orderByRaw('FIELD(`discussions`.`id`, '.implode(',', $results->pluck('discussion_id')->all()).')')
|
|
||||||
->get();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function exactMatch(string $field, string $q, int $fieldBoost = 1): Query
|
|
||||||
{
|
|
||||||
$query = (new MatchPhraseQuery($field, $q));
|
|
||||||
|
|
||||||
return $query->boost(2 * $fieldBoost);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function wordMatch(string $field, string $q, string $operator, int $fieldBoost = 1): Query
|
|
||||||
{
|
|
||||||
$query = (new MatchQuery($field, $q))
|
|
||||||
->operator($operator);
|
|
||||||
|
|
||||||
$boost = $operator === 'and' ? 1.8 : .8;
|
|
||||||
|
|
||||||
return $query->boost($boost * $fieldBoost);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This file is part of blomstra/search.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2022 Blomstra Ltd.
|
|
||||||
*
|
|
||||||
* For the full copyright and license information, please view the LICENSE.md
|
|
||||||
* file that was distributed with this source code.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Blomstra\Search\Discussion;
|
|
||||||
|
|
||||||
use Blomstra\Search\Elasticsearch\TermsQuery;
|
|
||||||
use Blomstra\Search\Search\Concerns\AppliesAccessControl;
|
|
||||||
use Blomstra\Search\Search\ElasticSearchState;
|
|
||||||
use Flarum\Extension\ExtensionManager;
|
|
||||||
use Flarum\Search\Filter\FilterInterface;
|
|
||||||
use Flarum\Search\SearchState;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Spatie\ElasticsearchQueryBuilder\Queries\BoolQuery;
|
|
||||||
use Spatie\ElasticsearchQueryBuilder\Queries\TermQuery;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @implements FilterInterface<ElasticSearchState>
|
|
||||||
*/
|
|
||||||
class PrivateFilterMutator implements FilterInterface
|
|
||||||
{
|
|
||||||
use AppliesAccessControl;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
protected ExtensionManager $extensions
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getFilterKey(): string
|
|
||||||
{
|
|
||||||
return 'private';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function filter(SearchState $state, array|string $value, bool $negate): void
|
|
||||||
{
|
|
||||||
$actor = $state->getActor();
|
|
||||||
|
|
||||||
if (!$this->extensions->isEnabled('fof-byobu') || $actor->isGuest()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$builder = $state->getBuilder();
|
|
||||||
$query = BoolQuery::create();
|
|
||||||
|
|
||||||
$query->add(self::byobuAccessQuery($state), 'filter');
|
|
||||||
|
|
||||||
$builder->addQuery($query);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function mutate(ElasticSearchState $state): void
|
|
||||||
{
|
|
||||||
$builder = $state->getBuilder();
|
|
||||||
|
|
||||||
// If this filter isn't active, we apply both the private and public queries as should clauses.
|
|
||||||
if (Arr::first($state->getActiveFilters(), fn ($filter) => $filter->getFilterKey() === 'private')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$query = BoolQuery::create();
|
|
||||||
|
|
||||||
$query
|
|
||||||
->add(self::restrictQuery($state), 'should')
|
|
||||||
->add(self::byobuAccessQuery($state), 'should');
|
|
||||||
|
|
||||||
$builder->addQuery($query);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function restrictQuery(SearchState $state): BoolQuery
|
|
||||||
{
|
|
||||||
return BoolQuery::create()
|
|
||||||
->add(TermQuery::create('is_private', 'false'))
|
|
||||||
->add(TermsQuery::create('groups', self::groupsForUser($state->getActor())));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function byobuAccessQuery(SearchState $state): BoolQuery
|
|
||||||
{
|
|
||||||
$actor = $state->getActor();
|
|
||||||
|
|
||||||
return BoolQuery::create()
|
|
||||||
->add(TermQuery::create('is_private', 'true'))
|
|
||||||
->add(
|
|
||||||
BoolQuery::create()
|
|
||||||
->add(TermsQuery::create('recipient_groups', self::groupsForUser($actor)), 'should')
|
|
||||||
->add(TermsQuery::create('recipient_users', [$actor->id]), 'should'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This file is part of blomstra/search.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2022 Blomstra Ltd.
|
|
||||||
*
|
|
||||||
* For the full copyright and license information, please view the LICENSE.md
|
|
||||||
* file that was distributed with this source code.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Blomstra\Search\Elasticsearch;
|
|
||||||
|
|
||||||
class Builder extends \Spatie\ElasticsearchQueryBuilder\Builder
|
|
||||||
{
|
|
||||||
protected array $collapse = [];
|
|
||||||
|
|
||||||
public function collapse(string $field): static
|
|
||||||
{
|
|
||||||
$this->collapse = [
|
|
||||||
'field' => $field,
|
|
||||||
];
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPayload(): array
|
|
||||||
{
|
|
||||||
$payload = parent::getPayload();
|
|
||||||
|
|
||||||
if ($this->collapse) {
|
|
||||||
$payload['collapse'] = $this->collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function search(): array
|
|
||||||
{
|
|
||||||
$params = $this->getParams();
|
|
||||||
|
|
||||||
return $this->client->search($params);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getParams(): array
|
|
||||||
{
|
|
||||||
$payload = $this->getPayload();
|
|
||||||
|
|
||||||
$params = [
|
|
||||||
'body' => $payload,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($this->searchIndex) {
|
|
||||||
$params['index'] = $this->searchIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->size !== null) {
|
|
||||||
$params['size'] = $this->size;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->from !== null) {
|
|
||||||
$params['from'] = $this->from;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $params;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of blomstra/search.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2022 Blomstra Ltd.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE.md
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Blomstra\Search\Elasticsearch;
|
||||||
|
|
||||||
|
use Spatie\ElasticsearchQueryBuilder\Queries\Query;
|
||||||
|
|
||||||
|
class HasChildQuery implements Query
|
||||||
|
{
|
||||||
|
protected bool $innerHits = false;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected string $type,
|
||||||
|
protected Query $query,
|
||||||
|
protected string $scoreMode = 'sum'
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function create(string $type, Query $query, string $scoreMode = 'sum'): static
|
||||||
|
{
|
||||||
|
return new static($type, $query, $scoreMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Include inner_hits so the best-matching post ID is available in the response.
|
||||||
|
* ES returns the top-scoring child document per parent hit under inner_hits.best_post.
|
||||||
|
*/
|
||||||
|
public function withInnerHits(): static
|
||||||
|
{
|
||||||
|
$this->innerHits = true;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
$hasChild = [
|
||||||
|
'type' => $this->type,
|
||||||
|
'score_mode' => $this->scoreMode,
|
||||||
|
'query' => $this->query->toArray(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->innerHits) {
|
||||||
|
$hasChild['inner_hits'] = ['name' => 'best_post', 'size' => 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['has_child' => $hasChild];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of blomstra/search.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2022 Blomstra Ltd.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE.md
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Blomstra\Search\Elasticsearch;
|
||||||
|
|
||||||
|
use Spatie\ElasticsearchQueryBuilder\Queries\Query;
|
||||||
|
|
||||||
|
class SimpleSearchQuery implements Query
|
||||||
|
{
|
||||||
|
protected float $boost = 1;
|
||||||
|
protected ?string $analyzer = null;
|
||||||
|
|
||||||
|
public static function create(array $field, string $value)
|
||||||
|
{
|
||||||
|
return new self($field, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boost(float $boost = 1)
|
||||||
|
{
|
||||||
|
$this->boost = $boost;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function analyzer(string $analyzer)
|
||||||
|
{
|
||||||
|
$this->analyzer = $analyzer;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected array $fields,
|
||||||
|
protected string $value
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'simple_query_string' => [
|
||||||
|
'query' => $this->value,
|
||||||
|
'fields' => $this->fields,
|
||||||
|
'analyzer' => $this->analyzer,
|
||||||
|
'default_operator' => 'AND',
|
||||||
|
'boost' => $this->boost,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,9 +14,9 @@ namespace Blomstra\Search\Exceptions;
|
||||||
|
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class IndexingException extends \Exception
|
class SeedingException extends \Exception
|
||||||
{
|
{
|
||||||
public function __construct(string $message, public array $items, $code = 0, Throwable $previous = null)
|
public function __construct($message = '', public array $items, $code = 0, Throwable $previous = null)
|
||||||
{
|
{
|
||||||
parent::__construct($message, $code, $previous);
|
parent::__construct($message, $code, $previous);
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of blomstra/search.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2022 Blomstra Ltd.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE.md
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Blomstra\Search\Jobs;
|
||||||
|
|
||||||
|
use Elasticsearch\Client;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class DeletingJob extends Job
|
||||||
|
{
|
||||||
|
public function handle(Client $client)
|
||||||
|
{
|
||||||
|
if ($this->models->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preparing body for storing.
|
||||||
|
$body = $this->models->map(function (Model $model) {
|
||||||
|
$document = $this->seeder->toDocument($model);
|
||||||
|
$routing = $this->seeder->routing($model);
|
||||||
|
|
||||||
|
return [
|
||||||
|
['delete' => ['_index' => $this->index, '_id' => $document->id, 'routing' => $routing]],
|
||||||
|
];
|
||||||
|
})->flatten(1);
|
||||||
|
|
||||||
|
$response = $client->bulk([
|
||||||
|
'index' => $this->index,
|
||||||
|
'body' => $body->toArray(),
|
||||||
|
'refresh' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of blomstra/search.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2022 Blomstra Ltd.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE.md
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Blomstra\Search\Jobs;
|
||||||
|
|
||||||
|
use Blomstra\Search\Seeders\Seeder;
|
||||||
|
use Flarum\Queue\AbstractJob;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
abstract class Job extends AbstractJob
|
||||||
|
{
|
||||||
|
protected string $index;
|
||||||
|
|
||||||
|
public static ?string $onQueue = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|null $targetIndex Explicit index name for blue-green builds.
|
||||||
|
* Defaults to the configured alias when null.
|
||||||
|
*/
|
||||||
|
public function __construct(protected Collection $models, protected Seeder $seeder, ?string $targetIndex = null)
|
||||||
|
{
|
||||||
|
$this->index = $targetIndex ?? resolve('blomstra.search.elastic_index');
|
||||||
|
|
||||||
|
if (static::$onQueue) {
|
||||||
|
$this->onQueue(static::$onQueue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of blomstra/search.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2022 Blomstra Ltd.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE.md
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Blomstra\Search\Jobs;
|
||||||
|
|
||||||
|
use Blomstra\Search\Exceptions\SeedingException;
|
||||||
|
use Elasticsearch\Client;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
class UpdateSearchJob extends Job
|
||||||
|
{
|
||||||
|
public function handle(Client $client)
|
||||||
|
{
|
||||||
|
if ($this->models->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->models->loadMissing($this->seeder->relationships());
|
||||||
|
|
||||||
|
// Preparing body for storing.
|
||||||
|
$body = $this->models->map(function (Model $model) {
|
||||||
|
$document = $this->seeder->toDocument($model);
|
||||||
|
$routing = $this->seeder->routing($model);
|
||||||
|
|
||||||
|
return [
|
||||||
|
['index' => ['_index' => $this->index, '_id' => $document->id, 'routing' => $routing]],
|
||||||
|
$document->toArray(),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->flatten(1);
|
||||||
|
|
||||||
|
$response = $client->bulk([
|
||||||
|
'index' => $this->index,
|
||||||
|
'body' => $body->toArray(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (Arr::get($response, 'errors') !== true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = Arr::get($response, 'items');
|
||||||
|
|
||||||
|
$error = Arr::get(Arr::first($items), 'index.error.reason');
|
||||||
|
|
||||||
|
throw new SeedingException(
|
||||||
|
"Failed to seed: $error",
|
||||||
|
$items
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of blomstra/search.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2022 Blomstra Ltd.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE.md
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Blomstra\Search\Jobs;
|
||||||
|
|
||||||
|
use Elasticsearch\Client;
|
||||||
|
use Flarum\Api\Serializer\DiscussionSerializer;
|
||||||
|
use Flarum\Discussion\Discussion;
|
||||||
|
use Flarum\Queue\AbstractJob;
|
||||||
|
|
||||||
|
class ViewsSearchJob extends AbstractJob
|
||||||
|
{
|
||||||
|
protected string $index;
|
||||||
|
protected string $documentType;
|
||||||
|
|
||||||
|
public function __construct(protected int $discussionId)
|
||||||
|
{
|
||||||
|
$this->index = resolve('blomstra.search.elastic_index');
|
||||||
|
$this->documentType = resolve(DiscussionSerializer::class)->getType(new Discussion());
|
||||||
|
|
||||||
|
if (Job::$onQueue) {
|
||||||
|
$this->onQueue(Job::$onQueue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(Client $client): void
|
||||||
|
{
|
||||||
|
$discussion = Discussion::find($this->discussionId);
|
||||||
|
|
||||||
|
if (!$discussion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = $this->documentType;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$client->update([
|
||||||
|
'index' => $this->index,
|
||||||
|
'id' => "$type:{$this->discussionId}",
|
||||||
|
'routing' => (string) $this->discussionId,
|
||||||
|
'retry_on_conflict' => 3,
|
||||||
|
'body' => [
|
||||||
|
'doc' => ['view_count' => (int) $discussion->view_count],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
} catch (\Elasticsearch\Common\Exceptions\Missing404Exception $e) {
|
||||||
|
// Document not yet indexed; will be picked up on next: blomstra:search:index fill
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This file is part of blomstra/search.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2022 Blomstra Ltd.
|
|
||||||
*
|
|
||||||
* For the full copyright and license information, please view the LICENSE.md
|
|
||||||
* file that was distributed with this source code.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Blomstra\Search\Post;
|
|
||||||
|
|
||||||
use Blomstra\Search\Save\Document;
|
|
||||||
use Blomstra\Search\Search\Concerns\AppliesAccessControl;
|
|
||||||
use Blomstra\Search\Search\ElasticIndex;
|
|
||||||
use Flarum\Extension\ExtensionManager;
|
|
||||||
use Flarum\Post\CommentPost;
|
|
||||||
use Flarum\Post\Post;
|
|
||||||
use Flarum\Search\IndexerInterface;
|
|
||||||
|
|
||||||
class CommentPostIndexer implements IndexerInterface
|
|
||||||
{
|
|
||||||
use AppliesAccessControl;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
protected ElasticIndex $elastic,
|
|
||||||
protected ExtensionManager $extensions
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function index(): string
|
|
||||||
{
|
|
||||||
return 'posts';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function save(array $models): void
|
|
||||||
{
|
|
||||||
$this->elastic->save(
|
|
||||||
self::index(),
|
|
||||||
array_filter($models, fn (Post $model) => $model->type === CommentPost::$type),
|
|
||||||
$this->toDocument(...)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(array $models): void
|
|
||||||
{
|
|
||||||
$this->elastic->delete(
|
|
||||||
self::index(),
|
|
||||||
array_filter($models, fn (Post $model) => $model->type === CommentPost::$type)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function build(): void
|
|
||||||
{
|
|
||||||
$this->elastic->build(self::index(), $this->properties());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function flush(): void
|
|
||||||
{
|
|
||||||
$this->elastic->flush(self::index());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function properties(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'rawId' => ['type' => 'integer'],
|
|
||||||
'content' => ['type' => 'text', 'analyzer' => 'flarum_analyzer_partial', 'search_analyzer' => 'flarum_analyzer'],
|
|
||||||
'discussion_id' => ['type' => 'integer'],
|
|
||||||
'created_at' => ['type' => 'date'],
|
|
||||||
'updated_at' => ['type' => 'date'],
|
|
||||||
'is_private' => ['type' => 'boolean'],
|
|
||||||
'groups' => ['type' => 'integer'],
|
|
||||||
'tags' => ['type' => 'integer'],
|
|
||||||
'recipient_groups' => ['type' => 'integer'],
|
|
||||||
'recipient_users' => ['type' => 'integer'],
|
|
||||||
'comment_count' => ['type' => 'integer'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toDocument(Post $model): Document
|
|
||||||
{
|
|
||||||
$document = new Document([
|
|
||||||
'id' => $model->id,
|
|
||||||
'type' => $model->type,
|
|
||||||
'rawId' => $model->id,
|
|
||||||
'discussion_id' => $model->discussion_id,
|
|
||||||
'content' => $model->content,
|
|
||||||
'created_at' => $model->created_at?->toAtomString(),
|
|
||||||
'updated_at' => $model->edited_at?->toAtomString(),
|
|
||||||
'is_private' => $model->is_private,
|
|
||||||
'user_id' => $model->user_id,
|
|
||||||
'groups' => $this->groupsForDiscussion($model->discussion),
|
|
||||||
'comment_count' => $model->discussion->comment_count,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($this->extensions->isEnabled('flarum-tags')) {
|
|
||||||
$document['tags'] = $model->discussion->tags->pluck('id')->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->extensions->isEnabled('fof-byobu')) {
|
|
||||||
$document['recipient_users'] = $model->discussion->recipientUsers->pluck('id')->toArray();
|
|
||||||
$document['recipient_groups'] = $model->discussion->recipientGroups->pluck('id')->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $document;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -12,25 +12,40 @@
|
||||||
|
|
||||||
namespace Blomstra\Search;
|
namespace Blomstra\Search;
|
||||||
|
|
||||||
|
use Blomstra\Search\Jobs\DeletingJob;
|
||||||
|
use Blomstra\Search\Jobs\Job;
|
||||||
|
use Blomstra\Search\Jobs\UpdateSearchJob;
|
||||||
|
use Blomstra\Search\Jobs\ViewsSearchJob;
|
||||||
use Elasticsearch\Client as Elastic;
|
use Elasticsearch\Client as Elastic;
|
||||||
use Elasticsearch\ClientBuilder;
|
use Elasticsearch\ClientBuilder;
|
||||||
|
use Flarum\Api\Client;
|
||||||
use Flarum\Foundation\AbstractServiceProvider;
|
use Flarum\Foundation\AbstractServiceProvider;
|
||||||
use Flarum\Foundation\Config;
|
use Flarum\Foundation\Config;
|
||||||
|
use Flarum\Http\Middleware\ExecuteRoute;
|
||||||
use Flarum\Settings\SettingsRepositoryInterface;
|
use Flarum\Settings\SettingsRepositoryInterface;
|
||||||
use Illuminate\Contracts\Container\Container;
|
use Illuminate\Contracts\Container\Container;
|
||||||
|
use Illuminate\Contracts\Events\Dispatcher;
|
||||||
|
use Illuminate\Contracts\Queue\Queue;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Laminas\Stratigility\MiddlewarePipe;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
class Provider extends AbstractServiceProvider
|
class Provider extends AbstractServiceProvider
|
||||||
{
|
{
|
||||||
public function register(): void
|
public function register()
|
||||||
{
|
{
|
||||||
$this->container->singleton(Elastic::class, function (Container $container) {
|
$this->container->tag([
|
||||||
/** @var Config $config */
|
Seeders\DiscussionSeeder::class,
|
||||||
$config = $this->container->make(Config::class);
|
Seeders\CommentSeeder::class,
|
||||||
|
], 'blomstra.search.seeders');
|
||||||
|
|
||||||
/** @var SettingsRepositoryInterface $settings */
|
/** @var SettingsRepositoryInterface $settings */
|
||||||
$settings = $this->container->make(SettingsRepositoryInterface::class);
|
$settings = $this->container->make(SettingsRepositoryInterface::class);
|
||||||
|
|
||||||
|
/** @var Config $config */
|
||||||
|
$config = $this->container->make(Config::class);
|
||||||
|
|
||||||
|
$this->container->singleton(Elastic::class, function (Container $container) use ($settings, $config) {
|
||||||
$builder = ClientBuilder::create()
|
$builder = ClientBuilder::create()
|
||||||
->setHosts([$settings->get('blomstra-search.elastic-endpoint')]);
|
->setHosts([$settings->get('blomstra-search.elastic-endpoint')]);
|
||||||
|
|
||||||
|
|
@ -47,10 +62,63 @@ class Provider extends AbstractServiceProvider
|
||||||
|
|
||||||
return $builder->build();
|
return $builder->build();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$this->container->instance(
|
||||||
|
'blomstra.search.elastic_index',
|
||||||
|
$settings->get('blomstra-search.elastic-index', 'flarum')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->container->extend(
|
||||||
|
Client::class,
|
||||||
|
function () {
|
||||||
|
$pipe = new MiddlewarePipe();
|
||||||
|
|
||||||
|
$exclude = resolve('flarum.api_client.exclude_middleware');
|
||||||
|
|
||||||
|
$middlewareStack = array_filter(resolve('flarum.api.middleware'), function ($middlewareClass) use ($exclude) {
|
||||||
|
return !in_array($middlewareClass, $exclude);
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach ($middlewareStack as $middleware) {
|
||||||
|
$pipe->pipe(resolve($middleware));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function boot(): void
|
$pipe->pipe(new ExecuteRoute());
|
||||||
|
|
||||||
|
return new Api\Client($pipe);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->container->tag([
|
||||||
|
Searchers\DiscussionSearcher::class,
|
||||||
|
Searchers\CommentPostSearcher::class,
|
||||||
|
], 'blomstra.search.searchers');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot()
|
||||||
{
|
{
|
||||||
//
|
/** @var array|string[] $seeders */
|
||||||
|
$seeders = $this->container->tagged('blomstra.search.seeders');
|
||||||
|
|
||||||
|
/** @var Dispatcher $events */
|
||||||
|
$events = resolve(Dispatcher::class);
|
||||||
|
|
||||||
|
/** @var Queue $queue */
|
||||||
|
$queue = resolve(Queue::class);
|
||||||
|
|
||||||
|
/** @var string|Seeders\Seeder $seeder */
|
||||||
|
foreach ($seeders as $seeder) {
|
||||||
|
$seeder::savingOn($events, function ($model) use ($queue, $seeder) {
|
||||||
|
$queue->pushOn(Job::$onQueue, new UpdateSearchJob(Collection::make([$model]), $seeder));
|
||||||
|
});
|
||||||
|
|
||||||
|
$seeder::deletingOn($events, function ($model) use ($queue, $seeder) {
|
||||||
|
$queue->pushOn(Job::$onQueue, new DeletingJob(Collection::make([$model]), $seeder));
|
||||||
|
});
|
||||||
|
|
||||||
|
$seeder::viewingOn($events, function (int $discussionId) use ($queue) {
|
||||||
|
$queue->pushOn(Job::$onQueue, new ViewsSearchJob($discussionId));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,12 @@
|
||||||
namespace Blomstra\Search\Save;
|
namespace Blomstra\Search\Save;
|
||||||
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Fluent;
|
use Illuminate\Support\Fluent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property string $id
|
* @property string $id
|
||||||
|
* @property int $rawId
|
||||||
* @property string $content
|
* @property string $content
|
||||||
* @property Carbon $created_at
|
* @property Carbon $created_at
|
||||||
* @property Carbon $updated_at
|
* @property Carbon $updated_at
|
||||||
|
|
@ -29,4 +31,13 @@ use Illuminate\Support\Fluent;
|
||||||
*/
|
*/
|
||||||
class Document extends Fluent
|
class Document extends Fluent
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Exclude `id` from the body sent to Elasticsearch. The document ID is
|
||||||
|
* already stored as `_id` by the bulk API action; duplicating it in
|
||||||
|
* `_source` wastes space and is redundant.
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return Arr::except($this->attributes, ['id']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This file is part of blomstra/search.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2022 Blomstra Ltd.
|
|
||||||
*
|
|
||||||
* For the full copyright and license information, please view the LICENSE.md
|
|
||||||
* file that was distributed with this source code.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Blomstra\Search\Search\Concerns;
|
|
||||||
|
|
||||||
use Flarum\Discussion\Discussion;
|
|
||||||
use Flarum\Extension\ExtensionManager;
|
|
||||||
use Flarum\Group\Group;
|
|
||||||
use Flarum\Group\Permission;
|
|
||||||
use Flarum\Tags\Tag;
|
|
||||||
use Flarum\User\User;
|
|
||||||
|
|
||||||
trait AppliesAccessControl
|
|
||||||
{
|
|
||||||
protected static function groupsForUser(User $actor): array
|
|
||||||
{
|
|
||||||
$groups = $actor->groups->pluck('id');
|
|
||||||
|
|
||||||
$groups->add(Group::GUEST_ID);
|
|
||||||
|
|
||||||
if ($actor->is_email_confirmed) {
|
|
||||||
$groups->add(Group::MEMBER_ID);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $groups->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function groupsForDiscussion(Discussion $discussion): array
|
|
||||||
{
|
|
||||||
$permissions = collect();
|
|
||||||
|
|
||||||
$globalPermission = Permission::query()
|
|
||||||
->where('permission', 'viewForum')
|
|
||||||
->pluck('group_id');
|
|
||||||
|
|
||||||
if (resolve(ExtensionManager::class)->isEnabled('flarum-tags')) {
|
|
||||||
/** @var \Illuminate\Database\Eloquent\Collection $tags */
|
|
||||||
$tags = $discussion->tags;
|
|
||||||
|
|
||||||
$tagPermissions = Permission::query()
|
|
||||||
->whereIn(
|
|
||||||
'permission',
|
|
||||||
$tags->pluck('id')->map(function (int $id) {
|
|
||||||
return "tag$id.viewForum";
|
|
||||||
})
|
|
||||||
)->get();
|
|
||||||
|
|
||||||
$permissions = $tags->map(function (Tag $tag) use ($tagPermissions) {
|
|
||||||
$permissions = $tagPermissions->where('permission', "tag$tag->id.viewForum");
|
|
||||||
|
|
||||||
if ($tag->is_restricted) {
|
|
||||||
$permissions = $permissions->add(['group_id' => Group::ADMINISTRATOR_ID]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $permissions->pluck('group_id');
|
|
||||||
})->flatten();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$discussion->is_private && $permissions->isEmpty()) {
|
|
||||||
$permissions = $globalPermission;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $permissions->toArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This file is part of blomstra/search.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2022 Blomstra Ltd.
|
|
||||||
*
|
|
||||||
* For the full copyright and license information, please view the LICENSE.md
|
|
||||||
* file that was distributed with this source code.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Blomstra\Search\Search;
|
|
||||||
|
|
||||||
use Blomstra\Search\Exceptions\IndexingException;
|
|
||||||
use Closure;
|
|
||||||
use Elasticsearch\Client;
|
|
||||||
use Flarum\Settings\SettingsRepositoryInterface;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
|
|
||||||
class ElasticIndex
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
protected Client $client,
|
|
||||||
protected SettingsRepositoryInterface $settings
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function save(string $index, array $models, Closure $toDocument): void
|
|
||||||
{
|
|
||||||
$models = collect($models);
|
|
||||||
|
|
||||||
// Preparing body for storing.
|
|
||||||
$body = $models->map(function (Model $model) use ($index, $toDocument) {
|
|
||||||
$document = $toDocument($model);
|
|
||||||
|
|
||||||
return [
|
|
||||||
[
|
|
||||||
'index' => [
|
|
||||||
'_index' => $index,
|
|
||||||
'_id' => $document->id,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
$document->toArray(),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->flatten(1);
|
|
||||||
|
|
||||||
$this->handleResponse(
|
|
||||||
$this->client->bulk([
|
|
||||||
'index' => $index,
|
|
||||||
'body' => $body->toArray(),
|
|
||||||
'refresh' => true,
|
|
||||||
])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(string $index, array $models): void
|
|
||||||
{
|
|
||||||
$models = collect($models);
|
|
||||||
|
|
||||||
$body = $models->map(function (Model $model) use ($index) {
|
|
||||||
return [
|
|
||||||
[
|
|
||||||
'delete' => [
|
|
||||||
'_index' => $index,
|
|
||||||
'_id' => $model->id,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
})->flatten(1);
|
|
||||||
|
|
||||||
$this->handleResponse(
|
|
||||||
$this->client->bulk([
|
|
||||||
'index' => $index,
|
|
||||||
'body' => $body->toArray(),
|
|
||||||
'refresh' => true,
|
|
||||||
])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function build(string $index, array $properties): void
|
|
||||||
{
|
|
||||||
$this->client->indices()->create([
|
|
||||||
'index' => $index,
|
|
||||||
'body' => [
|
|
||||||
'settings' => [
|
|
||||||
'index.max_ngram_diff' => 10,
|
|
||||||
'analysis' => [
|
|
||||||
'analyzer' => [
|
|
||||||
'flarum_analyzer' => [
|
|
||||||
'type' => $this->settings->get('blomstra-search.analyzer-language') ?: 'english',
|
|
||||||
],
|
|
||||||
'flarum_analyzer_partial' => [
|
|
||||||
'type' => 'custom',
|
|
||||||
'tokenizer' => 'standard',
|
|
||||||
'filter' => [
|
|
||||||
'lowercase',
|
|
||||||
'partial_search_filter',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'filter' => [
|
|
||||||
'partial_search_filter' => [
|
|
||||||
'type' => 'ngram',
|
|
||||||
'min_gram' => 1,
|
|
||||||
'max_gram' => 10,
|
|
||||||
'token_chars' => ['letter', 'digit', 'symbol'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'mappings' => [
|
|
||||||
'properties' => $properties,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function flush(string $index): void
|
|
||||||
{
|
|
||||||
$this->client->indices()->delete([
|
|
||||||
'index' => $index,
|
|
||||||
'ignore_unavailable' => true,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handleResponse(array $response): void
|
|
||||||
{
|
|
||||||
if (Arr::get($response, 'errors') !== true) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$items = Arr::get($response, 'items');
|
|
||||||
|
|
||||||
$error = Arr::get(Arr::first($items), 'index.error.reason');
|
|
||||||
|
|
||||||
throw new IndexingException(
|
|
||||||
"Failed to seed: $error",
|
|
||||||
$items
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This file is part of blomstra/search.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2022 Blomstra Ltd.
|
|
||||||
*
|
|
||||||
* For the full copyright and license information, please view the LICENSE.md
|
|
||||||
* file that was distributed with this source code.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Blomstra\Search\Search;
|
|
||||||
|
|
||||||
use Flarum\Search\AbstractDriver;
|
|
||||||
|
|
||||||
class ElasticSearchDriver extends AbstractDriver
|
|
||||||
{
|
|
||||||
public static function name(): string
|
|
||||||
{
|
|
||||||
return 'blomstra-elasticsearch';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This file is part of blomstra/search.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2022 Blomstra Ltd.
|
|
||||||
*
|
|
||||||
* For the full copyright and license information, please view the LICENSE.md
|
|
||||||
* file that was distributed with this source code.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Blomstra\Search\Search;
|
|
||||||
|
|
||||||
use Blomstra\Search\Elasticsearch\Builder;
|
|
||||||
use Closure;
|
|
||||||
use Flarum\Search\SearchState;
|
|
||||||
|
|
||||||
class ElasticSearchState extends SearchState
|
|
||||||
{
|
|
||||||
protected Builder $builder;
|
|
||||||
protected ?Closure $retrieveDatabaseRecordsUsing = null;
|
|
||||||
|
|
||||||
public function setBuilder(Builder $builder): void
|
|
||||||
{
|
|
||||||
$this->builder = $builder;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getBuilder(): Builder
|
|
||||||
{
|
|
||||||
return $this->builder;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function retrieveDatabaseRecordsUsing(Closure $retrieveDatabaseRecordsUsing): void
|
|
||||||
{
|
|
||||||
$this->retrieveDatabaseRecordsUsing = $retrieveDatabaseRecordsUsing;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRetrieveDatabaseRecordsUsing(): ?Closure
|
|
||||||
{
|
|
||||||
return $this->retrieveDatabaseRecordsUsing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This file is part of blomstra/search.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2022 Blomstra Ltd.
|
|
||||||
*
|
|
||||||
* For the full copyright and license information, please view the LICENSE.md
|
|
||||||
* file that was distributed with this source code.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Blomstra\Search\Search;
|
|
||||||
|
|
||||||
use Blomstra\Search\Elasticsearch\Builder;
|
|
||||||
use Elasticsearch\Client;
|
|
||||||
use Flarum\Search\Filter\FilterManager;
|
|
||||||
use Flarum\Search\SearchCriteria;
|
|
||||||
use Flarum\Search\SearcherInterface;
|
|
||||||
use Flarum\Search\SearchResults;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Spatie\ElasticsearchQueryBuilder\Sorts\Sort;
|
|
||||||
|
|
||||||
abstract class Searcher implements SearcherInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
protected FilterManager $filters,
|
|
||||||
/** @var array<callable> */
|
|
||||||
protected array $mutators,
|
|
||||||
protected Client $elastic
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract public function index(): string;
|
|
||||||
|
|
||||||
public function search(SearchCriteria $criteria): SearchResults
|
|
||||||
{
|
|
||||||
$builder = (new Builder($this->elastic))
|
|
||||||
->index($this->index())
|
|
||||||
->size($criteria->limit + 1)
|
|
||||||
->from($criteria->offset);
|
|
||||||
|
|
||||||
$state = new ElasticSearchState($criteria->actor, $criteria->isFulltext());
|
|
||||||
$state->setBuilder($builder);
|
|
||||||
|
|
||||||
// Default logic for retrieving database records.
|
|
||||||
// This is normally overriden by the fulltext filter.
|
|
||||||
$state->retrieveDatabaseRecordsUsing(function (array $response, SearchCriteria $criteria): Collection {
|
|
||||||
$results = (new Collection(Arr::get($response, 'hits.hits')))->map(function ($hit) {
|
|
||||||
$type = $hit['_source']['type'];
|
|
||||||
$id = Str::after($hit['_source']['id'], "$type:");
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => $id,
|
|
||||||
'score' => Arr::get($hit, '_score'),
|
|
||||||
'weight' => Arr::get($hit, 'sort.0'),
|
|
||||||
];
|
|
||||||
})->sortByDesc('weight');
|
|
||||||
|
|
||||||
$ids = $results
|
|
||||||
->take($criteria->limit)
|
|
||||||
->pluck('id')
|
|
||||||
->all();
|
|
||||||
|
|
||||||
return $this->getQuery($criteria->actor)
|
|
||||||
->whereIn('id', $ids)
|
|
||||||
->orderByRaw('FIELD(id, '.implode(',', $ids).')')
|
|
||||||
->get();
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->filters->apply($state, $criteria->filters);
|
|
||||||
|
|
||||||
$this->applySort($state, $criteria);
|
|
||||||
|
|
||||||
foreach ($this->mutators as $mutator) {
|
|
||||||
$mutator($state, $criteria);
|
|
||||||
}
|
|
||||||
|
|
||||||
// echo json_encode($builder->getParams(), JSON_PRETTY_PRINT);
|
|
||||||
// exit;
|
|
||||||
|
|
||||||
$response = $builder->search();
|
|
||||||
|
|
||||||
// header('Content-Type: application/json');
|
|
||||||
// echo json_encode($response, JSON_PRETTY_PRINT);
|
|
||||||
// exit;
|
|
||||||
|
|
||||||
$areMoreResults = count($response['hits']['hits']) > $criteria->limit;
|
|
||||||
|
|
||||||
$callback = $state->getRetrieveDatabaseRecordsUsing();
|
|
||||||
|
|
||||||
if (!$callback) {
|
|
||||||
throw new \RuntimeException('No callback set to retrieve database records');
|
|
||||||
}
|
|
||||||
|
|
||||||
$records = $callback($response, $criteria);
|
|
||||||
|
|
||||||
return new SearchResults($records, $areMoreResults);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function applySort(ElasticSearchState $state, SearchCriteria $criteria): void
|
|
||||||
{
|
|
||||||
$sort = $criteria->sort;
|
|
||||||
|
|
||||||
if ($criteria->sortIsDefault && !empty($state->getDefaultSort())) {
|
|
||||||
$sort = $state->getDefaultSort();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_callable($sort)) {
|
|
||||||
$sort($state->getBuilder());
|
|
||||||
} else {
|
|
||||||
foreach (($criteria->sort ?? []) as $field => $direction) {
|
|
||||||
$state->getBuilder()->addSort(new Sort(Str::snake($field), $direction));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of blomstra/search.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2022 Blomstra Ltd.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE.md
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Blomstra\Search\Searchers;
|
||||||
|
|
||||||
|
use Blomstra\Search\Seeders\CommentSeeder;
|
||||||
|
|
||||||
|
class CommentPostSearcher extends Searcher
|
||||||
|
{
|
||||||
|
protected string|null $seeder = CommentSeeder::class;
|
||||||
|
|
||||||
|
public function enabled(): bool
|
||||||
|
{
|
||||||
|
$enabled = $this->setting('blomstra-search.search-post-bodies', true);
|
||||||
|
|
||||||
|
return boolval($enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of blomstra/search.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2022 Blomstra Ltd.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE.md
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Blomstra\Search\Searchers;
|
||||||
|
|
||||||
|
use Blomstra\Search\Seeders\DiscussionSeeder;
|
||||||
|
|
||||||
|
class DiscussionSearcher extends Searcher
|
||||||
|
{
|
||||||
|
protected string|null $seeder = DiscussionSeeder::class;
|
||||||
|
|
||||||
|
public function enabled(): bool
|
||||||
|
{
|
||||||
|
$enabled = $this->setting('blomstra-search.search-discussion-subjects', true);
|
||||||
|
|
||||||
|
return boolval($enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boost(): float
|
||||||
|
{
|
||||||
|
return 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of blomstra/search.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2022 Blomstra Ltd.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE.md
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Blomstra\Search\Searchers;
|
||||||
|
|
||||||
|
use Blomstra\Search\Seeders\Seeder;
|
||||||
|
use Flarum\Settings\SettingsRepositoryInterface;
|
||||||
|
|
||||||
|
abstract class Searcher
|
||||||
|
{
|
||||||
|
protected string|null $seeder = null;
|
||||||
|
|
||||||
|
public function type(): string
|
||||||
|
{
|
||||||
|
/** @var Seeder $seeder */
|
||||||
|
$seeder = $this->seeder;
|
||||||
|
|
||||||
|
if (empty($seeder)) {
|
||||||
|
throw new \InvalidArgumentException('Implement type or add $seeder');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new $seeder())->type();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enabled(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boost(): float
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setting(string $key, $default = null)
|
||||||
|
{
|
||||||
|
return resolve(SettingsRepositoryInterface::class)->get($key, $default);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of blomstra/search.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2022 Blomstra Ltd.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE.md
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Blomstra\Search\Seeders;
|
||||||
|
|
||||||
|
use Blomstra\Search\Save\Document;
|
||||||
|
use Flarum\Api\Serializer\DiscussionSerializer;
|
||||||
|
use Flarum\Api\Serializer\PostSerializer;
|
||||||
|
use Flarum\Discussion\Discussion;
|
||||||
|
use Flarum\Post\CommentPost;
|
||||||
|
use Flarum\Post\Event as Core;
|
||||||
|
use Illuminate\Contracts\Events\Dispatcher;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class CommentSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function type(): string
|
||||||
|
{
|
||||||
|
return resolve(PostSerializer::class)->getType(new CommentPost());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function joinRelation(): string
|
||||||
|
{
|
||||||
|
return 'post';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function routing(Model $model): string
|
||||||
|
{
|
||||||
|
return (string) $model->discussion_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function query(): Builder
|
||||||
|
{
|
||||||
|
return CommentPost::query()
|
||||||
|
->where('type', CommentPost::$type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function savingOn(Dispatcher $events, callable $callable)
|
||||||
|
{
|
||||||
|
$events->listen([
|
||||||
|
Core\Posted::class,
|
||||||
|
Core\Revised::class,
|
||||||
|
Core\Hidden::class,
|
||||||
|
Core\Restored::class,
|
||||||
|
], function ($event) use ($callable) {
|
||||||
|
$callable($event->post);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function deletingOn(Dispatcher $events, callable $callable)
|
||||||
|
{
|
||||||
|
$events->listen([
|
||||||
|
Core\Deleted::class
|
||||||
|
], function ($event) use ($callable) {
|
||||||
|
$callable($event->post);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cached discussion type string (e.g. "discussions") — resolved once per job. */
|
||||||
|
private ?string $discussionType = null;
|
||||||
|
|
||||||
|
private function discussionType(): string
|
||||||
|
{
|
||||||
|
return $this->discussionType ??= resolve(DiscussionSerializer::class)->getType(new Discussion());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post documents only need content for has_child matching and the join
|
||||||
|
* field to establish the parent-child relationship. All discussion-level
|
||||||
|
* fields (groups, tags, comment_count, etc.) live on the discussion
|
||||||
|
* document and are irrelevant here.
|
||||||
|
*
|
||||||
|
* @param CommentPost $model
|
||||||
|
*/
|
||||||
|
public function toDocument(Model $model): Document
|
||||||
|
{
|
||||||
|
return new Document([
|
||||||
|
'join_field' => ['name' => $this->joinRelation(), 'parent' => "{$this->discussionType()}:{$model->discussion_id}"],
|
||||||
|
'discussion_id' => $model->discussion_id,
|
||||||
|
'id' => $this->type().':'.$model->id,
|
||||||
|
'rawId' => $model->id,
|
||||||
|
'content' => $model->content,
|
||||||
|
'is_hidden' => $model->hidden_at !== null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,209 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of blomstra/search.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2022 Blomstra Ltd.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE.md
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Blomstra\Search\Seeders;
|
||||||
|
|
||||||
|
use Blomstra\Search\Save\Document;
|
||||||
|
use Flarum\Api\Serializer\DiscussionSerializer;
|
||||||
|
use Flarum\Discussion\Discussion;
|
||||||
|
use Flarum\Discussion\Event as Core;
|
||||||
|
use Flarum\Extension\ExtensionManager;
|
||||||
|
use Flarum\Group\Group;
|
||||||
|
use Flarum\Group\Permission;
|
||||||
|
use Flarum\Tags\Tag;
|
||||||
|
use FoF\Byobu\Events as Byobu;
|
||||||
|
use FoF\DiscussionViews\Events\DiscussionWasViewed;
|
||||||
|
use Illuminate\Contracts\Events\Dispatcher;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class DiscussionSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function type(): string
|
||||||
|
{
|
||||||
|
return resolve(DiscussionSerializer::class)->getType(new Discussion());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function joinRelation(): string
|
||||||
|
{
|
||||||
|
return 'discussion';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function routing(Model $model): string
|
||||||
|
{
|
||||||
|
return (string) $model->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function query(): Builder
|
||||||
|
{
|
||||||
|
return Discussion::query()
|
||||||
|
->whereNull('hidden_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function relationships(): array
|
||||||
|
{
|
||||||
|
$includes = [];
|
||||||
|
|
||||||
|
if ($this->extensionEnabled('flarum-tags')) {
|
||||||
|
$includes[] = 'tags';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->extensionEnabled('fof-byobu')) {
|
||||||
|
$includes[] = 'recipientUsers';
|
||||||
|
$includes[] = 'recipientGroups';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $includes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function savingOn(Dispatcher $events, callable $callable)
|
||||||
|
{
|
||||||
|
$events->listen([
|
||||||
|
// flarum/core events
|
||||||
|
Core\Started::class, Core\Restored::class,
|
||||||
|
// fof/byobu discussion recipients events.
|
||||||
|
Byobu\DiscussionMadePublic::class, Byobu\RemovedSelf::class, Byobu\RecipientsChanged::class,
|
||||||
|
], function ($event) use ($callable) {
|
||||||
|
return $callable($event->discussion);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function viewingOn(Dispatcher $events, callable $callable): void
|
||||||
|
{
|
||||||
|
if (!resolve(ExtensionManager::class)->isEnabled('fof-discussion-views')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$events->listen(DiscussionWasViewed::class, function (DiscussionWasViewed $event) use ($callable) {
|
||||||
|
$viewCount = $event->discussion->view_count;
|
||||||
|
|
||||||
|
$shouldSync = match (true) {
|
||||||
|
$viewCount < 15 => true,
|
||||||
|
$viewCount < 100 => rand(1, 3) === 1,
|
||||||
|
default => rand(1, 19) === 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($shouldSync) {
|
||||||
|
$callable($event->discussion->id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function deletingOn(Dispatcher $events, callable $callable)
|
||||||
|
{
|
||||||
|
$events->listen([
|
||||||
|
// flarum/core events.
|
||||||
|
Core\Deleted::class, Core\Hidden::class
|
||||||
|
], function ($event) use ($callable) {
|
||||||
|
return $callable($event->discussion);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Discussion $model
|
||||||
|
*
|
||||||
|
* @return Document
|
||||||
|
*/
|
||||||
|
public function toDocument(Model $model): Document
|
||||||
|
{
|
||||||
|
$document = new Document([
|
||||||
|
'join_field' => $this->joinRelation(),
|
||||||
|
'id' => $this->type().':'.$model->id,
|
||||||
|
'rawId' => $model->id,
|
||||||
|
'content' => $model->title,
|
||||||
|
'created_at' => $model->created_at?->toAtomString(),
|
||||||
|
'updated_at' => $model->last_posted_at?->toAtomString(),
|
||||||
|
'is_private' => $model->is_private,
|
||||||
|
'user_id' => $model->user_id,
|
||||||
|
'groups' => $this->groupsForDiscussion($model),
|
||||||
|
'comment_count' => $model->comment_count,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($this->extensionEnabled('flarum-tags')) {
|
||||||
|
$document['tags'] = $model->tags->pluck('id')->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->extensionEnabled('fof-byobu')) {
|
||||||
|
$document['recipient_users'] = $model->recipientUsers
|
||||||
|
->whereNull('removed_at')
|
||||||
|
->pluck('id')
|
||||||
|
->toArray();
|
||||||
|
$document['recipient_groups'] = $model->recipientGroups
|
||||||
|
->whereNull('removed_at')
|
||||||
|
->pluck('id')
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->extensionEnabled('flarum-sticky')) {
|
||||||
|
$document['is_sticky'] = (bool) $model->is_sticky;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->extensionEnabled('fof-discussion-views')) {
|
||||||
|
$document['view_count'] = (int) ($model->view_count ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $document;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All viewForum permissions keyed by permission string, loaded once per seeder instance.
|
||||||
|
* Avoids N×2 Permission queries inside the per-document map loop.
|
||||||
|
*/
|
||||||
|
private ?Collection $cachedPermissions = null;
|
||||||
|
private ?Collection $cachedGlobalPermission = null;
|
||||||
|
|
||||||
|
private function allPermissions(): Collection
|
||||||
|
{
|
||||||
|
if ($this->cachedPermissions === null) {
|
||||||
|
$this->cachedPermissions = Permission::query()
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->where('permission', 'viewForum')
|
||||||
|
->orWhere('permission', 'like', 'tag%.viewForum');
|
||||||
|
})
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$this->cachedGlobalPermission = $this->cachedPermissions
|
||||||
|
->where('permission', 'viewForum')
|
||||||
|
->pluck('group_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->cachedPermissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function groupsForDiscussion(Discussion $discussion): array
|
||||||
|
{
|
||||||
|
$allPerms = $this->allPermissions();
|
||||||
|
$permissions = collect();
|
||||||
|
|
||||||
|
if ($this->extensionEnabled('flarum-tags')) {
|
||||||
|
/** @var Collection $tags */
|
||||||
|
$tags = $discussion->tags;
|
||||||
|
|
||||||
|
$permissions = $tags->map(function (Tag $tag) use ($allPerms) {
|
||||||
|
$tagPerms = $allPerms->where('permission', "tag$tag->id.viewForum");
|
||||||
|
|
||||||
|
if ($tag->is_restricted) {
|
||||||
|
$tagPerms = $tagPerms->add(['group_id' => Group::ADMINISTRATOR_ID]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tagPerms->pluck('group_id');
|
||||||
|
})->flatten();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$discussion->is_private && $permissions->isEmpty()) {
|
||||||
|
$permissions = $this->cachedGlobalPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $permissions->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of blomstra/search.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2022 Blomstra Ltd.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE.md
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Blomstra\Search\Seeders;
|
||||||
|
|
||||||
|
use Blomstra\Search\Save\Document;
|
||||||
|
use Flarum\Extension\ExtensionManager;
|
||||||
|
use Illuminate\Contracts\Events\Dispatcher;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
abstract class Seeder
|
||||||
|
{
|
||||||
|
abstract public function type(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The join relation name for this document type ('discussion' or 'post').
|
||||||
|
* Used in the join_field mapping and for filtering in seed-missing checks.
|
||||||
|
*/
|
||||||
|
abstract public function joinRelation(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ES routing key for this model. Parent and child documents for the same
|
||||||
|
* discussion must share a routing key so ES places them on the same shard.
|
||||||
|
*/
|
||||||
|
abstract public function routing(Model $model): string;
|
||||||
|
|
||||||
|
abstract public function query(): Builder;
|
||||||
|
|
||||||
|
abstract public static function savingOn(Dispatcher $events, callable $callable);
|
||||||
|
|
||||||
|
abstract public static function deletingOn(Dispatcher $events, callable $callable);
|
||||||
|
|
||||||
|
/** No-op default; override in seeders that need to react to view events. */
|
||||||
|
public static function viewingOn(Dispatcher $events, callable $callable): void {}
|
||||||
|
|
||||||
|
abstract public function toDocument(Model $model): Document;
|
||||||
|
|
||||||
|
/** Relationships to eager-load on the collection before calling toDocument(). */
|
||||||
|
public function relationships(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function extensionEnabled(string $extension): bool
|
||||||
|
{
|
||||||
|
/** @var ExtensionManager $manager */
|
||||||
|
$manager = resolve(ExtensionManager::class);
|
||||||
|
|
||||||
|
return $manager->isEnabled($extension);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue